Skip to content

Commit bb7da8c

Browse files
authored
Merge pull request #102 from tamirelazar/feat/showcase-polish
Rework WASM showcase as a terminal instrument
2 parents 5e4a94f + 33af489 commit bb7da8c

7 files changed

Lines changed: 270 additions & 99 deletions

File tree

tslime-wasm/src/lib.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ pub struct TslimeWasm {
3131
frame: Option<DownsampledFrame>,
3232
adaptive: AdaptiveBrightness,
3333
palette: Palette,
34+
// Render look knobs (tuned from JS without recreating the sim). `brightness`
35+
// scales the adaptive white point down: >1 = brighter veins + more lit cells.
36+
brightness: f32,
37+
charset: Charset,
3438
}
3539

3640
#[wasm_bindgen]
@@ -72,7 +76,12 @@ impl TslimeWasm {
7276
frame: None,
7377
// window=100, enabled=true — the TUI's default auto-normalize.
7478
adaptive: AdaptiveBrightness::new(100, true),
75-
palette: Palette::Warm,
79+
palette: Palette::Slime,
80+
// Slime (green) palette + ASCII density glyphs. Green reads with far
81+
// more contrast on the dark field than warm brown, so a modest
82+
// brightness lift keeps the airy ASCII texture while staying legible.
83+
brightness: 2.2,
84+
charset: Charset::Ascii,
7685
})
7786
}
7887

@@ -117,15 +126,16 @@ impl TslimeWasm {
117126
// Update the adaptive white point from this frame, then use it as the
118127
// brightness divisor — identical recipe to the TUI print path.
119128
self.adaptive.update(frame.cells());
120-
let gain = self.adaptive.get_max_brightness();
129+
// Scale the white point down by `brightness` (>1 brightens the veins and
130+
// lifts more cells over the visibility threshold).
131+
let gain = self.adaptive.get_max_brightness() / self.brightness.max(0.05);
121132

122-
// Bolt info-route launch look: warm palette, ASCII charset.
123133
render_ansi_cells(
124134
frame.cells(),
125135
cols,
126136
rows,
127137
self.palette.clone(),
128-
Charset::Ascii,
138+
self.charset.clone(),
129139
gain,
130140
)
131141
}
@@ -242,6 +252,22 @@ impl TslimeWasm {
242252
);
243253
}
244254

255+
/// Brightness multiplier for the ANSI render: 1.0 = TUI auto-normalize,
256+
/// >1 brightens (lower white-point divisor). Render-only; no sim restart.
257+
pub fn set_brightness(&mut self, brightness: f32) {
258+
self.brightness = brightness.max(0.05);
259+
}
260+
261+
/// Toggle the render charset: half-block (filled cells, denser) vs ASCII
262+
/// density glyphs (airy). Render-only; no sim restart.
263+
pub fn set_charset_halfblock(&mut self, halfblock: bool) {
264+
self.charset = if halfblock {
265+
Charset::HalfBlock
266+
} else {
267+
Charset::Ascii
268+
};
269+
}
270+
245271
pub fn preset_count(&self) -> u32 {
246272
PRESETS.len() as u32
247273
}

