www.git

www

~ Personal site — projects, posts, about. Built with sqzass.
git clone https://github.com/debuman/www.git
commit b91126d702a6b51845c05b8b90d249245fdd2bea
parent 2d976c57463c80fe17bbc4005d53735d6755cb25
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Tue, 4 Aug 2026 19:10:00 +0900

Revert the project detail page, keep the masthead fix

The document-style rewrite is out. The split hero, the metrics band, the
numbered feature grid, the terminal with its title bar and the closing call to
action are back, along with the twelve project files and the CSS that draws
them. The owner preferred the denser page and that settles it.

What stays from that commit is the part that had nothing to do with it: below
736px the masthead could not fit its contents and said so by wrapping the
language label onto three lines. Chips no longer wrap, the centre axis is given
up when it costs more than it buys, the search control shows a word where a
keycap means nothing to a touch screen, and below 544px GitHub leaves the bar
because the colophon has the same link and a truncated nav label is worse than
one less duplicate.

Diffstat:
Mcontent/projects/doomsday.ko.md | 73+++++++++++++++++++++++++++++--------------------------------------------
Mcontent/projects/doomsday.md | 73+++++++++++++++++++++++++++++--------------------------------------------
Mcontent/projects/ommp.ko.md | 131+++++++++++++++++++++++++++++++------------------------------------------------
Mcontent/projects/ommp.md | 131+++++++++++++++++++++++++++++++------------------------------------------------
Mcontent/projects/sqzass.ko.md | 133+++++++++++++++++++++++++++++++------------------------------------------------
Mcontent/projects/sqzass.md | 134+++++++++++++++++++++++++++++++------------------------------------------------
Mcontent/projects/uaf.ko.md | 78+++++++++++++++++++++++++++++-------------------------------------------------
Mcontent/projects/uaf.md | 78+++++++++++++++++++++++++++++-------------------------------------------------
Mcontent/projects/udfx.ko.md | 78+++++++++++++++++++++++++++++-------------------------------------------------
Mcontent/projects/udfx.md | 78+++++++++++++++++++++++++++++-------------------------------------------------
Mcontent/projects/usfx.ko.md | 83++++++++++++++++++++++++++++---------------------------------------------------
Mcontent/projects/usfx.md | 83++++++++++++++++++++++++++++---------------------------------------------------
Mi18n/en.toml | 1-
Mi18n/ko.toml | 1-
Mstatic/css/main.css | 258+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mstatic/fonts/display-kr.woff2 | 0binary
Dtemplates/partials/spec.html | 32--------------------------------
Mtemplates/project.html | 208++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
18 files changed, 724 insertion(+), 929 deletion(-)

diff --git a/content/projects/doomsday.ko.md b/content/projects/doomsday.ko.md
@@ -7,58 +7,43 @@ date = 2026-02-09
 [extra]
 tier = "brief"
 
-eyebrow = "GNOME 셸 확장 · JavaScript"
-tagline = "카운트다운은 이미 눈이 가는 자리에 있어야 쓸모가 있다. 이건 상단 패널, 시계 옆에 산다."
+eyebrow  = "GNOME 셸 확장 · JavaScript"
+tagline  = "카운트다운은 이미 눈이 가는 자리에 있어야 쓸모가 있다. 이건 상단 패널, 시계 옆에 산다."
+lang     = "JavaScript"
+license  = "GPL-3.0"
+version  = ""
+platform = "GNOME 45 · 46 · 47"
+repo     = "https://github.com/sqzer-x/doomsday"
+stars     = 0
+accent     = "#c98a8a"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/doomsday"
-stars = 0
-
-accent = "#c98a8a"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "소스", url = "https://github.com/sqzer-x/doomsday", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "JavaScript"
-url = ""
-
-[[extra.spec]]
-key = "License"
-value = "GPL-3.0"
-url = ""
+facts = [
+  { value = "45–47",   label = "GNOME" },
+  { value = "∞",       label = "이벤트" },
+  { value = "자정",     label = "갱신" },
+  { value = "GPL-3.0", label = "라이선스" },
+]
 
-[[extra.spec]]
-key = "Platform"
-value = "GNOME 45 · 46 · 47"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "Refreshes"
-value = "매일 자정"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 GNOME 상단 패널에 D-Day 를 띄운다. 이벤트는 원하는 만큼 넣고, 한 번 눌러서
diff --git a/content/projects/doomsday.md b/content/projects/doomsday.md
@@ -7,58 +7,43 @@ date = 2026-02-09
 [extra]
 tier = "brief"
 
-eyebrow = "GNOME Shell extension · JavaScript"
-tagline = "A countdown is only useful where you already look. This one lives in the top panel, next to the clock."
+eyebrow  = "GNOME Shell extension · JavaScript"
+tagline  = "A countdown is only useful where you already look. This one lives in the top panel, next to the clock."
+lang     = "JavaScript"
+license  = "GPL-3.0"
+version  = ""
+platform = "GNOME 45 · 46 · 47"
+repo     = "https://github.com/sqzer-x/doomsday"
+stars     = 0
+accent     = "#c98a8a"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/doomsday"
-stars = 0
-
-accent = "#c98a8a"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "Source", url = "https://github.com/sqzer-x/doomsday", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "JavaScript"
-url = ""
-
-[[extra.spec]]
-key = "License"
-value = "GPL-3.0"
-url = ""
+facts = [
+  { value = "45–47",   label = "GNOME" },
+  { value = "∞",       label = "Events" },
+  { value = "midnight", label = "Refreshes" },
+  { value = "GPL-3.0", label = "License" },
+]
 
-[[extra.spec]]
-key = "Platform"
-value = "GNOME 45 · 46 · 47"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "Refreshes"
-value = "Daily, at midnight"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 D-Day counters in the GNOME top panel. Add as many events as you like, switch
diff --git a/content/projects/ommp.ko.md b/content/projects/ommp.ko.md
@@ -7,108 +7,79 @@ date = 2026-08-01
 [extra]
 tier = "landing"
 
-eyebrow = "터미널 음악 재생기 · Rust"
-tagline = "터미널 음악 재생기는 대개 먼저 띄워 둬야 하는 무언가의 클라이언트다. ommp 는 그 전부다. 실행하면 재생되고, 끄면 남는 것이 없다."
+eyebrow  = "터미널 음악 재생기 · Rust"
+tagline  = "터미널 음악 재생기는 대개 먼저 띄워 둬야 하는 무언가의 클라이언트다. ommp 는 그 전부다. 실행하면 재생되고, 끄면 남는 것이 없다."
+lang     = "Rust"
+license  = "Apache-2.0"
+version  = ""
+platform = "Linux"
+repo     = "https://github.com/sqzer-x/ommp"
+stars     = 0
+accent     = "#7fc0d4"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/ommp"
-stars = 0
-
-accent = "#7fc0d4"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
-  { label = "웹사이트", url = "https://ommp.sqzer.com", style = "primary" },
-  { label = "소스", url = "https://github.com/sqzer-x/ommp", style = "ghost" },
+  { label = "웹사이트", url = "https://ommp.sqzer.com",          style = "primary" },
+  { label = "소스",     url = "https://github.com/sqzer-x/ommp", style = "ghost"   },
 ]
 
