From 31009d34d095a50fcc4124361fd19ab0c115b6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 31 Aug 2026 14:41:18 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20Document=20Type=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20=EB=AA=A8=EB=93=88=20=EA=B0=90=EC=82=AC=20ledger=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- audits/README.md | 23 +++ audits/document-types.json | 183 ++++++++++++++++++ site/package.json | 2 +- site/scripts/check-document-type-audits.mjs | 41 ++++ .../docs/DocumentTypeCandidateRoute.tsx | 18 ++ site/tests/browser/site-shell.spec.ts | 3 + 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 audits/README.md create mode 100644 audits/document-types.json create mode 100644 site/scripts/check-document-type-audits.mjs diff --git a/audits/README.md b/audits/README.md new file mode 100644 index 00000000..fcd392d5 --- /dev/null +++ b/audits/README.md @@ -0,0 +1,23 @@ +# Canonical module audits + +이 폴더는 공개 제품 문서가 아니라 repository 책임 감사를 위한 machine-readable +ledger를 소유합니다. 사이트는 결과를 표시할 수 있지만 감사 계약과 판정은 +site-owned가 아닙니다. + +`document-types.json`의 후보 분모는 `site/site-routes.json`의 `Document Types` +서브 메뉴입니다. 후보를 감사할 때는 다음 순서로 닫습니다. + +1. package public entrypoint, Usage source registry와 live demo registry에서 runtime + closure를 정한다. +2. 독립 knowledge, decision, change reason 또는 lifecycle마다 responsibility + occurrence를 하나 만든다. 한 파일에 여러 책임이 있으면 passport를 나눈다. +3. 모든 occurrence에 `$canonical-module-audit`의 MECE disposition을 정확히 하나 + 기록한다. +4. `sourcePath`와 `symbol`을 실제 구현에 연결하고 `denominator`를 occurrence 수와 + 일치시킨다. +5. nonconforming occurrence가 남아 있으면 후보 상태를 `audited-tbd`로 유지한다. + +Ledger 추가는 `npm run check:canonical-modules -w +@interactive-os/json-document-site`로 검증합니다. Guard는 내비게이션 분모 불일치, +누락된 source·symbol·passport field, 중복 occurrence, 잘못된 disposition과 성급한 +TBD 종료를 실패시킵니다. diff --git a/audits/document-types.json b/audits/document-types.json new file mode 100644 index 00000000..f88e72fb --- /dev/null +++ b/audits/document-types.json @@ -0,0 +1,183 @@ +{ + "version": 1, + "invariant": "same role + same responsibility = same canonical module", + "candidates": ["rich-text", "order", "object", "tree", "database", "calendar", "sheet", "kanban", "annotation"], + "audits": { + "calendar": { + "status": "audited-tbd", + "denominator": 10, + "horizon": { + "enumerators": [ + "packages/json-document-editing/src/index.ts", + "packages/json-document-calendar/src/index.ts", + "site/src/shared/demo-workbench/demo-sources.ts", + "site/src/app/live-demo-registry.tsx" + ], + "predicate": "Calendar의 model, invariant, semantic operation, projection, Editing lifecycle, platform translation, affordance, Hand와 reusable UI 책임", + "excluded": "fixtures, copy, layout-only CSS, tests and generated route files" + }, + "occurrences": [ + { + "id": "calendar-model", + "role": "Document Model", + "knowledge": "calendar, event, recurrence and calendar membership vocabulary", + "decision": "the canonical JSON shape of a Calendar document", + "changeReason": "Calendar document vocabulary changes", + "stateLifecycle": "immutable document value", + "inputsOutputs": "CalendarDocument and nested JSON values", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "Document Type boundary: model survives without Editing and UI", + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "CalendarDocument", + "disposition": "mislocated module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "admit and name the Calendar Document Type owner before moving the public type" + }, + { + "id": "calendar-invariant", + "role": "Validation", + "knowledge": "Calendar document reference and interval invariants", + "decision": "whether a Calendar document is valid", + "changeReason": "Calendar validity rules change", + "stateLifecycle": "stateless validation", + "inputsOutputs": "CalendarDocument to validation failure or success", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "Document Type boundary owns schema and invariants", + "sourcePath": "packages/json-document-editing/src/calendar-validation.ts", + "symbol": "assertCalendarDocument", + "disposition": "mislocated module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "define a stable validation contract at the admitted owner" + }, + { + "id": "calendar-occurrence-projection", + "role": "Projection", + "knowledge": "recurrence expansion and excluded occurrence dates", + "decision": "which occurrences a recurring event produces in a range", + "changeReason": "Calendar recurrence semantics change", + "stateLifecycle": "stateless projection", + "inputsOutputs": "CalendarEvent and range to CalendarOccurrence list", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "Document Type boundary owns projections independent of Editing", + "sourcePath": "packages/json-document-editing/src/calendar-occurrence.ts", + "symbol": "projectCalendarOccurrences", + "disposition": "mislocated module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "separate recurrence projection from occurrence editing transitions" + }, + { + "id": "calendar-semantic-operation", + "role": "Document Operation", + "knowledge": "Calendar event and recurrence mutation meaning", + "decision": "the semantic event update represented by a Calendar operation", + "changeReason": "Calendar mutation semantics change", + "stateLifecycle": "operation value interpreted by Editing", + "inputsOutputs": "CalendarIntent event cases to document patch", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "CalendarIntent currently mixes document operations with selection and clipboard intents", + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "CalendarIntent", + "disposition": "missing canonical module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "split input-independent Calendar operations from Editing-only intents" + }, + { + "id": "calendar-editor", + "role": "Editing lifecycle", + "knowledge": "selection, clipboard, history and Intent execution", + "decision": "how Calendar editing state transitions are committed", + "changeReason": "Calendar editing workflow changes", + "stateLifecycle": "stateful Editing session", + "inputsOutputs": "EditingDocumentSource and Calendar Intent to EditingResult", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "repository naming standard assigns Intent, Selection, Clipboard and History to Editing", + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "createCalendarEditor", + "disposition": "canonical API gap", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "consume the future Calendar Document Type API instead of locally owning its model and rules" + }, + { + "id": "calendar-pointer-interpretation", + "role": "Affordance", + "knowledge": "input-independent pointer release gesture meaning", + "decision": "which Calendar resize or move intent a gesture means", + "changeReason": "Calendar gesture grammar changes", + "stateLifecycle": "pointer gesture release", + "inputsOutputs": "pointer release value to Calendar intent", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "repository naming standard assigns human manipulation grammar to Affordance", + "sourcePath": "packages/json-document-editing/src/calendar-time-grid-pointer.ts", + "symbol": "interpretCalendarTimeGridPointer", + "disposition": "mislocated module", + "intendedOwner": "@interactive-os/json-document-affordance", + "nextCheck": "compare all-day and month pointer interpreters before choosing one Calendar affordance module" + }, + { + "id": "calendar-web-input", + "role": "Web Adapter", + "knowledge": "DOM keyboard and grid geometry contracts", + "decision": "how Web input becomes Calendar-neutral input values", + "changeReason": "browser input contracts change", + "stateLifecycle": "platform event translation", + "inputsOutputs": "Web event and element geometry to public input values", + "currentOwner": "@interactive-os/json-document-web", + "canonicalEvidence": "repository naming standard assigns platform translation to Adapter", + "sourcePath": "packages/json-document-web/src/calendar-input.ts", + "symbol": "calendarCommandFromWebKeyboardEvent", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-web", + "nextCheck": "preserve Web-only knowledge while Calendar operations are separated" + }, + { + "id": "calendar-hand", + "role": "Hand composition", + "knowledge": "Calendar editor observation and occurrence interaction composition", + "decision": "how Editing, Adapter, Affordance and UI close into a usable Calendar Hand", + "changeReason": "Calendar Hand interaction composition changes", + "stateLifecycle": "React Hand lifecycle", + "inputsOutputs": "CalendarEditor to CalendarHand", + "currentOwner": "@interactive-os/json-document-calendar", + "canonicalEvidence": "package metadata and implementation-shape standard classify this package as Product-facing Hand", + "sourcePath": "packages/json-document-calendar/src/use-calendar-hand.ts", + "symbol": "useCalendarHand", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar", + "nextCheck": "keep composition only after Document Type capabilities move behind a public API" + }, + { + "id": "calendar-month-grid", + "role": "Reusable UI behavior", + "knowledge": "Calendar month-grid rendering and interaction surface", + "decision": "accessible month-grid projection and interaction contract", + "changeReason": "Calendar Hand UI behavior changes", + "stateLifecycle": "React component lifecycle", + "inputsOutputs": "Calendar events, Hand and policies to React UI", + "currentOwner": "@interactive-os/json-document-calendar", + "canonicalEvidence": "public Calendar Hand package owns reusable Calendar UI behavior", + "sourcePath": "packages/json-document-calendar/src/calendar-month-grid.tsx", + "symbol": "CalendarMonthGrid", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar", + "nextCheck": "ensure projection inputs come from the future Document Type API" + }, + { + "id": "calendar-host", + "role": "Host composition", + "knowledge": "demo fixtures, product copy, layout and concrete dependency injection", + "decision": "which Calendar capabilities and policies the demo composes", + "changeReason": "Calendar demo product composition changes", + "stateLifecycle": "route lifecycle", + "inputsOutputs": "fixtures and concrete instances to rendered product page", + "currentOwner": "site Calendar route", + "canonicalEvidence": "live demo registry is the canonical Host enumerator", + "sourcePath": "site/src/routes/calendar-demo/CalendarDemoRoute.tsx", + "symbol": "CalendarDemoRoute", + "disposition": "Host composition", + "intendedOwner": "site Calendar route", + "nextCheck": "audit the runtime import closure for local model, schema, operation or projection implementations" + } + ] + } + } +} diff --git a/site/package.json b/site/package.json index 1aa80209..ef90278a 100644 --- a/site/package.json +++ b/site/package.json @@ -14,7 +14,7 @@ "check:primitives": "node scripts/check-live-demo-primitives.mjs", "check:ui-roles": "node scripts/check-ui-role-primitives.mjs", "check:choice-id": "node scripts/check-choice-id.mjs", - "check:canonical-modules": "node scripts/check-canonical-module-closure.mjs", + "check:canonical-modules": "node scripts/check-canonical-module-closure.mjs && node scripts/check-document-type-audits.mjs", "check:interaction-handles": "node scripts/check-interaction-handles.mjs", "check:contextual-affordance": "node scripts/check-contextual-affordance.mjs", "check:product-shell-toolbar": "node scripts/check-product-shell-toolbar.mjs", diff --git a/site/scripts/check-document-type-audits.mjs b/site/scripts/check-document-type-audits.mjs new file mode 100644 index 00000000..22abcfd1 --- /dev/null +++ b/site/scripts/check-document-type-audits.mjs @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("../..", import.meta.url).pathname; +const ledger = JSON.parse(readFileSync(join(root, "audits/document-types.json"), "utf8")); +const siteRoutes = JSON.parse(readFileSync(join(root, "site/site-routes.json"), "utf8")); +const allowed = new Set(["canonical consumer", "Host composition", "duplicate implementation", "canonical API gap", "missing canonical module", "mislocated module", "out of scope", "unverified"]); +const expectedCandidates = siteRoutes + .filter((route) => route.navigationGroup === "Document Types" && route.path !== "/docs/document-types") + .map((route) => route.path.slice("/docs/document-types/".length)); + +if (JSON.stringify(ledger.candidates) !== JSON.stringify(expectedCandidates)) throw new Error("Document Type audit candidates do not match the TBD navigation denominator"); + +for (const [candidate, audit] of Object.entries(ledger.audits)) { + if (!ledger.candidates.includes(candidate)) throw new Error(`unknown Document Type audit: ${candidate}`); + if (audit.denominator !== audit.occurrences.length) throw new Error(`${candidate} audit denominator mismatch: expected ${audit.denominator}, found ${audit.occurrences.length}`); + const ids = new Set(); + for (const occurrence of audit.occurrences) { + if (ids.has(occurrence.id)) throw new Error(`${candidate} duplicate occurrence id: ${occurrence.id}`); + ids.add(occurrence.id); + if (!allowed.has(occurrence.disposition)) throw new Error(`${candidate}/${occurrence.id} has invalid disposition: ${occurrence.disposition}`); + for (const field of ["role", "knowledge", "decision", "changeReason", "stateLifecycle", "inputsOutputs", "currentOwner", "canonicalEvidence", "sourcePath", "intendedOwner", "nextCheck"]) { + if (typeof occurrence[field] !== "string" || occurrence[field].trim() === "") throw new Error(`${candidate}/${occurrence.id} is missing ${field}`); + } + const sourceFile = join(root, occurrence.sourcePath); + if (!existsSync(sourceFile)) throw new Error(`${candidate}/${occurrence.id} source does not exist: ${occurrence.sourcePath}`); + if (occurrence.symbol && !new RegExp(`\\b${occurrence.symbol}\\b`).test(readFileSync(sourceFile, "utf8"))) { + throw new Error(`${candidate}/${occurrence.id} symbol does not exist: ${occurrence.symbol}`); + } + } +} + +const calendar = ledger.audits.calendar; +for (const role of ["Document Model", "Validation", "Projection", "Document Operation", "Editing lifecycle", "Affordance", "Web Adapter", "Hand composition", "Reusable UI behavior", "Host composition"]) { + if (!calendar.occurrences.some((occurrence) => occurrence.role === role)) throw new Error(`Calendar audit is missing role: ${role}`); +} +if (calendar.status !== "audited-tbd" || !calendar.occurrences.some((occurrence) => !["canonical consumer", "Host composition"].includes(occurrence.disposition))) { + throw new Error("Calendar must remain audited-tbd while nonconforming occurrences remain"); +} + +console.log(`Document Type audits ok; candidates=${ledger.candidates.length}; audited=${Object.keys(ledger.audits).length}; Calendar occurrences=${calendar.occurrences.length}.`); diff --git a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx index 317ab30b..85eaf9db 100644 --- a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx +++ b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx @@ -1,5 +1,6 @@ import { PageFrame, PageHeader } from "../../shared/ui/primitives"; import { MarkdownViewer } from "./MarkdownViewer"; +import documentTypeAudits from "../../../../audits/document-types.json"; const candidateNames = { "rich-text": "Rich Text", @@ -17,10 +18,27 @@ export type DocumentTypeCandidate = keyof typeof candidateNames; export function DocumentTypeCandidateRoute(props: { readonly candidate: DocumentTypeCandidate }) { const name = candidateNames[props.candidate]; + const audit = props.candidate === "calendar" ? documentTypeAudits.audits.calendar : undefined; + const auditSource = audit === undefined ? "" : ` +## 소스 기반 감사 현황 + +- 상태: **감사됨 · TBD** +- 감사 분모: **${audit.denominator}개 책임 occurrence** +- 정본 소비/Host composition: **${audit.occurrences.filter((item) => item.disposition === "canonical consumer" || item.disposition === "Host composition").length}개** +- canonical move 필요: **${audit.occurrences.filter((item) => item.disposition !== "canonical consumer" && item.disposition !== "Host composition").length}개** + +| 책임 | 현재 owner | 판정 | 다음 확인 | +| --- | --- | --- | --- | +${audit.occurrences.map((item) => `| ${item.role} | \`${item.currentOwner}\` | ${item.disposition} | ${item.nextCheck} |`).join("\n")} + +감사 분모는 \`${audit.horizon.enumerators.join("`, `")}\`에서 출발하며, +fixture, copy, layout-only CSS, tests와 generated route는 제외합니다. +`; const source = `## 후보 상태 ${name}은 현재 Document Type 후보입니다. 기존 package와 Hands에서 실제 책임을 옮기거나 완료를 선언하지 않았습니다. +${auditSource} ## 확정에 필요한 증거 diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts index 8a0eae25..fe35f7ce 100644 --- a/site/tests/browser/site-shell.spec.ts +++ b/site/tests/browser/site-shell.spec.ts @@ -211,6 +211,9 @@ test("Document Type candidate submenu keeps ownership explicitly TBD", async ({ await expect(page).toHaveTitle("Calendar Document Type · TBD - json-document"); await expect(page.getByRole("heading", { level: 1, name: "Calendar Document Type · TBD" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "확정에 필요한 증거" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 2, name: "소스 기반 감사 현황" })).toBeVisible(); + await expect(page.getByText("10개 책임 occurrence", { exact: false })).toBeVisible(); + await expect(page.getByRole("cell", { name: "mislocated module", exact: true }).first()).toBeVisible(); await expect(page.getByRole("navigation", { name: "Site navigation" }).getByRole("link", { name: "Calendar · TBD" })).toHaveAttribute("aria-current", "page"); }); From 27eafe89ca04f0a9b790530e2fbd98088a09a230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 31 Aug 2026 14:49:57 +0900 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20Document=20Type=20TBD=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=84=A4=EB=AA=85=EA=B3=BC=20schema=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- audits/README.md | 4 +- audits/document-types.json | 65 +++++++++++++++++++ site/scripts/check-document-type-audits.mjs | 13 +++- .../docs/DocumentTypeCandidateRoute.tsx | 20 ++++++ site/tests/browser/site-shell.spec.ts | 4 ++ 5 files changed, 104 insertions(+), 2 deletions(-) diff --git a/audits/README.md b/audits/README.md index fcd392d5..fbd4d729 100644 --- a/audits/README.md +++ b/audits/README.md @@ -5,7 +5,9 @@ ledger를 소유합니다. 사이트는 결과를 표시할 수 있지만 감사 site-owned가 아닙니다. `document-types.json`의 후보 분모는 `site/site-routes.json`의 `Document Types` -서브 메뉴입니다. 후보를 감사할 때는 다음 순서로 닫습니다. +서브 메뉴입니다. 모든 후보는 `candidateProfiles`에 필요성, 역할, 현재 관찰된 +schema와 근거 source symbol을 기록합니다. 이 schema는 감사 입력이며 정본 계약을 +미리 확정하지 않습니다. 후보를 감사할 때는 다음 순서로 닫습니다. 1. package public entrypoint, Usage source registry와 live demo registry에서 runtime closure를 정한다. diff --git a/audits/document-types.json b/audits/document-types.json index f88e72fb..b7d60b8f 100644 --- a/audits/document-types.json +++ b/audits/document-types.json @@ -2,6 +2,71 @@ "version": 1, "invariant": "same role + same responsibility = same canonical module", "candidates": ["rich-text", "order", "object", "tree", "database", "calendar", "sheet", "kanban", "annotation"], + "candidateProfiles": { + "rich-text": { + "why": "paragraph, list, mark와 extension node의 의미와 유효성을 일반 JSON 편집 규칙만으로 보존할 수 없기 때문입니다.", + "does": "Rich Text node vocabulary, block/inline 관계, mark와 extension 계약을 정의하고 구조화된 문서를 text·HTML 같은 표현으로 투영합니다.", + "schema": "{ profile: string; type: \"doc\"; id: string; content: RichTextBlockNode[] }", + "sourcePath": "packages/json-document-rich-text/src/model.ts", + "symbol": "RichTextDocument" + }, + "order": { + "why": "순서가 의미인 item 집합은 배열 위치뿐 아니라 안정된 item identity와 rename 규칙을 보존해야 하기 때문입니다.", + "does": "식별된 item의 선형 순서와 label을 정의하고 reorder·rename 같은 의미 연산의 기반을 제공합니다.", + "schema": "{ items: Array<{ id: string; label: string }> }", + "sourcePath": "packages/json-document-editing/src/order.ts", + "symbol": "OrderDocument" + }, + "object": { + "why": "캔버스 object는 identity, geometry와 presentation 값을 하나의 의미 단위로 유지해야 하기 때문입니다.", + "does": "object의 위치·크기·색상 모델을 정의하고 translate·resize·fill 같은 공간 연산의 기반을 제공합니다.", + "schema": "{ objects: Array<{ id: string; label: string; x: number; y: number; width: number; height: number; color: string }> }", + "sourcePath": "packages/json-document-editing/src/object.ts", + "symbol": "ObjectDocument" + }, + "tree": { + "why": "계층 문서는 node identity와 parent reference의 무결성, 순서와 가시성 투영을 함께 보존해야 하기 때문입니다.", + "does": "parent-linked node 모델을 정의하고 visible tree와 구조 이동을 위한 의미 기반을 제공합니다.", + "schema": "{ nodes: Array<{ id: string; parentId: string | null; label: string }> }", + "sourcePath": "packages/json-document-editing/src/tree.ts", + "symbol": "TreeDocument" + }, + "database": { + "why": "record 값은 property schema와 view가 참조하는 identity·type 규칙을 함께 만족해야 하기 때문입니다.", + "does": "property schema, record 값과 저장된 table view를 정의하고 typed value·sort·filter projection의 기반을 제공합니다.", + "schema": "{ schema: { properties: DatabaseProperty[] }; records: DatabaseRecord[]; views: DatabaseTableView[] }", + "sourcePath": "packages/json-document-editing/src/database.ts", + "symbol": "DatabaseDocument" + }, + "calendar": { + "why": "event interval, calendar reference와 recurrence가 시간 의미와 참조 무결성을 함께 보존해야 하기 때문입니다.", + "does": "calendar와 event·recurrence 모델을 정의하고 occurrence, busy date와 기간별 event projection의 기반을 제공합니다.", + "schema": "{ calendars: CalendarCalendar[]; events: Array<{ id: string; title: string; start: string; end: string; allDay: boolean; calendarId: string; recurrence: CalendarRecurrence | null; excludeDates: string[] }> }", + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "CalendarDocument" + }, + "sheet": { + "why": "grid cell 값은 안정된 row·column identity와 좌표 관계를 유지해야 배열 index 변화에도 의미가 보존되기 때문입니다.", + "does": "column, row와 column-keyed cell 모델을 정의하고 grid topology와 range projection의 기반을 제공합니다.", + "schema": "{ columns: Array<{ id: string; label: string }>; rows: Array<{ id: string; cells: Record }> }", + "sourcePath": "packages/json-document-editing/src/sheet.ts", + "symbol": "SheetDocument" + }, + "kanban": { + "why": "card identity와 column의 card reference·순서가 보드 상태의 핵심 불변식이기 때문입니다.", + "does": "column과 card 모델, column별 card 순서를 정의하고 card 이동 연산의 기반을 제공합니다.", + "schema": "{ columns: Array<{ id: string; title: string; cardIds: string[] }>; cards: Array<{ id: string; title: string }> }", + "sourcePath": "packages/json-document-editing/src/kanban.ts", + "symbol": "KanbanDocument" + }, + "annotation": { + "why": "annotation은 source identity, selector geometry, instruction body와 presentation 의미를 일관되게 연결해야 하기 때문입니다.", + "does": "annotation source·selector·body·presentation 모델과 참조 무결성을 정의하고 target별 annotation projection의 기반을 제공합니다.", + "schema": "{ profile: \"urn:interactive-os:json-document:annotation:1\"; id: string; sources: AnnotationSource[]; annotations: Annotation[] }", + "sourcePath": "packages/json-document-editing/src/annotation.ts", + "symbol": "AnnotationDocument" + } + }, "audits": { "calendar": { "status": "audited-tbd", diff --git a/site/scripts/check-document-type-audits.mjs b/site/scripts/check-document-type-audits.mjs index 22abcfd1..9a434fee 100644 --- a/site/scripts/check-document-type-audits.mjs +++ b/site/scripts/check-document-type-audits.mjs @@ -11,6 +11,17 @@ const expectedCandidates = siteRoutes if (JSON.stringify(ledger.candidates) !== JSON.stringify(expectedCandidates)) throw new Error("Document Type audit candidates do not match the TBD navigation denominator"); +for (const candidate of ledger.candidates) { + const profile = ledger.candidateProfiles[candidate]; + if (profile === undefined) throw new Error(`${candidate} is missing its candidate profile`); + for (const field of ["why", "does", "schema", "sourcePath", "symbol"]) { + if (typeof profile[field] !== "string" || profile[field].trim() === "") throw new Error(`${candidate} candidate profile is missing ${field}`); + } + const sourceFile = join(root, profile.sourcePath); + if (!existsSync(sourceFile)) throw new Error(`${candidate} candidate profile source does not exist: ${profile.sourcePath}`); + if (!new RegExp(`\\b${profile.symbol}\\b`).test(readFileSync(sourceFile, "utf8"))) throw new Error(`${candidate} candidate profile symbol does not exist: ${profile.symbol}`); +} + for (const [candidate, audit] of Object.entries(ledger.audits)) { if (!ledger.candidates.includes(candidate)) throw new Error(`unknown Document Type audit: ${candidate}`); if (audit.denominator !== audit.occurrences.length) throw new Error(`${candidate} audit denominator mismatch: expected ${audit.denominator}, found ${audit.occurrences.length}`); @@ -38,4 +49,4 @@ if (calendar.status !== "audited-tbd" || !calendar.occurrences.some((occurrence) throw new Error("Calendar must remain audited-tbd while nonconforming occurrences remain"); } -console.log(`Document Type audits ok; candidates=${ledger.candidates.length}; audited=${Object.keys(ledger.audits).length}; Calendar occurrences=${calendar.occurrences.length}.`); +console.log(`Document Type audits ok; candidates=${ledger.candidates.length}; candidate profiles=${Object.keys(ledger.candidateProfiles).length}; audited=${Object.keys(ledger.audits).length}; Calendar occurrences=${calendar.occurrences.length}.`); diff --git a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx index 85eaf9db..9d925e73 100644 --- a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx +++ b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx @@ -18,6 +18,7 @@ export type DocumentTypeCandidate = keyof typeof candidateNames; export function DocumentTypeCandidateRoute(props: { readonly candidate: DocumentTypeCandidate }) { const name = candidateNames[props.candidate]; + const profile = documentTypeAudits.candidateProfiles[props.candidate]; const audit = props.candidate === "calendar" ? documentTypeAudits.audits.calendar : undefined; const auditSource = audit === undefined ? "" : ` ## 소스 기반 감사 현황 @@ -38,6 +39,25 @@ fixture, copy, layout-only CSS, tests와 generated route는 제외합니다. ${name}은 현재 Document Type 후보입니다. 기존 package와 Hands에서 실제 책임을 옮기거나 완료를 선언하지 않았습니다. + +## 왜 필요한가 + +${profile.why} + +## 무엇을 하는가 + +${profile.does} + +## 현재 관찰된 schema · TBD + +이 schema는 현재 source에 존재하는 document interface를 축약한 감사 입력입니다. +정본 owner와 schema 계약이 확정됐다는 뜻은 아닙니다. + +\`\`\`ts +${profile.schema} +\`\`\` + +근거: \`${profile.sourcePath}\`의 \`${profile.symbol}\` ${auditSource} ## 확정에 필요한 증거 diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts index fe35f7ce..1deef6ee 100644 --- a/site/tests/browser/site-shell.spec.ts +++ b/site/tests/browser/site-shell.spec.ts @@ -212,6 +212,10 @@ test("Document Type candidate submenu keeps ownership explicitly TBD", async ({ await expect(page.getByRole("heading", { level: 1, name: "Calendar Document Type · TBD" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "확정에 필요한 증거" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "소스 기반 감사 현황" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 2, name: "왜 필요한가" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 2, name: "무엇을 하는가" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 2, name: "현재 관찰된 schema · TBD" })).toBeVisible(); + await expect(page.getByText("packages/json-document-editing/src/calendar.ts", { exact: true })).toBeVisible(); await expect(page.getByText("10개 책임 occurrence", { exact: false })).toBeVisible(); await expect(page.getByRole("cell", { name: "mislocated module", exact: true }).first()).toBeVisible(); await expect(page.getByRole("navigation", { name: "Site navigation" }).getByRole("link", { name: "Calendar · TBD" })).toHaveAttribute("aria-current", "page"); From ac3edf0c6032dc13cabf2026b4ebb20cc7293177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 31 Aug 2026 15:04:03 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20Document=20Type=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EB=AC=B8=EC=84=9C=20=EC=A0=95=EB=A0=AC=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/routes/docs/DocumentTypeCandidateRoute.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx index 9d925e73..19ef662d 100644 --- a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx +++ b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx @@ -72,10 +72,10 @@ ${auditSource} return ( - - 후보의 이름만 등록했으며 소유권과 구현 경계는 아직 확정하지 않았습니다. -
+ + 후보의 이름만 등록했으며 소유권과 구현 경계는 아직 확정하지 않았습니다. +
From 382990115b799dff6b1d7e76293b1ccff53d23e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 31 Aug 2026 15:37:28 +0900 Subject: [PATCH 04/10] =?UTF-8?q?docs:=20Document=20Type=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20interface=EC=99=80=20=ED=95=84=EB=93=9C=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- audits/document-types.json | 84 +++++++++++++++++-- site/scripts/check-document-type-audits.mjs | 6 ++ .../_page/docs/document-types/$candidate.tsx | 17 ++-- .../docs/DocumentTypeCandidateRoute.tsx | 6 ++ site/tests/browser/site-shell.spec.ts | 2 + 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/audits/document-types.json b/audits/document-types.json index b7d60b8f..9e08c16c 100644 --- a/audits/document-types.json +++ b/audits/document-types.json @@ -6,63 +6,129 @@ "rich-text": { "why": "paragraph, list, mark와 extension node의 의미와 유효성을 일반 JSON 편집 규칙만으로 보존할 수 없기 때문입니다.", "does": "Rich Text node vocabulary, block/inline 관계, mark와 extension 계약을 정의하고 구조화된 문서를 text·HTML 같은 표현으로 투영합니다.", - "schema": "{ profile: string; type: \"doc\"; id: string; content: RichTextBlockNode[] }", + "schema": "interface RichTextDocument {\n readonly id: string;\n readonly profile: string;\n readonly type: \"doc\";\n readonly content: ReadonlyArray;\n}\n\ntype RichTextBlockNode =\n | RichTextParagraph\n | RichTextHeading\n | RichTextBlockquote\n | RichTextCodeBlock\n | RichTextBulletList\n | RichTextOrderedList;\n\ninterface RichTextText { readonly id: string; readonly type: \"text\"; readonly text: string; readonly marks: ReadonlyArray }\ninterface RichTextHardBreak { readonly id: string; readonly type: \"hardBreak\" }\ninterface RichTextParagraph { readonly id: string; readonly type: \"paragraph\"; readonly content: ReadonlyArray }\ninterface RichTextHeading { readonly id: string; readonly type: \"heading\"; readonly attrs: { readonly level: 1 | 2 | 3 | 4 | 5 | 6 }; readonly content: ReadonlyArray }\ninterface RichTextBlockquote { readonly id: string; readonly type: \"blockquote\"; readonly content: ReadonlyArray }\ninterface RichTextCodeBlock { readonly id: string; readonly type: \"codeBlock\"; readonly attrs: { readonly language: string | null }; readonly content: readonly [] | readonly [RichTextText] }\ninterface RichTextListItem { readonly id: string; readonly type: \"listItem\"; readonly content: ReadonlyArray }\ninterface RichTextBulletList { readonly id: string; readonly type: \"bulletList\"; readonly content: ReadonlyArray }\ninterface RichTextOrderedList { readonly id: string; readonly type: \"orderedList\"; readonly attrs: { readonly start: number }; readonly content: ReadonlyArray }\ntype RichTextExtensionNode = { readonly id: string; readonly type: `${string}/${string}`; readonly attrs?: Readonly>; readonly content?: ReadonlyArray };", + "fields": [ + { "name": "id", "description": "문서와 각 node를 편집·selection·extension에서 안정적으로 참조하는 identity입니다." }, + { "name": "profile", "description": "문서가 따르는 Rich Text vocabulary와 호환 계약을 식별합니다." }, + { "name": "type", "description": "document와 node variant를 판별합니다. extension은 namespace/name 형태를 사용합니다." }, + { "name": "content", "description": "block·inline·list의 허용된 계층을 보존하는 ordered child sequence입니다." }, + { "name": "attrs", "description": "heading level, code language, ordered-list start와 extension별 구조화 속성입니다." }, + { "name": "text / marks", "description": "text node의 문자열과 해당 문자열 전체에 적용되는 strong, link 같은 의미 표식입니다." } + ], "sourcePath": "packages/json-document-rich-text/src/model.ts", "symbol": "RichTextDocument" }, "order": { "why": "순서가 의미인 item 집합은 배열 위치뿐 아니라 안정된 item identity와 rename 규칙을 보존해야 하기 때문입니다.", "does": "식별된 item의 선형 순서와 label을 정의하고 reorder·rename 같은 의미 연산의 기반을 제공합니다.", - "schema": "{ items: Array<{ id: string; label: string }> }", + "schema": "interface OrderItem {\n readonly id: string;\n readonly label: string;\n}\n\ninterface OrderDocument {\n readonly items: ReadonlyArray;\n}", + "fields": [ + { "name": "items", "description": "문서가 의미하는 선형 순서입니다. 배열 순서가 곧 item 순서입니다." }, + { "name": "items[].id", "description": "rename, selection과 paste target이 배열 위치와 무관하게 item을 찾는 identity입니다." }, + { "name": "items[].label", "description": "item이 사용자에게 나타내는 편집 가능한 이름입니다." } + ], "sourcePath": "packages/json-document-editing/src/order.ts", "symbol": "OrderDocument" }, "object": { "why": "캔버스 object는 identity, geometry와 presentation 값을 하나의 의미 단위로 유지해야 하기 때문입니다.", "does": "object의 위치·크기·색상 모델을 정의하고 translate·resize·fill 같은 공간 연산의 기반을 제공합니다.", - "schema": "{ objects: Array<{ id: string; label: string; x: number; y: number; width: number; height: number; color: string }> }", + "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}", + "fields": [ + { "name": "objects", "description": "같은 공간에서 배치·선택되는 object의 ordered collection입니다." }, + { "name": "objects[].id", "description": "selection, transform과 clipboard가 object를 안정적으로 참조하는 identity입니다." }, + { "name": "objects[].label", "description": "object의 사용자 가시 이름입니다." }, + { "name": "x / y", "description": "문서 좌표계에서 object 좌상단의 위치입니다." }, + { "name": "width / height", "description": "resize 이후에도 유효해야 하는 object의 공간 크기입니다." }, + { "name": "color", "description": "object가 소유하는 현재 presentation 값입니다." } + ], "sourcePath": "packages/json-document-editing/src/object.ts", "symbol": "ObjectDocument" }, "tree": { "why": "계층 문서는 node identity와 parent reference의 무결성, 순서와 가시성 투영을 함께 보존해야 하기 때문입니다.", "does": "parent-linked node 모델을 정의하고 visible tree와 구조 이동을 위한 의미 기반을 제공합니다.", - "schema": "{ nodes: Array<{ id: string; parentId: string | null; label: string }> }", + "schema": "interface TreeNode {\n readonly id: string;\n readonly parentId: string | null;\n readonly label: string;\n}\n\ninterface TreeDocument {\n readonly nodes: ReadonlyArray;\n}", + "fields": [ + { "name": "nodes", "description": "tree node의 저장 순서입니다. 같은 parent 아래 sibling order의 입력이 됩니다." }, + { "name": "nodes[].id", "description": "parent reference, selection과 구조 연산이 사용하는 고유 identity입니다." }, + { "name": "nodes[].parentId", "description": "부모 node를 참조하며 null이면 root입니다. 존재하는 id만 참조해야 합니다." }, + { "name": "nodes[].label", "description": "node가 나타내는 사용자 가시 이름입니다." } + ], "sourcePath": "packages/json-document-editing/src/tree.ts", "symbol": "TreeDocument" }, "database": { "why": "record 값은 property schema와 view가 참조하는 identity·type 규칙을 함께 만족해야 하기 때문입니다.", "does": "property schema, record 값과 저장된 table view를 정의하고 typed value·sort·filter projection의 기반을 제공합니다.", - "schema": "{ schema: { properties: DatabaseProperty[] }; records: DatabaseRecord[]; views: DatabaseTableView[] }", + "schema": "type DatabasePropertyType = \"title\" | \"text\" | \"number\" | \"select\" | \"checkbox\";\ninterface DatabaseSelectOption { readonly id: string; readonly name: string }\ninterface DatabaseProperty { readonly id: string; readonly name: string; readonly type: DatabasePropertyType; readonly options: ReadonlyArray }\ninterface DatabaseRecord { readonly id: string; readonly values: Readonly> }\ninterface DatabaseSort { readonly propertyId: string; readonly direction: \"ascending\" | \"descending\" }\ninterface DatabaseFilter { readonly propertyId: string; readonly operator: \"equals\"; readonly value: JSONValue }\ninterface DatabaseTableView {\n readonly id: string; readonly name: string; readonly type: \"table\";\n readonly propertyOrder: ReadonlyArray;\n readonly propertyVisibility: Readonly>;\n readonly propertyWidths: Readonly>;\n readonly sort: DatabaseSort | null; readonly filter: DatabaseFilter | null;\n}\ninterface DatabaseDocument {\n readonly schema: { readonly properties: ReadonlyArray };\n readonly records: ReadonlyArray;\n readonly views: ReadonlyArray;\n}", + "fields": [ + { "name": "schema.properties", "description": "record value의 key, type과 표시 이름을 정의하는 canonical property 목록입니다." }, + { "name": "properties[].options", "description": "select property가 허용하는 안정된 option identity와 이름입니다. 다른 type에서는 빈 목록입니다." }, + { "name": "records[].id", "description": "view, selection과 CRUD가 record를 참조하는 고유 identity입니다." }, + { "name": "records[].values", "description": "property id를 key로 갖는 값 map이며 각 값은 property type을 만족해야 합니다." }, + { "name": "views", "description": "같은 records를 표시하는 저장된 table projection 설정입니다." }, + { "name": "propertyOrder / propertyVisibility / propertyWidths", "description": "view별 열 순서·가시성·너비이며 존재하는 property id만 참조해야 합니다." }, + { "name": "sort / filter", "description": "view 결과를 파생하는 property 참조와 정렬·동등 비교 조건입니다." } + ], "sourcePath": "packages/json-document-editing/src/database.ts", "symbol": "DatabaseDocument" }, "calendar": { "why": "event interval, calendar reference와 recurrence가 시간 의미와 참조 무결성을 함께 보존해야 하기 때문입니다.", "does": "calendar와 event·recurrence 모델을 정의하고 occurrence, busy date와 기간별 event projection의 기반을 제공합니다.", - "schema": "{ calendars: CalendarCalendar[]; events: Array<{ id: string; title: string; start: string; end: string; allDay: boolean; calendarId: string; recurrence: CalendarRecurrence | null; excludeDates: string[] }> }", + "schema": "interface CalendarCalendar { readonly id: string; readonly title: string; readonly hidden: boolean; readonly color: string }\ninterface CalendarRecurrence { readonly freq: \"daily\" | \"weekly\" | \"monthly\" | \"yearly\"; readonly interval: number; readonly until: string }\ninterface CalendarEvent {\n readonly id: string; readonly title: string; readonly start: string; readonly end: string;\n readonly allDay: boolean; readonly calendarId: string;\n readonly recurrence: CalendarRecurrence | null; readonly excludeDates: ReadonlyArray;\n}\ninterface CalendarDocument {\n readonly calendars: ReadonlyArray;\n readonly events: ReadonlyArray;\n}", + "fields": [ + { "name": "calendars", "description": "event를 분류하는 calendar의 고유 목록입니다." }, + { "name": "calendars[].hidden / color", "description": "calendar별 가시성 상태와 event 표현에 쓰는 비어 있지 않은 색상 값입니다." }, + { "name": "events[].id / calendarId", "description": "event identity와 소속 calendar reference입니다. calendarId는 존재하는 calendar를 가리켜야 합니다." }, + { "name": "start / end / allDay", "description": "interval을 정의합니다. allDay이면 date, 아니면 datetime-local 형식을 사용합니다." }, + { "name": "recurrence.freq / interval / until", "description": "반복 주기, 양의 간격과 반복 종료 경계를 정의합니다." }, + { "name": "excludeDates", "description": "반복 projection에서 제외할 occurrence date 목록입니다." } + ], "sourcePath": "packages/json-document-editing/src/calendar.ts", "symbol": "CalendarDocument" }, "sheet": { "why": "grid cell 값은 안정된 row·column identity와 좌표 관계를 유지해야 배열 index 변화에도 의미가 보존되기 때문입니다.", "does": "column, row와 column-keyed cell 모델을 정의하고 grid topology와 range projection의 기반을 제공합니다.", - "schema": "{ columns: Array<{ id: string; label: string }>; rows: Array<{ id: string; cells: Record }> }", + "schema": "interface SheetColumn { readonly id: string; readonly label: string }\ninterface SheetRow { readonly id: string; readonly cells: Readonly> }\ninterface SheetDocument {\n readonly columns: ReadonlyArray;\n readonly rows: ReadonlyArray;\n}", + "fields": [ + { "name": "columns", "description": "grid의 열 순서와 column identity를 정의합니다." }, + { "name": "columns[].id / label", "description": "cell key로 쓰는 안정된 identity와 사용자 가시 열 이름입니다." }, + { "name": "rows", "description": "grid의 행 순서를 정의하는 row 목록입니다." }, + { "name": "rows[].id", "description": "selection과 range가 배열 위치와 무관하게 row를 참조하는 identity입니다." }, + { "name": "rows[].cells", "description": "column id를 key로 JSON cell value를 저장합니다. 알려진 column만 key로 사용해야 합니다." } + ], "sourcePath": "packages/json-document-editing/src/sheet.ts", "symbol": "SheetDocument" }, "kanban": { "why": "card identity와 column의 card reference·순서가 보드 상태의 핵심 불변식이기 때문입니다.", "does": "column과 card 모델, column별 card 순서를 정의하고 card 이동 연산의 기반을 제공합니다.", - "schema": "{ columns: Array<{ id: string; title: string; cardIds: string[] }>; cards: Array<{ id: string; title: string }> }", + "schema": "interface KanbanCard { readonly id: string; readonly title: string }\ninterface KanbanColumn { readonly id: string; readonly title: string; readonly cardIds: ReadonlyArray }\ninterface KanbanDocument {\n readonly columns: ReadonlyArray;\n readonly cards: ReadonlyArray;\n}", + "fields": [ + { "name": "columns", "description": "보드의 열 순서를 정의하는 고유 column 목록입니다." }, + { "name": "columns[].id / title", "description": "drop target이 참조하는 identity와 사용자 가시 열 이름입니다." }, + { "name": "columns[].cardIds", "description": "column 안의 card 순서입니다. 존재하는 card id만 중복 없이 참조해야 합니다." }, + { "name": "cards", "description": "보드가 소유하는 card의 canonical 목록입니다." }, + { "name": "cards[].id / title", "description": "이동·selection의 안정된 identity와 사용자 가시 card 제목입니다." } + ], "sourcePath": "packages/json-document-editing/src/kanban.ts", "symbol": "KanbanDocument" }, "annotation": { "why": "annotation은 source identity, selector geometry, instruction body와 presentation 의미를 일관되게 연결해야 하기 때문입니다.", "does": "annotation source·selector·body·presentation 모델과 참조 무결성을 정의하고 target별 annotation projection의 기반을 제공합니다.", - "schema": "{ profile: \"urn:interactive-os:json-document:annotation:1\"; id: string; sources: AnnotationSource[]; annotations: Annotation[] }", + "schema": "type AnnotationSelector =\n | { readonly type: \"point\"; readonly x: number; readonly y: number }\n | { readonly type: \"rectangle\"; readonly x: number; readonly y: number; readonly width: number; readonly height: number }\n | { readonly type: \"path\"; readonly points: ReadonlyArray<{ x: number; y: number }> }\n | { readonly type: \"arrow\"; readonly from: { x: number; y: number }; readonly to: { x: number; y: number } };\ntype AnnotationPresentation =\n | { readonly type: \"marker\" } | { readonly type: \"reaction\"; readonly reaction: \"like\" | \"dislike\" }\n | { readonly type: \"outline\" } | { readonly type: \"stroke\" } | { readonly type: \"arrow\" };\ninterface AnnotationSource { readonly id: string; readonly src: string; readonly width: number; readonly height: number }\ninterface Annotation { readonly id: string; readonly body: { readonly instruction: string }; readonly target: { readonly sourceId: string; readonly selector: AnnotationSelector }; readonly presentation: AnnotationPresentation }\ninterface AnnotationDocument {\n readonly profile: \"urn:interactive-os:json-document:annotation:1\"; readonly id: string;\n readonly sources: ReadonlyArray; readonly annotations: ReadonlyArray;\n}", + "fields": [ + { "name": "profile / id", "description": "Annotation v1 계약과 문서 identity를 식별합니다." }, + { "name": "sources", "description": "annotation target이 참조할 이미지·문서 source와 양의 원본 크기를 정의합니다." }, + { "name": "annotations[].body.instruction", "description": "선택된 위치에 연결된 사용자 또는 agent 지시 내용입니다." }, + { "name": "target.sourceId", "description": "존재하는 source를 가리키는 참조입니다." }, + { "name": "target.selector", "description": "point, rectangle, path 또는 arrow로 target geometry를 표현합니다." }, + { "name": "presentation", "description": "selector와 호환되는 marker, reaction, outline, stroke 또는 arrow 표현 의미입니다." } + ], "sourcePath": "packages/json-document-editing/src/annotation.ts", "symbol": "AnnotationDocument" } diff --git a/site/scripts/check-document-type-audits.mjs b/site/scripts/check-document-type-audits.mjs index 9a434fee..5cd7b6d0 100644 --- a/site/scripts/check-document-type-audits.mjs +++ b/site/scripts/check-document-type-audits.mjs @@ -17,6 +17,12 @@ for (const candidate of ledger.candidates) { for (const field of ["why", "does", "schema", "sourcePath", "symbol"]) { if (typeof profile[field] !== "string" || profile[field].trim() === "") throw new Error(`${candidate} candidate profile is missing ${field}`); } + if (!Array.isArray(profile.fields) || profile.fields.length === 0) throw new Error(`${candidate} candidate profile is missing field descriptions`); + for (const field of profile.fields) { + if (typeof field.name !== "string" || field.name.trim() === "" || typeof field.description !== "string" || field.description.trim() === "") { + throw new Error(`${candidate} candidate profile has an incomplete field description`); + } + } const sourceFile = join(root, profile.sourcePath); if (!existsSync(sourceFile)) throw new Error(`${candidate} candidate profile source does not exist: ${profile.sourcePath}`); if (!new RegExp(`\\b${profile.symbol}\\b`).test(readFileSync(sourceFile, "utf8"))) throw new Error(`${candidate} candidate profile symbol does not exist: ${profile.symbol}`); diff --git a/site/src/app/routes/_page/docs/document-types/$candidate.tsx b/site/src/app/routes/_page/docs/document-types/$candidate.tsx index 5403acdb..719fbbd1 100644 --- a/site/src/app/routes/_page/docs/document-types/$candidate.tsx +++ b/site/src/app/routes/_page/docs/document-types/$candidate.tsx @@ -1,8 +1,11 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; -import { - DocumentTypeCandidateRoute, - type DocumentTypeCandidate, -} from "../../../../../routes/docs/DocumentTypeCandidateRoute"; +import { lazy, Suspense } from "react"; +import type { DocumentTypeCandidate } from "../../../../../routes/docs/DocumentTypeCandidateRoute"; + +const DocumentTypeCandidatePage = lazy(async () => { + const module = await import("../../../../../routes/docs/DocumentTypeCandidateRoute"); + return { default: module.DocumentTypeCandidateRoute }; +}); const candidates = new Set([ "rich-text", @@ -25,5 +28,9 @@ export const Route = createFileRoute("/_page/docs/document-types/$candidate")({ function CandidateRoute() { const { candidate } = Route.useParams(); - return ; + return ( + + + + ); } diff --git a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx index 19ef662d..77e7a5aa 100644 --- a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx +++ b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx @@ -58,6 +58,12 @@ ${profile.schema} \`\`\` 근거: \`${profile.sourcePath}\`의 \`${profile.symbol}\` + +### 필드 설명 + +| 항목 | 의미 | +| --- | --- | +${profile.fields.map((field) => `| \`${field.name}\` | ${field.description} |`).join("\n")} ${auditSource} ## 확정에 필요한 증거 diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts index 1deef6ee..f6796b5e 100644 --- a/site/tests/browser/site-shell.spec.ts +++ b/site/tests/browser/site-shell.spec.ts @@ -215,6 +215,8 @@ test("Document Type candidate submenu keeps ownership explicitly TBD", async ({ await expect(page.getByRole("heading", { level: 2, name: "왜 필요한가" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "무엇을 하는가" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "현재 관찰된 schema · TBD" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 3, name: "필드 설명" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "start / end / allDay", exact: true })).toBeVisible(); await expect(page.getByText("packages/json-document-editing/src/calendar.ts", { exact: true })).toBeVisible(); await expect(page.getByText("10개 책임 occurrence", { exact: false })).toBeVisible(); await expect(page.getByRole("cell", { name: "mislocated module", exact: true }).first()).toBeVisible(); From 74f00990bd0ef682d2466ef561a5ea4ef1e3e9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 31 Aug 2026 15:49:10 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/package.json | 2 +- site/scripts/check-documentation-page.mjs | 27 ++++++++ site/src/routes/docs/ConceptsRoute.tsx | 54 +++------------- site/src/routes/docs/DocsRoute.tsx | 57 +++-------------- .../docs/DocumentTypeCandidateRoute.tsx | 17 +++-- site/src/routes/docs/DocumentationPage.tsx | 62 +++++++++++++++++++ site/tests/browser/site-shell.spec.ts | 1 + 7 files changed, 113 insertions(+), 107 deletions(-) create mode 100644 site/scripts/check-documentation-page.mjs create mode 100644 site/src/routes/docs/DocumentationPage.tsx diff --git a/site/package.json b/site/package.json index ef90278a..8274078e 100644 --- a/site/package.json +++ b/site/package.json @@ -14,7 +14,7 @@ "check:primitives": "node scripts/check-live-demo-primitives.mjs", "check:ui-roles": "node scripts/check-ui-role-primitives.mjs", "check:choice-id": "node scripts/check-choice-id.mjs", - "check:canonical-modules": "node scripts/check-canonical-module-closure.mjs && node scripts/check-document-type-audits.mjs", + "check:canonical-modules": "node scripts/check-canonical-module-closure.mjs && node scripts/check-document-type-audits.mjs && node scripts/check-documentation-page.mjs", "check:interaction-handles": "node scripts/check-interaction-handles.mjs", "check:contextual-affordance": "node scripts/check-contextual-affordance.mjs", "check:product-shell-toolbar": "node scripts/check-product-shell-toolbar.mjs", diff --git a/site/scripts/check-documentation-page.mjs b/site/scripts/check-documentation-page.mjs new file mode 100644 index 00000000..2d3c047f --- /dev/null +++ b/site/scripts/check-documentation-page.mjs @@ -0,0 +1,27 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("../..", import.meta.url).pathname; +const docsRoot = join(root, "site/src/routes/docs"); +const canonicalOwner = "DocumentationPage.tsx"; +const consumers = ["DocsRoute.tsx", "ConceptsRoute.tsx", "DocumentTypeCandidateRoute.tsx"]; + +for (const name of readdirSync(docsRoot).filter((entry) => entry.endsWith(".tsx"))) { + const source = readFileSync(join(docsRoot, name), "utf8"); + if (name !== canonicalOwner && name !== "MarkdownViewer.tsx" && /(?:MarkdownViewer|markdownHeadings)/.test(source)) { + throw new Error(`${name} bypasses the canonical DocumentationPage composition`); + } +} + +const owner = readFileSync(join(docsRoot, canonicalOwner), "utf8"); +for (const contract of ["PageFrame", "PageHeader", "MarkdownViewer", "markdownHeadings", "Documentation sections", "On this page", "max-w-3xl"]) { + if (!owner.includes(contract)) throw new Error(`DocumentationPage is missing canonical contract: ${contract}`); +} +for (const consumer of consumers) { + const source = readFileSync(join(docsRoot, consumer), "utf8"); + if (!source.includes('from "./DocumentationPage"') || !source.includes(" = { selection: "cursor", @@ -47,48 +45,12 @@ function EditingConceptRoute(props: { const source = props.source ?? page?.source ?? ""; const illustration = props.illustration ?? (props.pageId ? editingConceptIllustrations[props.pageId] : "cursor"); - const headings = markdownHeadings(source).filter((heading) => heading.level === 2); - return ( - -
-
- - {props.summary} - - - - -
- -
-
- - -
-
+ ); } diff --git a/site/src/routes/docs/DocsRoute.tsx b/site/src/routes/docs/DocsRoute.tsx index 505b9e8d..4b6d618e 100644 --- a/site/src/routes/docs/DocsRoute.tsx +++ b/site/src/routes/docs/DocsRoute.tsx @@ -1,9 +1,6 @@ -import { useMemo } from "react"; -import { ActionLink } from "../../shared/ui/interactive"; -import { PageFrame, PageHeader, type PetiteCatIllustration } from "../../shared/ui/primitives"; -import { classes, ui } from "../../shared/ui/styles"; -import { MarkdownViewer, markdownHeadings } from "./MarkdownViewer"; +import type { PetiteCatIllustration } from "../../shared/ui/primitives"; import { docPages, type DocPageId } from "./doc-pages"; +import { DocumentationPage } from "./DocumentationPage"; const docIllustrations: Record = { overview: "package", @@ -194,51 +191,11 @@ export function ReactEditingDocsRoute() { export function DocsRoute({ pageId }: { readonly pageId: DocPageId }) { const page = docPages[pageId]; - const headings = useMemo( - () => markdownHeadings(page.source).filter((heading) => heading.level === 2), - [page.source], - ); - return ( - -
-
-
- - - -
-
- - -
-
+ ); } diff --git a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx index 77e7a5aa..c86e6237 100644 --- a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx +++ b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx @@ -1,6 +1,5 @@ -import { PageFrame, PageHeader } from "../../shared/ui/primitives"; -import { MarkdownViewer } from "./MarkdownViewer"; import documentTypeAudits from "../../../../audits/document-types.json"; +import { DocumentationPage } from "./DocumentationPage"; const candidateNames = { "rich-text": "Rich Text", @@ -77,13 +76,11 @@ ${auditSource} `; return ( - -
- - 후보의 이름만 등록했으며 소유권과 구현 경계는 아직 확정하지 않았습니다. - - -
-
+ ); } diff --git a/site/src/routes/docs/DocumentationPage.tsx b/site/src/routes/docs/DocumentationPage.tsx new file mode 100644 index 00000000..d953f08f --- /dev/null +++ b/site/src/routes/docs/DocumentationPage.tsx @@ -0,0 +1,62 @@ +import { useMemo, type ReactNode } from "react"; +import { ActionLink } from "../../shared/ui/interactive"; +import { PageFrame, PageHeader, type PetiteCatIllustration } from "../../shared/ui/primitives"; +import { classes, ui } from "../../shared/ui/styles"; +import { MarkdownViewer, markdownHeadings } from "./MarkdownViewer"; + +export function DocumentationPage(props: { + readonly title: ReactNode; + readonly source: string; + readonly illustration: PetiteCatIllustration; + readonly summary?: ReactNode; +}) { + const headings = useMemo( + () => markdownHeadings(props.source).filter((heading) => heading.level === 2), + [props.source], + ); + + return ( + +
+
+
+ + {props.summary} + + + +
+
+ + +
+
+ ); +} diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts index f6796b5e..b7f0b0d1 100644 --- a/site/tests/browser/site-shell.spec.ts +++ b/site/tests/browser/site-shell.spec.ts @@ -217,6 +217,7 @@ test("Document Type candidate submenu keeps ownership explicitly TBD", async ({ await expect(page.getByRole("heading", { level: 2, name: "현재 관찰된 schema · TBD" })).toBeVisible(); await expect(page.getByRole("heading", { level: 3, name: "필드 설명" })).toBeVisible(); await expect(page.getByRole("cell", { name: "start / end / allDay", exact: true })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "On this page" }).getByRole("link", { name: "현재 관찰된 schema · TBD" })).toBeVisible(); await expect(page.getByText("packages/json-document-editing/src/calendar.ts", { exact: true })).toBeVisible(); await expect(page.getByText("10개 책임 occurrence", { exact: false })).toBeVisible(); await expect(page.getByRole("cell", { name: "mislocated module", exact: true }).first()).toBeVisible(); From 6e43b8e412d3dab790ebaaf91e5b1b68338b1c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 31 Aug 2026 15:58:28 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EA=B8=B4=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EA=B0=80=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=9D=84=20=EB=B0=80=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/routes/docs/MarkdownViewer.tsx | 2 +- site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx | 6 +++++- site/tests/browser/site-shell.spec.ts | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/site/src/routes/docs/MarkdownViewer.tsx b/site/src/routes/docs/MarkdownViewer.tsx index 7983bc2c..393c6766 100644 --- a/site/src/routes/docs/MarkdownViewer.tsx +++ b/site/src/routes/docs/MarkdownViewer.tsx @@ -11,7 +11,7 @@ type MarkdownHeading = { id: string; level: number; text: string }; export function MarkdownViewer({ source, hideTitle = false }: { source: string; hideTitle?: boolean }) { return ( -
+
+
page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); await expect(page.getByText("packages/json-document-editing/src/calendar.ts", { exact: true })).toBeVisible(); await expect(page.getByText("10개 책임 occurrence", { exact: false })).toBeVisible(); await expect(page.getByRole("cell", { name: "mislocated module", exact: true }).first()).toBeVisible(); From 699299979756fd957c483d049d8b7e1cfb101290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 31 Aug 2026 18:25:00 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20Application=20=EC=A4=91=EC=8B=AC?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20IA=EB=A5=BC=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/public/applications.md | 38 +++++ docs/public/concepts.md | 38 ++++- docs/public/how-we-build.md | 43 +++++ site/scripts/route-checks.mjs | 1 + site/site-routes.json | 55 ++++-- site/src/app/breadcrumb.tsx | 39 ++++- site/src/app/navigation-layer-icon.tsx | 32 ++-- site/src/app/page-descriptors.ts | 3 +- site/src/app/routeTree.gen.ts | 85 +++++++++ site/src/app/routes/__root.tsx | 105 +++++++----- .../routes/_page/applications/ai-agent.tsx | 6 + .../routes/_page/applications/calendar.tsx | 20 +++ .../app/routes/_page/applications/index.tsx | 6 + .../app/routes/_page/artifact/llm-agent.tsx | 7 +- site/src/app/routes/_page/demo/calendar.tsx | 22 +-- .../app/routes/_page/docs/how-we-build.tsx | 6 + site/src/app/site-layers.ts | 68 ++++++-- site/src/routes/docs/DocsRoute.tsx | 10 ++ site/src/routes/docs/doc-pages.ts | 4 + site/src/routes/home/HomeRoute.tsx | 21 ++- site/tests/unit/app-chrome.test.ts | 3 +- site/tests/unit/app-shell.test.tsx | 161 +++--------------- site/tests/unit/breadcrumb.test.ts | 113 ++---------- site/tests/unit/docs-route.test.tsx | 31 ++-- 24 files changed, 535 insertions(+), 382 deletions(-) create mode 100644 docs/public/applications.md create mode 100644 docs/public/how-we-build.md create mode 100644 site/src/app/routes/_page/applications/ai-agent.tsx create mode 100644 site/src/app/routes/_page/applications/calendar.tsx create mode 100644 site/src/app/routes/_page/applications/index.tsx create mode 100644 site/src/app/routes/_page/docs/how-we-build.tsx diff --git a/docs/public/applications.md b/docs/public/applications.md new file mode 100644 index 00000000..058bb5b1 --- /dev/null +++ b/docs/public/applications.md @@ -0,0 +1,38 @@ +# Applications + +Application은 Artifact와 Hands를 navigation, runtime, 제품 정책과 함께 조합한 +완성된 제품 표면입니다. Artifact가 사람이 만들고 수정하는 작업물이라면, +Application은 그 작업물을 실제 경험으로 제공하는 실행 주체입니다. + +## Calendar + +[Calendar Application](/applications/calendar)은 Calendar Document Type, Editing, +Calendar Hand와 UI primitives를 day·week·month·year 제품 경험으로 조합합니다. + +```text +Calendar Application +├─ Calendar Document Type · event, recurrence, interval +├─ Calendar Hand · selection, create, move, resize, history +├─ Calendar UI · grids, inspector, date controls +└─ App-owned · navigation, URL state, copy, fixture, layout +``` + +Calendar라는 이름 아래의 모든 코드를 App이 소유하지 않습니다. 재사용 책임은 +각 canonical package에 남고 Application은 제품 조합과 정책만 소유합니다. + +## AI Agent + +[AI Agent Application](/applications/ai-agent)은 session runtime에 Composer, +Markdown, AG-UI와 A2UI projection을 조합합니다. + +```text +AI Agent Application +├─ Composer · Mention Hands +├─ Markdown · Rich Text projection +├─ AG-UI → A2UI integration +└─ App-owned · session navigation, runtime connection, shell, policy +``` + +두 Application은 showcase가 아니라 책임을 발견하고 canonical API가 실제 제품에서 +다시 소비되는지 검증하는 production composition root입니다. 개발 순환은 +[How We Build](/docs/how-we-build)에서 설명합니다. diff --git a/docs/public/concepts.md b/docs/public/concepts.md index 8966ac70..2b26488a 100644 --- a/docs/public/concepts.md +++ b/docs/public/concepts.md @@ -17,7 +17,9 @@ Affordance ─ input grammar ─┐ UI Primitives ─ standard UI ├─ Host가 장르별 Hands를 조합 Rich Text 등 domain ────────┘ -Hands를 제품 surface에 조합한 결과가 사람이 다루는 Artifact가 됩니다. +Hands를 surface에 조합한 결과가 사람이 다루는 Artifact가 됩니다. Application은 +그 Artifact를 navigation, runtime과 제품 정책에 놓아 실제 제품 경험으로 +제공합니다. ``` 이 그림의 선은 허용된 의존·조합 방향입니다. 모든 노드를 순서대로 설치하라는 @@ -25,10 +27,22 @@ Hands를 제품 surface에 조합한 결과가 사람이 다루는 Artifact가 외부 라이브러리가 필요할 때 고릅니다. Collaboration은 다음 계층이 아니라 같은 `JSONDocument` 계약의 다른 구현입니다. -권장 읽기 순서는 `JSON Document → Document Types → Editing → Adapter → Connector → Affordance -→ UI Primitives → Hands → Artifact`입니다. 이 순서는 학습을 위한 서사일 뿐 -package dependency를 주장하지 않습니다. Collaboration은 Core의 대체 구현과 -profile 포함 관계를 따로 보기 위해 별도 묶음에서 읽습니다. +권장 읽기 순서는 `Foundation → Building Blocks → Hands → Artifact → Application`입니다. +Foundation 안에서는 JSON Document, Document Types, Editing과 Collaboration을, +Building Blocks에서는 Adapter, Connector, Affordance와 UI Primitives를 읽습니다. +이 순서는 학습을 위한 서사일 뿐 package dependency를 주장하지 않습니다. +Collaboration은 Core의 대체 구현과 profile 포함 관계로 Foundation 안에서 읽습니다. + +프로젝트가 책임을 발견하는 방향은 이 읽기·구현 방향과 반대입니다. + +```text +구현 의존: Foundation → Building Blocks → Hands → Artifact → Application +책임 발견: Application → 책임 발견 → Canonical Module → Application +``` + +먼저 제품을 만들고 실제 사용 흐름에서 반복되는 책임을 찾습니다. 추출된 책임은 +canonical owner와 public API를 얻고, Application은 임시 구현 대신 그 API를 다시 +소비합니다. 자세한 순환은 [How We Build](how-we-build.md)에서 설명합니다. ## JSON Document @@ -117,13 +131,25 @@ MD, PPT, Sheet는 서로 다른 화면과 Hands를 사용해도 같은 문서와 놓는 정보 구조와 시각 가설만 확인하며, 실제 계약 증거는 각 Hands Live Demo와 package test에서 봅니다. +## Application + +Application은 Artifact와 Hands를 실제 제품 경험으로 제공하는 최종 composition +root입니다. 주요 화면 영역과 실행 순서, URL과 navigation, 제품 copy와 fixture, +concrete runtime 연결은 Application에 남습니다. 문서의 의미, editing lifecycle, +platform translation과 반복 UI처럼 같은 역할과 책임을 갖는 코드는 canonical +module로 추출됩니다. + +[Calendar와 AI Agent](/applications)는 제품에서 발견한 책임과 App에 남은 정책을 +함께 보여 줍니다. Calendar Document Type, Calendar Hand와 Calendar Application은 +같은 이름을 공유하지만 서로 다른 owner입니다. + ## Collaboration Collaboration은 JSON Document 계약을 여러 참여자의 인과 변경으로 구현합니다. 로컬 구현과 마찬가지로 값을 읽고, 변경을 적용하고, 결과를 구독하지만 내부 기록은 참여자의 변경 순서와 수렴을 다룹니다. -위치가 Artifact 다음인 것은 의존 방향이 아니라 문서 분류를 나타냅니다. +Collaboration은 Foundation 안에서 JSON Document와 같은 계약의 대체 구현으로 읽습니다. 협업 document를 Editing에 주입할 수 있지만 History command는 editor-local History 대신 actor-local `runtime.history`로 연결해야 합니다. base → History → Text profile의 포함 관계는 [Collaboration](collaboration.md)에 있습니다. diff --git a/docs/public/how-we-build.md b/docs/public/how-we-build.md new file mode 100644 index 00000000..8d7ab5b8 --- /dev/null +++ b/docs/public/how-we-build.md @@ -0,0 +1,43 @@ +# 제품에서 정본 모듈을 발견하는 방법 + +json-document는 추상 계층을 먼저 완성한 뒤 제품에 적용하지 않습니다. Calendar나 +AI Agent 같은 Application을 먼저 만들고, 실제 사용 흐름에서 반복되는 책임을 +발견해 canonical module로 추출합니다. 제품은 추출된 공개 API를 다시 소비하며 +경계를 검증합니다. + +```text +Application을 만든다 + ↓ +실제 제품 사건과 반복 책임을 관찰한다 + ↓ +Document Type · Editing · Adapter · UI 책임을 분리한다 + ↓ +canonical module과 public API로 정본화한다 + ↓ +Application이 정본 API를 다시 소비한다 +``` + +구현 의존 방향과 책임을 발견하는 방향은 서로 반대입니다. + +```text +구현 의존: Foundation → Building Blocks → Hands → Artifact → Application +책임 발견: Application → 책임 발견 → Canonical Module → Application +``` + +## Application에 남는 것 + +Application은 화면의 주요 영역과 실행 순서, URL과 navigation, 제품 copy, +permission, fixture, concrete runtime 연결을 소유합니다. 제품 전체를 제거했을 때 +함께 사라지는 정책입니다. + +## 모듈로 추출하는 것 + +문서의 의미와 유효성, selection과 history, 입력 번역, 반복되는 UI 동작처럼 +제품 밖에서도 같은 역할과 책임을 갖는 코드는 canonical owner로 이동합니다. +한 Application에서만 발견됐더라도 독립적인 책임이면 이름과 경계를 갖습니다. + +## 다시 제품으로 돌아오기 + +추출은 복사본을 하나 더 만드는 일이 아닙니다. Application의 임시 구현을 제거하고 +canonical public API를 소비해야 순환이 닫힙니다. [Applications](/applications)는 +각 제품에 남은 정책과 추출된 책임을 함께 보여 줍니다. diff --git a/site/scripts/route-checks.mjs b/site/scripts/route-checks.mjs index 9f1a3101..6aad2e6b 100644 --- a/site/scripts/route-checks.mjs +++ b/site/scripts/route-checks.mjs @@ -10,6 +10,7 @@ const navigationGroups = new Set([ "UI Primitives", "Hands", "Artifact", + "Applications", ]); export function validateSiteRoutes(routes, fail) { diff --git a/site/site-routes.json b/site/site-routes.json index 14f0190e..c48b47b5 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -31,6 +31,42 @@ "language": "ko", "navigationGroup": "JSON Document" }, + { + "path": "/docs/how-we-build", + "label": "How We Build", + "title": "How We Build - json-document", + "heading": "제품에서 정본 모듈을 발견하는 방법", + "description": "Application을 먼저 만들고 실제 제품 책임을 canonical module로 추출한 뒤 제품이 다시 소비하는 개발 순환을 설명합니다.", + "language": "ko", + "navigationGroup": "JSON Document" + }, + { + "path": "/applications", + "label": "Overview", + "title": "Applications - json-document", + "heading": "Applications", + "description": "Artifact와 Hands를 실제 제품 경험으로 조합하고 재사용 책임을 발견하는 Application 목록입니다.", + "language": "ko", + "navigationGroup": "Applications" + }, + { + "path": "/applications/calendar", + "label": "Calendar", + "title": "Calendar Application - json-document", + "description": "Calendar Document Type, Hand, UI와 App-owned policy를 조합한 Calendar Application입니다.", + "language": "ko", + "navigationGroup": "Applications", + "chrome": "app" + }, + { + "path": "/applications/ai-agent", + "label": "AI Agent", + "title": "AI Agent Application - json-document", + "description": "Composer, Markdown, AG-UI와 A2UI를 session runtime에 조합한 AI Agent Application입니다.", + "language": "ko", + "navigationGroup": "Applications", + "chrome": "app" + }, { "path": "/docs/document-types", "label": "Overview · TBD", @@ -649,13 +685,11 @@ }, { "path": "/demo/calendar", - "label": "Calendar", - "title": "Calendar Hands Demo - json-document", - "description": "Interval events on one document across day, week, month, and year views.", + "label": "Calendar legacy route", + "title": "Calendar legacy route - json-document", + "description": "Calendar Application의 이전 Demo 경로를 새 Application 경로로 연결합니다.", "language": "ko", - "parentPath": "/editors", - "navigationGroup": "Hands", - "chrome": "app" + "sidebar": false }, { "path": "/demo/date-controls", @@ -726,11 +760,10 @@ }, { "path": "/artifact/llm-agent", - "label": "LLM Agent", - "title": "LLM Agent - json-document", - "description": "로컬 Codex thread를 선택하고 이어서 대화하며 agent turn을 스트리밍하는 Artifact 앱입니다.", - "navigationGroup": "Artifact", - "chrome": "app" + "label": "AI Agent legacy route", + "title": "AI Agent legacy route - json-document", + "description": "AI Agent Application의 이전 Artifact 경로를 새 Application 경로로 연결합니다.", + "sidebar": false }, { "path": "/docs/mention", diff --git a/site/src/app/breadcrumb.tsx b/site/src/app/breadcrumb.tsx index db6ea63f..cb83a71a 100644 --- a/site/src/app/breadcrumb.tsx +++ b/site/src/app/breadcrumb.tsx @@ -1,6 +1,6 @@ import { type SiteNavigationGroup, type SiteRoute } from "./router"; import { ChevronRight } from "lucide-react"; -import { siteLayers } from "./site-layers"; +import { sectionForGroup, siteSections, type SiteSection } from "./site-layers"; import { ActionLink } from "../shared/ui/interactive"; import { ui } from "../shared/ui/styles"; @@ -11,9 +11,19 @@ export type BreadcrumbCrumb = { const overview: BreadcrumbCrumb = { path: "/", label: "Overview" }; -const groupLandings: Record = Object.fromEntries( - siteLayers.map((layer) => [layer.group, { path: layer.path, label: layer.label }]), -) as Record; +const groupLandings: Record = { + "JSON Document": { path: "/docs", label: "JSON Document" }, + "Document Types": { path: "/docs/document-types", label: "Document Types" }, + Editing: { path: "/docs/intent-guide", label: "Editing" }, + Collaboration: { path: "/docs/collaboration", label: "Collaboration" }, + Adapter: { path: "/docs/adapters", label: "Platform Adapters" }, + Connector: { path: "/docs/connectors", label: "Ecosystem Connectors" }, + Affordance: { path: "/docs/affordance", label: "Affordances" }, + "UI Primitives": { path: "/docs/ui-primitives", label: "UI Primitives" }, + Hands: { path: "/editors", label: "Hands" }, + Artifact: { path: "/viewer", label: "Artifact" }, + Applications: { path: "/applications", label: "Applications" }, +}; export function breadcrumbTrail( route: SiteRoute, @@ -31,17 +41,31 @@ export function breadcrumbTrail( : undefined; } + const directSection = siteSections.find((section) => section.path === route.path && section.groups.length === 0); const group = routeGroup(route, routes); - if (group) { + if (directSection) { + stack[0] = { path: directSection.path, label: directSection.label }; + } else if (group) { + const section = sectionForGroup(group); const landing = groupLandings[group]; - if (stack[0]?.path === landing.path && stack[0]?.label === "Overview") stack[0] = landing; - else if (stack[0]?.label !== group) stack.unshift(landing); + if (stack[0]?.path === section.path) stack[0] = { path: section.path, label: section.label }; + else { + if (landing.path !== section.path && stack[0]?.path !== landing.path) stack.unshift(landing); + stack.unshift({ path: section.path, label: section.label }); + } } if (stack[0]?.path !== overview.path) stack.unshift(overview); return stack; } +export function routeSection(route: SiteRoute, routes: ReadonlyArray): SiteSection | undefined { + const direct = siteSections.find((section) => section.path === route.path); + if (direct) return direct; + const group = routeGroup(route, routes); + return group ? sectionForGroup(group) : undefined; +} + function crumbLabel(route: SiteRoute): string { return route.label; } @@ -94,6 +118,7 @@ export function rootNavRoutes(routes: ReadonlyArray): ReadonlyArray> = { - "JSON Document": { icon: Braces, size: 19 }, - "Document Types": { icon: FileType2, size: 19 }, - Editing: { icon: PencilLine, size: 18 }, - Adapter: { icon: Cable, size: 19 }, - Connector: { icon: Link2, size: 21 }, - Affordance: { icon: MousePointer2, size: 20 }, - "UI Primitives": { icon: Blocks, size: 18 }, - Hands: { icon: Hand, size: 19 }, - Artifact: { icon: Files, size: 18 }, - Collaboration: { icon: UsersRound, size: 19 }, +const layerIcons: Readonly> = { + foundation: { icon: Braces, size: 19 }, + "building-blocks": { icon: Blocks, size: 18 }, + hands: { icon: Hand, size: 19 }, + artifact: { icon: Files, size: 18 }, + applications: { icon: PanelsTopLeft, size: 19 }, + reference: { icon: Library, size: 18 }, }; export function NavigationLayerIcon(props: { - readonly group: SiteNavigationGroup; + readonly section: SiteSectionId; readonly className?: string; }) { - const layerIcon = layerIcons[props.group]; + const layerIcon = layerIcons[props.section]; const Icon = layerIcon.icon; return