web/index.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>tslime — a terminal that grows</title>
6+
<title>tslime — a terminal screensaver that grows on you</title>
77
<meta name="description" content="A lightweight terminal screensaver simulating Physarum slime-mold growth. Watch it run live in your browser." />
88
<link rel="canonical" href="https://tamirelazar.github.io/tslime/" />
99
<meta property="og:type" content="website" />
10-
<meta property="og:title" content="tslime — a terminal that grows" />
10+
<meta property="og:title" content="tslime — a terminal screensaver that grows on you" />
1111
<meta property="og:description" content="A lightweight terminal screensaver simulating Physarum slime-mold growth." />
1212
<meta property="og:image" content="https://tamirelazar.github.io/tslime/poster.png" />
1313
<meta property="og:url" content="https://tamirelazar.github.io/tslime/" />
@@ -20,17 +20,17 @@
2020
}
2121
:root { color-scheme: dark; }
2222
* { box-sizing: border-box; }
23-
html, body { margin: 0; background: #0b0d0e; color: #c8c0b0;
23+
html, body { margin: 0; background: #17120d; color: #c8c0b0;
2424
font-family: ui-sans-serif, system-ui, sans-serif; }
2525
</style>
2626
</head>
2727
<body>
2828
<!-- Static first paint: meaningful to crawlers + JS-off visitors. chrome.ts
2929
enhances this; it does not supply the first paint. -->
3030
<main id="app">
31-
<h1>A terminal that grows.</h1>
31+
<h1>A terminal screensaver that grows on you.</h1>
3232
<p>A lightweight terminal screensaver that simulates the growth patterns of
33-
Physarum polycephalum — slime mold. Organic, algorithmic, alive.</p>
33+
Physarum polycephalum — slime mold.</p>
3434
<noscript><img src="/tslime/poster.png" alt="tslime slime-mold simulation still" width="900" /></noscript>
3535
</main>
3636
<script type="module" src="/src/main.ts"></script>

web/src/chrome.ts

Lines changed: 112 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,124 @@
11
import { COPY } from './data';
22

3+
// "Instrument" chrome: the live sim is a recessed screen with bordered tags cut
4+
// into its bezel (live / status / readout) and registration ticks at the
5+
// corners. Square, inset, warm-dark field, green the single cold accent.
6+
// Controls = a command line with clickable tokens.
37
const CSS = `
4-
#app{--bg:#0b0d0e;--ink:#c8c0b0;--dim:#8b9398;--warm:#bfa46a;--line:rgba(143,143,85,.30);
5-
min-height:100vh;display:flex;flex-direction:column;align-items:center;gap:1.4rem;
6-
padding:0 1rem 4rem}
7-
#app header{display:flex;justify-content:space-between;align-items:center;width:100%;
8-
max-width:980px;padding:1.3rem .25rem;border-bottom:1px solid var(--line)}
9-
#app .wm{font:600 1.1rem "FiraCodeNFMono",monospace;letter-spacing:.02em}
10-
#app nav a{color:var(--dim);text-decoration:none;margin-left:1.3rem;font-size:.9rem}
11-
#app nav a:hover{color:var(--warm)}
12-
#app h1{margin:1.4rem 0 0;font-size:2.1rem;letter-spacing:-.02em;text-align:center}
13-
#app p{margin:0;max-width:38rem;text-align:center;color:var(--dim);line-height:1.55}
14-
#app .frame{position:relative;width:min(900px,96vw);aspect-ratio:16/10;background:#1c1916;
15-
border:1px solid var(--line);border-radius:10px;padding:20px;overflow:hidden;
16-
box-shadow:0 0 0 1px rgba(143,143,85,.10),0 0 28px rgba(143,143,85,.16),0 18px 50px rgba(0,0,0,.55)}
17-
#app .frame[data-loading]::after{content:"warming…";position:absolute;inset:0;display:grid;
18-
place-items:center;color:var(--dim);font:.9rem "FiraCodeNFMono",monospace}
8+
#app{--bg:#17120d;--screen:#0b0805;--ink:#c8c0b0;--dim:#8b8378;--acc:#6dbf66;
9+
--line:rgba(150,140,120,.20);
10+
font-family:"FiraCodeNFMono",monospace;min-height:100vh;display:flex;
11+
flex-direction:column;gap:1.6rem;width:min(940px,94vw);margin:0 auto;
12+
padding:0 0 3rem}
13+
#app a{color:var(--dim);text-decoration:none}
14+
#app a:hover{color:var(--acc)}
15+
16+
#app .top{display:flex;justify-content:space-between;align-items:baseline;
17+
padding:1.4rem 0 .9rem;border-bottom:1px solid var(--line);gap:1rem;flex-wrap:wrap}
18+
#app .wm{font-weight:600;font-size:1.05rem;letter-spacing:.02em;color:var(--ink)}
19+
#app .wm b{color:var(--acc);font-weight:600}
20+
#app .meta{font-size:.78rem;color:var(--dim);letter-spacing:.04em}
21+
#app .meta a{margin-left:.9rem}
22+
23+
#app .lede{display:flex;flex-direction:column;gap:.7rem}
24+
#app h1{margin:0;font-size:clamp(1.9rem,5vw,2.9rem);font-weight:600;
25+
letter-spacing:-.01em;line-height:1.05;color:var(--ink)}
26+
#app h1 i{color:var(--acc);font-style:normal;margin-right:.4rem}
27+
#app .blurb{margin:0;max-width:58ch;color:var(--dim);font-size:.9rem;line-height:1.6}
28+
29+
/* Recessed screen: square, inner shadow well, bordered bezel tags. */
30+
#app .screen{position:relative;width:100%;aspect-ratio:16/9;background:var(--screen);
31+
border:1px solid var(--line);padding:18px;
32+
box-shadow:inset 0 1px 0 rgba(255,244,228,.05),inset 0 18px 44px -14px rgba(0,0,0,.9),
33+
inset 0 -10px 30px -16px rgba(0,0,0,.7)}
34+
#app .viewport{position:absolute;inset:18px;overflow:hidden}
1935
#app .host{width:100%;height:100%}
20-
#app .ctl{display:flex;flex-wrap:wrap;gap:.4rem;justify-content:center;align-items:center}
21-
#app .ctl .lbl{font:600 .72rem "FiraCodeNFMono",monospace;color:var(--dim);
22-
text-transform:uppercase;letter-spacing:.1em;margin-right:.3rem}
23-
#app .pill{all:unset;cursor:pointer;font-size:.82rem;padding:.32rem .8rem;border-radius:999px;
24-
border:1px solid var(--line);color:var(--dim)}
25-
#app .pill:hover{color:var(--ink);border-color:var(--warm)}
26-
#app .pill[data-active]{background:var(--warm);color:#1c1916;border-color:var(--warm)}
27-
#app .frame[data-error]::after{content:"Couldn't start the simulation. Here's a still.";
28-
position:absolute;inset:0;display:grid;place-items:end center;padding:1rem;
29-
color:var(--dim);font:.85rem "FiraCodeNFMono",monospace;background:
30-
center/cover no-repeat url('/tslime/poster.png')}
31-
#app .toggle{all:unset;cursor:pointer;font:.78rem "FiraCodeNFMono",monospace;
32-
color:var(--dim);border:1px solid var(--line);border-radius:999px;padding:.28rem .8rem}
33-
#app .toggle:hover{color:var(--warm);border-color:var(--warm)}`;
36+
/* registration ticks at the four corners */
37+
#app .tick{position:absolute;width:9px;height:9px;border:1px solid var(--acc);opacity:.55}
38+
#app .tick.tl{top:6px;left:6px;border-right:0;border-bottom:0}
39+
#app .tick.tr{top:6px;right:6px;border-left:0;border-bottom:0}
40+
#app .tick.bl{bottom:6px;left:6px;border-right:0;border-top:0}
41+
#app .tick.br{bottom:6px;right:6px;border-left:0;border-top:0}
42+
/* bordered HUD tags floating just inside the screen, over the sim */
43+
#app .tag{position:absolute;font-size:.64rem;letter-spacing:.12em;text-transform:uppercase;
44+
background:#241c14;border:1px solid rgba(150,140,120,.35);padding:.18rem .6rem;color:var(--ink);
45+
box-shadow:inset 0 1px 0 rgba(255,244,228,.08),0 3px 11px rgba(0,0,0,.75)}
46+
#app .slabel{top:1.5rem;left:1.5rem}
47+
#app .status{top:1.5rem;right:1.5rem;color:var(--acc);cursor:pointer}
48+
#app .status[data-paused]{color:var(--dim)}
49+
#app .readout{bottom:1.5rem;right:1.5rem;text-transform:none;letter-spacing:.04em}
50+
#app .readout .leg{color:var(--dim);opacity:.7}
51+
#app .screen[data-loading] .viewport::after{content:"warming…";position:absolute;
52+
inset:0;display:grid;place-items:center;color:var(--dim);font-size:.85rem}
53+
#app .screen[data-error] .viewport{background:center/cover no-repeat url('/tslime/poster.png')}
54+
#app .screen[data-error] .viewport::after{content:"can't start the sim — here's a still";
55+
position:absolute;left:0;right:0;bottom:0;padding:.7rem;text-align:center;
56+
color:var(--ink);font-size:.8rem;background:linear-gradient(transparent,rgba(0,0,0,.7))}
57+
58+
/* command line: clickable tokens cycle presets/palettes */
59+
#app .cmd{font-size:clamp(.85rem,2.4vw,1.05rem);color:var(--dim);
60+
display:flex;flex-wrap:wrap;align-items:center;gap:.5ch}
61+
#app .cmd .pr{color:var(--acc)}
62+
#app .cmd .name{color:var(--ink)}
63+
#app .cmd .tok{color:var(--acc);cursor:pointer;border-bottom:1px dotted var(--acc);
64+
padding-bottom:1px;transition:color .12s}
65+
#app .cmd .tok:hover{color:#a8e29c;border-bottom-style:solid}
66+
#app .cmd .cur{color:var(--acc);animation:blink 1.1s steps(1) infinite;margin-left:.2ch}
67+
@keyframes blink{50%{opacity:0}}
68+
#app .hint{font-size:.74rem;color:var(--dim);opacity:.7;line-height:1.5;min-height:1.1em;
69+
transition:opacity 1.1s ease}
70+
#app .hint[data-faded]{opacity:0}`;
71+
72+
export interface ChromeHandles {
73+
host: HTMLElement;
74+
screen: HTMLElement;
75+
tokPreset: HTMLElement;
76+
tokPalette: HTMLElement;
77+
status: HTMLElement;
78+
readout: HTMLElement;
79+
hint: HTMLElement;
80+
cursor: HTMLElement;
81+
chevron: HTMLElement;
82+
}
3483

