sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git
commit c24222ce6413ae1d9cbcbad2c80b867d700ac7ec
parent c453f54128e6058a383d4696be7aa8a88ec4b9f8
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sun, 26 Jul 2026 00:34:21 +0900

Give the chrome a component system, and make the sidebar collapse

The masthead grew with its contents, so its height changed from page to page,
and `align-items: baseline` sat a brand containing an image on a different line
from the nav beside it. The text looked scattered because it was. It is now a
fixed-height row aligned on centre, and sticky, which a documentation site
wants anyway.

Four controls sat side by side in three different treatments: search was a
bordered box, theme was a button, and the language and GitHub links were bare
text. To a reader they are all things you click, and the two that looked like
nothing read as unfinished. They share one shape now — `--control-h`, one
border, one radius, one hover — defined once and applied to all four. The shape
is keycap-like rather than pill-like because this is a tool you drive from a
terminal.

The sidebar dumped every section and every page, twenty-odd items, straight
down the page with no way to fold any of it. Sections are `<details>` now. That
is the whole mechanism: no JavaScript, keyboard and screen readers work
already, and — the part that matters — the section containing the current page
is opened by the server, so the state is right in the first paint rather than
after a script has run. Pages that belong to no section open the first one,
because five closed accordions tell a new reader nothing.

Narrow screens were not broken so much as absent: there was no breakpoint at
all, and at 420px "sqzass" wrapped one letter per line, as did "GitHub". Below
60rem the section nav and the table of contents step aside — the drawer says
the same thing — GitHub goes (it is in the colophon) and the sidebar becomes a
drawer using the same `<details>`. It ships open so a page without JavaScript
is complete, and ui.js closes it on narrow screens before paint.

Also: the current page is a filled pill spanning the panel rather than a hairline
on its left edge, the sidebar fills the viewport height so its rule does not
stop mid-list and read as unfinished, and the table of contents marks where you
are as you scroll. That last one is an IntersectionObserver with a rootMargin
that pins the trigger near the top of the viewport; without it a heading counts
as current while still at the bottom of the screen and the marker runs ahead of
the reader.

Diffstat:
Mdocs/i18n/en.toml | 1+
Mdocs/i18n/ko.toml | 1+
Mdocs/static/css/main.css | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Adocs/static/js/ui.js | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/templates/base.html | 36+++++++++++++++++++++---------------
Mdocs/templates/partials/sidebar.html | 35++++++++++++++++++++++++-----------
6 files changed, 291 insertion(+), 87 deletion(-)

diff --git a/docs/i18n/en.toml b/docs/i18n/en.toml
@@ -17,3 +17,4 @@ next            = "Next"
 
 not_found_title = "Page not found"
 not_found_body  = "That page does not exist, or it moved and left no forwarding address. Everything in this documentation is below, and Ctrl+/ searches it."
+overview        = "Overview"
diff --git a/docs/i18n/ko.toml b/docs/i18n/ko.toml
@@ -17,3 +17,4 @@ next            = "다음"
 
 not_found_title = "페이지를 찾을 수 없습니다"
 not_found_body  = "그런 페이지가 없거나, 옮겨 가면서 안내를 남기지 않았습니다. 이 문서의 전부가 아래에 있고, Ctrl+/ 로 검색할 수 있습니다."
+overview        = "개요"
diff --git a/docs/static/css/main.css b/docs/static/css/main.css
@@ -40,6 +40,13 @@
   --measure: 68ch;
   --gutter: clamp(1.25rem, 4vw, 3rem);
 
+  /* 크롬 컨트롤의 치수. 검색·언어·GitHub·테마가 전부 이 값을 쓴다.
+     하나만 다른 모양이면 그 하나가 고장난 것처럼 보인다.
+     터미널에서 모는 도구라 키캡처럼 다룬다 — 얇은 테두리, 작은 반경. */
+  --control-h: 2rem;
+  --radius: 3px;
+  --masthead-h: 3.5rem;
+
   --s-1: 0.8125rem;
   --s0:  1.0625rem;
   --s1:  1.25rem;
@@ -99,11 +106,20 @@ a:hover { text-decoration-thickness: 2px; }
 
 /* ---------- header ---------- */
 
