From 1bce46a839430a6d3367cb574677fbccfc7a6d16 Mon Sep 17 00:00:00 2001 From: Tanq16 <37408906+Tanq16@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:25:40 -0400 Subject: [PATCH 1/2] ai-docs: add collapsible rails and a responsive table pattern The file-tree sidebar and the right-hand TOC are both collapsible from the top bar, persisted across reloads and restored before first paint. Tables were width:100% with overflow:hidden, so a many-column table squished its columns into slivers and clipped whatever could not shrink further. They now wrap while there is room and switch to scrolling inside their own bounded container once columns hit a readable floor, without ever crossing into the TOC rail. Also fixes note callout icons riding high against the first line, and wide tables silently clipping in the PDF export. --- .../embedded/default-skills/ai-docs/SKILL.md | 2 +- .../default-skills/ai-docs/assets/index.html | 98 ++++++++++++++++--- .../default-skills/ai-docs/assets/print.html | 1 + .../references/component-vocabulary.md | 2 + 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/internal/embedded/default-skills/ai-docs/SKILL.md b/internal/embedded/default-skills/ai-docs/SKILL.md index 0711f88..be1c131 100644 --- a/internal/embedded/default-skills/ai-docs/SKILL.md +++ b/internal/embedded/default-skills/ai-docs/SKILL.md @@ -64,7 +64,7 @@ Run from the project root; `--docs` resolves relative to the current directory. ## How it works -The viewer machinery (`server.mjs`, `convert.mjs`, `index.html`, `print.html`, `package.json`) lives in this skill's `assets/` and is never copied into the project. The user runs the server and points it at a docs directory: `node /assets/server.mjs [--docs ] [--port ]`. With no `--docs` it serves `./AI-docs` (created if missing) on port 4321. It serves the `.html` docs in that directory; the browser **live-reloads automatically** when files change — no manual refresh. The "PDF" button renders via headless Chrome. The viewer loads Tailwind / fonts / highlight.js from CDNs, so the browser needs internet. **The server itself has no dependencies** and runs with just `node`, nothing else. The viewer also injects copy controls on load: every code block copies its text, and every diagram copies as SVG source or as a rendered PNG (dark background baked in) — authors write plain `
`/``.
+The viewer machinery (`server.mjs`, `convert.mjs`, `index.html`, `print.html`, `package.json`) lives in this skill's `assets/` and is never copied into the project. The user runs the server and points it at a docs directory: `node /assets/server.mjs [--docs ] [--port ]`. With no `--docs` it serves `./AI-docs` (created if missing) on port 4321. It serves the `.html` docs in that directory; the browser **live-reloads automatically** when files change — no manual refresh. The "PDF" button renders via headless Chrome. The viewer loads Tailwind / fonts / highlight.js from CDNs, so the browser needs internet. **The server itself has no dependencies** and runs with just `node`, nothing else. The viewer also injects copy controls on load: every code block copies its text, and every diagram copies as SVG source or as a rendered PNG (dark background baked in) — authors write plain `
`/``. The file-tree sidebar and the right-hand TOC are both collapsible from the top bar, and the choice persists.
 
 **Markdown is handled out-of-band, on request only — the server never renders `.md`.** When the user asks to convert or move existing markdown into the hub, run `node /assets/convert.mjs --docs ` (default `AI-docs/`). It bulk-converts untouched `.md` → sibling `.html` and leaves curated files (those with the `` marker) untouched. `convert.mjs` self-installs its one dependency (`marked`) into `assets/` on first run, so no setup is needed.
 
diff --git a/internal/embedded/default-skills/ai-docs/assets/index.html b/internal/embedded/default-skills/ai-docs/assets/index.html
index 174a0e8..7efd5aa 100644
--- a/internal/embedded/default-skills/ai-docs/assets/index.html
+++ b/internal/embedded/default-skills/ai-docs/assets/index.html
@@ -4,6 +4,14 @@
 
 
 AI-docs
+
 
 
 
@@ -39,7 +47,9 @@
   ::-webkit-scrollbar-corner { background: transparent; }
 
   /* sidebar */
-  aside.sidebar { background: var(--mantle); }
+  aside.sidebar { background: var(--mantle); transition: width 120ms ease, visibility 0s; }
+  /* visibility (not just width:0) keeps the collapsed tree out of the tab order and a11y tree; delayed so it survives the width animation. */
+  html.sidebar-collapsed aside.sidebar { width: 0; overflow: hidden; visibility: hidden; transition: width 120ms ease, visibility 0s linear 120ms; }
   .nav-folder { margin-top: 0.1rem; }
   .nav-folder + .nav-folder { margin-top: 0.4rem; }
   .nav-section { display: flex; align-items: center; gap: 0.35rem; width: 100%; text-align: left; background: transparent; border: 0; cursor: pointer; color: var(--overlay1); font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.55rem 0.85rem 0.35rem 0.7rem; font-weight: 500; font-family: inherit; }
@@ -55,6 +65,8 @@
 
   /* top bar */
   .top-bar { background: color-mix(in srgb, var(--base) 80%, transparent); backdrop-filter: blur(10px); }
+  #toc-toggle { display: none; }
+  html.has-toc #toc-toggle { display: flex; }
 
   /* prose */
   .prose { color: var(--text); max-width: 120ch; font-size: 0.95rem; line-height: 1.7; }
@@ -83,9 +95,12 @@
   .prose blockquote { background: var(--mantle); border-radius: 8px; padding: 0.7rem 1rem; margin: 1rem 0; color: var(--subtext1); }
   .prose blockquote p { margin: 0.25rem 0; }
   .prose hr { border: none; height: 1px; background: var(--hair); margin: 2.25rem 0; }
-  .prose table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.88rem; background: var(--mantle); border-radius: 8px; overflow: hidden; }
+  .table-wrap { max-width: 100%; overflow-x: auto; margin: 1rem 0; background: var(--mantle); border-radius: 8px; transition: box-shadow 120ms ease; }
+  .table-wrap.scroll-cue { box-shadow: inset -2rem 0 1.5rem -1.5rem var(--crust); }
+  .prose table { --table-col-min: 7rem; width: 100%; border-collapse: collapse; margin: 0; font-size: 0.88rem; }
   .prose th { text-align: left; padding: 0.55rem 0.85rem; background: color-mix(in srgb, var(--surface0) 60%, var(--mantle)); color: var(--lavender); font-weight: 600; font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.05em; }
   .prose td { padding: 0.55rem 0.85rem; vertical-align: top; }
+  .prose th, .prose td { min-width: var(--table-col-min); overflow-wrap: anywhere; }
   .prose tbody tr + tr td { border-top: 1px solid var(--hair); }
   .prose img { max-width: 100%; border-radius: 8px; }
 
@@ -143,8 +158,10 @@
 
   /* note (no left border bar) */
   .note { background: var(--mantle); border-radius: 8px; padding: 0.7rem 1rem; margin: 1rem 0; font-size: 0.9rem; color: var(--subtext1); display: flex; gap: 0.55rem; align-items: flex-start; }
-  .note .note-icon { color: var(--overlay1); flex-shrink: 0; margin-top: 0.15rem; }
+  /* centers the 1rem icon on the first line box rather than on the text's ascender */
+  .note .note-icon { color: var(--overlay1); flex-shrink: 0; margin-top: calc((1lh - 1rem) / 2); }
   .note p { margin: 0.15rem 0; }
+  .note p:first-of-type { margin-top: 0; }
   .note-mauve .note-icon { color: var(--mauve); }
   .note-yellow .note-icon { color: var(--yellow); }
   .note-red .note-icon { color: var(--red); }
@@ -162,7 +179,7 @@
   .cols-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
   .cols-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 1rem 0; }
   @media (max-width: 900px) { .cols-2, .cols-3 { grid-template-columns: 1fr; } }
-  .col { background: var(--mantle); border-radius: 8px; padding: 0.85rem 1rem; }
+  .col { background: var(--mantle); border-radius: 8px; padding: 0.85rem 1rem; min-width: 0; }
   .col h4 { margin-top: 0; }
 
   /* chips */
@@ -208,7 +225,8 @@
   .toc a.active { color: var(--lavender); background: var(--surface0); }
   .toc .toc-h3 a { padding-left: 1.6rem; font-size: 0.74rem; color: var(--overlay1); }
   .toc .toc-h3 a:hover, .toc .toc-h3 a.active { color: var(--subtext1); }
-  @media (max-width: 1320px) { .toc { display: none; } }
+  html.toc-collapsed .toc, html:not(.has-toc) .toc { display: none; }
+  @media (max-width: 1320px) { .toc { display: none; } html.has-toc #toc-toggle { display: none; } }
 
 
 
@@ -223,12 +241,22 @@
 
 
- -
- + +
+
+ +
+ +
@@ -239,6 +267,7 @@ @@ -248,9 +277,12 @@ const breadcrumb = document.getElementById('breadcrumb'); const exportBtn = document.getElementById('export-btn'); const tocEl = document.getElementById('toc'); + const sidebarToggle = document.getElementById('sidebar-toggle'); + const tocToggle = document.getElementById('toc-toggle'); let currentDoc = null; let tocObserver = null; + let tableObserver = null; marked.setOptions({ gfm: true, breaks: false, headerIds: false, mangle: false }); @@ -265,6 +297,18 @@ const saveCollapsed = (s) => { try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...s])); } catch {} }; const collapsed = getCollapsed(); + const SIDEBAR_KEY = 'docs-sidebar-collapsed-v1'; + const TOC_KEY = 'docs-toc-collapsed-v1'; + + function bindRailToggle(btn, cls, key) { + btn.addEventListener('click', () => { + const on = document.documentElement.classList.toggle(cls); + try { localStorage.setItem(key, on ? '1' : '0'); } catch {} + }); + } + bindRailToggle(sidebarToggle, 'sidebar-collapsed', SIDEBAR_KEY); + bindRailToggle(tocToggle, 'toc-collapsed', TOC_KEY); + const INDENT_STEP = 0.75; const BASE_PAD = 0.7; @@ -339,7 +383,7 @@ return item; } - async function loadDocs() { + async function loadDocs(opts) { const res = await fetch('/api/docs'); const items = await res.json(); treeEl.innerHTML = ''; @@ -348,7 +392,8 @@ const hash = decodeURIComponent(location.hash.slice(1)); const target = hash ? revealInTree(hash) : null; - (target || treeEl.querySelector('.nav-item'))?.click(); + const el = target || treeEl.querySelector('.nav-item'); + if (el) await loadDoc(el.dataset.path, el.dataset.type, opts); } function highlightCode() { @@ -442,9 +487,31 @@ }); } + const updateScrollCue = (wrap) => wrap.classList.toggle('scroll-cue', wrap.scrollWidth - wrap.clientWidth - wrap.scrollLeft > 1); + const onTableScroll = (e) => updateScrollCue(e.currentTarget); + + function wrapTables() { + if (tableObserver) { tableObserver.disconnect(); tableObserver = null; } + contentEl.querySelectorAll('table').forEach(table => { + if (table.closest('.table-wrap')) return; + const wrap = document.createElement('div'); + wrap.className = 'table-wrap'; + table.parentNode.insertBefore(wrap, table); + wrap.appendChild(table); + }); + // One observer catches every width change at once: window resize and either rail collapsing. + tableObserver = new ResizeObserver(entries => entries.forEach(e => updateScrollCue(e.target))); + contentEl.querySelectorAll('.table-wrap').forEach(wrap => { + wrap.addEventListener('scroll', onTableScroll); + tableObserver.observe(wrap); + updateScrollCue(wrap); + }); + } + function buildToc() { if (tocObserver) { tocObserver.disconnect(); tocObserver = null; } const headings = [...contentEl.querySelectorAll('h2, h3')]; + document.documentElement.classList.toggle('has-toc', headings.length >= 3); if (headings.length < 3) { tocEl.innerHTML = ''; return; } const seen = new Set(); @@ -474,7 +541,7 @@ headings.forEach(h => tocObserver.observe(h)); } - async function loadDoc(path, type) { + async function loadDoc(path, type, { preserveScroll = false } = {}) { currentDoc = { path, type }; location.hash = encodeURIComponent(path); document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.path === path)); @@ -488,9 +555,10 @@ } highlightCode(); decorateCopyables(); + wrapTables(); buildToc(); rebuildIcons(); - document.querySelector('main').scrollTo({ top: 0, behavior: 'instant' }); + if (!preserveScroll) document.querySelector('main').scrollTo({ top: 0, behavior: 'instant' }); } exportBtn.addEventListener('click', () => { @@ -515,7 +583,7 @@ liveReloadTimer = setTimeout(async () => { const main = document.querySelector('main'); const y = main ? main.scrollTop : 0; - await loadDocs(); + await loadDocs({ preserveScroll: true }); if (main) main.scrollTo({ top: y, behavior: 'instant' }); }, 120); } diff --git a/internal/embedded/default-skills/ai-docs/assets/print.html b/internal/embedded/default-skills/ai-docs/assets/print.html index 75f9125..d12084d 100644 --- a/internal/embedded/default-skills/ai-docs/assets/print.html +++ b/internal/embedded/default-skills/ai-docs/assets/print.html @@ -64,6 +64,7 @@ .prose table { width: 100%; border-collapse: collapse; margin: 0.7rem 0; font-size: 8.5pt; background: var(--mantle); border-radius: 7px; overflow: hidden; page-break-inside: avoid; } .prose th { text-align: left; padding: 0.4rem 0.65rem; background: color-mix(in srgb, var(--surface0) 60%, var(--mantle)); color: var(--lavender); font-weight: 600; font-size: 7.5pt; text-transform: uppercase; letter-spacing: 0.05em; } .prose td { padding: 0.4rem 0.65rem; vertical-align: top; } + .prose th, .prose td { overflow-wrap: anywhere; } .prose tbody tr + tr td { border-top: 1px solid var(--hair); } .hljs { background: transparent; color: var(--text); } diff --git a/internal/embedded/default-skills/ai-docs/references/component-vocabulary.md b/internal/embedded/default-skills/ai-docs/references/component-vocabulary.md index 34e3fd2..3c2ec6f 100644 --- a/internal/embedded/default-skills/ai-docs/references/component-vocabulary.md +++ b/internal/embedded/default-skills/ai-docs/references/component-vocabulary.md @@ -142,6 +142,8 @@ Inline status tags. Plain markdown-style HTML (``, `
    `, `
      `, `

      `, `

      `) is already styled. Use tables for comparisons, lists when there are more than two items, prose for everything else. A right-hand table of contents is generated automatically from `

      ` and `

      ` once a doc has 3+ of them — so lean on headings to make long docs navigable. +Write a plain `