-# 결정 목록의 머리. 항목은 아래 [[extra.register]] 에 있다.
-register_eyebrow = "기능"
-register_title   = "라이브러리, 재생 목록, 그리고 앨범 아트"
-register_intro   = "화면은 세 칸이다. 왼쪽이 훑고, 가운데가 줄을 세우고, 오른쪽이 표지와 지금 곡을 보여 준다."
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = "~/Music 에 음악을 두고 실행하면 된다. 설정할 것이 없고, 앨범 아트는 파일 안의 태그에서 나온다."
-closing_links = [
-  { label = "ommp.sqzer.com", url = "https://ommp.sqzer.com" },
+facts = [
+  { value = "8",           label = "오디오 형식" },
+  { value = "3",           label = "이미지 프로토콜" },
+  { value = "0",           label = "띄울 데몬" },
+  { value = "Apache-2.0",  label = "라이선스" },
 ]
 
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Rust"
-url = ""
-
-[[extra.spec]]
-key = "License"
-value = "Apache-2.0"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Linux"
-url = ""
-
-[[extra.spec]]
-key = "Audio formats"
-value = "FLAC · MP3 · M4A · OGG · WAV · Opus · AAC · WMA"
-url = ""
-
-[[extra.spec]]
-key = "Image protocols"
-value = "Kitty · Sixel · iTerm2"
-url = ""
-
-[[extra.spec]]
-key = "Daemons to start"
-value = "0"
-url = ""
-
-# ---- 세션. 주장 하나에 영수증 하나. 여러 개일 수 있다.
-
-[[extra.session]]
-eyebrow = "설치"
-title   = "명령 하나, 그다음이 음악"
-caption = "인터페이스 아이콘에 Nerd Font 가, 색에 24비트 지원 터미널이 필요하다."
-lines = [
-  { kind = "cmd", text = "$ cargo install ommp" },
+features_eyebrow = "기능"
+features_title   = "라이브러리, 재생 목록, 그리고 앨범 아트"
+features_intro   = "화면은 세 칸이다. 왼쪽이 훑고, 가운데가 줄을 세우고, 오른쪽이 표지와 지금 곡을 보여 준다."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "설치"
+session_title   = "명령 하나, 그다음이 음악"
+session_shell   = "zsh"
+session_caption = "인터페이스 아이콘에 Nerd Font 가, 색에 24비트 지원 터미널이 필요하다."
+session_lines = [
+  { kind = "cmd",     text = "$ cargo install ommp" },
   { kind = "comment", text = "# Arch 라면:  yay -S ommp" },
-  { kind = "cmd", text = "$ ommp" },
+  { kind = "cmd",     text = "$ ommp" },
   { kind = "comment", text = "# ~/Music 을 읽고 하위 디렉터리까지 훑는다. 바뀌면 바로 따라간다" },
-  { kind = "out", text = "  Ctrl+S   검색        artist:  album:  genre:  *.flac" },
-  { kind = "out", text = "  Ctrl+H   키 목록" },
+  { kind = "out",     text = "  Ctrl+S   검색        artist:  album:  genre:  *.flac" },
+  { kind = "out",     text = "  Ctrl+H   키 목록" },
 ]
 
-# ---- 결정. 번호는 저자가 적지 않는다. 순서가 곧 번호다.
+cta_title = "~/Music 에 음악을 두고 실행하면 됩니다."
+cta_text  = "설정할 것이 없다. 앨범 아트는 파일 안의 태그에서 나온다."
+cta_actions = [
+  { label = "ommp.sqzer.com", url = "https://ommp.sqzer.com",          style = "primary" },
+  { label = "소스",           url = "https://github.com/sqzer-x/ommp", style = "ghost"   },
+]
 
-[[extra.register]]
+[[extra.features]]
 title = "여덟 가지 형식"
-body  = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC, WMA 를 PulseAudio 나 ALSA 로 낸다."
+desc  = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC, WMA 를 PulseAudio 나 ALSA 로 낸다."
 
-[[extra.register]]
+[[extra.features]]
 title = "앨범 아트를 실제로 그린다"
-body  = "Kitty 그래픽, Sixel, iTerm2 프로토콜 중 되는 것을 시작할 때 알아서 고른다. 아무것도 없으면 블록 문자로 떨어지는데, 그래도 표지는 보인다."
+desc  = "Kitty 그래픽, Sixel, iTerm2 프로토콜 중 되는 것을 시작할 때 알아서 고른다. 아무것도 없으면 블록 문자로 떨어지는데, 그래도 표지는 보인다."
 
-[[extra.register]]
+[[extra.features]]
 title = "생각하는 대로 훑는다"
-body  = "아티스트, 앨범, 장르, 형식, 디렉터리, 재생 목록. 위쪽 탭이 왼쪽 칸을 바꾼다."
+desc  = "아티스트, 앨범, 장르, 형식, 디렉터리, 재생 목록. 위쪽 탭이 왼쪽 칸을 바꾼다."
 
-[[extra.register]]
+[[extra.features]]
 title = "필드 검색"
-body  = "Ctrl+S 를 누르면 치는 대로 걸러진다. artist:radiohead, album:ok computer, genre:rock, *.flac."
+desc  = "Ctrl+S 를 누르면 치는 대로 걸러진다. artist:radiohead, album:ok computer, genre:rock, *.flac."
 
-[[extra.register]]
+[[extra.features]]
 title = "기억한다"
-body  = "음량과 재생 목록과 칸 배치가 다시 켜도 그대로고, 파일이 생기거나 사라지면 라이브러리가 따라간다."
+desc  = "음량과 재생 목록과 칸 배치가 다시 켜도 그대로고, 파일이 생기거나 사라지면 라이브러리가 따라간다."
 
-[[extra.register]]
+[[extra.features]]
 title = "마우스도 쓴다"
-body  = "누르고 굴리고 끌어서 칸 크기를 바꾼다. 키보드로도 전부 되고, Ctrl+H 가 목록을 보여 준다."
+desc  = "누르고 굴리고 끌어서 칸 크기를 바꾼다. 키보드로도 전부 되고, Ctrl+H 가 목록을 보여 준다."
 
 +++
 
diff --git a/content/projects/ommp.md b/content/projects/ommp.md
@@ -7,108 +7,79 @@ date = 2026-08-01
 [extra]
 tier = "landing"
 
-eyebrow = "Terminal music player · Rust"
-tagline = "Most terminal music players are a client for something else you have to start first. ommp is the whole thing: run it, and it plays. Quit it, and nothing is left running."
+eyebrow  = "Terminal music player · Rust"
+tagline  = "Most terminal music players are a client for something else you have to start first. ommp is the whole thing: run it, and it plays. Quit it, and nothing is left running."
+lang     = "Rust"
+license  = "Apache-2.0"
+version  = ""
+platform = "Linux"
+repo     = "https://github.com/sqzer-x/ommp"
+stars     = 0
+accent     = "#7fc0d4"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/ommp"
-stars = 0
-
-accent = "#7fc0d4"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
-  { label = "Website", url = "https://ommp.sqzer.com", style = "primary" },
-  { label = "Source", url = "https://github.com/sqzer-x/ommp", style = "ghost" },
+  { label = "Website", url = "https://ommp.sqzer.com",            style = "primary" },
+  { label = "Source",  url = "https://github.com/sqzer-x/ommp",   style = "ghost"   },
 ]
 
-# 결정 목록의 머리. 항목은 아래 [[extra.register]] 에 있다.
-register_eyebrow = "Capabilities"
-register_title   = "A library browser, a queue, and the artwork"
-register_intro   = "Three panels. The left browses, the centre queues, the right shows the cover and the track."
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = "Put your music in ~/Music and run it. There is nothing to configure — album art comes out of the tags in the files themselves."
-closing_links = [
-  { label = "ommp.sqzer.com", url = "https://ommp.sqzer.com" },
+facts = [
+  { value = "8",           label = "Audio formats" },
+  { value = "3",           label = "Image protocols" },
+  { value = "0",           label = "Daemons to start" },
+  { value = "Apache-2.0",  label = "License" },
 ]
 
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Rust"
-url = ""
-
-[[extra.spec]]
-key = "License"
-value = "Apache-2.0"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Linux"
-url = ""
-
-[[extra.spec]]
-key = "Audio formats"
-value = "FLAC · MP3 · M4A · OGG · WAV · Opus · AAC · WMA"
-url = ""
-
-[[extra.spec]]
-key = "Image protocols"
-value = "Kitty · Sixel · iTerm2"
-url = ""
-
-[[extra.spec]]
-key = "Daemons to start"
-value = "0"
-url = ""
-
-# ---- 세션. 주장 하나에 영수증 하나. 여러 개일 수 있다.
-
-[[extra.session]]
-eyebrow = "Install"
-title   = "One command, then your music"
-caption = "Needs a Nerd Font for the interface icons and a true-colour terminal."
-lines = [
-  { kind = "cmd", text = "$ cargo install ommp" },
+features_eyebrow = "Capabilities"
+features_title   = "A library browser, a queue, and the artwork"
+features_intro   = "Three panels. The left browses, the centre queues, the right shows the cover and the track."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "Install"
+session_title   = "One command, then your music"
+session_shell   = "zsh"
+session_caption = "Needs a Nerd Font for the interface icons and a true-colour terminal."
+session_lines = [
+  { kind = "cmd",     text = "$ cargo install ommp" },
   { kind = "comment", text = "# on Arch:  yay -S ommp" },
-  { kind = "cmd", text = "$ ommp" },
+  { kind = "cmd",     text = "$ ommp" },
   { kind = "comment", text = "# reads ~/Music, scans subdirectories, picks up changes as they happen" },
-  { kind = "out", text = "  Ctrl+S   search      artist:  album:  genre:  *.flac" },
-  { kind = "out", text = "  Ctrl+H   keybindings" },
+  { kind = "out",     text = "  Ctrl+S   search      artist:  album:  genre:  *.flac" },
+  { kind = "out",     text = "  Ctrl+H   keybindings" },
 ]
 
-# ---- 결정. 번호는 저자가 적지 않는다. 순서가 곧 번호다.
+cta_title = "Put your music in ~/Music and run it."
+cta_text  = "Nothing to configure. Album art comes out of the tags in the files themselves."
+cta_actions = [
+  { label = "ommp.sqzer.com", url = "https://ommp.sqzer.com",          style = "primary" },
+  { label = "Source",         url = "https://github.com/sqzer-x/ommp", style = "ghost"   },
+]
 
-[[extra.register]]
+[[extra.features]]
 title = "Eight formats"
-body  = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC and WMA, through PulseAudio or ALSA."
+desc  = "FLAC, MP3, M4A, OGG, WAV, Opus, AAC and WMA, through PulseAudio or ALSA."
 
-[[extra.register]]
+[[extra.features]]
 title = "Album art, actually drawn"
-body  = "Kitty graphics, Sixel or the iTerm2 protocol, detected at startup. Everywhere else it falls back to block characters, which still shows the cover."
+desc  = "Kitty graphics, Sixel or the iTerm2 protocol, detected at startup. Everywhere else it falls back to block characters, which still shows the cover."
 
-[[extra.register]]
+[[extra.features]]
 title = "Browse how you think"
-body  = "By artist, album, genre, format, directory or playlist. The tabs across the top switch the left panel."
+desc  = "By artist, album, genre, format, directory or playlist. The tabs across the top switch the left panel."
 
-[[extra.register]]
+[[extra.features]]
 title = "Field search"
-body  = "Ctrl+S filters as you type. artist:radiohead, album:ok computer, genre:rock, or *.flac."
+desc  = "Ctrl+S filters as you type. artist:radiohead, album:ok computer, genre:rock, or *.flac."
 
-[[extra.register]]
+[[extra.features]]
 title = "It remembers"
-body  = "Volume, playlists and pane layout survive a restart, and the library re-syncs when files appear or vanish."
+desc  = "Volume, playlists and pane layout survive a restart, and the library re-syncs when files appear or vanish."
 
-[[extra.register]]
+[[extra.features]]
 title = "Mouse, if you want it"
-body  = "Click, scroll, and drag the panes to resize. The keyboard does everything too — Ctrl+H lists the bindings."
+desc  = "Click, scroll, and drag the panes to resize. The keyboard does everything too — Ctrl+H lists the bindings."
 
 +++
 
diff --git a/content/projects/sqzass.ko.md b/content/projects/sqzass.ko.md
@@ -7,108 +7,79 @@ date = 2026-07-31
 [extra]
 tier = "landing"
 
-eyebrow = "정적 사이트 생성기 · Rust"
-tagline = "대부분의 생성기는 예쁜 URL 과 리다이렉트, 캐시 헤더를 호스트에 맡긴다. sqzass 는 그 일을 직접 한다. 그래서 같은 출력이 GitHub Pages 에서도, Vercel 에서도, 평범한 HTTP 로 디렉터리를 내주는 서버에서도 똑같이 맞다."
+eyebrow  = "정적 사이트 생성기 · Rust"
+tagline  = "대부분의 생성기는 예쁜 URL 과 리다이렉트, 캐시 헤더를 호스트에 맡긴다. sqzass 는 그 일을 직접 한다. 그래서 같은 출력이 GitHub Pages 에서도, Vercel 에서도, 평범한 HTTP 로 디렉터리를 내주는 서버에서도 똑같이 맞다."
+lang     = "Rust"
+license  = "MIT"
+version  = "0.1.0"
+platform = "Linux · macOS"
+repo     = "https://github.com/sqzer-x/sqzass"
+stars     = 0
+accent     = "#e0a06a"
+mark     = "images/projects/sqzass.png"
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/sqzass"
-stars = 0
-
-accent = "#e0a06a"
-mark   = "images/projects/sqzass.png"
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
-  { label = "문서", url = "https://sqzass.sqzer.com", style = "primary" },
-  { label = "소스", url = "https://github.com/sqzer-x/sqzass", style = "ghost" },
+  { label = "문서", url = "https://sqzass.sqzer.com",          style = "primary" },
+  { label = "소스", url = "https://github.com/sqzer-x/sqzass", style = "ghost"   },
 ]
 
-# 결정 목록의 머리. 항목은 아래 [[extra.register]] 에 있다.
-register_eyebrow = "설계"
-register_title   = "여섯 개의 결정과 각각의 근거"
-register_intro   = "취향이 아니다. 하나하나가 다른 데서 먼저 일어난 사고다."
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = "문서의 빠른 시작부터 보면 된다. 나머지는 레퍼런스이고, 이 사이트와 같은 두 언어로 쓰여 있다."
-closing_links = [
-  { label = "sqzass.sqzer.com", url = "https://sqzass.sqzer.com" },
+facts = [
+  { value = "25 ms",  label = "1,000 페이지" },
+  { value = "0",      label = "런타임 의존성" },
+  { value = "0.1.0",  label = "버전" },
+  { value = "MIT",    label = "라이선스" },
 ]
 
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Rust"
-url = ""
-
-[[extra.spec]]
-key = "License"
-value = "MIT"
-url = ""
-
-[[extra.spec]]
-key = "Version"
-value = "0.1.0"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Linux · macOS"
-url = ""
-
-[[extra.spec]]
-key = "Build"
-value = "1,000 페이지에 25 ms"
-url = ""
-
-[[extra.spec]]
-key = "Runtime dependencies"
-value = "0"
-url = ""
-
-# ---- 세션. 주장 하나에 영수증 하나. 여러 개일 수 있다.
-
-[[extra.session]]
-eyebrow = "빠른 시작"
-title   = "파일 세 개, 그다음이 사이트"
-caption = "실제 출력이다. 이 레포의 문서 사이트가 60 페이지다."
-lines = [
-  { kind = "cmd", text = "$ sqzass init mysite" },
+features_eyebrow = "설계"
+features_title   = "여섯 개의 결정과 각각의 근거"
+features_intro   = "취향이 아니다. 하나하나가 다른 데서 먼저 일어난 사고다."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "빠른 시작"
+session_title   = "파일 세 개, 그다음이 사이트"
+session_shell   = "zsh"
+session_caption = "실제 출력이다. 이 레포의 문서 사이트가 60 페이지다."
+session_lines = [
+  { kind = "cmd",     text = "$ sqzass init mysite" },
   { kind = "comment", text = "# sqzass.toml, content/_index.md, templates/page.html 을 쓴다" },
-  { kind = "cmd", text = "$ sqzass serve -i mysite" },
-  { kind = "out", text = "  http://127.0.0.1:3000   라이브 리로드" },
-  { kind = "cmd", text = "$ sqzass build -i docs" },
-  { kind = "out", text = "  60 pages → docs/public" },
+  { kind = "cmd",     text = "$ sqzass serve -i mysite" },
+  { kind = "out",     text = "  http://127.0.0.1:3000   라이브 리로드" },
+  { kind = "cmd",     text = "$ sqzass build -i docs" },
+  { kind = "out",     text = "  60 pages → docs/public" },
 ]
 
-# ---- 결정. 번호는 저자가 적지 않는다. 순서가 곧 번호다.
+cta_title = "문서를 읽어 보세요."
+cta_text  = "같은 레포의 docs 디렉터리에서 sqzass 로 만들어진다. CI 가 방금 컴파일한 바이너리로 그 문서를 빌드한다."
+cta_actions = [
+  { label = "sqzass.sqzer.com", url = "https://sqzass.sqzer.com",          style = "primary" },
+  { label = "소스",             url = "https://github.com/sqzer-x/sqzass", style = "ghost"   },
+]
 
-[[extra.register]]
+[[extra.features]]
 title = "깨진 참조는 빌드를 세운다"
-body  = "풀리지 않는 링크, 없는 템플릿, 같은 URL 을 주장하는 두 페이지, 오타 난 설정 키. 전부 경고가 아니라 에러다."
+desc  = "풀리지 않는 링크, 없는 템플릿, 같은 URL 을 주장하는 두 페이지, 오타 난 설정 키. 전부 경고가 아니라 에러다."
 
-[[extra.register]]
+[[extra.features]]
 title = "마크다운은 AST 에서 다룬다"
-body  = "링크 재작성, 제목 앵커, 목차가 전부 트리 연산이다. 완성된 HTML 에 정규식을 돌리면 작은따옴표로 감싼 속성을 조용히 건너뛴다."
+desc  = "링크 재작성, 제목 앵커, 목차가 전부 트리 연산이다. 완성된 HTML 에 정규식을 돌리면 작은따옴표로 감싼 속성을 조용히 건너뛴다."
 
-[[extra.register]]
+[[extra.features]]
 title = "바이트까지 같은 빌드"
-body  = "같은 입력을 두 번 빌드하면 같은 바이트가 나오고, CI 가 푸시마다 확인한다. 렌더는 병렬이고, 결정성은 일정이 아니라 병합이 만든다."
+desc  = "같은 입력을 두 번 빌드하면 같은 바이트가 나오고, CI 가 푸시마다 확인한다. 렌더는 병렬이고, 결정성은 일정이 아니라 병합이 만든다."
 
-[[extra.register]]
+[[extra.features]]
 title = "한국어는 나중에 얹은 것이 아니다"
-body  = "번역이 원문 옆에 있고, 링크는 읽는 사람의 언어로 풀리며, 검색은 부분 문자열로 맞춘다. 검색엔진최적화 안의 최적화를 찾는 방법은 그것뿐이다."
+desc  = "번역이 원문 옆에 있고, 링크는 읽는 사람의 언어로 풀리며, 검색은 부분 문자열로 맞춘다. 검색엔진최적화 안의 최적화를 찾는 방법은 그것뿐이다."
 
-[[extra.register]]
+[[extra.features]]
 title = "인라인 스타일 대신 클래스"
-body  = "구문 강조가 클래스 이름을 낸다. 인라인 색은 테마 하나를 모든 문서에 영원히 박아 넣고, 엄격한 style-src 를 불가능하게 만든다."
+desc  = "구문 강조가 클래스 이름을 낸다. 인라인 색은 테마 하나를 모든 문서에 영원히 박아 넣고, 엄격한 style-src 를 불가능하게 만든다."
 
-[[extra.register]]
+[[extra.features]]
 title = "바이너리 하나, 런타임 없음"
-body  = "Node 도 Python 도 시스템 라이브러리도 필요 없다. 내려받은 것이 곧 도는 것이다."
+desc  = "Node 도 Python 도 시스템 라이브러리도 필요 없다. 내려받은 것이 곧 도는 것이다."
 
 +++
 
diff --git a/content/projects/sqzass.md b/content/projects/sqzass.md
@@ -7,108 +7,80 @@ date = 2026-07-31
 [extra]
 tier = "landing"
 
-eyebrow = "Static site generator · Rust"
-tagline = "Most generators lean on their host for pretty URLs, redirects and cache headers. sqzass does that work itself, so the same output is correct on GitHub Pages, on Vercel, or in a directory served over plain HTTP."
+eyebrow  = "Static site generator · Rust"
+tagline  = "Most generators lean on their host for pretty URLs, redirects and cache headers. sqzass does that work itself, so the same output is correct on GitHub Pages, on Vercel, or in a directory served over plain HTTP."
+lang     = "Rust"
+license  = "MIT"
+version  = "0.1.0"
+platform = "Linux · macOS"
+repo     = "https://github.com/sqzer-x/sqzass"
+stars     = 0
+accent     = "#e0a06a"
+mark     = "images/projects/sqzass.png"
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/sqzass"
-stars = 0
-
-accent = "#e0a06a"
-mark   = "images/projects/sqzass.png"
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
-  { label = "Documentation", url = "https://sqzass.sqzer.com", style = "primary" },
-  { label = "Source", url = "https://github.com/sqzer-x/sqzass", style = "ghost" },
+  { label = "Documentation", url = "https://sqzass.sqzer.com",                 style = "primary" },
+  { label = "Source",        url = "https://github.com/sqzer-x/sqzass",        style = "ghost"   },
 ]
 
-# 결정 목록의 머리. 항목은 아래 [[extra.register]] 에 있다.
-register_eyebrow = "Design"
-register_title   = "Six decisions, and the argument for each"
-register_intro   = "None of these are preferences. Each one is a failure that happened somewhere else first."
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = "Start with the quickstart in the documentation. The rest of it is reference, and it is written in the same two languages this site is."
-closing_links = [
-  { label = "sqzass.sqzer.com", url = "https://sqzass.sqzer.com" },
+# 별 개수 대신 확인할 수 있는 사실. Rust 도구를 보러 온 사람이 실제로 궁금해하는 것들이다.
+facts = [
+  { value = "25 ms",  label = "1,000 pages" },
+  { value = "0",      label = "Runtime deps" },
+  { value = "0.1.0",  label = "Version" },
+  { value = "MIT",    label = "License" },
 ]
 
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Rust"
-url = ""
-
-[[extra.spec]]
-key = "License"
-value = "MIT"
-url = ""
-
-[[extra.spec]]
-key = "Version"
-value = "0.1.0"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Linux · macOS"
-url = ""
-
-[[extra.spec]]
-key = "Build"
-value = "25 ms for 1,000 pages"
-url = ""
-
-[[extra.spec]]
-key = "Runtime dependencies"
-value = "0"
-url = ""
-
-# ---- 세션. 주장 하나에 영수증 하나. 여러 개일 수 있다.
-
-[[extra.session]]
-eyebrow = "Quickstart"
-title   = "Three files, then a site"
-caption = "Real output. The documentation site in this repository is 60 pages."
-lines = [
-  { kind = "cmd", text = "$ sqzass init mysite" },
+features_eyebrow = "Design"
+features_title   = "Six decisions, and the argument for each"
+features_intro   = "None of these are preferences. Each one is a failure that happened somewhere else first."
+# 항목은 아래 [[extra.features]] 에 있다. 인라인 테이블은 TOML 1.0 에서
+# 한 줄을 넘지 못하고, 이 설명들은 한 줄에 들어가지 않는다.
+
+session_eyebrow = "Quickstart"
+session_title   = "Three files, then a site"
+session_shell   = "zsh"
+session_caption = "Real output. The documentation site in this repository is 60 pages."
+session_lines = [
+  { kind = "cmd",     text = "$ sqzass init mysite" },
   { kind = "comment", text = "# writes sqzass.toml, content/_index.md, templates/page.html" },
-  { kind = "cmd", text = "$ sqzass serve -i mysite" },
-  { kind = "out", text = "  http://127.0.0.1:3000   live reload" },
-  { kind = "cmd", text = "$ sqzass build -i docs" },
-  { kind = "out", text = "  60 pages → docs/public" },
+  { kind = "cmd",     text = "$ sqzass serve -i mysite" },
+  { kind = "out",     text = "  http://127.0.0.1:3000   live reload" },
+  { kind = "cmd",     text = "$ sqzass build -i docs" },
+  { kind = "out",     text = "  60 pages → docs/public" },
 ]
 
-# ---- 결정. 번호는 저자가 적지 않는다. 순서가 곧 번호다.
+cta_title = "Read the documentation."
+cta_text  = "Built with sqzass, from the docs directory of the same repository. CI builds it with the binary it just compiled."
+cta_actions = [
+  { label = "sqzass.sqzer.com", url = "https://sqzass.sqzer.com",          style = "primary" },
+  { label = "Source",           url = "https://github.com/sqzer-x/sqzass", style = "ghost"   },
+]
 
-[[extra.register]]
+[[extra.features]]
 title = "Broken references stop the build"
-body  = "An unresolved link, a missing template, two pages claiming the same URL, a misspelled config key — every one is an error, not a warning you scroll past."
+desc  = "An unresolved link, a missing template, two pages claiming the same URL, a misspelled config key — every one is an error, not a warning you scroll past."
 
-[[extra.register]]
+[[extra.features]]
 title = "Markdown on the AST"
-body  = "Link rewriting, heading anchors and the table of contents are tree operations. A regex over finished HTML silently skips any attribute that is single-quoted."
+desc  = "Link rewriting, heading anchors and the table of contents are tree operations. A regex over finished HTML silently skips any attribute that is single-quoted."
 
-[[extra.register]]
+[[extra.features]]
 title = "Byte-identical builds"
-body  = "Two builds of the same input produce the same bytes, and CI checks it on every push. Rendering is parallel; determinism comes from the merge, not the schedule."
+desc  = "Two builds of the same input produce the same bytes, and CI checks it on every push. Rendering is parallel; determinism comes from the merge, not the schedule."
 
-[[extra.register]]
+[[extra.features]]
 title = "Korean was not bolted on"
-body  = "Translations sit beside their originals, links resolve to the reader's language, and search matches substrings — the only way 최적화 inside 검색엔진최적화 is ever found."
+desc  = "Translations sit beside their originals, links resolve to the reader's language, and search matches substrings — the only way 최적화 inside 검색엔진최적화 is ever found."
 
-[[extra.register]]
+[[extra.features]]
 title = "Classes, never inline styles"
-body  = "Highlighting emits class names. Inline colours pin one theme into every document forever and put a strict style-src out of reach."
+desc  = "Highlighting emits class names. Inline colours pin one theme into every document forever and put a strict style-src out of reach."
 
-[[extra.register]]
+[[extra.features]]
 title = "One binary, no runtime"
-body  = "No Node, no Python, no system libraries. The thing you download is the thing that runs."
+desc  = "No Node, no Python, no system libraries. The thing you download is the thing that runs."
 
 +++
 
diff --git a/content/projects/uaf.ko.md b/content/projects/uaf.ko.md
@@ -7,63 +7,43 @@ date = 2026-02-14
 [extra]
 tier = "brief"
 
-eyebrow = "무선 AP 감사 · Python"
-tagline = "PCI-DSS 는 무선 점검을 요구한다. 그 요구를 솔직하게 옮기면 이런 질문이 된다. 이 AP 들 중 어느 것이 우리 것이고, 어느 것이 열려 있는가."
+eyebrow  = "무선 AP 감사 · Python"
+tagline  = "PCI-DSS 는 무선 점검을 요구한다. 그 요구를 솔직하게 옮기면 이런 질문이 된다. 이 AP 들 중 어느 것이 우리 것이고, 어느 것이 열려 있는가."
+lang     = "Python"
+license  = ""
+version  = ""
+platform = "Linux"
+repo     = "https://github.com/sqzer-x/uaf"
+stars     = 0
+accent     = "#9aa6cc"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/uaf"
-stars = 0
-
-accent = "#9aa6cc"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "소스", url = "https://github.com/sqzer-x/uaf", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Python 3.10+"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Linux"
-url = ""
-
-[[extra.spec]]
-key = "Bands"
-value = "2.4 · 5 · 6 GHz"
-url = ""
+facts = [
+  { value = "3",     label = "탐지 대역" },
+  { value = "5",     label = "보안 분류" },
+  { value = "3.10+", label = "Python" },
+  { value = "root",  label = "권한" },
+]
 
-[[extra.spec]]
-key = "Security classes"
-value = "WEP · WPA · WPA2 · WPA3 · OPN"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "Privileges"
-value = "root"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 스캔 도구는 대개 SSID 목록에서 멈춘다. 감사 보고서에는 그것만으로 부족하다.
diff --git a/content/projects/uaf.md b/content/projects/uaf.md
@@ -7,63 +7,43 @@ date = 2026-02-14
 [extra]
 tier = "brief"
 
-eyebrow = "Wireless AP audit · Python"
-tagline = "PCI-DSS asks for a wireless assessment, and the honest version of that question is: which of these access points are ours, and which of them are open?"
+eyebrow  = "Wireless AP audit · Python"
+tagline  = "PCI-DSS asks for a wireless assessment, and the honest version of that question is: which of these access points are ours, and which of them are open?"
+lang     = "Python"
+license  = ""
+version  = ""
+platform = "Linux"
+repo     = "https://github.com/sqzer-x/uaf"
+stars     = 0
+accent     = "#9aa6cc"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/uaf"
-stars = 0
-
-accent = "#9aa6cc"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "Source", url = "https://github.com/sqzer-x/uaf", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Python 3.10+"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Linux"
-url = ""
-
-[[extra.spec]]
-key = "Bands"
-value = "2.4 · 5 · 6 GHz"
-url = ""
+facts = [
+  { value = "3",     label = "Bands scanned" },
+  { value = "5",     label = "Security classes" },
+  { value = "3.10+", label = "Python" },
+  { value = "root",  label = "Privileges" },
+]
 
-[[extra.spec]]
-key = "Security classes"
-value = "WEP · WPA · WPA2 · WPA3 · OPN"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "Privileges"
-value = "root"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 Scanning tools tend to stop at a list of SSIDs. An audit report needs more than
diff --git a/content/projects/udfx.ko.md b/content/projects/udfx.ko.md
@@ -7,63 +7,43 @@ date = 2026-02-06
 [extra]
 tier = "brief"
 
-eyebrow = "Active Directory DNS · PowerShell"
-tagline = "망이 끊긴 윈도우 환경에서 DNS 영역이 곧 지도이고, 그 지도는 이미 Active Directory 가 들고 있다. 이 도구는 그걸 직접 달라고 한다."
+eyebrow  = "Active Directory DNS · PowerShell"
+tagline  = "망이 끊긴 윈도우 환경에서 DNS 영역이 곧 지도이고, 그 지도는 이미 Active Directory 가 들고 있다. 이 도구는 그걸 직접 달라고 한다."
+lang     = "PowerShell"
+license  = ""
+version  = ""
+platform = "Windows"
+repo     = "https://github.com/sqzer-x/udfx"
+stars     = 0
+accent     = "#c2a06e"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/udfx"
-stars = 0
-
-accent = "#c2a06e"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "소스", url = "https://github.com/sqzer-x/udfx", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "PowerShell 5.1+"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Windows"
-url = ""
-
-[[extra.spec]]
-key = "Method"
-value = "LDAP"
-url = ""
+facts = [
+  { value = "LDAP",        label = "조회 방식" },
+  { value = "5.1+",        label = "PowerShell" },
+  { value = "도메인 사용자", label = "권한" },
+  { value = "4",           label = "출력 파일" },
+]
 
-[[extra.spec]]
-key = "Privileges"
-value = "도메인 사용자"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "Output files"
-value = "4"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 AD 통합 DNS 에 등록된 모든 도메인과 서브도메인을 LDAP 으로 읽는다. 인터넷도,
diff --git a/content/projects/udfx.md b/content/projects/udfx.md
@@ -7,63 +7,43 @@ date = 2026-02-06
 [extra]
 tier = "brief"
 
-eyebrow = "Active Directory DNS · PowerShell"
-tagline = "In an air-gapped Windows estate the DNS zone is the map, and Active Directory is already holding it. This asks for it directly."
+eyebrow  = "Active Directory DNS · PowerShell"
+tagline  = "In an air-gapped Windows estate the DNS zone is the map, and Active Directory is already holding it. This asks for it directly."
+lang     = "PowerShell"
+license  = ""
+version  = ""
+platform = "Windows"
+repo     = "https://github.com/sqzer-x/udfx"
+stars     = 0
+accent     = "#c2a06e"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/udfx"
-stars = 0
-
-accent = "#c2a06e"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "Source", url = "https://github.com/sqzer-x/udfx", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "PowerShell 5.1+"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Windows"
-url = ""
-
-[[extra.spec]]
-key = "Method"
-value = "LDAP"
-url = ""
+facts = [
+  { value = "LDAP",        label = "Method" },
+  { value = "5.1+",        label = "PowerShell" },
+  { value = "Domain user", label = "Privileges" },
+  { value = "4",           label = "Output files" },
+]
 
-[[extra.spec]]
-key = "Privileges"
-value = "Domain user"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "Output files"
-value = "4"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 Every domain and subdomain registered in AD-integrated DNS, read over LDAP. No
diff --git a/content/projects/usfx.ko.md b/content/projects/usfx.ko.md
@@ -7,68 +7,43 @@ date = 2026-01-20
 [extra]
 tier = "brief"
 
-eyebrow = "서브도메인 탐색 · Python"
-tagline = "이름난 서브도메인 도구는 하나같이 인터넷의 어떤 서비스를 부르는 것으로 시작한다. 내부망에서는 그게 정확히 없는 것이다."
+eyebrow  = "서브도메인 탐색 · Python"
+tagline  = "이름난 서브도메인 도구는 하나같이 인터넷의 어떤 서비스를 부르는 것으로 시작한다. 내부망에서는 그게 정확히 없는 것이다."
+lang     = "Python"
+license  = ""
+version  = "1.2.0"
+platform = "크로스 플랫폼"
+repo     = "https://github.com/sqzer-x/usfx"
+stars     = 1
+accent     = "#8fb9a2"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/usfx"
-stars = 1
-
-accent = "#8fb9a2"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "소스", url = "https://github.com/sqzer-x/usfx", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Python"
-url = ""
-
-[[extra.spec]]
-key = "Version"
-value = "1.2.0"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "크로스 플랫폼"
-url = ""
-
-[[extra.spec]]
-key = "Modules"
-value = "12"
-url = ""
+facts = [
+  { value = "12",     label = "모듈" },
+  { value = "18,000", label = "최대 워드리스트" },
+  { value = "0",      label = "외부 서비스" },
+  { value = "1.2.0",  label = "버전" },
+]
 
-[[extra.spec]]
-key = "Largest wordlist"
-value = "18,000"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "External services"
-value = "0"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 내부 DNS 서버를 알려 주면 거기서부터 시작한다. 기법은 열두 가지이고 전부
diff --git a/content/projects/usfx.md b/content/projects/usfx.md
@@ -7,68 +7,43 @@ date = 2026-01-20
 [extra]
 tier = "brief"
 
-eyebrow = "Subdomain discovery · Python"
-tagline = "Every well-known subdomain tool starts by querying a service on the internet. On an internal network that is exactly the thing you do not have."
+eyebrow  = "Subdomain discovery · Python"
+tagline  = "Every well-known subdomain tool starts by querying a service on the internet. On an internal network that is exactly the thing you do not have."
+lang     = "Python"
+license  = ""
+version  = "1.2.0"
+platform = "Cross-platform"
+repo     = "https://github.com/sqzer-x/usfx"
+stars     = 1
+accent     = "#8fb9a2"
+mark     = ""
+logo     = ""
 
-# 저장소와 별 줄은 template 이 이 두 값에서 만든다. tools/stars.py 가 고쳐
-# 쓰는 것이 이 짝이라, 스펙 표에 손으로 적으면 조용히 낡는다. 0 이어도 낸다.
-repo  = "https://github.com/sqzer-x/usfx"
-stars = 1
-
-accent = "#8fb9a2"
-mark   = ""
-logo   = ""
-
-# 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다.
 actions = [
   { label = "Source", url = "https://github.com/sqzer-x/usfx", style = "ghost" },
 ]
 
-# 브리프에는 세션도 결정 목록도 없다. 빈 배열이 이미 '없음'이라
-# 플래그를 따로 두지 않는다 — 템플릿이 데이터를 보고 정한다.
-register_eyebrow = ""
-register_title   = ""
-register_intro   = ""
-register = []
-session  = []
-
-# 닫는 줄. 제목도 띠도 두 번째 버튼 줄도 없다 — 한 문장과 갈 곳뿐이다.
-closing_text  = ""
-closing_links = []
-
-# ---- 스펙. 배지 줄과 큰 숫자 띠를 대신한다. 순서가 곧 화면 순서다.
-# 키는 라벨이라 두 언어에서 영어다. url 이 비면 링크가 아니라 글자다.
-
-[[extra.spec]]
-key = "Language"
-value = "Python"
-url = ""
-
-[[extra.spec]]
-key = "Version"
-value = "1.2.0"
-url = ""
-
-[[extra.spec]]
-key = "Platform"
-value = "Cross-platform"
-url = ""
-
-[[extra.spec]]
-key = "Modules"
-value = "12"
-url = ""
+facts = [
+  { value = "12",     label = "Modules" },
+  { value = "18,000", label = "Largest wordlist" },
+  { value = "0",      label = "External services" },
+  { value = "1.2.0",  label = "Version" },
+]
 
-[[extra.spec]]
-key = "Largest wordlist"
-value = "18,000"
-url = ""
+features_eyebrow = ""
+features_title   = ""
+features_intro   = ""
+features = []
 
-[[extra.spec]]
-key = "External services"
-value = "0"
-url = ""
+session_eyebrow = ""
+session_title   = ""
+session_shell   = ""
+session_caption = ""
+session_lines = []
 
+cta_title = ""
+cta_text  = ""
+cta_actions = []
 +++
 
 Point it at an internal DNS server and it works from there. Twelve techniques,
diff --git a/i18n/en.toml b/i18n/en.toml
@@ -35,7 +35,6 @@ website      = "Website"
 source       = "Source"
 docs         = "Documentation"
 repository   = "Repository"
-stars        = "Stars"
 status       = "Status"
 language     = "Language"
 archived     = "Archived"
diff --git a/i18n/ko.toml b/i18n/ko.toml
@@ -41,7 +41,6 @@ website      = "Website"
 source       = "Source"
 docs         = "Documentation"
 repository   = "Repository"
-stars        = "Stars"
 status       = "Status"
 language     = "Language"
 archived     = "Archived"
diff --git a/static/css/main.css b/static/css/main.css
@@ -358,12 +358,6 @@ main { display: block; padding-bottom: clamp(5rem, 12vw, 9rem); }
   line-height: 1.14;
   text-wrap: balance;
 }
-/* 이름 앞의 마크는 글자다. inline-grid 라 제목이 줄바꿈될 때 같이 흐르고,
-   em 단위라 등급에 따라 제목이 작아지면 마크도 따라 작아진다. */
-.page-title .chip-mark {
-  display: inline-grid; width: 1.05em; height: 1.05em;
-  vertical-align: -0.16em; margin-right: 0.34em;
-}
 
 .page-lede {
   margin: clamp(0.9rem, 2vw, 1.4rem) auto 0;
@@ -660,10 +654,8 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
 }
 .ledger-year:first-child { margin-top: 0; }
 
-/* 행 사이에 선이 없다. 간격과 빛이 나눈다.
-   프로젝트 페이지의 결정 목록(.register)이 같은 장치를 쓴다 — 좁은 표지 열과
-   넓은 본문 열. 한 규칙에 같이 두어야 한쪽 폭을 고칠 때 다른 쪽이 안 어긋난다. */
-.ledger-link, .register li {
+/* 행 사이에 선이 없다. 간격과 빛이 나눈다. */
+.ledger-link {
   position: relative; display: grid;
   grid-template-columns: clamp(5rem, 11vw, 8rem) minmax(0, 1fr);
   align-items: baseline; gap: clamp(1rem, 2.5vw, 2rem);
@@ -697,7 +689,7 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
 }
 
 @media (max-width: 40rem) {
-  .ledger-link, .register li { grid-template-columns: 1fr; gap: 0.5rem; padding-inline: 0; margin-inline: 0; }
+  .ledger-link { grid-template-columns: 1fr; gap: 0.5rem; padding-inline: 0; margin-inline: 0; }
 }
 
 /* 홈의 맨 위 글 — 액자와 같은 문법, 더 크게. */
@@ -735,29 +727,113 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
 .btn-text { border-color: transparent; padding-inline: 0.35rem; }
 .btn-text:hover { color: var(--accent); }
 
-/* ---------- project detail ---------- */
+.back {
+  display: inline-flex; align-items: center; gap: 0.45rem;
+  margin-top: 2rem;
+  font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase;
+  color: var(--ink-4);
+  transition: color var(--tr-ink);
+}
+.back:hover { color: var(--ink-2); }
 
-/* 프로젝트 한 장은 랜딩 페이지가 아니라 소프트웨어에 대한 문서다. 순서가 그렇게
-   말한다 — 명패, 펜스, 스펙, 주장, 영수증, 들어가는 길. 이 자리 전용으로 만든
-   부품은 없다: .page-head, .fence, .prose, 원장 격자, .prose 의 표를 그대로
-   쓴다. 새 물건이 필요해지는 프로젝트가 나타나면 그때 만든다.
+/* ---------- project detail ---------- */
 
-   프로젝트마다 자기 색조를 갖는다. 전역 --accent 를 이 페이지 안에서만 덮으므로
-   링크·표지 번호·포커스가 한꺼번에 따라간다. 값이 없는 프로젝트는 인라인 style
-   이 아예 안 붙어서 사이트 기본값을 쓴다. 대비 범위는 좁게 잡는다 — 중간 명도,
-   낮은 채도. 바닥 위에서 읽히면서도 페이지를 자기 색으로 물들이지는 않는 띠다. */
+/* 프로젝트마다 자기 색조를 갖는다. 전역 --accent 를 이 페이지 안에서만
+   덮으므로 eyebrow 대시·특징 번호·링크·포커스가 한꺼번에 따라간다. 값이 없는
+   프로젝트는 인라인 style 이 아예 안 붙어서 사이트 기본값을 쓴다.
+   대비 범위는 좁게 잡는다 — 중간 명도, 낮은 채도. 니어블랙 위에서 읽히면서도
+   페이지를 자기 색으로 물들이지는 않는 띠다. */
 .project { padding-bottom: clamp(3rem, 8vw, 6rem); }
 
-/* 등급 차이는 순서와 폭이다. 랜딩은 스펙을 먼저 놓고(자기를 설명하는 규격서다)
-   세션과 결정 목록에서 두 번 52rem 으로 벌어진다. 브리프는 글을 먼저 놓고
-   펜스부터 끝까지 읽는 폭 안에 있다 — 스펙이 각주로 뒤에 붙는 글이다.
-   폭이 등급을 말하므로 두 화면이면 세어 보지 않고도 어느 쪽인지 안다.
-   제목도 한 단계 작다. 그러지 않으면 "같은 페이지, 더 짧은 것"이다. */
-.project[data-tier="brief"] .page-title { font-size: clamp(1.7rem, 1.35rem + 1.4vw, 2.4rem); }
+/* 브리프는 읽는 폭 안에 살고 제목도 한 단계 작다. 등급 차이가 열 개수가
+   아니라 타이포로 보여야 한다 — 그러지 않으면 "같은 페이지, 더 짧은 것"이다. */
+.project[data-tier="brief"] .hero-name { font-size: clamp(2rem, 1.6rem + 1.6vw, 2.8rem); }
+.project[data-tier="brief"] .hero { border-bottom: 0; padding-bottom: clamp(1.5rem, 3vw, 2rem); }
+
+.hero {
+  display: grid; grid-template-columns: 1.35fr 1fr;
+  gap: clamp(1.5rem, 5vw, 4rem); align-items: center;
+  padding-block: clamp(2rem, 5vw, 3.5rem) clamp(2.5rem, 6vw, 4rem);
+  border-bottom: 1px solid var(--rule);
+}
+.hero-main { min-width: 0; }
+
+/* eyebrow 앞의 액센트 대시. 제품 명패의 시작을 표시한다. */
+.hero .eyebrow { display: flex; align-items: center; gap: 0.75rem; }
+.hero .eyebrow::before {
+  content: ""; width: 1.75rem; height: 1px; background: var(--accent); flex: none;
+}
+
+.hero-name {
+  display: flex; align-items: center; gap: 0.6rem; margin: 0;
+  font-size: clamp(2.6rem, 1.9rem + 2.6vw, 3.8rem);
+  font-weight: var(--w-display); letter-spacing: var(--track-air); line-height: 1.05;
+}
+.hero-name .chip-mark { width: 1.05em; height: 1.05em; }
+
+.hero-tagline { margin: 1.1rem 0 0; max-width: 46ch; color: var(--ink-2); text-wrap: pretty; }
+
+.badges { display: flex; flex-wrap: wrap; gap: 1.25rem; margin: 1.5rem 0 0; padding: 0; list-style: none; }
+.badges li {
+  font-family: var(--mono); font-size: 0.66rem;
+  letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-4);
+}
 
-/* 연 사이는 여백이 나눈다. 원장이 행 사이에 선을 안 긋는 것과 같은 이유다 —
-   선을 그으면 읽는 물건이 아니라 채워 넣은 표가 된다. */
-.block { padding-block: clamp(2.5rem, 6vw, 4rem); }
+.hero .btn-row { justify-content: flex-start; }
+
+.hero-aside { display: grid; place-items: center; }
+.medallion {
+  display: grid; place-items: center;
+  width: min(100%, 20rem); aspect-ratio: 1;
+  border: 1px solid var(--rule); border-radius: var(--radius);
+  background: var(--bg-2); overflow: hidden;
+}
+.medallion img { width: 76%; height: 76%; object-fit: contain; }
+.medallion-mark::before {
+  content: ""; width: 72%; height: 72%;
+  background-color: var(--mark-ink);
+  -webkit-mask: var(--mark-src) center / contain no-repeat;
+  mask: var(--mark-src) center / contain no-repeat;
+}
+
+@media (max-width: 52rem) {
+  .hero { grid-template-columns: minmax(0, 1fr); text-align: center; }
+  .hero .eyebrow, .hero-name, .hero .btn-row { justify-content: center; }
+  .hero-tagline, .badges { margin-inline: auto; justify-content: center; }
+  /* 좁을 때 옆칸은 지우지 않고 위로 올린다. 지우면 그 프로젝트만 그림이 없는
+     것처럼 보인다. */
+  .hero-aside { order: -1; }
+  .medallion { width: min(60%, 13rem); }
+}
+
+/* ---------- facts ---------- */
+
+.facts {
+  display: flex; flex-wrap: wrap; justify-content: center;
+  margin-block: clamp(2rem, 5vw, 3rem);
+  padding-block: clamp(1.5rem, 3vw, 2rem);
+  border-bottom: 1px solid var(--rule);
+}
+.fact {
+  flex: 1 1 9rem; display: flex; flex-direction: column; gap: 0.3rem;
+  align-items: center; text-align: center; padding-inline: 1rem;
+  border-left: 1px solid var(--rule);
+}
+.fact:first-child { border-left: 0; }
+.fact-value {
+  font-size: clamp(1.9rem, 1.5rem + 1.6vw, 2.9rem);
+  font-weight: var(--w-display); letter-spacing: 0.005em; line-height: 1;
+  font-variant-numeric: tabular-nums;
+}
+.fact-label {
+  font-size: 0.64rem; letter-spacing: 0.22em; text-transform: uppercase;
+  color: var(--ink-4);
+}
+
+/* ---------- lattice ---------- */
+
+.block { padding-block: clamp(2.5rem, 6vw, 4rem); border-bottom: 1px solid var(--rule); }
+.block:last-child { border-bottom: 0; }
 .block-head { margin-bottom: clamp(1.75rem, 4vw, 2.5rem); text-align: center; }
 .block-title {
   margin: 0; font-size: clamp(1.5rem, 1.25rem + 1.1vw, 2.1rem);
@@ -765,38 +841,59 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
 }
 .block-intro { margin: 0.8rem auto 0; max-width: 56ch; color: var(--ink-3); }
 
-/* ---------- spec ---------- */
-
-/* 배지 줄과 큰 숫자 띠를 대신한다. 같은 값을 세 번 찍던 자리였다 — 이름 위
-   eyebrow 에 한 번, 배지에 한 번, 숫자 띠에 한 번. 이제 한 번이다.
-
-   숫자를 46px 로 키우지 않는 이유: 이 프로젝트들의 별 개수는 0 이고, 0 을 그
-   크기로 세우면 그건 사실 전달이 아니라 프로젝트에 대한 진술이 된다. 같은 값도
-   `런타임 의존성 · 0` 이라는 한 줄에서는 바이너리에 대한 사실로 읽힌다.
+/* 선은 음수 마진으로 겹친 1px 이다. 개수와 열수가 무엇이든 맞아 들어가고
+   :nth-child 규칙이 필요 없다. 20rem 은 임의값이 아니다 — 71rem 안에서 열이
+   3개가 되고, 특징을 여섯 개로 쓰면 두 줄이 정확히 찬다. */
+.lattice {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
+  border-top: 1px solid var(--rule);
+  border-left: 1px solid var(--rule);
+}
+.cell {
+  position: relative; isolation: isolate;
+  padding: clamp(1.25rem, 3vw, 1.75rem) clamp(1rem, 2vw, 1.5rem);
+  min-width: 0;
+  margin: 0 -1px -1px 0;
+  border-right: 1px solid var(--rule);
+  border-bottom: 1px solid var(--rule);
+}
+/* hover 에서 위로 올린다. 그러지 않으면 이웃에 가려 두 변만 밝아진다. */
+.cell:hover { z-index: 1; border-color: var(--rule-2); }
+.cell { transition: border-color var(--tr-ink); }
 
-   표는 .prose 의 표와 같은 규칙을 쓴다. 이 집에서 키-값 표는 한 가지 물건이고,
-   한 곳에서 고치면 둘 다 따라와야 한다. 펜스 바로 아래 앉아 프런트매터처럼
-   읽히는 것도 의도다 — 이 사이트의 모든 페이지가 실제로 그렇게 생겼다. */
-/* 아래 여백은 다음 연을 밀어내는 것이다. 랜딩에서는 규격서 바로 뒤에 글이
-   오는데, 표의 마지막 행 선과 첫 문단이 붙으면 문단이 표의 일부로 읽힌다. */
-.spec { max-width: 34rem; margin: 1.75rem auto clamp(2.5rem, 6vw, 4rem); }
-.spec th { width: 42%; border-bottom-color: var(--rule); }
-.spec a { color: inherit; transition: color var(--tr-ink); }
-.spec a:hover { color: var(--accent); }
+.cell-index {
+  display: block; font-family: var(--mono); font-size: 0.66rem;
+  letter-spacing: 0.18em; color: var(--ink-4); margin-bottom: 0.7rem;
+  transition: color var(--tr-ink);
+}
+.cell:hover .cell-index { color: var(--accent); }
+.cell-title { margin: 0; font-size: 1rem; font-weight: var(--w-label); letter-spacing: var(--track-set); }
+.cell-desc { margin: 0.5rem 0 0; font-size: 0.86rem; color: var(--ink-3); text-wrap: pretty; }
 
-/* ---------- session ---------- */
+/* ---------- terminal ---------- */
 
-/* 주장 하나에 영수증 하나. 창틀 — 신호등 점 세 개와 `zsh` 라벨 — 은 없앴다.
-   그건 터미널의 그림이고 여기 있는 것은 실제 출력이다. 상자는 이 사이트가
-   기계 출력에 이미 쓰는 그 상자(.prose pre)와 같은 재질이다. */
 .term {
-  max-width: 52rem; margin: 0 auto; padding: 1.2rem 1.3rem; overflow-x: auto;
-  border: 1px solid var(--rule); border-radius: var(--radius);
-  background: var(--bg-recess);
+  border: 1px solid var(--rule-2); border-radius: var(--radius);
+  overflow: hidden; background: var(--bg-recess);
+  max-width: 52rem; margin-inline: auto;
+}
+.term-bar {
+  display: flex; align-items: center; gap: 0.7rem;
+  padding: 0.55rem 0.85rem;
+  border-bottom: 1px solid var(--rule); background: var(--bg-2);
+}
+.term-dots { display: inline-flex; gap: 0.32rem; }
+.term-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--rule-3); }
+.term-dots i:first-child { background: var(--ink-4); }
+.term-name { font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.1em; color: var(--ink-4); }
+
+.term-body {
+  margin: 0; padding: 1.2rem 1.3rem; overflow-x: auto;
   font-family: var(--mono); font-size: 0.8rem; line-height: 1.85; white-space: pre;
 }
-.term code { font: inherit; background: none; padding: 0; border: 0; }
-.term span { display: block; }
+.term-body code { font: inherit; background: none; padding: 0; border: 0; }
+.term-body span { display: block; }
 .term-cmd { color: var(--ink); }
 /* 주석은 기울이고, 출력은 중립 회색이다. 출력에 색을 주면 그게 결과가 아니라
    상태로 읽힌다. */
@@ -807,35 +904,15 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
   font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.06em; color: var(--ink-4);
 }
 
-/* ---------- register ---------- */
+/* ---------- cta ---------- */
 
-/* 결정 목록. 원장과 같은 격자를 쓴다 — 좁은 표지 열, 넓은 본문 열, 행 사이에
-   선 없음. 다른 것은 표지가 날짜가 아니라 순번이고 행이 링크가 아니라는 것뿐이다.
-   번호가 남은 이유는 내용이 그걸 벌기 때문이다: 순서가 있는 여섯 개의 결정이고,
-   하나하나가 다른 데서 먼저 일어난 사고다. 순번은 저자가 적지 않는다 — 순서가
-   곧 번호이므로 루프가 만든다. */
-.register { list-style: none; margin: 0 auto; padding: 0; max-width: 52rem; }
-.register li { padding-inline: 0; margin-inline: 0; }
-.register-index {
-  font-family: var(--mono); font-size: 0.72rem;
-  letter-spacing: 0.12em; font-variant-numeric: tabular-nums; color: var(--ink-4);
+.cta { margin-top: clamp(3rem, 7vw, 5rem); padding-block: clamp(2.5rem, 6vw, 5rem); text-align: center; }
+.cta-inner { max-width: 46ch; margin-inline: auto; }
+.cta-title {
+  margin: 0; font-size: clamp(1.6rem, 1.3rem + 1.4vw, 2.4rem);
+  font-weight: var(--w-display); letter-spacing: var(--track-air); text-wrap: balance;
 }
-.register-title {
-  margin: 0; font-size: 1.0625rem;
-  font-weight: var(--w-item); letter-spacing: var(--track-set);
-}
-.register-body { margin: 0.4rem 0 0; max-width: 58ch; color: var(--ink-3); text-wrap: pretty; }
-
-/* ---------- closing ---------- */
-
-/* 닫는 줄. 띠도 제목도 두 번째 버튼 줄도 없다 — 한 문장과 그 문장이 가리키는
-   곳뿐이다. 채운 버튼과 유령 버튼 한 쌍은 페이지에 한 번, 머리에만 선다.
-   읽는 사람이 결정하는 자리가 거기이기 때문이다. */
-.closing { margin-top: clamp(2.5rem, 6vw, 4rem); text-align: center; }
-.closing p { max-width: 46ch; margin-inline: auto; color: var(--ink-3); }
-.closing .btn-row { margin-top: 1rem; }
-/* 주소는 낱말이 아니라 주소다. mono 로 두고 대문자로 만들지 않는다. */
-.closing .btn { font-family: var(--mono); font-size: 0.7rem; text-transform: none; letter-spacing: 0.03em; }
+.cta-text { margin: 0.9rem 0 0; color: var(--ink-3); }
 
 /* ---------- about ---------- */
 
@@ -938,19 +1015,17 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
 .copy-btn:hover { color: var(--ink); border-color: var(--rule-3); }
 .copy-btn.is-done { opacity: 1; color: var(--accent); border-color: var(--accent); }
 
-/* 프로젝트 페이지의 스펙표(.spec)가 같은 규칙 위에 선다 — 키-값 표는 이 집에서
-   한 가지 물건이다. 따로 두면 한쪽만 고쳐지고 두 페이지가 서로 달라 보인다. */
-.prose table, .spec {
+.prose table {
   width: 100%; margin-top: 1.75rem; border-collapse: collapse;
   font-size: 0.9rem; font-variant-numeric: tabular-nums;
 }
-.prose th, .prose td, .spec th, .spec td { padding: 0.55rem 0.75rem; text-align: left; border-bottom: 1px solid var(--rule); }
-.prose th, .spec th {
+.prose th, .prose td { padding: 0.55rem 0.75rem; text-align: left; border-bottom: 1px solid var(--rule); }
+.prose th {
   font-size: 0.64rem; font-weight: 500;
   letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-4);
   border-bottom-color: var(--rule-2);
 }
-.prose td, .spec td { color: var(--ink-2); }
+.prose td { color: var(--ink-2); }
 .prose hr { margin-top: 2.5rem; border: 0; border-top: 1px solid var(--rule); }
 
 .prose .anchor { opacity: 0; color: var(--ink-4); text-decoration: none; transition: opacity var(--tr-ink); }
@@ -1034,10 +1109,13 @@ html[lang="ko"] [data-tooltip]::after { letter-spacing: 0; text-transform: none;
    문 링크)은 두 언어에서 영어로 고정이므로 대문자와 자간을 그대로 둔다 —
    되돌리면 한국어 페이지에서만 라벨이 소문자로 나와 두 언어가 달라 보인다. */
 html[lang="ko"] .eyebrow,
+html[lang="ko"] .fact-label,
 html[lang="ko"] .prose th,
 html[lang="ko"] .crumbs,
 html[lang="ko"] .palette-crumb,
-html[lang="ko"] .btn {
+html[lang="ko"] .btn,
+html[lang="ko"] .back,
+html[lang="ko"] .badges li {
   font-family: var(--sans);
   letter-spacing: 0;
   text-transform: none;
@@ -1064,7 +1142,7 @@ html[lang="ko"] {
 html[lang="ko"] .ledger-date,
 html[lang="ko"] .feature-date,
 html[lang="ko"] .ledger-year,
-html[lang="ko"] .term,
+html[lang="ko"] .term-body,
 html[lang="ko"] .stars { font-family: var(--mono); }
 
 /* ---------- reduced motion ---------- */
diff --git a/static/fonts/display-kr.woff2 b/static/fonts/display-kr.woff2
Binary files differ.
diff --git a/templates/partials/spec.html b/templates/partials/spec.html
@@ -1,32 +0,0 @@
-{#- 규격서. 배지 줄과 큰 숫자 띠를 대신하고, 같은 값을 세 번 찍던 것을 한 번으로
-    줄인다. url 이 빈 문자열이면 링크가 아니라 글자다. #}
-{% macro spec(page) %}
-  {%- if page.extra.spec or page.extra.repo %}
-  <table class="spec">
-    <tbody>
-      {%- for s in page.extra.spec %}
-      <tr>
-        <th scope="row">{{ s.key }}</th>
-        <td>{% if s.url %}<a href="{{ s.url }}"{% if s.url is startingwith "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ s.value }}</a>{% else %}{{ s.value }}{% endif %}</td>
-      </tr>
-      {%- endfor %}
-      {%- if page.extra.repo %}
-      <tr>
-        <th scope="row">{{ t("repository") }}</th>
-        <td><a href="{{ page.extra.repo }}" target="_blank" rel="noopener noreferrer">{{ page.extra.repo | replace("https://", "") }}</a></td>
-      </tr>
-      <tr>
-        <th scope="row">{{ t("stars") }}</th>
-        <td class="stars">{{ page.extra.stars }}</td>
-      </tr>
-      {%- endif %}
-    </tbody>
-  </table>
-  {%- endif %}
-{% endmacro %}
-
-{% macro prose(page) %}
-  {%- if page.content %}
-  <div class="prose">{{ page.content | safe }}</div>
-  {%- endif %}
-{% endmacro %}
diff --git a/templates/project.html b/templates/project.html
@@ -1,121 +1,147 @@
-{#- 프로젝트 한 장. 랜딩 페이지가 아니라 소프트웨어에 대한 문서다 — 명패,
-    프런트매터, 그다음 주장, 그다음 영수증, 그다음 들어가는 길.
+{#- 프로젝트 한 장. 두 등급뿐이다: 랜딩이거나 브리프거나. 중간 등급을 만들면
+    어느 프로젝트가 어디에 속하는지 매번 새로 판단해야 한다.
 
-    등급은 둘뿐이다: 랜딩이거나 브리프거나. 중간 등급을 만들면 어느 프로젝트가
-    어디에 속하는지 매번 새로 판단해야 한다. 두 등급이 다른 것은 섹션 개수가
-    아니라 **순서와 폭**이다. 랜딩은 규격서를 먼저 놓고 글로 이어 가며 세션과
-    결정 목록에서 두 번 넓어진다. 브리프는 글이 먼저이고 규격서는 각주처럼 뒤에
-    붙으며 끝까지 읽는 폭 안에 있다. 그러면 읽는 사람이 섹션을 세지 않고도 두
-    화면 만에 어느 쪽인지 안다.
-
-    블록의 유무는 데이터가 정한다 — register 가 [] 면 그 섹션이 없다. 빈 배열이
-    이미 "없음"이라 플래그를 따로 두지 않는다.
+    등급은 `tier` 가 정하지만 블록의 유무는 데이터가 정한다 — features 가 []
+    면 그 섹션이 없다. 빈 배열이 이미 "없음"이라 플래그를 따로 두지 않는다.
 
     별 개수는 tools/stars.py 가 front matter 에 구워 둔 값이다. 렌더 중에
     네트워크를 부르면 재현되는 빌드가 아니게 되고, 브라우저에서 부르면 익명
-    API 한도(IP 당 60/시간)에 걸린다. 0 이어도 보여 준다 — 그게 사실이다.
-    저장소와 별 줄은 저자가 적지 않는다: repo 와 stars 스칼라에서 template 이
-    만든다. stars.py 가 고쳐 쓰는 것이 그 두 줄이라 한 곳에만 있어야 한다. #}
+    API 한도(IP 당 60/시간)에 걸린다. 0 이어도 보여 준다 — 그게 사실이고,
+    없는 항목만 숫자가 빠지면 줄이 어긋난다. #}
 {% extends "base.html" %}
-{% from "partials/icons.html" import i_out %}
-{% from "partials/spec.html" import spec, prose %}
+{% from "partials/icons.html" import i_out, i_arrow %}
 
 {% block content %}
-{%- set landing = page.extra.tier == "landing" %}
-<article class="{{ "wrap" if landing else "wrap wrap-read" }} project" data-tier="{{ page.extra.tier }}"
+<div class="{{ "wrap" if page.extra.tier == "landing" else "wrap wrap-read" }} project" data-tier="{{ page.extra.tier }}"
      {%- if page.extra.accent %} style="--accent: {{ page.extra.accent }}"{% endif %}>
 
-  <header class="page-head">
-    {%- if page.section %}
-    <nav class="crumbs" aria-label="{{ t("breadcrumb") }}">
-      <a href="{{ page.section.url }}">{{ page.section.title }}</a>
-    </nav>
-    {%- endif %}
-    {%- if page.extra.eyebrow %}
-    <p class="eyebrow">{{ page.extra.eyebrow }}</p>
-    {%- endif %}
-    {#- 이름 앞의 작은 마크가 명패의 시작이다. 없으면 이름만 선다 — 상세
-        페이지에는 채워야 할 자리가 없으므로 "그림 없음" 상자를 두지 않는다.
-        그 상자는 갤러리에서 한 칸을 메우는 물건이고, 여기서는 없는 것에
-        테두리를 둘러 없다고 말하는 꼴이 된다. #}
-    <h1 class="page-title">
-      {%- if page.extra.mark %}<span class="chip-mark" style="--mark-src: url('/{{ page.extra.mark }}')" aria-hidden="true"></span>
-      {%- elif page.extra.logo %}<span class="chip-mark" aria-hidden="true"><img src="{{ asset(page.extra.logo) }}" alt=""></span>
-      {%- endif %}{{ page.title }}</h1>
-    {%- if page.extra.tagline %}
-    <p class="page-lede">{{ page.extra.tagline }}</p>
-    {%- endif %}
-    {#- 페이지에 버튼은 이 한 쌍뿐이다. 머리에 한 번, 그게 전부다 — 읽는 사람이
-        갈지 말지 정하는 자리가 여기다. #}
-    {%- if page.extra.actions %}
-    <div class="btn-row">
-      {%- for a in page.extra.actions %}
-      <a class="btn btn-{{ a.style }}" href="{{ a.url }}"{% if a.url is startingwith "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>
-        <span>{{ a.label }}</span>{{ i_out() }}
-      </a>
-      {%- endfor %}
+  {%- if page.section %}
+  <a class="back" href="{{ page.section.url }}">
+    <svg viewBox="0 0 24 24" width="13" height="13" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H6M11 6l-6 6 6 6"/></svg>
+    <span>{{ page.section.title }}</span>
+  </a>
+  {%- endif %}
+
+  <header class="hero">
+    <div class="hero-main">
+      {%- if page.extra.eyebrow %}
+      <p class="eyebrow">{{ page.extra.eyebrow }}</p>
+      {%- endif %}
+      {#- 이름 옆의 작은 마크가 명패의 시작이다. 없으면 이름만 선다. #}
+      <h1 class="hero-name">
+        {%- if page.extra.mark %}<span class="chip-mark" style="--mark-src: url('/{{ page.extra.mark }}')" aria-hidden="true"></span>
+        {%- elif page.extra.logo %}<span class="chip-mark" aria-hidden="true"><img src="{{ asset(page.extra.logo) }}" alt=""></span>
+        {%- endif %}
+        <span>{{ page.title }}</span>
+      </h1>
+      {%- if page.extra.tagline %}
+      <p class="hero-tagline">{{ page.extra.tagline }}</p>
+      {%- endif %}
+
+      <ul class="badges">
+        {%- if page.extra.lang %}<li>{{ page.extra.lang }}</li>{% endif %}
+        {%- if page.extra.license %}<li>{{ page.extra.license }}</li>{% endif %}
+        {%- if page.extra.version %}<li>{{ page.extra.version }}</li>{% endif %}
+        {%- if page.extra.platform %}<li>{{ page.extra.platform }}</li>{% endif %}
+        {%- if page.extra.repo %}<li class="stars">{{ page.extra.stars }} ★</li>{% endif %}
+      </ul>
+
+      {%- if page.extra.actions %}
+      <div class="btn-row">
+        {%- for a in page.extra.actions %}
+        <a class="btn btn-{{ a.style }}" href="{{ a.url }}"{% if a.url is startingwith "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>
+          <span>{{ a.label }}</span>{{ i_out() }}
+        </a>
+        {%- endfor %}
+      </div>
+      {%- endif %}
+    </div>
+
+    {#- 옆칸은 규격이 하나다. 마크 → 로고 → "그림 없음". 좁은 화면에서는 지우지
+        않고 위로 올린다 — 지우면 그 프로젝트만 그림이 없는 것처럼 보인다. #}
+    <div class="hero-aside" aria-hidden="true">
+      {%- if page.extra.mark %}
+      <span class="medallion medallion-mark" style="--mark-src: url('/{{ page.extra.mark }}')"></span>
+      {%- elif page.extra.logo %}
+      <span class="medallion"><img src="{{ asset(page.extra.logo) }}" alt="" loading="eager" decoding="async"></span>
+      {%- else %}
+      <span class="medallion"><span class="noimg"></span></span>
+      {%- endif %}
     </div>
-    {%- endif %}
   </header>
 
-  <div class="fence" aria-hidden="true">+++</div>
+  {%- if page.extra.facts %}
+  <section class="facts">
+    {%- for f in page.extra.facts %}
+    <div class="fact">
+      <span class="fact-value">{{ f.value }}</span>
+      <span class="fact-label">{{ f.label }}</span>
+    </div>
+    {%- endfor %}
+  </section>
+  {%- endif %}
 
-  {%- if landing %}
-  {{ spec(page) }}
-  {{ prose(page) }}
-  {%- else %}
-  {{ prose(page) }}
-  {{ spec(page) }}
+  {%- if page.content %}
+  <div class="prose">{{ page.content | safe }}</div>
   {%- endif %}
 
-  {%- for s in page.extra.session %}
+  {%- if page.extra.features %}
   <section class="block">
     <header class="block-head">
-      {%- if s.eyebrow %}<p class="eyebrow">{{ s.eyebrow }}</p>{% endif %}
-      {%- if s.title %}<h2 class="block-title">{{ s.title }}</h2>{% endif %}
+      {%- if page.extra.features_eyebrow %}<p class="eyebrow">{{ page.extra.features_eyebrow }}</p>{% endif %}
+      {%- if page.extra.features_title %}<h2 class="block-title">{{ page.extra.features_title }}</h2>{% endif %}
+      {%- if page.extra.features_intro %}<p class="block-intro">{{ page.extra.features_intro }}</p>{% endif %}
     </header>
-    <pre class="term"><code>{%- for l in s.lines %}
-<span class="term-{{ l.kind }}">{{ l.text }}</span>
-{%- endfor %}</code></pre>
-    {%- if s.caption %}
-    <p class="term-caption">{{ s.caption }}</p>
-    {%- endif %}
+    <div class="lattice">
+      {%- for f in page.extra.features %}
+      <article class="cell">
+        {#- 번호는 저자가 적지 않는다. 순서가 곧 번호이므로 루프가 만든다. #}
+        <span class="cell-index" aria-hidden="true">{% if loop.index < 10 %}0{% endif %}{{ loop.index }}</span>
+        <h3 class="cell-title">{{ f.title }}</h3>
+        <p class="cell-desc">{{ f.desc }}</p>
+      </article>
+      {%- endfor %}
+    </div>
   </section>
-  {%- endfor %}
+  {%- endif %}
 
-  {%- if page.extra.register %}
+  {%- if page.extra.session_lines %}
   <section class="block">
     <header class="block-head">
-      {%- if page.extra.register_eyebrow %}<p class="eyebrow">{{ page.extra.register_eyebrow }}</p>{% endif %}
-      {%- if page.extra.register_title %}<h2 class="block-title">{{ page.extra.register_title }}</h2>{% endif %}
-      {%- if page.extra.register_intro %}<p class="block-intro">{{ page.extra.register_intro }}</p>{% endif %}
+      {%- if page.extra.session_eyebrow %}<p class="eyebrow">{{ page.extra.session_eyebrow }}</p>{% endif %}
+      {%- if page.extra.session_title %}<h2 class="block-title">{{ page.extra.session_title }}</h2>{% endif %}
     </header>
-    <ol class="register">
-      {%- for r in page.extra.register %}
-      <li>
-        {#- 순번은 저자가 적지 않는다. 순서가 곧 번호이므로 루프가 만든다. #}
-        <span class="register-index" aria-hidden="true">{% if loop.index < 10 %}0{% endif %}{{ loop.index }}</span>
-        <div>
-          <h3 class="register-title">{{ r.title }}</h3>
-          <p class="register-body">{{ r.body }}</p>
-        </div>
-      </li>
-      {%- endfor %}
-    </ol>
+    <div class="term">
+      <div class="term-bar">
+        <span class="term-dots" aria-hidden="true"><i></i><i></i><i></i></span>
+        <span class="term-name">{{ page.extra.session_shell }}</span>
+      </div>
+      <pre class="term-body"><code>{%- for l in page.extra.session_lines %}
+<span class="term-{{ l.kind }}">{{ l.text }}</span>
+{%- endfor %}</code></pre>
+    </div>
+    {%- if page.extra.session_caption %}
+    <p class="term-caption">{{ page.extra.session_caption }}</p>
+    {%- endif %}
   </section>
   {%- endif %}
 
-  {%- if page.extra.closing_text %}
-  <section class="closing">
-    <p>{{ page.extra.closing_text }}</p>
-    {%- if page.extra.closing_links %}
-    <div class="btn-row">
-      {%- for l in page.extra.closing_links %}
-      <a class="btn btn-text" href="{{ l.url }}"{% if l.url is startingwith "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ l.label }}</a>
-      {%- endfor %}
+  {%- if page.extra.cta_title %}
+  <section class="cta">
+    <div class="cta-inner">
+      <h2 class="cta-title">{{ page.extra.cta_title }}</h2>
+      {%- if page.extra.cta_text %}<p class="cta-text">{{ page.extra.cta_text }}</p>{% endif %}
+      {%- if page.extra.cta_actions %}
+      <div class="btn-row">
+        {%- for a in page.extra.cta_actions %}
+        <a class="btn btn-{{ a.style }}" href="{{ a.url }}"{% if a.url is startingwith "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>
+          <span>{{ a.label }}</span>{{ i_arrow() }}
+        </a>
+        {%- endfor %}
+      </div>
+      {%- endif %}
     </div>
-    {%- endif %}
   </section>
   {%- endif %}
-</article>
+</div>
 {% endblock %}