commit 747460121f763a40c0a699a095725cd55468858c
parent e35aee3f64f90a56fe310caf4fb5176fc43458c8
Author: Damian Myrda <damian@prime8.dev>
Date: Thu, 22 Oct 2026 00:20:18 +0000
feat(web): strict monospace grid, git-graph experience rail, versioned app.js
- real JetBrains Mono 700 (no synthetic bold), box-drawing glyphs from
the full font, ligatures off, tab-size 8
- about page on one line grid: contiguous rows, one blank row between
sections, single-row tags
- experience timeline drawn as * and | glyphs in one column
- app.js?v=<sha256> cache busting
Diffstat:
6 files changed, 248 insertions(+), 71 deletions(-)
diff --git a/internal/web/renderer.go b/internal/web/renderer.go
@@ -1,12 +1,18 @@
package web
import (
+ "crypto/sha256"
"embed"
+ "encoding/hex"
"fmt"
"html/template"
"io/fs"
+ "os"
"path"
+ "path/filepath"
+ "strconv"
"strings"
+ "sync"
"time"
"git.prime8.dev/moncheeta/prime8dev/internal/config"
@@ -46,6 +52,37 @@ type Renderer struct {
tpl *template.Template
}
+// StaticDir is where the server serves /static/ from (relative to the working
+// directory, /app in the Docker image). assetVersion hashes files from here.
+var StaticDir = "static"
+
+// assetVersions caches one content hash per static file for the life of the
+// process. Static files only change on deploy, which restarts the server.
+var (
+ assetVersionsMu sync.Mutex
+ assetVersions = map[string]string{}
+ startupVersion = strconv.FormatInt(time.Now().Unix(), 36)
+)
+
+// AssetVersion returns a short content hash of static/<name>, for cache
+// busting: /static/app.js?v=<hash>. If the file can't be read (e.g. tests run
+// from another directory) it falls back to the process start time, which
+// still changes on every deploy.
+func AssetVersion(name string) string {
+ assetVersionsMu.Lock()
+ defer assetVersionsMu.Unlock()
+ if v, ok := assetVersions[name]; ok {
+ return v
+ }
+ v := startupVersion
+ if b, err := os.ReadFile(filepath.Join(StaticDir, filepath.FromSlash(path.Clean("/"+name)))); err == nil {
+ sum := sha256.Sum256(b)
+ v = hex.EncodeToString(sum[:])[:12]
+ }
+ assetVersions[name] = v
+ return v
+}
+
func New() (*Renderer, error) {
inlineStyles, err := loadInlineStyles()
if err != nil {
@@ -69,6 +106,7 @@ func New() (*Renderer, error) {
"durationHasYear": utils.DurationHasYear,
"timelineYear": utils.TimelineYear,
"toHTML": toHTML,
+ "assetVersion": AssetVersion,
"gitCloneURL": config.GitCloneURL,
"gitSSHCloneURL": config.GitSSHCloneURL,
"wakaTimeUser": func() string { return config.WakaTimeUser },
@@ -80,6 +118,9 @@ func New() (*Renderer, error) {
},
}
+ // hash the bundle once at startup rather than on the first request
+ AssetVersion("app.js")
+
tpl, err := template.New("site").Funcs(funcs).ParseFS(templatesFS, "templates/*.gohtml", "templates/partials/*.gohtml")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
diff --git a/internal/web/styles/about.css b/internal/web/styles/about.css
@@ -1,17 +1,27 @@
+/*
+ * The about page is a terminal: one font-size, one line-height, every row
+ * exactly one line (1.5em) tall, no gaps between a prompt and its output, and
+ * exactly one blank row between sections. Nothing below may add vertical padding,
+ * margins or a different line-height, or rows fall off the grid.
+ */
.about-layout {
+ --row: 1.5em;
width: 100%;
height: 100%;
- padding: 24px 32px;
+ padding: var(--row) 32px;
overflow-y: auto;
display: flex;
flex-direction: column;
- gap: 14px;
+ gap: var(--row);
+ font-size: var(--fs-14);
+ line-height: 1.5;
}
.shell-prompt {
color: var(--fg-dim);
- font-size: var(--fs-14);
- margin-bottom: 3px;
+ font-size: inherit;
+ line-height: inherit;
+ margin: 0;
}
.shell-user {
@@ -38,33 +48,46 @@
flex-shrink: 0;
display: flex;
flex-direction: column;
- gap: 2px;
+ gap: 0;
}
.section .bio {
color: var(--fg);
- font-size: var(--fs-14);
- line-height: 1.6;
+ font-size: inherit;
+ line-height: inherit;
margin: 0;
}
+/* chips: one cell of horizontal padding, zero vertical, so each is one row */
.tags {
display: flex;
flex-wrap: wrap;
- gap: 6px;
+ gap: 0 1ch;
+}
+
+.about-layout .tag {
+ display: inline-block;
+ font-size: inherit;
+ line-height: inherit;
+ height: var(--row);
+ padding: 0 1ch;
}
-/* fastfetch */
+/* fastfetch: logo width and gap in ch so the text column starts on a cell */
+/* the logo is 24ch x 10 rows so whatever follows stays on the row grid */
.fetch {
display: flex;
align-items: flex-start;
- gap: 24px;
- margin-top: 6px;
+ gap: 3ch;
+ margin: 0;
+ font-size: var(--fs-14);
+ line-height: 1.5;
}
.fetch-logo {
- width: 200px;
- aspect-ratio: 1;
+ display: block;
+ width: 24ch;
+ height: 15em; /* 10 rows of 1.5em */
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border);
@@ -73,8 +96,6 @@
.fetch-info {
min-width: 0;
- font-size: var(--fs-14);
- line-height: 1.5;
}
.fetch-rule {
@@ -161,22 +182,44 @@
@media (max-width: 700px) {
.about-layout {
- padding: 16px;
+ padding: var(--row) 16px;
}
.fetch {
flex-direction: column;
- gap: 12px;
+ gap: 1.5em;
}
.fetch-logo {
- width: 120px;
+ width: 14ch;
+ height: 9em; /* 6 rows */
}
}
-/* interactive prompt (modules/shell.js) */
+/* interactive prompt (modules/shell.js)
+ * Everything in the shell (prompt, typed text, cursor, output) shares one
+ * font-size and line-height so it all sits on the same character grid. */
.shell {
cursor: text;
+ font-size: var(--fs-14);
+ line-height: 1.5;
+ font-weight: 400;
+ letter-spacing: 0;
+ word-spacing: 0;
+ tab-size: 8;
+ font-kerning: none;
+ font-variant-ligatures: none;
+ font-feature-settings: "liga" 0, "calt" 0;
+ font-synthesis: none;
+}
+
+.shell .shell-prompt,
+.shell .shell-out,
+.shell .shell-line,
+.shell .shell-cmd,
+.shell .shell-cursor {
+ font: inherit;
+ line-height: inherit;
}
.shell-live {
@@ -212,7 +255,6 @@
.shell-history {
display: flex;
flex-direction: column;
- gap: 2px;
}
.shell-history:empty {
@@ -225,7 +267,18 @@
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
- margin-bottom: 3px;
+}
+
+/* inside the shell, lines stack with no extra gaps: a strict row grid */
+.shell .shell-prompt,
+.shell .shell-out {
+ margin: 0;
+}
+
+/* echoed command lines keep their exact spacing, like the live line */
+.shell .shell-prompt {
+ white-space: pre-wrap;
+ word-break: break-all;
}
/* streamed output: one div per line, no gap between them */
@@ -240,7 +293,7 @@
.shell-match {
color: var(--red);
- font-weight: bold;
+ font-weight: 700;
}
.shell-grep-file {
@@ -257,7 +310,7 @@
.shell-dir {
color: var(--blue);
- font-weight: bold;
+ font-weight: 700;
}
.shell-reconnect {
@@ -305,4 +358,8 @@
}
/* fetch output inside the shell */
-.shell-fetch { margin: 0.4em 0; white-space: normal; }
+.shell-fetch {
+ margin: 0;
+ padding: 0 0 1.5em;
+ white-space: normal;
+}
diff --git a/internal/web/styles/experience.css b/internal/web/styles/experience.css
@@ -41,63 +41,98 @@
border-color: var(--accent);
}
+/*
+ * `git log --graph` rail, drawn entirely in monospace text:
+ *
+ * * [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
+ *
+ * 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.
+ */
.exp-timeline {
- width: 64px;
+ --row: 1.5em;
flex-shrink: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- padding-top: 24px;
+ width: 10ch;
position: relative;
+ padding-top: 24px;
+ font-size: var(--fs-14);
+ line-height: 1.5;
+ font-variant-ligatures: none;
+ font-feature-settings: "liga" 0, "calt" 0;
+ font-synthesis: none;
+ letter-spacing: 0;
+ color: var(--fg-dim);
+ cursor: default;
+ user-select: none;
}
-.timeline-dot {
- width: 12px;
- height: 12px;
- border: 2px solid var(--fg-dim);
- border-radius: 50%;
- background: transparent;
+.graph-head {
+ height: var(--row);
+ white-space: pre;
}
-.timeline-dot-end {
- position: absolute;
- bottom: 8px;
+.graph-node {
+ color: var(--fg-dim);
}
-.exp-timeline.has-end-dot .timeline-line {
- bottom: 14px;
+.timeline-year {
+ color: var(--fg-dim);
}
-.exp-timeline.active .timeline-dot-end {
- border-color: var(--accent);
- background: var(--accent);
+/* segment below the node, down to the card bottom (or the start node) */
+.graph-rail {
+ position: absolute;
+ left: 0;
+ top: calc(24px + var(--row));
+ bottom: 0;
+ width: 1ch;
+ overflow: hidden;
+ color: var(--fg-dim);
}
-.exp-timeline.active .timeline-dot {
- border-color: var(--accent);
- background: var(--accent);
+.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;
}
-.timeline-year {
- font-size: var(--fs-12);
+/* segment above the node, bridging the 16px row gap from the row above */
+.exp-row + .exp-row .exp-timeline::before {
+ position: absolute;
+ left: 0;
+ top: -16px;
+ height: calc(16px + 24px);
+ width: 1ch;
+ overflow: hidden;
color: var(--fg-dim);
- margin-top: 4px;
}
-.timeline-line {
+/* start node on the card's last row; the rail stops just above it */
+.graph-node-end {
position: absolute;
- top: 56px;
- bottom: -16px;
- width: 2px;
- left: 50%;
- transform: translateX(-50%);
- background: repeating-linear-gradient(
- to bottom,
- var(--border) 0px,
- var(--border) 4px,
- transparent 4px,
- transparent 8px
- );
+ left: 0;
+ bottom: 0;
+ height: var(--row);
+}
+
+.exp-timeline.has-end-dot .graph-rail {
+ bottom: var(--row);
+}
+
+.exp-timeline.active .graph-node,
+.exp-timeline.active .timeline-year {
+ color: var(--accent);
+}
+
+.exp-timeline.active .graph-rail {
+ color: var(--accent-dim);
}
.exp-card-header {
diff --git a/internal/web/styles/layout.css b/internal/web/styles/layout.css
@@ -48,11 +48,51 @@
--fs-32: var(--type-8);
}
+/*
+ * Real 400 and 700 faces so bold text never gets synthesized (synthetic bold
+ * widens glyphs in Firefox and knocks text off the monospace grid). The
+ * fontsource "latin" subset has no box-drawing/arrow glyphs (tree's ├── │),
+ * so those ranges come from the full upstream webfont; the browser only
+ * fetches it when such a glyph is actually on the page.
+ */
@font-face {
font-family: "JetBrains Mono";
src: url("https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2")
format("woff2");
font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-700-normal.woff2")
+ format("woff2");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono@v2.304/fonts/webfonts/JetBrainsMono-Regular.woff2")
+ format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ unicode-range: U+2190-21FF, U+2500-25FF;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono@v2.304/fonts/webfonts/JetBrainsMono-Bold.woff2")
+ format("woff2");
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+ unicode-range: U+2190-21FF, U+2500-25FF;
}
body {
@@ -63,6 +103,12 @@ body {
line-height: 1.5;
min-height: 100vh;
overflow: hidden;
+ /* strict monospace grid: no ligatures/contextual alternates, no faux bold */
+ font-variant-ligatures: none;
+ font-feature-settings: "liga" 0, "calt" 0;
+ font-synthesis: none;
+ letter-spacing: 0;
+ tab-size: 8;
}
* {
@@ -180,16 +226,14 @@ a:hover {
.boot-title {
font-family: "JetBrains Mono", "SF Mono", monospace;
font-size: var(--fs-32);
- font-weight: 600;
+ font-weight: 700;
color: var(--accent);
- letter-spacing: -0.5px;
}
.boot-subtitle {
font-family: "JetBrains Mono", "SF Mono", monospace;
font-size: var(--fs-14);
color: var(--fg-dim);
- letter-spacing: 0.5px;
}
@keyframes bootFadeOut {
@@ -258,7 +302,7 @@ a:hover {
.help-title {
color: var(--accent);
- font-weight: 600;
+ font-weight: 700;
}
.help-close {
diff --git a/internal/web/templates/layout.gohtml b/internal/web/templates/layout.gohtml
@@ -12,6 +12,7 @@
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin="anonymous">
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin="anonymous">
+ <link rel="preload" href="https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.18/files/jetbrains-mono-latin-700-normal.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<meta property="og:type" content="website">
<meta property="og:url" content="{{tabURL .ActiveTab}}">
<meta property="og:title" content="{{.Title}} | {{.About.Name}}">
@@ -55,7 +56,7 @@
{{template "lightbox" .}}
- <script src="/static/app.js" defer></script>
+ <script src="/static/app.js?v={{assetVersion "app.js"}}" defer></script>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Person","name":"{{.About.Name}}"}</script>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebSite","name":"{{.About.Name}}","url":"https://www.prime8.dev"}</script>
</body>
diff --git a/internal/web/templates/partials/experience.gohtml b/internal/web/templates/partials/experience.gohtml
@@ -6,10 +6,9 @@
{{$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}}">
- <span class="timeline-dot"></span>
- <span class="timeline-year">[{{timelineYear $exp.EndDate $exp.Current}}]</span>
- <div class="timeline-line"></div>
- {{if $hasYear}}<span class="timeline-dot timeline-dot-end"></span>{{end}}
+ <div class="graph-head"><span class="graph-node" aria-hidden="true">*</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}}">