From ff888ae445e92462b1ab524824d53cfc33676e81 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Thu, 9 Jul 2026 14:46:34 +0800 Subject: [PATCH 01/12] feat(home): add golden-ratio landing, brand identity, and hover previews Build-time page-preview loader powers Wikipedia-style hovercards; add hub back-link and page transitions. Brand blue #124689 from the logo, with a monospace voice for metadata. Nav narrowed to about / guide / repair. --- .vitepress/config.mts | 20 +-- .vitepress/theme/ConceptPreview.vue | 166 ++++++++++++++++++++++++ .vitepress/theme/HubBackLink.vue | 30 +++++ .vitepress/theme/Layout.vue | 18 +++ .vitepress/theme/concepts.data.ts | 139 ++++++++++++++++++++ .vitepress/theme/index.mts | 2 + .vitepress/theme/style.css | 189 ++++++++++++++++++++++++++++ index.md | 19 ++- 8 files changed, 562 insertions(+), 21 deletions(-) create mode 100644 .vitepress/theme/ConceptPreview.vue create mode 100644 .vitepress/theme/HubBackLink.vue create mode 100644 .vitepress/theme/Layout.vue create mode 100644 .vitepress/theme/concepts.data.ts diff --git a/.vitepress/config.mts b/.vitepress/config.mts index b54e729..6de432d 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -1,8 +1,7 @@ import { withMermaid } from 'vitepress-plugin-mermaid' +import { sidebar as sidebarAbout } from '../about/sidebar' import { sidebar as sidebarArchived } from '../archived/sidebar' -import { sidebar as sidebarProcess } from '../process/sidebar' -import { sidebar as sidebarRepair } from '../repair/sidebar' -import { sidebar as sidebarTutorial } from '../tutorial/sidebar' +import { sidebar as sidebarGuide } from '../tutorial/sidebar' // https://vitepress.dev/reference/site-config export default withMermaid({ @@ -15,18 +14,19 @@ export default withMermaid({ themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ - { text: '教程', link: '/tutorial/' }, - { text: '流程', link: '/process/' }, - { text: '维修', link: '/repair/guide' }, - { text: '存档', link: '/archived' }, + { text: '关于', link: '/about/' }, + { text: '指南', link: '/tutorial/' }, + { text: '维修', link: '/repair/' }, ], search: { provider: 'local', }, sidebar: { - '/tutorial/': sidebarTutorial, - '/process/': sidebarProcess, - '/repair/': sidebarRepair, + '/about/': sidebarAbout, + // Guide = tutorial + process; one shared sidebar mounted on both paths. + '/tutorial/': sidebarGuide, + '/process/': sidebarGuide, + // repair and concepts use a hub + inline-link + search model; no full sidebar. '/archived': sidebarArchived, }, editLink: { diff --git a/.vitepress/theme/ConceptPreview.vue b/.vitepress/theme/ConceptPreview.vue new file mode 100644 index 0000000..ad9adb2 --- /dev/null +++ b/.vitepress/theme/ConceptPreview.vue @@ -0,0 +1,166 @@ + + + diff --git a/.vitepress/theme/HubBackLink.vue b/.vitepress/theme/HubBackLink.vue new file mode 100644 index 0000000..1151bad --- /dev/null +++ b/.vitepress/theme/HubBackLink.vue @@ -0,0 +1,30 @@ + + + diff --git a/.vitepress/theme/Layout.vue b/.vitepress/theme/Layout.vue new file mode 100644 index 0000000..8d5ce6f --- /dev/null +++ b/.vitepress/theme/Layout.vue @@ -0,0 +1,18 @@ + + + diff --git a/.vitepress/theme/concepts.data.ts b/.vitepress/theme/concepts.data.ts new file mode 100644 index 0000000..ce0772f --- /dev/null +++ b/.vitepress/theme/concepts.data.ts @@ -0,0 +1,139 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// Indexes every content page by URL path, title and first paragraph, for hover previews. +export interface PagePreview { + path: string + title: string + summary: string +} + +declare const data: PagePreview[] +export { data } + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +) +const contentDirs = ['about', 'process', 'repair', 'tutorial', 'concepts', 'archived'] + +function cleanInline(s: string): string { + return s + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +function truncate(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n).trimEnd()}…` : s +} + +function stripFrontmatter(src: string): string { + if (!src.startsWith('---')) + return src + const end = src.indexOf('\n---', 3) + if (end === -1) + return src + const after = src.indexOf('\n', end + 1) + return after === -1 ? '' : src.slice(after + 1) +} + +function extractTitle(src: string): string | undefined { + const m = stripFrontmatter(src).match(/^#[ \t]+(\S.*)$/m) + return m ? cleanInline(m[1]) : undefined +} + +// First prose paragraph, skipping headings, `:::` blocks, fenced code, tables, lists and quotes. +function extractSummary(src: string): string { + const lines = stripFrontmatter(src).split(/\r?\n/) + let inContainer = false + let inFence = false + let current: string[] = [] + let paragraph = '' + + for (const raw of lines) { + const line = raw.trim() + if (line.startsWith('```') || line.startsWith('~~~')) { + inFence = !inFence + continue + } + if (inFence) + continue + if (line.startsWith(':::')) { + inContainer = !inContainer + continue + } + if (inContainer) + continue + if (line === '') { + if (current.length) { + paragraph = current.join(' ') + break + } + continue + } + if (/^(?:[#>|]|[-*+]\s|\d+\.\s)/.test(line)) { + current = [] + continue + } + current.push(line) + } + if (!paragraph && current.length) + paragraph = current.join(' ') + + return truncate(cleanInline(paragraph), 140) +} + +function walk(dir: string): string[] { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } + catch { + return [] + } + const out: string[] = [] + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) + out.push(...walk(full)) + else if (entry.name.endsWith('.md')) + out.push(full) + } + return out +} + +function toUrlPath(file: string): string { + const rel = path.relative(rootDir, file).replace(/\\/g, '/').replace(/\.md$/, '') + if (rel === 'index') + return '/' + if (rel.endsWith('/index')) + return `/${rel.slice(0, -'index'.length)}` + return `/${rel}` +} + +function loadPages(): PagePreview[] { + return contentDirs + .flatMap(dir => walk(path.join(rootDir, dir))) + .map((file) => { + const src = fs.readFileSync(file, 'utf-8') + const title = extractTitle(src) + if (!title) + return null + return { path: toUrlPath(file), title, summary: extractSummary(src) } + }) + .filter((x): x is PagePreview => x !== null) +} + +export default { + watch: contentDirs.map(dir => path.join(rootDir, dir, '**/*.md')), + load(): PagePreview[] { + return loadPages() + }, +} diff --git a/.vitepress/theme/index.mts b/.vitepress/theme/index.mts index 9672a74..50c4f60 100644 --- a/.vitepress/theme/index.mts +++ b/.vitepress/theme/index.mts @@ -1,5 +1,6 @@ import type { EnhanceAppContext, Theme } from 'vitepress' import DefaultTheme from 'vitepress/theme' +import Layout from './Layout.vue' import './style.css' let transitionTimer: number | undefined @@ -15,6 +16,7 @@ function clearTransition(content: HTMLElement) { export default { extends: DefaultTheme, + Layout, enhanceApp({ router }: EnhanceAppContext) { if (typeof window === 'undefined') return diff --git a/.vitepress/theme/style.css b/.vitepress/theme/style.css index 61218d8..a86c58d 100644 --- a/.vitepress/theme/style.css +++ b/.vitepress/theme/style.css @@ -54,3 +54,192 @@ height: 1rem; vertical-align: middle; } + +/* Narrow tables (e.g. leadership roster): no wrap; scroll instead of squishing on small screens. */ +.vp-doc .nb-people-table th, +.vp-doc .nb-people-table td { + white-space: nowrap; +} + +/* NBTCA visual identity. Brand blue #124689 from the logo; metadata/dates use a + monospace "machine voice" against prose. */ + +:root { + --vp-c-brand-1: #124689; + --vp-c-brand-2: #0f3a73; + --vp-c-brand-3: #17529c; + --vp-c-brand-soft: rgba(18, 70, 137, 0.1); + + --nb-mono: var(--vp-font-family-mono); +} + +.dark { + --vp-c-brand-1: #7db0ec; + --vp-c-brand-2: #9cc4f2; + --vp-c-brand-3: #2f6bbd; + --vp-c-brand-soft: rgba(125, 176, 236, 0.16); +} + +/* Home hero — monospace tagline. */ +.VPHero .name { + letter-spacing: -0.02em; +} + +.VPHero .tagline { + font-family: var(--nb-mono); + font-size: 15px; + line-height: 1.6; + letter-spacing: 0.005em; + color: var(--vp-c-text-2); +} + +/* Home composition — centre the hero in the content area; spacing/split follow φ. */ +@media (min-width: 960px) { + .VPContent.is-home { + display: flex; + min-height: calc(100vh - var(--vp-nav-height)); + align-items: center; + } + + .VPContent.is-home .VPHome { + width: 100%; + } + + .VPContent.is-home .VPHero { + padding-top: 0; + padding-bottom: 8vh; + } + + .VPContent.is-home .VPHero .container { + align-items: center; + } + + /* text : emblem ≈ φ */ + .VPContent.is-home .VPHero .main { + flex-grow: 1.618; + } + + .VPContent.is-home .VPHero .image { + flex-grow: 1; + } + + /* gaps step by φ: 13 → 21 → 34 */ + .VPContent.is-home .VPHero .tagline { + margin-top: 21px; + } + + .VPContent.is-home .VPHero .actions { + margin-top: 34px; + } +} + +/* Callouts — calmer surfaces, monospace kicker titles. */ +.vp-doc .custom-block { + border-radius: 8px; +} + +.vp-doc .custom-block-title { + font-family: var(--nb-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; +} + +/* Maintainer-info blocks read as a small monospace stamp. */ +.vp-doc .custom-block.info table { + margin: 0; + font-family: var(--nb-mono); + font-size: 13px; +} + +.vp-doc .custom-block.info table th { + font-weight: 600; + color: var(--vp-c-text-2); +} + +/* Internal links: dotted, hover-previewable. */ +.vp-doc a:not([href^='http']):not([href^='#']):not([href^='mailto']):not(.header-anchor) { + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +/* External links: trailing ↗. */ +.vp-doc a[target='_blank']::after { + content: '↗'; + display: inline-block; + margin-left: 1px; + font-size: 0.72em; + font-weight: 400; + line-height: 0; + vertical-align: baseline; + color: var(--vp-c-text-3); +} + +/* "Back to hub" anchor for sidebar-less sections. */ +.nb-hub-back { + display: inline-block; + margin-bottom: 18px; + font-family: var(--nb-mono); + font-size: 13px; + color: var(--vp-c-text-2); + text-decoration: none; +} + +.nb-hub-back:hover { + color: var(--vp-c-brand-1); +} + +/* Hover preview card. */ +.nb-concept-card { + position: fixed; + z-index: 60; + width: 320px; + max-width: calc(100vw - 24px); + padding: 14px 16px 15px; + background: var(--vp-c-bg); + border: 1px solid var(--vp-c-divider); + border-radius: 10px; + box-shadow: + 0 12px 32px rgba(0, 0, 0, 0.12), + 0 2px 6px rgba(0, 0, 0, 0.06); +} + +.nb-concept-card.is-above { + transform: translateY(-100%); +} + +.nb-cp-kicker { + margin-bottom: 5px; + font-family: var(--nb-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--vp-c-brand-1); +} + +.nb-cp-title { + margin-bottom: 6px; + font-size: 15px; + font-weight: 700; + line-height: 1.3; + color: var(--vp-c-text-1); +} + +.nb-cp-summary { + margin: 0; + font-size: 13px; + line-height: 1.62; + color: var(--vp-c-text-2); +} + +.nb-cp-enter-active, +.nb-cp-leave-active { + transition: opacity 0.14s ease; +} + +.nb-cp-enter-from, +.nb-cp-leave-to { + opacity: 0; +} diff --git a/index.md b/index.md index f00f724..7e430c8 100644 --- a/index.md +++ b/index.md @@ -11,23 +11,20 @@ hero: alt: NBTCA-Logo actions: - theme: brand - text: 教程 - link: /tutorial/index - - theme: brand - text: 流程 - link: /process/index + text: 认识 NBTCA + link: /about/what-is-nbtca - theme: alt - text: 维修 - link: /repair/guide + text: 指南 + link: /tutorial/ - theme: alt - text: 存档 - link: /archived/index + text: 维修 + link: /repair/ ---