prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit 9b1fe1a24494f692811aced271eb6885d02144dc
parent cd04c453de6eabf4d48518ac06b3d97edc1b8a8e
Author: Damian Myrda <damian@prime8.dev>
Date:   Fri, 23 Oct 2026 00:32:00 +0000

web, tui: draw the portrait one text row at a time

Diffstat:
Minternal/tui/about_render.go | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/content.go | 1+
Minternal/tui/types.go | 3+++
Minternal/tui/update.go | 13++++++++++---
Minternal/web/scripts/app.js | 2++
Ainternal/web/scripts/modules/portrait.js | 23+++++++++++++++++++++++
Minternal/web/styles/about.css | 24++++++++++++++++++++++++
Minternal/web/templates/partials/about.gohtml | 2+-
Astatic/me-sm.jpg | 0
9 files changed, 118 insertions(+), 4 deletions(-)

diff --git a/internal/tui/about_render.go b/internal/tui/about_render.go @@ -2,6 +2,7 @@ package tui import ( "strings" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -49,3 +50,56 @@ func (m *portfolioModel) applyAboutASCIIRendered(msg aboutASCIIRenderedMsg) { } m.aboutASCIICache[msg.width] = msg.ascii } + +// aboutLogoRowTick paces the portrait reveal: 16 rows in about 320ms, the +// same budget the web gives its row-by-row draw. +const aboutLogoRowTick = 20 * time.Millisecond + +type aboutLogoRowMsg struct{ gen int } + +func aboutLogoRowCmd(gen int) tea.Cmd { + return tea.Tick(aboutLogoRowTick, func(time.Time) tea.Msg { return aboutLogoRowMsg{gen: gen} }) +} + +// startAboutLogoReveal restarts the portrait from its first row. A newer +// reveal bumps the generation so ticks from an older one are dropped. +func (m *portfolioModel) startAboutLogoReveal() tea.Cmd { + if m.activeTab != tabAbout || m.bootActive { + return nil + } + if strings.TrimSpace(m.aboutASCIICache[aboutLogoWidth]) == "" { + return nil // starts when the render lands + } + m.aboutLogoGen++ + m.aboutLogoRows = 1 + return aboutLogoRowCmd(m.aboutLogoGen) +} + +func (m *portfolioModel) stepAboutLogoReveal(msg aboutLogoRowMsg) tea.Cmd { + if msg.gen != m.aboutLogoGen { + return nil + } + total := strings.Count(m.aboutASCIICache[aboutLogoWidth], "\n") + 1 + if m.aboutLogoRows >= total { + return nil + } + m.aboutLogoRows++ + if m.aboutLogoRows >= total { + return nil + } + return aboutLogoRowCmd(m.aboutLogoGen) +} + +// revealedLogo keeps the portrait's full size so the fetch lines never +// shift, with the rows not drawn yet left blank. +func revealedLogo(logo string, rows int) string { + lines := strings.Split(logo, "\n") + if rows >= len(lines) { + return logo + } + blank := strings.Repeat(" ", aboutLogoWidth) + for i := rows; i < len(lines); i++ { + lines[i] = blank + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/content.go b/internal/tui/content.go @@ -39,6 +39,7 @@ func (m portfolioModel) aboutFetch() string { if logo == "" { return lines } + logo = revealedLogo(logo, m.aboutLogoRows) if m.width < 100 { return logo + "\n\n" + lines } diff --git a/internal/tui/types.go b/internal/tui/types.go @@ -31,6 +31,9 @@ type portfolioModel struct { currentJob *appconfig.Experience aboutASCIICache map[int]string aboutASCIILoads map[int]bool + // rows of the portrait drawn so far; the reveal prints it a row at a time + aboutLogoRows int + aboutLogoGen int projects []appconfig.Project experience []appconfig.Experience diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -67,7 +67,7 @@ func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.syncBootFrame(time.Time(msg)) if !m.bootActive { - return m, nil + return m, m.startAboutLogoReveal() } return m, bootTickCmd() case wakaFooterInitMsg: @@ -91,10 +91,17 @@ func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case aboutASCIIRenderedMsg: m.applyAboutASCIIRendered(msg) + cmd := m.startAboutLogoReveal() if m.activeTab == tabAbout { m.syncViewport() } - return m, nil + return m, cmd + case aboutLogoRowMsg: + cmd := m.stepAboutLogoReveal(msg) + if m.activeTab == tabAbout { + m.syncViewport() + } + return m, cmd case wakaFooterMsg: m.wakaLoading = false m.wakaLoaded = true @@ -217,7 +224,7 @@ func (m *portfolioModel) activateTab(tab int) tea.Cmd { m.shell.lastKey = time.Now() m.resetViewport() - return m.refreshDataCmd() + return tea.Batch(m.refreshDataCmd(), m.startAboutLogoReveal()) } func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool { diff --git a/internal/web/scripts/app.js b/internal/web/scripts/app.js @@ -3,6 +3,7 @@ import { initHeader, updateAppTitle } from "./modules/header.js"; import { initHelp } from "./modules/help.js"; import { initLayout } from "./modules/layout.js"; import { initLightbox } from "./modules/lightbox.js"; +import { initPortrait } from "./modules/portrait.js"; import { initProjects } from "./modules/projects.js"; import { revealLines } from "./modules/reveal.js"; import { initShell } from "./modules/shell.js"; @@ -15,6 +16,7 @@ function initApp() { const initDynamicContent = () => { initShell(); + initPortrait(); initProjects(); initExperience(); updateAppTitle(); diff --git a/internal/web/scripts/modules/portrait.js b/internal/web/scripts/modules/portrait.js @@ -0,0 +1,23 @@ +// Draws the about portrait one text row at a time once it has decoded, so +// it never paints in pixel rows as the jpeg streams in. +export function initPortrait() { + const img = document.querySelector("#about .fetch-logo"); + if (!img || img.dataset.boundRows === "1") return; + img.dataset.boundRows = "1"; + + const play = () => { + img.classList.remove("rows-pending"); + img.classList.remove("rows-in"); + void img.offsetWidth; // restart the animation on a re-swap + img.classList.add("rows-in"); + }; + + if (img.complete && img.naturalWidth > 0) { + play(); + return; + } + img.classList.add("rows-pending"); + const done = () => (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(play); + img.addEventListener("load", done, { once: true }); + img.addEventListener("error", () => img.classList.remove("rows-pending"), { once: true }); +} diff --git a/internal/web/styles/about.css b/internal/web/styles/about.css @@ -95,6 +95,26 @@ filter: saturate(0.85); } +/* the portrait draws in one text row (1.5em) at a time, like a terminal + printing it, instead of the browser's pixel-row jpeg decode */ +.fetch-logo.rows-pending { + visibility: hidden; +} + +.fetch-logo.rows-in { + animation: logoRows 320ms steps(10, start) both; +} + +@keyframes logoRows { + from { clip-path: inset(0 0 100% 0); } + to { clip-path: inset(0 0 0 0); } +} + +@media (prefers-reduced-motion: reduce) { + .fetch-logo.rows-pending { visibility: visible; } + .fetch-logo.rows-in { animation: none; } +} + .fetch-info { min-width: 0; } @@ -184,6 +204,10 @@ width: 14ch; height: 9em; /* 6 rows */ } + + .fetch-logo.rows-in { + animation-timing-function: steps(6, start); + } } /* interactive prompt (modules/shell.js) diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml @@ -3,7 +3,7 @@ <div class="about-layout"> <div class="section"> <div class="fetch"> - <img class="fetch-logo" src="/static/me.jpg" alt="{{.About.Name}} - {{.About.Profession}}" loading="eager"> + <img class="fetch-logo" src="/static/me-sm.jpg" width="480" height="480" decoding="async" alt="{{.About.Name}} - {{.About.Profession}}" loading="eager"> <div class="fetch-info"> <div class="fetch-title"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span></div> <div class="fetch-rule" aria-hidden="true">----------------</div> diff --git a/static/me-sm.jpg b/static/me-sm.jpg Binary files differ.