prime8dev

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

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:
Minternal/web/renderer.go | 41+++++++++++++++++++++++++++++++++++++++++
Minternal/web/styles/about.css | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Minternal/web/styles/experience.css | 111++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Minternal/web/styles/layout.css | 52++++++++++++++++++++++++++++++++++++++++++++++++----
Minternal/web/templates/layout.gohtml | 3++-
Minternal/web/templates/partials/experience.gohtml | 7+++----
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}}">