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:
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.