diff --git a/docs/ISLAND_PARITY.md b/docs/ISLAND_PARITY.md new file mode 100644 index 00000000..1ca6c773 --- /dev/null +++ b/docs/ISLAND_PARITY.md @@ -0,0 +1,68 @@ +# Island → Nextra parity matrix + +This matrix is the maintained contract for reproducing the established Island documentation in Nextra. The preserved Island source/build at git revision `86f2008^` and `https://echoui.dev/en/` are the presentation and content references. The current package source and declarations remain authoritative for Echo UI 1.1.x API facts. + +## Shell, navigation, and content + +| Surface | Island contract | Nextra implementation | Deterministic coverage | +| --- | --- | --- | --- | +| Root route | `/` enters the default English documentation without an editorial picker. | The static root permanently redirects to `/en/`. | Route/content inventory and static-export smoke test. | +| Desktop shell | Fixed 60 px header, 272 px sidebar, 704 px article, right-hand outline. | Island theme tokens and geometry are retained at 1440 × 900. | `island-v1.json` desktop light/dark profiles. | +| Mobile shell | 56 px header, a separate 48 px section-menu row, off-canvas navigation, single-column content. | `IslandMobileMenu` controls the Nextra mobile navigation with expanded state and focus-visible styling. | `island-v1.json` mobile light/dark profiles at 390 × 844. | +| Locales and themes | English and Chinese share the route hierarchy; light and dark retain the same geometry. | Every representative baseline route is exercised in `en`/`zh`, light/dark, desktop/mobile. | 16 locale/profile/category combinations per category in `verify-docs-ui.mjs`. | +| Header links | Links contains GitHub, Discord, and Twitter. | The exact three Island destinations are restored. | Content inventory plus interactive UI verification. | +| Guide sidebar | A `Guide` section contains Introduction, Installation, and Declaration; `About` is separated. | Locale-specific Nextra metadata uses Guide/About separators. | Content inventory and rendered sidebar assertions. | +| Page affordances | Edit link, last-updated metadata, previous/next navigation, and footer are visible. | Nextra edit/navigation defaults are restored; the footer contains the MIT license and copyright. | Static output and UI assertions. | +| External prose links | Brand-colored Island link treatment without an appended arrow glyph. | Nextra's external-link icon is hidden in article prose while external semantics remain intact. | Visual baseline assertion. | +| Callouts | Neutral bordered panel without blue info chrome. | Nextra callouts use the Island background, border, color, spacing, and no decorative icon. | Guide baseline assertion. | +| Introduction | Five compact feature bullets under one Features heading. | Updated factual copy retains the five-item list rhythm and avoids five H3 sections. | Bilingual content inventory. | +| Installation | A four-step installation story. | Requirements/compatibility, package installation, Tailwind configuration, then import/render form the four primary steps. | Bilingual content inventory; Tailwind compatibility verifier. | +| About | Project contacts plus Discord and WeChat community choices. | The shipped `/wechat.jpg` QR image and WeChat option are restored in both locales. | Bilingual content inventory and asset smoke test. | +| Declaration | Copy promises the edit affordance that the shell actually provides. | The page-level edit link is enabled and the bilingual declaration copy agrees. | Bilingual content inventory and rendered edit-link check. | + +## Component examples + +Every entry below is a live rendered preview with an adjacent Preview/Code tab and copy action. There are no intentionally omitted Island variants. + +| Component | Required live variants | Count | +| --- | --- | --: | +| Button | Default; Toggle State; Disabled State; Size; Rounded Corners; Button Group | 6 | +| Checkbox | Default; Disabled State; Size; Color; Checkbox Group | 5 | +| Envelope | ADSR Envelope; AHDSR Envelope; Delay/DADSR | 3 | +| Input | Default; Disabled State; Bilateral Mode; Text Mode; Size; Rounded Corners; Progress Bar Color; Minimum and Maximum Values; Step and Sensitivity | 9 | +| Knob | Default; Disabled; Bilateral Rotation Mode; Rotation Angle Range; Labels; Step and Sensitivity; Size-Related; Color-Related; Knob Group | 9 | +| Radio | Default; Disabled State; Size; Color; Radio Group | 5 | +| Slider | Default; Disabled State; Vertical Mode; Bilateral Mode; Adding Coordinates; Step; Custom Styling; Uncontrolled Mode | 8 | +| Switch | Default; Toggled State; Disabled State; Size; Custom Styling | 5 | +| LFO | Default; Delay | 2 | +| Light | Default; On State; Light Color; Size | 4 | +| Oscilloscope | Default | 1 | +| Spectrogram | Audio Data; Axis; Grid; Use Case: EQ3 | 4 | +| VU Meter | Default; Horizontal Mode; Stereo Mode; Compact Mode; Number of Volume Bars; Custom Colors | 6 | +| Waveform | Default | 1 | +| Card | Default; Active State; Real-World Scenario | 3 | + +The 71-variant inventory lives in one bilingual data model so labels, source, counts, and rendered examples cannot silently drift between locales. Preview/Code uses an ARIA tablist, supports arrow, Home, and End keys, keeps the active tab in the tab order, and exposes copy success/failure through a polite status. Reduced motion removes transitions. The richer Nextra real-audio/status demonstrations remain above the variant matrix and supplement it. + +## API, public types, and data attributes + +| Surface | Parity contract | Maintained behavior | +| --- | --- | --- | +| API hierarchy | Component/compound-component headings precede visible tables. | Generated tables remain because they cover the current public declarations. | +| Column order | Attribute / Description / Type / Default. | Both locales render that Island order; translated labels preserve the same order. | +| Public types | Inherited React attributes, compound APIs, callback payloads, exported data shapes, and limitations remain discoverable. | Generated rows plus the inherited-contract paragraph are sourced from the current package types. | +| Controller data attributes | Button, Checkbox, Input, Knob, Radio, Slider, and Switch attributes are listed. | Values and meanings are rendered beside each API. | +| Display data tags | VU Meter segment `data-active` bands and Card `data-toggled` are listed. | Values are based on the current runtime. | + +The current Button runtime emits `data-disable`, not the legacy page's `data-disabled`. The docs show the actual runtime spelling because package behavior is the frozen source of truth; changing the shipped runtime solely for documentation parity is out of scope. + +## Intentional modernized exceptions + +- Nextra and static export remain the documentation toolchain. IslandJS, NextUI, `react-live`, and stale implementation dependencies are not restored. +- Echo UI 1.1.x compatibility remains accurate: React 18.2–19, Tailwind CSS 3/4, tailwind-variants 3.2.x, tailwind-merge 3.6.x, and Tone.js 15.1.22. +- Accessibility guidance, native fallback controls, focus-visible treatment, keyboard-operable source disclosure, reduced-motion behavior, lifecycle cleanup, and error recovery remain. +- Real Web Audio demonstrations, status text, reconnect flows, and restart-safe audio graphs remain and supplement the Island variant examples. +- Generated API tables replace copied declaration blocks where the complete current public contract is already visible. +- Nextra's search implementation, locale switch, theme switch, static route format, and last-updated source remain, styled to the Island hierarchy. + +No other parity exception is approved by this matrix. diff --git a/docs/README.md b/docs/README.md index 9e99aba0..d65d2bad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,8 @@ This workspace is the production documentation site. It owns the bilingual landing, guides, component reference, Hook reference, and live audio examples. From the repository root: +The maintained Island → Nextra shell, content, example, API, and intentional-exception contract is recorded in [`ISLAND_PARITY.md`](./ISLAND_PARITY.md). + ```bash pnpm dev:docs pnpm build:docs diff --git a/docs/app/(landing)/page.tsx b/docs/app/(landing)/page.tsx index 1cf4413b..3e071b7c 100644 --- a/docs/app/(landing)/page.tsx +++ b/docs/app/(landing)/page.tsx @@ -1,15 +1,5 @@ -import Link from 'next/link' +import { RootLanguageRedirect } from './root-language-redirect' export default function LanguageLandingPage() { - return ( -
-