`: the viewer auto-wraps it in a scroll container so a wide table wraps its cells while there is room and scrolls horizontally inside its own box once columns hit their readable floor. Don't hand-wrap tables in a div and don't add wrapper classes. + ## Copy buttons (automatic — author nothing) The viewer injects a hover copy control onto every code block and every diagram, so you never hand-author copy UI: From a534c13db8d5ccb8dab5472f6f085eaea1853503 Mon Sep 17 00:00:00 2001 From: Tanq16 <37408906+Tanq16@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:41:14 -0400 Subject: [PATCH 2/2] ai-docs: drop comments that restate the code --- internal/embedded/default-skills/ai-docs/assets/index.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/embedded/default-skills/ai-docs/assets/index.html b/internal/embedded/default-skills/ai-docs/assets/index.html index 7efd5aa..ac8255e 100644 --- a/internal/embedded/default-skills/ai-docs/assets/index.html +++ b/internal/embedded/default-skills/ai-docs/assets/index.html @@ -48,7 +48,7 @@ /* sidebar */ aside.sidebar { background: var(--mantle); transition: width 120ms ease, visibility 0s; } - /* visibility (not just width:0) keeps the collapsed tree out of the tab order and a11y tree; delayed so it survives the width animation. */ + /* width:0 alone would leave the collapsed tree focusable; the delay keeps visibility from cutting the width animation short. */ html.sidebar-collapsed aside.sidebar { width: 0; overflow: hidden; visibility: hidden; transition: width 120ms ease, visibility 0s linear 120ms; } .nav-folder { margin-top: 0.1rem; } .nav-folder + .nav-folder { margin-top: 0.4rem; } @@ -158,7 +158,6 @@ /* note (no left border bar) */ .note { background: var(--mantle); border-radius: 8px; padding: 0.7rem 1rem; margin: 1rem 0; font-size: 0.9rem; color: var(--subtext1); display: flex; gap: 0.55rem; align-items: flex-start; } - /* centers the 1rem icon on the first line box rather than on the text's ascender */ .note .note-icon { color: var(--overlay1); flex-shrink: 0; margin-top: calc((1lh - 1rem) / 2); } .note p { margin: 0.15rem 0; } .note p:first-of-type { margin-top: 0; } @@ -499,7 +498,6 @@ table.parentNode.insertBefore(wrap, table); wrap.appendChild(table); }); - // One observer catches every width change at once: window resize and either rail collapsing. tableObserver = new ResizeObserver(entries => entries.forEach(e => updateScrollCue(e.target))); contentEl.querySelectorAll('.table-wrap').forEach(wrap => { wrap.addEventListener('scroll', onTableScroll);