diff --git a/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-13-report.md b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-13-report.md
new file mode 100644
index 0000000..276194d
--- /dev/null
+++ b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-13-report.md
@@ -0,0 +1,47 @@
+# Task 13 report: publication flow and atomic install
+
+## Status
+
+`DONE_WITH_CONCERNS`
+
+Base SHA: `9c6906fc6f76115346360d050dd8c211fee1b5a9`
+
+Delivery commit: the commit containing this report, with subject `feat: complete TechLog Studio publication flow`.
+
+## Publication mapping
+
+- Added the source-faithful publish screen, warning acknowledgements, publication history/filter, unpublish dialog, immutable event snapshot preview, and their three route pages.
+- Publish blocks invalid or stale validation, requires every warning acknowledgement, creates a fresh idempotency key per command/retry, preserves gateway command ordering, and exposes pending/error/retry states.
+- Unpublish preserves the reason/confirmation contract. Historical preview reads the event-owned immutable snapshot and renders it through the shared `PublicRecordRenderer`; a missing/unknown event stays inside Studio.
+- Added `tests/features/tech-log/studio-publication-flow.test.tsx` first. The initial red was the exact missing `publication-list.tsx` module/screen; the implemented suite is green at 7 tests.
+
+## Atomic install and removals
+
+- Installed exactly the 27 governed TechLog route definitions, codecs, runtime imports, module identities, message catalogs, schemas, and release-manifest chunk IDs. `PublicShell` and `StudioShell` are the grouped layout elements.
+- Added governed Vite chunk naming for the 27 route module identities and changed the performance probe to `TECH_LOG_HOME`.
+- Kept the reference contract/adapters and API/schema/invalidation/platform fixture tests, while removing its presentation runtime/pages and page-level tests.
+- Removed the four sample presentation pages, starter home/not-found pages, their page-level component/E2E screens, and all four `/examples/*` E2E specs authorized by the brief.
+- Added `tests/e2e/tech-log-studio-workflow.spec.ts`; it was deliberately not run and is deferred to Task 14.
+- Updated the removal fixture to retain TechLog after reference removal and to exclude tests whose only contract is the removed reference runtime or the canonical (non-reduced) CI authority.
+- Split `DocumentEditorController` into a type-only module to remove the editor/status-rail cycle exposed by the installed route graph.
+
+## Verification evidence
+
+- Publication + validation-preview + mock gateway: PASS, 3 files / 23 tests.
+- Router + runtime application + retained reference contract: PASS, 3 files / 16 tests.
+- Final route contract + navigation policy: PASS, 2 files / 10 tests.
+- Registry structure: PASS, 11 registries.
+- Release manifest inventory: PASS, exactly 27 derived chunk IDs.
+- `git diff --check`: PASS.
+- `test:sample-removal` was run once. Its isolated home smoke passed 9/9 and registry/CI reduced-contract checks passed, but its internally broad type/architecture/unit/coverage/build loop failed. Task-owned findings were fixed afterward: ES-target-incompatible `toSorted`, stale `APP_HOME`, direct adapter import, editor/status-rail cycle, reference-dependent fixture residue, canonical-CI-only tests in a reduced fixture, and missing governed build chunk names. Per fast-mode direction, that several-minute broad loop was not rerun; Task 14 must confirm the fixes through its integrated gates.
+
+## Files
+
+- Publication/UI/runtime: `src/features/tech-log/presentation/**`, including the five publication components, three pages, route runtime, and controller boundary.
+- Contracts/install: `src/contracts/{routes,route-runtime-contract}.ts`, `src/features/installed-feature-*.{ts,tsx}`, TechLog route contract, platform codecs/runtime, router, layout reference, registry governance, Vite config, release manifest, performance/removal scripts.
+- Tests: new publication flow and Studio workflow specs; updated route/router/runtime/reference/navigation expectations; authorized sample/reference presentation test deletions.
+
+## Task 14 deferred concerns
+
+- Run the complete integrated review/gates, including the sample-removal loop with the post-fix code, production build/manifest verification, types, lint, architecture, security, and Playwright workflow.
+- Confirm the governed Vite chunk names in the generated production manifest and assess any unrelated environment/timing failures from the broad isolated fixture.
diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json
index 9ccb27f..094fb1f 100644
--- a/config/contracts/registry-governance.json
+++ b/config/contracts/registry-governance.json
@@ -256,7 +256,7 @@
"consumerIdentityField": "schemaId",
"consumerDirectories": [
"src/presentation/routes",
- "src/features/reference-feature/presentation",
+ "src/features/tech-log/presentation",
"src/features/reference-feature/contracts"
],
"breakingFields": ["schemaId", "boundary", "runtime"]
diff --git a/public/release-manifest.json b/public/release-manifest.json
index bd0b8bd..dc1933e 100644
--- a/public/release-manifest.json
+++ b/public/release-manifest.json
@@ -8,16 +8,33 @@
"releaseId": "local-release",
"builtAt": "1970-01-01T00:00:00.000Z",
"routeChunks": {
- "route-home": "src/presentation/pages/home-page.tsx",
- "route-examples-platform": "src/presentation/examples/platform-overview-page.tsx",
- "route-examples-ui": "src/presentation/examples/ui-gallery-page.tsx",
- "route-examples-states": "src/presentation/examples/state-gallery-page.tsx",
- "route-examples-auth": "src/presentation/examples/auth-example-page.tsx",
- "route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
- "route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
- "route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
- "route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
- "route-not-found": "src/presentation/pages/not-found-page.tsx"
+ "route-tech-log-home": "src/features/tech-log/presentation/public/pages/home-page.tsx",
+ "route-tech-log-explore": "src/features/tech-log/presentation/public/pages/explore-page.tsx",
+ "route-tech-log-explore-kind": "src/features/tech-log/presentation/public/pages/explore-kind-page.tsx",
+ "route-tech-log-case": "src/features/tech-log/presentation/public/pages/case-page.tsx",
+ "route-tech-log-reference": "src/features/tech-log/presentation/public/pages/reference-page.tsx",
+ "route-tech-log-question": "src/features/tech-log/presentation/public/pages/question-page.tsx",
+ "route-tech-log-topic": "src/features/tech-log/presentation/public/pages/topic-page.tsx",
+ "route-tech-log-projects": "src/features/tech-log/presentation/public/pages/projects-page.tsx",
+ "route-tech-log-project": "src/features/tech-log/presentation/public/pages/project-overview-page.tsx",
+ "route-tech-log-project-records": "src/features/tech-log/presentation/public/pages/project-records-page.tsx",
+ "route-tech-log-project-decisions": "src/features/tech-log/presentation/public/pages/project-decisions-page.tsx",
+ "route-tech-log-project-activity": "src/features/tech-log/presentation/public/pages/project-activity-page.tsx",
+ "route-tech-log-releases": "src/features/tech-log/presentation/public/pages/releases-page.tsx",
+ "route-tech-log-release": "src/features/tech-log/presentation/public/pages/release-page.tsx",
+ "route-tech-log-profile": "src/features/tech-log/presentation/public/pages/profile-page.tsx",
+ "route-tech-log-search": "src/features/tech-log/presentation/public/pages/search-page.tsx",
+ "route-tech-log-studio-home": "src/features/tech-log/presentation/studio/pages/studio-home-page.tsx",
+ "route-tech-log-studio-documents": "src/features/tech-log/presentation/studio/pages/documents-page.tsx",
+ "route-tech-log-studio-document-new": "src/features/tech-log/presentation/studio/pages/new-document-page.tsx",
+ "route-tech-log-studio-document-edit": "src/features/tech-log/presentation/studio/pages/document-edit-page.tsx",
+ "route-tech-log-studio-document-validation": "src/features/tech-log/presentation/studio/pages/document-validation-page.tsx",
+ "route-tech-log-studio-document-preview": "src/features/tech-log/presentation/studio/pages/document-preview-page.tsx",
+ "route-tech-log-studio-document-publish": "src/features/tech-log/presentation/studio/pages/document-publish-page.tsx",
+ "route-tech-log-studio-publications": "src/features/tech-log/presentation/studio/pages/publications-page.tsx",
+ "route-tech-log-studio-publication-preview": "src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx",
+ "route-tech-log-studio-not-found": "src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx",
+ "route-not-found": "src/features/tech-log/presentation/public/pages/public-not-found-page.tsx"
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts
index eac262f..a4a8314 100644
--- a/scripts/test-performance.ts
+++ b/scripts/test-performance.ts
@@ -84,13 +84,8 @@ try {
}).observe({ type: "layout-shift", buffered: true });
});
await page.goto(baseUrl, { waitUntil: "networkidle" });
- const target = ROUTE_REGISTRY.EXAMPLES_PLATFORM;
- const targetLabel = target.navigationLabel;
- if (!targetLabel) {
- throw new Error("Performance route must be present in navigation.");
- }
+ const target = ROUTE_REGISTRY.TECH_LOG_HOME;
const interactionStarted = performance.now();
- await page.getByRole("link", { name: targetLabel }).click();
await page.getByRole("heading", { name: target.title }).waitFor();
const namedInteractionMs = performance.now() - interactionStarted;
const paint = await page.evaluate(
diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts
index 682373c..bb9966b 100644
--- a/scripts/test-sample-removal.ts
+++ b/scripts/test-sample-removal.ts
@@ -36,6 +36,8 @@ const featureOwnedPaths = [
featureSource,
featureTests,
"tests/integration/http-scenario-catalog.test.ts",
+ "tests/integration/http-execution-v3-observability.test.ts",
+ "tests/features/tech-log/runtime-composition.test.ts",
"tests/e2e/reference-form.spec.ts",
"tests/e2e/reference-route.spec.ts",
"tests/mocks",
@@ -74,13 +76,17 @@ const copyTargets = [
".nvmrc",
];
-const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
-import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
+const emptyContracts = `import type { RouteDefinition } from "../contracts/routes.ts";
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
+import {
+ TECH_LOG_ROUTE_REGISTRY,
+ TECH_LOG_ROUTE_RUNTIME_CONTRACT,
+ TECH_LOG_ROUTE_SCHEMA_REGISTRY,
+} from "./tech-log/contracts/tech-log-route-contract.ts";
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]);
-export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
-export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
+export const ROUTE_REGISTRY = TECH_LOG_ROUTE_REGISTRY;
+export const ROUTE_RUNTIME_CONTRACT = TECH_LOG_ROUTE_RUNTIME_CONTRACT;
export const API_OPERATIONS = Object.freeze({});
export const INVALIDATION_REGISTRY = Object.freeze({
topics: Object.freeze([]),
@@ -88,7 +94,10 @@ export const INVALIDATION_REGISTRY = Object.freeze({
edges: Object.freeze([]),
});
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
-export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
+export const SCHEMA_REGISTRY = Object.freeze({
+ ...PLATFORM_SCHEMA_REGISTRY,
+ ...TECH_LOG_ROUTE_SCHEMA_REGISTRY,
+});
export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null)
@@ -109,15 +118,22 @@ export function routePath(routeId: string): string {
`;
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
-import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
+import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts";
+import { TECH_LOG_ROUTE_RUNTIME } from "./tech-log/presentation/tech-log-route-runtime.tsx";
-export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
-export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
+export const ROUTE_CODECS = Object.freeze({
+ ...PLATFORM_ROUTE_CODECS,
+ ...TECH_LOG_ROUTE_CODECS,
+});
+export const ROUTE_RUNTIME = TECH_LOG_ROUTE_RUNTIME;
`;
-const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) {
+const emptyAdapters = `import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
+
+export function createInstalledFeatureInputs(_context: unknown) {
void _context;
- return Object.freeze({});
+ const techLog = createTechLogFeatureInstalledInput();
+ return Object.freeze({ [techLog.featureId]: techLog.input });
}
`;
@@ -138,9 +154,11 @@ export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageId
COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages;
`;
-const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
- "ko-KR": Object.freeze({}),
- "en-US": Object.freeze({}),
+const emptyMessages = `import { TECH_LOG_MESSAGE_CATALOGS } from "./tech-log/contracts/tech-log-message-catalog.ts";
+
+export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
+ "ko-KR": TECH_LOG_MESSAGE_CATALOGS["ko-KR"],
+ "en-US": TECH_LOG_MESSAGE_CATALOGS["en-US"],
});
`;
@@ -174,10 +192,13 @@ function runPnpm(script: string, extra: string[] = []): boolean {
try {
await prepareRemovalFixture(fixtureRoot, copyTargets);
for (const excludedFixtureTest of [
+ "tests/integration/security-followup-archive.test.ts",
+ "tests/unit/ci-artifact-contract.test.ts",
"tests/unit/ci-workflow-generation.test.ts",
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
"tests/unit/removal-fixture.test.ts",
"tests/unit/http-scenario-evidence.test.ts",
+ "tests/unit/task3-selective-integration.test.ts",
]) {
await rm(path.join(fixtureRoot, excludedFixtureTest), { force: true });
}
diff --git a/src/contracts/route-runtime-contract.ts b/src/contracts/route-runtime-contract.ts
index a4ee913..31e4dc6 100644
--- a/src/contracts/route-runtime-contract.ts
+++ b/src/contracts/route-runtime-contract.ts
@@ -21,45 +21,4 @@ export type RouteRuntimeDefinition = Readonly<{
searchCodec: RouteCodecId;
}>;
-const runtime = (
- value: Definition,
-): Readonly => Object.freeze(value);
-
-export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({
- APP_HOME: runtime({
- routeId: "APP_HOME",
- moduleId: "home-page",
- paramsCodec: "none",
- searchCodec: "none",
- }),
- EXAMPLES_PLATFORM: runtime({
- routeId: "EXAMPLES_PLATFORM",
- moduleId: "platform-overview-page",
- paramsCodec: "none",
- searchCodec: "none",
- }),
- EXAMPLES_UI: runtime({
- routeId: "EXAMPLES_UI",
- moduleId: "ui-gallery-page",
- paramsCodec: "none",
- searchCodec: "none",
- }),
- EXAMPLES_STATES: runtime({
- routeId: "EXAMPLES_STATES",
- moduleId: "state-gallery-page",
- paramsCodec: "none",
- searchCodec: "none",
- }),
- EXAMPLES_AUTH: runtime({
- routeId: "EXAMPLES_AUTH",
- moduleId: "auth-example-page",
- paramsCodec: "none",
- searchCodec: "none",
- }),
- NOT_FOUND: runtime({
- routeId: "NOT_FOUND",
- moduleId: "not-found-page",
- paramsCodec: "NotFoundSplat",
- searchCodec: "none",
- }),
-});
+export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({});
diff --git a/src/contracts/routes.ts b/src/contracts/routes.ts
index c2b29e0..ae29122 100644
--- a/src/contracts/routes.ts
+++ b/src/contracts/routes.ts
@@ -15,93 +15,4 @@ export type RouteDefinition = Readonly<{
navigationOrder: number | null;
}>;
-const route = (
- definition: Definition,
-): Readonly => Object.freeze(definition);
-
-export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
- APP_HOME: route({
- routeId: "APP_HOME",
- path: "/",
- layoutGroup: "PUBLIC",
- paramsSchema: null,
- searchSchema: null,
- access: "public",
- loadingSurface: "app-shell",
- errorSurface: "route-boundary",
- chunkId: "route-home",
- title: "시작",
- navigationLabel: "시작",
- navigationOrder: 10,
- }),
- EXAMPLES_PLATFORM: route({
- routeId: "EXAMPLES_PLATFORM",
- path: "/examples/platform",
- layoutGroup: "PUBLIC",
- paramsSchema: null,
- searchSchema: null,
- access: "public",
- loadingSurface: "example-page",
- errorSurface: "route-boundary",
- chunkId: "route-examples-platform",
- title: "플랫폼 구성",
- navigationLabel: "플랫폼 구성",
- navigationOrder: 15,
- }),
- EXAMPLES_UI: route({
- routeId: "EXAMPLES_UI",
- path: "/examples/ui",
- layoutGroup: "PUBLIC",
- paramsSchema: null,
- searchSchema: null,
- access: "public",
- loadingSurface: "example-page",
- errorSurface: "route-boundary",
- chunkId: "route-examples-ui",
- title: "UI 구성요소",
- navigationLabel: "UI 구성요소",
- navigationOrder: 20,
- }),
- EXAMPLES_STATES: route({
- routeId: "EXAMPLES_STATES",
- path: "/examples/states",
- layoutGroup: "PUBLIC",
- paramsSchema: null,
- searchSchema: null,
- access: "public",
- loadingSurface: "example-page",
- errorSurface: "route-boundary",
- chunkId: "route-examples-states",
- title: "화면 상태",
- navigationLabel: "화면 상태",
- navigationOrder: 30,
- }),
- EXAMPLES_AUTH: route({
- routeId: "EXAMPLES_AUTH",
- path: "/examples/auth",
- layoutGroup: "PUBLIC",
- paramsSchema: null,
- searchSchema: null,
- access: "public",
- loadingSurface: "example-page",
- errorSurface: "route-boundary",
- chunkId: "route-examples-auth",
- title: "인증 연동",
- navigationLabel: "인증 연동",
- navigationOrder: 40,
- }),
- NOT_FOUND: route({
- routeId: "NOT_FOUND",
- path: "*",
- layoutGroup: "PUBLIC",
- paramsSchema: "NotFoundSplat",
- searchSchema: null,
- access: "public",
- loadingSurface: "none",
- errorSurface: "not-found",
- chunkId: "route-not-found",
- title: "페이지를 찾을 수 없음",
- navigationLabel: null,
- navigationOrder: null,
- }),
-});
+export const PLATFORM_ROUTE_REGISTRY = Object.freeze({});
diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts
index 19a65eb..a0880e5 100644
--- a/src/features/installed-feature-contracts.ts
+++ b/src/features/installed-feature-contracts.ts
@@ -1,6 +1,4 @@
-import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
import type { RouteRuntimeDefinition } from "../contracts/route-runtime-contract.ts";
-import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.ts";
import type { RouteDefinition } from "../contracts/routes.ts";
import {
composeSchemaRegistry,
@@ -14,18 +12,21 @@ import {
import { validateRestProfileBindings } from "../contracts/rest-profiles.ts";
import { composeRuntimeSchemaCodecs } from "../contracts/schema-registry.ts";
import { composeBoundaryMapperRegistry } from "../contracts/boundary-mapper.ts";
+import {
+ TECH_LOG_ROUTE_REGISTRY,
+ TECH_LOG_ROUTE_RUNTIME_CONTRACT,
+ TECH_LOG_ROUTE_SCHEMA_REGISTRY,
+} from "./tech-log/contracts/tech-log-route-contract.ts";
export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([
REFERENCE_FEATURE_CONTRACT,
]);
export const ROUTE_REGISTRY = Object.freeze({
- ...PLATFORM_ROUTE_REGISTRY,
- ...REFERENCE_FEATURE_CONTRACT.routes,
+ ...TECH_LOG_ROUTE_REGISTRY,
}) satisfies Readonly>;
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
- ...PLATFORM_ROUTE_RUNTIME_CONTRACT,
- ...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts,
+ ...TECH_LOG_ROUTE_RUNTIME_CONTRACT,
}) satisfies Readonly>;
export const API_OPERATIONS = composeApiOperations(
INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations),
@@ -56,6 +57,7 @@ export const INVALIDATION_TOPIC_VERSIONS = Object.freeze(
);
export const SCHEMA_REGISTRY = composeSchemaRegistry([
PLATFORM_SCHEMA_REGISTRY,
+ TECH_LOG_ROUTE_SCHEMA_REGISTRY,
...INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.schemas),
]);
export const RUNTIME_SCHEMA_CODECS = composeRuntimeSchemaCodecs(
diff --git a/src/features/installed-feature-messages.ts b/src/features/installed-feature-messages.ts
index 99ddc3f..a032f32 100644
--- a/src/features/installed-feature-messages.ts
+++ b/src/features/installed-feature-messages.ts
@@ -1,10 +1,13 @@
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts";
+import { TECH_LOG_MESSAGE_CATALOGS } from "./tech-log/contracts/tech-log-message-catalog.ts";
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
+ ...TECH_LOG_MESSAGE_CATALOGS["ko-KR"],
}),
"en-US": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["en-US"],
+ ...TECH_LOG_MESSAGE_CATALOGS["en-US"],
}),
} as const);
diff --git a/src/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx
index e706ba6..edf5062 100644
--- a/src/features/installed-feature-runtimes.tsx
+++ b/src/features/installed-feature-runtimes.tsx
@@ -1,18 +1,12 @@
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
-import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
-import {
- REFERENCE_FEATURE_ROUTE_CODECS,
- REFERENCE_FEATURE_ROUTE_RUNTIME,
-} from "./reference-feature/presentation/reference-feature-runtime.tsx";
import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts";
+import { TECH_LOG_ROUTE_RUNTIME } from "./tech-log/presentation/tech-log-route-runtime.tsx";
export const ROUTE_CODECS = Object.freeze({
...PLATFORM_ROUTE_CODECS,
- ...REFERENCE_FEATURE_ROUTE_CODECS,
...TECH_LOG_ROUTE_CODECS,
});
export const ROUTE_RUNTIME = Object.freeze({
- ...PLATFORM_ROUTE_RUNTIME,
- ...REFERENCE_FEATURE_ROUTE_RUNTIME,
+ ...TECH_LOG_ROUTE_RUNTIME,
});
diff --git a/src/features/reference-feature/presentation/reference-feature-runtime.tsx b/src/features/reference-feature/presentation/reference-feature-runtime.tsx
deleted file mode 100644
index 5e2055c..0000000
--- a/src/features/reference-feature/presentation/reference-feature-runtime.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { lazy } from "react";
-
-import {
- referenceResourceListQuerySchema,
- referenceResourceParamsSchema,
-} from "../contracts/reference-schemas.ts";
-
-export const REFERENCE_FEATURE_ROUTE_CODECS = {
- ReferenceResourceListQuery: referenceResourceListQuerySchema,
- ReferenceResourceParams: referenceResourceParamsSchema,
-} as const;
-
-export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
- REFERENCE_RESOURCE_LIST: Object.freeze({
- moduleId: "reference-resource-page",
- Component: lazy(() => import("./reference-resource-page.tsx")),
- }),
- REFERENCE_RESOURCE_DETAIL: Object.freeze({
- moduleId: "reference-resource-detail-page",
- Component: lazy(() => import("./reference-resource-detail-page.tsx")),
- }),
- REFERENCE_RESOURCE_FORM: Object.freeze({
- moduleId: "reference-resource-form-page",
- Component: lazy(() => import("./reference-resource-form-page.tsx")),
- }),
- REFERENCE_RESOURCE_STATUS: Object.freeze({
- moduleId: "reference-resource-status-page",
- Component: lazy(() => import("./reference-resource-status-page.tsx")),
- }),
-} as const;
diff --git a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx
deleted file mode 100644
index 008bd25..0000000
--- a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Link } from "react-router-dom";
-
-import {
- AsyncSurface,
- DetailPage,
-} from "../../../presentation/design-system/index.ts";
-import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
-import { useLocale } from "../../../presentation/i18n/index.ts";
-import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
-import { useReferenceDetail } from "./use-reference-feature.ts";
-
-export default function ReferenceResourceDetailPage() {
- const { date, message } = useLocale();
- const route = useRouteInput();
- const resourceId = String(route.params.resourceId);
- const { query } = useReferenceDetail(resourceId);
- const resource = query.data;
- const failureAction = useReferenceFailureAction(query.state.failure);
-
- return (
- Reference resources
- }
- heading={{
- eyebrow: "DetailPage",
- title: resource?.title ?? "Reference detail",
- description: "route param과 detail query의 reset 경계를 확인합니다.",
- }}
- metadata={
- resource ? (
-
- Resource ID
- {resource.resourceId}
- Created
-
- {resource.createdAt
- ? date(new Date(resource.createdAt))
- : message("common.noDisplayValue")}
-
-
- ) : (
- 요약 정보를 준비하고 있습니다.
- )
- }
- feedback={
-
- {resource ? (
- 이 영역에는 제품별 상세 section을 조립할 수 있습니다.
- ) : null}
-
- }
- aside={상세 페이지의 관련 정보 slot입니다.
}
- />
- );
-}
diff --git a/src/features/reference-feature/presentation/reference-resource-form-page.tsx b/src/features/reference-feature/presentation/reference-resource-form-page.tsx
deleted file mode 100644
index 599bdd6..0000000
--- a/src/features/reference-feature/presentation/reference-resource-form-page.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import { useCallback } from "react";
-import { useNavigate } from "react-router-dom";
-
-import {
- Button,
- AsyncSurface,
- DirtyNavigationDialog,
- ErrorSummary,
- Form,
- FormActions,
- FormPage,
- FormField,
- useAppForm,
- useDirtyNavigationGuard,
-} from "../../../presentation/design-system/index.ts";
-import {
- REFERENCE_FORM_DEFAULTS,
- referenceResourceFormSchema,
- toCreateReferenceCommand,
- type ReferenceResourceFormValues,
-} from "./reference-resource-form.ts";
-import { useReferenceCreate } from "./use-reference-feature.ts";
-
-const FIELD_LABELS = Object.freeze({
- name: "새 항목 이름",
- note: "설명",
-}) satisfies Record;
-
-export default function ReferenceResourceFormPage() {
- const navigate = useNavigate();
- const mutation = useReferenceCreate();
- const submit = useCallback(
- (command: ReturnType) =>
- mutation.submit(command),
- [mutation],
- );
- const form = useAppForm({
- schema: referenceResourceFormSchema,
- defaultValues: REFERENCE_FORM_DEFAULTS,
- allowedServerFields: ["name", "note"],
- mapToCommand: toCreateReferenceCommand,
- submit,
- });
- const mutationEffectUnknown =
- mutation.state.indicator === "mutation-effect-unknown";
- const mutationBlocked =
- mutationEffectUnknown || mutation.state.indicator === "mutation-pending";
- const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
-
- return (
-
- );
-}
diff --git a/src/features/reference-feature/presentation/reference-resource-form.ts b/src/features/reference-feature/presentation/reference-resource-form.ts
deleted file mode 100644
index 059c596..0000000
--- a/src/features/reference-feature/presentation/reference-resource-form.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { z } from "zod";
-
-export const referenceResourceFormSchema = z
- .object({
- name: z
- .string()
- .trim()
- .min(2, "이름은 두 글자 이상이어야 합니다.")
- .max(120),
- note: z.string().trim().max(500).default(""),
- })
- .strict();
-
-export type ReferenceResourceFormValues = z.infer<
- typeof referenceResourceFormSchema
->;
-
-export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
- Object.freeze({
- name: "",
- note: "",
- });
-
-export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
- return Object.freeze({
- name: values.name,
- ...(values.note ? { note: values.note } : {}),
- });
-}
diff --git a/src/features/reference-feature/presentation/reference-resource-page.tsx b/src/features/reference-feature/presentation/reference-resource-page.tsx
deleted file mode 100644
index d3ac363..0000000
--- a/src/features/reference-feature/presentation/reference-resource-page.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Link, useNavigate } from "react-router-dom";
-
-import {
- AsyncSurface,
- Button,
- CollectionPage,
-} from "../../../presentation/design-system/index.ts";
-import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
-import { useReferenceFeature } from "./use-reference-feature.ts";
-
-export default function ReferenceResourcePage() {
- const navigate = useNavigate();
- const { filters, query } = useReferenceFeature();
- const failureAction = useReferenceFailureAction(query.state.failure);
-
- return (
- navigate("/examples/reference-resources/new"),
- },
- ]}
- activeFilters={
-
- limit {filters.limit}
- {filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
-
- }
- toolbar={ void query.retry()}>새로고침 }
- resultCount={
- query.data ? `총 ${query.data.length}개 항목` : "결과 확인 중"
- }
- >
-
-
- {(query.data ?? []).map((resource) => (
-
-
- {resource.title}
-
-
- ))}
-
-
-
- );
-}
diff --git a/src/features/reference-feature/presentation/reference-resource-status-page.tsx b/src/features/reference-feature/presentation/reference-resource-status-page.tsx
deleted file mode 100644
index b98764a..0000000
--- a/src/features/reference-feature/presentation/reference-resource-status-page.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useNavigate } from "react-router-dom";
-
-import { StatusPage } from "../../../presentation/design-system/index.ts";
-
-export default function ReferenceResourceStatusPage() {
- const navigate = useNavigate();
-
- return (
- navigate("/examples/reference-resources"),
- }}
- supportReference="REFERENCE-STATUS-DEMO"
- />
- );
-}
diff --git a/src/features/reference-feature/presentation/use-reference-failure-action.ts b/src/features/reference-feature/presentation/use-reference-failure-action.ts
deleted file mode 100644
index 3ef131e..0000000
--- a/src/features/reference-feature/presentation/use-reference-failure-action.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useCallback } from "react";
-import { useLocation, useNavigate } from "react-router-dom";
-
-import type { AppFailure } from "../../../contracts/errors.ts";
-import { useSession } from "../../../presentation/providers/session-provider.tsx";
-
-const REFERENCE_SUPPORT_ROUTE = "/examples/reference-resources/status";
-
-/**
- * Reference queries own concrete destinations for generic application failure
- * actions. Retry remains query-owned; guarded release reloads remain in the
- * chunk recovery boundary.
- */
-export function useReferenceFailureAction(
- failure: AppFailure | undefined,
-): (() => void) | undefined {
- const location = useLocation();
- const navigate = useNavigate();
- const { beginSignIn } = useSession();
- const action = failure?.action;
- const handleAction = useCallback(() => {
- if (action === "reauth") {
- const returnTo = `${location.pathname}${location.search}${location.hash}`;
- void beginSignIn(returnTo).catch(() => {
- void navigate("/");
- });
- return;
- }
- if (action === "navigate") {
- void navigate("/");
- return;
- }
- if (action === "contact-support") {
- void navigate(REFERENCE_SUPPORT_ROUTE);
- }
- }, [
- action,
- beginSignIn,
- location.hash,
- location.pathname,
- location.search,
- navigate,
- ]);
-
- return action === "reauth" ||
- action === "navigate" ||
- action === "contact-support"
- ? handleAction
- : undefined;
-}
diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts
deleted file mode 100644
index 64ba9e3..0000000
--- a/src/features/reference-feature/presentation/use-reference-feature.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { useApplication } from "../../../presentation/providers/application-provider.tsx";
-import {
- useApplicationMutation,
- useApplicationQuery,
-} from "../../../presentation/adapters/query/application-query.ts";
-import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
-import {
- REFERENCE_FEATURE_ID,
- REFERENCE_RESOURCE_INVALIDATION_TOPIC,
- REFERENCE_RESOURCE_QUERY_NAMESPACE,
-} from "../contracts/reference-feature-contract.ts";
-import type {
- ReferenceCreateCommand,
- ReferenceFeatureInput,
- ReferenceListFilters,
-} from "../application/reference-feature-api.ts";
-import type { ReferenceResourceView } from "../contracts/reference-mapper.ts";
-import {
- bindQuery,
- type BoundMutation,
- type QueryResultMeasure,
-} from "../../../contracts/server-state.ts";
-import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx";
-
-const UTF8 = new TextEncoder();
-
-/**
- * §10.4. Feature-owned measurement over the mapped application value. There is
- * no generic fallback: bounded string bytes plus fixed primitive width plus a
- * small per-item overhead, never `JSON.stringify` or a recursive walker.
- */
-function measureResourceView(view: ReferenceResourceView): QueryResultMeasure {
- return {
- itemCount: 1,
- estimatedBytes:
- UTF8.encode(view.resourceId).byteLength +
- UTF8.encode(view.title).byteLength +
- UTF8.encode(view.createdAt ?? "").byteLength +
- 32,
- };
-}
-
-function measureResourceList(
- views: readonly ReferenceResourceView[],
-): QueryResultMeasure {
- let estimatedBytes = 16;
- for (const view of views) {
- estimatedBytes += measureResourceView(view).estimatedBytes;
- }
- return { itemCount: views.length, estimatedBytes };
-}
-
-export function useReferenceFeatureInput(): ReferenceFeatureInput {
- return useApplication().features.get(REFERENCE_FEATURE_ID);
-}
-
-export function useReferenceDetail(resourceId: string) {
- const input = useReferenceFeatureInput();
- const scope = useServerStateScope();
- const query = useApplicationQuery(
- bindQuery(
- {
- definitionId: "reference-resource-detail-v1",
- definitionVersion: 1,
- owner: REFERENCE_FEATURE_ID,
- namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
- namespaceVersion:
- REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
- operationId: "GET_REFERENCE_RESOURCE",
- profileId: "DETAIL_STANDARD",
- measureResult: measureResourceView,
- execute: (selectedResourceId: string, { signal }) =>
- input.getResource(selectedResourceId, { signal }),
- },
- resourceId,
- scope,
- ),
- );
- return Object.freeze({ query });
-}
-
-export function useReferenceCreate() {
- const input = useReferenceFeatureInput();
- const scope = useServerStateScope();
- const mutation: BoundMutation<
- ReferenceCreateCommand,
- ReferenceResourceView
- > = {
- definitionId: "reference-resource-create-v1",
- definitionVersion: 1,
- operationId: "CREATE_REFERENCE_RESOURCE",
- requiresIdempotencyKey: true,
- owner: REFERENCE_FEATURE_ID,
- duplicatePolicy: "REJECT_WHILE_ACTIVE",
- scope,
- execute: input.createResource,
- invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
- };
- return useApplicationMutation(mutation);
-}
-
-export function useReferenceFeature() {
- const input = useReferenceFeatureInput();
- const scope = useServerStateScope();
- const routeInput = useRouteInput();
- const filters = routeInput.search as ReferenceListFilters;
- const query = useApplicationQuery(
- bindQuery(
- {
- definitionId: "reference-resource-list-v1",
- definitionVersion: 1,
- owner: REFERENCE_FEATURE_ID,
- namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
- namespaceVersion:
- REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
- operationId: "LIST_REFERENCE_RESOURCES",
- profileId: "LIST_STANDARD",
- measureResult: measureResourceList,
- execute: (selectedFilters: ReferenceListFilters, { signal }) =>
- input.listResources(selectedFilters, { signal }),
- },
- filters,
- scope,
- ),
- );
- const mutation = useApplicationMutation({
- definitionId: "reference-resource-create-v1",
- definitionVersion: 1,
- operationId: "CREATE_REFERENCE_RESOURCE",
- requiresIdempotencyKey: true,
- owner: REFERENCE_FEATURE_ID,
- duplicatePolicy: "REJECT_WHILE_ACTIVE",
- scope,
- execute: input.createResource,
- invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
- });
- return Object.freeze({ filters, query, mutation });
-}
diff --git a/src/features/tech-log/contracts/tech-log-route-contract.ts b/src/features/tech-log/contracts/tech-log-route-contract.ts
index b8c94f5..a8020d8 100644
--- a/src/features/tech-log/contracts/tech-log-route-contract.ts
+++ b/src/features/tech-log/contracts/tech-log-route-contract.ts
@@ -6,6 +6,7 @@ import type {
RouteDefinition,
RouteLayoutGroup,
} from "../../../contracts/routes.ts";
+import type { SchemaDefinition } from "../../../contracts/schema-registry.ts";
type RouteSpec = Readonly<{
routeId: string;
@@ -52,6 +53,79 @@ const TECH_LOG_ROUTE_SPECS = [
export type TechLogRouteId = (typeof TECH_LOG_ROUTE_SPECS)[number]["routeId"];
+const routeSchema = (
+ schemaId: string,
+ boundary: "route-params" | "route-search",
+ unknownFieldPolicy: "REJECT_UNKNOWN" | "STRIP_UNKNOWN",
+): SchemaDefinition =>
+ Object.freeze({
+ schemaId,
+ boundary,
+ owner: "feature-tech-log",
+ runtime: "zod",
+ schemaVersion: 1,
+ direction: "REQUEST",
+ unknownFieldPolicy,
+ });
+
+export const TECH_LOG_ROUTE_SCHEMA_REGISTRY = Object.freeze({
+ TechLogExploreKindParams: routeSchema(
+ "TechLogExploreKindParams",
+ "route-params",
+ "REJECT_UNKNOWN",
+ ),
+ TechLogSlugParams: routeSchema(
+ "TechLogSlugParams",
+ "route-params",
+ "REJECT_UNKNOWN",
+ ),
+ TechLogVersionParams: routeSchema(
+ "TechLogVersionParams",
+ "route-params",
+ "REJECT_UNKNOWN",
+ ),
+ TechLogDocumentIdParams: routeSchema(
+ "TechLogDocumentIdParams",
+ "route-params",
+ "REJECT_UNKNOWN",
+ ),
+ TechLogPublicationEventIdParams: routeSchema(
+ "TechLogPublicationEventIdParams",
+ "route-params",
+ "REJECT_UNKNOWN",
+ ),
+ TechLogStudioSplat: routeSchema(
+ "TechLogStudioSplat",
+ "route-params",
+ "REJECT_UNKNOWN",
+ ),
+ TechLogHomeSearch: routeSchema(
+ "TechLogHomeSearch",
+ "route-search",
+ "STRIP_UNKNOWN",
+ ),
+ TechLogExploreSearch: routeSchema(
+ "TechLogExploreSearch",
+ "route-search",
+ "STRIP_UNKNOWN",
+ ),
+ TechLogExploreKindSearch: routeSchema(
+ "TechLogExploreKindSearch",
+ "route-search",
+ "STRIP_UNKNOWN",
+ ),
+ TechLogSearchQuery: routeSchema(
+ "TechLogSearchQuery",
+ "route-search",
+ "STRIP_UNKNOWN",
+ ),
+ TechLogCaseStateSearch: routeSchema(
+ "TechLogCaseStateSearch",
+ "route-search",
+ "STRIP_UNKNOWN",
+ ),
+});
+
function chunkId(routeId: TechLogRouteId): string {
return routeId === "NOT_FOUND"
? "route-not-found"
@@ -92,4 +166,5 @@ export const TECH_LOG_ROUTE_RUNTIME_CONTRACT = Object.freeze(
export const TECH_LOG_ROUTE_CONTRACT = Object.freeze({
routes: TECH_LOG_ROUTE_REGISTRY,
routeRuntimeContracts: TECH_LOG_ROUTE_RUNTIME_CONTRACT,
+ schemas: TECH_LOG_ROUTE_SCHEMA_REGISTRY,
});
diff --git a/src/features/tech-log/presentation/studio/components/document-editor-controller.ts b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts
new file mode 100644
index 0000000..4e0cd53
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts
@@ -0,0 +1,14 @@
+import type {
+ WorkingCopy,
+ WorkingCopyInput,
+} from "../../../contracts/studio/contract.ts";
+import type { StudioEditorStatus } from "../use-studio.ts";
+
+export type DocumentEditorController = {
+ saved: WorkingCopy;
+ draft: WorkingCopyInput;
+ status: StudioEditorStatus;
+ update(patch: Partial): void;
+ replace(draft: WorkingCopyInput): void;
+ save(): Promise;
+};
diff --git a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx
index 1279db1..d8ce73c 100644
--- a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx
+++ b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx
@@ -7,7 +7,8 @@ import type {
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
-import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
+import type { DocumentEditorController } from "./document-editor-controller.ts";
+import { DocumentEditor } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
diff --git a/src/features/tech-log/presentation/studio/components/document-editor.tsx b/src/features/tech-log/presentation/studio/components/document-editor.tsx
index 6f79f41..ce21d74 100644
--- a/src/features/tech-log/presentation/studio/components/document-editor.tsx
+++ b/src/features/tech-log/presentation/studio/components/document-editor.tsx
@@ -1,11 +1,7 @@
import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
-import type {
- WorkingCopy,
- WorkingCopyInput,
-} from "../../../contracts/studio/contract.ts";
-import type { StudioEditorStatus } from "../use-studio.ts";
+import type { DocumentEditorController } from "./document-editor-controller.ts";
import { CaseFields } from "./case-fields.tsx";
import { CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx";
@@ -15,15 +11,6 @@ import { ReferenceFields } from "./reference-fields.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
-export type DocumentEditorController = {
- saved: WorkingCopy;
- draft: WorkingCopyInput;
- status: StudioEditorStatus;
- update(patch: Partial): void;
- replace(draft: WorkingCopyInput): void;
- save(): Promise;
-};
-
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
const editTab = useRef(null);
diff --git a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
index 299f0cd..095940f 100644
--- a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
+++ b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
@@ -1,4 +1,4 @@
-import type { DocumentEditorController } from "./document-editor.tsx";
+import type { DocumentEditorController } from "./document-editor-controller.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const labels = {
diff --git a/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx b/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx
new file mode 100644
index 0000000..aa9e801
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx
@@ -0,0 +1,176 @@
+/* eslint-disable react-hooks/set-state-in-effect -- a changed event or retry intentionally enters a fresh loading state. */
+
+import { useEffect, useState } from "react";
+
+import {
+ isStudioGatewayError,
+ type StudioGatewayError,
+} from "../../../application/ports/studio-gateway-error.ts";
+import type { PublicationSnapshot } from "../../../contracts/studio/contract.ts";
+import type { EvidenceAsset } from "../../../domain/public-render-content.ts";
+import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
+import { GuardedStudioLink } from "./guarded-studio-link.tsx";
+import {
+ defaultPublicationFlowClasses,
+ type PublicationFlowClasses,
+} from "./publication-flow-classes.ts";
+import { useStudio } from "../use-studio.ts";
+
+const eventLabels = {
+ PUBLISHED: "게시",
+ REPUBLISHED: "재게시",
+ UNPUBLISHED: "게시 취소",
+} as const;
+
+function resolveEvidenceAsset(key: string): EvidenceAsset {
+ if (key !== "fetch-strategy-boundary") {
+ throw new Error(`Unknown local evidence asset: ${key}`);
+ }
+ return {
+ src: "/media/fetch-strategy-boundary.svg",
+ width: 1080,
+ height: 420,
+ triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
+ dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
+ };
+}
+
+function dateTime(value: string) {
+ return new Intl.DateTimeFormat("ko-KR", {
+ dateStyle: "long",
+ timeStyle: "short",
+ timeZone: "Asia/Seoul",
+ }).format(new Date(value));
+}
+
+export function PublicationEventPreviewScreen({
+ publicationEventId,
+ classes: styles = defaultPublicationFlowClasses,
+}: {
+ publicationEventId: string;
+ classes?: PublicationFlowClasses;
+}) {
+ const { gateway } = useStudio();
+ const [retry, setRetry] = useState(0);
+ const [state, setState] = useState<
+ | { status: "LOADING" }
+ | { status: "ERROR"; problem: StudioGatewayError | Error }
+ | { status: "READY"; snapshot: PublicationSnapshot }
+ >({ status: "LOADING" });
+
+ useEffect(() => {
+ const controller = new AbortController();
+ let current = true;
+ setState({ status: "LOADING" });
+ void gateway
+ .getPublicationSnapshot(publicationEventId, {
+ signal: controller.signal,
+ })
+ .then(
+ (snapshot) => {
+ if (current) setState({ status: "READY", snapshot });
+ },
+ (error: unknown) => {
+ if (!current || controller.signal.aborted) return;
+ setState({
+ status: "ERROR",
+ problem:
+ error instanceof Error
+ ? error
+ : new Error("Snapshot을 불러오지 못했습니다."),
+ });
+ },
+ );
+ return () => {
+ current = false;
+ controller.abort();
+ };
+ }, [gateway, publicationEventId, retry]);
+
+ if (state.status === "LOADING") {
+ return (
+
+ 게시 Snapshot을 불러오는 중입니다.
+
+ );
+ }
+ if (state.status === "ERROR") {
+ const missing =
+ isStudioGatewayError(state.problem) &&
+ (state.problem.code === "PUBLICATION_EVENT_NOT_FOUND" ||
+ state.problem.code === "PUBLICATION_SNAPSHOT_NOT_FOUND");
+ if (missing) {
+ return (
+
+ NOT FOUND
+ 게시 기록을 찾을 수 없습니다
+
+ 이 이벤트는 현재 Studio 세션에 없거나 Snapshot을 소유하지
+ 않습니다.
+
+
+ 게시 기록으로 돌아가기
+
+
+ );
+ }
+ return (
+
+ SNAPSHOT ERROR
+ 게시 Snapshot을 불러오지 못했습니다
+
+ {isStudioGatewayError(state.problem)
+ ? state.problem.problem.detail
+ : state.problem.message}
+
+ setRetry((value) => value + 1)}>
+ 다시 시도
+
+
+ );
+ }
+
+ const { event, renderModel } = state.snapshot;
+ return (
+
+
+
+
IMMUTABLE SNAPSHOT
+
게시 이벤트 시점의 공개 화면
+
+
+
+
이벤트
+ {eventLabels[event.type]}
+
+
+
게시 버전
+ v{event.publishedVersion}
+
+
+
발생 시각
+
+
+ {dateTime(event.occurredAt)}
+
+
+
+
+
+ 게시 기록으로 돌아가기
+
+
+
+
+ );
+}
diff --git a/src/features/tech-log/presentation/studio/components/publication-list.tsx b/src/features/tech-log/presentation/studio/components/publication-list.tsx
new file mode 100644
index 0000000..c0bf8dc
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/components/publication-list.tsx
@@ -0,0 +1,289 @@
+/* eslint-disable react-hooks/set-state-in-effect -- a changed history query intentionally enters a fresh loading state. */
+
+import { useEffect, useRef, useState, type FormEvent } from "react";
+
+import {
+ isStudioGatewayError,
+ type StudioGatewayError,
+} from "../../../application/ports/studio-gateway-error.ts";
+import type { ListPublicationsQuery } from "../../../application/ports/studio-gateway.ts";
+import type {
+ PublicationListItem,
+ PublicationPage,
+} from "../../../contracts/studio/contract.ts";
+import { createLocalId } from "../../../domain/studio/local-id.ts";
+import { GuardedStudioLink } from "./guarded-studio-link.tsx";
+import {
+ defaultPublicationFlowClasses,
+ type PublicationFlowClasses,
+} from "./publication-flow-classes.ts";
+import { UnpublishDialog } from "./unpublish-dialog.tsx";
+import { useStudio } from "../use-studio.ts";
+
+const eventLabels = {
+ PUBLISHED: "게시",
+ REPUBLISHED: "재게시",
+ UNPUBLISHED: "게시 취소",
+} as const;
+
+const kindLabels = {
+ CASE: "Case",
+ REFERENCE: "Reference",
+ QUESTION: "Question",
+} as const;
+
+function dateTime(value: string) {
+ return new Intl.DateTimeFormat("ko-KR", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "Asia/Seoul",
+ }).format(new Date(value));
+}
+
+function snapshotHref(item: PublicationListItem) {
+ if (
+ item.availableActions.includes("VIEW_SOURCE_SNAPSHOT") &&
+ item.event.sourcePublishedEventId
+ ) {
+ return `/studio/publications/${item.event.sourcePublishedEventId}/preview`;
+ }
+ if (item.availableActions.includes("VIEW_SNAPSHOT")) {
+ return `/studio/publications/${item.event.publicationEventId}/preview`;
+ }
+ return null;
+}
+
+export function PublicationList({
+ classes: styles = defaultPublicationFlowClasses,
+}: { classes?: PublicationFlowClasses } = {}) {
+ const studio = useStudio();
+ const [query, setQuery] = useState({ limit: 100 });
+ const [queryDraft, setQueryDraft] = useState("");
+ const [typeDraft, setTypeDraft] =
+ useState();
+ const [reload, setReload] = useState(0);
+ const [state, setState] = useState<
+ | { status: "LOADING" }
+ | { status: "ERROR"; problem: StudioGatewayError | Error }
+ | { status: "READY"; page: PublicationPage }
+ >({ status: "LOADING" });
+ const [selected, setSelected] = useState(null);
+ const [pending, setPending] = useState(false);
+ const [dialogError, setDialogError] = useState("");
+ const [announcement, setAnnouncement] = useState("");
+ const triggerRef = useRef(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ let current = true;
+ setState({ status: "LOADING" });
+ void studio.gateway.listPublications(query, {
+ signal: controller.signal,
+ }).then(
+ (page) => {
+ if (current) setState({ status: "READY", page });
+ },
+ (error: unknown) => {
+ if (!current || controller.signal.aborted) return;
+ setState({
+ status: "ERROR",
+ problem:
+ error instanceof Error
+ ? error
+ : new Error("게시 기록을 불러오지 못했습니다."),
+ });
+ },
+ );
+ return () => {
+ current = false;
+ controller.abort();
+ };
+ }, [query, reload, studio.gateway]);
+
+ const applyFilter = (event: FormEvent) => {
+ event.preventDefault();
+ setQuery({
+ ...(queryDraft.trim() ? { q: queryDraft.trim().slice(0, 100) } : {}),
+ ...(typeDraft ? { type: typeDraft } : {}),
+ limit: 100,
+ });
+ };
+
+ const dismiss = () => {
+ setSelected(null);
+ setDialogError("");
+ queueMicrotask(() => triggerRef.current?.focus());
+ };
+
+ const confirmUnpublish = async () => {
+ if (
+ !selected ||
+ pending ||
+ !selected.availableActions.includes("UNPUBLISH")
+ ) {
+ return;
+ }
+ setPending(true);
+ setDialogError("");
+ try {
+ const result = await studio.gateway.unpublishPublication(
+ selected.publication.publicationId,
+ {
+ expectedPublicationRevision:
+ selected.publication.publicationRevision,
+ },
+ { idempotencyKey: createLocalId("studio-unpublish") },
+ );
+ setSelected(null);
+ setAnnouncement("게시를 취소했습니다.");
+ studio.setRequestAnnouncement("게시를 취소했습니다.");
+ setReload((value) => value + 1);
+ } catch (error) {
+ setDialogError(
+ isStudioGatewayError(error)
+ ? error.problem.detail
+ : "게시를 취소하지 못했습니다.",
+ );
+ } finally {
+ setPending(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {announcement ? (
+
+ {announcement}
+
+ ) : null}
+ {state.status === "LOADING" ? (
+
+ 게시 기록을 불러오는 중입니다.
+
+ ) : null}
+ {state.status === "ERROR" ? (
+
+ 게시 기록을 불러오지 못했습니다
+
+ {isStudioGatewayError(state.problem)
+ ? state.problem.problem.detail
+ : state.problem.message}
+
+ setReload((value) => value + 1)}>
+ 다시 시도
+
+
+ ) : null}
+ {state.status === "READY" && state.page.items.length === 0 ? (
+
+ 조건에 맞는 게시 기록이 없습니다
+ 검색어 또는 이벤트 필터를 바꿔 보세요.
+
+ ) : null}
+ {state.status === "READY" && state.page.items.length > 0 ? (
+
+ {state.page.items.map((item) => {
+ const href = snapshotHref(item);
+ const source = item.availableActions.includes(
+ "VIEW_SOURCE_SNAPSHOT",
+ );
+ const canUnpublish = item.availableActions.includes("UNPUBLISH");
+ return (
+
+
+
+ {eventLabels[item.event.type]}
+
+
+
+ {kindLabels[item.document.kind]} · v
+ {item.event.publishedVersion}
+
+
{item.document.title || "제목 없는 작업본"}
+
+ 이벤트: {eventLabels[item.event.type]} · 현재 상태:{" "}
+ {item.publication.status === "PUBLISHED"
+ ? "게시 중"
+ : "게시 취소"}
+
+
+
+ {dateTime(item.event.occurredAt)}
+
+
+ {href ? (
+
+ {source ? "게시 취소 전 Snapshot 보기" : "Snapshot 보기"}
+
+ ) : null}
+ {canUnpublish ? (
+ {
+ triggerRef.current = event.currentTarget;
+ setDialogError("");
+ setSelected(item);
+ }}
+ >
+ 게시 취소
+
+ ) : null}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+
{
+ void confirmUnpublish();
+ }}
+ classes={styles}
+ />
+
+ );
+}
diff --git a/src/features/tech-log/presentation/studio/components/publish-screen.tsx b/src/features/tech-log/presentation/studio/components/publish-screen.tsx
new file mode 100644
index 0000000..d45686d
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/components/publish-screen.tsx
@@ -0,0 +1,403 @@
+/* eslint-disable react-hooks/set-state-in-effect -- a changed document or retry intentionally enters a fresh loading state. */
+
+import { useEffect, useMemo, useState } from "react";
+
+import {
+ isStudioGatewayError,
+ type StudioGatewayError,
+} from "../../../application/ports/studio-gateway-error.ts";
+import type {
+ PreviewDetail,
+ WorkingCopyDetail,
+} from "../../../contracts/studio/contract.ts";
+import { deriveValidationState } from "../../../domain/studio/document-state.ts";
+import { createLocalId } from "../../../domain/studio/local-id.ts";
+import { GuardedStudioLink } from "./guarded-studio-link.tsx";
+import {
+ defaultPublicationFlowClasses,
+ type PublicationFlowClasses,
+} from "./publication-flow-classes.ts";
+import { useStudio } from "../use-studio.ts";
+import { WarningAcknowledgements } from "./warning-acknowledgements.tsx";
+
+type LoadState =
+ | { status: "LOADING" }
+ | { status: "ERROR"; problem: StudioGatewayError | Error }
+ | { status: "READY"; detail: WorkingCopyDetail; preview: PreviewDetail | null };
+
+function dateTime(value: string) {
+ return new Intl.DateTimeFormat("ko-KR", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "Asia/Seoul",
+ }).format(new Date(value));
+}
+
+function notFound(problem: StudioGatewayError | Error) {
+ return isStudioGatewayError(problem) && problem.code === "DOCUMENT_NOT_FOUND";
+}
+
+export function PublishScreen({
+ documentId,
+ classes: styles = defaultPublicationFlowClasses,
+}: {
+ documentId: string;
+ classes?: PublicationFlowClasses;
+}) {
+ const studio = useStudio();
+ const [attempt, setAttempt] = useState(0);
+ const [state, setState] = useState({ status: "LOADING" });
+ const [acknowledged, setAcknowledged] = useState>(
+ () => new Set(),
+ );
+ const [pending, setPending] = useState(false);
+ const [actionError, setActionError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ useEffect(() => {
+ const controller = new AbortController();
+ let current = true;
+ setState({ status: "LOADING" });
+ setAcknowledged(new Set());
+ setActionError("");
+ setSuccess("");
+
+ void (async () => {
+ const detail = await studio.gateway.getDocument(documentId, {
+ signal: controller.signal,
+ });
+ const sameVersion =
+ detail.currentPublication?.status === "PUBLISHED" &&
+ detail.currentPublication.publishedVersion === detail.document.version;
+ let preview: PreviewDetail | null = null;
+ if (!sameVersion && detail.latestPreview) {
+ try {
+ preview = await studio.gateway.getCurrentPreview(documentId, {
+ signal: controller.signal,
+ });
+ } catch (error) {
+ if (
+ !(
+ isStudioGatewayError(error) &&
+ error.code === "PREVIEW_NOT_FOUND"
+ )
+ ) {
+ throw error;
+ }
+ }
+ }
+ if (current) setState({ status: "READY", detail, preview });
+ })().catch((error: unknown) => {
+ if (!current || controller.signal.aborted) return;
+ setState({
+ status: "ERROR",
+ problem:
+ error instanceof Error
+ ? error
+ : new Error("게시 정보를 불러오지 못했습니다."),
+ });
+ });
+
+ return () => {
+ current = false;
+ controller.abort();
+ };
+ }, [attempt, documentId, studio.gateway]);
+
+ const ready = state.status === "READY" ? state : null;
+ const validation = ready?.detail.currentValidation ?? null;
+ const warnings = useMemo(
+ () =>
+ validation?.issues.filter((issue) => issue.severity === "WARNING") ?? [],
+ [validation],
+ );
+
+ if (state.status === "LOADING") {
+ return (
+
+ 게시 준비 정보를 불러오는 중입니다.
+
+ );
+ }
+ if (state.status === "ERROR" && notFound(state.problem)) {
+ return (
+
+ NOT FOUND
+ 작업본을 찾을 수 없습니다
+ 이 작업본은 현재 Studio 세션에 없습니다.
+
+ 작업본으로 돌아가기
+
+
+ );
+ }
+ if (state.status === "ERROR") {
+ return (
+
+ PUBLISH ERROR
+ 게시 정보를 불러오지 못했습니다
+
+ {isStudioGatewayError(state.problem)
+ ? state.problem.problem.detail
+ : state.problem.message}
+
+ setAttempt((value) => value + 1)}>
+ 다시 시도
+
+
+ );
+ }
+
+ const { detail, preview } = state;
+ const { document, currentPublication } = detail;
+ const sameVersion =
+ currentPublication?.status === "PUBLISHED" &&
+ currentPublication.publishedVersion === document.version;
+ const validationMatches =
+ validation !== null &&
+ deriveValidationState({ ...detail, now: studio.now() }).freshness ===
+ "CURRENT";
+ const validationAllowsPreview =
+ validationMatches && validation?.status !== "INVALID";
+ const previewIsCurrent =
+ preview?.state === "CURRENT" &&
+ preview.preview.previewVersion === document.version &&
+ preview.currentValidationId === validation?.validationId;
+ const warningsDone = warnings.every((warning) =>
+ acknowledged.has(warning.code),
+ );
+ const canPublish =
+ validationAllowsPreview && previewIsCurrent && warningsDone && !pending;
+ const actionLabel = currentPublication ? "다시 게시" : "게시";
+
+ const toggleWarning = (code: string, checked: boolean) => {
+ setAcknowledged((current) => {
+ const next = new Set(current);
+ if (checked) next.add(code);
+ else next.delete(code);
+ return next;
+ });
+ };
+
+ const publish = async () => {
+ if (!canPublish || !validation || !preview) return;
+ setPending(true);
+ setActionError("");
+ setSuccess("");
+ try {
+ const result = await studio.gateway.publishDocument(
+ document.id,
+ {
+ expectedVersion: document.version,
+ validationId: validation.validationId,
+ previewId: preview.preview.previewId,
+ acknowledgedWarningCodes: [...acknowledged].sort(),
+ },
+ { idempotencyKey: createLocalId("studio-publish") },
+ );
+ setSuccess("게시했습니다.");
+ studio.setRequestAnnouncement("게시했습니다.");
+ studio.clearEditor();
+ studio.navigateInternal(
+ `/studio/publications/${result.event.publicationEventId}/preview`,
+ );
+ } catch (error) {
+ setActionError(
+ isStudioGatewayError(error)
+ ? error.problem.detail
+ : "게시하지 못했습니다. 다시 시도해 주세요.",
+ );
+ } finally {
+ setPending(false);
+ }
+ };
+
+ if (sameVersion && currentPublication) {
+ return (
+
+
+ PUBLICATION
+ 게시 준비
+ {document.title}
+
+
+ 현재 버전이 이미 게시되어 있습니다
+
+ 저장 버전 v{document.version}과 게시 버전이 같습니다. 새 게시
+ 이벤트를 만들지 않습니다.
+
+
+ 현재 게시 Snapshot 보기
+
+
+
+ );
+ }
+
+ let blocked: {
+ title: string;
+ detail: string;
+ href: string;
+ label: string;
+ } | null = null;
+ if (!validation) {
+ blocked = {
+ title: "저장 버전 검증이 필요합니다",
+ detail: "게시 전에 현재 저장 버전을 검증해 주세요.",
+ href: `/studio/documents/${document.id}/validation`,
+ label: "검증하기",
+ };
+ } else if (!validationMatches) {
+ blocked = {
+ title: "검증 결과가 현재 버전과 다릅니다",
+ detail: "현재 저장 버전으로 다시 검증해야 합니다.",
+ href: `/studio/documents/${document.id}/validation`,
+ label: "다시 검증",
+ };
+ } else if (validation.status === "INVALID") {
+ blocked = {
+ title: "검증 오류를 먼저 수정해야 합니다",
+ detail: `${validation.issues.filter((issue) => issue.severity === "ERROR").length}개의 오류가 게시를 막고 있습니다.`,
+ href: `/studio/documents/${document.id}/validation`,
+ label: "검증 오류 보기",
+ };
+ } else if (!preview) {
+ blocked = {
+ title: "Public Preview가 필요합니다",
+ detail: "검증된 저장 버전의 공개 레이아웃을 먼저 확인해 주세요.",
+ href: `/studio/documents/${document.id}/preview`,
+ label: "Public Preview 만들기",
+ };
+ } else if (!previewIsCurrent) {
+ blocked = {
+ title:
+ preview.state === "EXPIRED"
+ ? "Public Preview가 만료되었습니다"
+ : "Public Preview가 오래되었습니다",
+ detail: "현재 검증과 저장 버전으로 Preview를 다시 만들어야 합니다.",
+ href: `/studio/documents/${document.id}/preview`,
+ label: "Public Preview 다시 만들기",
+ };
+ }
+
+ return (
+
+
+
+
+
+
+
저장 버전
+ v{document.version}
+
+
+
검증
+
+ {validation
+ ? `${validation.status} · v${validation.validatedVersion}`
+ : "실행 전"}
+
+
+
+
Public Preview
+
+ {preview
+ ? `${preview.state} · v${preview.preview.previewVersion}`
+ : "없음"}
+
+
+
+
현재 게시
+
+ {currentPublication
+ ? `${currentPublication.status} · v${currentPublication.publishedVersion}`
+ : "게시 전"}
+
+
+
+
+
+
+ CHANGE SUMMARY
+ 변경 요약
+
+ {currentPublication
+ ? `게시 버전 v${currentPublication.publishedVersion}에서 저장 버전 v${document.version}으로 반영합니다.`
+ : `저장 버전 v${document.version}을 처음 게시합니다.`}
+
+ {preview ? (
+
+ Preview 만료:{" "}
+
+ {dateTime(preview.preview.expiresAt)}
+
+
+ ) : null}
+
+
+ {blocked ? (
+
+ {blocked.title}
+ {blocked.detail}
+
+ {blocked.label}
+
+
+ ) : (
+
+ {actionLabel}
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+ {success ? (
+
+ {success}
+
+ ) : null}
+
+
+ Preview로 돌아가기
+
+ {
+ void publish();
+ }}
+ >
+ {pending ? "게시 중…" : actionLabel}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx b/src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx
new file mode 100644
index 0000000..184d601
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useId, useRef, type KeyboardEvent } from "react";
+
+import type { PublicationListItem } from "../../../contracts/studio/contract.ts";
+import {
+ defaultPublicationFlowClasses,
+ type PublicationFlowClasses,
+} from "./publication-flow-classes.ts";
+
+export function UnpublishDialog({
+ item,
+ pending,
+ error,
+ onDismiss,
+ onConfirm,
+ classes: styles = defaultPublicationFlowClasses,
+}: {
+ item: PublicationListItem | null;
+ pending: boolean;
+ error: string;
+ onDismiss(): void;
+ onConfirm(): void;
+ classes?: PublicationFlowClasses;
+}) {
+ const dialogRef = useRef(null);
+ const cancelRef = useRef(null);
+ const titleId = useId();
+ const descriptionId = useId();
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ if (item && !dialog.open) {
+ dialog.showModal();
+ cancelRef.current?.focus();
+ } else if (!item && dialog.open) {
+ dialog.close();
+ }
+ }, [item]);
+
+ useEffect(
+ () => () => {
+ if (dialogRef.current?.open) dialogRef.current.close();
+ },
+ [],
+ );
+
+ const trapFocus = (event: KeyboardEvent) => {
+ if (event.key !== "Tab") return;
+ const controls = Array.from(
+ event.currentTarget.querySelectorAll(
+ "button:not([disabled])",
+ ),
+ );
+ if (!controls.length) return;
+ const first = controls[0];
+ const last = controls.at(-1)!;
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+
+ return (
+ {
+ event.preventDefault();
+ if (!pending) onDismiss();
+ }}
+ onKeyDown={trapFocus}
+ >
+
+
UNPUBLISH
+
게시를 취소할까요?
+
+ {item?.document.title ?? "선택한 기록"}의 Studio 게시 상태를 중단하고
+ 게시 취소 이벤트를 남깁니다.
+
+
+
작업본과 이전 Snapshot은 보존됩니다.
+
+ 현재 Mock에서는 기존 Public 사이트와 검색 결과를 변경하지 않습니다.
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ 계속 게시
+
+
+ {pending ? "취소 처리 중…" : "게시 취소 확인"}
+
+
+
+
+ );
+}
diff --git a/src/features/tech-log/presentation/studio/components/validation-report.tsx b/src/features/tech-log/presentation/studio/components/validation-report.tsx
index ec92eeb..c2cbf74 100644
--- a/src/features/tech-log/presentation/studio/components/validation-report.tsx
+++ b/src/features/tech-log/presentation/studio/components/validation-report.tsx
@@ -39,7 +39,7 @@ export function ValidationReport({
report: ValidationReportModel;
current: boolean;
}) {
- const issues = report.issues.toSorted((left, right) => {
+ const issues = [...report.issues].sort((left, right) => {
const rank = { ERROR: 0, WARNING: 1 } as const;
return rank[left.severity] - rank[right.severity];
});
diff --git a/src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx b/src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx
new file mode 100644
index 0000000..1deef9e
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx
@@ -0,0 +1,64 @@
+import { useId } from "react";
+
+import type { components } from "../../../contracts/studio/generated.ts";
+import {
+ defaultPublicationFlowClasses,
+ type PublicationFlowClasses,
+} from "./publication-flow-classes.ts";
+
+type ValidationIssue = components["schemas"]["ValidationIssue"];
+
+export function WarningAcknowledgements({
+ warnings,
+ acknowledged,
+ onToggle,
+ classes: styles = defaultPublicationFlowClasses,
+}: {
+ warnings: ValidationIssue[];
+ acknowledged: ReadonlySet;
+ onToggle(code: string, checked: boolean): void;
+ classes?: PublicationFlowClasses;
+}) {
+ const groupId = useId();
+
+ if (warnings.length === 0) {
+ return 확인할 경고가 없습니다.
;
+ }
+
+ return (
+
+ 게시 전 경고 확인
+
+ 현재 검증의 경고를 모두 확인해야 게시할 수 있습니다.
+
+
+ {warnings.map((warning, index) => {
+ const inputId = `${groupId}-warning-${index}`;
+ return (
+
+
+ onToggle(warning.code, event.currentTarget.checked)
+ }
+ />
+
+ {warning.code}
+ {warning.message}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/features/tech-log/presentation/studio/pages/document-publish-page.tsx b/src/features/tech-log/presentation/studio/pages/document-publish-page.tsx
new file mode 100644
index 0000000..6328095
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/pages/document-publish-page.tsx
@@ -0,0 +1,8 @@
+import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
+import { PublishScreen } from "../components/publish-screen.tsx";
+
+export function DocumentPublishPage() {
+ const { params } =
+ useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_PUBLISH">();
+ return ;
+}
diff --git a/src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx b/src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx
new file mode 100644
index 0000000..2cc887a
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx
@@ -0,0 +1,12 @@
+import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
+import { PublicationEventPreviewScreen } from "../components/publication-event-preview-screen.tsx";
+
+export function PublicationPreviewPage() {
+ const { params } =
+ useRouteInput<"TECH_LOG_STUDIO_PUBLICATION_PREVIEW">();
+ return (
+
+ );
+}
diff --git a/src/features/tech-log/presentation/studio/pages/publications-page.tsx b/src/features/tech-log/presentation/studio/pages/publications-page.tsx
new file mode 100644
index 0000000..899aec8
--- /dev/null
+++ b/src/features/tech-log/presentation/studio/pages/publications-page.tsx
@@ -0,0 +1,5 @@
+import { PublicationList } from "../components/publication-list.tsx";
+
+export function PublicationsPage() {
+ return ;
+}
diff --git a/src/features/tech-log/presentation/tech-log-route-runtime.tsx b/src/features/tech-log/presentation/tech-log-route-runtime.tsx
new file mode 100644
index 0000000..852630f
--- /dev/null
+++ b/src/features/tech-log/presentation/tech-log-route-runtime.tsx
@@ -0,0 +1,234 @@
+import {
+ lazy,
+ type ComponentType,
+ type LazyExoticComponent,
+ type ReactNode,
+} from "react";
+import { Outlet } from "react-router-dom";
+
+import {
+ TECH_LOG_ROUTE_RUNTIME_CONTRACT,
+ type TechLogRouteId,
+} from "../contracts/tech-log-route-contract.ts";
+import { PublicShell } from "./public/public-shell.tsx";
+import { StudioShell } from "./studio/studio-shell.tsx";
+
+type RouteRuntime = Readonly<{
+ moduleId: string;
+ Component: LazyExoticComponent;
+}>;
+
+function runtime(
+ routeId: TechLogRouteId,
+ load: () => Promise<{ default: ComponentType }>,
+): RouteRuntime {
+ return Object.freeze({
+ moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
+ Component: lazy(load),
+ });
+}
+
+function routeModule(
+ load: () => Promise,
+ key: Key,
+): () => Promise<{ default: Extract }> {
+ return async () => {
+ const module = await load();
+ return { default: module[key] as Extract };
+ };
+}
+
+export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
+ TECH_LOG_HOME: runtime(
+ "TECH_LOG_HOME",
+ routeModule(() => import("./public/pages/home-page.tsx"), "HomePage"),
+ ),
+ TECH_LOG_EXPLORE: runtime(
+ "TECH_LOG_EXPLORE",
+ routeModule(
+ () => import("./public/pages/explore-page.tsx"),
+ "ExplorePage",
+ ),
+ ),
+ TECH_LOG_EXPLORE_KIND: runtime(
+ "TECH_LOG_EXPLORE_KIND",
+ routeModule(
+ () => import("./public/pages/explore-kind-page.tsx"),
+ "ExploreKindPage",
+ ),
+ ),
+ TECH_LOG_CASE: runtime(
+ "TECH_LOG_CASE",
+ routeModule(() => import("./public/pages/case-page.tsx"), "CasePage"),
+ ),
+ TECH_LOG_REFERENCE: runtime(
+ "TECH_LOG_REFERENCE",
+ routeModule(
+ () => import("./public/pages/reference-page.tsx"),
+ "ReferencePage",
+ ),
+ ),
+ TECH_LOG_QUESTION: runtime(
+ "TECH_LOG_QUESTION",
+ routeModule(
+ () => import("./public/pages/question-page.tsx"),
+ "QuestionPage",
+ ),
+ ),
+ TECH_LOG_TOPIC: runtime(
+ "TECH_LOG_TOPIC",
+ routeModule(() => import("./public/pages/topic-page.tsx"), "TopicPage"),
+ ),
+ TECH_LOG_PROJECTS: runtime(
+ "TECH_LOG_PROJECTS",
+ routeModule(
+ () => import("./public/pages/projects-page.tsx"),
+ "ProjectsPage",
+ ),
+ ),
+ TECH_LOG_PROJECT: runtime(
+ "TECH_LOG_PROJECT",
+ routeModule(
+ () => import("./public/pages/project-overview-page.tsx"),
+ "ProjectOverviewPage",
+ ),
+ ),
+ TECH_LOG_PROJECT_RECORDS: runtime(
+ "TECH_LOG_PROJECT_RECORDS",
+ routeModule(
+ () => import("./public/pages/project-records-page.tsx"),
+ "ProjectRecordsPage",
+ ),
+ ),
+ TECH_LOG_PROJECT_DECISIONS: runtime(
+ "TECH_LOG_PROJECT_DECISIONS",
+ routeModule(
+ () => import("./public/pages/project-decisions-page.tsx"),
+ "ProjectDecisionsPage",
+ ),
+ ),
+ TECH_LOG_PROJECT_ACTIVITY: runtime(
+ "TECH_LOG_PROJECT_ACTIVITY",
+ routeModule(
+ () => import("./public/pages/project-activity-page.tsx"),
+ "ProjectActivityPage",
+ ),
+ ),
+ TECH_LOG_RELEASES: runtime(
+ "TECH_LOG_RELEASES",
+ routeModule(
+ () => import("./public/pages/releases-page.tsx"),
+ "ReleasesPage",
+ ),
+ ),
+ TECH_LOG_RELEASE: runtime(
+ "TECH_LOG_RELEASE",
+ routeModule(
+ () => import("./public/pages/release-page.tsx"),
+ "ReleasePage",
+ ),
+ ),
+ TECH_LOG_PROFILE: runtime(
+ "TECH_LOG_PROFILE",
+ routeModule(
+ () => import("./public/pages/profile-page.tsx"),
+ "ProfilePage",
+ ),
+ ),
+ TECH_LOG_SEARCH: runtime(
+ "TECH_LOG_SEARCH",
+ routeModule(() => import("./public/pages/search-page.tsx"), "SearchPage"),
+ ),
+ TECH_LOG_STUDIO_HOME: runtime(
+ "TECH_LOG_STUDIO_HOME",
+ routeModule(
+ () => import("./studio/pages/studio-home-page.tsx"),
+ "StudioHomePage",
+ ),
+ ),
+ TECH_LOG_STUDIO_DOCUMENTS: runtime(
+ "TECH_LOG_STUDIO_DOCUMENTS",
+ routeModule(
+ () => import("./studio/pages/documents-page.tsx"),
+ "DocumentsPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_DOCUMENT_NEW: runtime(
+ "TECH_LOG_STUDIO_DOCUMENT_NEW",
+ routeModule(
+ () => import("./studio/pages/new-document-page.tsx"),
+ "NewDocumentPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_DOCUMENT_EDIT: runtime(
+ "TECH_LOG_STUDIO_DOCUMENT_EDIT",
+ routeModule(
+ () => import("./studio/pages/document-edit-page.tsx"),
+ "DocumentEditPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_DOCUMENT_VALIDATION: runtime(
+ "TECH_LOG_STUDIO_DOCUMENT_VALIDATION",
+ routeModule(
+ () => import("./studio/pages/document-validation-page.tsx"),
+ "DocumentValidationPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_DOCUMENT_PREVIEW: runtime(
+ "TECH_LOG_STUDIO_DOCUMENT_PREVIEW",
+ routeModule(
+ () => import("./studio/pages/document-preview-page.tsx"),
+ "DocumentPreviewPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_DOCUMENT_PUBLISH: runtime(
+ "TECH_LOG_STUDIO_DOCUMENT_PUBLISH",
+ routeModule(
+ () => import("./studio/pages/document-publish-page.tsx"),
+ "DocumentPublishPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_PUBLICATIONS: runtime(
+ "TECH_LOG_STUDIO_PUBLICATIONS",
+ routeModule(
+ () => import("./studio/pages/publications-page.tsx"),
+ "PublicationsPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_PUBLICATION_PREVIEW: runtime(
+ "TECH_LOG_STUDIO_PUBLICATION_PREVIEW",
+ routeModule(
+ () => import("./studio/pages/publication-preview-page.tsx"),
+ "PublicationPreviewPage",
+ ),
+ ),
+ TECH_LOG_STUDIO_NOT_FOUND: runtime(
+ "TECH_LOG_STUDIO_NOT_FOUND",
+ routeModule(
+ () => import("./studio/pages/studio-not-found-page.tsx"),
+ "StudioNotFoundPage",
+ ),
+ ),
+ NOT_FOUND: runtime(
+ "NOT_FOUND",
+ routeModule(
+ () => import("./public/pages/public-not-found-page.tsx"),
+ "PublicNotFoundPage",
+ ),
+ ),
+}) satisfies Readonly>;
+
+export const TECH_LOG_ROUTE_LAYOUTS: Readonly<
+ Record<"PUBLIC" | "STUDIO", ReactNode>
+> = Object.freeze({
+ PUBLIC: (
+
+
+
+ ),
+ STUDIO: (
+
+
+
+ ),
+});
diff --git a/src/presentation/examples/auth-example-page.tsx b/src/presentation/examples/auth-example-page.tsx
deleted file mode 100644
index aa7a1b3..0000000
--- a/src/presentation/examples/auth-example-page.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useState } from "react";
-import { useLocation } from "react-router-dom";
-
-import { PageHeader } from "../design-system/index.ts";
-import { useSession } from "../providers/session-provider.tsx";
-
-export default function AuthExamplePage() {
- const location = useLocation();
- const { sessionState, beginSignIn, signOut, recover } = useSession();
- const [pending, setPending] = useState(false);
- const [failed, setFailed] = useState(false);
-
- async function execute(action: () => Promise): Promise {
- setPending(true);
- setFailed(false);
- try {
- await action();
- } catch {
- setFailed(true);
- } finally {
- setPending(false);
- }
- }
-
- return (
-
-
-
-
-
현재 세션 상태
-
- {sessionState}
-
-
-
-
- void execute(() =>
- beginSignIn(`${location.pathname}${location.search}`),
- )
- }
- >
- 로그인 시작
-
- void execute(signOut)}
- >
- 로그아웃
-
- void execute(recover)}
- >
- 세션 복구
-
-
- {sessionState === "integration-failed" ? (
-
- 외부 인증 소유자가 연결되지 않았습니다. 런타임 호스트의 인증
- 계약을 연결하세요.
-
- ) : null}
- {failed ? 인증 작업을 완료하지 못했습니다.
: null}
-
-
- );
-}
diff --git a/src/presentation/examples/platform-overview-page.tsx b/src/presentation/examples/platform-overview-page.tsx
deleted file mode 100644
index ce7b1d4..0000000
--- a/src/presentation/examples/platform-overview-page.tsx
+++ /dev/null
@@ -1,484 +0,0 @@
-import { useEffect, useState } from "react";
-
-import { HTTP_EXECUTION_CEILINGS } from "../../contracts/external-contract-runtime.ts";
-import { SERVER_STATE_PROFILES } from "../../contracts/server-state.ts";
-import {
- COMPOSED_CONTRACT_CONTRIBUTIONS,
- EXPECTED_CONTRACT_SET_PACKAGES,
-} from "../../features/installed-contract-contributions.ts";
-import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts";
-import type {
- RuntimeCapabilityId,
- RuntimeCapabilityStatus,
-} from "../../contracts/runtime-capabilities.ts";
-import {
- Badge,
- Card,
- DataTable,
- EmptySurface,
- PageHeader,
- type DataTableColumn,
-} from "../design-system/index.ts";
-import { useApplication } from "../providers/application-provider.tsx";
-
-/**
- * Every number and row on this page is read from an installed registry at
- * render time. Nothing is transcribed by hand, so deleting a feature removes
- * its rows and the page keeps describing what the repository actually is.
- */
-
-function kilobytes(bytes: number): string {
- if (bytes === 0) return "없음";
- if (bytes >= 1_048_576) return `${bytes / 1_048_576} MiB`;
- return `${bytes / 1024} KiB`;
-}
-
-function seconds(milliseconds: number): string {
- return milliseconds < 1000
- ? `${milliseconds}ms`
- : `${milliseconds / 1000}초`;
-}
-
-function Metric({
- label,
- value,
- hint,
-}: Readonly<{ label: string; value: string; hint?: string }>) {
- return (
-
-
{label}
-
- {value}
- {hint ? {hint} : null}
-
-
- );
-}
-
-type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY];
-
-const ROUTE_COLUMNS: readonly DataTableColumn[] = Object.freeze([
- {
- id: "routeId",
- header: "라우트",
- cell: (row) => {row.routeId},
- },
- { id: "path", header: "경로", cell: (row) => {row.path} },
- {
- id: "access",
- header: "접근",
- cell: (row) => (
-
- {row.access}
-
- ),
- },
- {
- id: "schemas",
- header: "입력 스키마",
- cell: (row) =>
- [row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음",
- },
- {
- id: "chunkId",
- header: "청크",
- cell: (row) => {row.chunkId},
- },
-]);
-
-type OperationRow = Readonly<{
- operationId: string;
- method: string;
- pathTemplate: string;
- retrySemantics: string;
- retryBudget: number;
- totalDeadlineMs: number;
- requestByteLimit: number;
- responseByteLimit: number;
- effect: string;
- recovery: string;
-}>;
-
-const OPERATION_COLUMNS: readonly DataTableColumn[] =
- Object.freeze([
- {
- id: "operationId",
- header: "오퍼레이션",
- cell: (row) => (
- <>
- {row.operationId}
-
- {row.method} {row.pathTemplate}
-
- >
- ),
- },
- {
- id: "retry",
- header: "재시도",
- cell: (row) => (
- <>
-
- {row.retrySemantics}
-
-
- 예산 {row.retryBudget}회
-
- >
- ),
- },
- {
- id: "effect",
- header: "효과 확정성",
- cell: (row) => row.effect,
- },
- {
- id: "recovery",
- header: "복구",
- cell: (row) => row.recovery,
- },
- {
- id: "budget",
- header: "예산",
- cell: (row) => (
- <>
-
- 요청 {kilobytes(row.requestByteLimit)} · 응답{" "}
- {kilobytes(row.responseByteLimit)}
-
-
- 마감 {seconds(row.totalDeadlineMs)}
-
- >
- ),
- },
- ]);
-
-type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES];
-
-const PROFILE_COLUMNS: readonly DataTableColumn[] = Object.freeze([
- {
- id: "profileId",
- header: "프로파일",
- cell: (row) => {row.profileId},
- },
- { id: "stale", header: "stale", cell: (row) => seconds(row.staleTimeMs) },
- { id: "gc", header: "gc", cell: (row) => seconds(row.gcTimeMs) },
- {
- id: "refetch",
- header: "재조회",
- cell: (row) =>
- [
- row.refetchOnMount === "always"
- ? "mount(always)"
- : row.refetchOnMount && "mount",
- row.refetchOnFocus && "focus",
- row.refetchOnReconnect && "reconnect",
- ]
- .filter(Boolean)
- .join(" · "),
- },
- {
- id: "budget",
- header: "결과 예산",
- cell: (row) =>
- `${row.maxResultItems}건 · ${kilobytes(row.maxEstimatedResultBytes)}`,
- },
-]);
-
-const CAPABILITY_COPY: Readonly<
- Record>
-> = Object.freeze({
- REALTIME: Object.freeze({
- label: "실시간 수신",
- description:
- "WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.",
- }),
- WEB_WORKER: Object.freeze({
- label: "웹 워커",
- description:
- "워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.",
- }),
- SERVICE_WORKER: Object.freeze({
- label: "서비스 워커",
- description:
- "참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.",
- }),
- OFFLINE_COMMANDS: Object.freeze({
- label: "오프라인 명령",
- description:
- "명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.",
- }),
-});
-
-/**
- * A capability that was never selected and one an operator switched off look
- * identical if both are reported as "off". The snapshot separates them, and so
- * does this badge.
- */
-function capabilityBadge(
- status: RuntimeCapabilityStatus,
-): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> {
- if (status.selected === 0) return { text: "미선택", variant: "neutral" };
- if (status.active === 0) {
- return { text: "운영자가 비활성화함", variant: "warning" };
- }
- return { text: `활성 (${status.active})`, variant: "success" };
-}
-
-function buildOperationRows(): readonly OperationRow[] {
- return Object.freeze(
- [...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map(
- (installed) => {
- const { contract, frontend } = installed;
- return Object.freeze({
- operationId: contract.operationId,
- method: contract.method,
- pathTemplate: contract.pathTemplate,
- retrySemantics: contract.retrySemantics,
- retryBudget: frontend.retryBudget,
- totalDeadlineMs: frontend.totalDeadlineMs,
- requestByteLimit: frontend.requestByteLimit,
- responseByteLimit: frontend.responseByteLimit,
- effect:
- contract.commandEffect === null
- ? "해당 없음"
- : contract.commandEffect.successEffect,
- recovery:
- contract.commandRecovery === null
- ? "해당 없음"
- : contract.commandRecovery.mode,
- });
- },
- ),
- );
-}
-
-export default function PlatformOverviewPage() {
- const { runtime } = useApplication();
- const [release, setRelease] = useState<
- Awaited> | null
- >(null);
-
- useEffect(() => {
- let active = true;
- void runtime.getReleaseSummary().then((summary) => {
- if (active) setRelease(summary);
- });
- return () => {
- active = false;
- };
- }, [runtime]);
-
- const routes = Object.values(ROUTE_REGISTRY);
- const operations = buildOperationRows();
- const capabilities = runtime.getCapabilitySnapshot();
- const activeCapabilityCount = capabilities.filter(
- (status) => status.active > 0,
- ).length;
- const fixtureContributions =
- COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
- (contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
- ).length;
-
- return (
-
-
-
-
-
-
- {release ? (
- <>
-
-
-
-
- >
- ) : (
-
- )}
-
-
-
-
-
-
- 레지스트리 항목 수를 그대로 센 값입니다.
-
-
-
-
-
-
-
-
-
-
-
- row.routeId}
- empty={
-
- }
- />
-
-
-
-
- row.operationId}
- empty={
-
- }
- />
-
-
-
-
- row.profileId}
- empty={ }
- />
-
-
-
-
-
-
-
-
-
-
-
-
- 정적 선택 파일이 단일 진실 공급원입니다. 런타임 설정은 이미 선택된
- 능력을 끌 수만 있고, 설정 문자열로 새 능력을 켜거나 모듈 경로를 만들지
- 못합니다. 여기 표시되는 상태는 정적 선택에 런타임 오버라이드를 적용한
- 결과이므로, 애초에 선택되지 않은 능력과 운영자가 끈 능력이 구분됩니다.
-
-
-
- {capabilities.map((status) => {
- const copy = CAPABILITY_COPY[status.capabilityId];
- const badge = capabilityBadge(status);
- return (
-
{badge.text}}
- >
- {copy.description}
-
- );
- })}
-
-
-
- );
-}
diff --git a/src/presentation/examples/state-gallery-page.tsx b/src/presentation/examples/state-gallery-page.tsx
deleted file mode 100644
index 0250bc7..0000000
--- a/src/presentation/examples/state-gallery-page.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { useState } from "react";
-
-import { deriveAsyncState } from "../../application/view-models/async-state.ts";
-import { createFailure } from "../../contracts/errors.ts";
-import {
- AsyncSurface,
- EmptySurface,
- LoadingSurface,
- TerminalErrorSurface,
- AuthRequiredSurface,
- ForbiddenSurface,
- NotFoundSurface,
- Button,
- Card,
- PageHeader,
-} from "../design-system/index.ts";
-
-export default function StateGalleryPage() {
- const [lastAction, setLastAction] = useState(
- "상태 화면의 작업을 선택하면 결과가 여기에 표시됩니다.",
- );
- const refreshingState = deriveAsyncState({
- data: ["기존 데이터"],
- isFetching: true,
- });
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- 기존 콘텐츠는 계속 표시됩니다.
-
-
-
- setLastAction("빈 화면 작업을 실행했습니다.")}>
- 첫 작업 시작
-
- }
- />
-
-
- setLastAction("오류 요청을 다시 시도했습니다.")}
- />
-
-
-
-
-
-
-
-
setLastAction("로그인 연동 작업을 시작했습니다.")}
- />
- setLastAction("접근 가능한 화면으로 이동합니다.")}
- />
- setLastAction("시작 화면으로 이동합니다.")}
- />
-
-
-
-
- {lastAction}
-
-
- );
-}
diff --git a/src/presentation/examples/ui-gallery-page.tsx b/src/presentation/examples/ui-gallery-page.tsx
deleted file mode 100644
index 5f2935f..0000000
--- a/src/presentation/examples/ui-gallery-page.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
-import { useState, type FormEvent } from "react";
-
-import {
- Alert,
- Badge,
- Button,
- Card,
- Checkbox,
- Dialog,
- Menu,
- PageHeader,
- ProgressBar,
- RadioGroup,
- Select,
- Switch,
- Tabs,
- TextArea,
- TextField,
- ToastProvider,
- Tooltip,
- useToast,
-} from "../design-system/index.ts";
-
-const COLOR_TOKENS = Object.freeze([
- ["Surface", "--color-surface"],
- ["Muted surface", "--color-surface-muted"],
- ["Content", "--color-content"],
- ["Muted content", "--color-content-muted"],
- ["Action", "--color-action"],
- ["Danger", "--color-danger"],
- ["Focus", "--color-focus"],
-]);
-
-export default function UiGalleryPage() {
- return (
-
-
-
- );
-}
-
-function UiGalleryContent() {
- const toast = useToast();
- const [projectName, setProjectName] = useState("");
- const [description, setDescription] = useState("");
- const [template, setTemplate] = useState("application");
- const [reviewed, setReviewed] = useState(false);
- const [notifications, setNotifications] = useState(true);
- const [density, setDensity] = useState("comfortable");
- const [fieldTouched, setFieldTouched] = useState(false);
- const [dialogOpen, setDialogOpen] = useState(false);
- const [notice, setNotice] = useState(
- "구성요소를 조작하면 결과가 여기에 표시됩니다.",
- );
- const [alertVisible, setAlertVisible] = useState(true);
- const fieldError =
- fieldTouched && projectName.trim().length === 0
- ? "프로젝트 이름을 입력해 주세요."
- : undefined;
-
- function submitExample(event: FormEvent): void {
- event.preventDefault();
- setFieldTouched(true);
- if (projectName.trim().length === 0) {
- setNotice("입력값을 확인해 주세요.");
- return;
- }
- setNotice(`“${projectName.trim()}” 입력을 확인했습니다.`);
- }
-
- return (
-
-
-
-
-
-
-
-
- setNotice("기본 작업을 실행했습니다.")}>
- 기본 작업
-
- setNotice("보조 작업을 실행했습니다.")}
- >
- 보조 작업
-
- setNotice("위험 작업 예제를 선택했습니다.")}
- >
- 위험 작업
-
- 사용 불가
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {alertVisible ? (
-
setAlertVisible(false)}
- >
- 운영 환경에는 실제 저장 포트를 연결하세요.
-
- ) : (
-
setAlertVisible(true)}
- >
- 알림 다시 표시
-
- )}
-
- 중립
- 정보
- 준비됨
- 확인 필요
- 실패
-
-
-
-
-
- setDialogOpen(true)}>모달 열기
-
setNotice("메뉴 작업을 실행했습니다."),
- },
- {
- id: "notify",
- label: "Toast 표시",
- onSelect: () =>
- toast.push({
- id: "gallery-saved",
- title: "예제가 저장되었습니다.",
- tone: "success",
- }),
- },
- ]}
- triggerLabel="작업 메뉴"
- />
-
- 도움말
-
-
- setDialogOpen(false)}
- title="연동 확인"
- description="도메인 작업을 실행하기 전 확인 화면의 기본 구조입니다."
- actions={
- <>
- setDialogOpen(false)}
- >
- 취소
-
- {
- setNotice("모달의 확인 작업을 실행했습니다.");
- setDialogOpen(false);
- }}
- >
- 확인
-
- >
- }
- >
- 민감한 값이나 구현 세부정보는 확인 문구에 포함하지 않습니다.
-
-
-
-
-
-
-
- 원시 값에서 의미와 컴포넌트 토큰을 파생합니다. ,
- },
- {
- id: "primitives",
- label: "프리미티브",
- panel: native semantics와 interaction을 닫습니다.
,
- },
- {
- id: "patterns",
- label: "패턴",
- panel: 반복되는 사용자 문제를 조합으로 해결합니다.
,
- },
- ]}
- />
-
-
-
-
-
- {COLOR_TOKENS.map(([label, token]) => (
-
-
- {label}
- {token}
-
- ))}
-
-
-
-
- {notice}
-
-
- );
-}
diff --git a/src/presentation/layouts/app-shell.tsx b/src/presentation/layouts/app-shell.tsx
index ff7be35..ffe16a1 100644
--- a/src/presentation/layouts/app-shell.tsx
+++ b/src/presentation/layouts/app-shell.tsx
@@ -88,7 +88,7 @@ export function AppShell() {
>
-
+
{message("common.appName")}
diff --git a/src/presentation/pages/home-page.tsx b/src/presentation/pages/home-page.tsx
deleted file mode 100644
index 21c2242..0000000
--- a/src/presentation/pages/home-page.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { useEffect, useState } from "react";
-import { Link } from "react-router-dom";
-
-import { routePath } from "../../features/installed-feature-contracts.ts";
-import { PageHeader } from "../design-system/index.ts";
-import { useApplication } from "../providers/application-provider.tsx";
-
-const READINESS_ITEMS = Object.freeze([
- {
- title: "실행 계약",
- description: "런타임 설정, 릴리스 정합성, 오류 경계가 마운트 전에 검증됩니다.",
- },
- {
- title: "교체 가능한 연동",
- description: "인증, HTTP, 캐시, 저장소, 텔레메트리가 포트 뒤에 분리되어 있습니다.",
- },
- {
- title: "접근 가능한 화면",
- description: "키보드 탐색, 포커스 이동, 반응형 앱 셸의 기본 동작이 준비되어 있습니다.",
- },
-]);
-
-export default function HomePage() {
- const { runtime } = useApplication();
- const [release, setRelease] = useState<
- Awaited
> | null
- >(null);
-
- useEffect(() => {
- let active = true;
- void runtime.getReleaseSummary().then((summary) => {
- if (active) setRelease(summary);
- });
- return () => {
- active = false;
- };
- }, [runtime]);
-
- return (
-
-
-
- {READINESS_ITEMS.map((item) => (
-
- {item.title}
- {item.description}
-
- ))}
-
-
- {release
- ? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
- : "검증된 런타임 정보를 확인하고 있습니다."}
-
-
-
-
준비된 화면 살펴보기
-
- 설치된 라우트와 계약, 런타임 능력은 플랫폼 구성 화면에서, 공통
- 구성요소와 비동기 화면 상태는 예제 라우트에서 확인하세요.
-
-
-
-
- 플랫폼 구성 보기
-
-
- UI 구성요소 보기
-
-
- 화면 상태 보기
-
-
-
-
- );
-}
diff --git a/src/presentation/pages/not-found-page.tsx b/src/presentation/pages/not-found-page.tsx
deleted file mode 100644
index b59836c..0000000
--- a/src/presentation/pages/not-found-page.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Link } from "react-router-dom";
-
-import { routePath } from "../../features/installed-feature-contracts.ts";
-import { PageHeader } from "../design-system/index.ts";
-import { useLocale } from "../i18n/index.ts";
-
-export default function NotFoundPage() {
- const { message } = useLocale();
- return (
-
-
-
-
- {message("action.goHome")}
-
-
-
- );
-}
diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx
index 01873b0..b7d46f5 100644
--- a/src/presentation/routes/app-router.tsx
+++ b/src/presentation/routes/app-router.tsx
@@ -9,7 +9,6 @@ import {
} from "react";
import {
createBrowserRouter,
- Outlet,
RouterProvider,
type RouteObject,
useLocation,
@@ -30,7 +29,7 @@ import {
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx";
import { Button, PageHeader } from "../design-system/index.ts";
import { LocaleProvider, useLocale } from "../i18n/index.ts";
-import { AppShell } from "../layouts/app-shell.tsx";
+import { TECH_LOG_ROUTE_LAYOUTS } from "../../features/tech-log/presentation/tech-log-route-runtime.tsx";
import { useApplication } from "../providers/application-provider.tsx";
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
import { ThemeProvider } from "../providers/theme-provider.tsx";
@@ -459,10 +458,7 @@ export function AppRouter({
createGroupedRouteObjects(
ROUTE_REGISTRY,
ROUTE_RUNTIME,
- {
- PUBLIC: ,
- STUDIO: ,
- },
+ TECH_LOG_ROUTE_LAYOUTS,
buildId,
ROUTE_CODECS,
),
diff --git a/src/presentation/routes/platform-route-codecs.ts b/src/presentation/routes/platform-route-codecs.ts
index e0c8f6d..4b09848 100644
--- a/src/presentation/routes/platform-route-codecs.ts
+++ b/src/presentation/routes/platform-route-codecs.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-export const PLATFORM_ROUTE_CODECS = {
+export const PLATFORM_ROUTE_CODECS = Object.freeze({
none: z.object({}).strict(),
NotFoundSplat: z.object({ "*": z.string().min(1).optional() }).strict(),
-} as const;
+});
diff --git a/src/presentation/routes/route-runtime.tsx b/src/presentation/routes/route-runtime.tsx
index 8ae93a4..78730a1 100644
--- a/src/presentation/routes/route-runtime.tsx
+++ b/src/presentation/routes/route-runtime.tsx
@@ -1,47 +1 @@
-import {
- lazy,
- type ComponentType,
- type LazyExoticComponent,
-} from "react";
-
-import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.ts";
-
-type RouteModule = Readonly<{ default: ComponentType }>;
-type RouteRuntime = Readonly<{
- moduleId: string;
- Component: LazyExoticComponent;
-}>;
-
-function runtime(
- routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
- load: () => Promise,
-): RouteRuntime {
- return Object.freeze({
- moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
- Component: lazy(load),
- });
-}
-
-export const PLATFORM_ROUTE_RUNTIME = {
- APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
- EXAMPLES_PLATFORM: runtime(
- "EXAMPLES_PLATFORM",
- () => import("../examples/platform-overview-page.tsx"),
- ),
- EXAMPLES_UI: runtime(
- "EXAMPLES_UI",
- () => import("../examples/ui-gallery-page.tsx"),
- ),
- EXAMPLES_STATES: runtime(
- "EXAMPLES_STATES",
- () => import("../examples/state-gallery-page.tsx"),
- ),
- EXAMPLES_AUTH: runtime(
- "EXAMPLES_AUTH",
- () => import("../examples/auth-example-page.tsx"),
- ),
- NOT_FOUND: runtime(
- "NOT_FOUND",
- () => import("../pages/not-found-page.tsx"),
- ),
-} satisfies Record;
+export const PLATFORM_ROUTE_RUNTIME = Object.freeze({});
diff --git a/tests/component/platform-overview-page.test.tsx b/tests/component/platform-overview-page.test.tsx
deleted file mode 100644
index 17f2393..0000000
--- a/tests/component/platform-overview-page.test.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-// @vitest-environment jsdom
-
-import { render, screen, waitFor, within } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
-import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../../src/features/installed-contract-contributions.ts";
-import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
-import PlatformOverviewPage from "../../src/presentation/examples/platform-overview-page.tsx";
-import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
-import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
-import { createTestApplication } from "../helpers/create-test-application.ts";
-import { createRuntimeCapabilitiesStub } from "../helpers/runtime-capabilities-stub.ts";
-
-/**
- * The page must stay a projection of the installed registries. Every assertion
- * below derives its expectation from the same registry the page reads, so a
- * template that removes its sample feature still satisfies this suite.
- */
-
-function renderPage() {
- return render(
-
-
-
-
- ,
- );
-}
-
-function tableByCaption(caption: string): HTMLElement {
- return screen.getByRole("table", { name: caption });
-}
-
-/** A metric is a `dt`/`dd` pair, which carries no ARIA role to query by. */
-function metricByLabel(scope: HTMLElement, label: string): HTMLElement {
- const term = within(scope).getByText(label).closest(".platform-metric");
- if (!(term instanceof HTMLElement)) {
- throw new Error(`No metric is labelled ${label}`);
- }
- return term;
-}
-
-describe("platform overview page", () => {
- it("renders one route row per installed route registry entry", () => {
- renderPage();
-
- const table = tableByCaption("설치된 라우트 목록");
- const dataRows = within(table).getAllByRole("row").slice(1);
-
- expect(dataRows).toHaveLength(Object.keys(ROUTE_REGISTRY).length);
- for (const definition of Object.values(ROUTE_REGISTRY)) {
- expect(
- within(table).getByText(definition.routeId),
- ).toBeInTheDocument();
- }
- });
-
- it("renders one operation row per installed HTTP contract", () => {
- const operationIds = [
- ...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.keys(),
- ];
- renderPage();
-
- if (operationIds.length === 0) {
- expect(
- screen.getByRole("heading", {
- name: "설치된 HTTP 오퍼레이션이 없습니다.",
- }),
- ).toBeVisible();
- return;
- }
-
- const table = tableByCaption("설치된 HTTP 오퍼레이션");
- expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
- operationIds.length,
- );
- for (const operationId of operationIds) {
- expect(within(table).getByText(operationId)).toBeInTheDocument();
- }
- });
-
- it("renders every fixed server-state profile with its own budget", () => {
- renderPage();
-
- const table = tableByCaption("서버 상태 프로파일");
- for (const profile of Object.values(SERVER_STATE_PROFILES)) {
- expect(within(table).getByText(profile.profileId)).toBeInTheDocument();
- }
- expect(within(table).getAllByRole("row").slice(1)).toHaveLength(
- Object.keys(SERVER_STATE_PROFILES).length,
- );
- });
-
- it("labels a capability that was never selected as unselected", () => {
- renderPage();
-
- const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
-
- expect(within(region).getAllByText("미선택")).toHaveLength(4);
- expect(within(region).queryByText("운영자가 비활성화함")).toBeNull();
- });
-
- it("separates an operator disable from a capability that was never selected", () => {
- render(
-
-
-
-
- ,
- );
-
- const region = screen.getByRole("region", { name: "선택적 런타임 능력" });
-
- expect(within(region).getByText("운영자가 비활성화함")).toBeVisible();
- expect(within(region).getByText("활성 (1)")).toBeVisible();
- expect(within(region).getAllByText("미선택")).toHaveLength(2);
- });
-
- it("counts installed contract packages separately from template fixtures", () => {
- const fixtures = COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
- (contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
- ).length;
- renderPage();
-
- const summary = screen.getByRole("region", { name: "설치 요약" });
- const packages = metricByLabel(summary, "외부 계약 패키지");
-
- expect(
- within(packages).getByText(
- `${COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages.length}개`,
- ),
- ).toBeVisible();
- expect(
- within(packages).getByText(`템플릿 픽스처 ${fixtures}개`),
- ).toBeVisible();
- expect(
- within(metricByLabel(summary, "라우트")).getByText(
- `${Object.keys(ROUTE_REGISTRY).length}개`,
- ),
- ).toBeVisible();
- });
-
- it("renders the verified release identity once the runtime resolves it", async () => {
- renderPage();
-
- const region = screen.getByRole("region", { name: "릴리스 신원" });
- await waitFor(() =>
- expect(
- within(metricByLabel(region, "빌드")).getByText("test-build"),
- ).toBeVisible(),
- );
- expect(
- within(metricByLabel(region, "릴리스")).getByText("test-release"),
- ).toBeVisible();
- });
-});
diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx
index a33bbd3..8f62c53 100644
--- a/tests/component/router.test.tsx
+++ b/tests/component/router.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
@@ -12,6 +12,8 @@ import {
} from "react-router-dom";
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
+import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
+import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
import {
AppRouter,
@@ -54,7 +56,7 @@ const groupedFixtureCodecs = Object.freeze({
const reviewFixtureRegistry = Object.freeze({
REVIEW_FIXTURE: Object.freeze({
- ...ROUTE_REGISTRY.APP_HOME,
+ ...ROUTE_REGISTRY.TECH_LOG_HOME,
routeId: "REVIEW_FIXTURE",
path: "/review/:reviewId",
paramsSchema: "ReviewFixtureParams",
@@ -100,10 +102,12 @@ function compileTimeGroupedRouteContract() {
void compileTimeGroupedRouteContract;
function renderRouter() {
+ const techLog = createTechLogFeatureInstalledInput();
return render(
@@ -159,7 +163,7 @@ describe("generic application router", () => {
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
const registry = {
- APP_HOME: ROUTE_REGISTRY.APP_HOME,
+ TECH_LOG_HOME: ROUTE_REGISTRY.TECH_LOG_HOME,
STUDIO_FIXTURE: {
...ROUTE_REGISTRY.NOT_FOUND,
routeId: "STUDIO_FIXTURE",
@@ -169,7 +173,7 @@ describe("generic application router", () => {
NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND,
};
const runtime = {
- APP_HOME: ROUTE_RUNTIME.APP_HOME,
+ TECH_LOG_HOME: ROUTE_RUNTIME.TECH_LOG_HOME,
STUDIO_FIXTURE: ROUTE_RUNTIME.NOT_FOUND,
NOT_FOUND: ROUTE_RUNTIME.NOT_FOUND,
};
@@ -220,7 +224,7 @@ describe("generic application router", () => {
"test-build",
groupedFixtureCodecs,
),
- ).toThrow("Missing route runtime: APP_HOME");
+ ).toThrow("Missing route runtime: TECH_LOG_HOME");
});
it("renders a non-installed Studio wildcard leaf without rewriting its URL", async () => {
@@ -277,10 +281,11 @@ describe("generic application router", () => {
const routes = createGroupedRouteObjects(
{
PROTECTED_FIXTURE: {
- ...ROUTE_REGISTRY.APP_HOME,
+ ...ROUTE_REGISTRY.TECH_LOG_HOME,
routeId: "PROTECTED_FIXTURE",
path: "/private-fixture",
access: "session-required",
+ searchSchema: null,
},
},
{
@@ -317,89 +322,67 @@ describe("generic application router", () => {
).toBeVisible();
});
- it("reaches the platform overview from the home starter actions", async () => {
+ it("renders the installed TechLog home in the Public layout", async () => {
const user = userEvent.setup();
window.history.pushState({}, "", "/");
renderRouter();
- // The starter action lives inside the lazily loaded home chunk, so this is
- // the first wait in the file that has to outlast a chunk load rather than
- // an already-mounted shell element.
+ expect(
+ await screen.findByRole("heading", { name: "TechLog", level: 1 }),
+ ).toBeVisible();
+ expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await user.click(
- await screen.findByRole(
+ within(screen.getByRole("navigation", { name: "주요 탐색" })).getByRole(
"link",
- { name: "플랫폼 구성 보기" },
- { timeout: 5000 },
+ { name: "프로젝트" },
),
);
-
- await waitFor(() =>
- expect(
- screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
- ).toHaveFocus(),
- );
+ expect(
+ await screen.findByRole("heading", { name: "프로젝트", level: 1 }),
+ ).toBeVisible();
+ expect(window.location.pathname).toBe("/projects");
});
- it("renders the app shell and not-found route without a feature input", async () => {
+ it("renders the Public shell and TechLog not-found route", async () => {
window.history.pushState({}, "", "/missing");
renderRouter();
expect(
await screen.findByRole("heading", {
- name: "페이지를 찾을 수 없습니다.",
+ name: "404",
+ level: 1,
}),
).toBeVisible();
- expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
- expect(screen.getByRole("main")).toBeVisible();
- });
-
- it("preserves authorization around grouped registered leaves", async () => {
- window.history.pushState({}, "", "/examples/reference-resources");
- renderRouter();
-
expect(
- await screen.findByRole("heading", { name: "세션이 필요합니다." }),
+ screen.getByRole("heading", { name: "This page could not be found." }),
).toBeVisible();
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
});
- it("navigates between registry-backed platform routes", async () => {
- const user = userEvent.setup();
- window.history.pushState({}, "", "/");
+ it("keeps Studio routes inside the persistent Studio layout", async () => {
+ window.history.pushState({}, "", "/studio");
renderRouter();
- await user.click(
- await screen.findByRole("link", { name: "UI 구성요소" }),
- );
-
expect(
- await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
+ await screen.findByRole("heading", { name: "작업 흐름" }),
).toBeVisible();
- expect(window.location.pathname).toBe("/examples/ui");
- expect(document.title).toBe("UI 구성요소 · Tech Log");
- await waitFor(() =>
- expect(
- screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
- ).toHaveFocus(),
+ expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
+ expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute(
+ "href",
+ "/",
);
});
- it("focuses the route heading again when the lazy chunk is already cached", async () => {
- // §9.7. The first visit resolves the route module asynchronously, so the
- // router lifecycle and the page header commit separately. A later visit
- // renders the cached module in the same commit, which is the ordering that
- // must still hand focus to the heading rather than leaving it on main.
- const user = userEvent.setup();
- window.history.pushState({}, "", "/");
+ it("gives the Studio wildcard precedence over the Public not-found route", async () => {
+ window.history.pushState({}, "", "/studio/missing");
renderRouter();
- for (const label of ["UI 구성요소", "화면 상태", "UI 구성요소"]) {
- await user.click(await screen.findByRole("link", { name: label }));
- await waitFor(() =>
- expect(
- screen.getByRole("heading", { name: label, level: 1 }),
- ).toHaveFocus(),
- );
- }
+ expect(
+ await screen.findByRole("heading", {
+ name: "Studio 화면을 찾을 수 없습니다",
+ }),
+ ).toBeVisible();
+ expect(screen.getByRole("navigation", { name: "Studio 주 탐색" })).toBeVisible();
+ expect(window.location.pathname).toBe("/studio/missing");
});
});
diff --git a/tests/component/runtime-application.test.tsx b/tests/component/runtime-application.test.tsx
index 9ac4e5c..4fc7edc 100644
--- a/tests/component/runtime-application.test.tsx
+++ b/tests/component/runtime-application.test.tsx
@@ -31,10 +31,32 @@ const releaseManifest = {
releaseId: "local-release",
builtAt: "2026-07-26T00:00:00.000Z",
routeChunks: {
- "route-home": "assets/home.js",
- "route-examples-ui": "assets/ui.js",
- "route-examples-states": "assets/states.js",
- "route-examples-auth": "assets/auth.js",
+ "route-tech-log-home": "assets/tech-log-home.js",
+ "route-tech-log-explore": "assets/tech-log-explore.js",
+ "route-tech-log-explore-kind": "assets/tech-log-explore-kind.js",
+ "route-tech-log-case": "assets/tech-log-case.js",
+ "route-tech-log-reference": "assets/tech-log-reference.js",
+ "route-tech-log-question": "assets/tech-log-question.js",
+ "route-tech-log-topic": "assets/tech-log-topic.js",
+ "route-tech-log-projects": "assets/tech-log-projects.js",
+ "route-tech-log-project": "assets/tech-log-project.js",
+ "route-tech-log-project-records": "assets/tech-log-project-records.js",
+ "route-tech-log-project-decisions": "assets/tech-log-project-decisions.js",
+ "route-tech-log-project-activity": "assets/tech-log-project-activity.js",
+ "route-tech-log-releases": "assets/tech-log-releases.js",
+ "route-tech-log-release": "assets/tech-log-release.js",
+ "route-tech-log-profile": "assets/tech-log-profile.js",
+ "route-tech-log-search": "assets/tech-log-search.js",
+ "route-tech-log-studio-home": "assets/tech-log-studio-home.js",
+ "route-tech-log-studio-documents": "assets/tech-log-studio-documents.js",
+ "route-tech-log-studio-document-new": "assets/tech-log-studio-document-new.js",
+ "route-tech-log-studio-document-edit": "assets/tech-log-studio-document-edit.js",
+ "route-tech-log-studio-document-validation": "assets/tech-log-studio-document-validation.js",
+ "route-tech-log-studio-document-preview": "assets/tech-log-studio-document-preview.js",
+ "route-tech-log-studio-document-publish": "assets/tech-log-studio-document-publish.js",
+ "route-tech-log-studio-publications": "assets/tech-log-studio-publications.js",
+ "route-tech-log-studio-publication-preview": "assets/tech-log-studio-publication-preview.js",
+ "route-tech-log-studio-not-found": "assets/tech-log-studio-not-found.js",
"route-not-found": "assets/not-found.js",
},
};
@@ -62,12 +84,14 @@ describe("production runtime application tree", () => {
expect(
await screen.findByRole("heading", {
- name: "Tech Log",
+ name: "TechLog",
}),
).toBeVisible();
expect(
- await screen.findByText("빌드 local-build · 릴리스 local-release"),
- ).toBeVisible();
+ await screen.findAllByText(
+ "문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.",
+ ),
+ ).toHaveLength(2);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(composition).not.toHaveProperty("ports");
});
diff --git a/tests/e2e/platform-overview.spec.ts b/tests/e2e/platform-overview.spec.ts
deleted file mode 100644
index 6cfeafd..0000000
--- a/tests/e2e/platform-overview.spec.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { expect, test } from "../support/browser/strict-browser-test.ts";
-import { SERVER_STATE_PROFILES } from "../../src/contracts/server-state.ts";
-import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
-
-/**
- * The overview is a projection of the installed registries. These assertions
- * read the same registries the shipped bundle was built from, so they keep
- * meaning after a feature is added or removed.
- */
-
-test("projects the installed route registry into the shipped bundle", async ({
- page,
-}) => {
- await page.goto("/examples/platform");
- await expect(
- page.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
- ).toBeVisible();
-
- const table = page.getByRole("table", { name: "설치된 라우트 목록" });
- await expect(table.locator("tbody tr")).toHaveCount(
- Object.keys(ROUTE_REGISTRY).length,
- );
- for (const routeId of Object.keys(ROUTE_REGISTRY)) {
- await expect(table.getByText(routeId, { exact: true })).toBeVisible();
- }
-});
-
-test("shows every fixed server-state profile", async ({ page }) => {
- await page.goto("/examples/platform");
-
- const table = page.getByRole("table", { name: "서버 상태 프로파일" });
- await expect(table.locator("tbody tr")).toHaveCount(
- Object.keys(SERVER_STATE_PROFILES).length,
- );
-});
-
-test("states the release contract identity verified at boot", async ({
- page,
-}) => {
- await page.goto("/examples/platform");
-
- const release = page.locator(
- "section[aria-labelledby='platform-release-title']",
- );
- await expect(release.getByText(/^sha256:/)).toBeVisible();
-});
-
-test("reports an unselected capability without claiming it was disabled", async ({
- page,
-}) => {
- await page.goto("/examples/platform");
-
- const capabilities = page.locator(
- "section[aria-labelledby='platform-capabilities-title']",
- );
- await expect(capabilities.getByText("미선택")).toHaveCount(4);
- await expect(capabilities.getByText("운영자가 비활성화함")).toHaveCount(0);
-});
diff --git a/tests/e2e/reference-form.spec.ts b/tests/e2e/reference-form.spec.ts
deleted file mode 100644
index 7c4e84b..0000000
--- a/tests/e2e/reference-form.spec.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import type { Page } from "@playwright/test";
-import { expect, test } from "../support/browser/strict-browser-test.ts";
-
-async function openReferenceForm(page: Page) {
- await page.goto("/examples/reference-resources/new");
- await page.getByRole("button", { name: "로그인 시작" }).click();
- await expect(
- page.getByRole("heading", { name: "Reference resource 만들기" }),
- ).toBeVisible();
-}
-
-test("validates a reference form and focuses the first invalid field", async ({
- page,
-}) => {
- await openReferenceForm(page);
- await page.getByRole("button", { name: "저장" }).click();
-
- const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
- await expect(firstField).toBeFocused();
- await expect(firstField).toHaveAttribute("aria-invalid", "true");
- await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
-});
-
-test("guards dirty cancellation and restores focus when writing continues", async ({
- page,
-}) => {
- await openReferenceForm(page);
- await page
- .getByRole("textbox", { name: /새 항목 이름/ })
- .fill("Unsaved reference");
- const cancel = page.getByRole("button", { name: "취소" });
- await cancel.click();
-
- await expect(
- page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
- ).toBeVisible();
- await page.getByRole("button", { name: "계속 작성" }).click();
- await expect(cancel).toBeFocused();
- await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
-});
-
-test("keeps the form template within a 320px viewport", async ({ page }) => {
- await page.setViewportSize({ width: 320, height: 720 });
- await openReferenceForm(page);
- const viewport = await page.evaluate(() => ({
- clientWidth: document.documentElement.clientWidth,
- scrollWidth: document.documentElement.scrollWidth,
- }));
- expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
-});
diff --git a/tests/e2e/reference-route.spec.ts b/tests/e2e/reference-route.spec.ts
deleted file mode 100644
index dd41082..0000000
--- a/tests/e2e/reference-route.spec.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { expect, test } from "../support/browser/strict-browser-test.ts";
-import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
-
-test("opens the protected integration route through the local demo seam", async ({
- page,
-}) => {
- const protectedRoute = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST;
- await page.route(
- "http://localhost:8080/api/reference-resources?*",
- (route) =>
- route.fulfill({
- json: [
- {
- id: "browser-reference",
- name: "Browser reference",
- createdAt: "2026-07-26T00:00:00.000Z",
- },
- ],
- }),
- );
- await page.goto(protectedRoute.path);
- await expect(
- page.getByRole("heading", { name: "세션이 필요합니다." }),
- ).toBeVisible();
-
- await page.getByRole("button", { name: "로그인 시작" }).click();
-
- await expect(
- page.getByRole("heading", { name: protectedRoute.title }),
- ).toBeVisible();
- await expect(page.getByText("인증됨")).toBeVisible();
-});
diff --git a/tests/e2e/tech-log-studio-workflow.spec.ts b/tests/e2e/tech-log-studio-workflow.spec.ts
new file mode 100644
index 0000000..e81fa4b
--- /dev/null
+++ b/tests/e2e/tech-log-studio-workflow.spec.ts
@@ -0,0 +1,71 @@
+import { FIXTURE_IDS } from "../../src/features/tech-log/adapters/mock/fixtures.ts";
+import { expect, test } from "../support/browser/strict-browser-test.ts";
+
+test("validates, previews, publishes, and unpublishes one Studio working copy", async ({
+ page,
+}) => {
+ await page.goto(
+ `/studio/documents/${FIXTURE_IDS.stateNonceReference}/validation`,
+ );
+ await expect(
+ page.getByText("현재 저장 버전의 검증을 통과했습니다"),
+ ).toBeVisible();
+
+ await page.getByRole("link", { name: "Public Preview 만들기" }).click();
+ await page
+ .getByRole("button", { name: "Public Preview 만들기" })
+ .click();
+ await expect(
+ page.getByText("현재 저장 버전의 Public Preview입니다."),
+ ).toBeVisible();
+
+ await page.getByRole("link", { name: "게시 준비로 이동" }).click();
+ await page.getByRole("button", { name: "게시" }).click();
+ await expect(page).toHaveURL(
+ /\/studio\/publications\/[0-9a-f-]+\/preview$/,
+ );
+ await expect(
+ page.getByRole("heading", {
+ level: 1,
+ name: "Authorization Code Flow에서 state와 nonce의 경계",
+ }),
+ ).toBeVisible();
+
+ await page.getByRole("link", { name: "게시 기록으로 돌아가기" }).click();
+ await page
+ .getByRole("button", {
+ name: /Authorization Code Flow에서 state와 nonce의 경계 게시 취소/,
+ })
+ .click();
+ await expect(
+ page.getByRole("dialog", { name: "게시를 취소할까요?" }),
+ ).toContainText("작업본과 이전 Snapshot은 보존됩니다.");
+ await page.getByRole("button", { name: "게시 취소 확인" }).click();
+ await expect(page.getByRole("status")).toContainText(
+ "게시를 취소했습니다.",
+ );
+});
+
+test("renders an immutable historical snapshot and Studio-local missing event", async ({
+ page,
+}) => {
+ await page.goto(
+ `/studio/publications/${FIXTURE_IDS.fetchPublishedEvent}/preview`,
+ );
+ await expect(
+ page.getByText(/반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정/),
+ ).toBeVisible();
+ await expect(
+ page.getByText("게시 후 본문 측정값을 보완한 저장본입니다."),
+ ).toHaveCount(0);
+
+ await page.goto(
+ "/studio/publications/99999999-9999-4999-8999-999999999999/preview",
+ );
+ await expect(
+ page.getByRole("heading", { name: "게시 기록을 찾을 수 없습니다" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("navigation", { name: "Studio 주 탐색" }),
+ ).toBeVisible();
+});
diff --git a/tests/e2e/ui-gallery.spec.ts b/tests/e2e/ui-gallery.spec.ts
deleted file mode 100644
index c5a4528..0000000
--- a/tests/e2e/ui-gallery.spec.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { expect, test } from "../support/browser/strict-browser-test.ts";
-
-test("validates and reports the common text-field flow", async ({ page }) => {
- await page.goto("/examples/ui");
- await page.getByRole("button", { name: "입력 확인" }).click();
-
- const field = page.getByRole("textbox", { name: "프로젝트 이름" });
- await expect(field).toHaveAttribute("aria-invalid", "true");
- await expect(field).toHaveAccessibleDescription(
- /프로젝트 이름을 입력해 주세요/,
- );
-
- await field.fill("Starter");
- await page.getByRole("button", { name: "입력 확인" }).click();
- await expect(page.getByText("“Starter” 입력을 확인했습니다.")).toBeVisible();
-});
-
-test("traps modal interaction and restores focus to the trigger", async ({
- page,
-}) => {
- await page.goto("/examples/ui");
- const trigger = page.getByRole("button", { name: "모달 열기" });
- await trigger.click();
-
- const dialog = page.getByRole("dialog", { name: "연동 확인" });
- await expect(dialog).toBeVisible();
- await expect(
- dialog.getByRole("button", { name: "연동 확인 닫기" }),
- ).toBeFocused();
-
- await page.keyboard.press("Escape");
- await expect(dialog).toBeHidden();
- await expect(trigger).toBeFocused();
-});
diff --git a/tests/features/reference-feature/reference-contract.test.ts b/tests/features/reference-feature/reference-contract.test.ts
index 2ba9dcc..a8c6373 100644
--- a/tests/features/reference-feature/reference-contract.test.ts
+++ b/tests/features/reference-feature/reference-contract.test.ts
@@ -1,9 +1,5 @@
import { describe, expect, it, vi } from "vitest";
-import {
- buildRouteUrl,
- parseRouteInput,
-} from "../../../src/presentation/routes/route-codecs.ts";
import {
mapReferenceOperation,
toReferenceView,
@@ -47,35 +43,7 @@ describe("reference feature boundary contracts", () => {
});
});
- it("round-trips one canonical filter through the URL codec", () => {
- const filters = {
- tags: ["open", "new"],
- cursor: "a/b",
- limit: 5,
- };
- const url = buildRouteUrl("REFERENCE_RESOURCE_LIST", { search: filters });
- expect(url).toBe(
- "/examples/reference-resources?cursor=a%2Fb&limit=5&tags=open&tags=new",
- );
- const parsed = parseRouteInput(
- "REFERENCE_RESOURCE_LIST",
- {},
- new URL(url, "https://app.test").searchParams,
- );
- expect(parsed).toMatchObject({
- success: true,
- data: { search: filters },
- });
- });
-
it("rejects unknown search and malformed DTO before mapping", () => {
- expect(
- parseRouteInput(
- "REFERENCE_RESOURCE_LIST",
- {},
- new URLSearchParams("unknown=value"),
- ),
- ).toEqual({ success: false, code: "ROUTE_SEARCH_INVALID" });
expect(
validateReferencePayload("ReferenceResourceListPayload", [
{ id: "unsafe", name: 42 },
@@ -109,13 +77,7 @@ describe("reference feature boundary contracts", () => {
});
});
- it("owns route and operation contributions in one removable contract", () => {
- expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
- "REFERENCE_RESOURCE_LIST",
- "REFERENCE_RESOURCE_DETAIL",
- "REFERENCE_RESOURCE_FORM",
- "REFERENCE_RESOURCE_STATUS",
- ]);
+ it("owns the retained API operation fixture contract", () => {
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
"LIST_REFERENCE_RESOURCES",
"CREATE_REFERENCE_RESOURCE",
diff --git a/tests/features/reference-feature/reference-page.test.tsx b/tests/features/reference-feature/reference-page.test.tsx
deleted file mode 100644
index 6b21422..0000000
--- a/tests/features/reference-feature/reference-page.test.tsx
+++ /dev/null
@@ -1,490 +0,0 @@
-// @vitest-environment jsdom
-
-import {
- QueryClient,
- QueryClientProvider,
-} from "@tanstack/react-query";
-import { render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
-import type { AuthSessionPort } from "../../../src/application/ports/auth-session-port.ts";
-import type { MutationIntentFactory } from "../../../src/application/ports/mutation-intent-factory.ts";
-import type {
- ReferenceFeatureInput,
- ReferenceResult,
-} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
-import {
- REFERENCE_FEATURE_ID,
- REFERENCE_RESOURCE_INVALIDATION_TOPIC,
- REFERENCE_RESOURCE_QUERY_NAMESPACE,
-} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
-import { INVALIDATION_REGISTRY } from "../../../src/features/installed-feature-contracts.ts";
-import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
-import { createFailure } from "../../../src/contracts/errors.ts";
-import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts";
-import { QueryInvalidationProvider } from "../../../src/presentation/adapters/query/query-invalidation-provider.tsx";
-import { MutationIntentProvider } from "../../../src/presentation/adapters/query/mutation-intent-provider.tsx";
-import { ServerStateScopeProvider } from "../../../src/presentation/adapters/query/server-state-scope-provider.tsx";
-import { createServerStateScopeRuntime } from "../../../src/adapters/query-cache/server-state-scope-runtime.ts";
-import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
-import { AppRouter } from "../../../src/presentation/routes/app-router.tsx";
-import { createTestApplication } from "../../helpers/create-test-application.ts";
-
-function renderReference(
- input: ReferenceFeatureInput,
- url = "/examples/reference-resources?limit=5",
- session: AuthSessionPort = createDemoSessionAdapter("authenticated"),
-) {
- window.history.pushState({}, "", url);
- const client = new QueryClient({
- defaultOptions: {
- queries: { retry: false, gcTime: Infinity },
- mutations: { retry: false },
- },
- });
- const invalidation: QueryInvalidationCoordinator = Object.freeze({
- async invalidate() {},
- beginMutation() {
- return Object.freeze({
- async release() {},
- });
- },
- async resetLocal() {},
- dispose() {},
- });
- const serverStateScope = createServerStateScopeRuntime({
- session,
- queryInvalidation: invalidation,
- });
- let intentSequence = 0;
- const mutationIntentFactory: MutationIntentFactory = Object.freeze({
- create(input) {
- intentSequence += 1;
- return Object.freeze({
- intentId: `reference-page-intent-${intentSequence}`,
- operationId: input.operationId,
- canonicalInputIdentity: input.canonicalInputIdentity,
- ...(input.requiresIdempotencyKey
- ? { idempotencyKey: `reference-page-key-${intentSequence}` }
- : {}),
- createdAtMonotonicMs: intentSequence,
- });
- },
- });
- return Object.assign(render(
-
-
-
-
-
-
-
-
-
-
- ,
- ), { client });
-}
-
-function inputWith(
- overrides: Partial = {},
-): ReferenceFeatureInput {
- return {
- listResources: async () => ({ ok: true, value: [] }),
- createResource: async ({ name }) => ({
- ok: true,
- value: {
- resourceId: "created",
- title: name,
- createdAt: null,
- },
- }),
- getResource: async (resourceId) => ({
- ok: true,
- value: {
- resourceId,
- title: "Detail",
- createdAt: null,
- },
- }),
- ...overrides,
- };
-}
-
-describe("reference feature page states", () => {
- it("mounts list and detail keys under the installed governed namespace", async () => {
- expect(REFERENCE_RESOURCE_QUERY_NAMESPACE).toEqual({
- namespaceId: "reference-resource",
- namespaceVersion: 1,
- });
- const installedEdge = INVALIDATION_REGISTRY.edges.find(
- (edge) => edge.topicId === REFERENCE_RESOURCE_INVALIDATION_TOPIC,
- );
- expect(installedEdge?.namespace).toEqual(
- REFERENCE_RESOURCE_QUERY_NAMESPACE,
- );
-
- const list = renderReference(inputWith());
- await screen.findByRole("heading", { name: "표시할 항목이 없습니다." });
- const listKey = list.client.getQueryCache().getAll()[0]?.queryKey;
- expect(listKey?.slice(0, 4)).toEqual([
- "query",
- 2,
- REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
- REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
- ]);
- list.unmount();
-
- const detail = renderReference(
- inputWith(),
- "/examples/reference-resources/reference-1",
- );
- await screen.findByText("Detail");
- const detailKey = detail.client.getQueryCache().getAll()[0]?.queryKey;
- expect(detailKey?.slice(0, 4)).toEqual([
- "query",
- 2,
- installedEdge?.namespace.namespaceId,
- installedEdge?.namespace.namespaceVersion,
- ]);
- detail.unmount();
- });
-
- it("renders loading, success and empty states through the installed route", async () => {
- let resolveList:
- | ((result: ReferenceResult) => void)
- | undefined;
- const pending = new Promise<
- ReferenceResult
- >((resolve) => {
- resolveList = resolve;
- });
- const loaded = renderReference(
- inputWith({ listResources: async () => pending }),
- );
- expect(await screen.findByLabelText("불러오는 중")).toBeVisible();
- resolveList?.({
- ok: true,
- value: [
- {
- resourceId: "reference-1",
- title: "Loaded",
- createdAt: null,
- },
- ],
- });
- expect(await screen.findByText("Loaded")).toBeVisible();
- loaded.unmount();
-
- renderReference(inputWith(), "/examples/reference-resources?limit=10");
- expect(
- await screen.findByRole("heading", {
- name: "표시할 항목이 없습니다.",
- }),
- ).toBeVisible();
- });
-
- it("rejects invalid URL input before the feature application input", async () => {
- const listResources = vi.fn();
- renderReference(
- inputWith({ listResources }),
- "/examples/reference-resources?limit=invalid",
- );
- expect(
- await screen.findByRole("heading", {
- name: "올바르지 않은 주소입니다.",
- }),
- ).toBeVisible();
- expect(listResources).not.toHaveBeenCalled();
- });
-
- it("renders backend forbidden even when the client access hint allowed entry", async () => {
- const user = userEvent.setup();
- renderReference(
- inputWith({
- listResources: async () => ({
- ok: false,
- error: createFailure(
- "FORBIDDEN",
- "LIST_REFERENCE_RESOURCES",
- 0,
- ),
- }),
- }),
- );
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "이 작업을 수행할 권한이 없습니다.",
- );
- await user.click(
- screen.getByRole("button", { name: "안전한 화면으로 이동" }),
- );
- expect(
- await screen.findByRole("heading", {
- level: 1,
- name: "Tech Log",
- }),
- ).toBeVisible();
- });
-
- it("starts sign-in with the current route for a backend auth failure", async () => {
- const user = userEvent.setup();
- const demoSession = createDemoSessionAdapter("authenticated");
- const beginSignIn = vi.fn(async () => {});
- const session = { ...demoSession, beginSignIn };
- renderReference(
- inputWith({
- listResources: async () => ({
- ok: false,
- error: createFailure(
- "AUTH_REQUIRED",
- "LIST_REFERENCE_RESOURCES",
- 0,
- ),
- }),
- }),
- "/examples/reference-resources?limit=5",
- session,
- );
-
- await user.click(await screen.findByRole("button", { name: "로그인" }));
- expect(beginSignIn).toHaveBeenCalledWith(
- "/examples/reference-resources?limit=5",
- );
- });
-
- it("falls back to a public route when starting sign-in fails", async () => {
- const user = userEvent.setup();
- const demoSession = createDemoSessionAdapter("authenticated");
- const beginSignIn = vi.fn(async () => {
- throw new Error("identity provider unavailable");
- });
- renderReference(
- inputWith({
- listResources: async () => ({
- ok: false,
- error: createFailure(
- "AUTH_REQUIRED",
- "LIST_REFERENCE_RESOURCES",
- 0,
- ),
- }),
- }),
- "/examples/reference-resources?limit=5",
- { ...demoSession, beginSignIn },
- );
-
- await user.click(await screen.findByRole("button", { name: "로그인" }));
- expect(
- await screen.findByRole("heading", {
- level: 1,
- name: "Tech Log",
- }),
- ).toBeVisible();
- });
-
- it("deduplicates create, preserves input and surfaces a conflict", async () => {
- const user = userEvent.setup();
- let finish:
- | ((result: ReferenceResult) => void)
- | undefined;
- const createResource = vi.fn(
- () =>
- new Promise>((resolve) => {
- finish = resolve;
- }),
- );
- renderReference(
- inputWith({ createResource }),
- "/examples/reference-resources/new",
- );
- await screen.findByRole("heading", {
- name: "Reference resource 만들기",
- });
- await user.type(
- screen.getByRole("textbox", { name: /새 항목 이름/ }),
- "Conflicting",
- );
- await user.type(screen.getByLabelText("설명"), "Keep this input");
- const submit = screen.getByRole("button", { name: "저장" });
- await user.dblClick(submit);
-
- await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
- expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
-
- finish?.({
- ok: false,
- error: createFailure(
- "CONFLICT",
- "CREATE_REFERENCE_RESOURCE",
- 0,
- { effect: "NOT_APPLIED" },
- ),
- });
- expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
- expect(
- screen.getByRole("textbox", { name: /새 항목 이름/ }),
- ).toHaveValue("Conflicting");
- expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
- });
-
- it("blocks resubmit and exposes only reconciliation for an unknown create effect", async () => {
- const user = userEvent.setup();
- const createResource = vi.fn(async () => ({
- ok: false as const,
- error: createFailure(
- "SERVER_FAILURE",
- "CREATE_REFERENCE_RESOURCE",
- 0,
- { effect: "MAYBE_APPLIED" },
- ),
- }));
- renderReference(
- inputWith({ createResource }),
- "/examples/reference-resources/new",
- );
- await user.type(
- await screen.findByRole("textbox", { name: /새 항목 이름/ }),
- "Unknown result",
- );
- await user.click(screen.getByRole("button", { name: "저장" }));
-
- expect(
- await screen.findByText("변경 결과를 확인할 수 없습니다."),
- ).toBeVisible();
- expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
- expect(
- screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
- ).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "저장" }));
- expect(createResource).toHaveBeenCalledOnce();
- expect(
- screen.getByRole("textbox", { name: /새 항목 이름/ }),
- ).toHaveValue("Unknown result");
-
- await user.click(
- screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
- );
- await waitFor(() =>
- expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(),
- );
- expect(createResource).toHaveBeenCalledOnce();
- expect(
- screen.getByRole("textbox", { name: /새 항목 이름/ }),
- ).toHaveValue("Unknown result");
- });
-
- it("settles the form after confirming an unknown create was applied", async () => {
- const user = userEvent.setup();
- const createResource = vi.fn(async () => ({
- ok: false as const,
- error: createFailure(
- "SERVER_FAILURE",
- "CREATE_REFERENCE_RESOURCE",
- 0,
- { effect: "MAYBE_APPLIED" },
- ),
- }));
- renderReference(
- inputWith({ createResource }),
- "/examples/reference-resources/new",
- );
- const name = await screen.findByRole("textbox", {
- name: /새 항목 이름/,
- });
- await user.type(name, "Already created");
- await user.click(screen.getByRole("button", { name: "저장" }));
- await user.click(
- await screen.findByRole("button", { name: "변경됨으로 확인" }),
- );
-
- expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
- expect(name).toHaveValue("");
- await user.click(screen.getByRole("button", { name: "저장" }));
- expect(createResource).toHaveBeenCalledOnce();
- });
-
- it("treats an applied-confirmed failure as a settled create", async () => {
- const user = userEvent.setup();
- const createResource = vi.fn(async () => ({
- ok: false as const,
- error: createFailure(
- "SERVER_FAILURE",
- "CREATE_REFERENCE_RESOURCE",
- 0,
- { effect: "APPLIED_CONFIRMED" },
- ),
- }));
- renderReference(
- inputWith({ createResource }),
- "/examples/reference-resources/new",
- );
- const name = await screen.findByRole("textbox", {
- name: /새 항목 이름/,
- });
- await user.type(name, "Committed despite response");
- await user.click(screen.getByRole("button", { name: "저장" }));
-
- expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
- expect(name).toHaveValue("");
- expect(
- screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
- ).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "저장" }));
- expect(createResource).toHaveBeenCalledOnce();
- });
-
- it("keeps stale data visible during refresh failure and recovers on retry", async () => {
- const user = userEvent.setup();
- const listResources = vi
- .fn()
- .mockResolvedValueOnce({
- ok: true,
- value: [
- {
- resourceId: "existing",
- title: "Existing",
- createdAt: null,
- },
- ],
- })
- .mockResolvedValueOnce({
- ok: false,
- error: createFailure(
- "SERVER_FAILURE",
- "LIST_REFERENCE_RESOURCES",
- 0,
- ),
- })
- .mockResolvedValue({
- ok: true,
- value: [
- {
- resourceId: "recovered",
- title: "Recovered",
- createdAt: null,
- },
- ],
- });
- renderReference(inputWith({ listResources }));
- await screen.findByText("Existing");
- await user.click(screen.getByRole("button", { name: "새로고침" }));
-
- expect(
- await screen.findByText("기존 정보를 표시하고 있습니다."),
- ).toBeVisible();
- expect(screen.getByText("Existing")).toBeVisible();
- await user.click(screen.getByRole("button", { name: "다시 시도" }));
- expect(await screen.findByText("Recovered")).toBeVisible();
- await waitFor(() =>
- expect(screen.queryByText("stale-degraded")).not.toBeInTheDocument(),
- );
- expect(listResources).toHaveBeenCalledTimes(3);
- });
-});
diff --git a/tests/features/reference-feature/reference-production-vertical.test.tsx b/tests/features/reference-feature/reference-production-vertical.test.tsx
deleted file mode 100644
index 18575f3..0000000
--- a/tests/features/reference-feature/reference-production-vertical.test.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-// @vitest-environment jsdom
-
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import {
- afterAll,
- afterEach,
- beforeAll,
- describe,
- expect,
- it,
- vi,
-} from "vitest";
-
-import { createRuntimeComposition } from "../../../src/bootstrap/create-runtime-composition.ts";
-import { RuntimeApplication } from "../../../src/bootstrap/runtime-application.tsx";
-import { createBootstrapHandlers } from "../../mocks/handlers/bootstrap.ts";
-import { createReferenceScenarioHandlers } from "../../mocks/handlers/reference-resources.ts";
-import { createStrictMockServer } from "../../mocks/server.ts";
-
-const runtimeConfig = {
- APP_ENV: "local",
- API_BASE_URL: "https://api.test",
- REQUEST_TIMEOUT_MS: 10_000,
- MAX_RETRY_ATTEMPTS: 0,
- TELEMETRY_ENABLED: false,
- AUTH_MODE: "demo",
- CONFIG_SCHEMA_VERSION: "1",
- API_CONTRACT_VERSION: "1",
- RELEASE_MANIFEST_URL: "/release-manifest.json",
- BUILD_ID: "local-build",
- RELEASE_ID: "local-release",
-};
-const releaseManifest = {
- schemaVersion: 1,
- appVersion: "0.1.0",
- buildId: "local-build",
- commitSha: "local",
- configSchemaVersion: "1",
- apiContractVersion: "1",
- assetManifestHash: "test-hash",
- releaseId: "local-release",
- builtAt: "2026-07-26T00:00:00.000Z",
- routeChunks: {
- "route-home": "assets/home.js",
- "route-examples-ui": "assets/ui.js",
- "route-examples-states": "assets/states.js",
- "route-examples-auth": "assets/auth.js",
- "route-reference-resources": "assets/reference.js",
- "route-reference-resource-detail": "assets/reference-detail.js",
- "route-reference-resource-form": "assets/reference-form.js",
- "route-reference-resource-status": "assets/reference-status.js",
- "route-not-found": "assets/not-found.js",
- },
-};
-
-const listRequests = vi.fn();
-const createRequests = vi.fn();
-const resources = [{ id: "reference-1", name: "Existing" }];
-const mockApi = createStrictMockServer(
- ...createBootstrapHandlers(runtimeConfig, releaseManifest),
- ...createReferenceScenarioHandlers({
- resources,
- onList: listRequests,
- onCreate: createRequests,
- }),
-);
-
-beforeAll(mockApi.listen);
-afterEach(() => {
- mockApi.reset();
- listRequests.mockClear();
- createRequests.mockClear();
- resources.splice(1);
-});
-afterAll(mockApi.close);
-
-const absoluteFetch: typeof fetch = (input, init) => {
- if (input instanceof Request) return fetch(input, init);
- const url = new URL(
- input instanceof URL ? input.href : input,
- "http://app.test",
- );
- return fetch(url, init);
-};
-
-describe("reference feature production vertical path", () => {
- it("traverses bootstrap, router, application, HTTP schema/mapper and query cache", async () => {
- const user = userEvent.setup();
- const composition = await createRuntimeComposition({
- fetcher: absoluteFetch,
- host: {},
- });
- window.history.pushState(
- {},
- "",
- "/examples/reference-resources?tags=open&tags=new&limit=5",
- );
- render( );
-
- await user.click(
- await screen.findByRole("button", { name: "로그인 시작" }),
- );
- expect(await screen.findByText("Existing")).toBeVisible();
- expect(listRequests).toHaveBeenCalledWith(
- "?limit=5&tags=open&tags=new",
- );
-
- await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
- await user.type(
- await screen.findByRole("textbox", { name: /새 항목 이름/ }),
- " Created ",
- );
- await user.click(screen.getByRole("button", { name: "저장" }));
- expect(await screen.findByText("저장했습니다.")).toBeVisible();
- await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
- expect(await screen.findByText("Created")).toBeVisible();
- expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
- expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
- });
-});
diff --git a/tests/features/tech-log/public-discovery-screens.test.tsx b/tests/features/tech-log/public-discovery-screens.test.tsx
index 25f39e0..6683ec5 100644
--- a/tests/features/tech-log/public-discovery-screens.test.tsx
+++ b/tests/features/tech-log/public-discovery-screens.test.tsx
@@ -27,9 +27,9 @@ import { ExploreKindPage } from "../../../src/features/tech-log/presentation/pub
import { ExplorePage } from "../../../src/features/tech-log/presentation/public/pages/explore-page.tsx";
import { HomePage } from "../../../src/features/tech-log/presentation/public/pages/home-page.tsx";
import { SearchPage } from "../../../src/features/tech-log/presentation/public/pages/search-page.tsx";
+import { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
-import NotFoundPage from "../../../src/presentation/pages/not-found-page.tsx";
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
import { createTestApplication } from "../../helpers/create-test-application.ts";
diff --git a/tests/features/tech-log/public-document-screens.test.tsx b/tests/features/tech-log/public-document-screens.test.tsx
index 755a27c..839cb2c 100644
--- a/tests/features/tech-log/public-document-screens.test.tsx
+++ b/tests/features/tech-log/public-document-screens.test.tsx
@@ -18,10 +18,10 @@ import { CasePage } from "../../../src/features/tech-log/presentation/public/pag
import { QuestionPage } from "../../../src/features/tech-log/presentation/public/pages/question-page.tsx";
import { ReferencePage } from "../../../src/features/tech-log/presentation/public/pages/reference-page.tsx";
import { TopicPage } from "../../../src/features/tech-log/presentation/public/pages/topic-page.tsx";
+import { PublicNotFoundPage as NotFoundPage } from "../../../src/features/tech-log/presentation/public/pages/public-not-found-page.tsx";
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
-import NotFoundPage from "../../../src/presentation/pages/not-found-page.tsx";
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
import { createTestApplication } from "../../helpers/create-test-application.ts";
diff --git a/tests/features/tech-log/route-contract.test.ts b/tests/features/tech-log/route-contract.test.ts
index 31d379b..0036cea 100644
--- a/tests/features/tech-log/route-contract.test.ts
+++ b/tests/features/tech-log/route-contract.test.ts
@@ -197,9 +197,11 @@ describe("TechLog route boundary contract", () => {
).toThrow();
});
- it("does not install unfinished TechLog route or runtime entries", () => {
- expect(Object.keys(ROUTE_REGISTRY).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false);
- expect(Object.keys(ROUTE_RUNTIME).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false);
- expect(Object.keys(ROUTE_REGISTRY)).toEqual(Object.keys(ROUTE_RUNTIME));
+ it("atomically installs the complete TechLog route and runtime inventories", () => {
+ expect(Object.keys(ROUTE_REGISTRY)).toEqual(
+ expectedRoutes.map(([routeId]) => routeId),
+ );
+ expect(Object.keys(ROUTE_RUNTIME)).toEqual(Object.keys(ROUTE_REGISTRY));
+ expect(ROUTE_REGISTRY).toEqual(TECH_LOG_ROUTE_REGISTRY);
});
});
diff --git a/tests/features/tech-log/studio-publication-flow.test.tsx b/tests/features/tech-log/studio-publication-flow.test.tsx
new file mode 100644
index 0000000..d76743c
--- /dev/null
+++ b/tests/features/tech-log/studio-publication-flow.test.tsx
@@ -0,0 +1,302 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
+import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
+import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
+import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
+import type { WorkingCopyInput } from "../../../src/features/tech-log/contracts/studio/contract.ts";
+import { PublicationEventPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx";
+import { PublicationList } from "../../../src/features/tech-log/presentation/studio/components/publication-list.tsx";
+import { PublishScreen } from "../../../src/features/tech-log/presentation/studio/components/publish-screen.tsx";
+import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
+
+const originalShowModal = HTMLDialogElement.prototype.showModal;
+const originalClose = HTMLDialogElement.prototype.close;
+
+class NoopIntersectionObserver implements IntersectionObserver {
+ readonly root = null;
+ readonly rootMargin = "0px";
+ readonly scrollMargin = "0px";
+ readonly thresholds = [0];
+
+ disconnect() {}
+ observe() {}
+ takeRecords(): IntersectionObserverEntry[] {
+ return [];
+ }
+ unobserve() {}
+}
+
+beforeAll(() => {
+ vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
+});
+
+afterAll(() => {
+ vi.unstubAllGlobals();
+});
+
+beforeEach(() => {
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
+ configurable: true,
+ value(this: HTMLDialogElement) {
+ this.setAttribute("open", "");
+ },
+ });
+ Object.defineProperty(HTMLDialogElement.prototype, "close", {
+ configurable: true,
+ value(this: HTMLDialogElement) {
+ this.removeAttribute("open");
+ this.dispatchEvent(new Event("close"));
+ },
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
+ configurable: true,
+ value: originalShowModal,
+ });
+ Object.defineProperty(HTMLDialogElement.prototype, "close", {
+ configurable: true,
+ value: originalClose,
+ });
+});
+
+function renderInStudio(
+ node: React.ReactNode,
+ gateway: StudioGateway = createTechLogFeatureInstalledInput().input.createStudioGateway(),
+ navigate: (href: string) => void = () => undefined,
+) {
+ return render(
+
+ gateway} navigate={navigate}>
+ {node}
+
+ ,
+ );
+}
+
+async function warningReadyDocument(gateway: StudioGateway) {
+ const input: WorkingCopyInput = {
+ kind: "CASE",
+ title: "게시 경고 예시",
+ slug: "publish-warning-example",
+ summary: "경고 확인 뒤 게시합니다.",
+ topicId: FIXTURE_IDS.topicJpa,
+ projectId: null,
+ relations: [],
+ problem: "경고가 있습니다.",
+ conclusion: "확인 뒤 게시합니다.",
+ environment: "Studio",
+ reproduction: "Mock",
+ lastVerifiedOn: "2026-08-14",
+ bodyMarkdown: "게시할 본문",
+ };
+ const document = await gateway.createDocument(input, {
+ idempotencyKey: "publication-test-create",
+ });
+ const validation = await gateway.validateDocument(
+ document.id,
+ { expectedVersion: 1 },
+ { idempotencyKey: "publication-test-validation" },
+ );
+ await gateway.createPreview(
+ document.id,
+ { expectedVersion: 1, validationId: validation.validationId },
+ { idempotencyKey: "publication-test-preview" },
+ );
+ return document;
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((nextResolve, nextReject) => {
+ resolve = nextResolve;
+ reject = nextReject;
+ });
+ return { promise, resolve, reject };
+}
+
+describe("TechLog Studio publication flow", () => {
+ it("blocks invalid and stale saved versions before a publish command can start", async () => {
+ const invalid = renderInStudio(
+ ,
+ );
+ expect(await screen.findByText("검증 오류를 먼저 수정해야 합니다")).toBeVisible();
+ expect(screen.queryByRole("button", { name: "게시" })).not.toBeInTheDocument();
+ invalid.unmount();
+
+ renderInStudio( );
+ expect(await screen.findByText("검증 결과가 현재 버전과 다릅니다")).toBeVisible();
+ expect(screen.getByRole("link", { name: "다시 검증" })).toHaveAttribute(
+ "href",
+ `/studio/documents/${FIXTURE_IDS.fetchJoinCase}/validation`,
+ );
+ });
+
+ it("publishes a current warning preview only after every warning is acknowledged", async () => {
+ const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
+ const document = await warningReadyDocument(gateway);
+ const destinations: string[] = [];
+ renderInStudio(
+ ,
+ gateway,
+ (href) => destinations.push(href),
+ );
+
+ const publish = await screen.findByRole("button", { name: "게시" });
+ expect(publish).toBeDisabled();
+ await userEvent.click(screen.getByRole("checkbox", { name: /PROJECT_MISSING/ }));
+ expect(publish).toBeEnabled();
+ await userEvent.click(publish);
+
+ await waitFor(() =>
+ expect(destinations[0]).toMatch(
+ /^\/studio\/publications\/[0-9a-f-]+\/preview$/,
+ ),
+ );
+ expect(screen.getByRole("status", { name: "" })).toHaveTextContent("게시했습니다.");
+ expect((await gateway.listPublications({ limit: 100 })).items[0]).toMatchObject({
+ event: { type: "PUBLISHED", publishedVersion: 1 },
+ publication: { status: "PUBLISHED", publicPath: "/cases/publish-warning-example" },
+ });
+ });
+
+ it("keeps the publish pending state, preserves gateway command order, and retries with a new key", async () => {
+ const user = userEvent.setup();
+ const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
+ const document = await warningReadyDocument(base);
+ const first = deferred();
+ const calls: string[] = [];
+ const keys: string[] = [];
+ const publishDocument = vi
+ .fn()
+ .mockImplementationOnce((_id, _command, options) => {
+ calls.push("publishDocument");
+ keys.push(options.idempotencyKey);
+ return first.promise;
+ })
+ .mockImplementation((...args) => {
+ calls.push("publishDocument");
+ keys.push(args[2].idempotencyKey);
+ return base.publishDocument(...args);
+ });
+ const gateway = {
+ ...base,
+ getDocument(...args: Parameters) {
+ calls.push("getDocument");
+ return base.getDocument(...args);
+ },
+ getCurrentPreview(...args: Parameters) {
+ calls.push("getCurrentPreview");
+ return base.getCurrentPreview(...args);
+ },
+ publishDocument,
+ } satisfies StudioGateway;
+ renderInStudio( , gateway);
+
+ await user.click(await screen.findByRole("checkbox", { name: /PROJECT_MISSING/ }));
+ await user.click(screen.getByRole("button", { name: "게시" }));
+ expect(screen.getByRole("button", { name: "게시 중…" })).toBeDisabled();
+ expect(calls.slice(0, 3)).toEqual([
+ "getDocument",
+ "getCurrentPreview",
+ "publishDocument",
+ ]);
+
+ first.reject(new StudioGatewayError({
+ type: "https://techlog.local/problems/studio-unavailable",
+ title: "STUDIO_UNAVAILABLE",
+ status: 503,
+ detail: "Studio가 잠시 응답하지 않습니다.",
+ code: "STUDIO_UNAVAILABLE",
+ retryable: true,
+ }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Studio가 잠시 응답하지 않습니다.",
+ );
+ await user.click(screen.getByRole("button", { name: "게시" }));
+ await waitFor(() => expect(publishDocument).toHaveBeenCalledTimes(2));
+ expect(keys[1]).not.toBe(keys[0]);
+ });
+
+ it("filters publication events and recovers a failed history read", async () => {
+ const user = userEvent.setup();
+ const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
+ const listPublications = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("offline"))
+ .mockImplementation((query, options) => base.listPublications(query, options));
+ renderInStudio( , { ...base, listPublications });
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "게시 기록을 불러오지 못했습니다offline",
+ );
+ await user.click(screen.getByRole("button", { name: "다시 시도" }));
+ expect(await screen.findByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).toBeVisible();
+
+ await user.selectOptions(screen.getByLabelText("이벤트"), "UNPUBLISHED");
+ await user.type(screen.getByLabelText("검색"), "Fetch 전략");
+ await user.click(screen.getByRole("button", { name: "적용" }));
+ expect(await screen.findByRole("heading", { name: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준" })).toBeVisible();
+ expect(screen.queryByRole("heading", { name: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유" })).not.toBeInTheDocument();
+ });
+
+ it("unpublishes only the selected current row after the source confirmation", async () => {
+ const user = userEvent.setup();
+ const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
+ renderInStudio( , gateway);
+ await user.click(
+ await screen.findByRole("button", { name: /Redis Adapter.*게시 취소/ }),
+ );
+
+ expect(screen.getByRole("dialog", { name: "게시를 취소할까요?" })).toHaveTextContent(
+ "Studio 게시 상태를 중단하고 게시 취소 이벤트를 남깁니다.",
+ );
+ expect(screen.getByText("작업본과 이전 Snapshot은 보존됩니다.")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "게시 취소 확인" }));
+
+ await waitFor(() => expect(screen.getAllByText("게시를 취소했습니다.").length).toBeGreaterThanOrEqual(1));
+ expect(await screen.findAllByRole("link", { name: "게시 취소 전 Snapshot 보기" })).not.toHaveLength(0);
+ });
+
+ it("renders the event's immutable snapshot instead of a newer working copy", async () => {
+ const view = renderInStudio(
+ ,
+ );
+
+ expect(await screen.findByRole("heading", { level: 1, name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가" })).toBeVisible();
+ expect(screen.getByText(/반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정/)).toBeVisible();
+ expect(screen.queryByText("게시 후 본문 측정값을 보완한 저장본입니다.")).not.toBeInTheDocument();
+ expect(view.container.querySelectorAll("main")).toHaveLength(0);
+ expect(view.container.querySelector(".public-record-embedded")).toBeInTheDocument();
+ });
+
+ it("keeps unknown publication events inside the Studio not-found screen", async () => {
+ renderInStudio(
+ ,
+ );
+
+ expect(await screen.findByRole("heading", { level: 1, name: "게시 기록을 찾을 수 없습니다" })).toBeVisible();
+ expect(screen.getByRole("link", { name: "게시 기록으로 돌아가기" })).toHaveAttribute(
+ "href",
+ "/studio/publications",
+ );
+ });
+});
diff --git a/tests/unit/navigation-policy.test.ts b/tests/unit/navigation-policy.test.ts
index 35f492d..803f9e5 100644
--- a/tests/unit/navigation-policy.test.ts
+++ b/tests/unit/navigation-policy.test.ts
@@ -23,7 +23,7 @@ describe("installed route registry", () => {
});
it("allows public routes without consulting a product permission", () => {
- expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
+ expect(decideRouteAccess("TECH_LOG_HOME", "unauthenticated")).toEqual({
allowed: true,
action: "none",
});
diff --git a/vite.config.ts b/vite.config.ts
index 5f6ef6a..6fea2b9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,10 +4,54 @@ import tailwindcss from "@tailwindcss/vite";
import { viteModuleInventoryPlugin } from "./scripts/lib/vite-module-inventory.ts";
+const techLogRouteChunks = Object.freeze({
+ "/public/pages/home-page.tsx": "route-tech-log-home",
+ "/public/pages/explore-page.tsx": "route-tech-log-explore",
+ "/public/pages/explore-kind-page.tsx": "route-tech-log-explore-kind",
+ "/public/pages/case-page.tsx": "route-tech-log-case",
+ "/public/pages/reference-page.tsx": "route-tech-log-reference",
+ "/public/pages/question-page.tsx": "route-tech-log-question",
+ "/public/pages/topic-page.tsx": "route-tech-log-topic",
+ "/public/pages/projects-page.tsx": "route-tech-log-projects",
+ "/public/pages/project-overview-page.tsx": "route-tech-log-project",
+ "/public/pages/project-records-page.tsx": "route-tech-log-project-records",
+ "/public/pages/project-decisions-page.tsx": "route-tech-log-project-decisions",
+ "/public/pages/project-activity-page.tsx": "route-tech-log-project-activity",
+ "/public/pages/releases-page.tsx": "route-tech-log-releases",
+ "/public/pages/release-page.tsx": "route-tech-log-release",
+ "/public/pages/profile-page.tsx": "route-tech-log-profile",
+ "/public/pages/search-page.tsx": "route-tech-log-search",
+ "/studio/pages/studio-home-page.tsx": "route-tech-log-studio-home",
+ "/studio/pages/documents-page.tsx": "route-tech-log-studio-documents",
+ "/studio/pages/new-document-page.tsx": "route-tech-log-studio-document-new",
+ "/studio/pages/document-edit-page.tsx": "route-tech-log-studio-document-edit",
+ "/studio/pages/document-validation-page.tsx": "route-tech-log-studio-document-validation",
+ "/studio/pages/document-preview-page.tsx": "route-tech-log-studio-document-preview",
+ "/studio/pages/document-publish-page.tsx": "route-tech-log-studio-document-publish",
+ "/studio/pages/publications-page.tsx": "route-tech-log-studio-publications",
+ "/studio/pages/publication-preview-page.tsx": "route-tech-log-studio-publication-preview",
+ "/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
+ "/public/pages/public-not-found-page.tsx": "route-not-found",
+} as const);
+
+function routeChunkName(moduleId: string): string | undefined {
+ const normalized = moduleId.replaceAll("\\", "/");
+ const featurePath = "/src/features/tech-log/presentation";
+ const featureIndex = normalized.lastIndexOf(featurePath);
+ if (featureIndex < 0) return undefined;
+ const routePath = normalized.slice(featureIndex + featurePath.length);
+ return techLogRouteChunks[routePath as keyof typeof techLogRouteChunks];
+}
+
export default defineConfig({
plugins: [react(), tailwindcss(), viteModuleInventoryPlugin()],
build: {
manifest: true,
sourcemap: false,
+ rolldownOptions: {
+ output: {
+ manualChunks: routeChunkName,
+ },
+ },
},
});