diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 35f914ef..03b2fcae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,6 +36,7 @@ jobs: run: npm install - name: Build run: npm run build + # TODO: wire navbar-visual.mjs once serve step is stable - name: Setup Pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Upload artifact diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index 858506e2..4edf212d 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -37,6 +37,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update Dakota versions + run: node scripts/update-dakota-versions.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check for changes id: changes run: | @@ -77,6 +82,10 @@ jobs: - `ublue-os/bluefin` (for GTS and Stable streams) - `ublue-os/bluefin-lts` (for LTS stream) + ### Dakota Versions + - Updated `public/dakota-versions.json` release metadata + - Data sourced from latest `projectbluefin/dakota` release tag + ### Review Notes - This is an automated daily update - Please verify the data looks correct before merging @@ -89,6 +98,7 @@ jobs: add-paths: | src/assets/svg/growth_bluefins.svg public/stream-versions.yml + public/dakota-versions.json - name: Create issue on failure if: failure() diff --git a/AGENTS.md b/AGENTS.md index 79893e16..38d2931d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ npm run lint:fix - **Vue 3** with Composition API (` + + + diff --git a/eslint.config.js b/eslint.config.js index 77979330..3a67c88a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,7 @@ const ignores = [ '.dist', 'node_modules', 'public', + 'tests/**', ] export default antfu({ diff --git a/index.html b/index.html index e52e0aca..e4e0e83c 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,7 @@ + diff --git a/package-lock.json b/package-lock.json index 94cb47e0..a13164b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6926,9 +6926,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", diff --git a/public/dakota-versions.json b/public/dakota-versions.json new file mode 100644 index 00000000..05a4046e --- /dev/null +++ b/public/dakota-versions.json @@ -0,0 +1,16 @@ +{ + "generatedAt": "2026-05-10T00:00:00.000Z", + "packages": { + "kernel": "6.19.11", + "gnome": "50.0", + "freedesktop-sdk": "25.08.11", + "mesa": "26.0.5", + "bootc": "1.15.2", + "nvidia": "595.71.05", + "systemd": "260.1", + "podman": "5.8.2", + "pipewire": "1.6.1", + "flatpak": "1.16.6", + "baseline": "x86-64-v3" + } +} diff --git a/public/evening/night-sky.webp b/public/evening/night-sky.webp new file mode 100644 index 00000000..17b9e3a7 Binary files /dev/null and b/public/evening/night-sky.webp differ diff --git a/scripts/update-dakota-versions.js b/scripts/update-dakota-versions.js new file mode 100644 index 00000000..49ffa26d --- /dev/null +++ b/scripts/update-dakota-versions.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +/** + * Script to update dakota-versions.json with release metadata from + * projectbluefin/dakota GitHub Releases API. + * + * Current approach: fetches the latest release tag and publication date, + * updates generatedAt and releaseTag while preserving existing package versions. + * + * TODO (proper fix): Query the OCI SBOM attestation for exact installed package + * versions: + * cosign download attestation ghcr.io/projectbluefin/dakota: \ + * | jq -r '.payload' | base64 -d | jq '.predicate.components[] | select(.name == "kernel")' + * This would use the syft JSON schema embedded in the image attestation and is + * immune to any changelog format changes. + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const OUT = path.join(__dirname, '../public/dakota-versions.json') +const GITHUB_API = 'https://api.github.com' + +async function main() { + const headers = { + 'User-Agent': 'bluefin-website-updater', + ...(process.env.GITHUB_TOKEN ? { Authorization: `token ${process.env.GITHUB_TOKEN}` } : {}), + } + + const res = await fetch(`${GITHUB_API}/repos/projectbluefin/dakota/releases/latest`, { headers }) + if (!res.ok) { + console.warn(`[dakota-versions] releases API returned ${res.status} — skipping update`) + return + } + + const release = await res.json() + const current = JSON.parse(fs.readFileSync(OUT, 'utf8')) + + current.generatedAt = release.published_at ?? new Date().toISOString() + current.releaseTag = release.tag_name + + fs.writeFileSync(OUT, `${JSON.stringify(current, null, 2)}\n`) + console.info(`[dakota-versions] updated to ${release.tag_name} (${current.generatedAt})`) +} + +main().catch((e) => { + console.error('[dakota-versions] error:', e.message) + process.exit(0) +}) diff --git a/src/App.vue b/src/App.vue index 7a61bbd3..fadb00b0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,6 +16,7 @@ import SectionPicker from './components/sections/SectionPicker.vue' import SectionVideo from './components/sections/SectionVideo.vue' import TopNavbar from './components/TopNavbar.vue' +import { setLocale } from './composables/useLocale' import { i18n } from './locales/schema' const visibleSection = ref('') @@ -52,7 +53,7 @@ onBeforeMount(() => { const urlParams = new URLSearchParams(window.location.search) const currentLocale = urlParams.get('lang') || window.navigator.language if (i18n.global.availableLocales.includes(currentLocale)) { - ;(i18n.global as any).locale = currentLocale + setLocale(currentLocale) } diff --git a/src/DakotaApp.vue b/src/DakotaApp.vue new file mode 100644 index 00000000..5cc062d7 --- /dev/null +++ b/src/DakotaApp.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/src/components/TopNavbar.vue b/src/components/TopNavbar.vue index f9cab9be..2b5b82a1 100644 --- a/src/components/TopNavbar.vue +++ b/src/components/TopNavbar.vue @@ -19,6 +19,7 @@ const leftNavLinks = [ const rightNavLinks = [ { name: t('TopBar.Blog'), href: 'https://docs.projectbluefin.io/blog' }, { name: t('TopBar.Changelog'), href: 'https://docs.projectbluefin.io/changelogs' }, + { name: t('TopBar.Reports'), href: 'https://docs.projectbluefin.io/reports' }, { name: t('TopBar.Discussions'), href: 'https://github.com/ublue-os/bluefin/discussions', @@ -52,6 +53,7 @@ const rightNavLinks = [ :key="link.name" :href="link.href" class="navbar__item navbar__link" + :class="{ 'navbar__link--active': link.name === t('TopBar.Docs') }" :target="link.external ? '_blank' : undefined" :rel="link.external ? 'noopener noreferrer' : undefined" > @@ -80,11 +82,11 @@ const rightNavLinks = [ .docusaurus-navbar { // Infima CSS variables - matching Docusaurus defaults --ifm-navbar-background-color: #242526; - --ifm-navbar-link-color: rgba(255, 255, 255, 0.9); - --ifm-navbar-link-hover-color: #4a69bd; + --ifm-navbar-link-color: rgb(227, 227, 227); + --ifm-navbar-link-hover-color: rgb(138, 151, 247); --ifm-navbar-height: 60px; - --ifm-navbar-padding-horizontal: 1rem; - --ifm-navbar-padding-vertical: calc((var(--ifm-navbar-height) - var(--ifm-navbar-item-height)) / 2); + --ifm-navbar-padding-horizontal: 16px; + --ifm-navbar-padding-vertical: 14px; --ifm-navbar-item-height: 32px; --ifm-navbar-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); --ifm-transition-fast: 200ms; @@ -126,7 +128,7 @@ const rightNavLinks = [ justify-content: space-between; max-width: 1440px; margin: 0 auto; - padding: var(--ifm-navbar-padding-vertical) var(--ifm-navbar-padding-horizontal); + padding: 0 16px; height: 100%; } @@ -141,9 +143,9 @@ const rightNavLinks = [ .navbar__brand { display: flex; align-items: center; - gap: 0.5rem; + gap: 8px; text-decoration: none; - margin-right: 1rem; + margin-right: 16px; height: var(--ifm-navbar-item-height); transition: opacity var(--ifm-transition-fast) var(--ifm-transition-timing-default); @@ -164,7 +166,7 @@ const rightNavLinks = [ height: 100%; img { - height: 2rem; + height: 32px; width: auto; display: block; max-width: none; @@ -172,7 +174,7 @@ const rightNavLinks = [ } .navbar__title { - font-size: 1.25rem; + font-size: 16px; font-weight: 700; color: var(--ifm-navbar-link-color); line-height: 1; @@ -192,9 +194,9 @@ const rightNavLinks = [ .navbar__link { color: var(--ifm-navbar-link-color); text-decoration: none; - font-size: 12pt; - font-weight: 400; - padding: 0 0.75rem; + font-size: 16px; + font-weight: 500; + padding: 4px 12px; line-height: 1.5; display: flex; align-items: center; @@ -210,6 +212,11 @@ const rightNavLinks = [ outline-offset: 2px; border-radius: 4px; } + + &--active { + color: rgb(138, 151, 247); + font-weight: 500; + } } .navbar__items--right { @@ -220,16 +227,16 @@ const rightNavLinks = [ // Mobile responsive - matching Docusaurus breakpoints exactly @media (max-width: 996px) { .docusaurus-navbar { - --ifm-navbar-padding-horizontal: 0.5rem; + --ifm-navbar-padding-horizontal: 8px; } .navbar__link { - font-size: 11pt; - padding: 0 0.5rem; + font-size: 14px; + padding: 4px 8px; } .navbar__brand { - margin-right: 0.5rem; + margin-right: 8px; } } diff --git a/src/components/dakota/DakotaDownloadCard.vue b/src/components/dakota/DakotaDownloadCard.vue new file mode 100644 index 00000000..e05004d7 --- /dev/null +++ b/src/components/dakota/DakotaDownloadCard.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/src/components/dakota/DakotaHighlights.vue b/src/components/dakota/DakotaHighlights.vue new file mode 100644 index 00000000..eae2c002 --- /dev/null +++ b/src/components/dakota/DakotaHighlights.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/src/components/dakota/DakotaScene.vue b/src/components/dakota/DakotaScene.vue new file mode 100644 index 00000000..594082e5 --- /dev/null +++ b/src/components/dakota/DakotaScene.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/dakota/DakotaVersionChips.vue b/src/components/dakota/DakotaVersionChips.vue new file mode 100644 index 00000000..8b9ad6f4 --- /dev/null +++ b/src/components/dakota/DakotaVersionChips.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/src/composables/useLocale.ts b/src/composables/useLocale.ts new file mode 100644 index 00000000..8d59fc63 --- /dev/null +++ b/src/composables/useLocale.ts @@ -0,0 +1,10 @@ +import { i18n } from '../locales/schema' + +/** + * Set the active locale. + * vue-i18n is configured in LEGACY mode, where i18n.global.locale + * is a plain string — NOT a ref. Never write .locale.value. + */ +export function setLocale(locale: string): void { + ;(i18n.global as any).locale = locale +} diff --git a/src/dakota-main.ts b/src/dakota-main.ts new file mode 100644 index 00000000..233e5680 --- /dev/null +++ b/src/dakota-main.ts @@ -0,0 +1,11 @@ +// @ts-expect-error Known issues, that's why we use it as a plugin +import IframeResizerPlugin from '@iframe-resizer/vue' +import { createApp } from 'vue' +import DakotaApp from './DakotaApp.vue' +import { i18n } from './locales/schema' +import './style/index.scss' + +const app = createApp(DakotaApp) +app.use(i18n) +app.use(IframeResizerPlugin) +app.mount('#app') diff --git a/src/locales/de-DE.json b/src/locales/de-DE.json index aeaead85..daa5a420 100644 --- a/src/locales/de-DE.json +++ b/src/locales/de-DE.json @@ -4,6 +4,7 @@ "AskBluefin": "Frag Bluefin", "Blog": "Blog", "Changelog": "Changelogs", + "Reports": "Reports", "Discussions": "Diskussionen", "Feedback": "Feedback", "Store": "Shop (nur USA)" diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 7c9218cd..d19f6eab 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -4,6 +4,7 @@ "AskBluefin": "Ask Bluefin", "Blog": "Blog", "Changelog": "Changelogs", + "Reports": "Reports", "Discussions": "Discussions", "Feedback": "Feedback", "Store": "Store (US Only)" diff --git a/src/locales/eo.json b/src/locales/eo.json index e39d46f2..1301a02e 100644 --- a/src/locales/eo.json +++ b/src/locales/eo.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "La sekva generacio de Linuksa laborstacio, desegnita por fidindeco, rendimento kaj daŭripovo.", "DiscoverButton": "Malkovri", diff --git a/src/locales/fr-FR.json b/src/locales/fr-FR.json index 2dfc0583..3d386e19 100644 --- a/src/locales/fr-FR.json +++ b/src/locales/fr-FR.json @@ -4,6 +4,7 @@ "AskBluefin": "Des Questions ?", "Blog": "Blog", "Changelog": "Changelogs", + "Reports": "Reports", "Discussions": "Discussions", "Feedback": "Vos Impressions", "Store": "Boutique (US uniquement)" diff --git a/src/locales/ja-JP.json b/src/locales/ja-JP.json index a5848181..d6e0438a 100644 --- a/src/locales/ja-JP.json +++ b/src/locales/ja-JP.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "信頼性、パフォーマンス、持続可能性のために設計された次世代 Linux ワークステーション。", "DiscoverButton": "詳しく見る", diff --git a/src/locales/ko-KR.json b/src/locales/ko-KR.json index b71105dd..3f132ae1 100644 --- a/src/locales/ko-KR.json +++ b/src/locales/ko-KR.json @@ -4,6 +4,7 @@ "AskBluefin": "Bluefin에게 문의", "Blog": "블로그", "Changelog": "변경 로그", + "Reports": "Reports", "Discussions": "토론", "Feedback": "피드백", "Store": "스토어 (US 전용)" diff --git a/src/locales/nl-NL.json b/src/locales/nl-NL.json index 70e8a8e9..1ec11ffa 100644 --- a/src/locales/nl-NL.json +++ b/src/locales/nl-NL.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "De volgende generatie Linux-werkstation, ontworpen voor betrouwbaarheid, prestaties en duurzaamheid.", "DiscoverButton": "Ontdekken", diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index 3582198d..b5699cb8 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -4,6 +4,7 @@ "AskBluefin": "Ask Bluefin", "Blog": "Blog", "Changelog": "Changelog", + "Reports": "Reports", "Discussions": "Discussões", "Feedback": "Feedback", "Store": "Loja (Somente EUA)" diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 1aeb8e17..0573b791 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "Операционная система нового поколения на базе Linux, разработанная с упором на надёжность, производительность и устойчивость.", "DiscoverButton": "Узнать больше", diff --git a/src/locales/sk-SK.json b/src/locales/sk-SK.json index e03a234f..ef8a5531 100644 --- a/src/locales/sk-SK.json +++ b/src/locales/sk-SK.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "Linuxová pracovná stanica novej generácie, vyrobená pre spoľahlivosť, výkon a udržateľnosť.", "DiscoverButton": "Objaviť", diff --git a/src/locales/vi-VN.json b/src/locales/vi-VN.json index 13c60925..3ee5e8c6 100644 --- a/src/locales/vi-VN.json +++ b/src/locales/vi-VN.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "Thế hệ tiếp theo của hệ thống làm việc dựa trên hệ điều hành Linux, được thiết kế dành cho sự ổn định, hiệu suất, và mang tính bền vững.", "DiscoverButton": "Khám phá", diff --git a/src/locales/zh-HK.json b/src/locales/zh-HK.json index 7aa640b3..faeebe77 100644 --- a/src/locales/zh-HK.json +++ b/src/locales/zh-HK.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "次世代 Linux 工作站,講求可靠、高性能、可持續。", "DiscoverButton": "瞭解更多", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 7aa640b3..faeebe77 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -1,4 +1,7 @@ { + "TopBar": { + "Reports": "Reports" + }, "Landing": { "Title": "次世代 Linux 工作站,講求可靠、高性能、可持續。", "DiscoverButton": "瞭解更多", diff --git a/src/style/app/_dakota.scss b/src/style/app/_dakota.scss new file mode 100644 index 00000000..c12863c9 --- /dev/null +++ b/src/style/app/_dakota.scss @@ -0,0 +1,3 @@ +// Dakota page — shared global overrides only. +// Component-scoped styles live in the .vue files. +// This file wires any page-level resets or body-level rules needed for /dakota/. diff --git a/src/style/app/index.scss b/src/style/app/index.scss index 1b546401..a9495478 100644 --- a/src/style/app/index.scss +++ b/src/style/app/index.scss @@ -8,6 +8,7 @@ @use 'sections'; @use 'parallax'; @use 'button'; +@use 'dakota'; @use '../setup/fonts'; h1 { diff --git a/src/tests/dakota-chips.test.ts b/src/tests/dakota-chips.test.ts new file mode 100644 index 00000000..5b4fa269 --- /dev/null +++ b/src/tests/dakota-chips.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +// Test the chip label mapping logic extracted from DakotaVersionChips.vue +const LABELS: Record = { + 'kernel': 'Kernel', + 'gnome': 'GNOME', + 'freedesktop-sdk': 'Freedesktop SDK', + 'mesa': 'Mesa', + 'bootc': 'bootc', + 'nvidia': 'Nvidia', + 'systemd': 'systemd', + 'podman': 'Podman', + 'pipewire': 'PipeWire', + 'flatpak': 'Flatpak', + 'baseline': 'x86-64', +} + +const FEATURE_KEYS = new Set(['baseline']) + +function makeChips(packages: Record) { + return Object.entries(packages) + .filter(([, v]) => v) + .map(([key, value]) => ({ + label: LABELS[key] ?? key, + value, + isFeature: FEATURE_KEYS.has(key), + })) +} + +describe('dakotaVersionChips', () => { + it('maps all known keys to labels', () => { + const chips = makeChips({ + 'kernel': '6.19.11', + 'gnome': '50.0', + 'freedesktop-sdk': '25.08.11', + 'mesa': '26.0.5', + 'bootc': '1.15.2', + 'nvidia': '595.71.05', + 'systemd': '260.1', + 'podman': '5.8.2', + 'pipewire': '1.6.1', + 'flatpak': '1.16.6', + 'baseline': 'x86-64-v3', + }) + expect(chips.find(c => c.label === 'Kernel')?.value).toBe('6.19.11') + expect(chips.find(c => c.label === 'GNOME')?.value).toBe('50.0') + expect(chips.find(c => c.label === 'Freedesktop SDK')?.value).toBe('25.08.11') + expect(chips.find(c => c.label === 'x86-64')?.value).toBe('x86-64-v3') + }) + + it('marks baseline as feature, others as versions', () => { + const chips = makeChips({ kernel: '6.19.11', baseline: 'x86-64-v3' }) + expect(chips.find(c => c.label === 'x86-64')?.isFeature).toBe(true) + expect(chips.find(c => c.label === 'Kernel')?.isFeature).toBe(false) + }) + + it('filters out null/empty values', () => { + const chips = makeChips({ kernel: '6.19.11', gnome: '', nvidia: '595.71.05' }) + expect(chips).toHaveLength(2) + expect(chips.find(c => c.label === 'GNOME')).toBeUndefined() + }) + + it('baseline chip is last in order', () => { + const chips = makeChips({ kernel: '6.19.11', gnome: '50.0', baseline: 'x86-64-v3' }) + expect(chips[chips.length - 1].label).toBe('x86-64') + }) +}) diff --git a/tests/navbar-visual.mjs b/tests/navbar-visual.mjs new file mode 100644 index 00000000..489f9132 --- /dev/null +++ b/tests/navbar-visual.mjs @@ -0,0 +1,539 @@ +/** + * Navbar visual regression test — standalone Playwright script + * Checks TopNavbar matches docs.projectbluefin.io styling + * + * Prerequisites: dev server must be running at http://localhost:5173 + * just serve (from repo root) + * + * Run: + * node src/tests/navbar-visual.mjs + */ + +import { chromium } from '/var/home/jorge/src/documentation/node_modules/playwright/index.mjs' + +const URL = 'http://localhost:5173/' +const SCREENSHOT = '/tmp/nav-test-result.png' +const VIEWPORT = { width: 1440, height: 900 } + +// ── Expected values ──────────────────────────────────────────────────────────── +// Pixel-accurate measurements from docs.projectbluefin.io @ 1440×900 dark-mode +// Captured 2026-05-11 via getBoundingClientRect() +const EXPECTED_LOGO_HEIGHT = '32px' +const EXPECTED_BRAND_MARGIN_RIGHT = '16px' +const EXPECTED_BRAND_GAP = '8px' // explicit; docs renders same 8px visually +const EXPECTED_LINK_PADDING_TOP = '4px' +const EXPECTED_LINK_PADDING_BOTTOM = '4px' +const EXPECTED_LINK_PADDING_LEFT = '12px' +const EXPECTED_LINK_PADDING_RIGHT = '12px' +const EXPECTED_LOGO_TO_TITLE_GAP_MIN = 7 // px — allow ±1px for sub-pixel +const EXPECTED_LOGO_TO_TITLE_GAP_MAX = 9 +const EXPECTED_WITHIN_GROUP_GAP_MAX = 1 // px — links in same group touch (0px) +const EXPECTED_INNER_PADDING_VERTICAL = '0px' // matches docs; align-items:center handles centering +const EXPECTED_NAVBAR_HEIGHT = '60px' + +const EXPECTED_LINKS = [ + 'Documentation', + 'Ask Bluefin', + 'Blog', + 'Changelogs', + 'Reports', + 'Discussions', + 'Feedback', + 'Store (US Only)', +] + +const EXPECTED_LINK_FONT_SIZE = '16px' +const EXPECTED_LINK_FONT_WEIGHT = '500' +const EXPECTED_TITLE_FONT_SIZE = '16px' +const EXPECTED_TITLE_FONT_WEIGHT = '700' + +// ── Helpers ──────────────────────────────────────────────────────────────────── +let passed = 0 +let failed = 0 + +function assert(label, actual, expected) { + const ok = actual === expected + const status = ok ? '✅ PASS' : '❌ FAIL' + if (ok) { + passed++ + console.log(` ${status} ${label}`) + console.log(` got: ${actual}`) + } + else { + failed++ + console.log(` ${status} ${label}`) + console.log(` expected: ${expected}`) + console.log(` got: ${actual}`) + } + return ok +} + +function assertRange(label, actual, min, max) { + const ok = actual >= min && actual <= max + const status = ok ? '✅ PASS' : '❌ FAIL' + if (ok) { + passed++ + console.log(` ${status} ${label}`) + console.log(` got: ${actual} (range ${min}–${max})`) + } + else { + failed++ + console.log(` ${status} ${label}`) + console.log(` expected range: ${min}–${max}`) + console.log(` got: ${actual}`) + } + return ok +} + +function assertFuzzy(label, actual, expected, tolerance = 1) { + const diff = Math.abs(actual - expected) + const ok = diff <= tolerance + const status = ok ? '✅ PASS' : '❌ FAIL' + if (ok) { + passed++ + console.log(` ${status} ${label}`) + console.log(` got: ${actual} (expected ${expected} ±${tolerance})`) + } + else { + failed++ + console.log(` ${status} ${label}`) + console.log(` expected: ${expected} ±${tolerance}`) + console.log(` got: ${actual} (diff: ${diff.toFixed(2)})`) + } + return ok +} + +function parsePx(value) { + return parseFloat(value ?? '0') +} + +function assertContains(label, list, item) { + const ok = list.includes(item) + const status = ok ? '✅ PASS' : '❌ FAIL' + if (ok) { + passed++ + console.log(` ${status} ${label}`) + } + else { + failed++ + console.log(` ${status} ${label}`) + console.log(` "${item}" not found in [${list.join(', ')}]`) + } + return ok +} + +// ── Main ─────────────────────────────────────────────────────────────────────── +const browser = await chromium.launch({ headless: true }) +const page = await browser.newPage() +await page.setViewportSize(VIEWPORT) + +console.log(`\n🔵 Bluefin TopNavbar visual test`) +console.log(` URL: ${URL}`) +console.log(` Viewport: ${VIEWPORT.width}×${VIEWPORT.height}`) +console.log(` Screenshot: ${SCREENSHOT}\n`) + +// ── Console error capture (must be wired before goto) ─────────────────────── +const consoleErrors = [] +page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()) + } +}) +page.on('pageerror', (err) => { + consoleErrors.push(err.message) +}) + +// ── Load page ────────────────────────────────────────────────────────────────── +try { + await page.goto(URL, { waitUntil: 'networkidle', timeout: 30_000 }) +} +catch { + // networkidle sometimes times out on animation-heavy pages — that's fine +} +await page.waitForTimeout(2000) + +// ── Section 1: .navbar__link computed CSS ───────────────────────────────────── +console.log('── Section 1: .navbar__link computed CSS ──') + +const linkHandle = await page.$('.navbar__link:not(.navbar__link--active)') +if (!linkHandle) { + console.log(' ❌ FAIL .navbar__link not found in DOM — is the server running?\n') + failed++ +} +else { + const linkStyles = await page.evaluate((el) => { + const cs = window.getComputedStyle(el) + return { + fontSize: cs.fontSize, + fontWeight: cs.fontWeight, + } + }, linkHandle) + + assert('.navbar__link fontSize', linkStyles.fontSize, EXPECTED_LINK_FONT_SIZE) + assert('.navbar__link fontWeight', linkStyles.fontWeight, EXPECTED_LINK_FONT_WEIGHT) +} + +// ── Section 2: .navbar__title computed CSS ──────────────────────────────────── +console.log('\n── Section 2: .navbar__title computed CSS ──') + +const titleHandle = await page.$('.navbar__title') +if (!titleHandle) { + console.log(' ❌ FAIL .navbar__title not found in DOM\n') + failed++ +} +else { + const titleStyles = await page.evaluate((el) => { + const cs = window.getComputedStyle(el) + return { + fontSize: cs.fontSize, + fontWeight: cs.fontWeight, + } + }, titleHandle) + + assert('.navbar__title fontSize', titleStyles.fontSize, EXPECTED_TITLE_FONT_SIZE) + assert('.navbar__title fontWeight', titleStyles.fontWeight, EXPECTED_TITLE_FONT_WEIGHT) +} + +// ── Section 3: nav link text presence ───────────────────────────────────────── +console.log('\n── Section 3: nav link text presence ──') + +const linkTexts = await page.$$eval('.navbar__link', els => + els.map(el => el.textContent?.trim()).filter(Boolean)) +console.log(` Found links: [${linkTexts.join(', ')}]`) + +for (const expected of EXPECTED_LINKS) { + assertContains(`link text "${expected}"`, linkTexts, expected) +} + +// ── Section 4: spacing and sizing ─────────────────────────────────────────── +console.log('\n── Section 4: spacing and sizing ──') + +const brandHandle = await page.$('.navbar__brand') +if (!brandHandle) { + console.log(' ❌ FAIL .navbar__brand not found in DOM') + failed++ +} +else { + const brandStyles = await page.evaluate((el) => { + const cs = window.getComputedStyle(el) + return { marginRight: cs.marginRight, gap: cs.gap } + }, brandHandle) + assert('.navbar__brand marginRight', brandStyles.marginRight, EXPECTED_BRAND_MARGIN_RIGHT) + assert('.navbar__brand gap', brandStyles.gap, EXPECTED_BRAND_GAP) +} + +const logoImgHandle = await page.$('.navbar__logo img') +if (!logoImgHandle) { + console.log(' ❌ FAIL .navbar__logo img not found in DOM') + failed++ +} +else { + const logoHeight = await page.evaluate( + el => window.getComputedStyle(el).height, + logoImgHandle, + ) + assert('.navbar__logo img height', logoHeight, EXPECTED_LOGO_HEIGHT) +} + +const docusaurusNavHandle = await page.$('.docusaurus-navbar') +if (!docusaurusNavHandle) { + console.log(' ❌ FAIL .docusaurus-navbar not found in DOM') + failed++ +} +else { + const navHeight = await page.evaluate( + el => window.getComputedStyle(el).height, + docusaurusNavHandle, + ) + assert('.docusaurus-navbar height', navHeight, EXPECTED_NAVBAR_HEIGHT) +} + +// ── Section 4b: pixel-accurate spacing (docs-baseline measurements) ──────────── +console.log('\n── Section 4b: pixel-accurate spacing (docs-baseline) ──') + +const spacingData = await page.evaluate(() => { + const logoImg = document.querySelector('.navbar__logo img') + const title = document.querySelector('.navbar__title') + const firstLink = document.querySelector('.navbar__link') + const leftItems = document.querySelector('.navbar__items:not(.navbar__items--right)') + const rightItems = document.querySelector('.navbar__items--right') + const inner = document.querySelector('.navbar__inner') + + const r = el => el ? el.getBoundingClientRect() : null + const cs = el => el ? window.getComputedStyle(el) : null + + const logoRect = r(logoImg) + const titleRect = r(title) + const logoToTitle = (logoRect && titleRect) ? titleRect.left - logoRect.right : null + + const allLinks = [...document.querySelectorAll('.navbar__link')] + const leftLinks = allLinks.filter(el => !el.closest('.navbar__items--right')) + const rightLinks = allLinks.filter(el => !!el.closest('.navbar__items--right')) + + // Gap between last left link and first right link + const lastLeft = leftLinks.length ? r(leftLinks[leftLinks.length - 1]) : null + const firstRight = rightLinks.length ? r(rightLinks[0]) : null + const middleGap = (lastLeft && firstRight) ? firstRight.left - lastLeft.right : null + + // Within-group gaps: check all adjacent left links and adjacent right links + const leftGaps = [] + for (let i = 0; i < leftLinks.length - 1; i++) { + leftGaps.push(r(leftLinks[i + 1]).left - r(leftLinks[i]).right) + } + const rightGaps = [] + for (let i = 0; i < rightLinks.length - 1; i++) { + rightGaps.push(r(rightLinks[i + 1]).left - r(rightLinks[i]).right) + } + + const innerCs = cs(inner) + const firstLinkCs = cs(firstLink) + + return { + logoToTitle, + middleGap, + leftGaps, + rightGaps, + innerPaddingTop: innerCs?.paddingTop, + innerPaddingBottom: innerCs?.paddingBottom, + linkPaddingTop: firstLinkCs?.paddingTop, + linkPaddingBottom: firstLinkCs?.paddingBottom, + linkPaddingLeft: firstLinkCs?.paddingLeft, + linkPaddingRight: firstLinkCs?.paddingRight, + } +}) + +if (spacingData.logoToTitle !== null) { + assertRange( + `logo → title gap (docs baseline: 8px ±1)`, + spacingData.logoToTitle, + EXPECTED_LOGO_TO_TITLE_GAP_MIN, + EXPECTED_LOGO_TO_TITLE_GAP_MAX, + ) +} +else { + console.log(' ❌ FAIL could not compute logo→title gap (elements missing)') + failed++ +} + +assert('.navbar__inner paddingTop', spacingData.innerPaddingTop, EXPECTED_INNER_PADDING_VERTICAL) +assert('.navbar__inner paddingBottom', spacingData.innerPaddingBottom, EXPECTED_INNER_PADDING_VERTICAL) +assert('.navbar__link paddingTop', spacingData.linkPaddingTop, EXPECTED_LINK_PADDING_TOP) +assert('.navbar__link paddingBottom', spacingData.linkPaddingBottom, EXPECTED_LINK_PADDING_BOTTOM) +assert('.navbar__link paddingLeft', spacingData.linkPaddingLeft, EXPECTED_LINK_PADDING_LEFT) +assert('.navbar__link paddingRight', spacingData.linkPaddingRight, EXPECTED_LINK_PADDING_RIGHT) + +console.log(` ℹ️ Middle gap (left↔right groups): ${spacingData.middleGap?.toFixed(1)}px`) +console.log(` (docs=140.4px; local uses full viewport width — layout diff, not a bug)`) + +const allWithinGroupGaps = [...spacingData.leftGaps, ...spacingData.rightGaps] +if (allWithinGroupGaps.length > 0) { + const maxGap = Math.max(...allWithinGroupGaps) + assertRange( + `max within-group link gap ≤ ${EXPECTED_WITHIN_GROUP_GAP_MAX}px (links touch within their group)`, + maxGap, + -Infinity, + EXPECTED_WITHIN_GROUP_GAP_MAX, + ) + console.log(` (individual within-group gaps: [${allWithinGroupGaps.map(g => g.toFixed(1)).join(', ')}])`) +} + +// ── Section 5: active link styling ─────────────────────────────────────────── +console.log('\n── Section 5: active link styling ──') + +const activeLinkHandle = await page.$('.navbar__link--active') +if (!activeLinkHandle) { + console.log(' ❌ FAIL .navbar__link--active not found in DOM') + failed++ +} +else { + const activeStyles = await page.evaluate((el) => { + const cs = window.getComputedStyle(el) + return { + color: cs.color, + fontWeight: cs.fontWeight, + } + }, activeLinkHandle) + + assert('.navbar__link--active color', activeStyles.color, 'rgb(138, 151, 247)') + assert('.navbar__link--active fontWeight', activeStyles.fontWeight, '500') +} + +// ── Section 6: right-side links exist and are in correct order ──────────────── +console.log('\n── Section 6: right-side links order ──') + +const EXPECTED_RIGHT_LINKS = ['Blog', 'Changelogs', 'Reports', 'Discussions', 'Feedback', 'Store (US Only)'] + +const rightLinkTexts = await page.$$eval( + '.navbar__items--right .navbar__link', + els => els.map(el => el.textContent?.trim()).filter(Boolean), +) +console.log(` Found right-side links: [${rightLinkTexts.join(', ')}]`) + +if (rightLinkTexts.length === 0) { + console.log(' ❌ FAIL No .navbar__items--right .navbar__link elements found') + failed++ +} +else { + const actualOrder = JSON.stringify(rightLinkTexts) + const expectedOrder = JSON.stringify(EXPECTED_RIGHT_LINKS) + assert('right-side links order', actualOrder, expectedOrder) +} + +// ── Section 7: spacing parity with docs (dark mode) ──────────────────────── +console.log('\n── Section 7: spacing parity with docs (dark mode) ──') + +let docsContext, docsPage +try { + docsContext = await browser.newContext({ colorScheme: 'dark' }) + docsPage = await docsContext.newPage() + await docsPage.setViewportSize({ width: 1440, height: 900 }) + await docsPage.goto('https://docs.projectbluefin.io', { waitUntil: 'networkidle', timeout: 15000 }) + await docsPage.waitForTimeout(2000) + + const docsMetrics = await docsPage.evaluate(() => { + const logoImg = document.querySelector('.navbar__logo img') + const title = document.querySelector('.navbar__title') + const firstLink = document.querySelector('.navbar__link') + const navbar = document.querySelector('nav.navbar') || document.querySelector('.navbar') + + const r = el => el ? el.getBoundingClientRect() : null + const cs = el => el ? window.getComputedStyle(el) : null + + const logoRect = r(logoImg) + const titleRect = r(title) + const firstLinkRect = r(firstLink) + + return { + logoToTitle: (logoRect && titleRect) ? titleRect.left - logoRect.right : null, + titleToFirstLink: (titleRect && firstLinkRect) ? firstLinkRect.left - titleRect.right : null, + linkPaddingTop: cs(firstLink)?.paddingTop, + linkPaddingBottom: cs(firstLink)?.paddingBottom, + linkPaddingLeft: cs(firstLink)?.paddingLeft, + linkPaddingRight: cs(firstLink)?.paddingRight, + navbarHeight: cs(navbar)?.height, + navBackground: cs(navbar)?.backgroundColor, + linkFontSize: cs(firstLink)?.fontSize, + linkFontWeight: cs(firstLink)?.fontWeight, + } + }) + + const localMetrics = await page.evaluate(() => { + const logoImg = document.querySelector('.navbar__logo img') + const title = document.querySelector('.navbar__title') + const firstLink = document.querySelector('.navbar__link') + const navbar = document.querySelector('.docusaurus-navbar') + || document.querySelector('nav.navbar') + || document.querySelector('.navbar') + + const r = el => el ? el.getBoundingClientRect() : null + const cs = el => el ? window.getComputedStyle(el) : null + + const logoRect = r(logoImg) + const titleRect = r(title) + const firstLinkRect = r(firstLink) + + return { + logoToTitle: (logoRect && titleRect) ? titleRect.left - logoRect.right : null, + titleToFirstLink: (titleRect && firstLinkRect) ? firstLinkRect.left - titleRect.right : null, + linkPaddingTop: cs(firstLink)?.paddingTop, + linkPaddingBottom: cs(firstLink)?.paddingBottom, + linkPaddingLeft: cs(firstLink)?.paddingLeft, + linkPaddingRight: cs(firstLink)?.paddingRight, + navbarHeight: cs(navbar)?.height, + navBackground: cs(navbar)?.backgroundColor, + linkFontSize: cs(firstLink)?.fontSize, + linkFontWeight: cs(firstLink)?.fontWeight, + } + }) + + console.log(` ℹ️ docs.projectbluefin.io extracted values:`) + console.log(` logo→title: ${docsMetrics.logoToTitle?.toFixed(1)}px`) + console.log(` title→first link: ${docsMetrics.titleToFirstLink?.toFixed(1)}px`) + console.log(` link padding: ${docsMetrics.linkPaddingTop} ${docsMetrics.linkPaddingRight}`) + console.log(` navbar height: ${docsMetrics.navbarHeight}`) + console.log(` nav background: ${docsMetrics.navBackground}`) + console.log(` link font-size: ${docsMetrics.linkFontSize} font-weight: ${docsMetrics.linkFontWeight}`) + + // ── Gap parity: local vs docs ────────────────────────────────────────────── + if (docsMetrics.logoToTitle !== null && localMetrics.logoToTitle !== null) { + assertFuzzy( + `logo→title gap parity (local ${localMetrics.logoToTitle?.toFixed(1)}px vs docs ${docsMetrics.logoToTitle?.toFixed(1)}px)`, + localMetrics.logoToTitle, docsMetrics.logoToTitle, + ) + } + else { + console.log(' ❌ FAIL logo→title gap: element missing on local or docs') + failed++ + } + + if (docsMetrics.titleToFirstLink !== null && localMetrics.titleToFirstLink !== null) { + assertFuzzy( + `title→first link gap parity (local ${localMetrics.titleToFirstLink?.toFixed(1)}px vs docs ${docsMetrics.titleToFirstLink?.toFixed(1)}px)`, + localMetrics.titleToFirstLink, docsMetrics.titleToFirstLink, + ) + } + else { + console.log(' ❌ FAIL title→first link gap: element missing on local or docs') + failed++ + } + + // ── Absolute targets (±1px) — compare local against known-good values ────── + assertFuzzy('link paddingTop (target 4px)', parsePx(localMetrics.linkPaddingTop), 4) + assertFuzzy('link paddingBottom (target 4px)', parsePx(localMetrics.linkPaddingBottom), 4) + assertFuzzy('link paddingLeft (target 12px)', parsePx(localMetrics.linkPaddingLeft), 12) + assertFuzzy('link paddingRight (target 12px)', parsePx(localMetrics.linkPaddingRight), 12) + assertFuzzy('navbar height (target 60px)', parsePx(localMetrics.navbarHeight), 60) + assertFuzzy('link font-size (target 16px)', parsePx(localMetrics.linkFontSize), 16) + assert('link font-weight (target 500)', localMetrics.linkFontWeight, '500') + + // ── Background color parity ─────────────────────────────────────────────── + assert( + `nav background color parity (local vs docs)`, + localMetrics.navBackground, + docsMetrics.navBackground, + ) + + await docsPage.close() + await docsContext.close() +} +catch (err) { + console.log(` ❌ FAIL Section 7 network/parse error: ${err.message}`) + failed++ + if (docsPage) await docsPage.close().catch(() => {}) + if (docsContext) await docsContext.close().catch(() => {}) +} + +// ── Section 8: no JS console errors ───────────────────────────────────────── +console.log('\n── Section 8: no JS console errors ──') + +if (consoleErrors.length === 0) { + passed++ + console.log(' ✅ PASS no console errors') + console.log(' got: 0 errors') +} +else { + failed++ + console.log(` ❌ FAIL ${consoleErrors.length} console error(s) detected:`) + consoleErrors.forEach((e, i) => console.log(` [${i + 1}] ${e}`)) +} + +// ── Screenshot ──────────────────────────────────────────────────────────────── +await page.screenshot({ path: SCREENSHOT, fullPage: false }) +console.log(`\n📸 Screenshot saved → ${SCREENSHOT}`) + +// ── Summary ─────────────────────────────────────────────────────────────────── +const total = passed + failed +console.log('\n══════════════════════════════════════════════') +if (failed === 0) { + console.log(`✅ ALL ${total} assertions PASSED`) +} +else { + console.log(`❌ ${failed}/${total} assertions FAILED`) + console.log('\n Interpretation:') + console.log(' These failures show the delta between the current component CSS') + console.log(' and the target docs.projectbluefin.io Docusaurus navbar styling.') +} +console.log('══════════════════════════════════════════════\n') + +await browser.close() +process.exit(failed > 0 ? 1 : 0) diff --git a/vite.config.ts b/vite.config.ts index f4202198..1a74ee5f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,8 @@ export default defineConfig({ rollupOptions: { input: { main: resolve(__dirname, 'index.html'), - testing: resolve(__dirname, 'public/testing.html') + testing: resolve(__dirname, 'public/testing.html'), + dakota: resolve(__dirname, 'dakota/index.html'), }, output: { manualChunks: {