35-
export function buildChrome(app: HTMLElement) {
84+
export function buildChrome(app: HTMLElement): ChromeHandles {
3685
const style = document.createElement('style');
3786
style.textContent = CSS;
3887
document.head.appendChild(style);
39-
// Replace the static first-paint contents with the enhanced chrome.
4088
app.innerHTML = `
41-
<header>
42-
<div class="wm">tslime</div>
43-
<nav><a href="${COPY.github}">GitHub</a><a href="${COPY.releases}">Releases</a></nav>
44-
</header>
45-
<h1>${COPY.tagline}</h1>
46-
<p>${COPY.blurb}</p>
47-
<div class="frame" data-loading><div class="host" id="host"></div></div>
48-
<div class="ctl" id="presetBar"><span class="lbl">Preset</span></div>
49-
<div class="ctl" id="paletteBar"><span class="lbl">Palette</span></div>`;
89+
<div class="top">
90+
<div class="wm">~ $ <b>tslime</b></div>
91+
<div class="meta">v${COPY.version}<a href="${COPY.github}">github</a><a href="${COPY.releases}">releases</a></div>
92+
</div>
93+
<div class="lede">
94+
<h1><i id="chev">❯</i>${COPY.tagline.replace(/\.$/, '')}</h1>
95+
<p class="blurb">${COPY.blurb}</p>
96+
</div>
97+
<div class="screen" data-loading>
98+
<span class="tick tl"></span><span class="tick tr"></span>
99+
<span class="tick bl"></span><span class="tick br"></span>
100+
<span class="tag slabel">live</span>
101+
<span class="tag status" id="status">● running</span>
102+
<div class="viewport"><div class="host" id="host"></div></div>
103+
<span class="tag readout" id="readout">—</span>
104+
</div>
105+
<div class="cmd" id="cmd">
106+
<span class="pr">$</span> <span class="name">tslime</span>
107+
<span class="flag">--preset</span> <span class="tok" id="tokPreset" role="button" tabindex="0">network</span>
108+
<span class="flag">--palette</span> <span class="tok" id="tokPalette" role="button" tabindex="0">slime</span>
109+
<span class="cur" id="cursor">█</span>
110+
</div>
111+
<div class="hint" id="hint">click a value to cycle</div>`;
112+
const q = <T extends HTMLElement>(s: string) => app.querySelector<T>(s)!;
50113
return {
51-
host: app.querySelector<HTMLElement>('#host')!,
52-
presetBar: app.querySelector<HTMLElement>('#presetBar')!,
53-
paletteBar: app.querySelector<HTMLElement>('#paletteBar')!,
114+
host: q('#host'),
115+
screen: q('.screen'),
116+
tokPreset: q('#tokPreset'),
117+
tokPalette: q('#tokPalette'),
118+
status: q('#status'),
119+
readout: q('#readout'),
120+
hint: q('#hint'),
121+
cursor: q('#cursor'),
122+
chevron: q('#chev'),
54123
};
55124
}

web/src/controls.ts

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,102 @@
11
import { CURATED_PRESETS, CURATED_PALETTES } from './data';
22
import type { TermController } from './terminal';
3+
import type { ChromeHandles } from './chrome';
34

4-
function bar(
5-
container: HTMLElement,
6-
names: string[],
7-
onPick: (name: string) => void,
8-
) {
9-
names.forEach((name, i) => {
10-
const b = document.createElement('button');
11-
b.className = 'pill';
12-
b.textContent = name;
13-
if (i === 0) b.setAttribute('data-active', '');
14-
b.addEventListener('click', () => {
15-
container.querySelectorAll('.pill').forEach((p) => p.removeAttribute('data-active'));
16-
b.setAttribute('data-active', '');
17-
onPick(name);
18-
});
19-
container.appendChild(b);
5+
// Chevron accent per palette: a lifted, slime-parallel sample of each palette's
6+
// hue (same lightness/chroma profile the Slime green carries as the UI accent).
7+
const PALETTE_ACCENT: Record<string, string> = {
8+
Slime: '#6dbf66',
9+
Warm: '#d2ab5f',
10+
Ocean: '#79aff7',
11+
Mono: '#b0adaa',
12+
Heat: '#f18c64',
13+
};
14+
15+
// One-line character note per preset, shown after the first preset cycle.
16+
const PRESET_INFO: Record<string, string> = {
17+
Network: 'dense, interconnected veins with rapid branching',
18+
Organic: 'balanced, natural-looking growth',
19+
Vortex: 'rotational currents that coil into spirals',
20+
Lightning: 'fast dendritic branching, like lightning',
21+
Tendrils: 'long arms reaching across the field',
22+
};
23+
24+
// A clickable command-line token that cycles a curated list and live-swaps.
25+
function cycler(el: HTMLElement, names: string[], onPick: (n: string) => void, onChange: () => void) {
26+
let i = 0;
27+
el.textContent = names[0];
28+
const next = () => {
29+
i = (i + 1) % names.length;
30+
el.textContent = names[i];
31+
onPick(names[i]);
32+
onChange();
33+
};
34+
el.addEventListener('click', next);
35+
el.addEventListener('keydown', (e) => {
36+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); next(); }
2037
});
2138
}
2239

23-
export function wireControls(
24-
presetBar: HTMLElement,
25-
paletteBar: HTMLElement,
26-
c: TermController,
27-
) {
28-
bar(presetBar, CURATED_PRESETS, (n) => c.setPresetByName(n));
29-
bar(paletteBar, CURATED_PALETTES, (n) => c.setPaletteByName(n));
40+
export function wireControls(h: ChromeHandles, c: TermController, agents: number) {
41+
const agentLabel = agents >= 1000 ? `${Math.round(agents / 1000)}k` : `${agents}`;
42+
const readout = () => {
43+
h.readout.innerHTML =
44+
`${h.tokPreset.textContent} · ${h.tokPalette.textContent} · ${agentLabel}` +
45+
` <span class="leg">· space pauses</span>`;
46+
};
47+
48+
// The cursor + "click a value to cycle" guide retire on the first cycle of
49+
// either token. The preset info line only appears once a preset is cycled — a
50+
// palette-first interaction leaves the line empty until then.
51+
let guided = true;
52+
const retireGuide = () => {
53+
if (!guided) return;
54+
guided = false;
55+
h.cursor.style.display = 'none';
56+
h.hint.textContent = '';
57+
};
58+
59+
// Show the preset note, then let it fade out gently after a long-ish dwell.
60+
// A fresh cycle cancels the pending fade and brings the line back.
61+
let fadeTimer: number | undefined;
62+
const showPresetInfo = (text: string) => {
63+
if (fadeTimer) clearTimeout(fadeTimer);
64+
h.hint.removeAttribute('data-faded');
65+
h.hint.textContent = text;
66+
fadeTimer = window.setTimeout(() => h.hint.setAttribute('data-faded', ''), 7000);
67+
};
68+
cycler(h.tokPreset, CURATED_PRESETS, (n) => c.setPresetByName(n), () => {
69+
retireGuide();
70+
showPresetInfo(PRESET_INFO[h.tokPreset.textContent ?? ''] ?? '');
71+
readout();
72+
});
73+
cycler(h.tokPalette, CURATED_PALETTES, (n) => {
74+
c.setPaletteByName(n);
75+
const col = PALETTE_ACCENT[n] ?? 'var(--acc)';
76+
h.chevron.style.color = col;
77+
h.tokPalette.style.color = col;
78+
h.tokPalette.style.borderBottomColor = col;
79+
}, () => {
80+
retireGuide();
81+
readout();
82+
});
83+
readout();
84+
85+
// Pause/resume: space (global) or clicking the status tag.
86+
let playing = true;
87+
const setPlaying = (v: boolean) => {
88+
playing = v;
89+
playing ? c.resume() : c.pause();
90+
h.status.textContent = playing ? '● running' : '❚❚ paused';
91+
h.status.toggleAttribute('data-paused', !playing);
92+
};
93+
h.status.addEventListener('click', () => setPlaying(!playing));
94+
window.addEventListener('keydown', (e) => {
95+
if (e.code === 'Space' && !/^(INPUT|TEXTAREA)$/.test((e.target as HTMLElement)?.tagName)) {
96+
e.preventDefault();
97+
setPlaying(!playing);
98+
}
99+
});
100+
101+
return { setPlaying };
30102
}

0 commit comments

Comments
 (0)