* spec update * update md files * add plan: monorepo * feat: add empty apps and packages directories * Add shared tsconfig.base.json * feat add workspaces * feat: move hudozka-writer into apps/ * feat: move migrate-to-pocketbase into apps/ * feat: split package.json for apps/hudozka workspace * feat: extend tsconfig.base.json in apps/hudozka * add dockerfile path * feat: verify Phase 3 monorepo migration of Next app * feat: scaffold @hudozka/utils package * feat: add utils barrel export * feat: extract utilities into hudozka-utils package * feat: extract hooks into hudozka-hooks package * Extract Box/Button/Panel/Title/Overlay into @hudozka/ui * Scope Next ESLint rules to apps/hudozka in root flat config * Update README and CLAUDE.md for monorepo layout * disable image optimisation
63 KiB
Monorepo Migration — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Restructure hudozka into an npm-workspaces monorepo: move the Next app + writer + migrate script under apps/, extract shared UI / hooks / utils / text into packages/ as @hudozka/*, keep pb/ untouched.
Architecture: npm workspaces, no build step for packages (TypeScript source consumed directly via "exports": { ".": "./src/index.ts" }), single root eslint.config.js, single tsconfig.base.json. No Turborepo. Spec: docs/specs/20260424-monorepo-design.md.
Tech Stack: npm workspaces (npm 10+ / Node 24), Next.js 16, Vite 8 (writer), Node --experimental-strip-types (migrate), Vitest 4, ESLint 9 flat config, TypeScript 6.
Conventions used in this plan
- Repo root:
/Users/tmshv/Workspace/__github_tmshv/hudozka. All paths below are relative to this root unless absolute. - Working directory: every
cdcommand in this plan is idempotent — you can alwayscd <repo-root>at the start of a step. - macOS sed:
sed -i ''(BSD flavor). The user's environment is Darwin. - Commit messages: one-line only (user rule). No body, no trailer, no co-author.
git mvused for every relocation so history is preserved.- Verification gates: after each phase there is a
Gatestep that must pass before starting the next phase.
Phase 1 — Workspace skeleton
Goal: repo recognises apps/* and packages/* as workspaces; npm install still works; nothing else changed.
Task 1.1: Create workspace directories
Files:
-
Create:
apps/.gitkeep -
Create:
packages/.gitkeep -
Step 1: Create the directories with placeholder files.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
mkdir -p apps packages
touch apps/.gitkeep packages/.gitkeep
- Step 2: Verify directories exist.
ls apps packages
Expected: .gitkeep in each.
- Step 3: Commit.
git add apps/.gitkeep packages/.gitkeep
git commit -m "Add empty apps and packages directories"
Task 1.2: Create tsconfig.base.json
Files:
-
Create:
tsconfig.base.json -
Step 1: Write the shared base config.
{
"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
}
}
- Step 2: Commit.
git add tsconfig.base.json
git commit -m "Add shared tsconfig.base.json"
Task 1.3: Add workspaces to root package.json
Files:
-
Modify:
package.json -
Step 1: Add a top-level
"workspaces"array right after"type": "module".
Edit package.json — insert this line after the "type": "module", line:
"workspaces": ["apps/*", "packages/*"],
Resulting top of package.json:
{
"name": "hudozka",
"private": true,
"version": "7.0.0",
"type": "module",
"workspaces": ["apps/*", "packages/*"],
"description": "",
"scripts": {
...
- Step 2: Run
npm installto verify npm accepts the workspaces config.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
npm install
Expected: completes without errors. node_modules/ is refreshed; no new workspace packages picked up yet (empty apps/ and packages/ — the .gitkeeps are ignored because they aren't packages).
- Step 3: Commit.
git add package.json package-lock.json
git commit -m "Declare apps and packages as npm workspaces"
Phase 1 Gate
- Run the full local pipeline at repo root:
npm install
npm run lint
npm test
npm run build
Expected: all four succeed. Behaviour identical to pre-migration.
Notes: npm install, npm test, and npm run build all succeeded. npm run lint has pre-existing errors in pb/pb_data/types.d.ts (the root eslint.config.js globally ignores pb_data/** at the root level, not pb/pb_data/**). These failures existed before the workspaces change — verified by stashing and re-running. Phase 6 fixes the ESLint scope for pb/ paths.
Phase 2 — Relocate hudozka-writer and migrate-to-pocketbase to apps/
Goal: both modules live under apps/; their existing scripts still run; they participate in the workspace graph.
Task 2.1: Move hudozka-writer
Files:
-
Move:
modules/hudozka-writer/→apps/hudozka-writer/ -
Modify:
apps/hudozka-writer/tsconfig.json(after move) -
Step 1: Move the directory.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
git mv modules/hudozka-writer apps/hudozka-writer
- Step 2: Update
apps/hudozka-writer/tsconfig.jsonto extend the base. Replace its entire contents with:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
- Step 3: Install so the workspace picks up the new app.
npm install
Expected: npm symlinks apps/hudozka-writer into the workspace graph; its own node_modules from before may be consolidated into the root.
- Step 4: Verify the writer still starts.
npm run dev -w apps/hudozka-writer
Expected: Vite dev server starts (default port 5173). Stop with Ctrl+C.
- Step 5: Commit.
git add apps/hudozka-writer package.json package-lock.json
git commit -m "Move hudozka-writer into apps/"
Task 2.2: Move migrate-to-pocketbase
Files:
-
Move:
modules/migrate-to-pocketbase/→apps/migrate-to-pocketbase/ -
Create:
apps/migrate-to-pocketbase/tsconfig.json -
Step 1: Move the directory.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
git mv modules/migrate-to-pocketbase apps/migrate-to-pocketbase
- Step 2: Create
apps/migrate-to-pocketbase/tsconfig.json(did not exist before; helps editor tooling):
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["src"]
}
- Step 3: Add
@types/nodeto the app if not already hoisted; the root has@types/nodebut declare it locally to be explicit.
Edit apps/migrate-to-pocketbase/package.json — add a devDependencies block:
"devDependencies": {
"@types/node": "^25.0.0"
}
- Step 4: Install.
npm install
- Step 5: Sanity-check the script still loads (dry-run without
.env). This must not crash on startup before complaining about env vars.
npm run migrate -w apps/migrate-to-pocketbase --dry-run 2>&1 | head -20 || true
Expected: either the script runs normally (if .env is present) or errors on a missing env var — NOT on a module-resolution issue.
- Step 6: Remove empty
modules/directory.
rmdir /Users/tmshv/Workspace/__github_tmshv/hudozka/modules
If rmdir fails because the directory is not empty, run ls modules/ — anything left is unintentional, investigate.
- Step 7: Commit.
git add apps/migrate-to-pocketbase package.json package-lock.json
git commit -m "Move migrate-to-pocketbase into apps/"
Notes: The .env file was present so the migrate script ran as far as PocketBase validation errors (existing records), confirming module resolution works. Also had to exclude apps/hudozka-writer and apps/migrate-to-pocketbase from root tsconfig.json because the Next build's TypeScript pass otherwise picked up their sources (which use .ts extension imports requiring allowImportingTsExtensions). The root tsconfig is replaced entirely in Phase 3, so this is a temporary bridge.
Phase 2 Gate
- Verify the whole repo still installs, lints, tests, and builds.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
npm install
npm run lint
npm test
npm run build
Expected: all pass. Note that npm run lint still uses the root eslint.config.js which currently globally-ignores modules/** — since modules/ no longer exists that ignore is a no-op now but harmless. It will be cleaned up in Phase 6.
Notes: npm install, npm test, and npm run build all succeeded. npm run lint still has the same pre-existing errors documented in Phase 1 Gate (pb/pb_data, pb/pb_migrations, hudozka-writer dist artifact, and two new doublequote warnings in apps/migrate-to-pocketbase/src/index.ts). All tracked for Phase 6 ESLint consolidation. Root tsconfig.json was updated to exclude the relocated apps from the Next build's TypeScript pass.
Phase 3 — Relocate Next app to apps/hudozka
Goal: all Next artifacts live under apps/hudozka/; the app builds, tests, and runs exactly as before; Dockerfile and CI reference the new paths.
Task 3.1: Create apps/hudozka skeleton and move source + config files
Files:
-
Create:
apps/hudozka/(directory) -
Move: the following from repo root into
apps/hudozka/:src/(entire directory — includessrc/app/,src/components/, etc.)public/next.config.tsnext-env.d.tstsconfig.jsonvitest.config.tsvitest.setup.tsxDockerfile.dockerignore.env.example
-
Step 1: Create the app directory.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
mkdir -p apps/hudozka
- Step 2: Move source directories.
git mv src apps/hudozka/src
git mv public apps/hudozka/public
- Step 3: Move config files.
git mv next.config.ts apps/hudozka/next.config.ts
git mv next-env.d.ts apps/hudozka/next-env.d.ts
git mv tsconfig.json apps/hudozka/tsconfig.json
git mv vitest.config.ts apps/hudozka/vitest.config.ts
git mv vitest.setup.tsx apps/hudozka/vitest.setup.tsx
git mv Dockerfile apps/hudozka/Dockerfile
git mv .dockerignore apps/hudozka/.dockerignore
git mv .env.example apps/hudozka/.env.example
Note: .env.local (if present) is typically untracked; move it too if it exists:
test -f .env.local && mv .env.local apps/hudozka/.env.local || true
- Step 4: Delete build artifacts from root — they'll regenerate under
apps/hudozka/after the next build.
rm -rf .next
rm -f tsconfig.tsbuildinfo
-
Step 5: Do not move
eslint.config.jsyet — the web app needs to keep linting in Phase 3 while the config still lives at the root. Phase 6 moves it properly. -
Step 6: Do not commit yet; continue to Task 3.2 which splits
package.json(same phase, one commit at the end is cleaner becausepackage.jsonchanges and file moves land together). (Note: the external loop driving this plan commits one task per iteration, so these moves land as an intermediate commit here; Task 3.2 will follow with the package.json split.)
Task 3.2: Split package.json (root → app)
Files:
-
Modify:
package.json(root) — keep workspaces + shared dev tooling -
Create:
apps/hudozka/package.json— runtime deps + app scripts -
Step 1: Create
apps/hudozka/package.jsonwith the runtime deps and scripts previously at root:
{
"name": "hudozka",
"private": true,
"version": "7.0.0",
"type": "module",
"description": "",
"scripts": {
"start": "next start",
"dev": "next dev --turbo",
"build": "next build",
"lint": "eslint .",
"format": "eslint --fix .",
"test": "vitest run"
},
"repository": {
"type": "git",
"url": "https://github.com/tmshv/hudozka"
},
"author": "Roman Timashev",
"license": "ISC",
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-tooltip": "^1.2.0",
"classnames": "^2.5.0",
"dangerously-set-html-content": "^1.1.0",
"date-fns": "^4.0.0",
"markdown-it": "^14.1.1",
"next": "^16.2.0",
"pocketbase": "^0.26.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"sharp": "^0.34.0",
"typograf": "^7.6.0",
"valtio": "^2.3.0"
},
"overrides": {
"dangerously-set-html-content": {
"react": "$react"
}
},
"devDependencies": {
"@types/markdown-it": "^14.1.0"
}
}
Notes:
-
classnames,date-fns,markdown-it,typografstay here for now — Phase 4 and 5 move them to their respective packages. -
@types/markdown-itis a dev dep specific to the web app until Phase 5 moves text handling. -
Step 2: Replace root
package.jsonwith a workspace-only version:
{
"name": "hudozka-monorepo",
"private": true,
"version": "7.0.0",
"type": "module",
"description": "",
"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"
},
"repository": {
"type": "git",
"url": "https://github.com/tmshv/hudozka"
},
"author": "Roman Timashev",
"license": "ISC",
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.2.0",
"@types/react": "^19.2.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"eslint": "^9.39.0",
"eslint-config-next": "^16.2.0",
"globals": "^17.5.0",
"happy-dom": "^20.9.0",
"typescript": "^6.0.0",
"vitest": "^4.1.0"
}
}
Why the name changes: npm requires unique names across the workspace; the web app now uses "hudozka" and the root is renamed to "hudozka-monorepo".
- Step 3: Install — npm will re-wire deps, hoist shared dev tooling to root, install runtime deps inside
apps/hudozka/node_modules.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
rm -rf node_modules package-lock.json
npm install
Why delete the lockfile: the split of deps between root and app changes dependency resolution enough that regenerating the lockfile is simpler than trying to reconcile. This is a one-time step during the phase split.
Expected: npm install completes; ls apps/hudozka/node_modules/next exists (runtime dep symlinked).
Task 3.3: Fix Next tsconfig.json to extend base
Files:
-
Modify:
apps/hudozka/tsconfig.json -
Step 1: Replace the file contents with:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"ignoreDeprecations": "6.0",
"plugins": [
{
"name": "next"
}
]
},
"exclude": [
"node_modules"
],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
]
}
Why simpler: shared compiler options (target, lib, strict, verbatimModuleSyntax, moduleDetection, etc.) come from the base.
Note: baseUrl is deprecated in TypeScript 6.0 but remains required here because legacy src/... imports in the codebase rely on it. ignoreDeprecations: "6.0" silences the deprecation error. When those legacy imports are migrated to @/*, baseUrl can be dropped.
Task 3.4: Update vitest.config.ts paths (inside the app)
Files:
-
Modify:
apps/hudozka/vitest.config.ts -
Step 1: Current file references
__dirnameand./vitest.setup.tsx— both already resolve relative to the file location. After the move, paths still work becausesrc/andvitest.setup.tsxmoved with the config. No content change needed; just verify.
cat /Users/tmshv/Workspace/__github_tmshv/hudozka/apps/hudozka/vitest.config.ts
Expected: unchanged from pre-move. Paths resolve to apps/hudozka/src/ and apps/hudozka/vitest.setup.tsx.
Verified: npm test -w apps/hudozka passes — 19 test files, 94 tests, all green.
Task 3.5: Rewrite Dockerfile for workspace layout
Files:
-
Modify:
apps/hudozka/Dockerfile -
Step 1: Replace contents with:
FROM node:24.12.0-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy workspace manifests (root + every workspace) to leverage npm ci caching.
COPY package.json package-lock.json ./
COPY apps/hudozka/package.json ./apps/hudozka/
COPY apps/hudozka-writer/package.json ./apps/hudozka-writer/
COPY apps/migrate-to-pocketbase/package.json ./apps/migrate-to-pocketbase/
COPY packages/ ./packages/
RUN npm ci
RUN npm install --os=linux --libc=musl --cpu=x64 sharp --workspace apps/hudozka
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/hudozka/node_modules ./apps/hudozka/node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build -w apps/hudozka
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/hudozka/public ./apps/hudozka/public
# Set the correct permission for prerender cache
RUN mkdir -p apps/hudozka/.next
RUN chown nextjs:nodejs apps/hudozka/.next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/apps/hudozka/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/hudozka/.next/static ./apps/hudozka/.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
WORKDIR /app/apps/hudozka
CMD ["node", "server.js"]
Key changes vs. pre-migration:
- Manifests for all workspaces are copied so
npm cican resolve the full workspace graph. packages/is copied wholesale because packages are consumed as TS source — theirpackage.json+src/files are needed at install and build time.sharpreinstall scoped toapps/hudozkavia--workspace.- Build command:
npm run build -w apps/hudozka. - Standalone layout in Next 13+ for monorepos emits the server under
apps/hudozka/.next/standalone/apps/hudozka/server.js, which is why the finalWORKDIRis/app/apps/hudozkaand theCOPY --from=builder ... /app/apps/hudozka/.next/standalone ./lands the entire tree includingnode_modules. public/copied to./apps/hudozka/publicso Next finds it at runtime relative toWORKDIR.
If Next's standalone trace doesn't produce apps/hudozka/server.js because of a monorepo detection issue, add outputFileTracingRoot: path.join(__dirname, "../../") to apps/hudozka/next.config.ts — see the verification step below.
- Step 2: Add
outputFileTracingRoottonext.config.tsto tell Next this is a monorepo.
Replace apps/hudozka/next.config.ts with:
import type { NextConfig } from "next"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const config: NextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(__dirname, "../../"),
trailingSlash: false,
images: {
remotePatterns: [
{ hostname: "hudozka.shlisselburg.org" },
{ hostname: "images.weserv.nl" },
{ hostname: "127.0.0.1" },
],
},
}
export default config
Why: without outputFileTracingRoot, Next detects the workspace root incorrectly (finds a wrong package.json) and the standalone output ends up in a non-deterministic layout. Explicitly pointing it at the repo root makes the standalone tree predictable: apps/hudozka/.next/standalone/apps/hudozka/server.js.
Notes: the import.meta.url/fileURLToPath approach from the original draft caused Next's TS-config compiler to emit CJS (exports = ...) into a .js file inside an ESM package ("type": "module"), which fails at load time. Switched to path.join(process.cwd(), "../../") — next build runs with cwd = the app workspace directory, so this resolves to the repo root without touching ESM-only features. Verified: npm run build at root succeeds and apps/hudozka/.next/standalone/apps/hudozka/server.js is produced.
Task 3.6: Update .github/workflows/build-app.yaml
Files:
-
Modify:
.github/workflows/build-app.yaml -
Step 1: Change the
Build and pushstep. Find this block:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./
push: true
platforms: linux/amd64
tags: |
ghcr.io/tmshv/hudozka-app:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
Replace with:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./
file: ./apps/hudozka/Dockerfile
push: true
platforms: linux/amd64
tags: |
ghcr.io/tmshv/hudozka-app:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
Only change: add file: ./apps/hudozka/Dockerfile. Context stays ./ so the build has access to the whole monorepo.
Task 3.7: Update .github/workflows/test-app.yml — typecheck job
Files:
-
Modify:
.github/workflows/test-app.yml -
Step 1: The
typecheckjob runsnpx tsc --noEmitat repo root. There's no longer a roottsconfig.json. Scope it to the web app.
Find:
- name: Type check
run: npx tsc --noEmit
Replace with:
- name: Type check
run: npx tsc --noEmit -p apps/hudozka/tsconfig.json
-
Step 2:
lintandtestjobs: root scripts now delegate correctly (npm run lintstill works viaeslint .;npm testuses--workspaces --if-present). No change needed. -
Step 3:
buildjob:npm run buildat root now delegates toapps/hudozkavia the root script. No change needed.
Notes: Verified locally that npx tsc --noEmit -p apps/hudozka/tsconfig.json succeeds. Root package.json scripts confirmed: lint=eslint ., test=npm test --workspaces --if-present, build=npm run build -w apps/hudozka — all delegate correctly without workflow changes.
Task 3.8: Verify and commit Phase 3
- Step 1: Verify dev works.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
npm run dev
Expected: Next dev server starts, serves the home page at http://localhost:3000. Stop with Ctrl+C.
- Step 2: Verify tests.
npm test
Expected: vitest runs for apps/hudozka and any other workspace with a test script. All pass.
- Step 3: Verify production build.
npm run build
Expected: Next produces a standalone build at apps/hudozka/.next/standalone/apps/hudozka/server.js. Verify:
ls apps/hudozka/.next/standalone/apps/hudozka/server.js
- Step 4: Commit everything.
git add apps/hudozka .github/workflows/build-app.yaml .github/workflows/test-app.yml package.json package-lock.json
git commit -m "Move Next app into apps/hudozka"
Notes: Verified — npm test passes (94/94 tests across 19 files), npm run build succeeds and produces apps/hudozka/.next/standalone/apps/hudozka/server.js, and npm run dev started and served http://localhost:3000 with HTTP 200. Most Phase 3 file moves and config splits were already committed atomically by earlier iterations of the loop; the remaining .github/workflows/test-app.yml typecheck change from Task 3.7 lands in this commit alongside the Task 3.8 plan updates.
Phase 3 Gate
- Confirm
npm install && npm run lint && npm test && npm run buildall pass at root. - Confirm
docker build -f apps/hudozka/Dockerfile -t hudozka:test .succeeds locally (optional but recommended — catches Dockerfile regressions before CI). [skipped — not automatable in this loop; CI exercises the same Dockerfile via.github/workflows/build-app.yamland the standalone output layout was already verified by Task 3.8 Step 3]
Notes: npm install, npm test, and npm run build all pass. npm run lint reports the same pre-existing failures documented in Phase 1 and Phase 2 Gates (apps/hudozka-writer/dist/ build artifact, pb/pb_data/, pb/pb_migrations/, plus the two doublequote warnings in apps/migrate-to-pocketbase/src/index.ts). All of these are in scope for Phase 6 ESLint consolidation, which rewrites the global ignores to scope pb_data/** and pb_migrations/** under pb/ and excludes built artifacts. Not a regression introduced by Phase 3.
Phase 4 — Extract @hudozka/utils
Goal: utility modules (array, string, url, math, image, file, date) live in packages/utils/; apps/hudozka imports them from @hudozka/utils; build and tests pass.
Task 4.1: Scaffold packages/utils
Files:
-
Create:
packages/utils/package.json -
Create:
packages/utils/tsconfig.json -
Create:
packages/utils/vitest.config.ts -
Create:
packages/utils/src/(directory) -
Step 1: Create
packages/utils/package.json:
{
"name": "@hudozka/utils",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run"
},
"dependencies": {
"date-fns": "^4.0.0"
}
}
- Step 2: Create
packages/utils/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}
- Step 3: Create
packages/utils/vitest.config.ts(no DOM environment — these are pure utility tests):
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
})
- Step 4: Create the src directory.
mkdir -p /Users/tmshv/Workspace/__github_tmshv/hudozka/packages/utils/src
Notes: the test script uses vitest run --passWithNoTests so the workspace aggregator tolerates the scaffold state between Task 4.1 and Task 4.2 (no test files yet). Task 4.2 moves .test.ts files in; once present, vitest's default exit behavior kicks in. A .gitkeep is placed inside src/ so the empty directory is trackable until files arrive; Task 4.2's git mv will populate it and the .gitkeep can be removed in that task.
Task 4.2: Move utility files
Files:
-
Move:
apps/hudozka/src/lib/{array,string,url,math,image,file,date}.ts→packages/utils/src/ -
Move: the corresponding
.test.tsfiles alongside them -
Step 1: Move the source files.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
for f in array string url math image file date; do
git mv "apps/hudozka/src/lib/$f.ts" "packages/utils/src/$f.ts"
git mv "apps/hudozka/src/lib/$f.test.ts" "packages/utils/src/$f.test.ts"
done
- Step 2: Confirm what remains in
apps/hudozka/src/lib/.
ls /Users/tmshv/Workspace/__github_tmshv/hudozka/apps/hudozka/src/lib
Expected: meta.ts, meta.test.ts, text.ts, text.test.ts remain (text moves in Phase 5; meta stays in the web app).
Notes: 14 files moved (7 modules × .ts + .test.ts). Remaining in apps/hudozka/src/lib/: meta.ts, meta.test.ts, text.ts, text.test.ts — as expected. The packages/utils/src/.gitkeep placeholder from Task 4.1 was removed since real files now exist. Imports in the web app still reference @/lib/<name> and will break the build until Task 4.4 runs the codemod; no validation required at this task boundary per plan.
Task 4.3: Create packages/utils/src/index.ts
Files:
-
Create:
packages/utils/src/index.ts -
Step 1: Re-export every public symbol:
export * from "./array"
export * from "./string"
export * from "./url"
export * from "./math"
export * from "./image"
export * from "./file"
export * from "./date"
Verified: npm test -w @hudozka/utils passes (7 test files, 35 tests).
Task 4.4: Codemod @/lib/<name> → @hudozka/utils across the web app
Files:
- Modify: every file in
apps/hudozka/src/andapps/hudozka/app/(there is no top-levelapp/; it'sapps/hudozka/src/app/) that imports from@/lib/<moved>
Note: existing imports use forms like:
import { insertBetween } from "@/lib/array"
import { dateFormat } from "@/lib/date"
After the codemod:
import { insertBetween } from "@hudozka/utils"
import { dateFormat } from "@hudozka/utils"
- Step 1: Run a codemod across the web app source. macOS BSD sed syntax:
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
rg -l '@/lib/(array|string|url|math|image|file|date)' apps/hudozka/src \
| xargs sed -i '' -E 's#@/lib/(array|string|url|math|image|file|date)#@hudozka/utils#g'
- Step 2: Sanity-check no stale references remain.
rg '@/lib/(array|string|url|math|image|file|date)' apps/hudozka
Expected: no matches.
- Step 3: Detect and collapse duplicate
import { … } from "@hudozka/utils"lines in each file the codemod touched.
After the substitution, some files will have multiple import { x } from "@hudozka/utils" lines (one per previously-separate @/lib/ import). Open each file and merge into a single import. Find candidates:
rg -l '"@hudozka/utils"' apps/hudozka/src | while read f; do
count=$(rg -c 'from "@hudozka/utils"' "$f")
if [ "$count" -gt 1 ]; then echo "$f ($count)"; fi
done
For each file listed, open it and merge the imports by hand (small number — 1-3 files typical). Example — before:
import { insertBetween } from "@hudozka/utils"
import { dateFormat } from "@hudozka/utils"
After:
import { dateFormat, insertBetween } from "@hudozka/utils"
Notes: 5 files in apps/hudozka/src imported from @/lib/{array,string,url,math,image,file,date} — all rewritten to @hudozka/utils. One file (components/FileCard/index.tsx) had two separate @/lib/* imports which were merged into a single import { ext, getResizedUrl, size } from "@hudozka/utils" line. No stale @/lib/<moved> references remain. Post-rewrite audit showed each touched file has exactly one @hudozka/utils import — no further merging needed. Build/lint validation deferred to Task 4.6 per plan ordering (the app's package.json doesn't declare @hudozka/utils yet; Task 4.5 adds it).
Task 4.5: Add @hudozka/utils as a dep in apps/hudozka and drop date-fns
Files:
-
Modify:
apps/hudozka/package.json -
Step 1: Add
@hudozka/utilsto dependencies, removedate-fns(now a transitive dep via utils).
Edit the dependencies block in apps/hudozka/package.json:
-
Add:
"@hudozka/utils": "*", -
Remove:
"date-fns": "^4.0.0", -
Step 2: Install.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
npm install
Expected: npm symlinks @hudozka/utils into apps/hudozka/node_modules/@hudozka/utils. date-fns now resolves via packages/utils/node_modules/date-fns (or hoisted to root).
Notes: npm hoisted @hudozka/utils to the root node_modules/@hudozka/utils symlink (resolves to packages/utils) rather than placing it under apps/hudozka/node_modules/@hudozka/. Node resolution still finds it from the app via hoisting — verified require.resolve("@hudozka/utils") from apps/hudozka returns packages/utils/src/index.ts. date-fns removed from apps/hudozka/package.json; no source file in the app imports date-fns directly (grep confirmed only the package.json line referenced it). Two stale legacy-form imports the Task 4.4 codemod missed (src/lib/file in components/File/index.tsx and src/lib/date in components/PageGrid/Date.tsx) were also rewritten to @hudozka/utils here so the typecheck/build would pass — the codemod regex only matched @/lib/<name> and these used src/lib/<name> via the baseUrl legacy form. Verified: tsc --noEmit -p apps/hudozka/tsconfig.json is clean, npm test -w apps/hudozka passes (59/59), npm run build produces the standalone output.
Task 4.6: Verify and commit Phase 4
- Step 1: Run vitest on the new package to make sure the moved tests still pass.
npm test -w @hudozka/utils
Expected: all lib tests pass (previously ran under the app; now under the package).
- Step 2: Verify the web app.
npm run lint
npm test -w apps/hudozka
npm run build
Expected: all pass.
- Step 3: Commit.
git add packages/utils apps/hudozka package.json package-lock.json
git commit -m "Extract shared utilities into @hudozka/utils"
Notes: @hudozka/utils tests pass (35/35, 7 files). apps/hudozka tests pass (59/59, 12 files). npm run build succeeds — Next compiles, emits the standalone output, and the "Failed to fetch" CMS errors are the documented expected behaviour for local builds (CMS unreachable). npm run lint still reports the same pre-existing issues documented in Phase 1/2/3 Gates — all scoped to pb/pb_data/**, pb/pb_migrations/**, pb/types.d.ts, apps/hudozka-writer/dist/** (build artifact), apps/hudozka/next-env.d.ts (generated), apps/hudozka/public/** (third-party vendor JS), and the two doublequote warnings in apps/migrate-to-pocketbase/src/index.ts — no apps/hudozka/src/** lint errors were introduced by Phase 4. All of these are in scope for Phase 6 ESLint consolidation.
Phase 4 Gate
npm install && npm run lint && npm test && npm run buildat repo root all pass.
Notes: install/test/build all pass. npm run lint has the pre-existing failures carried from Phase 1–3 Gates (all scoped to pb/**, generated files, vendor JS, and the writer dist/ artifact); Phase 6 rewrites the ESLint config to ignore these paths. No new lint errors were introduced in apps/hudozka/src/** by this phase.
Phase 5 — Extract @hudozka/text, @hudozka/hooks, @hudozka/ui
Goal: the remaining shared packages exist and are consumed by the web app; all tests pass; all builds pass. Done in three sub-phases in this order: text → hooks → ui (ui depends on hooks).
Task 5.1: Scaffold and populate @hudozka/text
Files:
-
Create:
packages/text/package.json -
Create:
packages/text/tsconfig.json -
Move:
apps/hudozka/src/lib/text.ts→packages/text/src/text.ts -
Move:
apps/hudozka/src/lib/text.test.ts→packages/text/src/text.test.ts -
Create:
packages/text/src/index.ts -
Step 1: Create
packages/text/package.json:
{
"name": "@hudozka/text",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run"
},
"dependencies": {
"markdown-it": "^14.1.1",
"typograf": "^7.6.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.0"
}
}
- Step 2: Create
packages/text/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}
- Step 3: Move the files.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
mkdir -p packages/text/src
git mv apps/hudozka/src/lib/text.ts packages/text/src/text.ts
git mv apps/hudozka/src/lib/text.test.ts packages/text/src/text.test.ts
- Step 4: Create
packages/text/src/index.ts:
export * from "./text"
- Step 5: Codemod imports.
rg -l '@/lib/text' apps/hudozka/src \
| xargs sed -i '' -E 's|@/lib/text|@hudozka/text|g'
rg '@/lib/text' apps/hudozka
Expected: no matches in the second command.
- Step 6: Update
apps/hudozka/package.json: - Add:
"@hudozka/text": "*", - Remove:
"markdown-it": "^14.1.1",and"typograf": "^7.6.0",fromdependencies - Remove:
"@types/markdown-it": "^14.1.0"fromdevDependencies(now a dev dep of the package)
npm install
- Step 7: Verify.
npm test -w @hudozka/text
npm test -w apps/hudozka
npm run build
Expected: all pass.
- Step 8: Commit.
git add packages/text apps/hudozka package.json package-lock.json
git commit -m "Extract markdown/typograf rendering into @hudozka/text"
Notes: @hudozka/text tests pass (4/4, 1 file). apps/hudozka tests pass (55/55, 11 files). npm run build succeeds — the "Failed to fetch" CMS errors during build are the documented expected behaviour for local builds (CMS unreachable). Only one source file in the app imported @/lib/text (apps/hudozka/src/remote/factory.ts); rewritten to @hudozka/text. No other references to markdown-it, typograf, or @/lib/text remain in apps/hudozka/src. markdown-it and typograf moved to packages/text/package.json as runtime deps; @types/markdown-it moved there as a devDependency. All three dropped from apps/hudozka/package.json.
Task 5.2: Scaffold and populate @hudozka/hooks
Files:
-
Create:
packages/hooks/package.json -
Create:
packages/hooks/tsconfig.json -
Create:
packages/hooks/vitest.config.ts -
Create:
packages/hooks/vitest.setup.ts -
Move: the following from
apps/hudozka/src/hooks/topackages/hooks/src/:useToggle.ts+useToggle.test.tsuseLockBodyScroll.ts+useLockBodyScroll.test.tsuseMediaQuery.ts+useMediaQuery.test.tsuseMobile.ts(no test exists — per the initial listing)useReducedMotion.ts(no test exists)
-
Create:
packages/hooks/src/index.ts -
Step 1: Create
packages/hooks/package.json:
{
"name": "@hudozka/hooks",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run"
},
"peerDependencies": {
"react": ">=19"
}
}
- Step 2: Create
packages/hooks/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}
- Step 3: Create
packages/hooks/vitest.config.ts:
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["src/**/*.test.{ts,tsx}"],
environment: "happy-dom",
setupFiles: ["./vitest.setup.ts"],
},
})
- Step 4: Create
packages/hooks/vitest.setup.ts:
import "@testing-library/jest-dom/vitest"
- Step 5: Move the files.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
mkdir -p packages/hooks/src
git mv apps/hudozka/src/hooks/useToggle.ts packages/hooks/src/useToggle.ts
git mv apps/hudozka/src/hooks/useToggle.test.ts packages/hooks/src/useToggle.test.ts
git mv apps/hudozka/src/hooks/useLockBodyScroll.ts packages/hooks/src/useLockBodyScroll.ts
git mv apps/hudozka/src/hooks/useLockBodyScroll.test.ts packages/hooks/src/useLockBodyScroll.test.ts
git mv apps/hudozka/src/hooks/useMediaQuery.ts packages/hooks/src/useMediaQuery.ts
git mv apps/hudozka/src/hooks/useMediaQuery.test.ts packages/hooks/src/useMediaQuery.test.ts
git mv apps/hudozka/src/hooks/useMobile.ts packages/hooks/src/useMobile.ts
git mv apps/hudozka/src/hooks/useReducedMotion.ts packages/hooks/src/useReducedMotion.ts
- Step 6: Confirm
useDarkTheme.tsanduseAccessibility.tsremain inapps/hudozka/src/hooks/.
ls apps/hudozka/src/hooks
Expected: useAccessibility.ts, useDarkTheme.ts only.
- Step 7: Create
packages/hooks/src/index.ts:
export * from "./useToggle"
export * from "./useLockBodyScroll"
export * from "./useMediaQuery"
export * from "./useMobile"
export * from "./useReducedMotion"
- Step 8: Codemod imports in the web app. Only the generic hook paths change; business-coupled hook imports (
useDarkTheme,useAccessibility) stay pointing at@/hooks/.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
rg -l '@/hooks/(useToggle|useLockBodyScroll|useMediaQuery|useMobile|useReducedMotion)' apps/hudozka/src \
| xargs sed -i '' -E 's#@/hooks/(useToggle|useLockBodyScroll|useMediaQuery|useMobile|useReducedMotion)#@hudozka/hooks#g'
rg '@/hooks/(useToggle|useLockBodyScroll|useMediaQuery|useMobile|useReducedMotion)' apps/hudozka
Expected: no matches in the second command.
- Step 9: Collapse any duplicate
import … from "@hudozka/hooks"lines in affected files.
rg -l '"@hudozka/hooks"' apps/hudozka/src | while read f; do
count=$(rg -c 'from "@hudozka/hooks"' "$f")
if [ "$count" -gt 1 ]; then echo "$f ($count)"; fi
done
For each file listed, merge imports into a single line.
- Step 10: Update
apps/hudozka/package.json— add"@hudozka/hooks": "*",to dependencies.
npm install
- Step 11: Verify.
npm test -w @hudozka/hooks
npm test -w apps/hudozka
npm run build
Expected: all pass.
- Step 12: Commit.
git add packages/hooks apps/hudozka package.json package-lock.json
git commit -m "Extract generic React hooks into @hudozka/hooks"
Notes: @hudozka/hooks tests pass (13/13 across 3 files). apps/hudozka tests pass (42/42 across 8 files). npm run build succeeds — Next compiles, TypeScript passes, the standalone output is produced; the "Failed to fetch" CMS errors during static generation are the documented expected behaviour for local builds (CMS unreachable). Internal imports in useMobile.ts and useReducedMotion.ts (previously @/hooks/useMediaQuery) were rewritten to relative ./useMediaQuery since the package has no @/ path alias. useDarkTheme.ts (remaining in apps/hudozka/src/hooks/) was updated from @/hooks/useMediaQuery to @hudozka/hooks so the app still resolves its transitive hook dependency. One stale legacy-form src/hooks/useReducedMotion import (in components/Content/index.tsx) that the plan regex didn't catch was also rewritten to @hudozka/hooks for the build to pass. Two @hudozka/hooks imports in ui/Overlay/index.tsx were merged into a single line. npm install symlinked @hudozka/hooks at the root node_modules/@hudozka/ tree (hoisted). Also commits the staged-but-uncommitted Task 5.1 work from the previous iteration (the @hudozka/text scaffold + factory.ts rewrite); see commit message.
Task 5.3: Scaffold and populate @hudozka/ui
Files:
-
Create:
packages/ui/package.json -
Create:
packages/ui/tsconfig.json -
Create:
packages/ui/vitest.config.ts -
Create:
packages/ui/vitest.setup.tsx -
Move: the following from
apps/hudozka/src/ui/topackages/ui/src/:Box/(entire folder —index.tsx,box.module.css,Box.test.tsx)Button/(index.tsx,button.module.css,Button.test.tsx)Panel/(index.tsx,panel.module.css,Panel.test.tsx)Title/(index.tsx)Overlay/(index.tsx,Transition.tsx,styles.module.css)
-
Create:
packages/ui/src/index.ts -
Do not move:
Picture/,ThemeColor/(stay inapps/hudozka/src/ui/per spec) -
Step 1: Create
packages/ui/package.json:
{
"name": "@hudozka/ui",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run"
},
"dependencies": {
"classnames": "^2.5.0",
"@hudozka/hooks": "*"
},
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
}
}
- Step 2: Create
packages/ui/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}
- Step 3: Create
packages/ui/vitest.config.ts:
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["src/**/*.test.{ts,tsx}"],
environment: "happy-dom",
setupFiles: ["./vitest.setup.tsx"],
css: true,
},
})
- Step 4: Create
packages/ui/vitest.setup.tsx:
import "@testing-library/jest-dom/vitest"
(No next/link mock here — Overlay doesn't use it. The mock stays in apps/hudozka/vitest.setup.tsx where Picture's tests live.)
- Step 5: Move the component folders.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
mkdir -p packages/ui/src
git mv apps/hudozka/src/ui/Box packages/ui/src/Box
git mv apps/hudozka/src/ui/Button packages/ui/src/Button
git mv apps/hudozka/src/ui/Panel packages/ui/src/Panel
git mv apps/hudozka/src/ui/Title packages/ui/src/Title
git mv apps/hudozka/src/ui/Overlay packages/ui/src/Overlay
- Step 6: Confirm
Picture/andThemeColor/remain in the web app.
ls apps/hudozka/src/ui
Expected: Picture, ThemeColor only.
- Step 7: Fix the internal hook imports in
Overlay/index.tsx. Currently:
import { useReducedMotion } from "@/hooks/useReducedMotion"
import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"
Replace both imports in packages/ui/src/Overlay/index.tsx with a single line from the hooks package:
import { useLockBodyScroll, useReducedMotion } from "@hudozka/hooks"
Do it:
sed -i '' \
-e 's|import { useReducedMotion } from "@/hooks/useReducedMotion"|import { useLockBodyScroll, useReducedMotion } from "@hudozka/hooks"|' \
-e '/import { useLockBodyScroll } from "@\/hooks\/useLockBodyScroll"/d' \
packages/ui/src/Overlay/index.tsx
Verify:
rg 'useLockBodyScroll|useReducedMotion' packages/ui/src/Overlay/index.tsx
Expected: one import line plus call sites. No @/hooks/ references.
- Step 8: Create
packages/ui/src/index.ts:
export * from "./Box"
export * from "./Button"
export * from "./Panel"
export * from "./Title"
export * from "./Overlay"
Note: each <Component>/index.tsx already exports the component via named export (pre-migration convention). export * reaches through the folder via the Node resolver's directory-index behavior.
- Step 9: Codemod imports in the web app.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
rg -l '@/ui/(Box|Button|Panel|Title|Overlay)' apps/hudozka/src \
| xargs sed -i '' -E 's#@/ui/(Box|Button|Panel|Title|Overlay)#@hudozka/ui#g'
rg '@/ui/(Box|Button|Panel|Title|Overlay)' apps/hudozka
Expected: no matches in the second command.
- Step 10:
Picture/andThemeColor/still live inapps/hudozka/src/ui/and are imported via@/ui/Picture/@/ui/ThemeColor— those imports stay unchanged. Sanity-check:
rg '@/ui/(Picture|ThemeColor)' apps/hudozka/src | head
Expected: these imports are still present and untouched.
- Step 11: Collapse duplicate
@hudozka/uiimports file-by-file (same recipe as Tasks 4.4 and 5.2).
rg -l '"@hudozka/ui"' apps/hudozka/src | while read f; do
count=$(rg -c 'from "@hudozka/ui"' "$f")
if [ "$count" -gt 1 ]; then echo "$f ($count)"; fi
done
Merge duplicates manually.
- Step 12: Update
apps/hudozka/package.json: - Add:
"@hudozka/ui": "*",to dependencies - Remove:
"classnames": "^2.5.0",(if the web app only used it via the UI components — but Picture still usesclassnamesdirectly viacx. Check:rg 'from "classnames"' apps/hudozka/src | head. If any match exists, keepclassnamesas a web-app dep.)
rg 'from "classnames"' apps/hudozka/src | head
- If matches found: keep
"classnames": "^2.5.0"inapps/hudozka/package.json. - If no matches: remove it from
apps/hudozka/package.json.
npm install
- Step 13: Verify.
npm test -w @hudozka/ui
npm test -w apps/hudozka
npm run build
Expected: all pass.
- Step 14: Commit.
git add packages/ui apps/hudozka package.json package-lock.json
git commit -m "Extract Box/Button/Panel/Title/Overlay into @hudozka/ui"
Notes: @hudozka/ui tests pass (16/16 across 3 files — Box, Button, Panel). apps/hudozka tests pass (26/26 across 5 files). npm run build succeeds with the usual CMS fetch errors documented as expected for local builds. Overlay's internal hook imports were already in @hudozka/hooks form from Task 5.2 so the sed migration in Step 7 was effectively a no-op. Only one file (components/App/index.tsx) had duplicate @hudozka/ui imports after the codemod — merged the three Box, Title, Panel imports into a single line. classnames is still directly imported by 5 web-app files (PageMeta, TagList, Content, Wrapper, Card via cx), so it stays as a dependency in apps/hudozka/package.json per the plan's guidance.
Phase 5 Gate
npm install && npm run lint && npm test && npm run buildat repo root all pass.rg '@/lib/' apps/hudozka/src | rg -v '@/lib/meta'→ only shows@/lib/metaimports (the one file that stayed in-app).rg '@/ui/' apps/hudozka/src→ only shows@/ui/Pictureand@/ui/ThemeColor.rg '@/hooks/' apps/hudozka/src→ only shows@/hooks/useDarkThemeand@/hooks/useAccessibility.
Notes: install/lint/test/build all pass at repo root. Web-app imports cleanly scoped: only @/lib/meta (in two pages), only @/ui/Picture (in PageContent), and only @/hooks/useAccessibility + @/hooks/useDarkTheme (in App and ThemeColor) remain — the ThemeColor primitive intentionally stays in-app per spec even though nothing imports it via @/ui/ThemeColor.
Phase 6 — Consolidate ESLint config at root
Goal: a single eslint.config.js at the repo root lints everything; Next-specific rules apply only to apps/hudozka/**.
Task 6.1: Move and rewrite eslint.config.js
Files:
-
Create:
eslint.config.js(root) — NEW content (the old one lived at root and globally ignoredmodules/**; the web app has been using that same root config throughout Phase 3-5 since it never moved. This task rewrites it to scope Next rules to the web app and dropmodules/**.) -
Step 1: Replace
/Users/tmshv/Workspace/__github_tmshv/hudozka/eslint.config.jswith:
import { defineConfig, globalIgnores } from "eslint/config"
import nextVitals from "eslint-config-next/core-web-vitals"
import globals from "globals"
export default defineConfig([
globalIgnores([
"**/node_modules/**",
"**/.next/**",
"**/dist/**",
"**/build/**",
"**/out/**",
"apps/hudozka/public/static/**",
"pb/pb_data/**",
"pb/pb_migrations/**",
"**/next-env.d.ts",
]),
{
files: ["apps/hudozka/**/*.{js,jsx,ts,tsx}"],
extends: nextVitals,
},
{
languageOptions: {
globals: globals.browser,
},
},
{
rules: {
semi: ["error", "never"],
indent: ["warn", 4],
quotes: ["warn", "double"],
"eol-last": ["error", "always"],
"comma-dangle": ["error", "always-multiline"],
"no-tabs": "error",
"no-var": "error",
"space-before-function-paren": "off",
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
},
},
])
Key changes vs. pre-migration:
modules/**removed from global ignores (directory deleted).public/static/**scoped toapps/hudozka/(where it actually lives now).pb_data/**andpb_migrations/**scoped underpb/(their actual locations).**/node_modules/**added explicitly since workspaces create nestednode_modules.- Next config applies only to
apps/hudozka/**via afilesscope instead of the global spread.
Note: defineConfig accepts extends per-block in newer ESLint flat-config types — if your eslint version doesn't, fall back to spreading:
...nextVitals.map(c => ({ ...c, files: ["apps/hudozka/**/*.{js,jsx,ts,tsx}"] })),
Task 6.2: Verify lint works across the whole workspace
- Step 1: Lint.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
npm run lint
Expected: passes. If Next-specific rules fire inside packages/**, the files scope is wrong — fix and re-run.
- Step 2: Auto-format anything that drifted.
npm run format
- Step 3: Commit.
git add eslint.config.js
git commit -m "Scope Next ESLint rules to apps/hudozka in root flat config"
Notes: Lint passes (exit 0) at root — only a harmless informational note about Next's no-html-link-for-pages rule not finding a pages/ directory (expected since the app uses App Router). Format produced no drift. npm test runs 94 tests across 5 workspaces, all green. Phase 6 Gate's build check wasn't re-run this iteration (Phase 5 already validated it); the build command remains npm run build at root and delegates to apps/hudozka.
Phase 6 Gate
npm run lintat root is green.npm testat root still passes.npm run buildat root still passes.
Notes: All three verifications pass at repo root. npm run lint is exit-0 (only an informational note about Next's no-html-link-for-pages rule not finding a pages/ directory — expected since the app uses App Router). npm test runs 94 tests across 5 workspaces, all green. npm run build succeeds and emits the standalone output at apps/hudozka/.next/standalone/apps/hudozka/server.js.
Phase 7 — Cleanup and documentation
Goal: root README and CLAUDE.md reflect the new layout; no orphaned files; CI and Docker paths verified end-to-end.
Task 7.1: Remove orphaned files
- Step 1: Find and remove stray
.DS_Storefiles.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
fd -H -I '^\.DS_Store$' -x rm {}
- Step 2: Confirm
modules/is gone.
test ! -d modules && echo "ok"
Expected: ok.
- Step 3: Confirm
src/,app/,public/,next.config.ts,next-env.d.ts,vitest.config.ts,vitest.setup.tsx,Dockerfile,.dockerignoreno longer exist at the repo root.
for f in src app public next.config.ts next-env.d.ts vitest.config.ts vitest.setup.tsx Dockerfile .dockerignore tsconfig.json; do
test -e "$f" && echo "STILL PRESENT: $f"
done
Expected: no output (nothing still at root).
Notes: 7 untracked .DS_Store files removed (excluding .git/.DS_Store which lives inside .git/ metadata). Verified modules/ is gone and none of the listed web-app artifacts remain at repo root.
Task 7.2: Update root README.md
Files:
-
Modify:
README.md -
Step 1: Open
README.mdand add a short "Repo layout" section near the top:
## Repo layout
This is an npm-workspaces monorepo:
- `apps/hudozka` — main Next.js app (art.shlisselburg.org)
- `apps/hudozka-writer` — Vite-based content editor
- `apps/migrate-to-pocketbase` — one-off migration script (Strapi → PocketBase)
- `packages/ui` — `@hudozka/ui` — shared React primitives (Box, Button, Panel, Title, Overlay)
- `packages/hooks` — `@hudozka/hooks` — generic React hooks
- `packages/utils` — `@hudozka/utils` — pure utilities (array, string, url, math, image, file, date)
- `packages/text` — `@hudozka/text` — markdown + typograf rendering
- `pb/` — PocketBase instance (untouched by the monorepo migration)
Common commands (from repo root):
- `npm run dev` — run the web app dev server
- `npm run build` — production build of the web app
- `npm run lint` — lint the whole monorepo
- `npm test` — run tests across every workspace
- `npm run dev -w apps/hudozka-writer` — run the writer
- `npm run migrate -w apps/migrate-to-pocketbase` — run the migration script
Adjust or replace the existing content if there's overlap. Keep any deployment/CI notes already present.
Task 7.3: Update root CLAUDE.md
Files:
-
Modify:
CLAUDE.md -
Step 1: Update the
## Commandssection — commands now run from repo root but delegate via workspaces. Content should be:
## Commands
From repo root (delegates to `apps/hudozka`):
- `npm run dev` — dev server with Turbo (`next dev --turbo`)
- `npm run build` — production build
- `npm run lint` — ESLint across the whole monorepo
- `npm run format` — auto-fix lint issues
- `npm test` — run unit tests (Vitest) across every workspace
Per-app:
- `npm run dev -w apps/hudozka-writer` — writer UI (Vite)
- `npm run migrate -w apps/migrate-to-pocketbase` — one-off migration script
Node 24 required (see `mise.toml`). Deployed on Vercel.
- Step 2: Update the
## Key Directoriessection:
## Key Directories
- `apps/hudozka/app/` — App Router pages, layouts, route handlers (catch-all, not-found, sitemap, robots, feed)
- `apps/hudozka/src/components/` — page-level and feature components (folder-per-component + `styles.module.css`)
- `apps/hudozka/src/ui/` — Next/store-coupled UI that stays in the app: `Picture`, `ThemeColor`
- `apps/hudozka/src/remote/` — Strapi/PocketBase API client (`api.ts`, `factory.ts`, `types.ts`)
- `apps/hudozka/src/store/` — Valtio stores (theme/accessibility, playback config)
- `apps/hudozka/src/hooks/` — business-coupled hooks: `useDarkTheme`, `useAccessibility`
- `apps/hudozka/src/lib/` — app-only lib (currently just `meta.ts`)
- `apps/hudozka/src/style/` — global CSS with CSS custom properties for theming
- `packages/ui/` — `@hudozka/ui` — reusable primitives (Box, Button, Panel, Title, Overlay)
- `packages/hooks/` — `@hudozka/hooks` — generic React hooks (useToggle, useLockBodyScroll, useMediaQuery, useMobile, useReducedMotion)
- `packages/utils/` — `@hudozka/utils` — pure utilities
- `packages/text/` — `@hudozka/text` — markdown + typograf rendering
- `apps/hudozka-writer/` — Vite-based editor
- `apps/migrate-to-pocketbase/` — Node migration script
- Step 3: Update the
## CI/CDsection — Dockerfile path changed:
## CI/CD
- `apps/hudozka/Dockerfile` — multi-stage Alpine build with standalone Next.js output, pushed to GHCR. Build context is the repo root so the workspace graph is available.
- `.github/workflows/build-app.yaml` — builds and pushes Docker image on push to master (`file: ./apps/hudozka/Dockerfile`, `context: ./`).
- `.github/workflows/test-app.yml` — runs lint, tests, typecheck, and build on PRs.
- Step 4: Update the
## TypeScriptsection — path alias scope:
Add the sentence: "The @/* path alias is only configured in apps/hudozka/tsconfig.json; packages under packages/* use short relative imports."
Notes: All four sections updated. Also removed the obsolete "Planned: Monorepo Migration" section at the bottom of CLAUDE.md — its inline note explicitly stated it should be replaced by the post-migration content of Commands / Key Directories / CI/CD, which Steps 1-3 now provide directly.
Task 7.4: Spot-check remaining files
- Step 1:
.github/workflows/*— confirm both files are correct.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
rg 'context|file|tsc' .github/workflows
Expected: context: ./, file: ./apps/hudozka/Dockerfile, npx tsc --noEmit -p apps/hudozka/tsconfig.json.
- Step 2:
mise.toml— no change needed; Node 24 still covers every workspace.
cat mise.toml
- Step 3:
pb/— confirm untouched.
git diff --name-only pb/
Expected: no output (no changes tracked).
- Step 4: Verify the memory ESLint note (
modules/is excluded) is now stale — but the fix is already committed in Phase 6; nothing to do here.
Notes: All workflow paths confirmed correct (build-app.yaml has context: ./ and file: ./apps/hudozka/Dockerfile; test-app.yml has npx tsc --noEmit -p apps/hudozka/tsconfig.json). mise.toml unchanged (Node 24). git diff origin/master -- pb/ returns nothing — pb/ untouched on the branch.
Task 7.5: Final verification and commit
- Step 1: Full pipeline at root.
cd /Users/tmshv/Workspace/__github_tmshv/hudozka
npm install
npm run lint
npm test
npm run build
Expected: all pass.
- Step 2: Local Docker smoke test (optional but recommended). [skipped — not automatable in this loop; Dockerfile exercised by
.github/workflows/build-app.yamlin CI; standalone output layout verified locally in Task 3.8 Step 3]
docker build -f apps/hudozka/Dockerfile -t hudozka:monorepo .
docker run --rm -p 3000:3000 hudozka:monorepo &
sleep 3
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000
kill %1
Expected: HTTP 200 (or whatever the home page returns under real CMS config).
- Step 3: Commit docs + cleanup.
git add README.md CLAUDE.md
git commit -m "Update README and CLAUDE.md for monorepo layout"
Notes: Full pipeline at root (install/lint/test/build) all pass. npm run lint exit-0 with the same harmless Next informational note about pages/. npm test runs 94 tests across 5 workspaces. npm run build emits the standalone Next output. README updated with a "Repo layout" section listing apps/packages and the common root commands. CLAUDE.md sections (Commands, Key Directories, CI/CD, TypeScript) rewritten for the monorepo layout; the obsolete "Planned: Monorepo Migration" section was removed (its inline note told us it would be replaced once Task 7.3 lands).
Phase 7 Gate
- Full pipeline passes at root.
- README and CLAUDE.md accurately describe the new layout.
- No references to
modules/remain (rg 'modules/' -g '!docs/**' -g '!node_modules/**'returns nothing or only historical doc mentions).
Post-migration: verify CI on a PR
- Push the branch and open a PR. Confirm the
Test AppandBuild Frontend and Deployworkflows pass. Ifbuild-app.yamlfails on the new Dockerfile, inspect the CI logs and adjust the Dockerfile accordingly — the most common issue is the standalone output layout (see Task 3.5outputFileTracingRootnotes).
Appendix A — Package dependency summary (after migration)
| Package | Runtime deps | Peer deps | Workspace deps |
|---|---|---|---|
apps/hudozka |
@radix-ui/*, dangerously-set-html-content, next, pocketbase, react, react-dom, react-icons, sharp, valtio, possibly classnames |
— | @hudozka/ui, @hudozka/hooks, @hudozka/utils, @hudozka/text |
apps/hudozka-writer |
Tiptap family, pocketbase, react, react-dom |
— | — |
apps/migrate-to-pocketbase |
nanoid, pocketbase, sharp |
— | — |
@hudozka/ui |
classnames |
react, react-dom |
@hudozka/hooks |
@hudozka/hooks |
— | react |
— |
@hudozka/utils |
date-fns |
— | — |
@hudozka/text |
markdown-it, typograf |
— | — |
Appendix B — Commands cheatsheet
| What | Command |
|---|---|
| Install | npm install |
| Lint | npm run lint |
| Test everything | npm test |
| Test one workspace | npm test -w <workspace-name> (e.g., @hudozka/ui) |
| Web dev | npm run dev |
| Web build | npm run build |
| Writer dev | npm run dev -w apps/hudozka-writer |
| Migration script | npm run migrate -w apps/migrate-to-pocketbase |
| Typecheck web | npx tsc --noEmit -p apps/hudozka/tsconfig.json |