From 08ec4b10b2d9c1da7fc034d9d6582b96faa66f7a Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Wed, 22 Jul 2026 02:12:34 +0800 Subject: [PATCH] docs: replicate Island UI in Nextra (#97) --- docs-nextra/app/[lang]/island-theme.css | 727 ++++++++++++++++++ docs-nextra/app/[lang]/layout.tsx | 40 +- docs-nextra/app/[lang]/styles.css | 359 ++++----- .../app/_components/controller-demo.tsx | 2 +- .../_components/controller-docs.module.css | 145 +--- .../app/_components/display-demo-frame.tsx | 7 +- .../app/_components/display-docs.module.css | 113 +-- docs-nextra/app/_components/home-page.tsx | 145 ++-- .../app/_components/install-package.tsx | 12 +- .../app/_components/island-mobile-menu.tsx | 46 ++ docs-nextra/content/en/_meta.ts | 10 +- docs-nextra/content/en/component/_meta.ts | 8 +- docs-nextra/content/en/component/button.mdx | 2 +- docs-nextra/content/en/component/checkbox.mdx | 2 +- docs-nextra/content/en/component/envelope.mdx | 2 +- docs-nextra/content/en/component/input.mdx | 2 +- docs-nextra/content/en/component/knob.mdx | 2 +- docs-nextra/content/en/component/radio.mdx | 2 +- docs-nextra/content/en/component/slider.mdx | 2 +- docs-nextra/content/en/component/switch.mdx | 2 +- docs-nextra/content/zh/_meta.ts | 8 +- docs-nextra/content/zh/component/_meta.ts | 4 + docs-nextra/content/zh/component/button.mdx | 2 +- docs-nextra/content/zh/component/checkbox.mdx | 2 +- docs-nextra/content/zh/component/envelope.mdx | 2 +- docs-nextra/content/zh/component/input.mdx | 2 +- docs-nextra/content/zh/component/knob.mdx | 2 +- docs-nextra/content/zh/component/radio.mdx | 2 +- docs-nextra/content/zh/component/slider.mdx | 2 +- docs-nextra/content/zh/component/switch.mdx | 2 +- docs-nextra/public/logo.png | Bin 0 -> 3715 bytes package.json | 2 +- scripts/verify-island-style-parity.mjs | 683 ++++++++++++++++ scripts/verify-nextra-output.mjs | 35 +- 34 files changed, 1837 insertions(+), 541 deletions(-) create mode 100644 docs-nextra/app/[lang]/island-theme.css create mode 100644 docs-nextra/app/_components/island-mobile-menu.tsx create mode 100644 docs-nextra/public/logo.png create mode 100644 scripts/verify-island-style-parity.mjs diff --git a/docs-nextra/app/[lang]/island-theme.css b/docs-nextra/app/[lang]/island-theme.css new file mode 100644 index 00000000..47dc0b5a --- /dev/null +++ b/docs-nextra/app/[lang]/island-theme.css @@ -0,0 +1,727 @@ +:root { + --island-c-white: #fff; + --island-c-white-soft: #f9f9f9; + --island-c-white-mute: #f1f1f1; + --island-c-black: #1a1a1a; + --island-c-gray-dark-3: #3a3a3a; + --island-c-divider-light-1: rgb(60 60 60 / 29%); + --island-c-divider-light-2: rgb(60 60 60 / 12%); + --island-c-divider-dark-1: rgb(84 84 84 / 65%); + --island-c-divider-dark-2: rgb(84 84 84 / 48%); + --island-c-text-light-1: #213547; + --island-c-text-light-2: rgb(60 60 60 / 70%); + --island-c-text-light-3: rgb(60 60 60 / 33%); + --island-c-text-dark-1: rgb(255 255 255 / 87%); + --island-c-text-dark-2: rgb(235 235 235 / 60%); + --island-c-text-dark-3: rgb(235 235 235 / 38%); + --island-c-bg: var(--island-c-white); + --island-c-bg-soft: var(--island-c-white-soft); + --island-c-bg-mute: var(--island-c-white-mute); + --island-c-bg-alt: var(--island-c-white-soft); + --island-c-divider: var(--island-c-divider-light-1); + --island-c-divider-light: var(--island-c-divider-light-2); + --island-c-text-1: var(--island-c-text-light-1); + --island-c-text-2: var(--island-c-text-light-2); + --island-c-text-3: var(--island-c-text-light-3); + --island-c-text-code: #e9a00d; + --island-c-brand: #fdaa04; + --island-c-brand-light: #fa0; + --island-c-brand-dark: #e9a00d; + --island-font-family-base: + 'Poppins', 'Inter var experimental', 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', + Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + --island-font-family-mono: Menlo, Monaco, Consolas, 'Courier New', monospace; + --island-layout-max-width: 1440px; + --island-nav-height-desktop: 60px; + --island-nav-height-mobile: 56px; + --island-sidebar-width: 272px; + --nextra-content-width: calc(var(--island-layout-max-width) - 16px); + --nextra-navbar-height: var(--island-nav-height-desktop); +} + +html:root { + --nextra-navbar-height: var(--island-nav-height-desktop); +} + +.dark { + --island-c-bg: #131313; + --island-c-bg-soft: #1e1e1e; + --island-c-bg-mute: var(--island-c-gray-dark-3); + --island-c-bg-alt: #171717; + --island-c-divider: var(--island-c-divider-dark-1); + --island-c-divider-light: var(--island-c-divider-dark-2); + --island-c-text-1: var(--island-c-text-dark-1); + --island-c-text-2: var(--island-c-text-dark-2); + --island-c-text-3: var(--island-c-text-dark-3); +} + +html, +body { + background-color: var(--island-c-bg); + color: var(--island-c-text-1); + font-family: var(--island-font-family-base); +} + +body { + font-size: 16px; + font-synthesis: none; + font-weight: 400; + line-height: 24px; + min-width: 320px; + text-rendering: optimizelegibility; +} + +footer { + border: 0 !important; + display: none !important; +} + +.island-mobile-menu { + display: none; +} + +.nextra-navbar { + background: transparent; + height: var(--island-nav-height-desktop); + inset: 0 0 auto; + position: fixed; +} + +.nextra-navbar-blur { + background-color: color-mix(in srgb, var(--island-c-bg) 70%, transparent); + border-bottom-color: var(--island-c-divider-light); +} + +.nextra-navbar > nav { + height: var(--island-nav-height-desktop); + max-width: calc(var(--island-layout-max-width) - 64px); + padding-inline: 0; + position: relative; +} + +.echo-docs-logo { + color: var(--island-c-text-1); + font-size: 16px; + font-weight: 600; + letter-spacing: 0; +} + +.nextra-navbar > nav > a[aria-label='Home page'] { + position: absolute; + left: 0; +} + +.nextra-navbar > nav > a[target='_blank'] { + display: none; +} + +.nextra-navbar > nav > .nextra-scrollbar { + gap: 24px; + margin-left: auto; + overflow: visible; +} + +.nextra-navbar > nav > .nextra-scrollbar > :is(a, button) { + color: var(--island-c-text-1); + font-size: 14px; + font-weight: 400; +} + +.nextra-navbar > nav > .nextra-scrollbar > [aria-current='true'] { + color: var(--island-c-brand); + font-weight: 400; +} + +.nextra-navbar > nav > div:has(> .island-search) { + left: 264px; + position: absolute; + width: 208px; +} + +.island-search { + height: 32px; + position: relative; + width: 208px; +} + +.island-search > svg { + color: var(--island-c-text-2); + fill: none; + height: 20px; + left: 8px; + position: absolute; + stroke: currentcolor; + stroke-linecap: round; + stroke-width: 1.5; + top: 6px; + width: 20px; + z-index: 1; +} + +.island-search .nextra-search { + margin-left: 32px; + width: 176px; +} + +.island-search .nextra-search input { + background: transparent; + border: 0; + border-radius: 0; + color: var(--island-c-text-1); + font-size: 14px; + height: 32px; + padding: 4px 44px 4px 4px; + width: 176px; +} + +.island-search .nextra-search input::placeholder { + color: var(--island-c-text-2); + opacity: 1; +} + +.island-search .nextra-search kbd { + background: transparent; + color: var(--island-c-text-2); + opacity: 1; + visibility: visible; +} + +.nextra-navbar .island-locale-switch, +.nextra-navbar .island-theme-switch { + color: var(--island-c-text-2); + flex: 0 0 auto; +} + +.nextra-navbar .island-locale-switch { + border-left: 1px solid var(--island-c-divider-light); + border-radius: 0; + margin-left: 4px; + padding-left: 16px; +} + +.nextra-navbar .island-theme-switch { + border: 1px solid var(--island-c-divider); + border-radius: 12px; + height: 22px; + justify-content: center; + padding: 0; + width: 40px; +} + +.nextra-navbar :is(a, button, input):focus-visible, +aside.nextra-sidebar :is(a, button):focus-visible { + outline: 2px solid var(--island-c-brand); + outline-offset: 2px; +} + +body > div:has(> aside.nextra-sidebar) { + align-items: flex-start; + box-sizing: border-box; + margin: var(--island-nav-height-desktop) 8px 0; + max-width: calc(var(--island-layout-max-width) - 16px); + padding: 24px; +} + +aside.nextra-sidebar { + background-color: var(--island-c-bg-alt); + bottom: 0; + box-sizing: border-box; + height: 100vh; + left: 0; + padding: var(--island-nav-height-desktop) 32px 128px; + position: fixed; + top: 0; + width: var(--island-sidebar-width); +} + +aside.nextra-sidebar > div:first-child { + padding: 0; +} + +aside.nextra-sidebar ul { + gap: 0; +} + +aside.nextra-sidebar li:not(:has(a, button)) { + color: var(--island-c-text-1); + font-size: 16px; + font-weight: 600; + line-height: 24px; + margin: 14px 0 8px; + padding: 0; +} + +aside.nextra-sidebar li:not(:has(a, button)):not(:first-child) { + border-top: 1px solid var(--island-c-divider-light); + margin-top: 16px; + padding-top: 16px; +} + +aside.nextra-sidebar li > a { + background: transparent; + border: 0; + border-radius: 0; + color: var(--island-c-text-2); + font-size: 14px; + font-weight: 400; + line-height: 20px; + padding: 4px; +} + +aside.nextra-sidebar li > a:hover, +aside.nextra-sidebar li.active > a { + background: transparent; + color: var(--island-c-brand); + font-weight: 400; +} + +aside.nextra-sidebar .nextra-sidebar-footer { + display: none; +} + +body > div:has(> aside.nextra-sidebar) > article { + box-sizing: border-box; + flex: 0 0 712px; + margin-left: var(--island-sidebar-width); + padding: 0 0 32px 8px; + width: 712px; +} + +.nextra-breadcrumb { + display: none; +} + +body > div:has(> aside.nextra-sidebar) > article > main { + max-width: none; + width: 704px; +} + +nav.nextra-toc { + border-left: 1px solid var(--island-c-divider-light); + flex: 0 0 256px; + font-size: 13px; + width: 256px; +} + +nav.nextra-toc > div > p { + color: var(--island-c-text-1); + font-size: 12px; + font-weight: 600; + letter-spacing: 0; + text-transform: uppercase; +} + +nav.nextra-toc > div > ul { + padding-top: 8px; +} + +nav.nextra-toc > div > ul li { + margin-block: 0; +} + +nav.nextra-toc > div > ul a { + color: var(--island-c-text-2); + font-size: 13px; + font-weight: 400; + line-height: 28px; +} + +nav.nextra-toc > div > div:last-child { + display: none; +} + +body:has(.echo-home) > div:has(> aside.nextra-sidebar) { + display: block; + margin: var(--island-nav-height-desktop) 0 0; + max-width: none; + padding: 0; +} + +body:has(.echo-home) > div:has(> aside.nextra-sidebar) > article { + flex: none; + margin: 0; + padding: 0; + width: 100%; +} + +body:has(.echo-home) > div:has(> aside.nextra-sidebar) > article > main { + max-width: none; + width: 100%; +} + +body:has(.echo-home) .nextra-navbar > nav > div:has(> .island-search) { + left: 83px; +} + +article main h1, +article main h2, +article main h3, +article main h4, +article main h5, +article main h6 { + font-weight: 600; + outline: none; + position: relative; +} + +article main h1 { + border: 0; + font-size: 32px; + letter-spacing: -0.02em; + line-height: 40px; + margin: 0 0 10px -10px; + padding: 0; +} + +article main h2 { + border: 0; + font-size: 24px; + letter-spacing: -0.02em; + line-height: 32px; + margin: 48px 0 5px -8px; + padding: 0; +} + +article main h3 { + font-size: 20px; + letter-spacing: -0.01em; + line-height: 28px; + margin: 32px 0 10px -6px; +} + +article main :is(h1, h2, h3, h4, h5, h6) > a { + color: inherit; + text-decoration: none; +} + +article main :is(p, summary) { + margin: 16px 0; +} + +article main p { + font-size: 16px; + line-height: 28px; +} + +article main a { + color: var(--island-c-brand); + font-weight: 500; + text-decoration-style: dotted; + transition: color 0.25s; +} + +article main a:hover { + color: var(--island-c-brand-dark); +} + +article main :is(ul, ol) { + margin: 16px 0; + padding-left: 1.25rem; +} + +article main ul { + list-style: disc; +} + +article main ol { + list-style: decimal; +} + +article main li + li { + margin-top: 8px; +} + +article main li > :is(ol, ul) { + margin: 8px 0 0; +} + +article main blockquote { + border-left: 2px solid var(--island-c-divider); + margin: 16px 0; + padding-left: 16px; +} + +article main blockquote > p { + color: var(--island-c-text-2); + margin: 0; +} + +article main :not(pre, h1, h2, h3, h4, h5, h6) > code { + font-size: 14px; +} + +article main :not(pre, div) > code { + background-color: var(--island-c-bg-mute); + border: 0; + border-radius: 4px; + color: var(--island-c-text-code); + padding: 3px 6px; +} + +article main :not(pre, div) > code[class] { + background-color: var(--island-c-bg-mute); + border: 0; + border-radius: 4px; + color: var(--island-c-text-code); + padding: 3px 6px; +} + +article main :not(pre) > code.nextra-code.nextra-code { + background-color: var(--island-c-bg-mute); + border: 0; + border-radius: 4px; + color: var(--island-c-text-code); + font-size: 14px; + padding: 3px 6px; +} + +article main div.nextra-code:has(> pre) { + background-color: var(--echo-background); + border: 0; + border-radius: 8px; + box-shadow: none; + margin: 16px 0; + padding: 20px; +} + +article main div.nextra-code:has(> pre) pre { + background-color: var(--echo-background); + border: 0; + border-radius: 0; + box-shadow: none; + font-family: 'JetBrains Mono', monospace; + font-size: 13.6px; + line-height: 20.4px; + margin: 0; + padding: 0; +} + +article main div.nextra-code:has(> pre) pre code { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +article main div:has(> table) { + border: 0; + border-radius: 0; + overflow-x: auto; +} + +article main table, +article main table[class] { + border-collapse: collapse; + display: block; + font-size: 13.6px; + line-height: 20.4px; + margin: 20px 0; + overflow-x: auto; + width: 100%; +} + +article main table tr { + border-top: 1px solid var(--island-c-divider); +} + +article main table tr:nth-child(2n) { + background-color: var(--island-c-bg-soft); +} + +article main table th, +article main table td, +article main table[class] th, +article main table[class] td { + border: 1px solid var(--island-c-divider); + padding: 12px 16px; + text-align: left; + vertical-align: top; +} + +article main table th, +article main table[class] th { + background-color: var(--island-c-white-soft); + color: var(--island-c-text-1); + font-size: 13.6px; + font-weight: 600; + letter-spacing: normal; + text-transform: none; +} + +article main table[class] code { + background-color: var(--island-c-bg-mute); + border: 0; + border-radius: 4px; + color: var(--island-c-text-code); + font-size: 14px; + font-weight: inherit; + padding: 3px 6px; + white-space: normal; +} + +.dark article main table th, +.dark article main table[class] th { + background-color: var(--island-c-black); +} + +@media (width >= 1440px) { + body > div:has(> aside.nextra-sidebar) { + margin-inline: auto; + } + + body > div:has(> aside.nextra-sidebar) > article { + margin-left: 340px; + } + + body:has(.echo-home) > div:has(> aside.nextra-sidebar) > article { + margin-left: 0; + } + + nav.nextra-toc { + flex-basis: 224px; + margin-left: 100px; + width: 224px; + } +} + +@media (width >= 768px) and (width < 1100px) { + body > div:has(> aside.nextra-sidebar) > article { + flex: 1 1 0; + min-width: 0; + width: auto; + } + + body > div:has(> aside.nextra-sidebar) > article > main { + width: 100%; + } +} + +@media (width < 768px) { + html:root { + --nextra-navbar-height: var(--island-nav-height-mobile); + } + + header.nextra-navbar { + height: var(--island-nav-height-mobile); + inset: auto; + margin-inline: 8px; + position: relative; + width: calc(100% - 16px); + } + + .nextra-navbar > nav { + height: var(--island-nav-height-mobile); + padding-left: 32px; + padding-right: 16px; + } + + .nextra-navbar > nav > a[aria-label='Home page'] { + left: 32px; + } + + .nextra-navbar > nav > div:has(> .island-search) { + display: block; + left: auto; + right: 52px; + width: 24px; + } + + .island-search { + width: 24px; + } + + .island-search > svg { + left: 2px; + } + + .island-search .nextra-search { + margin-left: 0; + width: 24px; + } + + .island-search .nextra-search input { + cursor: pointer; + opacity: 0; + padding: 0; + width: 24px; + } + + .island-search .nextra-search kbd { + display: none; + } + + .nextra-navbar .island-locale-switch, + .nextra-navbar .island-theme-switch { + display: none; + } + + body > div:has(> aside.nextra-sidebar) { + display: block; + margin: 0; + max-width: none; + padding: 44px 16px 96px 24px; + position: relative; + } + + .island-mobile-menu { + align-items: center; + background: transparent; + border-bottom: 1px solid var(--island-c-divider-light); + border-left: 0; + border-right: 0; + border-top: 1px solid var(--island-c-divider-light); + color: var(--island-c-text-1); + display: flex; + font-size: 14px; + gap: 8px; + height: 48px; + left: 8px; + padding-inline: 28px; + position: absolute; + right: 8px; + text-align: left; + top: var(--island-nav-height-mobile); + width: calc(100% - 16px); + z-index: 1; + } + + .island-mobile-menu:focus-visible { + outline: 2px solid var(--island-c-brand); + outline-offset: -2px; + } + + body:has(.echo-home) > div:has(> aside.nextra-sidebar) { + margin: 0; + padding: 0; + } + + body:has(.echo-home) .island-mobile-menu { + display: none; + } + + body:has(.echo-home) > div:has(> aside.nextra-sidebar) > article { + margin: 0; + padding: 0; + width: 100%; + } + + body > div:has(> aside.nextra-sidebar) > article { + flex: none; + margin: 0; + padding: 16px 16px 32px; + width: 100%; + } + + body > div:has(> aside.nextra-sidebar) > article > main { + max-width: none; + width: 100%; + } + + nav.nextra-toc { + display: none; + } + + article main h1 { + font-size: 28px; + } + +} diff --git a/docs-nextra/app/[lang]/layout.tsx b/docs-nextra/app/[lang]/layout.tsx index 942c4184..c3247314 100644 --- a/docs-nextra/app/[lang]/layout.tsx +++ b/docs-nextra/app/[lang]/layout.tsx @@ -1,11 +1,13 @@ import type { Metadata } from 'next' -import { Head } from 'nextra/components' +import { Head, Search } from 'nextra/components' import { getPageMap } from 'nextra/page-map' -import { Footer, Layout, LocaleSwitch, Navbar } from 'nextra-theme-docs' +import { Footer, Layout, LocaleSwitch, Navbar, ThemeSwitch } from 'nextra-theme-docs' import 'nextra-theme-docs/style.css' import type { FC, ReactNode } from 'react' import '@nafr/echo-ui/style.css' +import { IslandMobileMenu } from '../_components/island-mobile-menu' import './styles.css' +import './island-theme.css' export const metadata: Metadata = { description: 'A high-performance UI framework designed for the Web Audio API.', @@ -25,10 +27,9 @@ type LayoutProps = Readonly<{ const messagesByLocale = { en: { - editLink: 'Edit this page on GitHub', - feedback: 'Report a documentation issue', footer: 'Released under the MIT License.', name: 'English', + search: 'Search', toc: { backToTop: 'Back to top', title: 'On this page', @@ -40,10 +41,9 @@ const messagesByLocale = { }, }, zh: { - editLink: '在 GitHub 上编辑此页', - feedback: '反馈文档问题', footer: '基于 MIT 许可证发布。', name: '简体中文', + search: '搜索', toc: { backToTop: '返回顶部', title: '本页目录', @@ -65,15 +65,20 @@ const RootLayout: FC = async ({ children, params }) => { const { lang } = await params const messages = messagesByLocale[lang as keyof typeof messagesByLocale] ?? messagesByLocale.en const navbar = ( - Echo UI} - logoLink={`/${lang}`} - projectLink="https://github.com/codeacme17/echo-ui" - > - + Echo UI} logoLink={`/${lang}`}> + + ) + const search = ( +
+ + +
+ ) const footer =
{messages.footer}
return ( @@ -87,16 +92,21 @@ const RootLayout: FC = async ({ children, params }) => { /> + {children} diff --git a/docs-nextra/app/[lang]/styles.css b/docs-nextra/app/[lang]/styles.css index 27a1678c..6cd045bf 100644 --- a/docs-nextra/app/[lang]/styles.css +++ b/docs-nextra/app/[lang]/styles.css @@ -1,278 +1,245 @@ -:root { - --echo-docs-accent: oklch(72% 0.17 70); - --echo-docs-ink: oklch(19% 0.018 70); - --echo-docs-muted: oklch(49% 0.025 70); - --echo-docs-paper: oklch(98% 0.012 78); - --echo-docs-rule: oklch(85% 0.025 74); -} - -.echo-docs-logo { - color: var(--echo-docs-accent); - font-size: 1.05rem; - letter-spacing: -0.02em; -} - .echo-home { - background: var(--echo-docs-paper); - color: var(--echo-docs-ink); - min-height: calc(100vh - var(--nextra-navbar-height)); - overflow: hidden; -} - -.dark .echo-home { - --echo-docs-ink: oklch(94% 0.015 72); - --echo-docs-muted: oklch(72% 0.025 72); - --echo-docs-paper: oklch(16% 0.016 70); - --echo-docs-rule: oklch(29% 0.028 70); -} - -.echo-home__hero, -.echo-home__features { - margin-inline: auto; - max-width: 90rem; - padding-inline: clamp(1.5rem, 5vw, 5rem); + background: var(--island-c-bg); + color: var(--island-c-text-1); + min-height: calc(100vh - var(--island-nav-height-desktop)); + padding-bottom: 48px; } .echo-home__hero { - display: grid; - min-height: min(48rem, calc(100vh - var(--nextra-navbar-height))); - padding-block: clamp(4.5rem, 10vw, 9rem) clamp(3rem, 7vw, 6rem); - position: relative; + align-items: center; + display: flex; + justify-content: space-between; + margin-inline: auto; + max-width: 1152px; + padding: 80px 0 60px; } .echo-home__hero-copy { - align-self: center; - max-width: 68rem; - position: relative; - z-index: 1; -} - -.echo-home__eyebrow { - align-items: center; - color: var(--echo-docs-muted); display: flex; - font-size: 0.75rem; - font-weight: 700; - gap: 0.65rem; - letter-spacing: 0.12em; - margin: 0 0 1.5rem; - text-transform: uppercase; + flex-direction: column; + margin: 0; + max-width: 592px; } -.echo-home__eyebrow span { - background: var(--echo-docs-accent); - border-radius: 999px; - box-shadow: 0 0 0 0.3rem color-mix(in oklch, var(--echo-docs-accent) 16%, transparent); - height: 0.45rem; - width: 0.45rem; +.echo-home__title, +.echo-home__hero-text { + color: var(--island-c-text-1); + font-family: var(--island-font-family-base); + font-size: 56px; + font-weight: 700; + line-height: 64px; + margin: 0; + max-width: 576px; } -.echo-home h1, -.echo-home h2, -.echo-home h3 { - font-family: 'Iowan Old Style', 'Palatino Linotype', 'Songti SC', serif; +.echo-home__title span:first-child { + background: linear-gradient(120deg, var(--island-c-brand) 30%, var(--island-c-brand-light)); + background-clip: text; + color: transparent; } -.echo-home h1 { - font-size: clamp(3.35rem, 9.2vw, 8.5rem); +.echo-home__tagline { + color: var(--island-c-text-2); + font-size: 24px; font-weight: 500; - letter-spacing: -0.065em; - line-height: 0.88; + line-height: 32px; margin: 0; - max-width: 10.5ch; - text-wrap: balance; -} - -.echo-home__lead { - color: var(--echo-docs-muted); - font-size: clamp(1.05rem, 1.5vw, 1.35rem); - line-height: 1.65; - margin: clamp(2rem, 4vw, 3.5rem) 0 0; - max-width: 49rem; + padding-top: 12px; + white-space: pre-wrap; } .echo-home__actions { - align-items: center; display: flex; flex-wrap: wrap; - gap: 0.75rem 1.25rem; - margin-top: 2rem; + gap: 8px; + padding-top: 32px; } .echo-home__action { align-items: center; + border: 1px solid transparent; + border-radius: 24px; display: inline-flex; - font-size: 0.925rem; - font-weight: 700; - gap: 1.25rem; + font-size: 16px; + font-weight: 500; justify-content: center; - min-height: 3rem; - padding: 0.75rem 1.15rem; + line-height: 24px; + min-height: 48px; + padding: 0 24px; text-decoration: none; transition: - background-color 140ms cubic-bezier(0.25, 1, 0.5, 1), - color 140ms cubic-bezier(0.25, 1, 0.5, 1), - transform 140ms cubic-bezier(0.25, 1, 0.5, 1); + background-color 0.25s, + border-color 0.25s, + color 0.25s; } .echo-home__action--primary { - background: var(--echo-docs-ink); - color: var(--echo-docs-paper); + background: var(--island-c-brand); + border-color: var(--island-c-brand-light); + color: rgb(255 255 255 / 87%); } .echo-home__action--secondary { - color: var(--echo-docs-ink); - text-decoration: underline; - text-decoration-color: var(--echo-docs-accent); - text-decoration-thickness: 0.15rem; - text-underline-offset: 0.35rem; + background: var(--island-c-bg-mute); + border-color: var(--island-c-divider); + color: var(--island-c-text-1); } .echo-home__action:focus-visible { - outline: 0.18rem solid var(--echo-docs-accent); - outline-offset: 0.2rem; -} - -.echo-home__kicker { - align-self: end; - border-top: 1px solid var(--echo-docs-rule); - color: var(--echo-docs-muted); - font-size: 0.75rem; - letter-spacing: 0.1em; - margin: 4rem 0 0; - padding-top: 1rem; - text-transform: uppercase; + outline: 2px solid var(--island-c-brand); + outline-offset: 2px; } -.echo-home__signal { - align-items: center; +.echo-home__image { display: flex; - gap: clamp(0.25rem, 0.6vw, 0.65rem); - inset: 8% clamp(1.5rem, 5vw, 5rem) auto auto; - opacity: 0.62; - position: absolute; - transform: rotate(-8deg); -} - -.echo-home__signal span { - background: var(--echo-docs-accent); - height: 1rem; - min-height: 0.3rem; - width: clamp(0.18rem, 0.3vw, 0.32rem); + flex: 0 0 256px; + height: 256px; + justify-content: center; + margin: auto; + order: 2; + width: 256px; } -.echo-home__signal span:nth-child(3n + 1) { - height: 2.75rem; +.echo-home__image img { + height: 256px; + width: 256px; } -.echo-home__signal span:nth-child(4n + 2) { - height: 5.5rem; +.echo-home__features { + display: grid; + column-gap: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-inline: auto; + max-width: 1152px; + row-gap: 16px; } -.echo-home__signal span:nth-child(5n + 3) { - height: 3.75rem; +.echo-home__feature { + background: var(--island-c-bg-soft); + border: 1px solid var(--island-c-bg-soft); + border-radius: 12px; + margin-right: 16px; + min-height: 222px; + padding: 24px; } -.echo-home__features { - border-top: 1px solid var(--echo-docs-rule); - padding-block: clamp(4rem, 8vw, 8rem); +.echo-home__feature > div { + align-items: center; + background: #e5e5e5; + border-radius: 6px; + display: flex; + font-size: 30px; + height: 48px; + justify-content: center; + margin-bottom: 20px; + width: 48px; } -.echo-home__features > header { - display: grid; - gap: 1.5rem; - grid-template-columns: minmax(5rem, 0.4fr) minmax(0, 1.6fr); - margin-bottom: clamp(3rem, 6vw, 5.5rem); +.dark .echo-home__feature > div { + background: var(--island-c-bg); } -.echo-home__features > header > p, -.echo-home__features li > span { - color: var(--echo-docs-accent); - font-size: 0.75rem; - font-variant-numeric: tabular-nums; +.echo-home__feature h2 { + color: var(--island-c-text-1); + font-size: 16px; font-weight: 700; - letter-spacing: 0.12em; + line-height: 24px; + margin: 0; } -.echo-home__features h2 { - font-size: clamp(2.4rem, 5vw, 5rem); +.echo-home__feature p { + color: var(--island-c-text-2); + font-size: 14px; font-weight: 500; - letter-spacing: -0.045em; - line-height: 0.95; + line-height: 24px; margin: 0; + padding-top: 8px; } -.echo-home__features header div > p { - color: var(--echo-docs-muted); - font-size: 1.05rem; - line-height: 1.65; - margin: 1.5rem 0 0; - max-width: 43rem; -} +@media (hover: hover) { + .echo-home__action--primary:hover { + background: var(--island-c-brand-light); + } -.echo-home__features ol { - list-style: none; - margin: 0; - padding: 0; + .echo-home__action--secondary:hover { + background: var(--island-c-divider-light); + } } -.echo-home__features li { - border-top: 1px solid var(--echo-docs-rule); - display: grid; - gap: 1.5rem; - grid-template-columns: minmax(5rem, 0.4fr) minmax(0, 1.6fr); - padding-block: clamp(1.5rem, 3vw, 2.5rem); +@media (width >= 768px) and (width < 1024px) { + .echo-home__image { + display: none; + } } -.echo-home__features li div { - display: grid; - gap: 0.75rem 2rem; - grid-template-columns: minmax(10rem, 0.65fr) minmax(14rem, 1.35fr); -} +@media (width < 768px) { + .echo-home { + min-height: calc(100vh - var(--island-nav-height-mobile)); + } -.echo-home__features h3 { - font-size: clamp(1.35rem, 2vw, 1.8rem); - font-weight: 600; - letter-spacing: -0.025em; - margin: 0; -} + .echo-home__hero { + flex-direction: column; + padding: 104px 24px 56px; + } -.echo-home__features li p { - color: var(--echo-docs-muted); - line-height: 1.65; - margin: 0; - max-width: 43rem; -} + .echo-home__hero-copy { + align-items: center; + margin: auto; + max-width: calc(100vw - 64px); + order: 2; + text-align: center; + width: 326px; + } -@media (hover: hover) { - .echo-home__action:hover { - transform: translateY(-0.12rem); + .echo-home__title, + .echo-home__hero-text { + font-size: 30px; + line-height: 36px; + max-width: 392px; } - .echo-home__action--primary:hover { - background: var(--echo-docs-accent); - color: oklch(18% 0.025 70); + .echo-home__hero-text { + width: 100%; } -} -@media (max-width: 44rem) { - .echo-home__hero { - min-height: auto; + .echo-home__tagline { + font-size: 14px; + line-height: 20px; } - .echo-home__signal { - opacity: 0.28; + .echo-home__actions { + align-self: stretch; + justify-content: center; + margin: -6px; } - .echo-home__features > header, - .echo-home__features li, - .echo-home__features li div { + .echo-home__image { + display: flex; + order: 1; + } + + .echo-home__features { + gap: 16px; grid-template-columns: 1fr; + padding-inline: 8px; + } + + .echo-home__feature { + margin-right: 0; + min-height: 0; } +} + +@media (width >= 1280px) { + .echo-home__image { + position: relative; + top: -2px; + } +} - .echo-home__features > header, - .echo-home__features li { - gap: 1rem; +@media (width >= 640px) and (width < 768px) { + .echo-home__features { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/docs-nextra/app/_components/controller-demo.tsx b/docs-nextra/app/_components/controller-demo.tsx index deca8f80..284370f3 100644 --- a/docs-nextra/app/_components/controller-demo.tsx +++ b/docs-nextra/app/_components/controller-demo.tsx @@ -29,7 +29,7 @@ type DemoFrameProps = Readonly<{ const DemoFrame: FC = ({ children, controller, lang, status, tall }) => (
- {lang === 'zh' ? '实时控件' : 'Live control'} + {lang === 'zh' ? '预览' : 'Preview'}

{status}

diff --git a/docs-nextra/app/_components/controller-docs.module.css b/docs-nextra/app/_components/controller-docs.module.css index 85b5bd83..0def45ff 100644 --- a/docs-nextra/app/_components/controller-docs.module.css +++ b/docs-nextra/app/_components/controller-docs.module.css @@ -1,98 +1,53 @@ -.install { - align-items: start; - background: oklch(97% 0.018 78); - border: 1px solid oklch(87% 0.045 78); - border-radius: 0.9rem; - display: grid; - gap: 0.5rem 1rem; - grid-template-columns: auto minmax(0, 1fr); - margin-block: 1.5rem 2rem; - padding: 1rem 1.25rem; -} - -.installBadge { - background: oklch(69% 0.17 66); - border-radius: 999px; - color: oklch(22% 0.045 66); - font-size: 0.75rem; - font-weight: 750; - letter-spacing: 0.08em; - line-height: 1.5rem; - padding-inline: 0.65rem; - text-transform: uppercase; -} - -.installCopy { - color: oklch(31% 0.025 66); - font-size: 0.9rem; - line-height: 1.5; - margin: 0; -} - -.installCommand { - background: oklch(92% 0.025 78); - border-radius: 0.35rem; - color: oklch(25% 0.035 66); - display: inline-block; - font-size: 0.85rem; - grid-column: 2; - line-height: 1.5; - overflow-wrap: anywhere; - padding: 0.35rem 0.55rem; -} - .demo { - background: - linear-gradient(oklch(98% 0.008 74 / 94%), oklch(98% 0.008 74 / 94%)), - radial-gradient(circle at 1px 1px, oklch(67% 0.04 74) 1px, transparent 0); - background-size: - auto, - 1rem 1rem; - border: 1px solid oklch(87% 0.025 74); - border-radius: 1.1rem; + background: transparent; + border: 1px solid var(--island-c-divider); + border-radius: 8px; container-type: inline-size; - margin-block: 1.5rem 2rem; + margin: 16px 0; overflow: clip; } .demoHeader { align-items: center; - border-bottom: 1px solid oklch(88% 0.025 74); + border-bottom: 1px solid var(--island-c-divider-light); display: flex; - gap: 0.75rem; - justify-content: space-between; - padding: 0.7rem 1rem; + min-height: 44px; + padding-inline: 12px; } .demoLabel { - color: oklch(38% 0.035 65); - font-size: 0.72rem; - font-weight: 750; - letter-spacing: 0.1em; - text-transform: uppercase; + border-bottom: 2px solid var(--island-c-brand); + color: var(--island-c-text-1); + font-size: 14px; + font-weight: 500; + padding: 10px 4px 8px; } .demoStatus { - color: oklch(42% 0.025 65); - font-size: 0.82rem; - font-variant-numeric: tabular-nums; - line-height: 1.4; - margin: 0; - text-align: right; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; } .demoSurface { align-items: center; + background: transparent; + border-radius: 8px; display: flex; flex-wrap: wrap; - gap: 1.25rem; + font-family: 'JetBrains Mono', monospace; + gap: 20px; justify-content: center; - min-height: 10rem; - padding: clamp(1.5rem, 6cqi, 3.5rem); + padding: 20px; } .demoSurfaceTall { - min-height: 16rem; + min-height: 240px; } .demoStack { @@ -246,36 +201,15 @@ width: 1px; } -:global(.dark) .install { - background: oklch(23% 0.025 67); - border-color: oklch(38% 0.045 67); -} - -:global(.dark) .installCopy, -:global(.dark) .installCommand { - color: oklch(88% 0.025 75); -} - -:global(.dark) .installCommand { - background: oklch(29% 0.03 67); -} - :global(.dark) .demo { - background: - linear-gradient(oklch(19% 0.012 70 / 94%), oklch(19% 0.012 70 / 94%)), - radial-gradient(circle at 1px 1px, oklch(42% 0.035 70) 1px, transparent 0); - background-size: - auto, - 1rem 1rem; - border-color: oklch(32% 0.025 70); + border-color: var(--island-c-divider); } :global(.dark) .demoHeader, :global(.dark) .apiSection + .apiSection { - border-color: oklch(32% 0.025 70); + border-color: var(--island-c-divider-light); } -:global(.dark) .demoLabel, :global(.dark) .demoStatus, :global(.dark) .demoField > span, :global(.dark) .apiInherited { @@ -302,33 +236,12 @@ } @container (max-width: 32rem) { - .demoHeader { - align-items: flex-start; - flex-direction: column; - } - - .demoStatus { - text-align: left; - } - .demoSurface { - justify-content: flex-start; + padding: 20px; } } @media (max-width: 640px) { - .install { - grid-template-columns: 1fr; - } - - .installBadge { - justify-self: start; - } - - .installCommand { - grid-column: 1; - } - .tableWrap { border: 0; overflow: visible; diff --git a/docs-nextra/app/_components/display-demo-frame.tsx b/docs-nextra/app/_components/display-demo-frame.tsx index 84c73d3a..68d8df90 100644 --- a/docs-nextra/app/_components/display-demo-frame.tsx +++ b/docs-nextra/app/_components/display-demo-frame.tsx @@ -13,7 +13,6 @@ export const demoCopy = { connected: 'Signal graph ready', connecting: 'Connecting signal graph', idle: 'Signal graph idle', - live: 'Live input', playing: 'Live signal running', reconnect: 'Reconnect graph', start: 'Start signal', @@ -25,7 +24,6 @@ export const demoCopy = { connected: '信号图已就绪', connecting: '正在连接信号图', idle: '信号图空闲', - live: '实时输入', playing: '实时信号运行中', reconnect: '重新连接音频图', start: '启动信号', @@ -74,10 +72,7 @@ export const DemoFrame: FC = ({ data-graph-connected={connected} >
- - - {demoCopy[lang].live} - + {lang === 'zh' ? '预览' : 'Preview'}

{status}

diff --git a/docs-nextra/app/_components/display-docs.module.css b/docs-nextra/app/_components/display-docs.module.css index e48cb1e2..b7d127b0 100644 --- a/docs-nextra/app/_components/display-docs.module.css +++ b/docs-nextra/app/_components/display-docs.module.css @@ -1,85 +1,60 @@ .demo { - --lab-accent: oklch(69% 0.17 66); - --lab-border: oklch(84% 0.035 72); - --lab-ink: oklch(27% 0.03 66); - --lab-muted: oklch(45% 0.025 68); - --lab-panel: oklch(96% 0.018 76); - background: var(--lab-panel); - border: 1px solid var(--lab-border); - border-radius: 0.8rem; + --lab-accent: var(--island-c-brand); + --lab-ink: var(--island-c-text-1); + background: transparent; + border: 1px solid var(--island-c-divider); + border-radius: 8px; container-type: inline-size; - margin-block: 1.5rem 2rem; + margin: 16px 0; overflow: clip; } .demoHeader { align-items: center; - background: linear-gradient(90deg, oklch(92% 0.035 72), oklch(97% 0.014 76)), var(--lab-panel); - border-bottom: 1px solid var(--lab-border); + background: transparent; + border-bottom: 1px solid var(--island-c-divider-light); display: flex; - gap: 1rem; - justify-content: space-between; - min-height: 3rem; - padding: 0.65rem clamp(0.85rem, 3cqi, 1.25rem); + min-height: 44px; + padding-inline: 12px; } .signalLabel { - align-items: center; - color: var(--lab-ink); - display: inline-flex; - font-size: 0.72rem; - font-weight: 800; - gap: 0.55rem; - letter-spacing: 0.11em; - text-transform: uppercase; -} - -.signalDot { - background: oklch(68% 0.035 70); - border: 1px solid oklch(55% 0.04 70); - border-radius: 50%; - display: inline-block; - height: 0.58rem; - transition: - background-color 140ms cubic-bezier(0.25, 1, 0.5, 1), - box-shadow 140ms cubic-bezier(0.25, 1, 0.5, 1); - width: 0.58rem; -} - -.signalDot[data-active='true'] { - background: oklch(72% 0.18 64); - box-shadow: 0 0 0.45rem oklch(72% 0.18 64); + border-bottom: 2px solid var(--island-c-brand); + color: var(--island-c-text-1); + font-size: 14px; + font-weight: 500; + padding: 10px 4px 8px; } .demoStatus { - color: var(--lab-muted); - font-size: 0.82rem; - font-variant-numeric: tabular-nums; - line-height: 1.4; - margin: 0; - text-align: right; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; } .instrument { display: grid; - gap: clamp(1rem, 3cqi, 1.5rem); - padding: clamp(1rem, 5cqi, 2.25rem); + font-family: 'JetBrains Mono', monospace; + gap: 20px; + padding: 20px; } .readout { align-items: center; - background: - linear-gradient(oklch(20% 0.025 68 / 96%), oklch(20% 0.025 68 / 96%)), - repeating-linear-gradient(0deg, transparent 0, transparent 19px, oklch(78% 0.06 68 / 15%) 20px), - repeating-linear-gradient(90deg, transparent 0, transparent 19px, oklch(78% 0.06 68 / 15%) 20px); - border: 1px solid oklch(34% 0.035 68); - border-radius: 0.45rem; - box-shadow: inset 0 0 0 1px oklch(13% 0.015 68); + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; display: flex; justify-content: center; min-height: clamp(11rem, 36cqi, 17rem); overflow: hidden; - padding: clamp(0.75rem, 3cqi, 1.25rem); + padding: 0; position: relative; } @@ -221,10 +196,8 @@ } .cardStage { - background: - radial-gradient(circle at 1px 1px, oklch(68% 0.045 72) 1px, transparent 0), oklch(93% 0.018 74); - background-size: 1rem 1rem; - padding: clamp(1.25rem, 6cqi, 3rem); + background: transparent; + padding: 20px; } .effectCard { @@ -258,14 +231,12 @@ } :global(.dark) .demo { - --lab-border: oklch(34% 0.03 70); --lab-ink: oklch(89% 0.025 75); - --lab-muted: oklch(72% 0.028 73); - --lab-panel: oklch(20% 0.015 70); + border-color: var(--island-c-divider); } :global(.dark) .demoHeader { - background: linear-gradient(90deg, oklch(24% 0.03 70), oklch(20% 0.015 70)); + background: transparent; } :global(.dark) .action, @@ -276,9 +247,7 @@ } :global(.dark) .cardStage { - background: - radial-gradient(circle at 1px 1px, oklch(43% 0.035 70) 1px, transparent 0), oklch(17% 0.012 70); - background-size: 1rem 1rem; + background: transparent; } @media (hover: hover) { @@ -299,16 +268,6 @@ } @container (max-width: 34rem) { - .demoHeader { - align-items: flex-start; - flex-direction: column; - gap: 0.3rem; - } - - .demoStatus { - text-align: left; - } - .controls > button { flex: 1 1 9rem; } diff --git a/docs-nextra/app/_components/home-page.tsx b/docs-nextra/app/_components/home-page.tsx index bab14e42..19a1379f 100644 --- a/docs-nextra/app/_components/home-page.tsx +++ b/docs-nextra/app/_components/home-page.tsx @@ -9,99 +9,109 @@ type HomePageProps = Readonly<{ const content = { en: { actions: { - primary: 'Read the guide', - secondary: 'View on GitHub', + primary: 'Get Started', + secondary: 'GitHub', }, - description: - 'Accessible React controls and visualizations for building focused, expressive audio experiences in the browser.', - eyebrow: 'React components for Web Audio', - featureHeading: 'Made for the signal path', - featureIntro: - 'Echo UI brings familiar audio interaction patterns to the web without turning your interface into a generic dashboard.', features: [ { - description: 'Start with controls, meters, scopes, and waveforms designed for audio workflows.', - title: 'Out of the box', + description: 'Easily build an audio interaction app with simple configurations', + icon: '📦', + title: 'Out-of-the-Box', }, { - description: 'Interactions borrow from instruments and digital audio workstations, not form builders.', - title: 'Purposeful interaction', + description: + 'Most components are inspired by Ableton Live, providing a better user experience', + icon: '🎛️', + title: 'High-Quality Interactions', }, { - description: 'Tune the visual system with CSS variables and Tailwind theme tokens.', - title: 'Designed to adapt', + description: + 'Built with React and TailwindCSS, making it easy to customize themes and extend components', + icon: '✨', + title: 'Customizable & Easily Extensible', }, { - description: 'Hooks cover playback, loading, envelopes, meters, waveforms, scopes, and spectra.', - title: 'Audio-aware hooks', + description: 'Hook specially designed for audio interaction and analysis applications.', + icon: '🛠️', + title: 'Easy-to-use Hook', }, { - description: 'Components respond to the space available to them across desktop and compact layouts.', - title: 'Responsive by default', + description: + 'All components support responsive layout and can easily adapt to different screen sizes.', + icon: '📈', + title: 'Responsive layout', + }, + { + description: 'Coming soon', + icon: '📱', + title: 'Mobile friendly', }, ], - kicker: 'Open source · MIT licensed', - title: 'A UI library born for Web Audio', + tagline: 'Built with React and TailwindCSS', + title: 'A UI library born for WAA', }, zh: { actions: { - primary: '阅读指南', - secondary: '在 GitHub 上查看', + primary: '快速开始', + secondary: 'GitHub', }, - description: '为浏览器中专注、富有表现力的音频体验提供无障碍 React 控件与可视化组件。', - eyebrow: '面向 Web Audio 的 React 组件', - featureHeading: '为信号链路而设计', - featureIntro: - 'Echo UI 将熟悉的音频交互方式带到 Web,同时保留每个声音工具应有的个性。', features: [ { - description: '直接使用为音频工作流设计的控件、电平表、示波器和波形图。', + description: '只需要简单的配置,就可以轻松构建一款音频交互应用', + icon: '📦', title: '开箱即用', }, { - description: '交互灵感来自乐器和数字音频工作站,而不是通用表单。', - title: '专注的交互', + description: '大部分组件的交互灵感来自于 Ableton Live,可以提供更好的用户体验', + icon: '🎛️', + title: '优质交互', + }, + { + description: '基于 React 与 TailwindCSS,可以轻松定制主题或扩展组件', + icon: '✨', + title: '可定制 & 易于扩展', }, { - description: '通过 CSS 变量和 Tailwind 主题 token 调整视觉系统。', - title: '可定制、易扩展', + description: '专门为音频交互、解析而应用设计的 Hook,可以轻松实现音频交互应用', + icon: '🛠️', + title: '简便易用的 Hook', }, { - description: 'Hook 覆盖播放、加载、包络、电平、波形、示波和频谱分析。', - title: '懂音频的 Hook', + description: '全部组件都支持响应式布局,可以轻松适配不同的屏幕尺寸', + icon: '📈', + title: '响应式布局', }, { - description: '组件会根据可用空间调整,适应桌面和紧凑布局。', - title: '默认响应式', + description: '即将到来', + icon: '📱', + title: '移动端友好', }, ], - kicker: '开源 · MIT 许可', - title: '为 Web Audio 而生的 UI 组件库', + tagline: '使用 React 和 TailwindCSS 构建', + title: '一款为 WAA 而生的 UI 组件库', }, } as const +const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH ?? '' + export function HomePage({ locale }: HomePageProps) { const copy = content[locale] return ( ) diff --git a/docs-nextra/app/_components/install-package.tsx b/docs-nextra/app/_components/install-package.tsx index 8cea0f7a..75119259 100644 --- a/docs-nextra/app/_components/install-package.tsx +++ b/docs-nextra/app/_components/install-package.tsx @@ -1,19 +1,9 @@ import type { FC } from 'react' -import styles from './controller-docs.module.css' type InstallPackageProps = Readonly<{ lang: 'en' | 'zh' }> -const copy = { - en: 'Install the package, then import its stylesheet once in your application entry point.', - zh: '安装软件包,然后在应用入口文件中引入一次样式表。', -} as const - export const InstallPackage: FC = ({ lang }) => ( - +

{lang === 'zh' ? '导入' : 'Import'}

) diff --git a/docs-nextra/app/_components/island-mobile-menu.tsx b/docs-nextra/app/_components/island-mobile-menu.tsx new file mode 100644 index 00000000..288f4c19 --- /dev/null +++ b/docs-nextra/app/_components/island-mobile-menu.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { FC } from 'react' +import { useEffect, useState } from 'react' + +type IslandMobileMenuProps = Readonly<{ + lang: 'en' | 'zh' +}> + +const mobileNavigationId = 'island-mobile-navigation' + +export const IslandMobileMenu: FC = ({ lang }) => { + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + const mobileNavigation = document.querySelector('.nextra-mobile-nav') + if (!mobileNavigation) return + + mobileNavigation.id = mobileNavigationId + const updateExpanded = () => + setExpanded(mobileNavigation.getBoundingClientRect().top >= 0) + const observer = new MutationObserver(updateExpanded) + observer.observe(mobileNavigation, { attributeFilter: ['class'], attributes: true }) + updateExpanded() + + return () => observer.disconnect() + }, []) + + const toggleNavigation = () => { + document.querySelector('.nextra-hamburger')?.click() + setExpanded((current) => !current) + } + + return ( + + ) +} diff --git a/docs-nextra/content/en/_meta.ts b/docs-nextra/content/en/_meta.ts index 169f0836..5e4ab437 100644 --- a/docs-nextra/content/en/_meta.ts +++ b/docs-nextra/content/en/_meta.ts @@ -2,6 +2,7 @@ import type { MetaRecord } from 'nextra' const meta: MetaRecord = { index: { + display: 'hidden', title: 'Home', type: 'page', theme: { @@ -21,7 +22,12 @@ const meta: MetaRecord = { }, component: { href: '/en/component/button/', - title: 'Components', + title: 'Component', + type: 'page', + }, + hook: { + href: '/en/guide/declaration/#about-the-hooks', + title: 'Hook', type: 'page', }, community: { @@ -39,7 +45,7 @@ const meta: MetaRecord = { title: 'Discord', }, }, - title: 'Community', + title: 'Links', type: 'menu', }, } diff --git a/docs-nextra/content/en/component/_meta.ts b/docs-nextra/content/en/component/_meta.ts index a36d601d..fd7c6216 100644 --- a/docs-nextra/content/en/component/_meta.ts +++ b/docs-nextra/content/en/component/_meta.ts @@ -1,6 +1,10 @@ import type { MetaRecord } from 'nextra' const meta: MetaRecord = { + controllers: { + title: 'Controller', + type: 'separator', + }, button: 'Button', checkbox: 'Checkbox', envelope: 'Envelope', @@ -10,7 +14,7 @@ const meta: MetaRecord = { slider: 'Slider', switch: 'Switch', visualizations: { - title: 'Visualizations', + title: 'Visualization', type: 'separator', }, lfo: 'LFO', @@ -20,7 +24,7 @@ const meta: MetaRecord = { vumeter: 'VU Meter', waveform: 'Waveform', containers: { - title: 'Containers', + title: 'Container', type: 'separator', }, card: 'Card', diff --git a/docs-nextra/content/en/component/button.mdx b/docs-nextra/content/en/component/button.mdx index 68a5ae34..a71644f2 100644 --- a/docs-nextra/content/en/component/button.mdx +++ b/docs-nextra/content/en/component/button.mdx @@ -22,7 +22,7 @@ export function AuditionButton() { } ``` -## Live example +## Usage The first button keeps its `toggled` state in React. The group is a controlled single-select field: click a waveform and the parent writes the next `value` back. diff --git a/docs-nextra/content/en/component/checkbox.mdx b/docs-nextra/content/en/component/checkbox.mdx index c54c2df0..30c6bc85 100644 --- a/docs-nextra/content/en/component/checkbox.mdx +++ b/docs-nextra/content/en/component/checkbox.mdx @@ -22,7 +22,7 @@ export function NormalizeOption() { } ``` -## Live example +## Usage This controlled group writes the array from `event.value` back to `value`. diff --git a/docs-nextra/content/en/component/envelope.mdx b/docs-nextra/content/en/component/envelope.mdx index 6fe64a8e..8aefab54 100644 --- a/docs-nextra/content/en/component/envelope.mdx +++ b/docs-nextra/content/en/component/envelope.mdx @@ -25,7 +25,7 @@ const initial: EnvelopeData = { } ``` -## Live example +## Usage Drag the SVG nodes or edit the equivalent native fields. Both paths update the same controlled `data` object. diff --git a/docs-nextra/content/en/component/input.mdx b/docs-nextra/content/en/component/input.mdx index f543a7e0..918c08bc 100644 --- a/docs-nextra/content/en/component/input.mdx +++ b/docs-nextra/content/en/component/input.mdx @@ -22,7 +22,7 @@ export function TrackName() { } ``` -## Live example +## Usage Type a gain value, or drag vertically after moving more than 20 pixels. The bilateral fill grows away from the range midpoint (`-24`) in this `-60` to `12` range. diff --git a/docs-nextra/content/en/component/knob.mdx b/docs-nextra/content/en/component/knob.mdx index 4ed29180..26cce061 100644 --- a/docs-nextra/content/en/component/knob.mdx +++ b/docs-nextra/content/en/component/knob.mdx @@ -22,7 +22,7 @@ export function GainKnob() { } ``` -## Live example +## Usage Drag upward or downward on either knob. The native range below mirrors Gain so keyboard and assistive-technology users can edit the same state. diff --git a/docs-nextra/content/en/component/radio.mdx b/docs-nextra/content/en/component/radio.mdx index fae11f25..b9857b0c 100644 --- a/docs-nextra/content/en/component/radio.mdx +++ b/docs-nextra/content/en/component/radio.mdx @@ -22,7 +22,7 @@ export function QualityChoice() { } ``` -## Live example +## Usage The group keeps its selected option in React state and reports the next option through `event.value`. diff --git a/docs-nextra/content/en/component/slider.mdx b/docs-nextra/content/en/component/slider.mdx index 597041b7..01b2088a 100644 --- a/docs-nextra/content/en/component/slider.mdx +++ b/docs-nextra/content/en/component/slider.mdx @@ -22,7 +22,7 @@ export function MixSlider() { } ``` -## Live example +## Usage Drag the track, or focus it and use an arrow key. The example adds the ARIA and keyboard behavior required around the pointer-driven base component. diff --git a/docs-nextra/content/en/component/switch.mdx b/docs-nextra/content/en/component/switch.mdx index 1a6916ca..37fe8799 100644 --- a/docs-nextra/content/en/component/switch.mdx +++ b/docs-nextra/content/en/component/switch.mdx @@ -22,7 +22,7 @@ export function MonitoringSwitch() { } ``` -## Live example +## Usage This controlled example supplies switch semantics and handles Space and Enter in addition to pointer clicks. diff --git a/docs-nextra/content/zh/_meta.ts b/docs-nextra/content/zh/_meta.ts index dcc217a4..021c3a8f 100644 --- a/docs-nextra/content/zh/_meta.ts +++ b/docs-nextra/content/zh/_meta.ts @@ -2,6 +2,7 @@ import type { MetaRecord } from 'nextra' const meta: MetaRecord = { index: { + display: 'hidden', title: '首页', type: 'page', theme: { @@ -24,6 +25,11 @@ const meta: MetaRecord = { title: '组件', type: 'page', }, + hook: { + href: '/zh/guide/declaration/#关于-hook', + title: 'Hook', + type: 'page', + }, community: { items: { discussions: { @@ -39,7 +45,7 @@ const meta: MetaRecord = { title: 'Discord', }, }, - title: '社区', + title: '链接', type: 'menu', }, } diff --git a/docs-nextra/content/zh/component/_meta.ts b/docs-nextra/content/zh/component/_meta.ts index d77bf6d4..6c2f4b4b 100644 --- a/docs-nextra/content/zh/component/_meta.ts +++ b/docs-nextra/content/zh/component/_meta.ts @@ -1,6 +1,10 @@ import type { MetaRecord } from 'nextra' const meta: MetaRecord = { + controllers: { + title: '可控组件', + type: 'separator', + }, button: 'Button 按钮', checkbox: 'Checkbox 复选框', envelope: 'Envelope 包络', diff --git a/docs-nextra/content/zh/component/button.mdx b/docs-nextra/content/zh/component/button.mdx index 2ab86575..b4c4838e 100644 --- a/docs-nextra/content/zh/component/button.mdx +++ b/docs-nextra/content/zh/component/button.mdx @@ -22,7 +22,7 @@ export function AuditionButton() { } ``` -## 实时示例 +## 用法 第一个按钮把 `toggled` 保存在 React 状态中。下方分组是受控单选字段:点击波形后,父组件把新的 `value` 写回。 diff --git a/docs-nextra/content/zh/component/checkbox.mdx b/docs-nextra/content/zh/component/checkbox.mdx index 81ca8641..0bd4b322 100644 --- a/docs-nextra/content/zh/component/checkbox.mdx +++ b/docs-nextra/content/zh/component/checkbox.mdx @@ -22,7 +22,7 @@ export function NormalizeOption() { } ``` -## 实时示例 +## 用法 这个受控分组把 `event.value` 中的数组写回 `value`。 diff --git a/docs-nextra/content/zh/component/envelope.mdx b/docs-nextra/content/zh/component/envelope.mdx index 14e1005b..77ce68fa 100644 --- a/docs-nextra/content/zh/component/envelope.mdx +++ b/docs-nextra/content/zh/component/envelope.mdx @@ -25,7 +25,7 @@ const initial: EnvelopeData = { } ``` -## 实时示例 +## 用法 拖动 SVG 节点,或编辑等价的原生字段。两种操作都会更新同一个受控 `data` 对象。 diff --git a/docs-nextra/content/zh/component/input.mdx b/docs-nextra/content/zh/component/input.mdx index b9534e33..020d6b9b 100644 --- a/docs-nextra/content/zh/component/input.mdx +++ b/docs-nextra/content/zh/component/input.mdx @@ -22,7 +22,7 @@ export function TrackName() { } ``` -## 实时示例 +## 用法 可直接输入增益值,也可在移动超过 20 像素后垂直拖动。此处 `-60` 至 `12` 的双向填充会从区间中点(`-24`)向两侧延伸。 diff --git a/docs-nextra/content/zh/component/knob.mdx b/docs-nextra/content/zh/component/knob.mdx index 2887652c..a4b7b8b8 100644 --- a/docs-nextra/content/zh/component/knob.mdx +++ b/docs-nextra/content/zh/component/knob.mdx @@ -22,7 +22,7 @@ export function GainKnob() { } ``` -## 实时示例 +## 用法 在任一旋钮上向上或向下拖动。下方原生范围输入镜像“增益”,因此键盘和辅助技术用户也能编辑同一状态。 diff --git a/docs-nextra/content/zh/component/radio.mdx b/docs-nextra/content/zh/component/radio.mdx index 8bdcf3cd..54816ad3 100644 --- a/docs-nextra/content/zh/component/radio.mdx +++ b/docs-nextra/content/zh/component/radio.mdx @@ -22,7 +22,7 @@ export function QualityChoice() { } ``` -## 实时示例 +## 用法 分组把选中项保存在 React 状态中,并通过 `event.value` 返回下一选项。 diff --git a/docs-nextra/content/zh/component/slider.mdx b/docs-nextra/content/zh/component/slider.mdx index 085028d8..90612834 100644 --- a/docs-nextra/content/zh/component/slider.mdx +++ b/docs-nextra/content/zh/component/slider.mdx @@ -22,7 +22,7 @@ export function MixSlider() { } ``` -## 实时示例 +## 用法 可拖动轨道,也可聚焦后使用方向键。示例在指针驱动的基础组件之外,补充了所需的 ARIA 与键盘行为。 diff --git a/docs-nextra/content/zh/component/switch.mdx b/docs-nextra/content/zh/component/switch.mdx index c670731c..ebec7e59 100644 --- a/docs-nextra/content/zh/component/switch.mdx +++ b/docs-nextra/content/zh/component/switch.mdx @@ -22,7 +22,7 @@ export function MonitoringSwitch() { } ``` -## 实时示例 +## 用法 这个受控示例提供开关语义,并在指针点击之外处理空格和 Enter。 diff --git a/docs-nextra/public/logo.png b/docs-nextra/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ceb35dc811c0023a8a93a87f6f623575fd6cd126 GIT binary patch literal 3715 zcmdT{i8s{i8-KrJ#>^-gTuTxnx*<}GWE(2`zKlU>N|Plt*~v15k{bzGYAl6h8EYlN zSSqp?jV1Bx=1Mb+o1K~Q)1PtQbDs10e4g{X%X!Z8JmE1Q%#|7FAe(iJFHt zlh(*@r5}sa7fYuQ@@FzipDzeByPYd3{rQ$(TTsN|e@Ug~xGz?#R`$tzp}DYzvd~Q-aXLQcV#ui zrJoO#60g6an`fz%G3&+Ec{V+*&Zu-f`M7shK*Si0Mju!zad>Wv+>+teGGY{QQF~ov z&PZYAG%WE(Xxe{*(et9HR4UusblIPKg9^Nrm<6t=;CadBa2In?O_Bgnv29@0Q1qWI zoXS{0XG?#M$YD}qFl#b&NujWNGHHG5}DwUXfyswj4@v5D5u)@09xkiAxCGqe8Ob|)Q;!1DE4b@`0wVJHP zhLmyEokBQNmPHAbQGHRCw$2UFhEy!0W%p2qKE9jr!s40Hu@@jaos6`Q7&*=d&li@X z%l|aNa65_Ijv3?=`N{F|h=zP^g9bFgXRGBHcvAcIOMk>-Ir-iX3mZ8Rkqh5X5f{sj38 z!B-e)pH>O(-0~xB^Yr%EKr8(h_duY_yojJ`aKY|H+hZ4qyn?*kLmSg#kNhtJqJNRS zQx)-vf`sAHc%fp`yL+Gdg@uJ(Ur?7pfdz5ekDv0O!#^M~whc`fUtNCz?r8b*2b5v? z*e+nVx?Dh)hyTf?7+}ZaGZ^_=TcL+2l_4Re9O+l!nAmWmm? zvNh0{eq_e7nwwsBp3T-xjvGiV_UmovX}!QCEx*&6Wl#?ujfMwGtT=}#8XkT7ug~kp z1rO@87rPTHL}8j8TRA(|p0t6q)24#+trz?AEHuc%9F^ z8KcRQ3$ugoeGirL6Z&@7sFRBeqk3f(pNx7mx3kdw_pjxawC)LP!E8)R+dq(Y`^miI8aP*a=D@muC0wr?C-6&egCb}?VnDgEEV#X&V@+zw^h9c-h{gv&XmMo3x)8jdn^wDB&)qiZ|mfY4*>j=UkJegR&K@uY@)V=x#yxuV5I^ol zlnLg|KxzWL8iT}{W8V1ZV&g6r2~q~DZBX%y`Vi{xD0&trQ!adv?WVq@(g2a;1PT>T z>ppNpREll<@lAl}U}C~X$FEPcnj#DmBlj<@Y~C!6t`kbW zMK7>J?q2Ekm4U%6`Dsx+D27GTXm4lGLiv0ubV;KWHjrQdDg{pc`nm&eXMW9j5H=nD z*Hm0;@yGe#wuCR;-sSFlY$0&%u*?p2++I%s+H z?XSI~V{lxRf9~3^yq6bP@~M}IK|weD7&`_xO|B78Q8BBus9;{G-vIs|34~uZ@^T(D zpgs)>h-DHarDdg6ZX0C^0}Tg3zDW71AlGMzHa!igaSyY@E>KO)_~wQaX(1+wD{^cp zfoAN=?g_OX2*9U^D^n!v@6L2;7Zgf)r$bZ`X<1c^OgpG6#~H#4>OEs_u(DD43)wZ+ zj`qG75uo99XlefKjm_aw?08+WB?Z}UOgC6AvqBQi1QteGXt}rr+r$hMBr=sTAa!g)OTxuR@EW~UDz4&~C>S8zx_eWtA&F|fe@&SJ$UnMo zP7B0)O*V9TATVIxxD)*$Qdn>D@E=#<6vy)cEn0q}_#8If3Y;QEgMg-wQkOhL4g+c3 zpk(*z7xC1589bcgDM9-x23PMpLJ|h47kcWCmWg#l4U5C*8fuHV$jT&7sEH>iHSN_ z&|q%Bqe%gU&oT6b(RFlB52N;`X6;DOY|IG4U|?KgWo6Snp7nBtoN}U2b+ETr+rz2)X8xB6O%(RXZFq-dg9OPUw+&Zua z@T#A(xb?4c1T^DTo%s1vL?8qV2!(UI{%q!RAzoSWh&U}K3m06LX_bQ}{cnPo364WHurtds7X!Ycu0&(EJ`gRXYm7W(bJ0BU^q;Pcd#6moK z+&*@v_WJiPF2jD0eOGtZ&te}fQ1kP7R+QU6cE`$Y&yK&k))E}Lao#9@>U4Cop8nmf zyUD5B9(yQi(f?9`;E!RSI7FN?f_e}=F4Osw&fl!B#w@?1bs*g zm)$E?o($x^Hhhz9y79NkfX9(*G3H|6R3UOZW0bMZCzorLF9K>^j_vew0Z|Hc6*zB= z{+iaSp<43I9D^`kN3O-1^>N*?2&guzcW&91!Mj`}IK>B(OWm5|UG_lO>1fl-DQXZ# zVmXy})dNB7$3;Jp09}?l#%)%rYXOlE&QCRbMLQ*bgleS}dkB0hjIC`WXo6tnb&RFQXVT@%-)hLCM7i=nM@L7?wdIFEn5(bxE` zY-|?AwjkWttY4+Llm2zu_QU`-d#I$n1t!;EQS#AJ=+QdE_s6$IH}qcSmv}COJ9V%z zhPy>p*rA_!L$lfP$>e7X1|lG?R-^RZ2DM+3Q+iYkSJnSWTXv|GZeJt? z@_Mjq(r!QdT4xENqGXMiR==NY+aO=Fj0{Cl#A+=gY^|+ZikG>i)1CG6Vz^`F5b<2# z1kte2GAeHBjrI^IlSfgK{s^8~R}b@V6F!EfD8~bWgN^Lx@4C+H9zB)OkD% { + const pathname = decodeURIComponent(new URL(requestUrl, 'http://localhost').pathname) + const hasExpectedBasePath = + !basePath || pathname === basePath || pathname.startsWith(`${basePath}/`) + assert.ok(hasExpectedBasePath, `${pathname} should stay within the configured base path`) + const relativePath = (basePath ? pathname.slice(basePath.length) : pathname).replace(/^\/+/, '') + const requestedPath = resolve( + outputRoot, + relativePath && !relativePath.endsWith('/') ? relativePath : `${relativePath}index.html`, + ) + + assert.ok( + requestedPath === outputRoot || requestedPath.startsWith(`${outputRoot}${sep}`), + 'Parity requests must stay within the Nextra static output.', + ) + + return requestedPath +} + +const islandFileForRequest = (requestUrl) => { + const pathname = decodeURIComponent(new URL(requestUrl, 'http://localhost').pathname) + const relativePath = pathname.replace(/^\/+/, '') + const requestedPath = resolve( + islandOutputRoot, + relativePath && !relativePath.endsWith('/') ? relativePath : `${relativePath}index.html`, + ) + + assert.ok( + requestedPath === islandOutputRoot || requestedPath.startsWith(`${islandOutputRoot}${sep}`), + 'Visual-reference requests must stay within the Island static output.', + ) + + return requestedPath +} + +const server = createServer(async (request, response) => { + try { + let filePath = fileForRequest(request.url ?? '/') + const fileStats = await stat(filePath) + if (fileStats.isDirectory()) filePath = resolve(filePath, 'index.html') + + response.writeHead(200, { + 'Content-Type': contentTypes[extname(filePath)] ?? 'application/octet-stream', + }) + createReadStream(filePath).pipe(response) + } catch { + response.writeHead(404) + response.end('Not found') + } +}) + +const islandServer = createServer(async (request, response) => { + try { + let filePath = islandFileForRequest(request.url ?? '/') + try { + const fileStats = await stat(filePath) + if (fileStats.isDirectory()) filePath = resolve(filePath, 'index.html') + } catch (error) { + if (extname(filePath)) throw error + filePath = `${filePath}.html` + await stat(filePath) + } + + response.writeHead(200, { + 'Content-Type': contentTypes[extname(filePath)] ?? 'application/octet-stream', + }) + createReadStream(filePath).pipe(response) + } catch { + response.writeHead(404) + response.end('Not found') + } +}) + +const listen = (staticServer) => + new Promise((resolveListen, reject) => { + staticServer.once('error', reject) + staticServer.listen(0, '127.0.0.1', () => resolveListen(staticServer.address())) + }) + +const closeServer = (staticServer) => + new Promise((resolveClose) => staticServer.close(resolveClose)) + +const launchBrowser = async () => { + try { + return await chromium.launch({ headless: true }) + } catch (bundledBrowserError) { + try { + return await chromium.launch({ channel: 'chrome', headless: true }) + } catch { + throw new Error('Playwright could not launch Chromium or Chrome.', { + cause: bundledBrowserError, + }) + } + } +} + +const px = (value) => Number.parseFloat(value) +const withBasePath = (path) => `${basePath}${path}` +const within = (actual, expected, tolerance = 1) => + assert.ok( + Math.abs(actual - expected) <= tolerance, + `Expected ${actual} to be within ${tolerance}px of ${expected}`, + ) + +const compareScreenshots = async (page, islandScreenshot, nextraScreenshot) => + page.evaluate( + async ({ islandDataUrl, nextraDataUrl }) => { + const loadImage = (source) => + new Promise((resolveImage, reject) => { + const image = new Image() + image.onload = () => resolveImage(image) + image.onerror = reject + image.src = source + }) + const [islandImage, nextraImage] = await Promise.all([ + loadImage(islandDataUrl), + loadImage(nextraDataUrl), + ]) + if ( + islandImage.naturalWidth !== nextraImage.naturalWidth || + islandImage.naturalHeight !== nextraImage.naturalHeight + ) { + throw new Error('Island and Nextra screenshots must use the same dimensions.') + } + + const canvas = document.createElement('canvas') + canvas.width = islandImage.naturalWidth + canvas.height = islandImage.naturalHeight + const context = canvas.getContext('2d', { willReadFrequently: true }) + context.drawImage(islandImage, 0, 0) + const islandPixels = context.getImageData(0, 0, canvas.width, canvas.height).data + context.clearRect(0, 0, canvas.width, canvas.height) + context.drawImage(nextraImage, 0, 0) + const nextraPixels = context.getImageData(0, 0, canvas.width, canvas.height).data + + let materiallyDifferentPixels = 0 + let totalDelta = 0 + for (let index = 0; index < islandPixels.length; index += 4) { + const redDelta = Math.abs(islandPixels[index] - nextraPixels[index]) + const greenDelta = Math.abs(islandPixels[index + 1] - nextraPixels[index + 1]) + const blueDelta = Math.abs(islandPixels[index + 2] - nextraPixels[index + 2]) + const maximumDelta = Math.max(redDelta, greenDelta, blueDelta) + totalDelta += redDelta + greenDelta + blueDelta + if (maximumDelta > 32) materiallyDifferentPixels += 1 + } + + const pixelCount = canvas.width * canvas.height + return { + meanChannelDelta: totalDelta / (pixelCount * 3), + mismatchRatio: materiallyDifferentPixels / pixelCount, + } + }, + { + islandDataUrl: `data:image/png;base64,${islandScreenshot.toString('base64')}`, + nextraDataUrl: `data:image/png;base64,${nextraScreenshot.toString('base64')}`, + }, + ) + +const componentRoutes = [ + 'button', + 'card', + 'checkbox', + 'envelope', + 'input', + 'knob', + 'lfo', + 'light', + 'oscilloscope', + 'radio', + 'slider', + 'spectrogram', + 'switch', + 'vumeter', + 'waveform', +] + +const profiles = [ + { colorScheme: 'light', desktop: true, viewport: { height: 900, width: 1440 } }, + { colorScheme: 'dark', desktop: true, viewport: { height: 900, width: 1440 } }, + { colorScheme: 'light', desktop: false, viewport: { height: 844, width: 390 } }, + { colorScheme: 'dark', desktop: false, viewport: { height: 844, width: 390 } }, +] + +const readContract = (page) => + page.evaluate(() => { + const measure = (selector) => { + const element = document.querySelector(selector) + if (!element) return null + const style = getComputedStyle(element) + const rect = element.getBoundingClientRect() + return { + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderColor: style.borderColor, + borderRadius: style.borderRadius, + borderWidth: style.borderWidth, + color: style.color, + display: style.display, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + height: rect.height, + justifyContent: style.justifyContent, + lineHeight: style.lineHeight, + margin: style.margin, + overflow: style.overflow, + padding: style.padding, + paddingTop: style.paddingTop, + position: style.position, + width: rect.width, + x: rect.x, + y: rect.y, + } + } + + return { + article: measure('article main'), + body: measure('body'), + codeBlock: measure('article main div.nextra-code:has(> pre)'), + demo: measure('section[data-controller-demo], section[data-display-demo]'), + demoLabel: document.querySelector( + 'section[data-controller-demo] > header > span, section[data-display-demo] > header > span', + )?.textContent, + demoStatus: measure( + 'section[data-controller-demo] > header > p, section[data-display-demo] > header > p', + ), + demoSurface: measure('section[data-controller-demo] > div:last-child'), + footer: measure('footer'), + h1: measure('article main h1'), + h2: measure('article main h2'), + h2Text: document.querySelector('article main h2')?.textContent?.replace('#', '').trim(), + header: measure('header'), + headerExternalLinks: [ + ...document.querySelectorAll('.nextra-navbar a[target="_blank"]'), + ].filter((link) => getComputedStyle(link).display !== 'none').length, + headerThemeSwitch: measure('.nextra-navbar button[title="Change theme"]'), + inlineCode: measure('article main p code'), + installPackage: measure('article main aside[aria-label="Installation"]'), + islandMobileMenu: measure('.island-mobile-menu'), + logo: measure('.echo-docs-logo'), + navLabels: [ + ...document.querySelectorAll('.nextra-navbar > nav > .nextra-scrollbar > :is(a, button)'), + ] + .filter((item) => getComputedStyle(item).display !== 'none') + .map((item) => item.textContent?.trim()), + paragraph: measure('article main p'), + pre: measure('article main pre'), + search: measure('.nextra-navbar input[type="search"]'), + searchPlaceholder: document.querySelector('.nextra-navbar input[type="search"]')?.placeholder, + sidebarActive: measure('aside.nextra-sidebar li.active > a'), + sidebarFooter: measure('aside.nextra-sidebar .nextra-sidebar-footer'), + sidebarSeparators: [ + ...document.querySelectorAll('aside.nextra-sidebar li:not(:has(a, button))'), + ].map((item) => item.textContent?.trim()), + sidebar: measure('aside.nextra-sidebar'), + table: measure('article main table'), + tableHead: measure('article main th'), + theme: document.documentElement.className, + toc: measure('nav[aria-label="table of contents"]'), + } + }) + +const assertContentContract = (contract, colorScheme, lang = 'en') => { + const dark = colorScheme === 'dark' + assert.match(contract.theme, new RegExp(`\\b${colorScheme}\\b`)) + assert.ok(contract.body?.fontFamily.startsWith('Poppins')) + assert.equal(contract.body?.backgroundColor, dark ? 'rgb(19, 19, 19)' : 'rgb(255, 255, 255)') + assert.equal(contract.body?.color, dark ? 'rgba(255, 255, 255, 0.87)' : 'rgb(33, 53, 71)') + + assert.equal(contract.h1?.fontWeight, '600') + assert.equal(contract.h1?.lineHeight, '40px') + assert.equal(contract.h1?.margin, '0px 0px 10px -10px') + assert.equal(contract.h2?.fontSize, '24px') + assert.equal(contract.h2?.fontWeight, '600') + assert.equal(contract.h2?.lineHeight, '32px') + assert.equal(contract.h2?.margin, '48px 0px 5px -8px') + assert.equal(contract.h2?.borderWidth, '0px') + assert.equal(contract.paragraph?.fontSize, '16px') + assert.equal(contract.paragraph?.lineHeight, '28px') + assert.equal(contract.paragraph?.margin, '16px 0px') + + assert.equal( + contract.inlineCode?.backgroundColor, + dark ? 'rgb(58, 58, 58)' : 'rgb(241, 241, 241)', + ) + assert.equal(contract.inlineCode?.borderRadius, '4px') + assert.equal(contract.inlineCode?.borderWidth, '0px') + assert.equal(contract.inlineCode?.color, 'rgb(233, 160, 13)') + assert.equal(contract.inlineCode?.fontSize, '14px') + assert.equal(contract.inlineCode?.padding, '3px 6px') + + assert.equal(contract.codeBlock?.backgroundColor, dark ? 'rgb(24, 24, 24)' : 'rgb(241, 239, 239)') + assert.equal(contract.codeBlock?.borderRadius, '8px') + assert.equal(contract.codeBlock?.margin, '16px 0px') + assert.equal(contract.codeBlock?.padding, '20px') + assert.equal(contract.pre?.backgroundColor, dark ? 'rgb(24, 24, 24)' : 'rgb(241, 239, 239)') + assert.equal(contract.pre?.borderRadius, '0px') + assert.equal(contract.pre?.borderWidth, '0px') + assert.equal(contract.pre?.fontFamily, '"JetBrains Mono", monospace') + assert.equal(contract.pre?.fontSize, '13.6px') + assert.equal(contract.pre?.lineHeight, '20.4px') + assert.equal(contract.pre?.margin, '0px') + assert.equal(contract.pre?.padding, '0px') + + assert.equal(contract.table?.fontSize, '13.6px') + assert.equal(contract.table?.margin, '20px 0px') + assert.equal(contract.tableHead?.backgroundColor, dark ? 'rgb(26, 26, 26)' : 'rgb(249, 249, 249)') + assert.equal(contract.tableHead?.fontWeight, '600') + assert.equal(contract.tableHead?.padding, '12px 16px') + + assert.equal(contract.demo?.backgroundImage, 'none') + assert.equal(contract.demo?.backgroundColor, 'rgba(0, 0, 0, 0)') + assert.equal(contract.demo?.borderRadius, '8px') + assert.equal(contract.demo?.borderWidth, '1px') + assert.equal(contract.demoLabel, lang === 'zh' ? '预览' : 'Preview') + assert.equal(contract.demoStatus?.position, 'absolute') + if (contract.demoSurface) { + assert.equal(contract.demoSurface.backgroundImage, 'none') + assert.equal(contract.demoSurface.borderRadius, '8px') + assert.equal(contract.demoSurface.display, 'flex') + assert.match(contract.demoSurface.fontFamily, /JetBrains Mono/) + assert.equal(contract.demoSurface.justifyContent, 'center') + assert.equal(contract.demoSurface.padding, '20px') + } + assert.equal(contract.footer?.display, 'none') + assert.equal(contract.headerExternalLinks, 0) + assert.ok(contract.headerThemeSwitch) + assert.equal(contract.installPackage, null) + assert.equal(contract.h2Text, lang === 'zh' ? '导入' : 'Import') + assert.deepEqual( + contract.navLabels, + lang === 'zh' ? ['指南', '组件', 'Hook', '链接'] : ['Guide', 'Component', 'Hook', 'Links'], + ) + assert.equal(contract.sidebarActive?.backgroundColor, 'rgba(0, 0, 0, 0)') + assert.equal(contract.sidebarActive?.color, 'rgb(253, 170, 4)') + assert.equal(contract.sidebarActive?.fontWeight, '400') + assert.equal(contract.sidebarFooter?.display, 'none') + assert.deepEqual( + contract.sidebarSeparators, + lang === 'zh' ? ['可控组件', '可视化', '容器'] : ['Controller', 'Visualization', 'Container'], + ) +} + +const assertDesktopShell = (contract, colorScheme, lang = 'en') => { + assert.equal(contract.header?.position, 'fixed') + within(contract.header?.height ?? 0, 60) + assert.equal(contract.sidebar?.position, 'fixed') + assert.equal( + contract.sidebar?.backgroundColor, + colorScheme === 'dark' ? 'rgb(23, 23, 23)' : 'rgb(249, 249, 249)', + ) + within(contract.sidebar?.width ?? 0, 272) + within(contract.sidebar?.height ?? 0, 900) + within(px(contract.sidebar?.paddingTop ?? '0'), 60) + within(contract.sidebar?.x ?? -1, 0) + within(contract.sidebar?.y ?? -1, 0) + within(contract.logo?.x ?? 0, 32) + assert.equal(contract.logo?.color, contract.body?.color) + assert.equal(contract.searchPlaceholder, lang === 'zh' ? '搜索' : 'Search') + within(contract.search?.x ?? 0, 328) + within(contract.article?.width ?? 0, 704) + within(contract.article?.x ?? 0, 380) + within(contract.article?.y ?? 0, 84) + within(contract.toc?.width ?? 0, 224) + within(contract.toc?.x ?? 0, 1184) + within(contract.toc?.y ?? 0, 84) + assert.equal(contract.h1?.fontSize, '32px') + assert.equal(contract.islandMobileMenu?.display, 'none') +} + +const assertMobileShell = (contract) => { + assert.equal(contract.header?.position, 'relative') + within(contract.header?.height ?? 0, 56) + within(contract.header?.width ?? 0, 374) + within(contract.header?.x ?? 0, 8) + within(contract.header?.y ?? 0, 0) + within(contract.logo?.x ?? 0, 40) + assert.ok( + contract.sidebar?.display === 'none' || + (contract.sidebar?.x ?? 0) + (contract.sidebar?.width ?? 0) <= 1, + 'The closed mobile sidebar must remain off canvas.', + ) + assert.equal(contract.toc?.display, 'none') + assert.equal(contract.islandMobileMenu?.display, 'flex') + within(contract.islandMobileMenu?.height ?? 0, 48) + within(contract.islandMobileMenu?.width ?? 0, 374) + within(contract.islandMobileMenu?.x ?? 0, 8) + within(contract.islandMobileMenu?.y ?? 0, 56) + within(contract.article?.width ?? 0, 318) + within(contract.article?.x ?? 0, 40) + within(contract.article?.y ?? 0, 116) + assert.equal(contract.h1?.fontSize, '28px') +} + +const address = await listen(server) +const islandAddress = await listen(islandServer) +assert.ok(address && typeof address === 'object') +assert.ok(islandAddress && typeof islandAddress === 'object') + +let browser +let activeContext + +try { + browser = await launchBrowser() + + for (const profile of profiles) { + activeContext = await browser.newContext({ + colorScheme: profile.colorScheme, + viewport: profile.viewport, + }) + const page = await activeContext.newPage() + const buttonResponse = await page.goto( + `http://127.0.0.1:${address.port}${withBasePath('/en/component/button/')}`, + { waitUntil: 'networkidle' }, + ) + assert.ok(buttonResponse?.ok(), 'The English Button route should render for parity checks.') + const contract = await readContract(page) + assertContentContract(contract, profile.colorScheme) + if (profile.desktop) assertDesktopShell(contract, profile.colorScheme) + else { + assertMobileShell(contract) + await page.locator('.island-mobile-menu').click() + await page.waitForFunction(() => { + const mobileNav = document.querySelector('.nextra-mobile-nav') + return mobileNav && mobileNav.getBoundingClientRect().top >= 0 + }) + assert.equal(await page.locator('.island-mobile-menu').getAttribute('aria-expanded'), 'true') + assert.equal( + await page.locator('.island-mobile-menu').getAttribute('aria-controls'), + 'island-mobile-navigation', + ) + await page.locator('.nextra-hamburger').click() + await page.waitForFunction( + () => document.querySelector('.island-mobile-menu')?.getAttribute('aria-expanded') === 'false', + ) + } + + for (const lang of ['en', 'zh']) { + for (const route of componentRoutes) { + const response = await page.goto( + `http://127.0.0.1:${address.port}${withBasePath(`/${lang}/component/${route}/`)}`, + { waitUntil: 'load' }, + ) + assert.ok(response?.ok(), `${lang}/${route} should render from the static export.`) + const routeContract = await page.evaluate(() => { + const demos = [ + ...document.querySelectorAll( + 'section[data-controller-demo], section[data-display-demo]', + ), + ] + return { + backgrounds: demos.map((demo) => getComputedStyle(demo).backgroundImage), + clientWidth: document.documentElement.clientWidth, + hasHeading: Boolean(document.querySelector('article main h1')), + hasSignalDot: Boolean( + document.querySelector('section[data-display-demo] [class*="signalDot"]'), + ), + labels: demos.map((demo) => demo.querySelector(':scope > header > span')?.textContent), + lang: document.documentElement.lang, + scrollWidth: document.documentElement.scrollWidth, + theme: document.documentElement.className, + } + }) + assert.equal(routeContract.lang, lang) + assert.match(routeContract.theme, new RegExp(`\\b${profile.colorScheme}\\b`)) + assert.equal(routeContract.hasHeading, true) + assert.ok( + routeContract.backgrounds.length > 0, + `${lang}/${route} should keep its live demo.`, + ) + assert.ok(routeContract.backgrounds.every((background) => background === 'none')) + assert.ok( + routeContract.labels.every((label) => label === (lang === 'zh' ? '预览' : 'Preview')), + ) + assert.equal(routeContract.hasSignalDot, false) + assert.ok( + routeContract.scrollWidth <= routeContract.clientWidth, + `${lang}/${route} should not overflow the ${profile.viewport.width}px viewport.`, + ) + const fullRouteContract = await readContract(page) + assertContentContract(fullRouteContract, profile.colorScheme, lang) + if (profile.desktop) assertDesktopShell(fullRouteContract, profile.colorScheme, lang) + else assertMobileShell(fullRouteContract) + } + + const homeResponse = await page.goto( + `http://127.0.0.1:${address.port}${withBasePath(`/${lang}/`)}`, + { waitUntil: 'load' }, + ) + assert.ok(homeResponse?.ok(), `${lang} home should render from the static export.`) + const homeContract = await page.evaluate(() => { + const rect = (selector) => { + const element = document.querySelector(selector) + if (!element) return null + const bounds = element.getBoundingClientRect() + return { + backgroundColor: getComputedStyle(element).backgroundColor, + display: getComputedStyle(element).display, + height: bounds.height, + width: bounds.width, + x: bounds.x, + y: bounds.y, + } + } + const home = document.querySelector('.echo-home') + const hero = document.querySelector('.echo-home__hero') + const heroRect = hero?.getBoundingClientRect() + return { + featureCount: document.querySelectorAll('.echo-home__feature').length, + firstFeature: rect('.echo-home__feature'), + hasSignalLab: Boolean(document.querySelector('.echo-home__signal')), + heroActions: rect('.echo-home__actions'), + heroImage: rect('.echo-home__image img'), + heroImageSource: document.querySelector('.echo-home__image img')?.currentSrc, + heroName: rect('.echo-home__title'), + heroTagline: rect('.echo-home__tagline'), + heroText: rect('.echo-home__hero-text'), + heroWidth: heroRect?.width, + heroX: heroRect?.x, + homeExists: Boolean(home), + menuButton: rect('.island-mobile-menu'), + title: [ + document.querySelector('.echo-home__title')?.textContent, + document.querySelector('.echo-home__hero-text')?.textContent, + ] + .filter(Boolean) + .join(' '), + } + }) + assert.equal(homeContract.homeExists, true) + assert.equal(homeContract.hasSignalLab, false) + assert.equal(homeContract.featureCount, 6) + assert.equal( + homeContract.firstFeature?.backgroundColor, + profile.colorScheme === 'dark' ? 'rgb(30, 30, 30)' : 'rgb(249, 249, 249)', + ) + assert.match(homeContract.heroImageSource ?? '', /\/logo\.png$/) + assert.equal(homeContract.menuButton?.display, 'none') + assert.equal( + homeContract.title, + lang === 'zh' ? 'Echo UI 一款为 WAA 而生的 UI 组件库' : 'Echo UI A UI library born for WAA', + ) + if (profile.desktop) { + within(homeContract.heroWidth ?? 0, 1152) + within(homeContract.heroX ?? 0, 144) + within(homeContract.heroImage?.width ?? 0, 256) + within(homeContract.heroImage?.height ?? 0, 256) + within(homeContract.heroImage?.x ?? 0, 880) + within(homeContract.heroImage?.y ?? 0, 168) + within(homeContract.firstFeature?.width ?? 0, 368) + within(homeContract.firstFeature?.height ?? 0, 222) + within(homeContract.firstFeature?.x ?? 0, 144) + within(homeContract.firstFeature?.y ?? 0, 516) + } else { + within(homeContract.heroImage?.width ?? 0, 256) + within(homeContract.heroImage?.height ?? 0, 256) + within(homeContract.heroImage?.x ?? 0, 67) + within(homeContract.heroImage?.y ?? 0, 160) + within(homeContract.heroName?.height ?? 0, 36) + within(homeContract.heroName?.y ?? 0, 416) + within(homeContract.heroText?.height ?? 0, 72) + within(homeContract.heroText?.x ?? 0, 32) + within(homeContract.heroText?.y ?? 0, 452) + within(homeContract.heroTagline?.y ?? 0, 524) + within(homeContract.heroActions?.x ?? 0, 26) + within(homeContract.heroActions?.y ?? 0, 550) + within(homeContract.firstFeature?.width ?? 0, 374) + within(homeContract.firstFeature?.height ?? 0, 198) + within(homeContract.firstFeature?.x ?? 0, 8) + within(homeContract.firstFeature?.y ?? 0, 680) + } + } + + const islandVisualPage = await activeContext.newPage() + const nextraVisualPage = await activeContext.newPage() + await Promise.all([ + islandVisualPage.goto(`http://127.0.0.1:${islandAddress.port}/en/`, { + waitUntil: 'networkidle', + }), + nextraVisualPage.goto( + `http://127.0.0.1:${address.port}${withBasePath('/en/')}`, + { waitUntil: 'networkidle' }, + ), + ]) + await Promise.all([ + islandVisualPage.evaluate(() => document.fonts.ready), + nextraVisualPage.evaluate(() => document.fonts.ready), + ]) + const [islandScreenshot, nextraScreenshot] = await Promise.all([ + islandVisualPage.screenshot({ animations: 'disabled' }), + nextraVisualPage.screenshot({ animations: 'disabled' }), + ]) + const comparisonPage = await activeContext.newPage() + const visualDifference = await compareScreenshots( + comparisonPage, + islandScreenshot, + nextraScreenshot, + ) + console.log( + `Visual ${profile.viewport.width}px ${profile.colorScheme}:`, + visualDifference, + ) + assert.ok( + visualDifference.mismatchRatio <= 0.02, + `${profile.viewport.width}px ${profile.colorScheme} Island/Nextra screenshots differ in more than 2% of pixels.`, + ) + assert.ok( + visualDifference.meanChannelDelta <= 2.5, + `${profile.viewport.width}px ${profile.colorScheme} Island/Nextra screenshots exceed the color-delta budget.`, + ) + await Promise.all([ + islandVisualPage.close(), + nextraVisualPage.close(), + comparisonPage.close(), + ]) + + await activeContext.close() + activeContext = undefined + } + + activeContext = await browser.newContext({ + colorScheme: 'light', + viewport: { height: 900, width: 800 }, + }) + const tabletPage = await activeContext.newPage() + const tabletResponse = await tabletPage.goto( + `http://127.0.0.1:${address.port}${withBasePath('/en/component/button/')}`, + { waitUntil: 'networkidle' }, + ) + assert.ok(tabletResponse?.ok(), 'The tablet regression route should render.') + const tabletContract = await readContract(tabletPage) + const tabletViewport = await tabletPage.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })) + assert.equal(tabletContract.header?.position, 'fixed') + assert.equal(tabletContract.islandMobileMenu?.display, 'none') + assert.ok( + (tabletContract.article?.x ?? 0) >= + (tabletContract.sidebar?.x ?? 0) + (tabletContract.sidebar?.width ?? 0), + 'The 800px article must not sit underneath the fixed sidebar.', + ) + assert.ok( + tabletViewport.scrollWidth <= tabletViewport.clientWidth, + 'The 800px documentation shell must not overflow horizontally.', + ) + await activeContext.close() + activeContext = undefined +} finally { + await activeContext?.close() + await browser?.close() + await Promise.all([closeServer(server), closeServer(islandServer)]) +} + +console.log( + 'Nextra reproduces Island across 120 component, 8 localized home, and 4 live visual comparisons.', +) diff --git a/scripts/verify-nextra-output.mjs b/scripts/verify-nextra-output.mjs index 5af6078a..0485ce99 100644 --- a/scripts/verify-nextra-output.mjs +++ b/scripts/verify-nextra-output.mjs @@ -65,11 +65,11 @@ const pages = [ route: '', en: { description: 'Build expressive Web Audio interfaces with accessible React components.', - heading: 'A UI library born for Web Audio', + heading: 'A UI library born for WAA', }, zh: { description: '使用无障碍 React 组件构建富有表现力的 Web Audio 界面。', - heading: '为 Web Audio 而生的 UI 组件库', + heading: '一款为 WAA 而生的 UI 组件库', }, }, { @@ -133,8 +133,7 @@ const pages = [ const locales = { en: { counterpart: 'zh', - componentLabel: 'Components', - editLink: 'Edit this page on GitHub', + componentLabel: 'Component', footer: 'Released under the MIT License.', guideLabel: 'Guide', tocLabel: 'On this page', @@ -142,7 +141,6 @@ const locales = { zh: { counterpart: 'en', componentLabel: '组件', - editLink: '在 GitHub 上编辑此页', footer: '基于 MIT 许可证发布。', guideLabel: '指南', tocLabel: '本页目录', @@ -157,10 +155,6 @@ for (const page of pages) { const html = await readFile(resolve(outputRoot, locale, page.file), 'utf8') const expected = page[locale] const localizedRoute = `/${locale}${page.route}/` - const sourcePath = page.route - ? `content/${locale}${page.route}.mdx` - : `content/${locale}/index.mdx` - assert.match(html, new RegExp(`]+lang="${locale}"`)) assert.ok(html.includes(expected.heading), `${localizedRoute} should include its heading`) assert.ok(html.includes(expected.description), `${localizedRoute} should include page metadata`) @@ -177,6 +171,12 @@ for (const page of pages) { assert.ok(html.includes('title="Change language"'), `${localizedRoute} should switch locales`) await access(resolve(outputRoot, labels.counterpart, page.file)) assert.ok(html.includes(labels.footer), `${localizedRoute} should include a localized footer`) + if (!page.route) { + assert.ok( + html.includes(`src="${withBasePath('/logo.png')}"`), + `${localizedRoute} should load its logo from the configured base path`, + ) + } await assertInternalLinksResolve(html, localizedRoute) if (page.route) { @@ -188,11 +188,6 @@ for (const page of pages) { html.includes(expected.toc), `${localizedRoute} should expose page headings in its TOC`, ) - assert.ok(html.includes(labels.editLink), `${localizedRoute} should expose its edit link`) - assert.ok( - html.includes(`https://github.com/codeacme17/echo-ui/tree/main/docs-nextra/${sourcePath}`), - `${localizedRoute} should edit the matching source file`, - ) } for (const assetPath of html.matchAll(/(?:href|src)="([^"?]*\/_next\/[^"?]+)(?:\?[^"?]*)?"/g)) { @@ -220,8 +215,6 @@ const verifyComponentRoute = async ({ component, kind, labels, locale, navigatio 'utf8', ) const localizedRoute = `/${locale}/component/${component}/` - const sourcePath = `content/${locale}/component/${component}.mdx` - assert.match(html, new RegExp(`]+lang="${locale}"`)) assert.ok( html.includes(`data-${kind}-demo="${component}"`), @@ -231,10 +224,7 @@ const verifyComponentRoute = async ({ component, kind, labels, locale, navigatio html.includes(`data-${kind}-api="${component}"`), `${localizedRoute} should render its public API reference`, ) - assert.ok( - html.includes('pnpm add @nafr/echo-ui'), - `${localizedRoute} should include installation guidance`, - ) + assert.ok(html.includes('id="import"'), `${localizedRoute} should include its import section`) assert.ok( html.includes(`href="${withBasePath(localizedRoute)}"`), `${localizedRoute} navigation should expose the localized route`, @@ -250,12 +240,7 @@ const verifyComponentRoute = async ({ component, kind, labels, locale, navigatio assert.ok(html.includes('title="Change theme"'), `${localizedRoute} should switch themes`) assert.ok(html.includes('title="Change language"'), `${localizedRoute} should switch locales`) assert.ok(html.includes(labels.tocLabel), `${localizedRoute} should label its table of contents`) - assert.ok(html.includes(labels.editLink), `${localizedRoute} should expose its edit link`) assert.ok(html.includes(labels.footer), `${localizedRoute} should include a localized footer`) - assert.ok( - html.includes(`https://github.com/codeacme17/echo-ui/tree/main/docs-nextra/${sourcePath}`), - `${localizedRoute} should edit the matching source file`, - ) await access(resolve(outputRoot, labels.counterpart, 'component', component, 'index.html')) await assertInternalLinksResolve(html, localizedRoute)