spec update

This commit is contained in:
tmshv committed 2026-04-24 21:24:46 +03:00
1 parent 0665fa7ae7
commit 8d1c212a00
3 files changed
+289

No files matched your search

@@ -0,0 +1,85 @@
# Writer Draft/Publish Feature
## Overview
Add draft-save and explicit publish workflow to hudozka-writer so editors can save work-in-progress without pushing changes live.
## Data Layer
PocketBase `pages` collection fields (already applied by user):
| Field | Type | Purpose |
|--------------|---------|--------------------------------------------------------|
| `doc` | JSON | Published document content (read by the main site) |
| `draft` | JSON | Unpublished work-in-progress, `null` when no draft |
| `published` | boolean | Page visibility (renamed from `draft`, inverted logic) |
## Editor Load Behavior
When opening a page:
- If `draft` is not null: load `draft` into the editor, show a banner "You have unpublished changes" with a **Discard** button.
- If `draft` is null: load `doc` into the editor, no banner.
## Save / Publish / Discard
| Action | API call | Effect |
|-------------|----------------------------------------------------|-------------------------------------------|
| **Save** | `update(id, { draft: editorContent })` | Persists WIP without affecting live site |
| **Publish** | `update(id, { doc: editorContent, draft: null })` | Pushes content live, clears draft |
| **Discard** | `update(id, { draft: null })` | Drops WIP, reloads editor from `doc` |
No confirmation dialog on Discard.
## Toolbar Changes
Current layout: `[formatting...] [MD] [Save]`
New layout: `[formatting...] [MD] [Save] [Publish]`
- **Save** button: enabled only when editor content differs from the last-saved state. Writes to `draft`.
- **Publish** button: enabled when there are any unpublished changes (unsaved edits or existing `draft`). Writes to `doc`, clears `draft`.
After Save: Save button becomes disabled (no new changes), Publish remains enabled (draft exists).
After Publish: both buttons become disabled (content is live, no draft).
## Draft Banner
Shown above the editor when `draft` was loaded. Simple bar with text and a Discard button:
```
[You have unpublished changes] [Discard]
```
Clicking Discard: sends API call to clear `draft`, reloads editor content from `doc`, hides the banner.
## Page Visibility Toggle
A checkbox in the app header (near page title/slug): `[ ] Published`
- Checked = page visible on site (`published: true`)
- Unchecked = page hidden (`published: false`)
- Toggling sends `update(id, { published: !current })` immediately
- Independent of save/publish content flow
## Dirty Tracking
Track whether editor content has changed since last load/save:
- On load: snapshot the initial content (serialized `DocV1`)
- On every editor change: compare current serialized content with snapshot
- After Save: update snapshot to current content
- After Publish: update snapshot to current content
- After Discard: update snapshot to `doc` content
This drives the enabled/disabled state of Save and Publish buttons.
## Type Changes
Update `PbPage` in `modules/hudozka-writer/src/types.ts`:
- Replace `draft: boolean` with `published: boolean` and `draft: DocV1 | null`
## Files to Modify
- `modules/hudozka-writer/src/types.ts` — update `PbPage` type
- `modules/hudozka-writer/src/components/Editor.tsx` — draft loading, save/publish/discard handlers, dirty tracking
- `modules/hudozka-writer/src/components/Toolbar.tsx` — add Publish button, accept dirty/hasDraft props
- `modules/hudozka-writer/src/App.tsx` — add published toggle in header, update `handleDuplicate` for new field names
@@ -0,0 +1,181 @@
# Writer Editor Improvements
Improvements to `modules/hudozka-writer/` — the Tiptap-based page editor for the hudozka CMS.
## 1. Fix Markdown Serialization + Global Toggle
### Problem
The `@tiptap/markdown` extension is registered but not used in the serialization layer (`src/lib/serialize.ts`). Currently:
- `docToTiptap()` splits text on `\n\n` and wraps each chunk in a plain paragraph node — no markdown parsing
- `tiptapToDoc()` extracts raw text content, discarding all inline marks (bold, italic, links) and block structure (headings, lists)
Result: formatting applied via toolbar is lost on save.
### Solution
Use `@tiptap/markdown` for both directions:
**Loading (DocV1 → Tiptap):** For each `text` block, call `editor.commands.setContent(block.text)` or use the Markdown extension's parser to convert the markdown string into Tiptap JSON nodes. This replaces the naive paragraph splitting in `textBlockToTiptap()`.
**Saving (Tiptap → DocV1):** When flushing the text buffer in `tiptapToDoc()`, use the Markdown extension's serializer (`editor.storage.markdown.getMarkdown()`) to convert Tiptap JSON back to a markdown string. This replaces the lossy `tiptapTextContent()` that strips all formatting.
Implementation approach: create a headless Tiptap editor instance (no DOM) used solely for markdown↔JSON conversion in `serialize.ts`. This keeps serialization pure and avoids coupling to the live editor instance.
### Global Markdown Toggle
Add a toolbar button that switches the entire editor between two modes:
- **Rendered mode** (default): Normal WYSIWYG editing via Tiptap
- **Markdown mode**: Replace the Tiptap editor surface with a plain `<textarea>` showing the serialized markdown of the full document
Toggle flow:
1. User clicks "MD" button in toolbar
2. Serialize current Tiptap content to markdown string (using the markdown serializer)
3. Hide `<EditorContent>`, show `<textarea>` with the markdown string
4. Custom blocks (image, document, embed, card-grid) are represented as fenced markers in the markdown view, e.g. `<!-- image:abc123 -->` — these are read-only anchors that preserve block positions
5. When toggling back: parse the textarea content, reconstruct Tiptap JSON, call `editor.commands.setContent()`
State: a single `markdownMode: boolean` in the `Editor` component.
## 2. Block Menu (Insert + Slash Commands)
### Hover "+" Button
A `+` button appears between blocks on hover. Clicking opens a dropdown menu with available block types:
- Text (inserts empty paragraph)
- Image (inserts imageBlock with empty attrs)
- File (inserts documentBlock with empty attrs)
- Embed (inserts embedBlock with empty attrs)
- Card Grid (inserts cardGridBlock with empty items)
Implementation: a Tiptap extension that uses a plugin to track cursor/hover position and renders a floating React element via a plugin view. The button appears in the left gutter between block-level nodes.
The dropdown is a simple list of block types. Clicking one inserts the corresponding node at the gap position using `editor.chain().focus().insertContentAt(pos, node).run()`.
### Slash Commands
Type `/` at the start of an empty paragraph to open the same block type picker as an inline dropdown. Uses Tiptap's `@tiptap/suggestion` pattern:
- Trigger: `/` character at start of empty paragraph
- Shows filtered list of block types (filters as user types, e.g. `/im` shows "Image")
- Enter or click to insert the selected block type, replacing the empty paragraph
- Escape to dismiss
Both the "+" menu and slash command share the same `BlockTypeMenu` React component — only the trigger and positioning differ.
### Block Types Available for Insertion
| Type | Tiptap node | Default attrs |
|-----------|-----------------|-------------------------------------------|
| Text | paragraph | empty paragraph |
| Image | imageBlock | `{ imageId: "", wide: false, caption: "" }`|
| File | documentBlock | `{ fileId: "", title: "" }` |
| Embed | embedBlock | `{ src: "" }` |
| Card Grid | cardGridBlock | `{ items: "[]" }` |
### Drag Reorder for All Blocks
All custom blocks already have `draggable: true`. No change needed for custom blocks.
For text blocks (paragraphs, headings, lists, etc.): Tiptap's default behavior allows cursor-based text editing, not dragging. Adding drag handles to standard text nodes would conflict with text selection. Leave text blocks non-draggable — users reorder text by cut/paste or by using the block menu to insert at the right position.
Custom blocks (image, document, embed, card-grid) remain draggable via their existing `data-drag-handle` attribute.
## 3. File Block Editor
Transform `DocumentBlock` from read-only display into a full editor, following the same pattern as `ImageBlock`.
### UI Layout
```
┌──────────────────────────────────────┐
│ Document │
│ ┌──────────────────────────────────┐ │
│ │ 📎 filename.pdf │ │
│ │ (or "Click to select file") │ │
│ └──────────────────────────────────┘ │
│ Title: [___________________________] │
│ [Pick] btn │
└──────────────────────────────────────┘
```
### FilePicker Component
New component `src/components/FilePicker.tsx`, modeled after `ImagePicker`:
- **Browse tab**: paginated list of files from a PocketBase `files` collection (or whichever collection stores documents). Each row shows filename + file size. Click to select.
- **Upload tab**: file input (no `accept` restriction — any file type) + upload button. Creates a record in the files collection, returns the new record ID.
Props: `{ onSelect: (fileId: string) => void, onClose: () => void }`
### DocumentBlock Changes
- Add local state: `showPicker: boolean`, `filename: string | null` (resolved from PB)
- On mount (when `fileId` is set): fetch file record from PB to get `filename` for display
- "Pick" button opens `FilePicker` modal
- Title field: inline `<input>` that calls `updateAttributes({ title: value })`
- Selecting a new file via picker calls `updateAttributes({ fileId: newId })`
### PocketBase Collection
Assumes a `files` collection exists with at least: `id`, `file` (file field), `filename` (string). If not, this needs to be created in PocketBase schema. The exact collection name and schema should be verified before implementation.
## 4. Card Grid Display Enhancement
`CardGridBlock` stays read-only. Enhance the view to show resolved page titles instead of just item count.
### Current Display
```
Card Grid
3 items
```
### New Display
```
Card Grid (3)
• Акварельная живопись
• Графический дизайн
• Скульптура и керамика
```
### Implementation
In `CardGridBlockView`:
1. Parse `items` from attrs (already done)
2. For each item, fetch `pb.collection("pages").getOne(item.page)` to get the page title
3. Show a bulleted list of page titles
4. Handle loading state (show "Loading..." while fetching)
5. Handle missing pages (show page ID if fetch fails)
Use `useEffect` + `useState` to batch-fetch all page titles on mount or when `items` attr changes. Cache results to avoid redundant fetches during re-renders.
## Files to Create
| File | Purpose |
|---------------------------------------|------------------------------------|
| `src/components/FilePicker.tsx` | File browse/upload modal |
| `src/components/FilePicker.css` | FilePicker styles |
| `src/components/BlockMenu.tsx` | Shared block type dropdown |
| `src/components/BlockMenu.css` | BlockMenu styles |
| `src/nodes/BlockInsertButton.tsx` | Hover "+" Tiptap plugin view |
| `src/extensions/SlashCommands.ts` | Slash command Tiptap extension |
## Files to Modify
| File | Changes |
|---------------------------------------|--------------------------------------------------|
| `src/lib/serialize.ts` | Use `@tiptap/markdown` for text block conversion |
| `src/components/Editor.tsx` | Add markdown toggle state, register new extensions |
| `src/components/Toolbar.tsx` | Add "MD" toggle button |
| `src/nodes/DocumentBlock.tsx` | Add interactive editing (picker, title input) |
| `src/nodes/CardGridBlock.tsx` | Fetch and display page titles |
| `src/nodes/index.ts` | Export new extensions |
| `src/components/Editor.css` | Styles for markdown textarea mode |
## Out of Scope
- Undo/redo system (deferred)
- Per-block markdown toggle (future enhancement)
- Card grid editing (add/remove/reorder items)
- Embed block editing
+289
View File
@@ -0,0 +1,289 @@
# Monorepo — Design
**Date:** 2026-04-24
**Status:** Approved, ready for plan
## Goal
Restructure the hudozka repo into an npm-workspaces monorepo. Consolidate the current Next.js app, the Vite-based `hudozka-writer`, and the Node-based `migrate-to-pocketbase` under `apps/`. Extract reusable UI primitives, hooks, and utilities into `packages/` scoped as `@hudozka/*`. Leave the PocketBase directory (`pb/`) untouched.
## Non-goals
- No behavioral changes to any app. This is a move + reorganization only.
- No adoption of `@hudozka/ui` or `@hudozka/hooks` inside `hudozka-writer`. Writer is relocated, not rewired.
- No Turborepo or other orchestration layer.
- No switch to built-output packages. Packages are consumed as TypeScript source.
- No publishing to npm. All packages are private workspace-only.
## Workspace layout
```
hudozka/
├─ apps/
│ ├─ hudozka/ # Next.js app (was repo root)
│ │ ├─ app/
│ │ ├─ src/
│ │ ├─ public/
│ │ ├─ Dockerfile # moved here from repo root
│ │ ├─ next.config.ts
│ │ ├─ tsconfig.json
│ │ └─ package.json
│ ├─ hudozka-writer/ # was modules/hudozka-writer
│ └─ migrate-to-pocketbase/ # was modules/migrate-to-pocketbase
├─ packages/
│ ├─ ui/ # @hudozka/ui
│ ├─ hooks/ # @hudozka/hooks
│ ├─ utils/ # @hudozka/utils
│ └─ text/ # @hudozka/text
├─ pb/ # PocketBase — untouched
├─ docs/
├─ .github/workflows/
├─ package.json # workspaces only
├─ tsconfig.base.json
├─ eslint.config.js
├─ vitest.config.ts # root web-app config stays; packages opt in
└─ mise.toml
```
App package names are **not** scoped (`hudozka`, `hudozka-writer`, `migrate-to-pocketbase`). Shared packages use the `@hudozka/*` scope.
## Package boundaries
All `packages/*` are consumed as TypeScript source. Each has:
```jsonc
{
"name": "@hudozka/<name>",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" }
}
```
No build step. Next, Vite, and `node --experimental-strip-types` handle TS source natively.
### `@hudozka/utils`
- **Source:** `array.ts`, `string.ts`, `url.ts`, `math.ts`, `image.ts`, `file.ts`, `date.ts` (+ co-located `.test.ts`)
- **Runtime deps:** `date-fns`
- **Peer deps:** none
- **Consumers:** `apps/hudozka`; `apps/migrate-to-pocketbase` if it uses any
### `@hudozka/text`
- **Source:** `text.ts`
- **Runtime deps:** `markdown-it`, `typograf`
- **Dev deps:** `@types/markdown-it`
- **Peer deps:** none
- **Consumers:** `apps/hudozka`
### `@hudozka/hooks`
- **Source:** `useToggle.ts`, `useLockBodyScroll.ts`, `useMediaQuery.ts`, `useMobile.ts`, `useReducedMotion.ts` (+ co-located `.test.ts`)
- **Peer deps:** `react` (>=19)
- **Consumers:** `@hudozka/ui`, `apps/hudozka`
Business-coupled hooks (`useDarkTheme`, `useAccessibility` — depend on the Valtio theme store) stay in `apps/hudozka/src/hooks/`.
### `@hudozka/ui`
- **Components:** `Box`, `Button`, `Panel`, `Title`, `Overlay` (+ `Transition` subcomponent) with co-located `.test.tsx` and CSS Modules
- **Runtime deps:** `classnames`
- **Peer deps:** `react` (>=19), `react-dom` (>=19)
- **Workspace deps:** `@hudozka/hooks`
- **Consumers:** `apps/hudozka`
`Picture` (imports `next/image`, `next/link`, `useDarkTheme`) and `ThemeColor` (bridges Valtio theme store) **stay in `apps/hudozka/src/components/`** to avoid leaking Next and business state into shared packages.
### Dependency graph
```
apps/hudozka ─┬─> @hudozka/ui ──> @hudozka/hooks
├─> @hudozka/hooks
├─> @hudozka/utils
└─> @hudozka/text
apps/hudozka-writer (no workspace deps — relocated only)
apps/migrate-to-pocketbase ──> @hudozka/utils (optional; only if used)
```
Acyclic. `ui → hooks` is the only inter-package edge.
## TypeScript
### Root `tsconfig.base.json`
Shared compiler options:
```json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"allowJs": true,
"incremental": true
}
}
```
### Per-workspace tsconfig
- `apps/hudozka/tsconfig.json` — extends base; adds `"plugins": [{ "name": "next" }]`; `"paths": { "@/*": ["src/*"] }`; includes `.next/types/**`.
- `apps/hudozka-writer/tsconfig.json` — moved as-is; updated to extend base.
- `apps/migrate-to-pocketbase/tsconfig.json` — new, minimal; extends base; Node-flavored `lib`.
- `packages/*/tsconfig.json` — extends base; `include: ["src/**/*"]`; no path aliases.
### Resolution
npm workspaces symlinks `@hudozka/*` into `node_modules`. `"exports"` points to source. No project references needed.
### Path aliases
`@/*` → `src/*` stays **only** in `apps/hudozka`. Packages use short relative imports. This matches the existing rule in the project's CLAUDE.md.
## ESLint
Single flat `eslint.config.js` at repo root. Changes vs. current:
- Drop `modules/` exclusion; add `apps/*/dist`, `apps/*/.next`, `public/static/` to global ignores.
- `eslint-config-next` scoped to `apps/hudozka/**` via a targeted config block.
- Style rules unchanged: no semicolons, double quotes, 4-space indent, always-multiline trailing commas, `eol-last`.
## Vitest
- `apps/hudozka/vitest.config.ts` and `apps/hudozka/vitest.setup.tsx` — moved as-is.
- `packages/ui/vitest.config.ts` and `packages/hooks/vitest.config.ts` — new, minimal; include jsdom/happy-dom setup for component tests.
- `packages/utils/vitest.config.ts` — new, no DOM setup needed.
- `packages/text/` — no tests currently; `test` script is a no-op runnable.
## Scripts
### Root `package.json`
```jsonc
{
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "npm run dev -w apps/hudozka",
"build": "npm run build -w apps/hudozka",
"start": "npm run start -w apps/hudozka",
"lint": "eslint .",
"format": "eslint --fix .",
"test": "npm test --workspaces --if-present"
}
}
```
- Root `dev`/`build`/`start` default to the web app (most common path).
- Other apps run via `-w`: `npm run dev -w apps/hudozka-writer`, `npm run migrate -w apps/migrate-to-pocketbase`.
- `--if-present` makes packages without a `test` script silent, not failing.
### Per-app/package scripts
- `apps/hudozka` — existing `dev`/`build`/`start`/`lint`/`format`/`test` relocated.
- `apps/hudozka-writer` — existing `dev`/`build`/`preview`.
- `apps/migrate-to-pocketbase` — existing `migrate`/`wipe`.
- `packages/*` — `test: vitest run` where applicable.
## Dev-dep hoisting
Shared tooling (`typescript`, `eslint`, `vitest`, `@testing-library/*`, `happy-dom`) lives only in the root `devDependencies`. Workspaces resolve them from the hoisted root `node_modules`. Apps declare their runtime deps in their own `dependencies`.
## CI / CD
### `.github/workflows/test-app.yml`
- `npm ci` at root, then `npm run lint` + `npm test` + `npm run build`.
- No per-workspace fan-out.
### `.github/workflows/build-app.yaml`
- `context:` stays `.` (repo root).
- `file:` becomes `./apps/hudozka/Dockerfile`.
### Dockerfile (at `apps/hudozka/Dockerfile`)
Multi-stage, workspace-aware:
1. **Install stage** — copy root `package.json`, `package-lock.json`, every `apps/*/package.json` + `packages/*/package.json`. Run `npm ci`.
2. **Build stage** — copy the rest of the monorepo. Run `npm run build -w apps/hudozka`.
3. **Runner** — copy `apps/hudozka/.next/standalone`, `apps/hudozka/.next/static`, `apps/hudozka/public` into the final image. Standalone output already bundles what it needs from `packages/*`.
Runtime path change: `./.next/standalone` → `./apps/hudozka/.next/standalone`.
## Migration sequencing
Each phase ends with a verification gate (`npm install` + lint + test + build all pass).
### Phase 1 — Workspace skeleton
- Add `workspaces: ["apps/*", "packages/*"]` to root `package.json`.
- Create empty `apps/`, `packages/` directories.
- Create `tsconfig.base.json`.
- **Gate:** `npm install` succeeds.
### Phase 2 — Relocate writer and migrate apps
- `git mv modules/hudozka-writer apps/hudozka-writer`
- `git mv modules/migrate-to-pocketbase apps/migrate-to-pocketbase`
- Update each app's tsconfig to extend `../../tsconfig.base.json` (writer already has a tsconfig; migrate gets a new minimal one).
- Remove empty `modules/` directory.
- **Gate:** `npm run dev -w apps/hudozka-writer` and `npm run migrate -w apps/migrate-to-pocketbase` work.
### Phase 3 — Relocate Next app to `apps/hudozka`
- `git mv` all top-level Next artifacts into `apps/hudozka/`: `app/`, `src/`, `public/`, `next.config.ts`, `next-env.d.ts`, `tsconfig.json`, `tsconfig.tsbuildinfo`, `vitest.config.ts`, `vitest.setup.tsx`, `eslint.config.js` (temporary; consolidated in phase 6), `Dockerfile`.
- Split root `package.json`: runtime deps + web-specific scripts move to `apps/hudozka/package.json`; root keeps only workspace config and shared dev deps.
- Update `apps/hudozka/tsconfig.json` to extend the root base.
- Update `.github/workflows/build-app.yaml` `file:` path.
- Update Dockerfile: context remains repo root; `COPY` paths now target `apps/hudozka/*`; adjust standalone output path.
- **Gate:** `dev`, `test`, `build` of `apps/hudozka` all pass.
### Phase 4 — Extract `@hudozka/utils`
- Create `packages/utils/` with `package.json`, `tsconfig.json`, `src/`, `src/index.ts`.
- `git mv` `array.ts`, `string.ts`, `url.ts`, `math.ts`, `image.ts`, `file.ts`, `date.ts` (+ tests) from `apps/hudozka/src/lib/` to `packages/utils/src/`.
- Move `date-fns` from `apps/hudozka/package.json` to `packages/utils/package.json`.
- Codemod imports in `apps/hudozka`: `@/lib/array` → `@hudozka/utils`, etc.
- **Gate:** lint + test + build pass.
### Phase 5 — Extract `@hudozka/text`, `@hudozka/hooks`, `@hudozka/ui`
Order: text → hooks → ui (ui depends on hooks).
- **`@hudozka/text`:** move `text.ts`, `markdown-it`, `typograf`, `@types/markdown-it`. Codemod imports.
- **`@hudozka/hooks`:** move `useToggle`, `useLockBodyScroll`, `useMediaQuery`, `useMobile`, `useReducedMotion` (+ tests). `useDarkTheme` and `useAccessibility` stay in `apps/hudozka`. Codemod imports.
- **`@hudozka/ui`:** move `Box`, `Button`, `Panel`, `Title`, `Overlay` (+ tests, CSS Modules). `Picture` and `ThemeColor` stay in `apps/hudozka/src/components/`. Move `classnames` dep. Codemod imports; Overlay's hook imports become `@hudozka/hooks`.
- **Gate after each package:** lint + test + build pass.
### Phase 6 — Consolidate ESLint & Vitest configs
- Move `eslint.config.js` from `apps/hudozka/` back to repo root; scope Next config to `apps/hudozka/**`.
- Add minimal `vitest.config.ts` (and setup where needed) to `packages/ui` and `packages/hooks`; `packages/utils` gets a non-DOM config.
- Update root `package.json` scripts as above.
- **Gate:** `npm run lint` at root lints everything; `npm test` runs all workspace tests.
### Phase 7 — Cleanup
- Delete orphaned files (stray `.DS_Store`, empty directories).
- Update root `README.md` with new layout (brief).
- Update root `CLAUDE.md` (Commands, Key Directories, Architecture paths).
- Spot-check `.github/workflows/*`, Dockerfile paths, `mise.toml`.
## Risks and mitigations
- **CSS Modules in `@hudozka/ui` consumed as source.** Next and Vite import `.module.css` natively; confirmed by current `src/ui/` usage. No build step needed. If a future consumer doesn't handle CSS, it must add that support itself.
- **Standalone Next output path change.** The Dockerfile runner copies a different path now. Explicit step in phase 3; guarded by the build gate.
- **Hoisted dev deps.** Different workspaces could theoretically disagree on major versions (e.g., `typescript`). Mitigation: all TS tooling lives only at the root; no workspace declares a conflicting version.
- **`experimental-strip-types` in `migrate-to-pocketbase`.** Still works when the script imports `@hudozka/utils` as source — Node strips types across the import boundary. Verified by the fact that utils has no non-TS-native syntax.
- **`eslint-config-next` globs.** Flat-config scoping must precisely target `apps/hudozka/**` to avoid leaking Next rules into packages. Phase 6 explicitly tests this by running `npm run lint` at root.
## Deferred
- Adopting `@hudozka/ui` or `@hudozka/hooks` in `apps/hudozka-writer`.
- Turborepo (revisit if build times or CI duration warrant it).
- Built-output packages + `.d.ts` emission (revisit if publishing or non-TS consumers appear).
- Further extraction from `apps/hudozka/src/` (e.g., splitting `remote/`, breaking up `components/`) — out of scope for this migration.