diff --git a/.vitepress/components/BlogByline.vue b/.vitepress/components/BlogByline.vue index d57747e..9e7170b 100644 --- a/.vitepress/components/BlogByline.vue +++ b/.vitepress/components/BlogByline.vue @@ -35,104 +35,33 @@ const authors = computed(() => { diff --git a/.vitepress/components/BlogIndex.vue b/.vitepress/components/BlogIndex.vue index 1e41c3a..641a940 100644 --- a/.vitepress/components/BlogIndex.vue +++ b/.vitepress/components/BlogIndex.vue @@ -1,8 +1,5 @@ diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 4970a6e..04b2029 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -41,6 +41,16 @@ export default defineConfig({ ["link", { rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" }], ["link", { rel: "manifest", href: "/site.webmanifest" }], ["meta", { property: "og:type", content: "website" }], + // Type Specimen homepage typography (also used site-wide for display). + ["link", { rel: "preconnect", href: "https://fonts.googleapis.com" }], + ["link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" }], + [ + "link", + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Spectral:ital,opsz,wght@0,7..72,200..800;1,7..72,200..800&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&family=Roboto:wght@400;500&display=swap", + }, + ], ], // Per-page og:* and twitter:* tags are derived from each page's frontmatter @@ -133,7 +143,6 @@ export default defineConfig({ ], footer: { - message: `Fontist is riboseopen`, copyright: `Copyright © 2026 Ribose Group Inc. All rights reserved.`, }, }, diff --git a/.vitepress/formula-count.data.ts b/.vitepress/formula-count.data.ts new file mode 100644 index 0000000..17c82b4 --- /dev/null +++ b/.vitepress/formula-count.data.ts @@ -0,0 +1,21 @@ +// Build-time data loader: fetches live formula/font counts from the +// formulas subsite's stats.json. Falls back to static numbers if the +// fetch fails (offline build, formulas site down, etc.). +export default { + async load() { + const fallback = { formulaCount: 4283, openSourceCount: 2143 }; + try { + const res = await fetch( + "https://www.fontist.org/formulas/stats.json", + ); + if (!res.ok) return fallback; + const data = await res.json(); + return { + formulaCount: data.total || fallback.formulaCount, + openSourceCount: data.licenses?.open_source || fallback.openSourceCount, + }; + } catch { + return fallback; + } + }, +}; diff --git a/.vitepress/theme/style.css b/.vitepress/theme/style.css index ba66915..6721a1c 100644 --- a/.vitepress/theme/style.css +++ b/.vitepress/theme/style.css @@ -1,5 +1,12 @@ +/* ================================================================ + Fontist — Type Specimen design system (site-wide) + One palette + type system applied across every surface: + body, VPNav, VPFooter, and .vp-doc (all content pages). + ================================================================ */ + +/* ── Brand + specimen palette ─────────────────────────────────── */ :root { - /* Brand Colors from Logo */ + /* Brand colors from the logo */ --fontist-rose: #bf4e6a; --fontist-rose-light: #d4718a; --fontist-dark: #4d4b54; @@ -8,145 +15,699 @@ --fontist-beige: #bebbac; --fontist-pale: #dddac8; - /* VitePress Theme Overrides - Light Mode */ - --vp-c-brand-1: var(--fontist-rose); + /* Specimen tokens — LIGHT (default) */ + --spec-paper: #f1ece1; + --spec-paper-deep: #e7e0d1; + --spec-ink: #1c1a18; + --spec-ink-soft: #4a4744; + --spec-mute: #75716c; + --spec-rose: #b8475f; + --spec-rose-soft: #d4718a; + --spec-rule: rgba(28, 26, 24, 0.16); + --spec-rule-strong: rgba(28, 26, 24, 0.5); + --spec-term-bg: #1c1a18; + --spec-term-ink: #ecdfd0; + + /* VitePress brand wiring */ + --vp-c-brand-1: var(--spec-rose); --vp-c-brand-2: #a3435a; - --vp-c-brand-3: var(--fontist-dark); - --vp-c-brand-soft: rgba(191, 78, 106, 0.14); - - /* Text Colors */ - --vp-c-text-1: var(--fontist-dark); - --vp-c-text-2: var(--fontist-gray); - --vp-c-text-3: #8a8888; - - /* Background Colors */ - --vp-c-bg-soft: #f8f7f4; - --vp-c-bg-alt: #f2f1ed; - --vp-c-bg: #ffffff; - - /* Hero */ - --vp-home-hero-name-color: var(--fontist-rose); - --vp-home-hero-name-background: linear-gradient( - 120deg, - var(--fontist-rose) 30%, - #d4718a - ); + --vp-c-brand-3: var(--spec-ink); + --vp-c-brand-soft: rgba(184, 71, 95, 0.12); + --vp-c-text-1: var(--spec-ink); + --vp-c-text-2: var(--spec-ink-soft); + --vp-c-text-3: var(--spec-mute); + --vp-c-bg: var(--spec-paper); + --vp-c-bg-soft: var(--spec-paper-deep); + --vp-c-bg-alt: var(--spec-paper-deep); + --vp-c-divider: var(--spec-rule); + --vp-c-gutter: var(--spec-rule); + + /* Nav bg must be opaque (paper) so scrolled content doesn't leak through */ + --vp-nav-bg-color: var(--spec-paper); + --vp-sidebar-bg-color: var(--spec-paper); + + /* Type */ + --spec-font-display: "Spectral", Georgia, serif; + --spec-font-body: "IBM Plex Sans", -apple-system, system-ui, sans-serif; + --spec-font-mono: "IBM Plex Mono", ui-monospace, "JetBrains Mono", monospace; + + /* Override VitePress's default Inter with our specimen fonts everywhere */ + --vp-font-family-base: var(--spec-font-body); + --vp-font-family-mono: var(--spec-font-mono); /* Buttons */ - --vp-button-brand-border: var(--fontist-rose); + --vp-button-brand-border: var(--spec-rose); --vp-button-brand-text: #ffffff; - --vp-button-brand-bg: var(--fontist-rose); + --vp-button-brand-bg: var(--spec-rose); --vp-button-brand-hover-border: #a3435a; --vp-button-brand-hover-text: #ffffff; --vp-button-brand-hover-bg: #a3435a; - --vp-button-brand-active-border: #8a3849; - --vp-button-brand-active-text: #ffffff; - --vp-button-brand-active-bg: #8a3849; - - /* Sidebar */ - --vp-sidebar-bg-color: var(--vp-c-bg-soft); - /* Nav */ - --vp-nav-bg-color: var(--vp-c-bg); + --vp-button-alt-border: var(--spec-rule-strong); + --vp-button-alt-text: var(--spec-ink); + --vp-button-alt-bg: transparent; + --vp-button-alt-hover-border: var(--spec-rose); + --vp-button-alt-hover-text: var(--spec-rose); + --vp-button-alt-hover-bg: transparent; } -/* Dark Mode */ +/* ── Dark mode specimen ───────────────────────────────────────── */ html.dark { - --vp-c-brand-1: var(--fontist-rose-light); + --spec-paper: #161513; + --spec-paper-deep: #211f1c; + --spec-ink: #ecdfd0; + --spec-ink-soft: #b8ada0; + --spec-mute: #8a857f; + --spec-rose: #d4718a; + --spec-rose-soft: #e08a9e; + --spec-rule: rgba(236, 223, 208, 0.14); + --spec-rule-strong: rgba(236, 223, 208, 0.42); + --spec-term-bg: #0d0c0c; + --spec-term-ink: #ecdfd0; + + --vp-c-brand-1: var(--spec-rose); --vp-c-brand-2: var(--fontist-rose); - --vp-c-brand-3: #e1dfd2; + --vp-c-brand-3: var(--spec-ink); --vp-c-brand-soft: rgba(212, 113, 138, 0.14); + --vp-c-text-1: var(--spec-ink); + --vp-c-text-2: var(--spec-ink-soft); + --vp-c-text-3: var(--spec-mute); + --vp-c-bg: var(--spec-paper); + --vp-c-bg-soft: var(--spec-paper-deep); + --vp-c-bg-alt: var(--spec-paper-deep); + --vp-c-divider: var(--spec-rule); + --vp-c-gutter: var(--spec-rule); - /* Text Colors - Dark Mode */ - --vp-c-text-1: #e1dfd2; - --vp-c-text-2: #bebbac; - --vp-c-text-3: #8a8888; - - /* Background Colors - Dark Mode */ - --vp-c-bg: #1a1918; - --vp-c-bg-soft: #222120; - --vp-c-bg-alt: #2a2826; - --vp-c-bg-elv: #2a2826; - - /* Hero - Dark Mode */ - --vp-home-hero-name-background: linear-gradient( - 120deg, - var(--fontist-rose-light) 30%, - #e08a9e - ); - - /* Buttons - Dark Mode */ - --vp-button-brand-border: var(--fontist-rose-light); - --vp-button-brand-text: #1a1918; - --vp-button-brand-bg: var(--fontist-rose-light); + --vp-button-brand-border: var(--spec-rose); + --vp-button-brand-text: #161513; + --vp-button-brand-bg: var(--spec-rose); --vp-button-brand-hover-border: var(--fontist-rose); - --vp-button-brand-hover-text: #1a1918; + --vp-button-brand-hover-text: #161513; --vp-button-brand-hover-bg: var(--fontist-rose); - --vp-button-brand-active-border: #a3435a; - --vp-button-brand-active-text: #1a1918; - --vp-button-brand-active-bg: #a3435a; } -/* Feature cards on home page - Light */ -.VPFeature .title { - color: var(--fontist-dark); +/* ── Site-wide body: paper + grain + base type ────────────────── */ +body { + background-color: var(--spec-paper); + font-family: var(--spec-font-body); + color: var(--spec-ink); } - -.VPFeature .details { - color: var(--fontist-gray); +/* paper grain overlay (every page) */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 200; + pointer-events: none; + opacity: 0.04; + background-image: url("data:image/svg+xml;utf8,"); + mix-blend-mode: multiply; +} +html.dark body::before { + opacity: 0.06; + mix-blend-mode: screen; } -.VPFeature:hover { - border-color: var(--fontist-rose); +/* ── Top navigation (VPNav) — specimen restyle ─────────────────── */ +.VPNavBar { + background-color: var(--spec-paper) !important; + border-bottom: 1px solid var(--spec-rule) !important; +} +.VPNavBar.has-sidebar .content, +.VPNavBar .content { + background-color: var(--spec-paper) !important; +} +.VPNavBar .content .curtain { + display: none !important; } -/* Feature cards - Dark */ -html.dark .VPFeature .title { - color: var(--fontist-cream); +/* Nav links + dropdown groups: mono, tracked */ +.VPNavBarMenuLink, +.VPNavBarMenuGroup { + font-family: var(--spec-font-mono) !important; + font-size: 12px !important; + letter-spacing: 0.14em !important; + text-transform: uppercase !important; + color: var(--spec-ink-soft) !important; + font-weight: 500 !important; + transition: color 0.2s ease; +} +.VPNavBarMenuLink:hover, +.VPNavBarMenuGroup:hover { + color: var(--spec-rose) !important; +} +.VPNavBarMenuLink .text, +.VPNavBarMenuGroup .text { + font-family: inherit !important; +} +/* Dropdown button — VitePress styles .button directly, so inheritance isn't enough */ +.VPNavBarMenuGroup .button, +.VPNavBarExtra .button { + font-family: var(--spec-font-mono) !important; + font-size: 12px !important; + letter-spacing: 0.14em !important; + text-transform: uppercase !important; + font-weight: 500 !important; + color: var(--spec-ink-soft) !important; + line-height: 64px !important; + transition: color 0.2s ease; +} +.VPNavBarMenuGroup:hover .button, +.VPNavBarExtra:hover .button { + color: var(--spec-rose) !important; +} +/* VitePress renders the visible text in an inner .text span with its own + font-size (14px) and color — must target it directly, not via .button */ +.VPNavBarMenuGroup .button .text, +.VPNavBarExtra .button .text { + font-size: 12px !important; + color: var(--spec-ink-soft) !important; + font-weight: 500 !important; +} +.VPNavBarMenuGroup:hover .button .text, +.VPNavBarExtra:hover .button .text { + color: var(--spec-rose) !important; } -html.dark .VPFeature .details { - color: var(--fontist-beige); +/* Flyout menu panel (shared by Integrations dropdown + ellipsis extra menu) */ +.VPMenu { + background: var(--spec-paper) !important; + border: 1px solid var(--spec-rule) !important; + border-radius: 0 !important; + box-shadow: none !important; +} +.VPMenu .group-title { + font-family: var(--spec-font-mono) !important; + font-size: 11px !important; + letter-spacing: 0.14em !important; + text-transform: uppercase !important; + color: var(--spec-mute) !important; +} +/* Menu links — must out-specify VitePress's .VPLink.link (0-2-0), + so we chain .VPMenu parent to reach 0-2-1. Actual DOM: + .VPMenu > .items > .VPMenuLink > a.VPLink.link > span */ +.VPMenu .VPMenuLink a, +.VPMenu .VPMenuLink a span { + font-family: var(--spec-font-mono) !important; + font-size: 12px !important; + letter-spacing: 0.14em !important; + text-transform: uppercase !important; + font-weight: 500 !important; + color: var(--spec-ink-soft) !important; + transition: color 0.2s ease, background 0.2s ease; +} +.VPMenu .VPMenuLink a:hover { + color: var(--spec-rose) !important; + background: var(--spec-paper-deep) !important; +} +.VPMenu .VPMenuLink a:hover span { + color: var(--spec-rose) !important; +} +/* Ellipsis menu: Appearance label + item labels — .VPMenu parent + boosts specificity above VitePress's scoped component styles */ +.VPMenu .item .label, +.VPMenu .group .item { + font-family: var(--spec-font-mono) !important; + font-size: 12px !important; + letter-spacing: 0.14em !important; + text-transform: uppercase !important; + font-weight: 500 !important; + color: var(--spec-ink-soft) !important; } -/* Links */ -.vp-doc a { - color: var(--fontist-rose); +/* Logo / title */ +.VPNavBar .VPNavBarTitle .title { + font-family: var(--spec-font-display) !important; + font-weight: 400 !important; + font-size: 18px !important; + letter-spacing: 0.02em !important; + color: var(--spec-ink) !important; +} +.VPNav .VPImage { + height: 32px; } -.vp-doc a:hover { - color: #a3435a; +/* Social links + appearance toggle + search: quieter */ +.VPNavBar .VPNavBarSocialLinks .social-link, +.VPNavBar .VPSocialLink, +.VPNavBarAppearance, +.VPNavBarExtra { + color: var(--spec-ink-soft) !important; +} +.VPNavBar .VPNavBarSearch { + --vp-nav-height: 56px; +} +.VPNavBarSearch .DocSearch-Button { + background: transparent !important; + border: 1px solid var(--spec-rule) !important; + font-family: var(--spec-font-mono) !important; + font-size: 12px !important; + letter-spacing: 0.06em !important; +} +.VPNavBarSearch .DocSearch-Button:hover { + border-color: var(--spec-rose) !important; } -html.dark .vp-doc a { - color: var(--fontist-rose-light); +/* ── Footer — specimen colophon ───────────────────────────────── */ +.VPFooter { + border-top: 1px solid var(--spec-rule) !important; + background-color: transparent !important; + padding: 28px 24px 40px !important; +} +.VPFooter .copyright, +.VPFooter .message { + font-family: var(--spec-font-mono) !important; + font-size: 11px !important; + letter-spacing: 0.1em !important; + color: var(--spec-mute) !important; +} +.VPFooter .message a { + color: var(--spec-ink-soft) !important; + border-bottom: 1px solid var(--spec-rule-strong); + text-decoration: none; +} +.VPFooter .message a:hover { + color: var(--spec-rose) !important; } -html.dark .vp-doc a:hover { - color: var(--fontist-rose); +/* ── .vp-doc typography — every content page (about, blog, docs) ─ */ +.vp-doc { + font-family: var(--spec-font-body); + color: var(--spec-ink); +} +.vp-doc h1, +.vp-doc h2, +.vp-doc h3, +.vp-doc h4, +.vp-doc h5, +.vp-doc h6 { + font-family: var(--spec-font-display); + color: var(--spec-ink); + letter-spacing: -0.018em; + font-weight: 380; +} +.vp-doc h1 { + font-size: clamp(40px, 6vw, 64px); + font-weight: 360; + font-variation-settings: "opsz" 120; + line-height: 1.02; + letter-spacing: -0.025em; + margin-top: 0; +} +.vp-doc h2 { + font-size: clamp(28px, 3.4vw, 40px); + font-variation-settings: "opsz" 60; + border-top: 1px solid var(--spec-rule); + padding-top: 1.6em; + margin-top: 2.2em; +} +.vp-doc h3 { + font-size: clamp(20px, 2vw, 24px); + font-variation-settings: "opsz" 36; +} +.vp-doc p, +.vp-doc li { + font-size: 16.5px; + line-height: 1.65; + color: var(--spec-ink-soft); +} +.vp-doc p { + margin: 1.1em 0; +} +.vp-doc a { + color: var(--spec-rose); + text-decoration: none; + border-bottom: 1px solid var(--spec-rule); + transition: color 0.2s, border-color 0.2s; +} +.vp-doc a:hover { + color: var(--spec-rose); + border-color: var(--spec-rose); +} +.vp-doc strong { + color: var(--spec-ink); + font-weight: 600; +} +.vp-doc blockquote { + font-family: var(--spec-font-display); + font-style: italic; + font-weight: 380; + font-size: 1.25em; + line-height: 1.5; + color: var(--spec-ink); + border-left: 3px solid var(--spec-rose); + padding-left: 1.5em; + margin: 2em 0; +} +.vp-doc hr { + border: none; + border-top: 1px solid var(--spec-rule-strong); + width: 48px; + margin: 3em auto; +} +/* Drop cap — rose Spectral italic on the first paragraph of content pages */ +.vp-doc > p:first-of-type::first-letter { + font-family: var(--spec-font-display); + font-style: italic; + font-weight: 460; + font-size: 3.4em; + float: left; + line-height: 0.8; + padding: 0.04em 0.1em 0 0; + color: var(--spec-rose); + font-variation-settings: "opsz" 72, "wght" 520; +} +/* Code blocks — light: warm paper-deep (matches Shiki light tokens); + dark: terminal ink (matches Shiki dark tokens) */ +.vp-doc [class*="language-"] { + background-color: var(--spec-paper-deep) !important; + border: 1px solid var(--spec-rule); + font-family: var(--spec-font-mono); + font-size: 13.5px; +} +html.dark .vp-doc [class*="language-"] { + background-color: var(--spec-term-bg) !important; +} +.vp-doc :not(pre) > code { + font-family: var(--spec-font-mono); + font-size: 0.9em; + background: var(--spec-paper-deep); + border: 1px solid var(--spec-rule); + padding: 0.1em 0.4em; + border-radius: 2px; + color: var(--spec-ink); +} +/* Tables */ +.vp-doc table { + font-size: 14px; + border-collapse: collapse; +} +.vp-doc table th, +.vp-doc table td { + border: 1px solid var(--spec-rule); + padding: 8px 12px; +} +.vp-doc table th { + font-family: var(--spec-font-mono); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--spec-mute); + background: var(--spec-paper-deep); } -/* Footer */ -.VPFooter { - background-color: var(--vp-c-bg-soft); - border-top: 1px solid var(--fontist-pale); +/* ── Sidebar (if used) ────────────────────────────────────────── */ +.VPSidebar { + background-color: var(--spec-paper) !important; + border-right: 1px solid var(--spec-rule) !important; + font-family: var(--spec-font-mono); +} +.VPSidebar .VPSidebarItem .text { + font-size: 13px; } -html.dark .VPFooter { - border-top-color: #3a3836; +/* ── Homepage palette (pageClass: specimen-home) ──────────────── */ +/* Specimen tokens are now global (above); the pageClass remains on + index.md for any homepage-only tweaks, but inherits the system. */ +.specimen-home { + background-color: var(--spec-paper); } -/* Code blocks - Light */ -.vp-doc [class*="language-"] { - background-color: var(--fontist-dark); +/* ── 404 page — specimen ───────────────────────────────────────── */ +.NotFound { + text-align: center; + padding: clamp(3rem, 8vw, 6rem) 1.5rem clamp(4rem, 10vw, 8rem); + font-family: var(--spec-font-body); +} +.NotFound .code { + font-family: var(--spec-font-display); + font-style: italic; + font-weight: 300; + font-variation-settings: "opsz" 144; + font-size: clamp(120px, 24vw, 280px); + line-height: 1; + color: var(--spec-rose); + opacity: 0.9; + margin: 0 0 0.5rem; +} +.NotFound .title { + font-family: var(--spec-font-mono); + font-size: 12px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--spec-ink-soft); + margin: 0; +} +.NotFound .divider { + width: 60px; + border-top: 1px solid var(--spec-rule-strong); + margin: 2rem auto; +} +.NotFound .quote { + font-family: var(--spec-font-display); + font-style: italic; + font-weight: 380; + font-size: clamp(18px, 2.2vw, 26px); + line-height: 1.5; + color: var(--spec-ink); + border: none; + max-width: 32ch; + margin: 0 auto 2rem; + padding: 0; +} +.NotFound .action .link { + font-family: var(--spec-font-mono); + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--spec-paper); + background: var(--spec-ink); + padding: 12px 22px; + text-decoration: none; + display: inline-block; + transition: background 0.25s ease; +} +.NotFound .action .link:hover { + background: var(--spec-rose); } -/* Code blocks - Dark */ -html.dark .vp-doc [class*="language-"] { - background-color: #0d0c0c; +/* ================================================================ + Section vocabulary — shared layout primitives + Used by HomePage.vue and available to any page that adopts + the specimen design system. + ================================================================ */ +.specimen { + color: var(--spec-ink); + background-color: var(--spec-paper); + overflow-x: hidden; +} +.specimen .wrap { + max-width: 1320px; + margin: 0 auto; + padding: 0 clamp(20px, 4vw, 56px); +} +.specimen .section { padding: clamp(64px, 11vw, 140px) 0; } +.specimen .divider { border-top: 1px solid var(--spec-rule); } +.specimen .masthead { + border-bottom: 1px solid var(--spec-rule); + padding: 14px clamp(20px, 4vw, 56px); + font-family: var(--spec-font-mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--spec-ink-soft); + display: flex; + justify-content: space-between; + gap: 24px; + align-items: center; +} +.specimen .masthead .c { color: var(--spec-ink); } +.specimen .eyebrow { + font-family: var(--spec-font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--spec-rose); + margin: 0 0 18px; +} +.specimen .lede { + font-family: var(--spec-font-display); + font-weight: 350; + font-size: clamp(19px, 2vw, 25px); + line-height: 1.4; + color: var(--spec-ink-soft); + max-width: 40ch; + margin: 0; +} +.specimen .head { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; + align-items: end; + margin-bottom: clamp(48px, 8vw, 96px); +} +.specimen .head h2 { + font-family: var(--spec-font-display); + font-weight: 340; + font-size: clamp(34px, 5vw, 68px); + line-height: 0.98; + letter-spacing: -0.022em; + margin: 0; + color: var(--spec-ink); +} +.specimen .head h2 em { font-style: italic; color: var(--spec-rose); } +.specimen .prompt { color: var(--spec-rose-soft); } +.specimen .btn-ink { + font-family: var(--spec-font-mono); + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--spec-paper); + background: var(--spec-ink); + padding: 14px 22px; + text-decoration: none; + transition: background 0.25s ease; +} +.specimen .btn-ink:hover { background: var(--spec-rose); } +.specimen .btn-ghost { + font-family: var(--spec-font-mono); + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--spec-ink); + text-decoration: none; + border-bottom: 1px solid var(--spec-rule-strong); + padding: 6px 0; + transition: color 0.2s, border-color 0.2s; +} +.specimen .btn-ghost:hover { color: var(--spec-rose); border-color: var(--spec-rose); } +.specimen .foot { + border-top: 1px solid var(--spec-rule); + padding: 40px clamp(20px, 4vw, 56px) 56px; + font-family: var(--spec-font-mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--spec-mute); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} +.specimen .foot .r { text-align: right; } +.specimen .foot em { + font-family: var(--spec-font-display); + font-style: italic; + text-transform: none; + letter-spacing: 0; + color: var(--spec-ink-soft); +} +@media (max-width: 860px) { + .specimen .head { grid-template-columns: 1fr; } + .specimen .foot { grid-template-columns: 1fr; } + .specimen .foot .r { text-align: left; } + .specimen .masthead { flex-direction: column; text-align: center; } } -/* Logo sizing */ -.VPNav .VPImage { - height: 39px; +/* ================================================================ + About page styles (moved from about.md inline diff --git a/index.md b/index.md index b9fe2ed..a85370e 100644 --- a/index.md +++ b/index.md @@ -2,6 +2,7 @@ layout: page title: Fontist - Cross-Platform Font Management description: Install, manage, and build fonts programmatically across Windows, Linux, and macOS. Designed for automated systems and digital publishing. +pageClass: specimen-home --- diff --git a/package.json b/package.json index d1782a9..cd92e74 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "build": "vitepress build && node scripts/dirify-urls.mjs", "preview": "vitepress preview", "format": "prettier -w .", - "check:subsite-links": "node scripts/check-subsite-links.mjs" + "check:subsite-links": "node scripts/check-subsite-links.mjs", + "test": "node --test tests/*.test.mjs" }, "type": "module", "devDependencies": { diff --git a/tests/dirify.test.mjs b/tests/dirify.test.mjs new file mode 100644 index 0000000..22dfc6b --- /dev/null +++ b/tests/dirify.test.mjs @@ -0,0 +1,95 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync, statSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Unit tests for the dirify-urls script logic. +// The script moves file-routes (foo.html → foo/index.html) so GitHub Pages +// resolves both /foo and /foo/. These tests verify the logic without a +// full VitePress build by creating a fake dist structure. + +const TEST_DIST = join(tmpdir(), `test-dist-${Date.now()}`); + +function createFakeDist() { + mkdirSync(join(TEST_DIST, "blog"), { recursive: true }); + mkdirSync(join(TEST_DIST, "integrations"), { recursive: true }); + mkdirSync(join(TEST_DIST, "assets"), { recursive: true }); + writeFileSync(join(TEST_DIST, "index.html"), "home"); + writeFileSync(join(TEST_DIST, "404.html"), "404"); + writeFileSync(join(TEST_DIST, "about.html"), "about"); + writeFileSync(join(TEST_DIST, "README.html"), "readme"); + writeFileSync(join(TEST_DIST, "blog", "index.html"), "blog index"); + writeFileSync(join(TEST_DIST, "blog", "post.html"), "blog post"); + writeFileSync(join(TEST_DIST, "integrations", "github-actions.html"), "ga"); + writeFileSync(join(TEST_DIST, "assets", "style.css"), "body { color: red; }"); + writeFileSync(join(TEST_DIST, "sitemap.xml"), "sitemap"); +} + +function cleanup() { + rmSync(TEST_DIST, { recursive: true, force: true }); +} + +describe("dirify-urls logic", () => { + before(() => { + cleanup(); + createFakeDist(); + + const DIST = TEST_DIST; + const HTML = ".html"; + const KEEP_AT_ROOT = new Set(["index.html", "404.html"]); + + function dirify(dir) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { dirify(full); continue; } + if (!entry.endsWith(HTML)) continue; + if (entry === "index.html") continue; + if (dir === DIST && KEEP_AT_ROOT.has(entry)) continue; + const name = entry.slice(0, -HTML.length); + const targetDir = join(dir, name); + mkdirSync(targetDir, { recursive: true }); + renameSync(full, join(targetDir, "index.html")); + } + } + dirify(DIST); + }); + after(() => { cleanup(); }); + + it("moves about.html to about/index.html", () => { + assert.ok(!existsSync(join(TEST_DIST, "about.html")), "about.html should be gone"); + assert.ok(existsSync(join(TEST_DIST, "about", "index.html")), "about/index.html should exist"); + }); + + it("moves README.html to README/index.html", () => { + assert.ok(!existsSync(join(TEST_DIST, "README.html"))); + assert.ok(existsSync(join(TEST_DIST, "README", "index.html"))); + }); + + it("moves nested files (blog/post → blog/post/index.html)", () => { + assert.ok(!existsSync(join(TEST_DIST, "blog", "post.html"))); + assert.ok(existsSync(join(TEST_DIST, "blog", "post", "index.html"))); + }); + + it("moves nested files (integrations/github-actions)", () => { + assert.ok(!existsSync(join(TEST_DIST, "integrations", "github-actions.html"))); + assert.ok(existsSync(join(TEST_DIST, "integrations", "github-actions", "index.html"))); + }); + + it("keeps index.html at root", () => { + assert.ok(existsSync(join(TEST_DIST, "index.html")), "index.html should remain at root"); + }); + + it("keeps 404.html at root", () => { + assert.ok(existsSync(join(TEST_DIST, "404.html")), "404.html should remain at root"); + }); + + it("keeps directory-style index.html in place", () => { + assert.ok(existsSync(join(TEST_DIST, "blog", "index.html")), "blog/index.html should remain"); + }); + + it("does not touch non-HTML files", () => { + assert.ok(existsSync(join(TEST_DIST, "assets", "style.css")), "assets/style.css should remain"); + assert.ok(existsSync(join(TEST_DIST, "sitemap.xml")), "sitemap.xml should remain"); + }); +}); diff --git a/tests/nav-consistency.spec.ts b/tests/nav-consistency.spec.ts new file mode 100644 index 0000000..d20dcf5 --- /dev/null +++ b/tests/nav-consistency.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "@playwright/test"; + +// Nav consistency test — verifies every VitePress nav element matches the +// specimen design language. Catches regressions on VitePress upgrades. +// +// These are the elements that required specificity hacks in style.css +// (lines 127–260). If VitePress changes its internal DOM structure or +// adds new scoped styles, this test fails BEFORE a visual regression ships. + +const EXPECTED = { + fontFamily: /IBM Plex Mono/i, + fontSize: "12px", + textTransform: "uppercase", + fontWeight: "500", + color: /rgb\(74, 71, 68\)/, // var(--spec-ink-soft) +}; + +test.describe("Nav element consistency (VitePress overrides)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForSelector(".VPNavBar", { state: "visible" }); + }); + + test("regular nav links match specimen styling", async ({ page }) => { + const links = page.locator(".VPNavBarMenuLink"); + const count = await links.count(); + expect(count).toBeGreaterThan(0); + + for (let i = 0; i < count; i++) { + const cs = await links.nth(i).evaluate((el) => { + const s = getComputedStyle(el); + return { fontFamily: s.fontFamily, fontSize: s.fontSize, textTransform: s.textTransform, color: s.color, fontWeight: s.fontWeight }; + }); + expect(cs.fontSize).toBe(EXPECTED.fontSize); + expect(cs.textTransform).toBe(EXPECTED.textTransform); + expect(cs.fontFamily).toMatch(EXPECTED.fontFamily); + } + }); + + test("Integrations dropdown button text matches", async ({ page }) => { + const text = page.locator(".VPNavBarMenuGroup .button .text"); + await expect(text).toBeVisible(); + const cs = await text.evaluate((el) => { + const s = getComputedStyle(el); + return { fontSize: s.fontSize, color: s.color, textTransform: s.textTransform }; + }); + expect(cs.fontSize).toBe(EXPECTED.fontSize); + expect(cs.textTransform).toBe(EXPECTED.textTransform); + }); + + test("ellipsis/extra button text matches", async ({ page }) => { + const text = page.locator(".VPNavBarExtra .button .text"); + const cs = await text.evaluate((el) => { + const s = getComputedStyle(el); + return { fontSize: s.fontSize, textTransform: s.textTransform }; + }); + expect(cs.fontSize).toBe(EXPECTED.fontSize); + expect(cs.textTransform).toBe(EXPECTED.textTransform); + }); + + test("flyout menu link (GitHub Actions) matches", async ({ page }) => { + const link = page.locator(".VPMenu .VPMenuLink a").first(); + const cs = await link.evaluate((el) => { + const s = getComputedStyle(el); + return { fontSize: s.fontSize, color: s.color, textTransform: s.textTransform }; + }); + expect(cs.fontSize).toBe(EXPECTED.fontSize); + expect(cs.textTransform).toBe(EXPECTED.textTransform); + }); + + test("Appearance label in ellipsis menu matches", async ({ page }) => { + const label = page.locator(".VPMenu .item .label").first(); + const cs = await label.evaluate((el) => { + const s = getComputedStyle(el); + return { fontSize: s.fontSize, fontFamily: s.fontFamily, textTransform: s.textTransform }; + }); + expect(cs.fontSize).toBe(EXPECTED.fontSize); + expect(cs.textTransform).toBe(EXPECTED.textTransform); + expect(cs.fontFamily).toMatch(EXPECTED.fontFamily); + }); + + test("nav bar background is opaque (no content leak on scroll)", async ({ page }) => { + await page.evaluate(() => window.scrollTo(0, 600)); + const bg = await page.locator(".VPNavBar").evaluate((el) => getComputedStyle(el).backgroundColor); + expect(bg).not.toBe("rgba(0, 0, 0, 0)"); + }); +});