commit ebdc7300fd19167036ecf52c9c59f10a4610e97c
Author: FedorVinog <fedor.vinogradov@student.howest.be>
Date: Wed, 10 Jun 2026 21:01:33 +0200
initial release — hugo-theme-tui
Terminal-UI Hugo theme: box-drawn frame, lazygit-style row hovers, Nord
palette, monospace typography, dot-matrix glyph marker on the frame
border. Configurable per-page-kind marker words via params.marker.
- theme.toml with MIT license, Hugo theme-gallery fields
- LICENSE (MIT)
- README with install, demo, and configuration docs
- exampleSite/ with minimal demo content and full hugo.toml example
- images/ placeholder with screenshot requirements documented
Diffstat:
27 files changed, 1407 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,16 @@
+# Hugo build artifacts (the example site is for demo only)
+exampleSite/public/
+exampleSite/resources/
+exampleSite/.hugo_build.lock
+
+# Hugo build artifacts at any level
+public/
+resources/
+.hugo_build.lock
+
+# Editor / OS noise
+.DS_Store
+.idea/
+.vscode/
+*.swp
+*.swo
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Fedor Vinogradov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
@@ -0,0 +1,135 @@
+# hugo-theme-tui
+
+A terminal-UI Hugo theme: a box-drawn frame around the page, lazygit-style
+row hovers on lists, a restrained Nord palette, a monospace font ladder,
+and a dot-matrix glyph marker on the frame border.
+
+Built for personal sites — bio + projects + a blog.
+
+```
+╭── whoami.md ──────────────────────────────────── • • • • ▏──╮
+│ │
+│ user@example.com ~/ [whoami] projects … │
+│ ──────────────────────────────────────────────────────── │
+│ Fedor Vinogradov │
+│ one-line description here │
+│ │
+│ ── about ────────────────────────────────────────────── │
+│ · recently graduated … │
+│ · working on … │
+│ · passionate about open source │
+│ │
+╰──────────────────────────────────────────────────────────────╯
+```
+
+## Install
+
+```bash
+# from your Hugo site root
+git submodule add https://github.com/tddra/hugo-theme-tui themes/hugo-theme-tui
+```
+
+Then in your `hugo.toml`:
+
+```toml
+theme = "hugo-theme-tui"
+```
+
+Or clone directly without submodules:
+
+```bash
+git clone https://github.com/tddra/hugo-theme-tui themes/hugo-theme-tui
+```
+
+## Try the demo site
+
+```bash
+git clone https://github.com/tddra/hugo-theme-tui
+cd hugo-theme-tui
+hugo server --source exampleSite --themesDir ../..
+```
+
+Then open `http://localhost:1313`.
+
+## Configure
+
+All configuration lives in `hugo.toml`. See `exampleSite/hugo.toml` for a
+complete working example. Minimum to look right:
+
+```toml
+[params]
+brandUser = "fedor" # left side of the user@host brand line
+brandHost = "fedorvin.com" # right side
+
+[params.about]
+title = "Your Name"
+description = "one-line tagline shown under the title"
+```
+
+### The dot-matrix marker
+
+The bracketed dot-matrix glyphs sitting on the frame's top border are the
+theme's signature element. The word changes per page kind, and is
+configurable:
+
+```toml
+[params.marker]
+home = ["Д", "О", "М"]
+projects = ["К", "О", "Д"]
+posts_list = ["П", "О", "С", "Т"]
+posts_single = ["С", "Т", "А", "Т"]
+fallback = ["С", "Т", "А", "Т"]
+```
+
+Each entry is a list of single characters. The bitmap font ships with
+**eight Cyrillic glyphs**: `А Д К М О П С Т`. To use different
+characters, add 5×5 bitmap entries to the `$font` dict in
+`layouts/partials/cyrillic-svg.html`. (Each glyph is a list of five
+strings of five `1`/`0` digits — see the existing entries for examples.)
+
+### Socials
+
+The home page renders a socials grid driven by `params.socialLinks`:
+
+```toml
+[[params.socialLinks]]
+key = "email"
+value = "hello@example.com"
+url = "mailto:hello@example.com"
+icon = "email"
+```
+
+Built-in icons: `email`, `linkedin`, `github`, `git`, `rss`, `pgp`,
+`coffee`. Add more by extending `layouts/partials/icon.html`.
+
+### Projects list
+
+A "projects" page renders a `proj-list` from page params, not from child
+pages. See `exampleSite/content/projects.md` for the structure:
+
+```toml
+[[projects]]
+title = "my-project"
+url = "https://github.com/…"
+lang = "rust"
+desc = "one-line description"
+```
+
+## Customizing
+
+The whole theme is four CSS files under `static/css/`:
+
+| File | Purpose |
+| ------------ | --------------------------------------------------------- |
+| `shared.css` | Nord palette variables, base typography, body reset |
+| `tui.css` | All component styles (frame, header, sections, rows, etc.) |
+| `pattern.css`| Marker positioning (border-anchored) |
+| `custom.css` | Hugo-rendered-markdown adjustments (TOC, images, etc.) |
+
+Override anything by creating a same-named partial or stylesheet in your
+site's own `layouts/` or `static/` directory — Hugo's lookup order picks
+your site's files over the theme's.
+
+## License
+
+[MIT](./LICENSE) — Fedor Vinogradov, 2026.
diff --git a/exampleSite/content/_index.md b/exampleSite/content/_index.md
@@ -0,0 +1,9 @@
++++
+title = "whoami"
++++
+
+* You are looking at the `hugo-theme-tui` demo site
+* Edit `content/_index.md` to write your own bio
+* The brand line in the header is set via `params.brandUser` and `params.brandHost`
+* The dot-matrix marker word is set per page kind via `params.marker`
+* Socials below come from `params.socialLinks` in `hugo.toml`
diff --git a/exampleSite/content/posts/_index.md b/exampleSite/content/posts/_index.md
@@ -0,0 +1,4 @@
++++
+title = "posts"
+description = "thoughts, notes, write-ups"
++++
diff --git a/exampleSite/content/posts/hello-world.md b/exampleSite/content/posts/hello-world.md
@@ -0,0 +1,37 @@
++++
+title = "hello, world"
+date = 2026-06-10
+description = "first post in the demo site"
+tags = ["meta", "intro"]
++++
+
+This is a demo post that ships with `hugo-theme-tui`. It shows how a single
+post is rendered: the frame title shows the file basename, the dot-matrix
+marker shows the `posts_single` word, and a table of contents appears above
+the article if the post has any `##` or `###` headings.
+
+## A section heading
+
+Body copy uses a monospace font (Atkinson Hyperlegible Mono) on a Nord
+palette. Inline `code` like this gets a subtle panel background and a 2px
+border-radius.
+
+```bash
+# fenced code blocks get a left rule in the Nord green
+echo "hello"
+```
+
+### A subsection
+
+Links look like [this](https://example.com) — solid underline in the link
+color at 25% opacity, switching to a brighter color on hover.
+
+> Blockquotes get a left rule in the Nord purple. Use them for asides.
+
+## Another section
+
+Lists use a thin `·` bullet:
+
+- item one
+- item two
+- item three
diff --git a/exampleSite/content/projects.md b/exampleSite/content/projects.md
@@ -0,0 +1,23 @@
++++
+title = "projects"
+description = "things I'm building"
+type = "projects"
+layout = "projects"
+
+[[projects]]
+title = "hugo-theme-tui"
+url = "https://github.com/tddra/hugo-theme-tui"
+lang = "hugo"
+desc = "this very theme"
+
+[[projects]]
+title = "example-cli"
+url = "https://example.com"
+lang = "rust"
+desc = "a one-line description of what this project does"
+
+[[projects]]
+title = "another-thing"
+lang = "go"
+desc = "no link, just a name + description"
++++
diff --git a/exampleSite/hugo.toml b/exampleSite/hugo.toml
@@ -0,0 +1,60 @@
+baseURL = "https://example.com/"
+languageCode = "en-us"
+title = "hugo-theme-tui demo"
+theme = "hugo-theme-tui"
+
+defaultContentLanguage = "en"
+
+[params]
+# Brand string rendered in the header: <user>@<host> ~/path
+brandUser = "user"
+brandHost = "example.com"
+
+[params.about]
+title = "hugo-theme-tui"
+description = "A terminal-UI Hugo theme. Edit `content/_index.md` to make this your own."
+
+# Dot-matrix marker words per page kind. Each is a list of single characters.
+# Only characters present in the bitmap font (see partials/cyrillic-svg.html)
+# will render — by default that's the Cyrillic set: А Д К М О П С Т.
+# Override any of these to use your own word.
+[params.marker]
+home = ["Д", "О", "М"]
+projects = ["К", "О", "Д"]
+posts_list = ["П", "О", "С", "Т"]
+posts_single = ["С", "Т", "А", "Т"]
+fallback = ["С", "Т", "А", "Т"]
+
+# Socials grid (home page). icon must be one of:
+# email | linkedin | github | git | rss | pgp | coffee
+[[params.socialLinks]]
+key = "email"
+value = "hello@example.com"
+url = "mailto:hello@example.com"
+icon = "email"
+
+[[params.socialLinks]]
+key = "github"
+value = "github.com/example"
+url = "https://github.com/example"
+icon = "github"
+
+[[params.socialLinks]]
+key = "rss"
+value = "/index.xml"
+url = "/index.xml"
+icon = "rss"
+
+[menu]
+
+ [[menu.main]]
+ identifier = "projects"
+ name = "projects"
+ url = "/projects/"
+ weight = 10
+
+ [[menu.main]]
+ identifier = "posts"
+ name = "posts"
+ url = "/posts/"
+ weight = 20
diff --git a/images/README.md b/images/README.md
@@ -0,0 +1,17 @@
+# Theme gallery images
+
+Drop two PNGs into this directory before submitting to the Hugo themes
+gallery (https://themes.gohugo.io):
+
+- `screenshot.png` — 1500 × 1000 px, full-page screenshot
+- `tn.png` — 900 × 600 px, thumbnail
+
+Generate them by running the example site and screenshotting:
+
+```bash
+hugo server --source exampleSite --themesDir ../..
+# open http://localhost:1313 and capture
+```
+
+This is a Hugo gallery requirement, not a theme requirement — the theme
+works fine without them.
diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html lang="{{ site.Language.Lang }}">
+<head>
+{{ partial "head.html" . }}
+</head>
+<body>
+{{- /*
+ Derive per-page-kind variables: frame title, dot-matrix marker word,
+ brand path, active nav state.
+
+ The marker word is a slice of single characters (one bitmap glyph per
+ char). Site users can override the per-page-kind words via:
+ [params.marker]
+ home = ["H","O","M","E"]
+ projects = ["C","O","D","E"]
+ posts_list = ["P","O","S","T"]
+ posts_single = ["R","E","A","D"]
+ fallback = ["P","A","G","E"]
+ Only characters present in the bitmap font (see partials/cyrillic-svg.html
+ $font dict) will render. Defaults below use the original Cyrillic motif.
+*/ -}}
+{{- $frameTitle := "" -}}
+{{- $cyrWord := slice -}}
+{{- $brandPath := "" -}}
+{{- $navActive := "home" -}}
+{{- $marker := site.Params.marker | default dict -}}
+
+{{- if .IsHome -}}
+ {{- $frameTitle = "── whoami.md ──" -}}
+ {{- $cyrWord = index $marker "home" | default (slice "Д" "О" "М") -}}
+ {{- $navActive = "home" -}}
+{{- else if eq .Type "projects" -}}
+ {{- $frameTitle = "── projects/ ──" -}}
+ {{- $cyrWord = index $marker "projects" | default (slice "К" "О" "Д") -}}
+ {{- $brandPath = "projects" -}}
+ {{- $navActive = "projects" -}}
+{{- else if eq .Section "posts" -}}
+ {{- $brandPath = "posts" -}}
+ {{- $navActive = "posts" -}}
+ {{- if .IsPage -}}
+ {{- $cyrWord = index $marker "posts_single" | default (slice "С" "Т" "А" "Т") -}}
+ {{- $slug := .File.BaseFileName -}}
+ {{- $frameTitle = printf "── %s.md ──" $slug -}}
+ {{- else -}}
+ {{- $cyrWord = index $marker "posts_list" | default (slice "П" "О" "С" "Т") -}}
+ {{- $frameTitle = "── posts/ ──" -}}
+ {{- end -}}
+{{- else -}}
+ {{- $frameTitle = printf "── %s ──" (.Title | default "page") -}}
+ {{- $cyrWord = index $marker "fallback" | default (slice "С" "Т" "А" "Т") -}}
+{{- end -}}
+
+<div class="tui">
+ <div class="frame" data-title="{{ $frameTitle }}">
+ {{ partial "cyrillic-svg.html" (dict "word" $cyrWord) }}
+ <div class="wrap">
+ {{ partial "header.html" (dict "active" $navActive "path" $brandPath "mobileWord" $cyrWord) }}
+ {{ block "main" . }}{{ end }}
+ </div>
+ {{ partial "footer.html" . }}
+ </div>
+</div>
+</body>
+</html>
diff --git a/layouts/_default/list.html b/layouts/_default/list.html
@@ -0,0 +1,17 @@
+{{ define "main" }}
+<h1 class="page-title">{{ .Title }}</h1>
+{{- with .Description }}<p class="page-sub">{{ . | markdownify }}</p>{{ end }}
+{{- with .Content }}<div class="bullets-wrap">{{ . }}</div>{{ end }}
+
+<ul class="proj-list">
+ {{- range .Pages }}
+ <li>
+ <div class="lang">{{ .Section }}</div>
+ <div class="body">
+ <a href="{{ .Permalink }}">{{ .Title }}</a>
+ {{- with .Description }}<div class="desc">{{ . }}</div>{{ end }}
+ </div>
+ </li>
+ {{- end }}
+</ul>
+{{ end }}
diff --git a/layouts/_default/projects.html b/layouts/_default/projects.html
@@ -0,0 +1,23 @@
+{{ define "main" }}
+<h1 class="page-title">{{ .Title | default "projects" | lower }}</h1>
+{{- with .Description }}<p class="page-sub">{{ . | markdownify }}</p>{{ end }}
+
+{{- with .Content }}<div class="bullets-wrap">{{ . }}</div>{{ end }}
+
+{{- $projects := .Params.projects -}}
+{{- with $projects }}
+<h2 class="section">repos<span class="count">{{ len . }} entries</span></h2>
+<ul class="proj-list">
+ {{- range . }}
+ <li>
+ <div class="lang">{{ .lang }}</div>
+ <div class="body">
+ {{- if .url }}<a href="{{ .url }}">{{ .title }}</a>
+ {{- else }}<span>{{ .title }}</span>{{ end }}
+ {{- with .desc }}<div class="desc">{{ . }}</div>{{ end }}
+ </div>
+ </li>
+ {{- end }}
+</ul>
+{{- end }}
+{{ end }}
diff --git a/layouts/_default/single.html b/layouts/_default/single.html
@@ -0,0 +1,7 @@
+{{ define "main" }}
+<h1 class="page-title">{{ .Title }}</h1>
+{{- with .Description }}<p class="page-sub">{{ . | markdownify }}</p>{{ end }}
+<article class="post">
+ {{ .Content }}
+</article>
+{{ end }}
diff --git a/layouts/index.html b/layouts/index.html
@@ -0,0 +1,15 @@
+{{ define "main" }}
+{{- $about := site.Params.about -}}
+<h1 class="page-title">{{ with $about.title }}{{ . }}{{ else }}{{ site.Title }}{{ end }}</h1>
+{{- with $about.description }}<p class="page-sub">{{ . | markdownify }}</p>{{ end }}
+
+<h2 class="section">about</h2>
+<div class="bullets-wrap">
+ {{ .Content }}
+</div>
+
+{{ if site.Params.socialLinks }}
+<h2 class="section">socials<span class="count">contact</span></h2>
+{{ partial "socials.html" . }}
+{{ end }}
+{{ end }}
diff --git a/layouts/partials/cyrillic-svg.html b/layouts/partials/cyrillic-svg.html
@@ -0,0 +1,66 @@
+{{- /*
+ Renders a 5×5 dot-matrix page marker as an inline <svg>. The bitmap font
+ ($font dict below) ships with eight Cyrillic glyphs; add more entries to
+ support other characters.
+
+ Context:
+ .word — a slice of single-character strings, in reading order
+ (e.g. (slice "Д" "О" "М") for "ДОМ")
+ .W — optional width (default 320)
+ .H — optional height (default 32)
+ .class — optional class for the <svg> (default "frame-marker")
+
+ Geometry:
+ step=4.5 r=1.45 cy=H/2 rightX = W - step*1.2
+ For each char (iterated right-to-left as col=0, -6, -12, ...) and
+ each bitmap cell (rr,cc) where the bit is "1":
+ x = rightX + (col + cc - 4) * step
+ y = cy + (rr - 2 ) * step
+*/ -}}
+
+{{- $word := .word -}}
+{{- $W := (.W | default 320) -}}
+{{- $H := (.H | default 32) -}}
+{{- $class := (.class | default "frame-marker") -}}
+
+{{- /* Scale step / r proportionally to height:
+ step = (H/32)*4.5, r = (H/32)*1.45. */ -}}
+{{- $hRatio := div (float $H) 32.0 -}}
+{{- $step := mul $hRatio 4.5 -}}
+{{- $r := mul $hRatio 1.45 -}}
+{{- $cy := div (float $H) 2.0 -}}
+{{- $rightX := sub (float $W) (mul $step 1.2) -}}
+
+{{- $font := dict
+ "А" (slice "01110" "10001" "11111" "10001" "10001")
+ "Д" (slice "01110" "01010" "01010" "11111" "10001")
+ "К" (slice "10010" "10100" "11000" "10100" "10010")
+ "М" (slice "10001" "11011" "10101" "10001" "10001")
+ "О" (slice "01110" "10001" "10001" "10001" "01110")
+ "П" (slice "11111" "10001" "10001" "10001" "10001")
+ "С" (slice "01111" "10000" "10000" "10000" "01111")
+ "Т" (slice "11111" "00100" "00100" "00100" "00100")
+-}}
+
+{{- /* Reverse the word so the rightmost char gets col=0. */ -}}
+{{- $reversed := collections.Reverse $word -}}
+
+<svg xmlns="http://www.w3.org/2000/svg" class="{{ $class }}" viewBox="0 0 {{ $W }} {{ $H }}" preserveAspectRatio="none" aria-hidden="true">
+ <line x1="0" y1="{{ $cy }}" x2="{{ $W }}" y2="{{ $cy }}" stroke="#434c5e" stroke-width="1" shape-rendering="crispEdges"/>
+ {{- range $idx, $ch := $reversed -}}
+ {{- $col := mul $idx -6 -}}
+ {{- with index $font $ch -}}
+ {{- range $rr, $row := . -}}
+ {{- range $cc := seq 0 4 -}}
+ {{- if eq (substr $row $cc 1) "1" -}}
+ {{- $i := add $col (sub $cc 4) -}}
+ {{- $j := sub $rr 2 -}}
+ {{- $x := add $rightX (mul (float $i) $step) -}}
+ {{- $y := add $cy (mul (float $j) $step) -}}
+ <circle cx="{{ printf "%.2f" $x }}" cy="{{ printf "%.2f" $y }}" r="{{ $r }}" fill="#88c0d0"/>
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end }}
+</svg>
diff --git a/layouts/partials/footer.html b/layouts/partials/footer.html
@@ -0,0 +1,5 @@
+{{- with site.Copyright -}}
+<footer class="site-footer">
+ <p class="copyright">{{ . | markdownify }}</p>
+</footer>
+{{- end -}}
diff --git a/layouts/partials/head.html b/layouts/partials/head.html
@@ -0,0 +1,21 @@
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ with .Title }}{{ . }} – {{ end }}{{ .Site.Title }}</title>
+{{ with .Site.Params.about.description }}<meta name="description" content="{{ . }}">{{ end }}
+{{ if .Site.Params.noindex }}<meta name="robots" content="noindex">{{ end }}
+
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link rel="stylesheet" href="{{ "css/shared.css" | absURL }}">
+<link rel="stylesheet" href="{{ "css/tui.css" | absURL }}">
+<link rel="stylesheet" href="{{ "css/pattern.css" | absURL }}">
+<link rel="stylesheet" href="{{ "css/custom.css" | absURL }}">
+
+{{ if os.FileExists "static/favicon.ico" }}<link rel="icon" href="{{ "favicon.ico" | absURL }}">{{ end }}
+{{ if os.FileExists "static/favicon-32x32.png" }}<link rel="icon" type="image/png" sizes="32x32" href="{{ "favicon-32x32.png" | absURL }}">{{ end }}
+{{ if os.FileExists "static/favicon-16x16.png" }}<link rel="icon" type="image/png" sizes="16x16" href="{{ "favicon-16x16.png" | absURL }}">{{ end }}
+{{ if os.FileExists "static/apple-touch-icon.png" }}<link rel="apple-touch-icon" href="{{ "apple-touch-icon.png" | absURL }}">{{ end }}
+{{ if os.FileExists "static/site.webmanifest" }}<link rel="manifest" href="{{ "site.webmanifest" | absURL }}">{{ end }}
+
+{{ with .OutputFormats.Get "rss" -}}
+ {{ printf `<link rel=%q type=%q href=%q title=%q>` .Rel .MediaType.Type .Permalink site.Title | safeHTML }}
+{{ end }}
diff --git a/layouts/partials/header.html b/layouts/partials/header.html
@@ -0,0 +1,27 @@
+{{- /*
+ Site header — brand (user@host ~/path) + nav (whoami / projects / posts).
+ The brand prefix `user@host` links back to the home page; the trailing
+ ` ~/path` reflects current section and stays as text.
+
+ Context:
+ .active — one of "home" | "projects" | "posts"
+ .path — section path shown after `~/` in the brand line
+ .mobileWord — slice of single chars for the mobile dot-matrix marker
+*/ -}}
+{{- $active := .active -}}
+{{- $path := .path -}}
+{{- $user := site.Params.brandUser | default "user" -}}
+{{- $host := site.Params.brandHost | default "example.com" -}}
+<header class="site">
+ <div class="brand">
+ <a class="brand-link" href="{{ "/" | relURL }}" aria-label="whoami"><span class="user">{{ $user }}</span><span class="at">@</span><span class="host">{{ $host }}</span></a><span class="path"> ~/{{ $path }}</span>
+ </div>
+ <nav class="site">
+ <a href="{{ "/" | relURL }}"{{ if eq $active "home" }} class="is-active"{{ end }}>whoami</a>
+ <a href="{{ "/projects/" | relURL }}"{{ if eq $active "projects" }} class="is-active"{{ end }}>projects</a>
+ <a href="{{ "/posts/" | relURL }}"{{ if eq $active "posts" }} class="is-active"{{ end }}>posts</a>
+ </nav>
+ {{- with .mobileWord -}}
+ {{ partial "cyrillic-svg.html" (dict "word" . "W" 320 "H" 26 "class" "frame-marker frame-marker-mobile") }}
+ {{- end -}}
+</header>
diff --git a/layouts/partials/icon.html b/layouts/partials/icon.html
@@ -0,0 +1,22 @@
+{{- /*
+ Inline SVG icons for socials, keyed by name.
+ Context: a plain string — the icon name ("email" | "linkedin" | "github" |
+ "git" | "rss" | "pgp" | "coffee"). Unknown names render nothing.
+*/ -}}
+{{- $name := . -}}
+{{- $common := `viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"` -}}
+{{- if eq $name "email" -}}
+<svg {{ $common | safeHTMLAttr }}><rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 7l9 6 9-6"/></svg>
+{{- else if eq $name "linkedin" -}}
+<svg {{ $common | safeHTMLAttr }}><rect x="3" y="3" width="18" height="18" rx="1"/><path d="M8 10v8M8 7v.01M12 18v-5M16 18v-3a2 2 0 0 0-4 0"/></svg>
+{{- else if eq $name "github" -}}
+<svg {{ $common | safeHTMLAttr }}><path d="M9 19c-4 1.5-4-2-6-2.5M15 22v-4c0-1 .1-1.4-.5-2 3-.3 6-1.5 6-6.5a4.7 4.7 0 0 0-1.3-3.3 4.4 4.4 0 0 0-.1-3.3s-1-.3-3.4 1.3a11.6 11.6 0 0 0-6.2 0C6.9 1.6 5.9 1.9 5.9 1.9a4.4 4.4 0 0 0-.1 3.3A4.7 4.7 0 0 0 4.5 8.5c0 5 3 6.2 6 6.5-.6.6-.6 1.2-.5 2v4"/></svg>
+{{- else if eq $name "git" -}}
+<svg {{ $common | safeHTMLAttr }}><path d="M12 2v6M12 22v-6M4.93 4.93l4.24 4.24M14.83 14.83l4.24 4.24M2 12h6M22 12h-6M4.93 19.07l4.24-4.24M14.83 9.17l4.24-4.24"/></svg>
+{{- else if eq $name "rss" -}}
+<svg {{ $common | safeHTMLAttr }}><path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
+{{- else if eq $name "pgp" -}}
+<svg {{ $common | safeHTMLAttr }}><rect x="4" y="11" width="16" height="10" rx="1"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
+{{- else if eq $name "coffee" -}}
+<svg {{ $common | safeHTMLAttr }}><path d="M18 8h1a4 4 0 0 1 0 8h-1M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>
+{{- end -}}
diff --git a/layouts/partials/socials.html b/layouts/partials/socials.html
@@ -0,0 +1,18 @@
+{{- /*
+ Socials grid for the home page. Driven by site.Params.socialLinks.
+ Each entry uses:
+ key — label shown in the tile's small caps line (e.g., "email")
+ value — value shown below the key (e.g., "hello@example.com")
+ url — href
+ icon — name passed to partials/icon.html (see that partial for keys)
+*/ -}}
+{{- with site.Params.socialLinks -}}
+<div class="socials">
+ {{- range . }}
+ <a href="{{ .url }}"{{ if hasPrefix .url "http" }} rel="me"{{ end }} aria-label="{{ .key }}" title="{{ .key }}">
+ <span class="ico">{{ partial "icon.html" .icon }}</span>
+ <span class="lbl"><span class="k">{{ .key }}</span><span class="v">{{ .value }}</span></span>
+ </a>
+ {{- end }}
+</div>
+{{- end -}}
diff --git a/layouts/posts/list.html b/layouts/posts/list.html
@@ -0,0 +1,24 @@
+{{ define "main" }}
+<h1 class="page-title">{{ .Title | default "posts" | lower }}</h1>
+{{- with .Description }}<p class="page-sub">{{ . | markdownify }}</p>
+{{- else }}{{ with .Content }}<div class="page-sub">{{ . }}</div>{{ end }}{{ end }}
+
+{{- $pages := where .Pages "Section" "posts" -}}
+{{- range $pages.GroupByDate "2006" }}
+<h2 class="section">{{ .Key }}<span class="count">{{ len .Pages }} entries</span></h2>
+<div class="qf">
+ {{- range .Pages }}
+ <div class="qf-row">
+ <div class="date">{{ .Date.Format "2006-01-02" }}</div>
+ <div class="title-cell">
+ <a href="{{ .Permalink }}">{{ .Title }}</a>
+ {{- with .Description }}<div class="desc">{{ . }}</div>{{ end }}
+ </div>
+ <div class="tags">
+ {{- range .Params.tags }}<span class="tag">#{{ . }}</span>{{ end -}}
+ </div>
+ </div>
+ {{- end }}
+</div>
+{{- end }}
+{{ end }}
diff --git a/layouts/posts/single.html b/layouts/posts/single.html
@@ -0,0 +1,24 @@
+{{ define "main" }}
+<h1 class="page-title">{{ .Title }}</h1>
+<p class="page-sub">
+ {{- if not .Date.IsZero }}<span style="color:var(--base0D)">{{ .Date.Format "2006-01-02" }}</span>{{ end -}}
+ {{- with .ReadingTime }}<span style="color:var(--base04);margin:0 8px">·</span><span>~{{ . }} min read</span>{{ end -}}
+ {{- with .Params.tags }}
+ <span style="color:var(--base04);margin:0 8px">·</span>
+ {{- range . }}<span style="color:var(--base0C)">[#{{ . }}]</span> {{ end -}}
+ {{- end -}}
+</p>
+
+<div class="post-layout">
+ {{- $toc := .TableOfContents -}}
+ {{- if $toc }}
+ <aside class="toc toc-vertical" aria-label="table of contents">
+ <div class="toc-head">── on this page ──</div>
+ {{ $toc }}
+ </aside>
+ {{- end }}
+ <article class="post">
+ {{ .Content }}
+ </article>
+</div>
+{{ end }}
diff --git a/static/css/custom.css b/static/css/custom.css
@@ -0,0 +1,123 @@
+/* Hugo-output tweaks: things the static design source doesn't cover
+ because it was hand-authored HTML rather than markdown-rendered. */
+
+/* ----- Home: scope the .bullets style to markdown-rendered <ul> ----- */
+.tui .bullets-wrap ul {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 26px;
+ display: flex;
+ flex-direction: column;
+}
+.tui .bullets-wrap ul li {
+ padding: 4px 10px 4px 22px;
+ position: relative;
+ color: var(--fg);
+ font-size: 14px;
+ border-left: 2px solid transparent;
+}
+.tui .bullets-wrap ul li::before {
+ content: "·";
+ position: absolute;
+ left: 10px; top: 4px;
+ color: var(--muted);
+ font-weight: 700;
+}
+.tui .bullets-wrap a { color: var(--link); text-decoration: none; }
+.tui .bullets-wrap a:hover { color: var(--hl); text-decoration: underline; text-decoration-style: dotted; }
+.tui .bullets-wrap strong { color: var(--fg-bright); }
+.tui .bullets-wrap p { margin: 0 0 18px; color: var(--fg); font-size: 14px; }
+
+/* ----- Single-post TOC: style Hugo's <nav id="TableOfContents"> to
+ approximate the design's tree-branch TOC ----- */
+.tui #TableOfContents > ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ counter-reset: tocnum;
+}
+.tui #TableOfContents > ul > li {
+ counter-increment: tocnum;
+}
+.tui #TableOfContents > ul > li > a {
+ color: var(--fg);
+ text-decoration: none;
+ font-size: 12.5px;
+ border-bottom: 1px dotted transparent;
+ line-height: 1.5;
+}
+.tui #TableOfContents > ul > li > a::before {
+ content: counter(tocnum, decimal-leading-zero) " ";
+ color: var(--accent);
+ font-size: 11px;
+}
+.tui #TableOfContents > ul > li > a:hover {
+ color: var(--hl);
+ border-bottom-color: var(--accent);
+}
+.tui #TableOfContents > ul > li > ul {
+ list-style: none;
+ margin: 2px 0 6px;
+ padding: 0 0 0 26px;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.tui #TableOfContents > ul > li > ul > li {
+ display: flex;
+ align-items: baseline;
+ line-height: 1.5;
+}
+.tui #TableOfContents > ul > li > ul > li::before {
+ content: "├── ";
+ color: var(--rule-bright);
+ white-space: pre;
+ user-select: none;
+ font-size: 12px;
+}
+.tui #TableOfContents > ul > li > ul > li:last-child::before {
+ content: "└── ";
+}
+.tui #TableOfContents > ul > li > ul > li > a {
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 12px;
+ border-bottom: 1px dotted transparent;
+}
+.tui #TableOfContents > ul > li > ul > li:hover > a {
+ color: var(--hl);
+ border-bottom-color: var(--accent);
+}
+
+/* The TOC container "── on this page ──" head when wrapped in our aside */
+.tui .toc { margin: 0 0 22px; padding: 12px 14px; border: 1px solid var(--rule); background: transparent; font-size: 12px; color: var(--muted); }
+.tui .toc-head { color: var(--accent); font-weight: 700; font-size: 10.5px; letter-spacing: 0.06em; margin-bottom: 8px; text-transform: lowercase; }
+
+/* ----- Make Hugo-rendered post bodies pick up article.post styles
+ even if the markdown renderer adds an extra wrapper. The actual
+ heading/paragraph styles live in tui.css. ----- */
+.tui article.post > :first-child { margin-top: 0; }
+
+/* Hugo renders code fences as <pre><code class="language-xxx">. Keep the
+ tui.css pre styling, just make sure inline class doesn't break it. */
+.tui article.post pre code[class*="language-"] { background: none; padding: 0; color: inherit; }
+
+/* Constrain markdown-rendered images to the article width. */
+.tui article.post img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 16px auto;
+ border: 1px solid var(--rule);
+}
+.tui article.post figure { margin: 16px 0; }
+.tui article.post figure img { margin: 0 auto; }
+.tui article.post figure figcaption {
+ font-size: 11.5px;
+ color: var(--muted);
+ text-align: center;
+ margin-top: 6px;
+}
diff --git a/static/css/pattern.css b/static/css/pattern.css
@@ -0,0 +1,54 @@
+/* ===== Cyrillic dot-matrix page-marker =====
+ Desktop: marker sits ON the frame's top border (right side, ~320px),
+ reading as part of the border itself.
+ Mobile: marker sits ON the header's bottom border. */
+
+.tui .frame { position: relative; }
+
+/* Hide V5's native right-meta strip (data-meta on .frame::after) — the
+ marker replaces it on this theme. */
+.tui .frame::after { display: none; }
+
+/* Desktop marker.
+ The SVG itself contains a full-width centerline at y=H/2 stroked in the
+ same color as the frame's border-top, so the two lines pixel-align and
+ read as one continuous rule. */
+.tui .frame > .frame-marker {
+ position: absolute;
+ /* -16.5px (not -16px) so the SVG's 1px centerline pixel-aligns with the
+ frame's 1px border-top — otherwise the two lines sit half a pixel apart
+ and read as two slightly-shifted rules. */
+ top: -16.5px;
+ right: 8px;
+ width: 320px;
+ height: 32px;
+ background: var(--bg);
+ pointer-events: none;
+ display: block;
+}
+
+/* Mobile marker — hidden on desktop. */
+.tui .frame-marker-mobile { display: none; }
+
+/* Real-viewport mobile (≤560px) — desktop marker off, mobile marker on
+ the header's bottom-border. */
+@media (max-width: 560px) {
+ .tui .frame > .frame-marker { display: none; }
+
+ .tui header.site {
+ position: relative;
+ padding-bottom: 12px;
+ }
+
+ .tui header.site .frame-marker-mobile {
+ display: block;
+ position: absolute;
+ /* Keep visually centered on the header's bottom border: bottom = -height/2 */
+ bottom: -13px;
+ right: 6px;
+ width: 320px;
+ height: 26px;
+ background: var(--bg);
+ pointer-events: none;
+ }
+}
diff --git a/static/css/shared.css b/static/css/shared.css
@@ -0,0 +1,49 @@
+/* Nord palette — exact values from the V8 design source. */
+:root {
+ /* Polar Night */
+ --base00: #242933;
+ --base01: #2e3440;
+ --base02: #3b4252;
+ --base03: #434c5e;
+ --base04: #4c566a;
+
+ /* Snow Storm */
+ --base05: #d8dee9;
+ --base06: #e5e9f0;
+ --base07: #eceff4;
+
+ /* Frost */
+ --base08: #8fbcbb;
+ --base09: #88c0d0;
+ --base0A: #81a1c1;
+ --base0B: #5e81ac;
+
+ /* Aurora */
+ --base0C: #b48ead;
+ --base0D: #ebcb8b;
+ --base0E: #a3be8c;
+ --base0F: #d08770;
+
+ /* Soft hover surface — panel at ~60% so row-selection feels eased
+ instead of slamming to a fully different background. */
+ --panel-soft: rgba(46, 52, 64, 0.6);
+}
+
+@import url("https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Mono:wght@400;500;700&display=swap");
+
+* { box-sizing: border-box; }
+
+html, body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ background: var(--base00);
+ font-family: "Atkinson Hyperlegible Mono", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
+ font-feature-settings: "ss01", "ss02";
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+a { color: inherit; }
+
+::selection { background: var(--base09); color: var(--base01); }
diff --git a/static/css/tui.css b/static/css/tui.css
@@ -0,0 +1,512 @@
+/* ===== hugo-theme-tui — base styles =====
+ Box-drawn frame around the page, lazygit-style row selection,
+ restrained Nord palette. */
+
+.tui {
+ --bg: var(--base00);
+ --panel: var(--base01);
+ --panel-2: var(--base02);
+ --fg: var(--base05);
+ --fg-bright: var(--base07);
+ --muted: var(--base04);
+ --rule: var(--base02);
+ --rule-bright: var(--base03);
+ --link: var(--base09);
+ --accent: var(--base08);
+ --hl: var(--base0D);
+ --tag: var(--base0C);
+ --green: var(--base0E);
+
+ background: var(--bg);
+ color: var(--fg);
+ font-size: 14px;
+ line-height: 1.65;
+ min-height: 100%;
+ padding: 28px 28px;
+ display: flex;
+ flex-direction: column;
+
+ /* Cap width on large displays so the frame doesn't stretch edge-to-edge.
+ Expressed in rem so it scales with the user's root font size. */
+ max-width: 64rem;
+ margin: 0 auto;
+}
+
+/* Hover decorations ease in over ~90ms — fast enough to feel native, slow
+ enough to not slam like a static screenshot flipping frames. Scoped to
+ the things that actually change on hover (color/background/border).
+ Animations stay off (no spinners etc). */
+.tui nav.site a,
+.tui ul.bullets li,
+.tui ul.bullets a,
+.tui .bullets-wrap a,
+.tui .qf .qf-row,
+.tui .qf .qf-row .title-cell a,
+.tui .proj-list li,
+.tui .proj-list .body a,
+.tui .socials a,
+.tui .socials a .ico,
+.tui .socials a .lbl .k,
+.tui article.post a,
+.tui .toc-vertical .toc-row a,
+.tui .toc-vertical .toc-subitem a,
+.tui #TableOfContents a,
+.tui footer.plain a,
+.tui .brand .brand-link,
+.tui .brand .brand-link .user,
+.tui .brand .brand-link .host {
+ transition: color 90ms ease-out, background-color 90ms ease-out, border-color 90ms ease-out;
+}
+
+.tui .frame {
+ border: 1px solid var(--rule-bright);
+ position: relative;
+ padding: 30px 36px 18px;
+ display: flex;
+ flex-direction: column;
+}
+/* corner-anchored title strip — looks like ╭─ whoami.md ─╮ */
+.tui .frame::before {
+ content: attr(data-title);
+ position: absolute;
+ top: -0.7em;
+ left: 18px;
+ background: var(--bg);
+ padding: 0 10px;
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+/* right-side meta strip */
+.tui .frame::after {
+ content: attr(data-meta);
+ position: absolute;
+ top: -0.7em;
+ right: 18px;
+ background: var(--bg);
+ padding: 0 10px;
+ color: var(--muted);
+ font-size: 11px;
+ letter-spacing: 0.04em;
+}
+
+.tui .wrap {
+ max-width: 50rem;
+ margin: 0 auto;
+ width: 100%;
+ flex: 1;
+}
+
+/* Header — bracketed tabs (kept from V4) */
+.tui header.site {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 22px;
+ gap: 24px;
+ font-size: 13px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--rule);
+}
+.tui .brand {
+ font-weight: 700;
+ color: var(--fg);
+}
+.tui .brand .user { color: var(--hl); }
+.tui .brand .at { color: var(--muted); }
+.tui .brand .host { color: var(--green); }
+.tui .brand .path { color: var(--muted); }
+
+/* The `user@host` brand string is a link back to the home page. Keep the
+ per-span colors at rest; on hover, the whole link turns bright (white). */
+.tui .brand .brand-link {
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+}
+.tui .brand .brand-link:hover .user,
+.tui .brand .brand-link:hover .host { color: var(--fg-bright); }
+
+.tui nav.site { display: flex; gap: 14px; font-size: 13px; }
+.tui nav.site a {
+ text-decoration: none;
+ color: var(--muted);
+ padding: 0 2px;
+}
+.tui nav.site a:hover { color: var(--fg-bright); }
+.tui nav.site a.is-active { color: var(--hl); font-weight: 700; }
+.tui nav.site a.is-active::before { content: "["; color: var(--accent); margin-right: 1px; font-weight: 400; }
+.tui nav.site a.is-active::after { content: "]"; color: var(--accent); margin-left: 1px; font-weight: 400; }
+
+/* Page title — minimal, no big # */
+.tui h1.page-title {
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--fg-bright);
+ margin: 0 0 4px;
+}
+.tui .page-sub {
+ color: var(--muted);
+ margin: 0 0 22px;
+ font-size: 12px;
+}
+
+/* Section divider — `── label ──` rule */
+.tui h2.section {
+ font-size: 11px;
+ text-transform: lowercase;
+ letter-spacing: 0.1em;
+ color: var(--accent);
+ font-weight: 700;
+ margin: 32px 0 16px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.tui h2.section::before { content: "──"; color: var(--rule-bright); font-weight: 400; }
+.tui h2.section::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
+.tui h2.section .count { color: var(--muted); font-weight: 400; font-size: 11px; letter-spacing: 0; }
+
+/* Key/value bio rows */
+.tui .kv {
+ display: grid;
+ grid-template-columns: 100px 1fr;
+ row-gap: 3px;
+ column-gap: 14px;
+ font-size: 14px;
+ margin: 0 0 30px;
+}
+.tui .kv .k { color: var(--accent); font-weight: 700; }
+.tui .kv .v { color: var(--fg); }
+.tui .kv .v .name { color: var(--hl); font-weight: 700; }
+.tui .kv .v .at { color: var(--muted); }
+.tui .kv .v .host { color: var(--green); font-weight: 700; }
+.tui .kv .v a { color: var(--link); text-decoration: none; }
+.tui .kv .v a:hover { color: var(--hl); text-decoration: underline; text-decoration-style: dotted; }
+.tui .kv .v .sep { color: var(--muted); margin: 0 8px; }
+
+/* Bullets */
+.tui ul.bullets {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 30px;
+ display: flex;
+ flex-direction: column;
+}
+.tui ul.bullets li {
+ padding: 4px 10px 4px 22px;
+ position: relative;
+ color: var(--fg);
+ font-size: 14px;
+ border-left: 2px solid transparent;
+}
+.tui ul.bullets li::before {
+ content: "·";
+ position: absolute;
+ left: 10px; top: 4px;
+ color: var(--muted);
+ font-weight: 700;
+}
+.tui ul.bullets li:hover {
+ background: var(--panel-soft);
+ border-left-color: var(--accent);
+}
+.tui ul.bullets a { color: var(--link); text-decoration: none; }
+.tui ul.bullets a:hover { color: var(--hl); text-decoration: underline; text-decoration-style: dotted; }
+.tui ul.bullets strong { color: var(--fg-bright); }
+
+/* Quickfix-style posts list — lazygit row selection on hover */
+.tui .qf {
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ margin: 0;
+}
+.tui .qf .qf-row {
+ display: grid;
+ grid-template-columns: 92px 1fr auto;
+ gap: 14px;
+ padding: 9px 14px 9px 12px;
+ align-items: baseline;
+ border-left: 2px solid transparent;
+ cursor: pointer;
+}
+.tui .qf .qf-row:hover {
+ background: var(--panel-soft);
+ border-left-color: var(--accent);
+}
+.tui .qf .qf-row .date { color: var(--muted); font-size: 12px; }
+.tui .qf .qf-row .title-cell { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.tui .qf .qf-row .title-cell a { color: var(--link); text-decoration: none; font-weight: 700; }
+.tui .qf .qf-row:hover .title-cell a { color: var(--hl); }
+.tui .qf .qf-row .title-cell .desc { color: var(--muted); font-size: 12px; line-height: 1.5; }
+.tui .qf .qf-row .tags { font-size: 10.5px; color: var(--tag); white-space: nowrap; }
+.tui .qf .qf-row .tags .tag { color: var(--tag); margin-left: 6px; }
+.tui .qf .qf-row .tags .tag::before { content: "["; color: var(--rule-bright); }
+.tui .qf .qf-row .tags .tag::after { content: "]"; color: var(--rule-bright); }
+
+/* Project rows */
+.tui .proj-list {
+ margin: 0; padding: 0; list-style: none;
+ display: flex; flex-direction: column;
+}
+.tui .proj-list li {
+ display: grid;
+ grid-template-columns: 56px 1fr;
+ gap: 14px;
+ padding: 9px 14px 9px 12px;
+ align-items: baseline;
+ font-size: 13px;
+ border-left: 2px solid transparent;
+}
+.tui .proj-list li:hover {
+ background: var(--panel-soft);
+ border-left-color: var(--accent);
+}
+.tui .proj-list .lang { color: var(--muted); font-size: 10.5px; text-align: left; }
+.tui .proj-list .lang::before { content: "["; color: var(--rule-bright); }
+.tui .proj-list .lang::after { content: "]"; color: var(--rule-bright); }
+.tui .proj-list .body a { color: var(--link); text-decoration: none; font-weight: 700; }
+.tui .proj-list li:hover .body a { color: var(--hl); }
+.tui .proj-list .body .desc { color: var(--muted); font-size: 12px; line-height: 1.5; }
+
+/* Post article */
+.tui article.post {
+ font-size: 14px;
+ line-height: 1.72;
+}
+.tui article.post p { margin: 0 0 16px; }
+.tui article.post h2 {
+ font-size: 14.5px;
+ color: var(--fg-bright);
+ margin: 26px 0 12px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.tui article.post h2::before { content: "▌"; color: var(--accent); font-size: 14px; }
+.tui article.post h3 {
+ font-size: 13px;
+ color: var(--fg-bright);
+ margin: 22px 0 10px;
+ font-weight: 700;
+ padding-left: 12px;
+ border-left: 2px solid var(--accent);
+ letter-spacing: 0.02em;
+}
+.tui article.post a {
+ color: var(--link);
+ text-decoration: none;
+ border-bottom: 1px solid rgba(136, 192, 208, 0.25);
+}
+.tui article.post a:hover { color: var(--hl); border-bottom-color: var(--hl); }
+.tui article.post code {
+ background: var(--panel-2);
+ padding: 2px 6px;
+ color: var(--accent);
+ font-size: 12.5px;
+ border-radius: 2px;
+}
+.tui article.post pre {
+ background: var(--panel-2);
+ padding: 14px 16px;
+ margin: 16px 0;
+ overflow: auto;
+ font-size: 12.5px;
+ border-left: 2px solid var(--green);
+ color: var(--fg);
+ line-height: 1.6;
+}
+.tui article.post pre code { background: none; padding: 0; color: inherit; border-radius: 0; }
+.tui article.post blockquote {
+ margin: 16px 0;
+ padding: 8px 18px;
+ border-left: 2px solid var(--tag);
+ color: var(--muted);
+}
+
+/* Socials — icon tiles, lazygit/btop style row of compact panels */
+.tui .socials {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-auto-rows: 1fr;
+ align-items: stretch;
+ gap: 8px;
+ margin: 0 0 30px;
+}
+.tui .socials a {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--rule);
+ border-left: 2px solid var(--rule);
+ background: var(--bg);
+ text-decoration: none;
+ color: var(--fg);
+ font-size: 13px;
+}
+.tui .socials a:hover {
+ background: var(--panel-soft);
+ border-left-color: var(--accent);
+ color: var(--hl);
+}
+.tui .socials a .ico {
+ flex: 0 0 16px;
+ width: 16px; height: 16px;
+ color: var(--accent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.tui .socials a:hover .ico { color: var(--hl); }
+.tui .socials a .ico svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.tui .socials a .lbl { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; overflow: hidden; }
+.tui .socials a .lbl .k { color: var(--accent); font-size: 10.5px; text-transform: lowercase; letter-spacing: 0.06em; font-weight: 700; }
+.tui .socials a:hover .lbl .k { color: var(--hl); }
+.tui .socials a .lbl .v { color: var(--fg); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+/* Post layout — TOC sits above the article as a compact box */
+.tui .post-layout { display: block; }
+
+.tui .toc {
+ margin: 0 0 22px;
+ padding: 12px 14px;
+ border: 1px solid var(--rule);
+ background: transparent;
+ font-size: 12px;
+ color: var(--muted);
+}
+.tui .toc-head {
+ color: var(--accent);
+ font-weight: 700;
+ font-size: 10.5px;
+ letter-spacing: 0.06em;
+ margin-bottom: 8px;
+ text-transform: lowercase;
+}
+
+/* Vertical — stacked numbered list, with h3 children branching as a tree */
+.tui .toc-vertical .toc-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.tui .toc-vertical .toc-item { display: block; }
+.tui .toc-vertical .toc-row {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ line-height: 1.5;
+}
+.tui .toc-vertical .num {
+ color: var(--accent);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ flex-shrink: 0;
+}
+.tui .toc-vertical .toc-row a {
+ color: var(--fg);
+ text-decoration: none;
+ font-size: 12.5px;
+ border-bottom: 1px dotted transparent;
+}
+.tui .toc-vertical .toc-row:hover a {
+ color: var(--hl);
+ border-bottom-color: var(--accent);
+}
+
+/* h3 sub-items: tree branches indented under each h2 */
+.tui .toc-vertical .toc-sub {
+ list-style: none;
+ margin: 2px 0 6px;
+ padding: 0 0 0 26px;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.tui .toc-vertical .toc-subitem {
+ display: flex;
+ align-items: baseline;
+ line-height: 1.5;
+}
+.tui .toc-vertical .branch {
+ color: var(--rule-bright);
+ white-space: pre;
+ user-select: none;
+ font-size: 12px;
+}
+.tui .toc-vertical .toc-subitem a {
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 12px;
+ border-bottom: 1px dotted transparent;
+}
+.tui .toc-vertical .toc-subitem:hover a {
+ color: var(--hl);
+ border-bottom-color: var(--accent);
+}
+
+/* Mobile — tighter padding */
+.tui-mobile .tui .toc { margin-bottom: 16px; padding: 8px 10px; }
+.tui-mobile .tui .toc-vertical .toc-row a { font-size: 11.5px; }
+.tui-mobile .tui .toc-vertical .toc-subitem a,
+.tui-mobile .tui .toc-vertical .branch { font-size: 11px; }
+.tui footer.plain {
+ margin-top: 32px;
+ font-size: 11.5px;
+ color: var(--muted);
+ line-height: 1.7;
+ letter-spacing: 0.02em;
+}
+.tui footer.plain .row { display: block; }
+.tui footer.plain .sep { color: var(--rule-bright); margin: 0 6px; }
+.tui footer.plain .hash { color: var(--green); }
+.tui footer.plain a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--rule-bright); }
+.tui footer.plain a:hover { color: var(--fg); border-bottom-color: var(--fg); }
+
+@media (max-width: 560px) {
+ .tui footer.plain { font-size: 11px; margin-top: 24px; }
+}
+
+/* ===== Real-viewport mobile fallback (when viewing the actual site on a phone) ===== */
+@media (max-width: 560px) {
+ .tui { padding: 10px 8px; font-size: 13.5px; }
+ .tui .frame { padding: 18px 12px 10px; border: none; }
+ .tui .frame::before,
+ .tui .frame::after { display: none; }
+ .tui .wrap { max-width: 100%; }
+ .tui header.site {
+ flex-direction: column; align-items: stretch; gap: 8px;
+ margin-bottom: 16px; padding-bottom: 8px;
+ }
+ .tui .brand { font-size: 12px; word-break: break-all; }
+ .tui nav.site {
+ gap: 14px; justify-content: flex-start; font-size: 12px;
+ border-top: 1px dashed var(--rule); padding-top: 8px;
+ }
+ .tui h1.page-title { font-size: 15.5px; }
+ .tui .page-sub { font-size: 11.5px; margin-bottom: 16px; word-wrap: break-word; }
+ .tui h2.section { font-size: 11px; margin: 20px 0 10px; }
+ .tui ul.bullets li { font-size: 12.5px; padding: 5px 4px 5px 18px; line-height: 1.55; }
+ .tui .socials { grid-template-columns: 1fr; gap: 0; }
+ .tui .socials a {
+ padding: 12px 10px; font-size: 12.5px; border: none;
+ border-bottom: 1px solid var(--rule); border-left: 2px solid transparent;
+ }
+ .tui .socials a:first-child { border-top: 1px solid var(--rule); }
+ .tui .qf .qf-row { grid-template-columns: 1fr; gap: 4px; padding: 10px 8px; }
+ .tui .qf .qf-row .tags { margin-top: 4px; white-space: normal; display: flex; flex-wrap: wrap; gap: 4px; }
+ .tui .qf .qf-row .tags .tag { margin-left: 0; }
+ .tui .proj-list li { grid-template-columns: 44px 1fr; gap: 8px; padding: 10px 8px; font-size: 12.5px; }
+ .tui article.post { font-size: 13px; line-height: 1.7; }
+ .tui article.post pre { padding: 10px; font-size: 11.5px; }
+ .tui footer.site {
+ flex-direction: column; align-items: flex-start; gap: 6px;
+ font-size: 10.5px; padding-top: 10px; margin-top: 18px;
+ }
+ .tui footer.site .kb { padding: 0; border-right: none; }
+}
diff --git a/theme.toml b/theme.toml
@@ -0,0 +1,14 @@
+name = "hugo-theme-tui"
+license = "MIT"
+licenselink = "https://github.com/tddra/hugo-theme-tui/blob/main/LICENSE"
+description = "Terminal-UI personal-site theme: box-drawn frame, lazygit-style row hovers, Nord palette, dot-matrix page markers."
+homepage = "https://github.com/tddra/hugo-theme-tui"
+demosite = "https://github.com/tddra/hugo-theme-tui"
+min_version = "0.110.0"
+
+tags = ["minimal", "terminal", "tui", "blog", "personal", "nord", "dark", "monospace"]
+features = ["blog", "projects", "posts", "rss", "table-of-contents", "responsive", "dark-mode"]
+
+[author]
+ name = "Fedor Vinogradov"
+ homepage = "https://fedorvin.com"