commit 597bbdbb93711fb17a2f311edc57dda853ccfc35
parent 8aa6ccd90a7145441b1ff9d4bb3fb20476e5d2aa
Author: Damian Myrda <damian@prime8.dev>
Date: Thu, 22 Oct 2026 00:30:13 +0000
feat: jj-style experience graph on web, blinking cursors
web: drop the end node; @ marks the current job, ◆ past ones, with a
hairline through the node column. Stop selectionchange from re-rendering
the prompt on its own DOM writes, which pinned the cursor solid.
tui: block cursor blinks at 530 ms, solid while typing.
Diffstat:
6 files changed, 64 insertions(+), 51 deletions(-)
diff --git a/internal/tui/program.go b/internal/tui/program.go
@@ -25,6 +25,7 @@ func (m portfolioModel) Init() tea.Cmd {
return tea.Batch(
bootTickCmd(),
tickCmd(),
+ cursorBlinkCmd(),
initWakaFooterCmd(),
)
}
diff --git a/internal/tui/shell.go b/internal/tui/shell.go
@@ -32,11 +32,13 @@ type shellEntry struct {
// shellState is the interactive prompt at the bottom of the about page.
type shellState struct {
- user string
- home string
- cwd string
- oldpwd string
- input string
+ user string
+ home string
+ cwd string
+ oldpwd string
+ input string
+ // lastKey restarts the cursor blink: solid while typing, like a real terminal.
+ lastKey time.Time
history []shellEntry
// cleared hides the about-page intro, like `clear` in a real terminal.
cleared bool
@@ -420,6 +422,7 @@ func commonPrefix(words []string) string {
// whether the key was consumed; unconsumed keys fall through to the global
// bindings (tab switching, scrolling, quit, ...).
func (m *portfolioModel) handleShellKey(msg tea.KeyMsg) (tea.Cmd, bool) {
+ m.shell.lastKey = time.Now()
s := &m.shell
switch msg.Type {
case tea.KeyRunes, tea.KeySpace:
@@ -575,7 +578,7 @@ func (m portfolioModel) shellView(width int) string {
}
parts = append(parts, wrap.Render(out))
}
- parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, m.shell.cwd, m.shell.input)+rowStyle.Render("█"))
+ parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, m.shell.cwd, m.shell.input)+m.shell.cursor(time.Now()))
return strings.Join(parts, "\n")
}
@@ -589,3 +592,21 @@ func renderShellPrompt(user, home, cwd, input string) string {
promptStyle.Render(" ") +
promptCmdStyle.Render(input)
}
+
+// cursorBlink is the half period of the prompt cursor's blink (xterm's 530 ms).
+const cursorBlink = 530 * time.Millisecond
+
+type cursorBlinkMsg struct{}
+
+func cursorBlinkCmd() tea.Cmd {
+ return tea.Tick(cursorBlink, func(time.Time) tea.Msg { return cursorBlinkMsg{} })
+}
+
+// cursor draws the block cursor: solid while keys arrive, then blinking.
+func (s shellState) cursor(now time.Time) string {
+ idle := now.Sub(s.lastKey)
+ if idle < cursorBlink || (idle/cursorBlink)%2 == 0 {
+ return rowStyle.Render("█")
+ }
+ return " "
+}
diff --git a/internal/tui/update.go b/internal/tui/update.go
@@ -50,6 +50,8 @@ func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.syncViewport()
return m, m.refreshDataCmd()
+ case cursorBlinkMsg:
+ return m, cursorBlinkCmd()
case tickMsg:
m.now = time.Time(msg)
return m, tea.Batch(tickCmd(), m.refreshDataCmd())
diff --git a/internal/web/scripts/modules/shell.js b/internal/web/scripts/modules/shell.js
@@ -172,10 +172,14 @@ export function initShell() {
live.scrollIntoView({ block: "nearest" });
}
+ let lastValue = null;
+ let lastCaret = -1;
function render() {
pathEl.textContent = displayPath(sh.home, sh.cwd);
const value = input.value;
const caret = input.selectionStart ?? value.length;
+ lastValue = value;
+ lastCaret = caret;
beforeEl.textContent = value.slice(0, caret);
cursorEl.textContent = value.slice(caret, caret + 1) || "\u00a0";
afterEl.textContent = value.slice(caret + 1);
@@ -725,8 +729,13 @@ export function initShell() {
input.addEventListener("input", render);
input.addEventListener("select", render);
+ // Only a real caret move re-renders: render() rewrites the cursor spans,
+ // which itself fires selectionchange, and re-rendering on that kept the
+ // cursor pinned solid forever (it never got to blink).
document.addEventListener("selectionchange", () => {
- if (document.activeElement === input) render();
+ if (document.activeElement !== input) return;
+ if (input.value === lastValue && input.selectionStart === lastCaret) return;
+ render();
});
input.addEventListener("focus", () => {
section.classList.add("focused");
diff --git a/internal/web/styles/experience.css b/internal/web/styles/experience.css
@@ -42,18 +42,13 @@
}
/*
- * `git log --graph` rail, drawn entirely in monospace text:
+ * jj log style graph rail:
*
- * * [now] <- .graph-node + .timeline-year (accent when active)
- * | <- .graph-rail: a column of "|" glyphs, clipped to the row
- * |
- * * [2025] <- next row's node, same character column
+ * @ [now] <- current job (jj's working copy)
+ * │ <- .graph-rail: 1px line through the node's character cell
+ * ◆ [2026] <- past jobs (immutable history)
*
- * The node and the rail share one font-size and one left offset, so the "*"
- * and every "|" land in the same character cell. Each rail segment is a
- * pre-formatted stack of "|" lines (generated content) clipped by its box,
- * so it stretches to any card height and stays crisp at every zoom. The rail
- * is dim; the selected job's node, year and rail go accent.
+ * The selected job's node and year go accent, its line accent-dim.
*/
.exp-timeline {
--row: 1.5em;
@@ -85,45 +80,32 @@
color: var(--fg-dim);
}
-/* segment below the node, down to the card bottom (or the start node) */
-.graph-rail {
+/* hairline through the node column */
+.graph-rail,
+.exp-row + .exp-row .exp-timeline::before {
position: absolute;
- left: 0;
- top: calc(24px + var(--row));
- bottom: 0;
- width: 1ch;
- overflow: hidden;
- color: var(--fg-dim);
+ /* centre of the node's character cell, so the line runs through it */
+ left: calc(0.5ch - 0.5px);
+ width: 1px;
+ background: var(--border);
}
-.graph-rail::before,
-.exp-row + .exp-row .exp-timeline::before {
- content: "|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A";
- display: block;
- white-space: pre;
+/* from under this job's node to the bottom of its row */
+.graph-rail {
+ top: calc(24px + var(--row));
+ bottom: 0;
}
-/* segment above the node, bridging the 16px row gap from the row above */
+/* across the gap between cards, down to the next node */
.exp-row + .exp-row .exp-timeline::before {
- position: absolute;
- left: 0;
+ content: "";
top: -16px;
height: calc(16px + 24px);
- width: 1ch;
- overflow: hidden;
- color: var(--fg-dim);
-}
-
-/* start node on the card's last row; the rail stops just above it */
-.graph-node-end {
- position: absolute;
- left: 0;
- bottom: 0;
- height: var(--row);
}
-.exp-timeline.has-end-dot .graph-rail {
- bottom: var(--row);
+/* oldest job: nothing below it */
+.exp-row:last-child .graph-rail {
+ display: none;
}
.exp-timeline.active .graph-node,
@@ -132,7 +114,7 @@
}
.exp-timeline.active .graph-rail {
- color: var(--accent-dim);
+ background: var(--accent-dim);
}
.exp-card-header {
diff --git a/internal/web/templates/partials/experience.gohtml b/internal/web/templates/partials/experience.gohtml
@@ -3,12 +3,10 @@
<article class="experience-layout">
<div class="experience-detail">
{{range $index, $exp := .Experience}}
- {{$hasYear := durationHasYear $exp.StartDate $exp.EndDate $exp.Current}}
<div class="exp-row" data-index="{{$index}}" data-id="{{$exp.ID}}">
- <div class="exp-timeline{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} active{{end}}{{else}}{{if eq $index 0}} active{{end}}{{end}}{{if $hasYear}} has-end-dot{{end}}" data-index="{{$index}}">
- <div class="graph-head"><span class="graph-node" aria-hidden="true">*</span> <span class="timeline-year">[{{timelineYear $exp.EndDate $exp.Current}}]</span></div>
+ <div class="exp-timeline{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} active{{end}}{{else}}{{if eq $index 0}} active{{end}}{{end}}" data-index="{{$index}}">
+ <div class="graph-head"><span class="graph-node" aria-hidden="true">{{if $exp.Current}}@{{else}}◆{{end}}</span> <span class="timeline-year">[{{timelineYear $exp.EndDate $exp.Current}}]</span></div>
<span class="graph-rail" aria-hidden="true"></span>
- {{if $hasYear}}<span class="graph-node graph-node-end" aria-hidden="true">*</span>{{end}}
</div>
<div class="exp-card{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} selected{{end}}{{else}}{{if eq $index 0}} selected{{end}}{{end}}" data-index="{{$index}}" data-id="{{$exp.ID}}">