+/* 높이를 고정한다. 내용에 따라 늘어나면 페이지마다 상단바 높이가 달라지고,
+   그게 "비율이 이상하다"로 읽힌다.
+   align-items는 center다. baseline이면 로고를 품은 브랜드와 옆 내비가 서로
+   다른 기준선에 앉아 글자들이 어긋난 것처럼 보인다. */
 .masthead {
+  position: sticky;
+  top: 0;
+  z-index: 20;
   display: flex;
-  align-items: baseline;
-  gap: clamp(1rem, 3vw, 2.25rem);
-  padding: 1.15rem var(--gutter);
+  align-items: center;
+  gap: clamp(0.75rem, 2.5vw, 2rem);
+  height: var(--masthead-h);
+  padding: 0 var(--gutter);
+  background: var(--paper);
   border-bottom: 1px solid var(--rule);
 }
 
@@ -111,38 +127,41 @@ a:hover { text-decoration-thickness: 2px; }
   display: inline-flex;
   align-items: center;
   gap: 0.5rem;
+  flex: none;
   font-family: var(--mono);
   font-size: var(--s1);
   font-weight: 600;
   letter-spacing: -0.03em;
   color: var(--ink);
   text-decoration: none;
+  /* 좁은 화면에서 "sqzass"가 한 글자씩 세로로 쪼개지던 자리. */
+  white-space: nowrap;
 }
 .brand:hover { color: var(--accent); }
 
 /* 검은 선화라 다크 배경에서는 그냥 안 보인다. 파일을 두 벌 두는 대신 반전한다 —
-   순수 흑백 선화에서는 반전이 정확히 흰 선을 만든다.
-   크기는 손등 해칭이 뭉개지지 않는 선까지 키웠다. 이보다 작으면 그림이 아니라
-   얼룩으로 읽힌다. */
+   순수 흑백 선화에서는 반전이 정확히 흰 선을 만든다. */
 .brand img {
-  width: 2.2em;
-  height: 2.2em;
+  width: 1.9em;
+  height: 1.9em;
   filter: invert(1);
 }
 :root[data-theme="light"] .brand img { filter: none; }
 
-/* The section list. Same data as the sidebar, one level up, so a reader can
-   cross between parts of the documentation without returning to the index. */
+/* 문서 갈래. 사이드바와 같은 데이터를 한 단계 위에서. */
 .sections {
   display: flex;
-  flex-wrap: wrap;
-  gap: clamp(0.75rem, 2vw, 1.5rem);
+  align-items: center;
+  gap: clamp(0.6rem, 1.6vw, 1.4rem);
+  min-width: 0;
+  overflow: hidden;
   font-size: var(--s-1);
+  white-space: nowrap;
 }
 .sections a {
   color: var(--ink-2);
   text-decoration: none;
-  padding-bottom: 0.15rem;
+  line-height: var(--masthead-h);
   border-bottom: 2px solid transparent;
 }
 .sections a:hover { color: var(--ink); }
@@ -152,41 +171,46 @@ a:hover { text-decoration-thickness: 2px; }
   margin-left: auto;
   display: flex;
   align-items: center;
-  gap: 1rem;
-  font-family: var(--mono);
-  font-size: var(--s-1);
+  gap: 0.4rem;
+  flex: none;
 }
 
-.tools a { color: var(--ink-2); text-decoration: none; }
-.tools a:hover { color: var(--accent); text-decoration: underline; }
-
-.theme-toggle, .search-trigger {
+/* ---------- chip: 크롬 컨트롤 하나의 모양 ----------
+   검색, 언어, GitHub, 테마가 전부 이것이다. 링크든 버튼이든 겉모습은 같아야
+   한다 — 사용자에게는 둘 다 "누르는 것"이고, 하나만 맨 텍스트면 그 하나가
+   빠뜨린 것처럼 보인다. */
+.chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  height: var(--control-h);
+  padding: 0 0.6rem;
   font: inherit;
   font-family: var(--mono);
+  font-size: var(--s-1);
+  line-height: 1;
   color: var(--ink-2);
   background: none;
   border: 1px solid var(--rule);
-  border-radius: 3px;
-  padding: 0.15rem 0.5rem;
+  border-radius: var(--radius);
+  text-decoration: none;
+  white-space: nowrap;
   cursor: pointer;
 }
