diff --git a/frontend/ARCHITECTURE.md b/frontend/ARCHITECTURE.md
index 597082f..004213b 100644
--- a/frontend/ARCHITECTURE.md
+++ b/frontend/ARCHITECTURE.md
@@ -95,9 +95,10 @@ frontend/src/
shared/
ui/
Button.tsx
- TextField.tsx
- tests/
- ui.test.tsx
+ text-field/
+ TextField.tsx
+ tests/
+ text-field.test.tsx
```
Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9).
diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx
index f4b9651..5c2903c 100644
--- a/frontend/src/app/routes.tsx
+++ b/frontend/src/app/routes.tsx
@@ -2,8 +2,8 @@ import type { RouteObject } from 'react-router'
import { RequireAuth } from '../features/auth/RequireAuth'
import { AppShell } from '../shared/layout/AppShell'
import HomePage from '../pages/public/HomePage'
-import LoginPage from '../pages/public/LoginPage'
-import RegisterPage from '../pages/public/RegisterPage'
+import LoginPage from '../pages/public/auth/LoginPage'
+import RegisterPage from '../pages/public/auth/RegisterPage'
import ProfilePage from '../pages/public/ProfilePage'
import NotFoundPage from '../pages/public/NotFoundPage'
// Code-split: the download page pulls in react-markdown, so it loads on demand.
diff --git a/frontend/src/features/auth/forms/LoginForm.tsx b/frontend/src/features/auth/forms/LoginForm.tsx
index e37b590..5c5353b 100644
--- a/frontend/src/features/auth/forms/LoginForm.tsx
+++ b/frontend/src/features/auth/forms/LoginForm.tsx
@@ -4,7 +4,7 @@ import { Link, useLocation, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
-import { TextField } from '../../../shared/ui/TextField'
+import { TextField } from '../../../shared/ui/text-field/TextField'
import { useSession } from '../session'
export function LoginForm() {
diff --git a/frontend/src/features/auth/forms/RegisterForm.tsx b/frontend/src/features/auth/forms/RegisterForm.tsx
index 0b186cc..d393c47 100644
--- a/frontend/src/features/auth/forms/RegisterForm.tsx
+++ b/frontend/src/features/auth/forms/RegisterForm.tsx
@@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
-import { TextField } from '../../../shared/ui/TextField'
+import { TextField } from '../../../shared/ui/text-field/TextField'
import { useSession } from '../session'
import { validateEmail, validateNick, validatePassword } from '../validation'
diff --git a/frontend/src/features/configs/components/PublishForm.tsx b/frontend/src/features/configs/components/PublishForm.tsx
index 3e01832..7a6ea07 100644
--- a/frontend/src/features/configs/components/PublishForm.tsx
+++ b/frontend/src/features/configs/components/PublishForm.tsx
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
-import { TextField } from '../../../shared/ui/TextField'
+import { TextField } from '../../../shared/ui/text-field/TextField'
import { publish } from '../api'
const MAX_DESCRIPTION = 500
diff --git a/frontend/src/features/configs/components/SlotPanel.tsx b/frontend/src/features/configs/components/SlotPanel.tsx
index 0a13f37..2370e2e 100644
--- a/frontend/src/features/configs/components/SlotPanel.tsx
+++ b/frontend/src/features/configs/components/SlotPanel.tsx
@@ -3,7 +3,7 @@ import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { Notice } from '../../../shared/ui/Notice'
-import { ShareCode } from '../../../shared/ui/ShareCode'
+import { ShareCode } from '../../../shared/ui/share-code/ShareCode'
import { regenerateCode, unpublish } from '../api'
import type { SlotSummary } from '../types'
import { PublishForm } from './PublishForm'
diff --git a/frontend/src/pages/app/ConfigsPage.tsx b/frontend/src/pages/app/ConfigsPage.tsx
index 9d8d89a..d04448e 100644
--- a/frontend/src/pages/app/ConfigsPage.tsx
+++ b/frontend/src/pages/app/ConfigsPage.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { SlotGrid } from '../../features/configs/components/SlotGrid'
-import { PageTitle } from '../../shared/ui/PageTitle'
+import { PageTitle } from '../../shared/layout/PageTitle'
const ACCENT = 'rgb(92, 200, 231)'
diff --git a/frontend/src/pages/app/LinkPage.tsx b/frontend/src/pages/app/LinkPage.tsx
index b38145c..d6d64db 100644
--- a/frontend/src/pages/app/LinkPage.tsx
+++ b/frontend/src/pages/app/LinkPage.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
-import { PageTitle } from '../../shared/ui/PageTitle'
+import { PageTitle } from '../../shared/layout/PageTitle'
const STEPS = ['one', 'two', 'three'] as const
diff --git a/frontend/src/pages/download/DownloadPage.tsx b/frontend/src/pages/download/DownloadPage.tsx
index fb5fb25..5d3743d 100644
--- a/frontend/src/pages/download/DownloadPage.tsx
+++ b/frontend/src/pages/download/DownloadPage.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { DownloadButton } from '../../features/download/DownloadButton'
import { ReleaseNotes } from '../../features/download/ReleaseNotes'
import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
-import { PageTitle } from '../../shared/ui/PageTitle'
+import { PageTitle } from '../../shared/layout/PageTitle'
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
const REQUIREMENTS = [
diff --git a/frontend/src/pages/public/NotFoundPage.tsx b/frontend/src/pages/public/NotFoundPage.tsx
index fe2cfbb..d27b02b 100644
--- a/frontend/src/pages/public/NotFoundPage.tsx
+++ b/frontend/src/pages/public/NotFoundPage.tsx
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { NOT_FOUND_ART } from '../../shared/easter/art.generated'
-import { PageTitle } from '../../shared/ui/PageTitle'
+import { PageTitle } from '../../shared/layout/PageTitle'
export default function NotFoundPage() {
const { t } = useTranslation('common')
diff --git a/frontend/src/pages/public/LoginPage.tsx b/frontend/src/pages/public/auth/LoginPage.tsx
similarity index 66%
rename from frontend/src/pages/public/LoginPage.tsx
rename to frontend/src/pages/public/auth/LoginPage.tsx
index 5afe445..d2623d4 100644
--- a/frontend/src/pages/public/LoginPage.tsx
+++ b/frontend/src/pages/public/auth/LoginPage.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
-import { LoginForm } from '../../features/auth/forms/LoginForm'
-import { AuthSplit } from './auth/AuthSplit'
+import { LoginForm } from '../../../features/auth/forms/LoginForm'
+import { AuthSplit } from './AuthSplit'
export default function LoginPage() {
const { t } = useTranslation('auth')
diff --git a/frontend/src/pages/public/RegisterPage.tsx b/frontend/src/pages/public/auth/RegisterPage.tsx
similarity index 72%
rename from frontend/src/pages/public/RegisterPage.tsx
rename to frontend/src/pages/public/auth/RegisterPage.tsx
index 49811bf..57c4400 100644
--- a/frontend/src/pages/public/RegisterPage.tsx
+++ b/frontend/src/pages/public/auth/RegisterPage.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
-import { RegisterForm } from '../../features/auth/forms/RegisterForm'
-import { AuthSplit } from './auth/AuthSplit'
+import { RegisterForm } from '../../../features/auth/forms/RegisterForm'
+import { AuthSplit } from './AuthSplit'
export default function RegisterPage() {
const { t } = useTranslation('auth')
diff --git a/frontend/src/pages/showcase/ShowcasePage.tsx b/frontend/src/pages/showcase/ShowcasePage.tsx
index 69adb08..2f961ce 100644
--- a/frontend/src/pages/showcase/ShowcasePage.tsx
+++ b/frontend/src/pages/showcase/ShowcasePage.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { ParticleField } from '../../features/landing/effects/ParticleField'
import { VoxelScene } from '../../features/landing/effects/VoxelScene'
import { ListingList } from '../../features/showcase/components/ListingList'
-import { PageTitle } from '../../shared/ui/PageTitle'
+import { PageTitle } from '../../shared/layout/PageTitle'
const ACCENT = 'rgb(92, 200, 231)'
diff --git a/frontend/src/pages/themes/ThemesPage.tsx b/frontend/src/pages/themes/ThemesPage.tsx
index 8aac99d..e912990 100644
--- a/frontend/src/pages/themes/ThemesPage.tsx
+++ b/frontend/src/pages/themes/ThemesPage.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
import { fromShareHash } from '../../features/themes/editor/model/codec'
import { PRESETS } from '../../features/themes/presets.generated'
-import { PageTitle } from '../../shared/ui/PageTitle'
+import { PageTitle } from '../../shared/layout/PageTitle'
import '../../features/themes/editor/editor.css'
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
diff --git a/frontend/src/shared/ui/PageTitle.tsx b/frontend/src/shared/layout/PageTitle.tsx
similarity index 100%
rename from frontend/src/shared/ui/PageTitle.tsx
rename to frontend/src/shared/layout/PageTitle.tsx
diff --git a/frontend/src/shared/layout/tests/layout.test.tsx b/frontend/src/shared/layout/tests/layout.test.tsx
index 329ef7a..99de0a9 100644
--- a/frontend/src/shared/layout/tests/layout.test.tsx
+++ b/frontend/src/shared/layout/tests/layout.test.tsx
@@ -1,10 +1,11 @@
-import { screen, within } from '@testing-library/react'
+import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getI18n } from 'react-i18next'
import { afterEach, expect, test } from 'vitest'
import { routes } from '../../../app/routes'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
+import { PageTitle } from '../PageTitle'
afterEach(async () => {
await getI18n().changeLanguage('ru')
@@ -103,3 +104,11 @@ test('footer carries the github link and the disclaimer', async () => {
const gh = screen.getByRole('link', { name: 'GitHub' })
expect(gh.getAttribute('href')).toMatch(/^https:\/\/github\.com\//)
})
+
+test('page title renders one h1 with an optional lead', () => {
+ render(
+ Мои конфиги,
+ )
+ expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument()
+ expect(screen.getByText('Короткое описание')).toBeInTheDocument()
+})
diff --git a/frontend/src/shared/ui/ShareCode.tsx b/frontend/src/shared/ui/share-code/ShareCode.tsx
similarity index 96%
rename from frontend/src/shared/ui/ShareCode.tsx
rename to frontend/src/shared/ui/share-code/ShareCode.tsx
index 2fc1351..d492863 100644
--- a/frontend/src/shared/ui/ShareCode.tsx
+++ b/frontend/src/shared/ui/share-code/ShareCode.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from './Button'
+import { Button } from '../Button'
export function ShareCode({ code, label }: { code: string; label?: string }) {
const { t } = useTranslation('common')
diff --git a/frontend/src/shared/ui/share-code/tests/share-code.test.tsx b/frontend/src/shared/ui/share-code/tests/share-code.test.tsx
new file mode 100644
index 0000000..5c576ba
--- /dev/null
+++ b/frontend/src/shared/ui/share-code/tests/share-code.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { expect, test, vi } from 'vitest'
+import { ShareCode } from '../ShareCode'
+
+test('share code copies to clipboard and confirms', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
+ render()
+ expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Скопировать' }))
+ expect(writeText).toHaveBeenCalledWith('7KQ3M9XA')
+ expect(await screen.findByText('Скопировано')).toBeInTheDocument()
+})
diff --git a/frontend/src/shared/ui/tests/ui.test.tsx b/frontend/src/shared/ui/tests/ui.test.tsx
deleted file mode 100644
index 1ccff6e..0000000
--- a/frontend/src/shared/ui/tests/ui.test.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { expect, test, vi } from 'vitest'
-import { Button } from '../Button'
-import { PageTitle } from '../PageTitle'
-import { TextField } from '../TextField'
-import { ShareCode } from '../ShareCode'
-import { Notice } from '../Notice'
-
-test('busy button is disabled and announces busy', () => {
- render()
- const b = screen.getByRole('button', { name: 'Сохранить' })
- expect(b).toBeDisabled()
- expect(b).toHaveAttribute('aria-busy', 'true')
-})
-
-test('text field links label and error', () => {
- render()
- const input = screen.getByLabelText('Почта')
- expect(input).toHaveAttribute('aria-invalid', 'true')
- expect(input).toHaveAccessibleDescription('Введи почту')
-})
-
-test('share code copies to clipboard and confirms', async () => {
- const user = userEvent.setup()
- const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
- render()
- expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument()
- await user.click(screen.getByRole('button', { name: 'Скопировать' }))
- expect(writeText).toHaveBeenCalledWith('7KQ3M9XA')
- expect(await screen.findByText('Скопировано')).toBeInTheDocument()
-})
-
-test('error notice is an alert', () => {
- render(Неверный код)
- expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
-})
-
-test('page title renders one h1 with an optional lead', () => {
- render(
- Мои конфиги,
- )
- expect(screen.getByRole('heading', { level: 1, name: 'Мои конфиги' })).toBeInTheDocument()
- expect(screen.getByText('Короткое описание')).toBeInTheDocument()
-})
diff --git a/frontend/src/shared/ui/TextField.tsx b/frontend/src/shared/ui/text-field/TextField.tsx
similarity index 100%
rename from frontend/src/shared/ui/TextField.tsx
rename to frontend/src/shared/ui/text-field/TextField.tsx
diff --git a/frontend/src/shared/ui/text-field/tests/text-field.test.tsx b/frontend/src/shared/ui/text-field/tests/text-field.test.tsx
new file mode 100644
index 0000000..9e69b9a
--- /dev/null
+++ b/frontend/src/shared/ui/text-field/tests/text-field.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@testing-library/react'
+import { expect, test } from 'vitest'
+import { Button } from '../../Button'
+import { Notice } from '../../Notice'
+import { TextField } from '../TextField'
+
+test('busy button is disabled and announces busy', () => {
+ render()
+ const b = screen.getByRole('button', { name: 'Сохранить' })
+ expect(b).toBeDisabled()
+ expect(b).toHaveAttribute('aria-busy', 'true')
+})
+
+test('text field links label and error', () => {
+ render()
+ const input = screen.getByLabelText('Почта')
+ expect(input).toHaveAttribute('aria-invalid', 'true')
+ expect(input).toHaveAccessibleDescription('Введи почту')
+})
+
+test('error notice is an alert', () => {
+ render(Неверный код)
+ expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
+})