mirror of
https://github.com/langgenius/dify.git
synced 2026-09-09 05:41:00 +08:00
fix: correct page landmarks and document layout rules (#41938)
This commit is contained in:
parent
d978fe710f
commit
efe506a0f5
@ -26,6 +26,14 @@ Flag:
|
||||
|
||||
Prefer semantic HTML before ARIA.
|
||||
|
||||
## Page Landmarks
|
||||
|
||||
When reviewing Web page layouts, navigation, or landmark changes, read [Page landmarks].
|
||||
Inspect the composed page with its parent layouts for missing or duplicate main regions,
|
||||
inappropriate nesting or roles, and ambiguous or broken landmark names. Check affected
|
||||
unit and E2E locators when semantic elements change. Web owns these composition rules;
|
||||
use the existing naming contract for label-source decisions.
|
||||
|
||||
## Accessible Names And Descriptions
|
||||
|
||||
Read [Accessible names and descriptions] when a change affects labels, ARIA naming, help/error relationships, or hidden text. That document owns the shared implementation and review contract.
|
||||
@ -123,4 +131,5 @@ Flag:
|
||||
- Hardcoded dates, times, numbers, or currency formats instead of `Intl.*`.
|
||||
|
||||
[Accessible names and descriptions]: ../../../../packages/dify-ui/docs/accessible-names-and-descriptions.md
|
||||
[Page landmarks]: ../../../../web/docs/landmarks.md
|
||||
[Web Interface Guidelines]: https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
Web owns application-specific requirements and consumes shared architecture guidance from skills and primitive contracts from Dify UI. Link to those owners instead of redefining their rules here.
|
||||
|
||||
- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure].
|
||||
- When adding or changing page layouts, navigation, or landmark elements, follow [Page landmarks] and inspect the composed page, including parent layouts.
|
||||
- User-facing strings must use `web/i18n/en-US/` keys. When adding or renaming a key, update every supported locale with the correct localized value.
|
||||
- For new backend calls and migrated surfaces, use generated `consoleQuery` / `consoleClient` APIs from `@/service/console`. Do not add handwritten REST helpers or DTO mirrors, mock-backed app state, or direct edits to generated contracts.
|
||||
- Prefer `@langgenius/dify-ui/*` primitives, data attributes, and design tokens. Use the [Dify UI package index] to find a primitive; read the relevant contract directly when it is already known. Preserve a visible focus indicator on the final focusable element.
|
||||
@ -36,5 +37,6 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
||||
[Dify UI package index]: ../packages/dify-ui/README.md
|
||||
[IconButton contract]: ../packages/dify-ui/src/icon-button/README.md
|
||||
[Input Group contract]: ../packages/dify-ui/src/input-group/README.md
|
||||
[Page landmarks]: docs/landmarks.md
|
||||
[Truncated Text Disclosure]: docs/truncated-text-disclosure.md
|
||||
[form contract]: ../packages/dify-ui/docs/forms.md
|
||||
|
||||
@ -202,7 +202,7 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP
|
||||
{t(($) => $['accessRule.appDescription'], { ns: 'permission' })}
|
||||
</p>
|
||||
</header>
|
||||
<main className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
|
||||
<div className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
|
||||
<AccessRulesEditor
|
||||
className="min-h-0 w-full flex-1"
|
||||
rules={appAccessRules}
|
||||
@ -227,7 +227,7 @@ const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentP
|
||||
onBatchRemoveAccessPolicyMemberBindings={handleBatchRemoveAccessPolicyMemberBindings}
|
||||
onAddAccessSubject={handleAddAccessSubject}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@ function AccessPointContent({
|
||||
environment === selectedEnvironment ? highlightedAccessPoint : null
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 flex-col bg-components-panel-bg">
|
||||
<div className="flex h-full min-h-0 flex-col bg-components-panel-bg">
|
||||
<header className="flex shrink-0 flex-col gap-3 px-6 pt-3 pb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex h-6 items-center">
|
||||
@ -145,7 +145,7 @@ function AccessPointContent({
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ function AppDeployContent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex h-full flex-col bg-components-panel-bg">
|
||||
<div className="flex h-full flex-col bg-components-panel-bg">
|
||||
<header className="flex shrink-0 flex-col gap-y-0.5 px-6 pt-3 pb-2">
|
||||
<div className="flex h-6 items-center">
|
||||
<h1 className="title-xl-semi-bold text-text-primary">
|
||||
@ -140,7 +140,7 @@ function AppDeployContent({
|
||||
onUndeploy={undeployWorkflow}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{deploymentRequest && (
|
||||
<DeploymentDialog
|
||||
appId={appId}
|
||||
|
||||
@ -231,7 +231,7 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) =>
|
||||
{t(($) => $['accessRule.datasetDescription'], { ns: 'permission' })}
|
||||
</p>
|
||||
</header>
|
||||
<main className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
|
||||
<div className="flex min-h-0 w-full max-w-240 flex-1 flex-col px-6 pt-8 pb-10 sm:pr-20 sm:pl-12.5">
|
||||
<AccessRulesEditor
|
||||
className="min-h-0 w-full flex-1"
|
||||
rules={datasetAccessRules}
|
||||
@ -258,7 +258,7 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) =>
|
||||
onBatchRemoveAccessPolicyMemberBindings={handleBatchRemoveAccessPolicyMemberBindings}
|
||||
onAddAccessSubject={handleAddAccessSubject}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -299,33 +299,22 @@ describe('Datasets', () => {
|
||||
expect(screen.queryByRole('status', { name: /common\.loading/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show Loading component when isFetchingNextPage is true', () => {
|
||||
render(<Datasets {...defaultProps} hasNextPage={true} isFetchingNextPage={true} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show Loading component when isFetchingNextPage is false', () => {
|
||||
render(<Datasets {...defaultProps} hasNextPage={true} isFetchingNextPage={false} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatasetList null handling', () => {
|
||||
it('should handle null datasetList gracefully', () => {
|
||||
render(<Datasets {...defaultProps} datasetList={null} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined datasetList gracefully', () => {
|
||||
render(<Datasets {...defaultProps} datasetList={undefined} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty pages array', () => {
|
||||
render(<Datasets {...defaultProps} datasetList={createDatasetListData([])} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
it.each([null, undefined, createDatasetListData([])])(
|
||||
'shows the empty state when dataset data is unavailable or empty (%j)',
|
||||
(datasetList) => {
|
||||
render(
|
||||
<Datasets
|
||||
{...defaultProps}
|
||||
datasetList={datasetList}
|
||||
emptyElement={<p>No knowledge bases</p>}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No knowledge bases')).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('IntersectionObserver', () => {
|
||||
@ -409,20 +398,18 @@ describe('Datasets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct grid styling', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toHaveClass(
|
||||
'relative',
|
||||
'grid',
|
||||
'grow',
|
||||
'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]',
|
||||
'content-start',
|
||||
'gap-3',
|
||||
'px-8',
|
||||
'pt-2',
|
||||
describe('Landmarks', () => {
|
||||
it('keeps knowledge cards in the page content without creating another navigation landmark', () => {
|
||||
render(
|
||||
<Datasets
|
||||
{...defaultProps}
|
||||
datasetList={createDatasetListData([
|
||||
{ data: [createMockDataset({ id: 'dataset-1', name: 'Dataset 1' })] },
|
||||
])}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ const Datasets = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8 pt-2">
|
||||
<div className="relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8 pt-2">
|
||||
{showDatasetSkeleton ? (
|
||||
<DatasetCardSkeleton label={t(($) => $.loading, { ns: 'common' })} />
|
||||
) : (
|
||||
@ -92,7 +92,7 @@ const Datasets = ({
|
||||
{!showDatasetSkeleton && !hasAnyDataset && emptyElement}
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -366,7 +366,10 @@ describe('IntegrationsPage', () => {
|
||||
expect(screen.getByTestId('model-provider-page')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.settings.provider')).toHaveLength(2)
|
||||
expect(container.firstElementChild).toHaveClass('bg-components-panel-bg')
|
||||
expect(container.querySelector('aside')).toHaveClass('bg-components-panel-bg')
|
||||
expect(
|
||||
screen.getByRole('navigation', { name: 'common.settings.integrations' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not replace the document title when embedded in a modal', () => {
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UpdateSettingDialog from '@/app/components/header/account-setting/update-setting-dialog'
|
||||
import {
|
||||
@ -126,6 +126,7 @@ export default function IntegrationsPage({
|
||||
syncDocumentTitle = false,
|
||||
}: IntegrationsPageProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigationTitleId = useId()
|
||||
const docLink = useDocLink()
|
||||
const router = useRouter()
|
||||
const section = useIntegrationSection(routeSection)
|
||||
@ -253,7 +254,7 @@ export default function IntegrationsPage({
|
||||
{syncDocumentTitle && (
|
||||
<IntegrationsDocumentTitle title={`${sectionTitle} · ${integrationsTitle}`} />
|
||||
)}
|
||||
<aside
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col border-r border-divider-burn bg-components-panel-bg px-2 py-2 transition-[width]',
|
||||
'w-50 items-end',
|
||||
@ -268,7 +269,10 @@ export default function IntegrationsPage({
|
||||
)}
|
||||
>
|
||||
<div className="flex h-6 min-w-0 flex-1 items-center justify-center">
|
||||
<div className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary">
|
||||
<div
|
||||
id={navigationTitleId}
|
||||
className="min-w-0 flex-1 title-2xl-semi-bold text-text-primary"
|
||||
>
|
||||
{t(($) => $['settings.integrations'], { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
@ -283,7 +287,10 @@ export default function IntegrationsPage({
|
||||
{!showInstallAction && reserveInstallActionSlot && (
|
||||
<div aria-hidden="true" className="h-8 w-full shrink-0" />
|
||||
)}
|
||||
<nav className={cn('shrink-0 space-y-px', reserveInstallActionSlot ? 'mt-6' : 'py-4')}>
|
||||
<nav
|
||||
aria-labelledby={navigationTitleId}
|
||||
className={cn('shrink-0 space-y-px', reserveInstallActionSlot ? 'mt-6' : 'py-4')}
|
||||
>
|
||||
<IntegrationSidebarNavItem
|
||||
item={providerItem}
|
||||
onSelect={onSectionChange}
|
||||
@ -337,7 +344,7 @@ export default function IntegrationsPage({
|
||||
onPermissionChange={handlePermissionChange}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{useFillLayout ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
|
||||
@ -710,7 +710,7 @@ describe('MainNav', () => {
|
||||
marketplaceLink.querySelector('.i-custom-vender-main-nav-marketplace-v2'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByRole('navigation'))
|
||||
within(screen.getByRole('navigation', { name: 'common.navigation.primary' }))
|
||||
.getAllByRole('link')
|
||||
.map((link) => link.getAttribute('href')),
|
||||
).toEqual([
|
||||
|
||||
@ -118,7 +118,10 @@ export function MainNav({ className }: MainNavProps) {
|
||||
<div className="p-2">
|
||||
<WorkspaceCard />
|
||||
</div>
|
||||
<nav className="isolate flex flex-col gap-px p-2">
|
||||
<nav
|
||||
aria-label={t(($) => $['navigation.primary'], { ns: 'common' })}
|
||||
className="isolate flex flex-col gap-px p-2"
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname}>
|
||||
{item.href === '/agents' && (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { CreatorProfileViewModel } from '../model'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CreatorProfileView from '../view'
|
||||
@ -13,10 +13,6 @@ vi.mock('#i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../creator-sidebar', () => ({
|
||||
default: () => <aside>Creator sidebar</aside>,
|
||||
}))
|
||||
|
||||
vi.mock('../creator-content', () => ({
|
||||
default: () => <section>Creator content</section>,
|
||||
}))
|
||||
@ -35,6 +31,26 @@ const profile: CreatorProfileViewModel = {
|
||||
}
|
||||
|
||||
describe('CreatorProfileView SSR background', () => {
|
||||
it('keeps the creator profile within the parent main landmark', () => {
|
||||
render(
|
||||
<main>
|
||||
<CreatorProfileView
|
||||
profile={profile}
|
||||
homeHref="/marketplace"
|
||||
isMarketplacePlatform
|
||||
getCreationAction={() => ({ type: 'link', href: '/' })}
|
||||
/>
|
||||
</main>,
|
||||
)
|
||||
|
||||
const main = screen.getByRole('main')
|
||||
expect(within(main).getByRole('heading', { level: 1, name: 'Creator' })).toBeInTheDocument()
|
||||
expect(
|
||||
within(main).getByRole('navigation', { name: 'marketplace.creatorProfile.breadcrumbLabel' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes the default background in server markup before the remote background loads', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CreatorProfileView
|
||||
@ -63,7 +79,7 @@ describe('CreatorProfileView SSR background', () => {
|
||||
)
|
||||
|
||||
expect(markup).toContain('default-background.png')
|
||||
expect(markup).not.toContain('<img')
|
||||
expect(markup).not.toContain('src="/creator-background.png"')
|
||||
expect(markup).toContain('border-0')
|
||||
})
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ export default function CreatorSidebar({ profile }: CreatorSidebarProps) {
|
||||
const isVerified = profile.badges.includes('verified')
|
||||
|
||||
return (
|
||||
<aside className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12">
|
||||
<div className="relative flex min-w-0 flex-col gap-4 pt-11 md:w-[234px] md:pt-12">
|
||||
<PublisherAvatar
|
||||
avatarUrl={profile.avatarUrl}
|
||||
name={profile.displayName}
|
||||
@ -109,6 +109,6 @@ export default function CreatorSidebar({ profile }: CreatorSidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ export default function CreatorProfileView({
|
||||
return (
|
||||
<div className="flex min-h-full shrink-0 flex-col bg-background-default">
|
||||
{header}
|
||||
<main
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-1 flex-col px-4',
|
||||
isMarketplacePlatform ? 'md:px-6' : 'md:px-9',
|
||||
@ -104,7 +104,7 @@ export default function CreatorProfileView({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
21
web/app/signin/__tests__/layout.spec.tsx
Normal file
21
web/app/signin/__tests__/layout.spec.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import SignInLayout from '../layout'
|
||||
|
||||
describe('SignInLayout landmarks', () => {
|
||||
it('separates the site header, sign-in content, and copyright', () => {
|
||||
render(
|
||||
<SignInLayout>
|
||||
<h1>Sign in</h1>
|
||||
</SignInLayout>,
|
||||
{ systemFeatures: { branding: { enabled: false } } },
|
||||
)
|
||||
|
||||
const main = screen.getByRole('main')
|
||||
expect(within(main).getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('banner')).not.toContainElement(main)
|
||||
expect(screen.getByRole('contentinfo')).toHaveTextContent('LangGenius')
|
||||
expect(main).not.toContainElement(screen.getByRole('banner'))
|
||||
expect(main).not.toContainElement(screen.getByRole('contentinfo'))
|
||||
})
|
||||
})
|
||||
@ -19,7 +19,7 @@ const Header = () => {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<header className="flex w-full items-center justify-between p-6">
|
||||
{systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo ? (
|
||||
<img
|
||||
src={systemFeatures.branding.login_page_logo}
|
||||
@ -40,7 +40,7 @@ const Header = () => {
|
||||
<Divider type="vertical" className="mx-0 ml-2 h-4" />
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -15,15 +15,15 @@ export default function SignInLayout({ children }: any) {
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<div
|
||||
<main
|
||||
className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-27')}
|
||||
>
|
||||
<div className="flex w-full flex-col md:w-100">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
{systemFeatures.branding.enabled === false && (
|
||||
<div className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
<footer className="px-8 py-6 system-xs-regular text-text-tertiary">
|
||||
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
57
web/docs/landmarks.md
Normal file
57
web/docs/landmarks.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Page Landmarks
|
||||
|
||||
Web owns landmark composition across route layouts and feature components. Use the
|
||||
[W3C landmark guide] for role semantics and the Dify UI [naming contract] for accessible
|
||||
names. This guide records how those contracts apply to Web pages.
|
||||
|
||||
## Page Ownership
|
||||
|
||||
- Place perceivable page content in meaningful landmark regions and check coverage after
|
||||
composing the layout and its children.
|
||||
- Trace the parent layouts before adding a page-level landmark. Inspect the composed
|
||||
page, including parallel route slots and portals, rather than judging a component alone.
|
||||
- [MainNavLayout] provides the console's `main` and skip-navigation target. Descendant
|
||||
pages and editors must not create another `main`. Hiding the main navigation does not
|
||||
remove this wrapper. A route outside this layout must establish its own main-content
|
||||
boundary in its owning layout or page.
|
||||
- Keep the skip link and its target under the same layout owner. Preserve the target ID
|
||||
and focus behavior when changing wrappers; a landmark role alone does not move focus.
|
||||
- Keep page and section headings meaningful inside these boundaries. A heading does not
|
||||
create a landmark or automatically name its surrounding section.
|
||||
|
||||
## Choose Roles by Content
|
||||
|
||||
- Use `nav` for a meaningful group of navigation links. A grid of resource cards is not
|
||||
automatically navigation merely because its cards can open detail pages.
|
||||
- A visual sidebar is not automatically complementary content. File controls, editors,
|
||||
and builder or version panels are parts of the current task. Use ordinary containers
|
||||
or named sections according to their navigation value. Follow APG's recommendation
|
||||
to keep genuine complementary landmarks at the top level.
|
||||
- Reserve named `section` regions for content useful to reach directly. Avoid turning
|
||||
every wrapper, card, or modal body into a landmark. Prefer an existing visible heading
|
||||
as the naming source, following the [naming contract].
|
||||
- Give repeated landmarks distinct names by purpose unless their content and purpose
|
||||
are identical. Keep names localized and avoid repeating the role in the name.
|
||||
- Page-level `header` and `footer` can expose `banner` and `contentinfo`. Their implicit
|
||||
roles depend on ancestors; local panel headers and footers do not necessarily expose
|
||||
those landmarks. Check the composed context before adding explicit roles.
|
||||
|
||||
## Verify the Composed Page
|
||||
|
||||
- Check the main-content boundary, landmark hierarchy, names, and heading relationships
|
||||
in the relevant loaded, empty, collapsed, and open-overlay states. Exclude hidden
|
||||
skeletons from the exposed landmark inventory. Check that label references still
|
||||
resolve when conditional content changes.
|
||||
- When changing a semantic element, inspect affected unit tests and E2E locators,
|
||||
including CSS selectors such as `closest('main')`. Locate the intended feature or
|
||||
control instead of relying on an extra page landmark or positional selection among
|
||||
duplicate landmarks.
|
||||
- Follow [Web testing policy] for regression coverage. At composition boundaries, keep
|
||||
the components that own landmark semantics real; a mocked sidebar cannot prove the
|
||||
real sidebar's role. Distinguish DOM assertions and Cucumber dry-runs from actual
|
||||
browser or assistive-technology verification.
|
||||
|
||||
[MainNavLayout]: ../app/components/main-nav/layout.tsx
|
||||
[W3C landmark guide]: https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions
|
||||
[Web testing policy]: test.md
|
||||
[naming contract]: ../../packages/dify-ui/docs/accessible-names-and-descriptions.md
|
||||
@ -960,7 +960,7 @@ export function AddSourcePage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="min-h-full px-4 py-6 sm:px-8 sm:py-7">
|
||||
<div className="min-h-full px-4 py-6 sm:px-8 sm:py-7">
|
||||
<header>
|
||||
<h2 className="title-xl-semi-bold text-text-primary">
|
||||
{t(($) => $['newKnowledge.addSource'])}
|
||||
@ -1111,7 +1111,7 @@ export function AddSourcePage({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<AddSourceExitDialog
|
||||
discarding={discarding}
|
||||
error={discardError}
|
||||
|
||||
@ -544,7 +544,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
|
||||
])
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-col px-4 py-6 sm:px-8 sm:py-7">
|
||||
<div className="flex min-h-full flex-col px-4 py-6 sm:px-8 sm:py-7">
|
||||
<header>
|
||||
<div>
|
||||
<h2 className="title-xl-semi-bold text-text-primary">
|
||||
@ -749,6 +749,6 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -93,7 +93,7 @@ describe('SkillDetailPage builder', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves the collapsed Skill Builder entry into the file tab header', async () => {
|
||||
it('reopens Skill Builder from the collapsed entry', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
@ -106,12 +106,14 @@ describe('SkillDetailPage builder', () => {
|
||||
const openBuilderButton = screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.open',
|
||||
})
|
||||
expect(openBuilderButton.closest('main')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'skill.skillManagement.detail.builder.title' }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(openBuilderButton)
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.close',
|
||||
await screen.findByRole('region', {
|
||||
name: 'skill.skillManagement.detail.builder.title',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
createDataTransfer,
|
||||
createSkillDetail,
|
||||
getFileTabButton,
|
||||
getFileTreeButton,
|
||||
getFileTreeContextRegion,
|
||||
getFileTreeItem,
|
||||
@ -247,13 +248,7 @@ describe('SkillDetailPage clipboard', () => {
|
||||
)
|
||||
})
|
||||
await waitFor(() => {
|
||||
const editorMain = screen.getAllByRole('main').at(-1)
|
||||
if (!editorMain) throw new Error('file editor not found')
|
||||
expect(
|
||||
within(editorMain).getByRole('button', {
|
||||
name: 'SKILL copy.md',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(getFileTabButton('SKILL copy.md')).toHaveAccessibleName('SKILL copy.md')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -51,12 +51,21 @@ describe('SkillDetailPage navigation', () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
expect(
|
||||
await screen.findByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.collapseSidebar',
|
||||
}),
|
||||
)
|
||||
expect(screen.queryByTestId('skill-detail-sidebar-header')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
@ -64,6 +73,9 @@ describe('SkillDetailPage navigation', () => {
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByTestId('skill-detail-sidebar-header')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the sidebar while the collapsed rail is hovered', async () => {
|
||||
|
||||
@ -517,7 +517,7 @@ describe('SkillDetailPage publishing', () => {
|
||||
await screen.findByRole('button', { name: 'skill.skillManagement.detail.versionHistory' }),
|
||||
)
|
||||
|
||||
await screen.findByText('skill.skillManagement.detail.versions')
|
||||
await screen.findByRole('region', { name: 'skill.skillManagement.detail.versions' })
|
||||
expect(screen.getAllByRole('button', { current: true })).toHaveLength(1)
|
||||
|
||||
await user.click(
|
||||
|
||||
@ -645,13 +645,9 @@ export async function openFileTreeActions(user: ReturnType<typeof userEvent.setu
|
||||
}
|
||||
|
||||
export async function openRootCreateMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
const triggers = Array.from(document.querySelectorAll('aside .i-ri-add-line'))
|
||||
.map((icon) => icon.closest('button'))
|
||||
.filter((button): button is HTMLButtonElement => button instanceof HTMLButtonElement)
|
||||
const trigger = triggers.at(-1)
|
||||
if (!(trigger instanceof HTMLButtonElement)) throw new Error('root create menu trigger not found')
|
||||
const fileTree = screen.getByRole('region', { name: /skillManagement\.detail\.fileCount/ })
|
||||
|
||||
await user.click(trigger)
|
||||
await user.click(within(fileTree).getByRole('button', { name: 'common.operation.add' }))
|
||||
}
|
||||
|
||||
export async function confirmUploadReview() {
|
||||
|
||||
@ -11,7 +11,7 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import {
|
||||
@ -298,6 +298,7 @@ export function SkillBuilderPanel({
|
||||
}) {
|
||||
const { t } = useTranslation('skill')
|
||||
const queryClient = useQueryClient()
|
||||
const titleId = useId()
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const initialBuilderModeRef = useRef({
|
||||
isEditMode: !isDefaultSkillBuilderDraft(detail),
|
||||
@ -803,7 +804,10 @@ export function SkillBuilderPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="relative my-1 mr-1 flex w-99 shrink-0 flex-col overflow-hidden rounded-lg inset-ring-[0.5px] inset-ring-divider-subtle">
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
className="relative my-1 mr-1 flex w-99 shrink-0 flex-col overflow-hidden rounded-lg inset-ring-[0.5px] inset-ring-divider-subtle"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0 bg-linear-to-b from-background-gradient-bg-fill-chat-bg-1 to-background-gradient-bg-fill-chat-bg-2"
|
||||
@ -817,7 +821,7 @@ export function SkillBuilderPanel({
|
||||
className="pointer-events-none absolute bottom-0 left-0 z-1 origin-center scale-y-[-1]"
|
||||
/>
|
||||
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between gap-2 pr-3 pl-4">
|
||||
<h2 className="system-xs-semibold-uppercase text-text-secondary">
|
||||
<h2 id={titleId} className="system-xs-semibold-uppercase text-text-secondary">
|
||||
{t(($) => $['skillManagement.detail.builder.title'])}
|
||||
</h2>
|
||||
<div className="flex h-8 items-center gap-1">
|
||||
@ -1119,6 +1123,6 @@ export function SkillBuilderPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1075,7 +1075,7 @@ export function FileEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative my-1 mr-1 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background-default inset-ring-[0.5px] inset-ring-divider-subtle">
|
||||
<div className="relative my-1 mr-1 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background-default inset-ring-[0.5px] inset-ring-divider-subtle">
|
||||
<FileTabs
|
||||
endAction={
|
||||
onOpenBuilder && (
|
||||
@ -1518,6 +1518,6 @@ export function FileEditor({
|
||||
onExit={onExitVersion}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
|
||||
import { formatForDisplay, matchesKeyboardEvent, useHotkey } from '@tanstack/react-hotkeys'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
|
||||
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
|
||||
@ -175,6 +175,7 @@ export function FileTree({
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const filesTitleId = useId()
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const [inlineAction, setInlineAction] = useState<FileTreeInlineAction>()
|
||||
const [draggingPaths, setDraggingPaths] = useState<string[]>([])
|
||||
@ -1105,7 +1106,7 @@ export function FileTree({
|
||||
const creatorName = detail?.created_by_name ?? detail?.created_by ?? '-'
|
||||
if (collapsed && !sidebarFloating) {
|
||||
return (
|
||||
<aside
|
||||
<div
|
||||
data-testid="skill-detail-sidebar-shell"
|
||||
className="relative flex h-full w-16 shrink-0 bg-background-body p-1"
|
||||
onMouseEnter={openSidebarFloatingPreview}
|
||||
@ -1137,13 +1138,14 @@ export function FileTree({
|
||||
<SkillSidebarAccountFooter compact />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
<section
|
||||
aria-labelledby={filesTitleId}
|
||||
ref={sidebarRef}
|
||||
data-testid="skill-detail-sidebar-shell"
|
||||
className={cn(
|
||||
@ -1313,7 +1315,10 @@ export function FileTree({
|
||||
<div className="h-px w-full bg-linear-to-r from-divider-subtle to-transparent" />
|
||||
</div>
|
||||
<div className="flex h-8 shrink-0 items-center gap-1 px-3">
|
||||
<h2 className="min-w-0 flex-1 system-xs-medium-uppercase text-text-tertiary">
|
||||
<h2
|
||||
id={filesTitleId}
|
||||
className="min-w-0 flex-1 system-xs-medium-uppercase text-text-tertiary"
|
||||
>
|
||||
{t(
|
||||
($) =>
|
||||
fileCount === 1
|
||||
@ -1325,6 +1330,7 @@ export function FileTree({
|
||||
{!readonly && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={tCommon(($) => $['operation.add'])}
|
||||
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
|
||||
disabled={!detail || isMutating}
|
||||
>
|
||||
@ -1601,7 +1607,7 @@ export function FileTree({
|
||||
</div>
|
||||
<SkillSidebarAccountFooter />
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import { SkillBuilderGridTexture } from './builder-grid-texture'
|
||||
export function DetailSkeleton() {
|
||||
return (
|
||||
<div aria-busy="true" className="flex h-0 min-w-0 grow overflow-hidden bg-background-body">
|
||||
<aside aria-hidden className="flex h-full w-62 shrink-0 bg-background-body p-1">
|
||||
<div aria-hidden className="flex h-full w-62 shrink-0 bg-background-body p-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg bg-components-panel-bg">
|
||||
<div className="flex h-12 shrink-0 items-center gap-1 px-2">
|
||||
<SkeletonRectangle className="my-0 size-6 rounded-md opacity-12" />
|
||||
@ -38,9 +38,9 @@ export function DetailSkeleton() {
|
||||
<SkeletonRectangle className="my-0 h-8 w-full rounded-lg opacity-12" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<main
|
||||
<div
|
||||
aria-hidden
|
||||
className="my-1 mr-1 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg bg-background-default inset-ring-[0.5px] inset-ring-divider-subtle"
|
||||
>
|
||||
@ -68,9 +68,9 @@ export function DetailSkeleton() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative my-1 mr-1 flex w-99 shrink-0 flex-col overflow-hidden rounded-lg inset-ring-[0.5px] inset-ring-divider-subtle"
|
||||
>
|
||||
@ -99,7 +99,7 @@ export function DetailSkeleton() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
@ -506,6 +506,7 @@ export function VersionPanel({
|
||||
const { t } = useTranslation('skill')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
const [filterValue, setFilterValue] = useState<VersionFilterValue>('all')
|
||||
const titleId = useId()
|
||||
const filteredVersions = versions.filter((version) => {
|
||||
if (filterValue === 'onlyNamed') return !!version.version_name
|
||||
|
||||
@ -513,10 +514,10 @@ export function VersionPanel({
|
||||
})
|
||||
|
||||
return (
|
||||
<aside className="flex w-67 shrink-0 flex-col py-1">
|
||||
<section aria-labelledby={titleId} className="flex w-67 shrink-0 flex-col py-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-l-lg bg-components-panel-bg">
|
||||
<div className="flex shrink-0 items-center gap-2 pt-3 pr-3 pl-4">
|
||||
<h2 className="min-w-0 flex-1 truncate system-xl-semibold text-text-primary">
|
||||
<h2 id={titleId} className="min-w-0 flex-1 truncate system-xl-semibold text-text-primary">
|
||||
{t(($) => $['skillManagement.detail.versions'])}
|
||||
</h2>
|
||||
<VersionFilter value={filterValue} onChange={setFilterValue} />
|
||||
@ -574,6 +575,6 @@ export function VersionPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "تعيين النموذج الافتراضي لإدخال تحويل النص إلى كلام في المحادثة.",
|
||||
"modelProvider.upgradeForLoadBalancing": "قم بترقية خطتك لتمكين موازنة التحميل.",
|
||||
"modelProvider.used": "مستخدم",
|
||||
"navigation.primary": "القائمة الرئيسية",
|
||||
"navigation.skipToMain": "الانتقال إلى المحتوى الرئيسي",
|
||||
"noData": "لا توجد بيانات",
|
||||
"operation.add": "إضافة",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Legen Sie das Standardmodell für die Text-zu-Sprache-Eingabe in Konversationen fest.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Aktualisieren Sie Ihren Plan, um den Lastenausgleich zu aktivieren.",
|
||||
"modelProvider.used": "verwendet",
|
||||
"navigation.primary": "Hauptmenü",
|
||||
"navigation.skipToMain": "Zum Hauptinhalt springen",
|
||||
"noData": "Keine Daten",
|
||||
"operation.add": "Hinzufügen",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Set the default model for text-to-speech input in conversation.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Upgrade your plan to enable Load Balancing.",
|
||||
"modelProvider.used": "used",
|
||||
"navigation.primary": "Main menu",
|
||||
"navigation.skipToMain": "Skip to main content",
|
||||
"noData": "No data",
|
||||
"operation.add": "Add",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Establece el modelo predeterminado para la entrada de texto a voz en la conversación.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Actualiza tu plan para habilitar el Balanceo de Carga.",
|
||||
"modelProvider.used": "usados",
|
||||
"navigation.primary": "Menú principal",
|
||||
"navigation.skipToMain": "Saltar al contenido principal",
|
||||
"noData": "Sin datos",
|
||||
"operation.add": "Agregar",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "مدل پیشفرض را برای ورودی متن به گفتار در مکالمه تنظیم کنید.",
|
||||
"modelProvider.upgradeForLoadBalancing": "برای فعال کردن تعادل بار، طرح خود را ارتقا دهید.",
|
||||
"modelProvider.used": "استفادهشده",
|
||||
"navigation.primary": "منوی اصلی",
|
||||
"navigation.skipToMain": "پرش به محتوای اصلی",
|
||||
"noData": "بدون داده",
|
||||
"operation.add": "افزودن",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Définissez le modèle par défaut pour l'entrée de texte à la parole dans une conversation.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Mettez à niveau votre plan pour activer l’équilibrage de charge.",
|
||||
"modelProvider.used": "utilisés",
|
||||
"navigation.primary": "Menu principal",
|
||||
"navigation.skipToMain": "Aller au contenu principal",
|
||||
"noData": "Aucune donnée",
|
||||
"operation.add": "Ajouter",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "संवाद में पाठ-से-भाषण इनपुट के लिए डिफ़ॉल्ट मॉडल सेट करें।",
|
||||
"modelProvider.upgradeForLoadBalancing": "लोड बैलेंसिंग सक्षम करने के लिए अपनी योजना अपग्रेड करें।",
|
||||
"modelProvider.used": "उपयोग किया गया",
|
||||
"navigation.primary": "मुख्य मेन्यू",
|
||||
"navigation.skipToMain": "मुख्य सामग्री पर जाएं",
|
||||
"noData": "कोई डेटा नहीं",
|
||||
"operation.add": "जोड़ें",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Atur model default untuk input teks-ke-ucapan dalam percakapan.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Tingkatkan paket Anda untuk mengaktifkan Penyeimbangan Beban.",
|
||||
"modelProvider.used": "digunakan",
|
||||
"navigation.primary": "Menu utama",
|
||||
"navigation.skipToMain": "Lewati ke konten utama",
|
||||
"noData": "Tidak ada data",
|
||||
"operation.add": "Tambah",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Imposta il modello predefinito per l'input da testo a voce nella conversazione.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Aggiorna il tuo piano per abilitare il Bilanciamento del Carico.",
|
||||
"modelProvider.used": "utilizzati",
|
||||
"navigation.primary": "Menu principale",
|
||||
"navigation.skipToMain": "Vai al contenuto principale",
|
||||
"noData": "Nessun dato",
|
||||
"operation.add": "Aggiungi",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "会話でのテキスト-to-音声入力に使用するデフォルトモデルを設定します。",
|
||||
"modelProvider.upgradeForLoadBalancing": "負荷分散を利用するには、プランのアップグレードが必要です。",
|
||||
"modelProvider.used": "使用済み",
|
||||
"navigation.primary": "メインメニュー",
|
||||
"navigation.skipToMain": "メインコンテンツへスキップ",
|
||||
"noData": "データなし",
|
||||
"operation.add": "追加",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "대화에서의 텍스트-to-음성 입력에 사용되는 기본 모델을 설정합니다.",
|
||||
"modelProvider.upgradeForLoadBalancing": "로드 밸런싱을 사용하도록 계획을 업그레이드합니다.",
|
||||
"modelProvider.used": "사용됨",
|
||||
"navigation.primary": "주 메뉴",
|
||||
"navigation.skipToMain": "본문으로 건너뛰기",
|
||||
"noData": "데이터 없음",
|
||||
"operation.add": "추가",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "ຕັ້ງຄ່າໂມເດວເລີ່ມຕົ້ນສຳລັບການປ່ຽນຂໍ້ຄວາມເປັນສຽງໃນການສົນທະນາ.",
|
||||
"modelProvider.upgradeForLoadBalancing": "ອັບເກຣດແຜນຂອງທ່ານເພື່ອເປີດໃຊ້ Load Balancing.",
|
||||
"modelProvider.used": "ໃຊ້ໄປແລ້ວ",
|
||||
"navigation.primary": "ເມນູຫຼັກ",
|
||||
"navigation.skipToMain": "ຂ້າມໄປເນື້ອຫາຫຼັກ",
|
||||
"noData": "ບໍ່ມີຂໍ້ມູນ",
|
||||
"operation.add": "ເພີ່ມ",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Set the default model for text-to-speech input in conversation.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Upgrade your plan to enable Load Balancing.",
|
||||
"modelProvider.used": "gebruikt",
|
||||
"navigation.primary": "Hoofdmenu",
|
||||
"navigation.skipToMain": "Naar hoofdinhoud springen",
|
||||
"noData": "No data",
|
||||
"operation.add": "Add",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Ustaw domyślny model dla konwersji tekstu na mowę w rozmowach.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Uaktualnij swój plan, aby włączyć równoważenie obciążenia.",
|
||||
"modelProvider.used": "wykorzystano",
|
||||
"navigation.primary": "Menu główne",
|
||||
"navigation.skipToMain": "Przejdź do głównej treści",
|
||||
"noData": "Brak danych",
|
||||
"operation.add": "Dodaj",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Defina o modelo padrão para entrada de texto para fala na conversa.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Atualize seu plano para habilitar o balanceamento de carga.",
|
||||
"modelProvider.used": "usados",
|
||||
"navigation.primary": "Menu principal",
|
||||
"navigation.skipToMain": "Pular para o conteúdo principal",
|
||||
"noData": "Sem dados",
|
||||
"operation.add": "Adicionar",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Setați modelul implicit pentru intrarea de conversie vorbire-la-text în conversație.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Actualizați-vă planul pentru a activa Load Balancing.",
|
||||
"modelProvider.used": "utilizate",
|
||||
"navigation.primary": "Meniu principal",
|
||||
"navigation.skipToMain": "Sări la conținutul principal",
|
||||
"noData": "Fără date",
|
||||
"operation.add": "Adaugă",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Установите модель по умолчанию для ввода текста в речь в разговоре.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Обновите свой тарифный план, чтобы включить балансировку нагрузки.",
|
||||
"modelProvider.used": "использовано",
|
||||
"navigation.primary": "Главное меню",
|
||||
"navigation.skipToMain": "Перейти к основному содержанию",
|
||||
"noData": "Нет данных",
|
||||
"operation.add": "Добавить",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Nastavite privzeti model za pretvorbo besedila v govor v pogovoru.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Nadgradite svoj načrt, da omogočite uravnoteženje obremenitev.",
|
||||
"modelProvider.used": "porabljeno",
|
||||
"navigation.primary": "Glavni meni",
|
||||
"navigation.skipToMain": "Preskoči na glavno vsebino",
|
||||
"noData": "Ni podatkov",
|
||||
"operation.add": "Dodaj",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "ตั้งค่าโมเดลเริ่มต้นสําหรับการป้อนข้อมูลเป็นข้อความเป็นคําพูดในการสนทนา",
|
||||
"modelProvider.upgradeForLoadBalancing": "อัปเกรดแผนของคุณเพื่อเปิดใช้งานการปรับสมดุลโหลด",
|
||||
"modelProvider.used": "ใช้แล้ว",
|
||||
"navigation.primary": "เมนูหลัก",
|
||||
"navigation.skipToMain": "ข้ามไปยังเนื้อหาหลัก",
|
||||
"noData": "ไม่มีข้อมูล",
|
||||
"operation.add": "เพิ่ม",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Konuşmada metinden konuşmaya giriş için varsayılan modeli ayarlayın.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Yük Dengelemeyi etkinleştirmek için planınızı yükseltin.",
|
||||
"modelProvider.used": "kullanıldı",
|
||||
"navigation.primary": "Ana menü",
|
||||
"navigation.skipToMain": "Ana içeriğe geç",
|
||||
"noData": "Veri yok",
|
||||
"operation.add": "Ekle",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Встановіть модель за замовчуванням для введення тексту в мовлення в розмові.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Оновіть свій план, щоб увімкнути балансування навантаження.",
|
||||
"modelProvider.used": "використано",
|
||||
"navigation.primary": "Головне меню",
|
||||
"navigation.skipToMain": "Перейти до основного вмісту",
|
||||
"noData": "Немає даних",
|
||||
"operation.add": "Додати",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "Thiết lập mô hình mặc định cho đầu vào văn bản thành tiếng nói trong cuộc trò chuyện.",
|
||||
"modelProvider.upgradeForLoadBalancing": "Nâng cấp gói của bạn để bật Cân bằng tải.",
|
||||
"modelProvider.used": "đã dùng",
|
||||
"navigation.primary": "Menu chính",
|
||||
"navigation.skipToMain": "Chuyển đến nội dung chính",
|
||||
"noData": "Không có dữ liệu",
|
||||
"operation.add": "Thêm",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "设置对话中文字转语音输出的默认使用模型。",
|
||||
"modelProvider.upgradeForLoadBalancing": "升级以解锁负载均衡功能",
|
||||
"modelProvider.used": "已用",
|
||||
"navigation.primary": "主菜单",
|
||||
"navigation.skipToMain": "跳转到主要内容",
|
||||
"noData": "暂无数据",
|
||||
"operation.add": "添加",
|
||||
|
||||
@ -441,6 +441,7 @@
|
||||
"modelProvider.ttsModel.tip": "設定對話中文字轉語音輸出的預設使用模型。",
|
||||
"modelProvider.upgradeForLoadBalancing": "升級您的計劃以啟用 Load Balancing。",
|
||||
"modelProvider.used": "已用",
|
||||
"navigation.primary": "主選單",
|
||||
"navigation.skipToMain": "跳至主要內容",
|
||||
"noData": "無資料",
|
||||
"operation.add": "新增",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user