-.theme-toggle:hover, .search-trigger:hover { color: var(--accent); border-color: currentColor; }
-
-.search-trigger {
-  display: flex;
-  align-items: center;
-  gap: 0.6rem;
-  min-width: 11rem;
-  text-align: left;
+.chip:hover {
+  color: var(--ink);
+  background: var(--raised);
+  border-color: var(--ink-3);
 }
+
+.search-trigger { min-width: 11rem; justify-content: space-between; }
 .search-trigger kbd {
-  margin-left: auto;
   font: inherit;
   font-size: 0.7rem;
   color: var(--ink-3);
   border: 1px solid var(--rule);
-  border-radius: 3px;
-  padding: 0 0.3rem;
+  border-radius: var(--radius);
+  padding: 0.1rem 0.3rem;
 }
 
 /* ---------- breadcrumb ---------- */
@@ -210,7 +234,7 @@ a:hover { text-decoration-thickness: 2px; }
 .layout {
   display: grid;
   grid-template-columns: 15rem minmax(0, 1fr) 13rem;
-  gap: clamp(1.5rem, 4vw, 3.5rem);
+  gap: clamp(1.5rem, 4vw, 3rem);
   align-items: start;
   max-width: 84rem;
   margin: 0 auto;
@@ -221,57 +245,96 @@ main { min-width: 0; max-width: var(--measure); }
 
 .sidebar, .toc {
   position: sticky;
-  top: 2rem;
+  /* 상단바가 sticky이므로 그 아래에 붙는다. */
+  top: calc(var(--masthead-h) + 1.5rem);
   font-size: var(--s-1);
   line-height: 1.6;
-  max-height: calc(100vh - 4rem);
+  max-height: calc(100vh - var(--masthead-h) - 3rem);
   overflow-y: auto;
 }
 
-/* ---------- sidebar ---------- */
+/* 사이드바는 높이를 채운다. 내용 높이에 맞추면 경계선이 목록 끝에서 뚝 끊겨
+   구역이 아니라 미완성으로 읽힌다. */
+.sidebar { height: calc(100vh - var(--masthead-h) - 3rem); }
 
-.sidebar ul { list-style: none; margin: 0; padding: 0; }
-.sidebar > ul > li + li { margin-top: 1.5rem; }
+/* ---------- sidebar ---------- */
 
-.sidebar ul ul {
-  margin-top: 0.4rem;
-  border-left: 1px solid var(--rule);
+/* <details>다. 넓은 화면에서는 열린 채 고정이라 summary를 감추고, 좁은 화면에서만
+   서랍이 된다. 구역을 나누는 건 오른쪽 경계선 하나 — 배경을 따로 깔지 않아도
+   사이드바가 본문과 다른 층이라는 게 읽힌다. */
+.sidebar {
+  border-right: 1px solid var(--rule);
+  padding-right: 1rem;
 }
+.sidebar > summary { display: none; }
 
-.sidebar a {
-  display: block;
-  padding: 0.2rem 0;
-  color: var(--ink-2);
-  text-decoration: none;
-}
-.sidebar ul ul a { padding-left: 0.85rem; margin-left: -1px; }
-.sidebar a:hover { color: var(--accent); }
+.nav-tree, .nav-tree ul { list-style: none; margin: 0; padding: 0; }
+.nav-tree > li + li { margin-top: 0.15rem; }
 
-/* Section headings in the tree read as labels, not links-among-links. */
-.sidebar > ul > li > a {
+/* 갈래 제목. 누르면 접힌다는 걸 셰브런이 말한다 — 텍스트로 쓸 수 없는 자리라
+   여기서는 도형을 쓰되, 폰트가 아니라 CSS로 그려서 폴백 문제를 피한다. */
+.nav-group > summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.4rem 0.6rem;
+  border-radius: var(--radius);
   font-family: var(--mono);
   font-size: 0.75rem;
   letter-spacing: 0.08em;
   text-transform: uppercase;
   color: var(--ink-3);
+  cursor: pointer;
+  list-style: none;
+}
+.nav-group > summary::-webkit-details-marker { display: none; }
+.nav-group > summary:hover { color: var(--ink); background: var(--raised); }
+
+.nav-group > summary::before {
+  content: "";
+  width: 0.35rem;
+  height: 0.35rem;
+  border-right: 1.5px solid currentColor;
+  border-bottom: 1.5px solid currentColor;
+  transform: rotate(-45deg);
+  transition: transform 0.12s ease;
+}
+.nav-group[open] > summary::before { transform: rotate(45deg); }
+
+.nav-tree a {
+  display: block;
+  padding: 0.35rem 0.6rem;
+  border-radius: var(--radius);
+  color: var(--ink-2);
+  text-decoration: none;
 }
+.nav-tree a:hover { color: var(--ink); background: var(--raised); }
 
-.sidebar a[aria-current="page"] {
+/* 현재 항목은 판 전체 폭을 쓰는 알약이다. 왼쪽 막대 하나보다 훨씬 빨리 눈에 띈다. */
+.nav-tree a[aria-current="page"] {
   color: var(--ink);
   font-weight: 600;
+  background: var(--raised);
+  box-shadow: inset 2px 0 0 var(--accent);
 }
-.sidebar ul ul a[aria-current="page"] {
-  border-left: 2px solid var(--accent);
-  padding-left: calc(0.85rem - 1px);
-}
+
+.nav-group > ul { margin: 0.1rem 0 0.6rem; padding-left: 0.6rem; }
 
 /* ---------- table of contents ---------- */
 
 .toc ul { list-style: none; margin: 0; padding: 0; }
 .toc > ul > li + li { margin-top: 0.15rem; }
 .toc ul ul { padding-left: 0.85rem; }
-.toc a { color: var(--ink-3); text-decoration: none; display: block; padding: 0.15rem 0; }
-.toc a:hover { color: var(--accent); }
+.toc a {
+  display: block;
+  padding: 0.2rem 0 0.2rem 0.7rem;
+  border-left: 2px solid transparent;
+  color: var(--ink-3);
+  text-decoration: none;
+}
+.toc a:hover { color: var(--ink); }
+/* 지금 읽고 있는 자리. ui.js가 스크롤을 보고 표시한다 — 없으면 그냥 목록이다. */
+.toc a[aria-current] { color: var(--ink); border-left-color: var(--accent); }
 
 .toc::before {
   content: attr(data-label);
@@ -684,9 +747,72 @@ html[lang="ko"] .pager span {
   color: var(--ink-3);
 }
 
-@media (max-width: 40rem) {
-  .search-trigger span { display: none; }
+/* ---------- narrow screens ----------
+   여기 규칙이 없어서 420px에서 마스트헤드가 무너졌다. 브랜드가 한 글자씩 세로로
+   쪼개지고 GitHub도 마찬가지였다. 좁아지면 줄이는 게 아니라 **뺀다** — 갈래 내비는
+   사이드바 서랍이 같은 일을 하고, GitHub는 콜로폰에 이미 있다. */
+
+@media (max-width: 60rem) {
+  .layout {
+    grid-template-columns: minmax(0, 1fr);
+    padding-top: 1.5rem;
+  }
+
+  /* 서랍이 된다. 열고 닫는 건 <details>가, 처음 닫는 건 ui.js가 한다. */
+  .sidebar {
+    position: static;
+    /* 넓은 화면에서 경계선을 채우려고 준 높이를 되돌린다. 안 그러면 서랍이
+       접혀 있어도 100vh를 차지해 본문이 화면 밖으로 밀린다. */
+    height: auto;
+    max-height: none;
+    overflow: visible;
+    border-right: none;
+    border-bottom: 1px solid var(--rule);
+    padding: 0 0 1rem;
+    margin-bottom: 1.5rem;
+  }
+  .sidebar > summary {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.5rem;
+    height: var(--control-h);
+    padding: 0 0.6rem;
+    border: 1px solid var(--rule);
+    border-radius: var(--radius);
+    font-family: var(--mono);
+    font-size: var(--s-1);
+    color: var(--ink-2);
+    cursor: pointer;
+    list-style: none;
+  }
+  .sidebar > summary::-webkit-details-marker { display: none; }
+  .sidebar > summary::before {
+    content: "";
+    width: 0.35rem;
+    height: 0.35rem;
+    border-right: 1.5px solid currentColor;
+    border-bottom: 1.5px solid currentColor;
+    transform: rotate(-45deg);
+  }
+  .sidebar[open] > summary::before { transform: rotate(45deg); }
+  .sidebar[open] > nav { margin-top: 0.75rem; }
+
+  /* 목차는 사이드바 서랍과 같은 말을 한 번 더 하는 셈이라 뺀다. */
+  .toc { display: none; }
+
+  /* 갈래 내비도 서랍에 있다. */
+  .sections { display: none; }
+
+  /* GitHub는 콜로폰에 있다. 검색은 라벨만 남기고 단축키 힌트를 뺀다 —
+     터치 화면에는 누를 키가 없다. */
+  .chip-wide { display: none; }
   .search-trigger { min-width: 0; }
+  .search-trigger kbd { display: none; }
+}
+
+@media (max-width: 26rem) {
+  /* 이보다 좁으면 워드마크까지 자리를 다툰다. 마크만 남긴다 — 로고가 곧 이름이다. */
+  .brand span { display: none; }
 }
 
 @media (prefers-reduced-motion: reduce) {
diff --git a/docs/static/js/ui.js b/docs/static/js/ui.js
@@ -0,0 +1,57 @@
+/* Chrome that needs the viewport or the scroll position to decide.
+ *
+ * Everything here degrades to a usable page when it does not run: the sidebar
+ * stays open, and the table of contents stays a plain list of links.
+ */
+(function () {
+  /* The sidebar ships open so that a page without JavaScript is complete. On a
+     narrow screen that means a long list before the content, so close it —
+     but only there, and only once, before anything is painted. */
+  var drawer = document.getElementById("nav-drawer");
+  if (drawer && matchMedia("(max-width: 60rem)").matches) {
+    drawer.open = false;
+  }
+
+  /* Mark the heading the reader is currently under.
+     rootMargin pins the trigger line near the top of the viewport: without it
+     a heading counts as "visible" while it is still at the bottom of the
+     screen, and the marker runs ahead of the reader. */
+  var links = document.querySelectorAll(".toc a[href^='#']");
+  if (!links.length || !("IntersectionObserver" in window)) return;
+
+  var byId = {};
+  var headings = [];
+  links.forEach(function (a) {
+    var id = decodeURIComponent(a.getAttribute("href").slice(1));
+    var h = document.getElementById(id);
+    if (!h) return;
+    byId[id] = a;
+    headings.push(h);
+  });
+
+  var current = null;
+  var seen = {};
+
+  function mark() {
+    // 가장 위에 있는, 이미 지나온 제목이 지금 읽는 곳이다.
+    var best = null;
+    headings.forEach(function (h) {
+      if (seen[h.id]) best = h;
+    });
+    if (!best || best === current) return;
+    if (current && byId[current.id]) byId[current.id].removeAttribute("aria-current");
+    byId[best.id].setAttribute("aria-current", "true");
+    current = best;
+  }
+
+  var io = new IntersectionObserver(
+    function (entries) {
+      entries.forEach(function (e) {
+        seen[e.target.id] = e.boundingClientRect.top < 0 || e.isIntersecting;
+      });
+      mark();
+    },
+    { rootMargin: "-10% 0px -70% 0px", threshold: 0 }
+  );
+  headings.forEach(function (h) { io.observe(h); });
+})();
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -62,36 +62,41 @@
     <span>{{ site.title }}</span>
   </a>
 
-  {#- The top nav is the section list. It is the same data the sidebar draws,
-      one level up, so a reader can move between parts of the documentation
-      without going back to the index. #}
+  {#- 문서 갈래. 사이드바와 같은 데이터를 한 단계 위에서 보여 주므로, 좁은 화면에서는
+      사이드바 서랍이 같은 일을 해서 감춘다. #}
   <nav class="sections" aria-label="{{ t("sections") }}">
     {%- for s in site.sections %}
     <a href="{{ s.url }}"{% if page.section and page.section.url == s.url or page.url == s.url %} aria-current="true"{% endif %}>{{ s.title }}</a>
     {%- endfor %}
   </nav>
 
+  {#- 크롬은 전부 같은 컨트롤이다. 하나만 텍스트 링크로 두면 그 하나가 고장난
+      것처럼 보인다. #}
   <div class="tools">
-    <button id="search-trigger" class="search-trigger" type="button"
+    <button id="search-trigger" class="chip search-trigger" type="button"
             data-index="{{ site.base_path }}/search-{{ page.language }}.json">
-      {#- The label is replaced with the reader's own modifier by search.js;
-          this is the value someone with JavaScript off sees. #}
-      <span>{{ t("search") }}</span><kbd>Ctrl /</kbd>
+      <span>{{ t("search") }}</span>
+      {#- 라벨은 search.js가 읽는 사람의 수정자 키로 바꿔 쓴다. 이건 JS가 없을 때 값. #}
+      <kbd>Ctrl /</kbd>
     </button>
     {%- for lang in page.translations %}
-    <a href="{{ lang.url }}" hreflang="{{ lang.code }}">{{ lang.name }}</a>
+    <a class="chip" href="{{ lang.url }}" hreflang="{{ lang.code }}">{{ lang.name }}</a>
     {%- endfor %}
-    <a href="https://github.com/sqzer-x/sqzass">GitHub</a>
-    {#- Text, not a glyph: ◐/☾ and friends are missing from enough fallback
-        stacks that they render as a box. #}
-    <button class="theme-toggle" type="button">{{ t("theme") }}</button>
+    <a class="chip chip-wide" href="https://github.com/sqzer-x/sqzass">GitHub</a>
+    {#- 글자다. ◐/☾ 같은 기호는 폴백 스택에 없는 경우가 많아 네모로 나온다. #}
+    <button class="chip theme-toggle" type="button">{{ t("theme") }}</button>
   </div>
 </header>
 
 <div class="layout">
-  <aside class="sidebar" aria-label="{{ t("documentation") }}">
-    {{ nav(site.sections, page.url) }}
-  </aside>
+  {#- <details>는 열린 채로 나간다. 좁은 화면에서만 ui.js가 닫는다 — JS가 없으면
+      펼쳐진 채로 남는데, 그건 못 쓰는 상태가 아니라 그냥 긴 상태다. #}
+  <details class="sidebar" id="nav-drawer" open>
+    <summary>{{ t("documentation") }}</summary>
+    <nav aria-label="{{ t("documentation") }}">
+      {{ nav(site.sections, page.url, page.section.url if page.section else "") }}
+    </nav>
+  </details>
 
   <main id="content">
     <div class="title-block">
@@ -148,5 +153,6 @@ document.querySelector(".theme-toggle").addEventListener("click", function () {
 });
 </script>
 <script src="{{ asset("js/search.js") }}" defer></script>
+<script src="{{ asset("js/ui.js") }}" defer></script>
 </body>
 </html>
diff --git a/docs/templates/partials/sidebar.html b/docs/templates/partials/sidebar.html
@@ -1,17 +1,30 @@
-{# 섹션 트리를 재귀적으로 그린다. site.sections는 현재 언어의 것만 담겨 있으므로
-   미번역 페이지는 애초에 여기 없다 — 죽은 링크가 생길 수 없다. #}
-{% macro nav(sections, current) %}
-<ul>
+{# 섹션 트리. site.sections는 현재 언어의 것만 담겨 있으므로 미번역 페이지는
+   애초에 여기 없다 — 죽은 링크가 생길 수 없다.
+
+   접기는 <details>가 한다. JS가 필요 없고, 키보드와 스크린리더가 그냥 동작하며,
+   무엇보다 **현재 페이지가 든 갈래만 서버가 열어 두므로** 첫 페인트부터 상태가
+   맞다. JS로 열면 펼쳐지는 순간이 눈에 보인다. #}
+{% macro nav(sections, current, open_url) %}
+<ul class="nav-tree">
   {%- for s in sections %}
   <li>
-    <a href="{{ s.url }}"{% if s.url == current %} aria-current="page"{% endif %}>{{ s.title }}</a>
     {%- if s.pages or s.subsections %}
-    <ul>
-      {%- for p in s.pages %}
-      <li><a href="{{ p.url }}"{% if p.url == current %} aria-current="page"{% endif %}>{{ p.title }}</a></li>
-      {%- endfor %}
-      {%- if s.subsections %}{{ nav(s.subsections, current) }}{% endif %}
-    </ul>
+    {#- 갈래에 속하지 않은 페이지(홈, 404)에서는 첫 갈래를 열어 둔다. 다섯 개가
+        전부 닫혀 있으면 처음 온 사람은 안에 뭐가 있는지 알 수가 없다. #}
+    <details class="nav-group"{% if s.url == open_url or s.url == current or (not open_url and loop.first) %} open{% endif %}>
+      <summary>{{ s.title }}</summary>
+      <ul>
+        {#- summary는 링크가 될 수 없다(누르면 접힌다). 섹션 인덱스로 가는 길을
+            첫 항목으로 따로 낸다. #}
+        <li><a href="{{ s.url }}"{% if s.url == current %} aria-current="page"{% endif %}>{{ t("overview") }}</a></li>
+        {%- for p in s.pages %}
+        <li><a href="{{ p.url }}"{% if p.url == current %} aria-current="page"{% endif %}>{{ p.title }}</a></li>
+        {%- endfor %}
+        {%- if s.subsections %}{{ nav(s.subsections, current, open_url) }}{% endif %}
+      </ul>
+    </details>
+    {%- else %}
+    <a class="nav-leaf" href="{{ s.url }}"{% if s.url == current %} aria-current="page"{% endif %}>{{ s.title }}</a>
     {%- endif %}
   </li>
   {%- endfor %}