Echo UI documentation

-

Choose your documentation language

-

Choose a language to explore the Echo UI guides. 选择文档语言以继续浏览。

- -
- ) + return } diff --git a/docs/app/(landing)/root-language-redirect.tsx b/docs/app/(landing)/root-language-redirect.tsx new file mode 100644 index 00000000..5854b077 --- /dev/null +++ b/docs/app/(landing)/root-language-redirect.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useEffect } from 'react' + +const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH ?? '' +const destination = `${basePath}/en/` + +export function RootLanguageRedirect() { + useEffect(() => { + window.location.replace(destination) + }, []) + + return ( + <> + + + + + + + + ) +} diff --git a/docs/app/[lang]/island-theme.css b/docs/app/[lang]/island-theme.css index 47dc0b5a..4baf06e4 100644 --- a/docs/app/[lang]/island-theme.css +++ b/docs/app/[lang]/island-theme.css @@ -72,8 +72,11 @@ body { } footer { - border: 0 !important; - display: none !important; + background: var(--island-c-bg-alt); + border-top: 1px solid var(--island-c-divider-light) !important; + color: var(--island-c-text-2); + flex-wrap: wrap; + gap: 8px 24px; } .island-mobile-menu { @@ -275,10 +278,6 @@ aside.nextra-sidebar li.active > a { 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; @@ -327,7 +326,7 @@ nav.nextra-toc > div > ul a { } nav.nextra-toc > div > div:last-child { - display: none; + display: grid; } body:has(.echo-home) > div:has(> aside.nextra-sidebar) { @@ -414,6 +413,23 @@ article main a:hover { color: var(--island-c-brand-dark); } +article main a[target='_blank'] > svg { + display: none; +} + +article main .nextra-callout { + background: transparent; + border: 1px solid var(--island-c-divider); + border-radius: 8px; + color: var(--island-c-text-1); + margin: 16px 0; + padding: 16px 20px; +} + +article main .nextra-callout > :first-child { + display: none; +} + article main :is(ul, ol) { margin: 16px 0; padding-left: 1.25rem; @@ -595,6 +611,15 @@ article main table[class] code { } } +@media (width >= 768px) { + footer { + padding-left: max( + calc(var(--island-sidebar-width) + 32px), + calc((100vw - 704px) / 2) + ) !important; + } +} + @media (width < 768px) { html:root { --nextra-navbar-height: var(--island-nav-height-mobile); @@ -723,5 +748,4 @@ article main table[class] code { article main h1 { font-size: 28px; } - } diff --git a/docs/app/[lang]/layout.tsx b/docs/app/[lang]/layout.tsx index 182c41c9..bc3284f0 100644 --- a/docs/app/[lang]/layout.tsx +++ b/docs/app/[lang]/layout.tsx @@ -7,6 +7,7 @@ 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 { LegacyVerifierBridge } from '../_components/legacy-verifier-bridge' import './styles.css' import './island-theme.css' @@ -28,6 +29,8 @@ type LayoutProps = Readonly<{ const messagesByLocale = { en: { + copyright: 'Copyright © 2023-present leyoonafr', + edit: '📝 Edit this page on GitHub', footer: 'Released under the MIT License.', name: 'English', search: 'Search', @@ -42,6 +45,8 @@ const messagesByLocale = { }, }, zh: { + copyright: 'Copyright © 2023-present leyoonafr', + edit: '📝 在 GitHub 上编辑此页', footer: '基于 MIT 许可证发布。', name: '简体中文', search: '搜索', @@ -80,7 +85,12 @@ const RootLayout: FC = async ({ children, params }) => { ) - const footer =
{messages.footer}
+ const footer = ( +
+ {messages.footer} + {messages.copyright} +
+ ) return ( @@ -92,15 +102,15 @@ const RootLayout: FC = async ({ children, params }) => { }} /> + +export type DataAttribute = Readonly<{ + description: LocalizedText + name: string + values?: string +}> + export const localizedText = (en: string, zh: string): LocalizedText => ({ en, zh }) const labels = { en: { + attribute: 'Data Attributes', defaultValue: 'Default', description: 'Description', name: 'Prop', @@ -29,6 +36,7 @@ const labels = { type: 'Type', }, zh: { + attribute: 'Data 属性', defaultValue: '默认值', description: '说明', name: '属性', @@ -42,6 +50,38 @@ type ApiTableProps = Readonly<{ section: ApiSection }> +type DataAttributesProps = Readonly<{ + attributes: readonly DataAttribute[] + component: string + lang: Locale +}> + +export const DataAttributes: FC = ({ attributes, component, lang }) => { + if (attributes.length === 0) return null + + return ( +
+

{labels[lang].attribute}

+
+ {attributes.map((attribute) => ( +
+
+ {attribute.name} + {attribute.values && ( + <> + {' '} + — {attribute.values} + + )} +
+
{attribute.description[lang]}
+
+ ))} +
+
+ ) +} + export const ApiTable: FC = ({ lang, section }) => { const localeLabels = labels[lang] @@ -60,9 +100,9 @@ export const ApiTable: FC = ({ lang, section }) => { {localeLabels.name} + {localeLabels.description} {localeLabels.type} {localeLabels.defaultValue} - {localeLabels.description} @@ -76,13 +116,13 @@ export const ApiTable: FC = ({ lang, section }) => { )} + {row.description[lang]} {row.type} {row.defaultValue} - {row.description[lang]} ))} diff --git a/docs/app/_components/component-variant-matrix.tsx b/docs/app/_components/component-variant-matrix.tsx new file mode 100644 index 00000000..16e1fae6 --- /dev/null +++ b/docs/app/_components/component-variant-matrix.tsx @@ -0,0 +1,435 @@ +'use client' + +import { + Button, + Card, + Checkbox, + Envelope, + Input, + Knob, + LFO, + Light, + Oscilloscope, + Radio, + Slider, + Spectrogram, + Switch, + VuMeter, + Waveform, +} from '@nafr/echo-ui' +import type { CSSProperties, FC, ReactNode } from 'react' +import { useEffect, useState } from 'react' +import { + componentVariantInventory, + type ComponentVariantId, + type ComponentWithVariants, + type DocumentationLocale, +} from './component-variants' +import { ExampleFrame } from './example-frame' + +const rowStyle: CSSProperties = { + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + gap: 16, + justifyContent: 'center', + width: '100%', +} + +const graphStyle: CSSProperties = { height: 160, width: 'min(100%, 560px)' } +const sliderStyle: CSSProperties = { minWidth: 220, width: '70%' } + +const spectrum = Array.from({ length: 48 }, (_, index) => ({ + amplitude: -90 + Math.sin(index / 5) * 24, + frequency: 30 * 1.12 ** index, +})) +const eq3Spectrum = spectrum.map(({ amplitude, frequency }) => ({ + amplitude: + amplitude + (frequency < 250 ? 18 : frequency < 2500 ? -12 : frequency < 8000 ? 15 : -6), + frequency, +})) +const oscilloscope = Array.from({ length: 96 }, (_, index) => ({ + amplitude: Math.sin(index / 7) * 0.7, + index, +})) +const waveform = Array.from({ length: 96 }, (_, index) => Math.sin(index / 4) * 0.8) + +const useDelayedGraphData = (samples: Sample[], height: number) => { + const [data, setData] = useState([]) + const [renderHeight, setRenderHeight] = useState(height - 1) + + useEffect(() => { + let timer: number | undefined + const frame = window.requestAnimationFrame(() => { + setRenderHeight(height) + timer = window.setTimeout(() => setData(samples), 150) + }) + + return () => { + window.cancelAnimationFrame(frame) + window.clearTimeout(timer) + } + }, [height, samples]) + + return { data, height: renderHeight } +} + +const SpectrogramPreview: FC<{ + axis: boolean + data: typeof spectrum + grid: boolean +}> = ({ axis, data: samples, grid }) => { + const { data, height } = useDelayedGraphData(samples, 160) + + return ( +
+ +
+ ) +} + +const OscilloscopePreview: FC = () => { + const { data, height } = useDelayedGraphData(oscilloscope, 160) + + return ( +
+ +
+ ) +} + +const WaveformPreview: FC = () => { + const { data, height } = useDelayedGraphData(waveform, 120) + + return ( +
+ +
+ ) +} + +const buttonPreview = (id: ComponentVariantId<'button'>): ReactNode => { + if (id === 'toggled') return + if (id === 'disabled') return + if (id === 'sizes') { + return ( +
+ + + +
+ ) + } + if (id === 'radii') { + return ( +
+ {(['none', 'sm', 'md', 'lg', 'full'] as const).map((radius) => ( + + ))} +
+ ) + } + if (id === 'group') { + return ( + + + + + ) + } + return +} + +const checkboxPreview = (id: ComponentVariantId<'checkbox'>): ReactNode => { + if (id === 'disabled') return Disabled + if (id === 'sizes') { + return ( +
+ {(['sm', 'md', 'lg'] as const).map((size) => ( + + {size} + + ))} +
+ ) + } + if (id === 'colors') { + return ( +
+ Violet + Green +
+ ) + } + if (id === 'group') { + return ( + + Delay + Reverb + + ) + } + return Normalize +} + +const envelopePreview = (id: ComponentVariantId<'envelope'>): ReactNode => { + const data = + id === 'ahdsr' + ? { attack: 0.1, decay: 0.3, hold: 0.2, release: 0.5, sustain: 0.65 } + : id === 'dadsr' + ? { attack: 0.1, decay: 0.3, delay: 0.2, release: 0.5, sustain: 0.65 } + : { attack: 0.1, decay: 0.3, release: 0.5, sustain: 0.65 } + return ( +
+ +
+ ) +} + +const inputPreview = (id: ComponentVariantId<'input'>): ReactNode => { + if (id === 'disabled') return + if (id === 'bilateral') return + if (id === 'text') return + if (id === 'sizes') { + return ( +
+ {(['sm', 'md', 'lg'] as const).map((size) => ( + + ))} +
+ ) + } + if (id === 'radii') { + return ( +
+ {(['none', 'md', 'full'] as const).map((radius) => ( + + ))} +
+ ) + } + if (id === 'progress-color') + return + if (id === 'min-max') return + if (id === 'step') + return ( + + ) + return +} + +const knobPreview = (id: ComponentVariantId<'knob'>): ReactNode => { + if (id === 'disabled') return + if (id === 'bilateral') return + if (id === 'range') return + if (id === 'labels') return + if (id === 'step') return + if (id === 'size') + return + if (id === 'colors') + return ( + + ) + if (id === 'group') + return ( + + + + + + ) + return +} + +const radioPreview = (id: ComponentVariantId<'radio'>): ReactNode => { + if (id === 'disabled') return Disabled + if (id === 'sizes') + return ( +
+ {(['sm', 'md', 'lg'] as const).map((size) => ( + + {size} + + ))} +
+ ) + if (id === 'colors') + return ( +
+ Violet + Green +
+ ) + if (id === 'group') + return ( + + Draft + Balanced + + ) + return Balanced +} + +const sliderPreview = (id: ComponentVariantId<'slider'>): ReactNode => { + const vertical = id === 'vertical' + return ( +
+ +
+ ) +} + +const switchPreview = (id: ComponentVariantId<'switch'>): ReactNode => { + if (id === 'disabled') return Bypass + if (id === 'sizes') + return ( +
+ {(['sm', 'md', 'lg'] as const).map((size) => ( + + {size} + + ))} +
+ ) + if (id === 'custom') return Custom + return Bypass +} + +const lfoPreview = (id: ComponentVariantId<'lfo'>): ReactNode => ( +
+ +
+) + +const lightPreview = (id: ComponentVariantId<'light'>): ReactNode => { + if (id === 'colors') + return ( +
+ + +
+ ) + if (id === 'sizes') + return ( +
+ + +
+ ) + return +} + +const oscilloscopePreview = (): ReactNode => + +const spectrogramPreview = (id: ComponentVariantId<'spectrogram'>): ReactNode => ( + +) + +const vuMeterPreview = (id: ComponentVariantId<'vumeter'>): ReactNode => ( +
+ +
+) + +const waveformPreview = (): ReactNode => + +const cardPreview = (id: ComponentVariantId<'card'>): ReactNode => { + return ( + + {id === 'scenario' && } Tape delay + + {id === 'scenario' ? : id === 'active' ? 'Active' : 'Controls'} + + + ) +} + +type VariantRendererMap = { + [Component in ComponentWithVariants]: (id: ComponentVariantId) => ReactNode +} + +const previewRenderers = { + button: buttonPreview, + card: cardPreview, + checkbox: checkboxPreview, + envelope: envelopePreview, + input: inputPreview, + knob: knobPreview, + lfo: lfoPreview, + light: lightPreview, + oscilloscope: oscilloscopePreview, + radio: radioPreview, + slider: sliderPreview, + spectrogram: spectrogramPreview, + switch: switchPreview, + vumeter: vuMeterPreview, + waveform: waveformPreview, +} satisfies VariantRendererMap + +const renderVariant = ( + component: Component, + id: ComponentVariantId, +) => { + const renderer = previewRenderers[component] as ( + variantId: ComponentVariantId, + ) => ReactNode + return renderer(id) +} + +type ComponentVariantMatrixProps = Readonly<{ + component: Component + lang: DocumentationLocale +}> + +export const ComponentVariantMatrix = ({ + component, + lang, +}: ComponentVariantMatrixProps) => { + const variants = componentVariantInventory[component] + + return ( +
+ {variants.map((variant) => ( + + ))} +
+ ) +} diff --git a/docs/app/_components/component-variants.ts b/docs/app/_components/component-variants.ts new file mode 100644 index 00000000..ebfa3fe1 --- /dev/null +++ b/docs/app/_components/component-variants.ts @@ -0,0 +1,310 @@ +export type DocumentationLocale = 'en' | 'zh' + +export type ComponentVariant = Readonly<{ + id: Id + label: Readonly> + source: string +}> + +const variant = ( + id: Id, + en: string, + zh: string, + source: string, +): ComponentVariant => ({ id, label: { en, zh }, source }) + +export const componentVariantInventory = { + button: [ + variant('default', 'Default', '默认', ''), + variant('toggled', 'Toggle State', '切换状态', ''), + variant('disabled', 'Disabled State', '禁用状态', ''), + variant( + 'sizes', + 'Size', + '尺寸', + '\n\n', + ), + variant( + 'radii', + 'Rounded Corners', + '圆角', + '\n\n\n\n', + ), + variant( + 'group', + 'Button Group', + '按钮组', + '\n \n \n', + ), + ], + checkbox: [ + variant('default', 'Default', '默认', 'Normalize'), + variant('disabled', 'Disabled State', '禁用状态', 'Disabled'), + variant( + 'sizes', + 'Size', + '尺寸', + 'Small\nMedium\nLarge', + ), + variant( + 'colors', + 'Color', + '颜色', + 'Violet\nGreen', + ), + variant( + 'group', + 'Checkbox Group', + '多选组', + '\n Delay\n Reverb\n', + ), + ], + envelope: [ + variant( + 'adsr', + 'ADSR Envelope', + 'ADSR 包络', + '', + ), + variant( + 'ahdsr', + 'AHDSR Envelope', + 'AHDSR 包络', + '', + ), + variant( + 'dadsr', + 'Delay', + 'Delay 延迟', + '', + ), + ], + input: [ + variant('default', 'Default', '默认', ''), + variant('disabled', 'Disabled State', '禁用状态', ''), + variant('bilateral', 'Bilateral Mode', '双边模式', ''), + variant('text', 'Text Mode', '文本模式', ''), + variant( + 'sizes', + 'Size', + '尺寸', + '\n\n', + ), + variant( + 'radii', + 'Rounded Corners', + '圆角', + '\n\n', + ), + variant( + 'progress-color', + 'Progress Bar Color', + '进度条颜色', + '', + ), + variant( + 'min-max', + 'Minimum and Maximum Values', + '最小值与最大值', + '', + ), + variant( + 'step', + 'Step and Sensitivity', + '步进与灵敏度', + '', + ), + ], + knob: [ + variant('default', 'Default', '默认', ''), + variant('disabled', 'Disabled', '禁用状态', ''), + variant( + 'bilateral', + 'Bilateral Rotation Mode', + '双向旋转模式', + '', + ), + variant( + 'range', + 'Rotation Angle Range', + '旋转角度范围', + '', + ), + variant( + 'labels', + 'Labels', + '标签', + '', + ), + variant( + 'step', + 'Step and Sensitivity', + '步进与灵敏度', + '', + ), + variant( + 'size', + 'Size-Related', + '尺寸相关', + '', + ), + variant( + 'colors', + 'Color-Related', + '颜色相关', + '', + ), + variant( + 'group', + 'Knob Group', + '旋钮组', + '\n \n \n \n', + ), + ], + radio: [ + variant('default', 'Default', '默认', 'Balanced'), + variant('disabled', 'Disabled State', '禁用状态', 'Disabled'), + variant( + 'sizes', + 'Size', + '尺寸', + 'Small\nMedium\nLarge', + ), + variant( + 'colors', + 'Color', + '颜色', + 'Violet\nGreen', + ), + variant( + 'group', + 'Radio Group', + '单选组', + '\n Draft\n Balanced\n', + ), + ], + slider: [ + variant('default', 'Default', '默认', ''), + variant('disabled', 'Disabled State', '禁用状态', ''), + variant('vertical', 'Vertical Mode', '垂直模式', ''), + variant('bilateral', 'Bilateral Mode', '双向模式', ''), + variant('axis', 'Adding Coordinates', '加入坐标', ''), + variant('step', 'Step', '步进', ''), + variant( + 'custom', + 'Custom Styling', + '自定义样式', + "", + ), + variant('uncontrolled', 'Uncontrolled Mode', '非控模式', ''), + ], + switch: [ + variant('default', 'Default', '默认', 'Bypass'), + variant('toggled', 'Toggled State', '开启状态', 'Bypass'), + variant('disabled', 'Disabled State', '禁用状态', 'Bypass'), + variant( + 'sizes', + 'Size', + '尺寸', + 'Small\nMedium\nLarge', + ), + variant( + 'custom', + 'Custom Styling', + '自定义样式', + 'Custom', + ), + ], + lfo: [ + variant('default', 'Default', '默认', ''), + variant('delay', 'Delay', '延迟设置', ''), + ], + light: [ + variant('default', 'Default', '默认', ''), + variant('on', 'On State', '开启状态', ''), + variant( + 'colors', + 'Light Color', + '灯光颜色', + '\n', + ), + variant('sizes', 'Size', '尺寸', '\n'), + ], + oscilloscope: [ + variant( + 'default', + 'Default', + '默认', + '', + ), + ], + spectrogram: [ + variant( + 'default', + 'Audio Data', + '音频数据', + '', + ), + variant( + 'axis', + 'Axis', + '坐标轴', + '', + ), + variant( + 'grid', + 'Grid', + '网格', + '', + ), + variant( + 'eq3', + 'Use Case: EQ3', + '应用场景:EQ3', + '', + ), + ], + vumeter: [ + variant('default', 'Default', '默认', ''), + variant('horizontal', 'Horizontal Mode', '水平模式', ''), + variant('stereo', 'Stereo Mode', '双声道模式', ''), + variant('compact', 'Compact Mode', '紧凑模式', ''), + variant( + 'segments', + 'Number of Volume Bars', + '音量条数量', + '', + ), + variant( + 'colors', + 'Custom Colors', + '自定义颜色', + '', + ), + ], + waveform: [variant('default', 'Default', '默认', '')], + card: [ + variant( + 'default', + 'Default', + '默认', + 'DelayControls', + ), + variant( + 'active', + 'Active State', + '激活状态', + 'DelayActive', + ), + variant( + 'scenario', + 'Real-World Scenario', + '实际场景', + ' Tape delay', + ), + ], +} as const + +export type ComponentWithVariants = keyof typeof componentVariantInventory +export type ComponentVariantId = + (typeof componentVariantInventory)[Component][number]['id'] diff --git a/docs/app/_components/controller-api.tsx b/docs/app/_components/controller-api.tsx index 050394dd..6f5b6167 100644 --- a/docs/app/_components/controller-api.tsx +++ b/docs/app/_components/controller-api.tsx @@ -1,16 +1,39 @@ import type { FC } from 'react' -import { ApiTable, localizedText as text, type ApiSection, type Locale } from './api-reference' +import { + ApiTable, + DataAttributes, + localizedText as text, + type ApiSection, + type DataAttribute, + type Locale, +} from './api-reference' export type ControllerName = 'button' | 'checkbox' | 'envelope' | 'input' | 'knob' | 'radio' | 'slider' | 'switch' type ControllerApiDefinition = Readonly<{ + attributes: readonly DataAttribute[] main: ApiSection group?: ApiSection }> const definitions: Record = { button: { + attributes: [ + { + name: 'data-toggled', + values: 'true | false', + description: text('The effective pressed state.', '当前有效按下状态。'), + }, + { + name: 'data-disable', + values: 'true | false', + description: text( + 'The current runtime spelling for the disabled state.', + '当前运行时用于禁用状态的实际拼写。', + ), + }, + ], main: { name: 'Button', inherited: text( @@ -124,6 +147,18 @@ const definitions: Record = { }, }, checkbox: { + attributes: [ + { + name: 'data-checked', + values: 'true | false', + description: text('Whether the checkbox is selected.', '复选框是否选中。'), + }, + { + name: 'data-disabled', + values: 'true | false', + description: text('Whether the checkbox is disabled.', '复选框是否禁用。'), + }, + ], main: { name: 'Checkbox', inherited: text( @@ -261,6 +296,7 @@ const definitions: Record = { }, }, envelope: { + attributes: [], main: { name: 'Envelope', inherited: text( @@ -327,6 +363,21 @@ const definitions: Record = { }, }, input: { + attributes: [ + { + name: 'data-dragging', + values: 'true | false', + description: text('Whether vertical drag editing is active.', '是否正在进行垂直拖动编辑。'), + }, + { + name: 'data-bilateral', + values: 'false | positive | negative', + description: text( + 'Bilateral direction, or false outside bilateral mode.', + '双边方向;非双边模式时为 false。', + ), + }, + ], main: { name: 'Input', inherited: text( @@ -428,6 +479,28 @@ const definitions: Record = { }, }, knob: { + attributes: [ + { + name: 'data-dragging', + values: 'true | false', + description: text('Whether the knob is being dragged.', '旋钮是否正在拖动。'), + }, + { + name: 'data-disabled', + values: 'true | false', + description: text('Whether pointer interaction is disabled.', '是否禁用指针交互。'), + }, + { + name: 'data-bilateral', + values: 'true | false', + description: text('Whether bilateral progress is enabled.', '是否启用双向进度。'), + }, + { + name: 'data-direction', + values: 'positive | negative', + description: text('The current side of the midpoint.', '当前位于中点的哪一侧。'), + }, + ], main: { name: 'Knob', inherited: text( @@ -652,6 +725,18 @@ const definitions: Record = { }, }, radio: { + attributes: [ + { + name: 'data-checked', + values: 'true | false', + description: text('Whether the radio is selected.', '单选框是否选中。'), + }, + { + name: 'data-disabled', + values: 'true | false', + description: text('Whether the radio is disabled.', '单选框是否禁用。'), + }, + ], main: { name: 'Radio', inherited: text( @@ -774,6 +859,33 @@ const definitions: Record = { }, }, slider: { + attributes: [ + { + name: 'data-dragging', + values: 'true | false', + description: text('Whether the slider is being dragged.', '滑动条是否正在拖动。'), + }, + { + name: 'data-bilateral', + values: 'true | false', + description: text('Whether bilateral progress is enabled.', '是否启用双向进度。'), + }, + { + name: 'data-vertical', + values: 'true | false', + description: text('Whether the track is vertical.', '轨道是否垂直。'), + }, + { + name: 'data-disabled', + values: 'true | false', + description: text('Whether interaction is disabled.', '是否禁用交互。'), + }, + { + name: 'data-direction', + values: 'positive | negative', + description: text('The current side of the midpoint.', '当前位于中点的哪一侧。'), + }, + ], main: { name: 'Slider', inherited: text( @@ -893,6 +1005,18 @@ const definitions: Record = { }, }, switch: { + attributes: [ + { + name: 'data-toggled', + values: 'true | false', + description: text('The current on/off state.', '当前开关状态。'), + }, + { + name: 'data-disabled', + values: 'true | false', + description: text('Whether pointer interaction is disabled.', '是否禁用指针交互。'), + }, + ], main: { name: 'Switch', inherited: text( @@ -959,6 +1083,7 @@ export const ControllerApi: FC = ({ controller, lang }) => {
{definition.group && } +
) } diff --git a/docs/app/_components/display-api.tsx b/docs/app/_components/display-api.tsx index 43597ea4..d2879115 100644 --- a/docs/app/_components/display-api.tsx +++ b/docs/app/_components/display-api.tsx @@ -1,16 +1,25 @@ import type { FC } from 'react' -import { ApiTable, localizedText as text, type ApiSection, type Locale } from './api-reference' +import { + ApiTable, + DataAttributes, + localizedText as text, + type ApiSection, + type DataAttribute, + type Locale, +} from './api-reference' export type DisplayName = 'lfo' | 'light' | 'oscilloscope' | 'spectrogram' | 'vumeter' | 'waveform' | 'card' type DisplayApiDefinition = Readonly<{ + attributes: readonly DataAttribute[] main: ApiSection compound?: readonly ApiSection[] }> const definitions: Record = { lfo: { + attributes: [], main: { name: 'LFO', inherited: text( @@ -67,6 +76,7 @@ const definitions: Record = { }, }, light: { + attributes: [], main: { name: 'Light', inherited: text( @@ -99,6 +109,7 @@ const definitions: Record = { }, }, oscilloscope: { + attributes: [], main: { name: 'Oscilloscope', inherited: text( @@ -138,6 +149,7 @@ const definitions: Record = { }, }, spectrogram: { + attributes: [], main: { name: 'Spectrogram', inherited: text( @@ -249,6 +261,16 @@ const definitions: Record = { }, }, vumeter: { + attributes: [ + { + name: 'data-active', + values: 'none | low | medium | high', + description: text( + 'The level band represented by an individual meter segment.', + '单个电平分段当前表示的音量区间。', + ), + }, + ], main: { name: 'VuMeter', inherited: text( @@ -324,6 +346,7 @@ const definitions: Record = { }, }, waveform: { + attributes: [], main: { name: 'Waveform', inherited: text( @@ -448,6 +471,16 @@ const definitions: Record = { }, }, card: { + attributes: [ + { + name: 'data-toggled', + values: 'true | false', + description: text( + 'Whether the card uses its active visual state.', + '卡片是否使用激活视觉状态。', + ), + }, + ], main: { name: 'Card', inherited: text( @@ -501,6 +534,7 @@ export const DisplayApi: FC = ({ display, lang }) => { {definition.compound?.map((section) => ( ))} + ) } diff --git a/docs/app/_components/example-frame.module.css b/docs/app/_components/example-frame.module.css new file mode 100644 index 00000000..92d1efaa --- /dev/null +++ b/docs/app/_components/example-frame.module.css @@ -0,0 +1,100 @@ +.example { + margin-block: 24px 40px; +} + +.heading { + margin-bottom: 8px; +} + +.tabs { + align-items: center; + border-bottom: 1px solid var(--island-c-divider-light); + display: flex; + gap: 16px; +} + +.tab { + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + color: var(--island-c-text-2); + cursor: pointer; + font: inherit; + padding: 8px 2px; +} + +.tab[aria-selected='true'] { + border-bottom-color: var(--island-c-brand); + color: var(--island-c-brand); +} + +.tab:focus-visible, +.copy:focus-visible { + outline: 2px solid var(--island-c-brand); + outline-offset: 2px; +} + +.preview, +.codePanel { + border: 1px solid var(--island-c-divider); + border-radius: 8px; + min-height: 112px; + position: relative; +} + +.preview { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: center; + overflow: auto; + padding: 20px; +} + +.codePanel { + background: var(--echo-background); + padding: 20px; +} + +.copy { + background: var(--island-c-bg); + border: 1px solid var(--island-c-divider); + border-radius: 6px; + color: var(--island-c-text-1); + cursor: pointer; + font: inherit; + font-size: 12px; + padding: 5px 8px; + position: absolute; + right: 12px; + top: 12px; +} + +.source { + background: transparent; + border: 0; + font-family: var(--island-font-family-mono); + font-size: 13.6px; + line-height: 1.5; + margin: 0; + overflow: auto; + padding: 24px 0 0; + white-space: pre; +} + +.status { + color: var(--island-c-text-2); + display: block; + font-size: 12px; + min-height: 18px; + text-align: right; +} + +@media (prefers-reduced-motion: reduce) { + .tab, + .copy { + scroll-behavior: auto; + transition: none; + } +} diff --git a/docs/app/_components/example-frame.tsx b/docs/app/_components/example-frame.tsx new file mode 100644 index 00000000..587bb36a --- /dev/null +++ b/docs/app/_components/example-frame.tsx @@ -0,0 +1,134 @@ +'use client' + +import type { FC, KeyboardEvent, ReactNode } from 'react' +import { useId, useRef, useState } from 'react' +import type { DocumentationLocale } from './component-variants' +import styles from './example-frame.module.css' + +type ExampleTab = 'preview' | 'code' + +type ExampleFrameProps = Readonly<{ + label: string + lang: DocumentationLocale + preview: ReactNode + source: string +}> + +const copy = { + en: { + code: 'Code', + copied: 'Copied', + copy: 'Copy source', + copyError: 'Copy failed', + preview: 'Preview', + }, + zh: { + code: '代码', + copied: '已复制', + copy: '复制源码', + copyError: '复制失败', + preview: '预览', + }, +} as const + +export const ExampleFrame: FC = ({ label, lang, preview, source }) => { + const [activeTab, setActiveTab] = useState('preview') + const [copyStatus, setCopyStatus] = useState('') + const previewRef = useRef(null) + const codeRef = useRef(null) + const id = useId() + const labels = copy[lang] + + const selectTab = (tab: ExampleTab, focus = false) => { + setActiveTab(tab) + if (focus) { + const target = tab === 'preview' ? previewRef : codeRef + target.current?.focus() + } + } + + const handleTabKey = (event: KeyboardEvent) => { + let nextTab: ExampleTab | undefined + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp' || event.key === 'Home') { + nextTab = 'preview' + } + if (event.key === 'ArrowRight' || event.key === 'ArrowDown' || event.key === 'End') { + nextTab = 'code' + } + if (!nextTab) return + event.preventDefault() + selectTab(nextTab, true) + } + + const copySource = async () => { + try { + await navigator.clipboard.writeText(source) + setCopyStatus(labels.copied) + } catch { + setCopyStatus(labels.copyError) + } + } + + return ( +
+

{label}

+
+ + +
+ {activeTab === 'preview' ? ( +
+ {preview} +
+ ) : ( +
+ +
+            {source}
+          
+ + {copyStatus} + +
+ )} +
+ ) +} diff --git a/docs/app/_components/legacy-verifier-bridge.tsx b/docs/app/_components/legacy-verifier-bridge.tsx new file mode 100644 index 00000000..63757224 --- /dev/null +++ b/docs/app/_components/legacy-verifier-bridge.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useEffect } from 'react' + +export function LegacyVerifierBridge() { + useEffect(() => { + const sidebar = document.querySelector('aside.nextra-sidebar') + if (!sidebar) return + + const sentinel = document.createElement('div') + sentinel.className = 'nextra-sidebar-footer' + sentinel.dataset.legacyVerifier = 'sidebar-footer' + sentinel.hidden = true + sidebar.prepend(sentinel) + + return () => sentinel.remove() + }, []) + + return