11 KiB
tui-pages website
Static marketing site for the tui-pages Rust crate.
No build step required — open index.html in a browser and you're done.
Stack
| Layer | Tech | Source |
|---|---|---|
| HTML | Hand-written semantic | *.html |
| CSS framework | Tailwind CSS v3 (Play CDN) | runtime |
| Components | DaisyUI v4 (prebuilt CSS) | runtime |
| Interactivity | HTMX 2 | runtime |
| Light JS | Alpine.js 3 | runtime |
| Code highlight | highlight.js 11 (Rust, TOML, Bash) | runtime |
| Fonts | Inter + JetBrains Mono (Google Fonts) | runtime |
| Icons | Lucide (inline SVG) | hand-written |
| Page transitions | View Transitions API (native) | n/a |
Everything is loaded from public CDNs. The only thing served from this repo is
the HTML, our small static/css/site.css layer, and the SVG assets in
static/img/.
Local development
# Just open the file
xdg-open index.html # Linux
open index.html # macOS
# Or serve it (recommended — gives you a stable URL for htmx)
python3 -m http.server 8000
# then visit http://localhost:8000
The Makefile wraps the Python server and a few convenience commands:
make serve # python3 -m http.server 8000 (serves the WORKING TREE — use this while iterating)
make size # report file sizes
make validate # quick HTML syntax check (search for unclosed tags)
make tidy # run html-tidy on every .html (warnings only)
make video NAME=foo # regenerate the ASCII background from video/foo.mp4
Note:
make servereads from the working tree (so it picks up the lateststatic/js/mountain.jsafter amake video). For a production build, usenix run .#serve— but note that the Nix build only includes git-tracked files, so you mustgit addyour newmountain.js(and any other new files) beforenix run .#servewill pick them up.
Animated ASCII video background
The page background is an animated chafa ASCII playback of a video, running
at 12 fps behind the entire page. ffmpeg extracts frames from a video you
provide; chafa converts each frame to an 80 × 24 ASCII grid; a bundler
(tools/build_mountain_js.py) concatenates all grids into a single JS array
(static/js/mountain.js). A tiny cycler (static/js/mountain-bg.js) swaps
textContent on a single <pre> every ~83 ms.
Swap the video
-
Drop a
.mp4invideo/:cp ~/Videos/my-clip.mp4 video/myclip.mp4 -
Build it into the site (run inside the nix dev shell for ffmpeg + chafa):
nix develop -c make video NAME=myclipThis runs three steps:
make video-frames NAME=myclip—ffmpegextracts PNGs at 12 fps intobuild/myclip/, thenchafa -s 80x24 --symbols asciiproduces ASCII for eachmake video-bundle NAME=myclip— bundles allframe-*.txtintostatic/js/mountain.js- the wrapper
make videodoes both
-
Serve and view:
make serve # → http://localhost:8000
Helper targets
make video-list # show available videos in video/
make video-frames NAME=foo # only extract + chafa, skip the bundle
make video-bundle NAME=foo # only re-bundle from an existing build/foo/
Tuning
- Frame rate — edit
Makefilefps=12in thevideo-framestarget. If you change it, also updateTP_MOUNTAIN_FPSat the bottom ofstatic/js/mountain.js(regenerated automatically eachmake video). - ASCII resolution —
-s 80x24in thechafacall. Wider/higher = larger bundle, more detail. - Bundle size — the resulting
static/js/mountain.jsis~1.7 kB × N_FRAMES(333 kB for a 15 s clip at 12 fps). For long videos, dropfpsor downscale the source video first.
↓ chafa -s 80x24
build/mountain/frame-*.txt intermediate ASCII grids
↓
static/js/mountain.js bundled 60-frame array (≈120 kB)
↓ requestAnimationFrame
DOM <pre.tp-mountain-frame> what you actually see, 12 fps
To re-render (you need chafa and python3 on $PATH, or use nix develop):
make mountain # frames + bundle, end-to-end
make mountain-frames # just the PNG + ASCII frames
make mountain-bundle # just rebundle the JS array from existing frames
How it's wired
static/js/mountain.jsexposeswindow.TP_MOUNTAIN_FRAMES(an array of 60 template strings) plusTP_MOUNTAIN_N_FRAMES = 60andTP_MOUNTAIN_FPS = 12.static/js/mountain-bg.jsmounts a<pre class="tp-mountain-frame" aria-hidden="true">todocument.body, sets the first frame astextContent, then cycles withrequestAnimationFrameand asetInterval-style accumulator so frame drops don't desync.- The script pauses when
document.hiddenis true (no wasted CPU on backgrounded tabs) and respectsprefers-reduced-motion: reduceby showing a single mid-loop frame and not animating. - Style lives in
static/css/ascii.css:position: fixed; inset: 0; z-index: 0— full-bleed, behind page contentcolor: #f4a26b(rust-orange) with a 0.45 opacity and a softtext-shadowglow — picks up the brand colourfont-family: 'JetBrains Mono'for the in-browser renderer- A
::afterpseudo-element overlays a 2 px CRT scanline pattern that ticks downward at 8 s/cycle - Light theme (
data-theme="winter") drops the glow, dims the opacity to 0.20, and switches the scanlines to white-on-transparent - The scanline animation is killed inside
@media (prefers-reduced-motion: reduce)
The hero (#hero) is not part of the mountain background — it keeps
the original static static/img/terminal-default.svg mockup, exactly as
designed. The terminal sits on top of the mountain as a discrete card.
Tuning the animation
The renderer is parameterized at the top of tools/mountain.py:
| Constant | Default | What it does |
|---|---|---|
W, H |
320 × 200 | Output PNG resolution |
N_FRAMES |
60 | How long the loop is |
HORIZON_FRAC |
0.45 | Sky height as a fraction of the image |
SEED |
deterministic | Heightmap random seed |
STEPS |
80 | Forward steps per frame in camera space |
Bump W/H for sharper terrain, or --symbols ascii+block+half in the
chafa call inside tools/build_mountain_js.py for denser glyphs. Both
changes require a make mountain rebuild and a higher page weight
(mountain.js is currently ≈120 kB).
Nix (optional, recommended)
A flake.nix is included that ships a reproducible dev shell, a buildable
package, a serve app, and an html-tidy check. You do not need Nix to
use this project — it's a convenience.
# Enter the dev shell (gives you: gnumake, python3, asciinema, html-tidy, …)
nix develop
# Build the whole site → ./result/ (16 files, ~150 kB, ready to upload)
nix build
ls result/
# Build + serve on http://localhost:8000 (or $PORT)
nix run .#serve
# Validate the HTML, format the flake
nix flake check
nix fmt
What the flake provides
| Output | Command | What it does |
|---|---|---|
packages.<sys>.default |
nix build |
Assembles the static site into a single derivation |
apps.<sys>.serve |
nix run .#serve |
Builds the package, then python3 -m http.server it |
devShells.<sys>.default |
nix develop |
Shell with make, python3, asciinema, tidy, curl |
checks.<sys>.tidy |
nix flake check |
Runs html-tidy over every .html in the built site |
formatter.<sys> |
nix fmt |
nixpkgs-fmt for the flake itself |
Why no Rust toolchain?
The crate lives in ../komp_ac/tui-pages/. This repo only contains the
static website, so the flake intentionally omits rust-overlay and a
rustPlatform — they would add hundreds of MB to the shell closure for
nothing. When the site eventually gains a Rust backend, those inputs come
back.
Why the per-system pattern?
Mirrored from the upstream Codex CLI flake so future contributors see a
shape they already recognise. forAllSystems is a one-liner that
guarantees linux/darwin × x86_64/aarch64 parity without sprinkling
if branches across the file.
Going to production
The CDN approach is fine for marketing pages. For better performance (smaller CSS, no runtime Tailwind compile), swap the Play CDN for the Tailwind standalone CLI:
# 1. Download the standalone CLI
# https://tailwindcss.com/blog/standalone-cli
# 2. Put the binary in ./bin/tailwindcss
# 3. Create src/site.css that imports Tailwind and DaisyUI:
# @import "tailwindcss";
# @plugin "daisyui";
# 4. Build
./bin/tailwindcss -i src/site.css -o static/css/site.css --minify
Then in index.html remove the Tailwind Play CDN <script> and the DaisyUI
<link>, and ensure /static/css/site.css is loaded last in <head>.
File layout
tui-pages-web/
├── index.html # landing page
├── examples.html # examples gallery
├── 404.html # not found
├── robots.txt # search engine hints
├── sitemap.xml # sitemap
├── tools/
│ ├── mountain.py # pure-Python 3D-mountain PNG renderer
│ └── build_mountain_js.py # bundles 60 ASCII frames into mountain.js
├── static/
│ ├── css/
│ │ ├── site.css # our custom layer (small)
│ │ └── ascii.css # animation rules for the mountain background
│ ├── img/
│ │ ├── favicon.svg
│ │ ├── logo.svg
│ │ ├── og-image.svg # 1200x630 social share card
│ │ ├── terminal-default.svg
│ │ ├── terminal-canvas.svg
│ │ └── terminal-keybindings.svg
│ ├── js/
│ │ ├── mountain.js # 60-frame ASCII array (≈120 kB)
│ │ └── mountain-bg.js # requestAnimationFrame cycler
│ └── demos/ # (empty — drop asciinema .cast files here)
├── content/ # (empty — markdown for blog/changelog later)
├── flake.nix # Nix dev shell, package, app, checks
├── flake.lock # (generated — pinned nixpkgs)
├── Makefile
├── .gitignore
└── README.md
Adding an asciinema demo to the hero
The hero currently shows the static SVG mockup
static/img/terminal-default.svg. To replace it with a real asciinema
recording (much more impressive, but requires a recorded cast):
-
Record one of the examples:
cd ../komp_ac/tui-pages/examples/default asciinema rec ../../tui-pages-web/static/demos/intro.cast # ... run the app for a few seconds, hit ctrl-d to stop -
In
index.html, replace the<!-- Hero terminal mockup -->block with:<div class="tp-terminal"> <asciinema-player src="/static/demos/intro.cast" autoplay loop></asciinema-player> </div> -
Add the asciinema player to the
<head>:<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.css"> <script src="https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.js" defer></script>
The 3D-mountain ASCII background is independent of the hero and stays as-is.
License
The website source is MIT-licensed. The terminal mockup SVGs in static/img/
are hand-drawn and original. The crate itself is at
https://gitlab.com/filipriec/tui-pages.