From 0210f0dd063b8f30ba961e918e2307b78d7dc405 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 22 Sep 2026 19:13:36 +0900 Subject: [PATCH] feat: add shared catalog queries and composable screen recipes --- AGENTS.md | 8 + README.md | 11 + assets/data/recipes.json | 114 ++++++++ assets/js/design-catalog.js | 184 +++++++++++++ assets/js/design-contracts.js | 93 +++++++ assets/js/design-recipes.js | 203 ++++++++++++++ assets/js/design-search.js | 139 ++++++++++ assets/js/design-views.js | 76 ++++++ .../260922_design_recipe_mcp/000_plan.md | 10 + .../011_phase1_progress.md | 49 ++++ .../020_codemode_mcp.md | 8 + docs/ATTRIBUTION.md | 2 +- package.json | 5 +- scripts/design-core-loader.mjs | 64 +++++ scripts/design-core.test.mjs | 248 ++++++++++++++++++ src/design-catalog.ts | 168 ++++++++++++ src/design-contracts.ts | 127 +++++++++ src/design-recipes.ts | 207 +++++++++++++++ src/design-search.ts | 126 +++++++++ src/design-views.ts | 62 +++++ structure/README.md | 9 + 21 files changed, 1910 insertions(+), 3 deletions(-) create mode 100644 assets/data/recipes.json create mode 100644 assets/js/design-catalog.js create mode 100644 assets/js/design-contracts.js create mode 100644 assets/js/design-recipes.js create mode 100644 assets/js/design-search.js create mode 100644 assets/js/design-views.js create mode 100644 devlog/_plan/260922_design_recipe_mcp/011_phase1_progress.md create mode 100644 scripts/design-core-loader.mjs create mode 100644 scripts/design-core.test.mjs create mode 100644 src/design-catalog.ts create mode 100644 src/design-contracts.ts create mode 100644 src/design-recipes.ts create mode 100644 src/design-search.ts create mode 100644 src/design-views.ts diff --git a/AGENTS.md b/AGENTS.md index 9dbeeb9..aa9c5bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -319,3 +319,11 @@ const popular = [ - 기존 098을 바꾸려면 `npm run images:finalize-quality -- --supersede --expected-previous-sha <현재098 SHA256>`를 쓴다. 이전 receipt·sheet는 해시별 보관하고 새 sheet는 별도 불변 경로에 저장한다. - 093–097과 기존 final sheet는 수정하지 않는다. 후속 receipt는 기존 승인 목록을 보존하고 새 승인 대상 셀만 바꿀 수 있다. - `npm run verify:quality-contracts`는 editorial/profile/final-history 회귀 검증이며 `npm run verify`에 포함된다. + +## 공유 카탈로그·레시피 코어 + +- `src/design-{contracts,catalog,search,views}.ts`와 `src/design-recipes.ts`는 DOM·IO 없는 classic namespace다. 기존 Finder 점수와 팔레트 계산은 변경하지 않는다. +- `assets/data/recipes.json`은 기존 ID를 참조하는 화면 조합만 소유한다. anti-pattern은 검색·조합에서 제외하고 명시적 get 조회에서만 허용한다. +- 데이터 버전은 여섯 카탈로그와 recipes/dev-guides/effects-docs/effects-snippets 열 파일의 정확한 바이트 및 계약 버전으로 계산한다. 커서는 버전·정규화 질의·필터에 묶는다. +- `scripts/design-core-loader.mjs`는 고정된 생성 파일만 읽는 Node 어댑터다. 클라이언트가 경로를 지정하는 API를 추가하지 않는다. `npm run test:design-core`는 verify에 포함한다. +- 레시피 출처와 라이선스는 `docs/ATTRIBUTION.md` 및 각 레시피 sources에 유지한다. 코드 예제·설계 가이드·실행 검증을 구분한다. diff --git a/README.md b/README.md index 05cdf0d..5f37891 100644 --- a/README.md +++ b/README.md @@ -197,3 +197,14 @@ CSS 레시피에는 필요한 JavaScript 상태 관리와 모션 감소 대응 기존 카탈로그 데이터와 이미지의 원본은 이 저장소에 있습니다. 위 프로젝트의 전체 자료나 프레임워크를 포함한다는 뜻은 아니며, 새 기능의 구현 상태는 해당 PR과 사용 문서를 따릅니다. + +## 화면 레시피와 공통 검색 코어 + +`assets/data/recipes.json`은 제품 소개, 편집형 읽기, 설정 작업 화면의 세 레시피를 담습니다. +각 레시피는 기존 카탈로그 항목을 참조하며 필수·보조·교체 가능 역할, 허용 대안, 구현 제약과 +확인할 항목을 제공합니다. 완성된 페이지 코드나 제품 검증 결과를 뜻하지 않습니다. + +`src/design-*.ts`의 순수 코어는 브라우저와 Node에서 같은 검색·상세 조회·조합 규칙을 사용합니다. +검색 결과에는 데이터 버전과 다음 커서가 붙고, anti-pattern은 명시적 조회에서만 나옵니다. +`npm run test:design-core`로 검색·경계 입력·페이지 이동·레시피 계약을 검증합니다. +MCP와 사이트 선택 화면은 후속 PR에서 연결합니다. diff --git a/assets/data/recipes.json b/assets/data/recipes.json new file mode 100644 index 0000000..a36c801 --- /dev/null +++ b/assets/data/recipes.json @@ -0,0 +1,114 @@ +{ + "version": 1, + "recipes": [ + { + "id": "product-landing", + "title": {"ko": "제품 소개와 다음 행동", "en": "Product story and next action"}, + "summary": {"ko": "하나의 제품 메시지와 실제 사용 장면을 먼저 보여 주고, 방문자가 선택할 다음 행동을 분명하게 둡니다.", "en": "Lead with one product promise and real usage evidence, then make the next action clear."}, + "slots": [ + {"id": "style", "label": {"ko": "시각 스타일", "en": "Visual style"}, "role": "substitutable", + "default": {"domain": "isms", "id": "minimalism"}, "alternatives": [{"domain": "isms", "id": "bauhaus"}]}, + {"id": "layout", "label": {"ko": "메시지와 행동 배치", "en": "Message and action layout"}, "role": "essential", + "default": {"domain": "layout", "id": "layout-hero-centered"}, "alternatives": [{"domain": "layout", "id": "layout-hero-full-media"}]}, + {"id": "color", "label": {"ko": "행동색과 표면", "en": "Action colors and surfaces"}, "role": "substitutable", + "default": {"domain": "color", "id": "saas-trust-blue"}, "alternatives": [{"domain": "color", "id": "minimalism-neutral"}]}, + {"id": "typography", "label": {"ko": "제품 제목과 본문", "en": "Product headings and body"}, "role": "essential", + "default": {"domain": "typography", "id": "outfit-pretendard-product"}, "alternatives": [{"domain": "typography", "id": "noto-serif-sans-kr-readable"}]}, + {"id": "effect", "label": {"ko": "다음 행동의 피드백", "en": "Next-action feedback"}, "role": "helper", + "default": {"domain": "effects", "id": "sticky-cta-bar"}, "alternatives": [{"domain": "effects", "id": "copy-confirmation"}]}, + {"id": "motion", "label": {"ko": "콘텐츠 전환", "en": "Content transition"}, "role": "helper", + "default": {"domain": "motion", "id": "motion-fade"}, "alternatives": [{"domain": "motion", "id": "motion-ease-in-out"}]} + ], + "constraints": [ + {"ko": "소개 문구, 행동, 제품 증거의 읽기 순서를 유지합니다. 선택한 레이아웃의 composition 필수 요소와 responsive 규칙을 구현 기준으로 삼고, 이미지가 핵심 설명을 대신하지 않게 합니다.", "en": "Keep the reading order of message, action, and product evidence. Implement the selected layout's required composition elements and responsive rules; images must not replace the essential explanation."}, + {"ko": "문서가 주 스크롤을 맡습니다. 고정 CTA를 쓰면 본문과 포커스가 가려지지 않도록 공간과 safe area를 확보하고 본문 끝에도 같은 행동을 둡니다. 전체 미디어 레이아웃을 선택하면 모바일 CTA는 고정하지 않고 문서 흐름에 둡니다.", "en": "Let the document own scrolling. Reserve space and safe-area padding for a sticky CTA and repeat the action at the end of the content. With the full-media layout, keep the mobile CTA in document flow instead of fixing it over the media."}, + {"ko": "복사 확인으로 바꾸면 고정 구매·문의 행동을 대신할 수 없습니다. 공유 링크나 코드의 실제 복사에만 연결하고, 주요 행동은 본문에 유지하며 복사 실패 시 수동 복사를 안내합니다.", "en": "Copy confirmation cannot replace a purchase or contact action. Attach it only to a real share-link or code copy, retain the primary action in the page, and offer manual copying on failure."}, + {"ko": "스타일을 바꿔도 행동색, 본문색, 표면색의 의미는 선택한 Color의 역할을 따릅니다. 서체 fallback과 한글 지원을 보존하고, 전체 미디어 배경에는 읽을 수 있는 대비면을 둡니다.", "en": "A style change must preserve the selected color system's action, text, and surface roles. Keep font fallbacks and Korean support; place readable contrast protection behind text over full-media imagery."}, + {"ko": "페이드는 콘텐츠 진입에만 쓰고 주요 행동을 기다리게 하지 않습니다. 가감속 대안은 hover 피드백에 한정하며 터치에서도 행동이 보이게 합니다. 모션 감소 시 선택한 preset의 reducedMotion 대안을 유지합니다.", "en": "Use fading for content entry without delaying the primary action. The ease-in-out alternative is hover feedback, not an automatic entry sequence; keep actions visible on touch. Preserve the selected preset's reducedMotion alternative."} + ], + "checks": [ + {"ko": "좁은 화면과 확대 상태에서 제목, 설명, CTA가 읽기 순서대로 나타나고 가로 넘침이 없는지 확인합니다.", "en": "Check that headings, explanation, and CTA retain reading order without horizontal overflow on narrow screens and when zoomed."}, + {"ko": "키보드만으로 모든 행동에 도달하고, 고정 영역이 포커스를 가리지 않는지 확인합니다. 전체 미디어 대안에서는 모바일 CTA의 비고정 배치를 확인합니다.", "en": "Reach every action by keyboard and check that fixed regions do not cover focus. For the full-media alternative, check that the mobile CTA is not fixed."}, + {"ko": "선택한 Color의 contrast 검사 쌍을 실제 배경에서 확인하고 웹폰트를 차단해 한글과 영문 fallback을 확인합니다.", "en": "Check the selected color system's contrast pairs against actual backgrounds, then block webfonts to inspect Korean and English fallbacks."}, + {"ko": "모션 감소와 JavaScript 실패 상태에서 설명과 행동이 보이는지 확인합니다. 복사 대안은 성공·거부·API 미지원 상태를 각각 실행합니다.", "en": "Check that content and actions remain visible with reduced motion and failed JavaScript. Exercise copy success, denial, and an unavailable Clipboard API when using the alternative."} + ], + "sources": [ + {"url": "https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/recipes/primitive-to-recipe-matrix.md", "license": "CC BY 4.0", "note": "IYEN / StyleGallery: role and substitution model. Atlas authored these bilingual constraints and catalog selections; no upstream code copied. License: https://creativecommons.org/licenses/by/4.0/"}, + {"url": "https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/guides/webpage-generation-workflow.md", "license": "CC BY 4.0", "note": "IYEN / StyleGallery: content purpose before composition. Adapted as Atlas-specific guidance, not an executed or verified generated page. License: https://creativecommons.org/licenses/by/4.0/"} + ] + }, + { + "id": "editorial-reading", + "title": {"ko": "기사 탐색과 차분한 읽기", "en": "Editorial discovery and quiet reading"}, + "summary": {"ko": "대표 기사와 보조 기사의 우선순위를 나누고, 작은 화면에서도 내용과 읽기 순서를 유지하는 편집형 화면입니다.", "en": "Establish a lead story and supporting stories while preserving content and reading order on smaller screens."}, + "slots": [ + {"id": "style", "label": {"ko": "편집 스타일", "en": "Editorial style"}, "role": "substitutable", + "default": {"domain": "isms", "id": "editorial-typography"}, "alternatives": [{"domain": "isms", "id": "minimalism"}]}, + {"id": "layout", "label": {"ko": "기사의 읽기 순서", "en": "Story reading order"}, "role": "essential", + "default": {"domain": "layout", "id": "layout-grid-magazine"}, "alternatives": [{"domain": "layout", "id": "layout-content-timeline"}]}, + {"id": "color", "label": {"ko": "본문과 편집 강조색", "en": "Reading and editorial colors"}, "role": "substitutable", + "default": {"domain": "color", "id": "media-editorial"}, "alternatives": [{"domain": "color", "id": "minimalism-neutral"}]}, + {"id": "typography", "label": {"ko": "한글 제목과 긴 본문", "en": "Korean headlines and long-form text"}, "role": "essential", + "default": {"domain": "typography", "id": "noto-serif-sans-kr-readable"}, "alternatives": [{"domain": "typography", "id": "gowun-batang-pretendard-calm"}]}, + {"id": "effect", "label": {"ko": "보조 읽기 피드백", "en": "Supporting reading feedback"}, "role": "helper", + "default": {"domain": "effects", "id": "scroll-reveal"}, "alternatives": [{"domain": "effects", "id": "tooltip"}]}, + {"id": "motion", "label": {"ko": "기사 영역 등장", "en": "Story-region entry"}, "role": "helper", + "default": {"domain": "motion", "id": "motion-scroll-reveal"}, "alternatives": [{"domain": "motion", "id": "motion-fade"}]} + ], + "constraints": [ + {"ko": "선택한 레이아웃의 composition과 responsive를 보존합니다. 매거진은 대표 기사부터 읽히게 하고 모바일에서 보조 레일을 뒤로 이어 붙입니다. 타임라인 대안은 날짜가 있는 시간순 서사에만 쓰며, 동등한 기사 목록의 대체품으로 쓰지 않습니다.", "en": "Preserve the selected layout's composition and responsive rules. Lead with the main story in the magazine and place the supporting rail after it on mobile. Use the timeline alternative only for dated chronological narratives, not as a substitute for an equal-weight story list."}, + {"ko": "문서가 스크롤을 맡고 본문 안에 별도 스크롤 영역을 만들지 않습니다. 시각적 재배치가 제목 계층, DOM 순서, 링크의 키보드 순서를 바꾸지 않게 합니다.", "en": "Let the document own scrolling without nested scrolling in article text. Visual rearrangement must preserve heading hierarchy, DOM order, and keyboard link order."}, + {"ko": "본문은 중립적인 텍스트 역할을 사용합니다. 편집 강조색은 섹션과 행동에 한정하고, 명조 제목과 고딕 본문의 역할 및 한글 fallback을 유지합니다.", "en": "Use neutral text roles for reading. Reserve editorial accents for sections and actions, and preserve the serif-heading/sans-body roles with Korean fallbacks."}, + {"ko": "스크롤 효과와 motion-scroll-reveal을 함께 선택하면 관찰자와 가시 상태의 소유자를 하나로 합칩니다. 기본 콘텐츠는 보이고, 연결된 항목만 한 번 등장시킵니다. 페이드 대안도 같은 가시 상태를 써서 이중 애니메이션과 지연을 피합니다.", "en": "When scroll-reveal and motion-scroll-reveal are selected together, use one observer and one visibility-state owner. Content is visible by default; only connected elements reveal once. The fade alternative must share that visibility state rather than add another animation or delay."}, + {"ko": "툴팁 대안은 용어의 짧은 보조 설명만 맡습니다. hover와 focus 모두에서 접근할 수 있게 하고, 터치 화면과 본문에도 같은 의미를 제공하며 필수 설명을 툴팁에 숨기지 않습니다. 기사 등장 모션과 툴팁의 열림 상태는 분리합니다.", "en": "The tooltip alternative is only for short supplementary definitions. Support hover and focus, provide the same meaning on touch and in the text, and never hide required information in a tooltip. Keep article-entry motion separate from tooltip open state."} + ], + "checks": [ + {"ko": "데스크탑과 모바일에서 기사 누락 없이 대표 기사부터 읽히는지 확인합니다. 타임라인을 선택했다면 날짜와 사건의 DOM 순서가 일치하는지 확인합니다.", "en": "Check that desktop and mobile retain every story and lead with the main story. For a timeline, check that dates and events follow the same chronological DOM order."}, + {"ko": "긴 한글 제목, 영문 단어, 확대된 글꼴로 넘침과 줄바꿈을 확인하고 웹폰트 실패 시에도 본문을 읽을 수 있는지 확인합니다.", "en": "Check wrapping and overflow with long Korean headlines, English words, and enlarged text, including readability when webfonts fail."}, + {"ko": "관찰 API 미지원, JavaScript 실패, 모션 감소에서 모든 기사 내용이 보이는지 확인하고 반복 스크롤로 등장 효과가 겹치지 않는지 확인합니다.", "en": "Check that every story remains visible without the observer API, with failed JavaScript, and under reduced motion. Repeated scrolling must not stack entry animations."}, + {"ko": "툴팁 대안은 키보드와 터치에서 보조 설명에 접근 가능한지 확인합니다. 색상과 애니메이션을 제거해도 기사 구분과 탐색이 유지되어야 합니다.", "en": "For the tooltip alternative, check access to supplementary explanations with keyboard and touch. Story grouping and navigation must survive removal of color and animation."} + ], + "sources": [ + {"url": "https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/recipes/primitive-to-recipe-matrix.md", "license": "CC BY 4.0", "note": "IYEN / StyleGallery: role and substitution model. Atlas authored the editorial constraints and selections for its own catalogs; no upstream code copied. License: https://creativecommons.org/licenses/by/4.0/"}, + {"url": "https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/layout/index.md", "license": "CC BY 4.0", "note": "IYEN / StyleGallery: explicit layout responsibilities and composition boundaries. Adapted to document-owned scrolling and Atlas responsive references. License: https://creativecommons.org/licenses/by/4.0/"} + ] + }, + { + "id": "settings-workspace", + "title": {"ko": "설정 편집과 저장 상태", "en": "Settings editing and save state"}, + "summary": {"ko": "설정 그룹과 저장 행동을 한 흐름에 두고, 편집 중인 값과 저장된 값을 혼동하지 않게 만드는 작업 화면입니다.", "en": "Keep settings groups and save actions in one flow while clearly distinguishing current edits from persisted values."}, + "slots": [ + {"id": "style", "label": {"ko": "작업 화면 스타일", "en": "Workspace style"}, "role": "substitutable", + "default": {"domain": "isms", "id": "minimalism"}, "alternatives": [{"domain": "isms", "id": "bauhaus"}]}, + {"id": "layout", "label": {"ko": "설정 그룹과 저장 배치", "en": "Settings groups and save layout"}, "role": "essential", + "default": {"domain": "layout", "id": "layout-form-settings"}, "alternatives": [{"domain": "layout", "id": "layout-form-multi-step"}]}, + {"id": "color", "label": {"ko": "상태와 행동 색상", "en": "State and action colors"}, "role": "substitutable", + "default": {"domain": "color", "id": "tailwind-slate-blue"}, "alternatives": [{"domain": "color", "id": "github-primer-light"}]}, + {"id": "typography", "label": {"ko": "설정 레이블과 도움말", "en": "Settings labels and help"}, "role": "essential", + "default": {"domain": "typography", "id": "outfit-pretendard-product"}, "alternatives": [{"domain": "typography", "id": "noto-serif-sans-kr-readable"}]}, + {"id": "effect", "label": {"ko": "작업 결과 피드백", "en": "Operation feedback"}, "role": "helper", + "default": {"domain": "effects", "id": "toast"}, "alternatives": [{"domain": "effects", "id": "inline-validation"}]}, + {"id": "motion", "label": {"ko": "부가 설정 펼침", "en": "Supplementary settings disclosure"}, "role": "helper", + "default": {"domain": "motion", "id": "motion-expand-collapse"}, "alternatives": [{"domain": "motion", "id": "motion-fade"}]} + ], + "constraints": [ + {"ko": "문서가 스크롤을 맡고 모바일에서는 목차, 설정 그룹, 저장 행동을 한 열로 둡니다. 선택한 레이아웃의 composition과 responsive를 보존하며, 다단계 대안은 실제 순차 과업일 때만 쓰고 단계 이동으로 입력을 잃지 않게 합니다.", "en": "Let the document own scrolling; stack navigation, settings groups, and save actions on mobile. Preserve the selected layout's composition and responsive rules. Use the multi-step alternative only for a genuinely sequential task and retain input across steps."}, + {"ko": "폼 소유자가 draft, 제출 시점의 값, 마지막 저장 확인값을 분리합니다. A를 제출한 뒤 B로 편집하고 A의 응답을 받아도 B는 미저장 상태로 남겨야 합니다. 실제 저장 확인 전에는 성공으로 표시하지 않습니다.", "en": "The form owner separates the draft, submitted snapshot, and last acknowledged baseline. If A is submitted and the user edits B before A is acknowledged, B remains unsaved. Do not report success before persistence is acknowledged."}, + {"ko": "토스트는 확인된 결과의 보조 알림입니다. 오류 수정과 저장 상태는 폼에도 남기고 중요한 행동을 토스트에만 두지 않습니다. 인라인 검증 대안은 필드 오류를 맡으며 저장 확인을 대신하지 않습니다.", "en": "A toast supplements an acknowledged result. Keep error recovery and save state in the form, and never place a required action only in a toast. Inline validation handles field errors; it does not replace persistence acknowledgement."}, + {"ko": "접기 제어부가 aria-expanded, 연결 영역의 hidden·inert, 포커스 복귀를 함께 관리하고 CSS는 전환만 맡습니다. 빠른 재열기는 이전 닫힘을 취소해야 합니다. 페이드 대안도 같은 상태 소유자를 사용하며 필수 필드는 숨기지 않습니다.", "en": "The disclosure controller owns aria-expanded, hidden/inert state, and focus recovery; CSS owns only the transition. Reopening must cancel a pending close. The fade alternative uses the same state owner and must not hide required fields."}, + {"ko": "색상은 상태 텍스트와 함께 사용하고 한글 레이블의 fallback을 보존합니다. Tailwind 팔레트는 v3.4 HEX 참고값이며 v4 토큰으로 간주하지 않습니다. 스타일을 바꿔도 정보 밀도와 저장 행동의 위계를 유지합니다.", "en": "Pair state colors with text and retain Korean label fallbacks. The Tailwind palette is a v3.4 HEX reference, not v4 tokens. Style changes must preserve information density and the save action's hierarchy."} + ], + "checks": [ + {"ko": "A 제출 → B 편집 → A 저장 응답 순서에서 B가 남고 미저장 표시가 유지되는지 확인합니다. 실패·재시도·중복 제출도 실제 저장 연결에서 따로 실행합니다.", "en": "Exercise submit A, edit B, then acknowledge A: B and the unsaved indicator must remain. Separately exercise failure, retry, and duplicate submission against the actual persistence integration."}, + {"ko": "키보드로 목차, 필드, 저장 행동에 도달하고 접힌 영역이 탭 순서에서 빠지는지 확인합니다. 빠르게 닫고 열어도 마지막 의도와 포커스가 유지되어야 합니다.", "en": "Reach navigation, fields, and save actions by keyboard; collapsed regions must leave the tab order. Rapid close/reopen actions must preserve the final intent and focus."}, + {"ko": "토스트가 사라져도 저장 상태와 오류 수정 방법이 남는지 확인합니다. 인라인 검증 대안은 오류와 필드의 연결, 검증 시점, 서버 오류 처리도 확인합니다.", "en": "Check that save state and error recovery remain after a toast disappears. For inline validation, check field-error association, validation timing, and server error handling."}, + {"ko": "모바일 키보드, 확대, 긴 레이블에서도 필드와 저장 버튼이 가려지지 않는지 확인합니다. 모션 감소에서는 접기 상태와 접근성을 유지하며 전환만 생략합니다.", "en": "Check that the mobile keyboard, zoom, and long labels do not obscure fields or save actions. Reduced motion must remove transitions while preserving disclosure state and accessibility."} + ], + "sources": [ + {"url": "https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/recipes/primitive-to-recipe-matrix.md", "license": "CC BY 4.0", "note": "IYEN / StyleGallery: role and substitution model. Atlas authored the settings constraints and catalog selections; no upstream code copied. License: https://creativecommons.org/licenses/by/4.0/"}, + {"url": "https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/design-engineering/component-contract.md", "license": "CC BY 4.0", "note": "IYEN / StyleGallery: separate draft, submitted snapshot, and persisted baseline. Adapted into consumer checks; these recipes do not implement or verify server persistence. License: https://creativecommons.org/licenses/by/4.0/"}, + {"url": "https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/motion/interaction-recipes.md", "license": "CC BY 4.0", "note": "IYEN / StyleGallery: state ownership and interruption rules. Adapted to Atlas disclosure guidance, not copied animation code. License: https://creativecommons.org/licenses/by/4.0/"} + ] + } + ] +} diff --git a/assets/js/design-catalog.js b/assets/js/design-catalog.js new file mode 100644 index 0000000..0020004 --- /dev/null +++ b/assets/js/design-catalog.js @@ -0,0 +1,184 @@ +"use strict"; +/** JSON boundary and immutable catalog lookup. No adapters, DOM, or IO. */ +var DesignCatalog; +(function (DesignCatalog) { + const indexes = new WeakMap(); + const SNAPSHOT_FIELDS = ['contractVersion', 'version', 'catalogs', 'recipes', 'guides', 'effectDocs', 'effectSnippets']; + // Clone descriptors rather than stringify: reject lossy values and never invoke toJSON/getters. + function cloneJson(value, ancestors = new Set(), depth = 0) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number' && Number.isFinite(value)) + return value; + if (typeof value !== 'object' || depth > 64 || ancestors.has(value)) { + return DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Snapshot must be finite, acyclic JSON with depth at most 64'); + } + if (!Array.isArray(value)) + DesignCatalog.Boundary.record(value, 'Snapshot record', 'INVALID_SNAPSHOT'); + ancestors.add(value); + const result = Array.isArray(value) ? [] : Object.create(null); + for (const key of Reflect.ownKeys(value)) { + if (Array.isArray(value) && key === 'length') + continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (typeof key !== 'string' || !descriptor || !descriptor.enumerable || !('value' in descriptor)) { + return DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Snapshot supports enumerable JSON data properties only'); + } + if (Array.isArray(value) && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length)) { + return DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Snapshot arrays cannot have named properties'); + } + Object.defineProperty(result, key, { + value: cloneJson(descriptor.value, ancestors, depth + 1), enumerable: true + }); + } + if (Array.isArray(value) && Object.keys(value).length !== value.length) { + return DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Snapshot arrays cannot contain holes'); + } + ancestors.delete(value); + return Object.freeze(result); + } + function validateEntry(domain, value) { + const entry = DesignCatalog.Boundary.record(value, domain, 'INVALID_SNAPSHOT'); + DesignCatalog.Boundary.id(entry.id, 'INVALID_SNAPSHOT'); + DesignCatalog.Boundary.text(entry.name, 'name', 'INVALID_SNAPSHOT', 500); + DesignCatalog.Boundary.text(entry.nameKr, 'nameKr', 'INVALID_SNAPSHOT', 500); + DesignCatalog.Boundary.text(domain === 'isms' ? entry.tagline : entry.summary, 'summary', 'INVALID_SNAPSHOT'); + if (domain === 'isms') + DesignCatalog.Boundary.text(entry.description, 'description', 'INVALID_SNAPSHOT'); + if (entry.kind !== undefined && entry.kind !== 'style' && entry.kind !== 'anti-pattern') { + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Unknown catalog kind'); + } + if (entry.kind === 'anti-pattern' && (domain !== 'isms' || entry.id !== 'ai-slop')) { + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Only isms/ai-slop may be an anti-pattern'); + } + if (domain === 'isms' && entry.id === 'ai-slop' && entry.kind !== 'anti-pattern') { + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'isms/ai-slop must be an anti-pattern'); + } + for (const field of ['keywords', 'alsoCalled', 'aliases', 'bestFor', 'useCases']) { + if (entry[field] !== undefined) + DesignCatalog.Boundary.strings(entry[field], field, 'INVALID_SNAPSHOT'); + } + for (const field of ['descriptionEn', 'summaryEn', 'taglineEn', 'family', 'category']) { + if (entry[field] !== undefined) + DesignCatalog.Boundary.text(entry[field], field, 'INVALID_SNAPSHOT'); + } + return entry; + } + function validateGuides(entry) { + for (const key of ['layout', 'typography', 'color', 'motion']) { + const fields = DesignCatalog.Boundary.record(entry[key], key, 'INVALID_SNAPSHOT'); + for (const value of Object.values(fields)) + DesignCatalog.Boundary.text(value, key, 'INVALID_SNAPSHOT'); + } + for (const key of ['dos', 'donts']) + DesignCatalog.Boundary.strings(entry[key], key, 'INVALID_SNAPSHOT'); + if (entry.implementation !== undefined) { + const implementation = DesignCatalog.Boundary.record(entry.implementation, 'implementation', 'INVALID_SNAPSHOT'); + DesignCatalog.Boundary.text(implementation.summary, 'implementation.summary', 'INVALID_SNAPSHOT'); + for (const key of ['components', 'build', 'checks']) { + DesignCatalog.Boundary.strings(implementation[key], key, 'INVALID_SNAPSHOT'); + } + } + } + function validateDocs(entry) { + for (const key of ['background', 'history']) + DesignCatalog.Boundary.text(entry[key], key, 'INVALID_SNAPSHOT'); + for (const key of ['useWhen', 'anatomy', 'misuse', 'implementationNotes']) { + DesignCatalog.Boundary.strings(entry[key], key, 'INVALID_SNAPSHOT'); + } + for (const [key, fields] of [['examples', ['context', 'description']], ['researchRefs', ['label', 'url']]]) { + const rows = entry[key]; + if (!Array.isArray(rows)) + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', `${key} must be an array`); + for (const row of rows) { + const record = DesignCatalog.Boundary.record(row, key, 'INVALID_SNAPSHOT'); + for (const field of fields) + DesignCatalog.Boundary.text(record[field], field, 'INVALID_SNAPSHOT'); + } + } + } + function validateSnippet(entry) { + for (const key of ['html', 'css']) + DesignCatalog.Boundary.text(entry[key], key, 'INVALID_SNAPSHOT'); + // Expansion snippets have HTML/CSS only; preserve absence rather than fabricate metadata. + if (entry.reducedMotion !== undefined) + DesignCatalog.Boundary.text(entry.reducedMotion, 'reducedMotion', 'INVALID_SNAPSHOT'); + if (entry.js !== undefined && typeof entry.js !== 'string') + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'js must be a string'); + for (const key of ['supports', 'a11yNotes', 'sourceRefs']) { + if (entry[key] !== undefined) + DesignCatalog.Boundary.strings(entry[key], key, 'INVALID_SNAPSHOT'); + } + } + function validateAuxiliary(raw, ids, validate) { + const entries = DesignCatalog.Boundary.record(raw, 'Auxiliary records', 'INVALID_SNAPSHOT'); + for (const [id, entry] of Object.entries(entries)) { + DesignCatalog.Boundary.id(id, 'INVALID_SNAPSHOT'); + if (!ids.has(id)) + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Auxiliary record has no catalog entry'); + validate(DesignCatalog.Boundary.record(entry, id, 'INVALID_SNAPSHOT')); + } + } + function create(source) { + const raw = DesignCatalog.Boundary.record(cloneJson(source), 'SourceSnapshot', 'INVALID_SNAPSHOT'); + DesignCatalog.Boundary.keys(raw, SNAPSHOT_FIELDS, 'INVALID_SNAPSHOT'); + DesignCatalog.Boundary.text(raw.contractVersion, 'contractVersion', 'INVALID_SNAPSHOT', 80); + DesignCatalog.Boundary.text(raw.version, 'version', 'INVALID_SNAPSHOT', 256); + const catalogs = DesignCatalog.Boundary.record(raw.catalogs, 'catalogs', 'INVALID_SNAPSHOT'); + DesignCatalog.Boundary.keys(catalogs, DesignCatalog.DOMAINS, 'INVALID_SNAPSHOT'); + const index = {}; + for (const domain of DesignCatalog.DOMAINS) { + const entries = catalogs[domain]; + if (!Array.isArray(entries)) + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', `${domain} must be an array`); + const byId = new Map(); + for (const value of entries) { + const entry = validateEntry(domain, value); + const id = DesignCatalog.Boundary.id(entry.id, 'INVALID_SNAPSHOT'); + if (byId.has(id)) + DesignCatalog.Boundary.fail('DUPLICATE_REF', `Duplicate ${domain}/${id}`); + byId.set(id, entry); + } + index[domain] = byId; + } + validateAuxiliary(raw.guides, index.isms, validateGuides); + validateAuxiliary(raw.effectDocs, index.effects, validateDocs); + const snippets = DesignCatalog.Boundary.record(raw.effectSnippets, 'effectSnippets', 'INVALID_SNAPSHOT'); + DesignCatalog.Boundary.text(snippets.version, 'effectSnippets.version', 'INVALID_SNAPSHOT'); + validateAuxiliary(snippets.snippets, index.effects, validateSnippet); + const recipes = DesignCatalog.Boundary.record(raw.recipes, 'recipes', 'INVALID_SNAPSHOT'); + if (recipes.version !== 1 || !Array.isArray(recipes.recipes)) { + DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Recipes require version 1 and a recipes array'); + } + // Recipe slot and cross-reference validation belongs to DesignRecipes.parse. + // All public data was cloned and recursively frozen before this boundary cast. + const snapshot = raw; + indexes.set(snapshot, index); + return snapshot; + } + DesignCatalog.create = create; + function resolve(snapshot, ref) { + const args = DesignCatalog.Boundary.record(ref, 'ref', 'INVALID_ARGUMENT'); + DesignCatalog.Boundary.keys(args, ['domain', 'id'], 'INVALID_ARGUMENT'); + const domain = DesignCatalog.Boundary.domain(args.domain, 'INVALID_ARGUMENT'); + const id = DesignCatalog.Boundary.id(args.id, 'INVALID_ARGUMENT'); + const index = indexes.get(snapshot); + if (!index) + return DesignCatalog.Boundary.fail('INVALID_SNAPSHOT', 'Use create() to construct the snapshot'); + const entry = index[domain].get(id); + return entry ?? DesignCatalog.Boundary.fail('UNKNOWN_REFERENCE', `Unknown ${domain}/${id}`); + } + DesignCatalog.resolve = resolve; + function summarize(domain, entry) { + // Entries originate at create()/resolve(); summary owns only projection. + const summary = { + ref: Object.freeze({ domain, id: entry.id }), + name: entry.name, + nameKr: entry.nameKr, + summary: (domain === 'isms' ? entry.tagline || entry.description : entry.summary), + ...(entry.kind === 'style' || entry.kind === 'anti-pattern' ? { kind: entry.kind } : {}) + }; + return Object.freeze(summary); + } + DesignCatalog.summarize = summarize; +})(DesignCatalog || (DesignCatalog = {})); diff --git a/assets/js/design-contracts.js b/assets/js/design-contracts.js new file mode 100644 index 0000000..f5017fc --- /dev/null +++ b/assets/js/design-contracts.js @@ -0,0 +1,93 @@ +"use strict"; +/** Pure catalog contracts. Load before design-catalog/search/views classic scripts. */ +var DesignCatalog; +(function (DesignCatalog) { + DesignCatalog.CONTRACT_VERSION = 'design-catalog/1'; + class CatalogError extends Error { + constructor(code, message) { + super(message); + this.code = code; + this.name = 'CatalogError'; + } + } + DesignCatalog.CatalogError = CatalogError; + DesignCatalog.DOMAINS = Object.freeze([ + 'isms', 'effects', 'color', 'typography', 'layout', 'motion' + ]); + DesignCatalog.SOURCE_FILES = Object.freeze([ + ...DesignCatalog.DOMAINS.map(domain => `assets/data/${domain}.json`), + 'assets/data/recipes.json', 'assets/data/dev-guides.json', + 'assets/data/effects-docs.json', 'assets/data/effects-snippets.json' + ].sort()); + /** Shared ingress rules for the catalog's classic-script files, not retrieval APIs. */ + let Boundary; + (function (Boundary) { + function fail(code, message) { + throw new CatalogError(code, message); + } + Boundary.fail = fail; + function record(value, label, code) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return fail(code, `${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + // Accept plain JSON records from either realm, but not class instances. + if (prototype !== null) { + const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value; + if (Object.getPrototypeOf(prototype) !== null || typeof constructor !== 'function' + || Object.getOwnPropertyDescriptor(constructor, 'name')?.value !== 'Object' + || Object.getOwnPropertyDescriptor(constructor, 'prototype')?.value !== prototype) { + return fail(code, `${label} must be a plain object`); + } + } + // Detach own data fields so inherited values can never supply omitted arguments. + const fields = Object.create(null); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (typeof key !== 'string' || !descriptor?.enumerable || !('value' in descriptor)) { + return fail(code, `${label} must contain enumerable JSON data fields`); + } + fields[key] = descriptor.value; + } + return Object.freeze(fields); + } + Boundary.record = record; + function keys(value, allowed, code) { + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowed.includes(key)) + fail(code, 'Unknown argument field'); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) + fail(code, 'Accessor fields are not supported'); + } + } + Boundary.keys = keys; + function text(value, label, code, max = Infinity) { + if (typeof value !== 'string' || !value.trim() || value.length > max) { + return fail(code, `${label} must be a nonempty string within its length limit`); + } + return value; + } + Boundary.text = text; + function id(value, code) { + const result = text(value, 'id', code, 128); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result)) + fail(code, 'Invalid catalog id'); + return result; + } + Boundary.id = id; + function domain(value, code) { + if (typeof value !== 'string' || !DesignCatalog.DOMAINS.includes(value)) { + return fail(code, 'Unknown catalog domain'); + } + return value; + } + Boundary.domain = domain; + function strings(value, label, code) { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string' && item.trim())) { + fail(code, `${label} must be an array of nonempty strings`); + } + } + Boundary.strings = strings; + })(Boundary = DesignCatalog.Boundary || (DesignCatalog.Boundary = {})); +})(DesignCatalog || (DesignCatalog = {})); diff --git a/assets/js/design-recipes.js b/assets/js/design-recipes.js new file mode 100644 index 0000000..f47f829 --- /dev/null +++ b/assets/js/design-recipes.js @@ -0,0 +1,203 @@ +"use strict"; +/** Pure recipe contracts. Depends only on DesignCatalog; adapters own IO and hashing. */ +var DesignRecipes; +(function (DesignRecipes) { + class RecipeError extends Error { + constructor(code, message) { + super(message); + this.code = code; + this.name = 'RecipeError'; + } + } + DesignRecipes.RecipeError = RecipeError; + const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']); + const safeId = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + function fail(code, message) { throw new RecipeError(code, message); } + function record(raw, keys, code) { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) + fail(code, 'Expected an object.'); + const proto = Object.getPrototypeOf(raw); + // Permit JSON objects from another realm as well as null-prototype dictionaries. + if (proto !== null) { + const ctor = Object.getOwnPropertyDescriptor(proto, 'constructor')?.value; + if (Object.getPrototypeOf(proto) !== null || typeof ctor !== 'function' || ctor.name !== 'Object') { + fail(code, 'Expected a plain object.'); + } + } + const result = Object.create(null); + for (const key of Reflect.ownKeys(raw)) { + if (typeof key !== 'string' || forbiddenKeys.has(key) || (keys !== null && !keys.includes(key))) { + fail(code, 'Unknown or unsafe field.'); + } + const descriptor = Object.getOwnPropertyDescriptor(raw, key); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) + fail(code, 'Expected JSON data fields.'); + result[key] = descriptor.value; + } + return result; + } + function string(raw, code) { + if (typeof raw !== 'string' || raw.trim().length === 0) + fail(code, 'Expected non-empty text.'); + return raw; + } + function id(raw, code) { + const value = string(raw, code); + if (!safeId.test(value) || forbiddenKeys.has(value)) + fail(code, 'Invalid identifier.'); + return value; + } + function array(raw, code, nonempty = true) { + if (!Array.isArray(raw) || (nonempty && raw.length === 0)) + fail(code, 'Expected an array of entries.'); + const result = []; + const keys = Reflect.ownKeys(raw); + if (keys.length !== raw.length + 1) + fail(code, 'Arrays cannot contain holes or named properties.'); + for (let index = 0; index < raw.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(raw, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) + fail(code, 'Expected dense JSON arrays.'); + result.push(descriptor.value); + } + return result; + } + function text(raw) { + const value = record(raw, ['ko', 'en'], 'INVALID_RECIPE'); + return Object.freeze({ ko: string(value.ko, 'INVALID_RECIPE'), en: string(value.en, 'INVALID_RECIPE') }); + } + function language(raw) { + if (raw !== 'ko' && raw !== 'en') + fail('INVALID_LANGUAGE', 'Language must be ko or en.'); + return raw; + } + function ref(raw, snapshot, code) { + const value = record(raw, ['domain', 'id'], code); + const domain = string(value.domain, code); + if (!Object.prototype.hasOwnProperty.call(snapshot.catalogs, domain)) + fail(code, 'Unknown catalog domain.'); + const reference = Object.freeze({ domain: domain, id: id(value.id, code) }); + let entry; + try { + entry = DesignCatalog.resolve(snapshot, reference); + } + catch { + return fail(code, 'Unknown catalog reference: ' + domain + '/' + reference.id); + } + if (reference.id === 'ai-slop' || entry.kind === 'anti-pattern') + fail(code, 'Anti-patterns cannot enter recipes.'); + return reference; + } + function sameRef(a, b) { + return a.domain === b.domain && a.id === b.id; + } + function slot(raw, snapshot) { + const value = record(raw, ['id', 'label', 'role', 'default', 'alternatives'], 'INVALID_RECIPE'); + if (value.role !== 'essential' && value.role !== 'helper' && value.role !== 'substitutable') { + fail('INVALID_RECIPE', 'Unknown slot role.'); + } + const fallback = ref(value.default, snapshot, 'INVALID_RECIPE'); + const alternatives = array(value.alternatives, 'INVALID_RECIPE', false).map(item => ref(item, snapshot, 'INVALID_RECIPE')); + const seen = new Set([fallback.id]); + for (const alternative of alternatives) { + if (alternative.domain !== fallback.domain || seen.has(alternative.id)) { + fail('INVALID_RECIPE', 'Alternatives must be unique references in the slot domain.'); + } + seen.add(alternative.id); + } + return Object.freeze({ id: id(value.id, 'INVALID_RECIPE'), label: text(value.label), role: value.role, + default: fallback, alternatives: Object.freeze(alternatives) }); + } + function source(raw) { + const value = record(raw, ['url', 'license', 'note'], 'INVALID_RECIPE'); + const url = string(value.url, 'INVALID_RECIPE'); + // No URL/network globals are needed by this pure module. Whitespace/markup are not source URLs. + if (!/^https:\/\/[a-z0-9.-]+(?::[0-9]+)?(?:[/?#][^\s<>"\\]*)?$/i.test(url)) { + fail('INVALID_RECIPE', 'Source must be an HTTPS URL.'); + } + return Object.freeze({ url, license: string(value.license, 'INVALID_RECIPE'), note: string(value.note, 'INVALID_RECIPE') }); + } + function recipe(raw, snapshot) { + const value = record(raw, ['id', 'title', 'summary', 'slots', 'constraints', 'checks', 'sources'], 'INVALID_RECIPE'); + const slots = array(value.slots, 'INVALID_RECIPE').map(item => slot(item, snapshot)); + if (new Set(slots.map(item => item.id)).size !== slots.length) + fail('INVALID_RECIPE', 'Duplicate slot ID.'); + return Object.freeze({ id: id(value.id, 'INVALID_RECIPE'), title: text(value.title), summary: text(value.summary), + slots: Object.freeze(slots), constraints: Object.freeze(array(value.constraints, 'INVALID_RECIPE').map(text)), + checks: Object.freeze(array(value.checks, 'INVALID_RECIPE').map(text)), + sources: Object.freeze(array(value.sources, 'INVALID_RECIPE').map(source)) }); + } + /** Parse the version:1 source document once; return detached, immutable recipes. */ + function parse(raw, snapshot) { + const value = record(raw, ['version', 'recipes'], 'INVALID_RECIPE'); + if (value.version !== 1) + fail('INVALID_RECIPE', 'Unsupported recipe schema version.'); + const recipes = array(value.recipes, 'INVALID_RECIPE').map(item => recipe(item, snapshot)); + if (new Set(recipes.map(item => item.id)).size !== recipes.length) + fail('INVALID_RECIPE', 'Duplicate recipe ID.'); + return Object.freeze(recipes); + } + DesignRecipes.parse = parse; + /** Adapters wrap this compact bilingual list with snapshot.version. */ + function list(recipes) { + return Object.freeze(recipes.map(item => Object.freeze({ id: item.id, title: item.title, summary: item.summary }))); + } + DesignRecipes.list = list; + /** Adapters wrap the complete authored contract with snapshot.version. */ + function detail(recipes, recipeId) { + const wanted = id(recipeId, 'RECIPE_NOT_FOUND'); + const found = recipes.find(item => item.id === wanted); + if (!found) + fail('RECIPE_NOT_FOUND', 'Recipe not found: ' + wanted); + return found; + } + DesignRecipes.detail = detail; + function compose(snapshot, recipes, options) { + const input = record(options, ['recipeId', 'selections', 'lang'], 'INVALID_SELECTION'); + const lang = language(Object.prototype.hasOwnProperty.call(input, 'lang') ? input.lang : 'ko'); + const chosen = detail(recipes, id(input.recipeId, 'RECIPE_NOT_FOUND')); + const selections = Object.prototype.hasOwnProperty.call(input, 'selections') + ? record(input.selections, chosen.slots.map(item => item.id), 'INVALID_SELECTION') : Object.create(null); + const slots = chosen.slots.map(item => { + const selected = Object.prototype.hasOwnProperty.call(selections, item.id) + ? ref(selections[item.id], snapshot, 'INVALID_SELECTION') : ref(item.default, snapshot, 'INVALID_SELECTION'); + if (![item.default, ...item.alternatives].some(allowed => sameRef(allowed, selected))) { + fail('INVALID_SELECTION', 'Selection is not allowed for slot: ' + item.id); + } + return Object.freeze({ id: item.id, role: item.role, ref: selected, + item: DesignCatalog.summarize(selected.domain, DesignCatalog.resolve(snapshot, selected)) }); + }); + return Object.freeze({ version: snapshot.version, recipeId: chosen.id, title: chosen.title[lang], + slots: Object.freeze(slots), constraints: Object.freeze(chosen.constraints.map(item => item[lang])), + checks: Object.freeze(chosen.checks.map(item => item[lang])), sources: chosen.sources }); + } + DesignRecipes.compose = compose; + function markdown(value) { + return value.replace(/[\\`*_{}\[\]()#+.!|>~-]/g, '\\$&').replace(/ '- ' + markdown(value)), '', '## ' + (ko ? '구현 후 확인할 항목' : 'Checks to run after implementation'), '', ...composition.checks.map(value => '- [ ] ' + markdown(value)), '', '## ' + (ko ? '출처' : 'Sources'), ''); + for (const item of composition.sources) { + lines.push('- <' + item.url + '> (' + markdown(item.license) + '): ' + markdown(item.note)); + } + return lines.join('\n') + '\n'; + } + DesignRecipes.formatBrief = formatBrief; +})(DesignRecipes || (DesignRecipes = {})); diff --git a/assets/js/design-search.js b/assets/js/design-search.js new file mode 100644 index 0000000..23b3731 --- /dev/null +++ b/assets/js/design-search.js @@ -0,0 +1,139 @@ +"use strict"; +/** Deterministic discovery and version-bound cursors, identical in Node and browser. */ +var DesignCatalog; +(function (DesignCatalog) { + const QUERY_LIMIT = 512; + const CURSOR_LIMIT = 20000; + function normalize(value) { + return value.normalize('NFKC').toLowerCase().replace(/\s+/gu, ' ').trim(); + } + function compare(a, b) { return a < b ? -1 : a > b ? 1 : 0; } + function parseSearch(args) { + const raw = DesignCatalog.Boundary.record(args, 'Search arguments', 'INVALID_ARGUMENT'); + DesignCatalog.Boundary.keys(raw, ['query', 'domains', 'limit', 'cursor'], 'INVALID_ARGUMENT'); + const query = raw.query === undefined ? '' : raw.query; + if (typeof query !== 'string' || query.length > QUERY_LIMIT) { + return DesignCatalog.Boundary.fail('INVALID_ARGUMENT', `query must be a string of at most ${QUERY_LIMIT} characters`); + } + const normalized = normalize(query); + if (normalized.length > QUERY_LIMIT) + DesignCatalog.Boundary.fail('INVALID_ARGUMENT', 'Normalized query is too long'); + const limit = raw.limit === undefined ? 6 : raw.limit; + if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 30) { + return DesignCatalog.Boundary.fail('INVALID_ARGUMENT', 'limit must be an integer from 1 to 30'); + } + let domains = [...DesignCatalog.DOMAINS]; + if (raw.domains !== undefined) { + if (!Array.isArray(raw.domains) || !raw.domains.length || raw.domains.length > DesignCatalog.DOMAINS.length) { + return DesignCatalog.Boundary.fail('INVALID_ARGUMENT', 'domains must contain between 1 and 6 domains'); + } + if (Reflect.ownKeys(raw.domains).length !== raw.domains.length + 1) { + return DesignCatalog.Boundary.fail('INVALID_ARGUMENT', 'domains must be a dense JSON array'); + } + const values = []; + for (let index = 0; index < raw.domains.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(raw.domains, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) { + return DesignCatalog.Boundary.fail('INVALID_ARGUMENT', 'domains must contain data values'); + } + values.push(DesignCatalog.Boundary.domain(descriptor.value, 'INVALID_ARGUMENT')); + } + domains = [...new Set(values)]; + } + domains.sort(compare); + if (raw.cursor !== undefined && (typeof raw.cursor !== 'string' || !raw.cursor.length || raw.cursor.length > CURSOR_LIMIT)) { + return DesignCatalog.Boundary.fail('INVALID_CURSOR', 'cursor must be a nonempty bounded string'); + } + return { query: normalized, domains, limit, ...(raw.cursor === undefined ? {} : { cursor: raw.cursor }) }; + } + // Canonical JSON stays compact and escapes lone surrogates without platform globals. + // This continuation token provides no authentication or tamper-proof signature. + function cursorFor(snapshot, context, offset) { + return 'dc1.' + JSON.stringify([snapshot.contractVersion, snapshot.version, context.query, context.domains, offset]); + } + function cursorOffset(snapshot, context, total) { + if (!context.cursor) + return 0; + const token = context.cursor; + if (!token.startsWith('dc1.')) { + return DesignCatalog.Boundary.fail('INVALID_CURSOR', 'Malformed cursor'); + } + let tuple; + try { + tuple = JSON.parse(token.slice(4)); + } + catch { + return DesignCatalog.Boundary.fail('INVALID_CURSOR', 'Malformed cursor payload'); + } + if (!Array.isArray(tuple) || tuple.length !== 5 || typeof tuple[0] !== 'string' || typeof tuple[1] !== 'string' + || typeof tuple[2] !== 'string' || !Array.isArray(tuple[3]) || !tuple[3].every(value => typeof value === 'string') + || typeof tuple[4] !== 'number' || !Number.isSafeInteger(tuple[4]) || tuple[4] < 1) { + return DesignCatalog.Boundary.fail('INVALID_CURSOR', 'Invalid cursor fields'); + } + if (tuple[0] !== snapshot.contractVersion || tuple[1] !== snapshot.version) { + return DesignCatalog.Boundary.fail('STALE_CURSOR', 'Cursor belongs to another snapshot version'); + } + if (token !== cursorFor(snapshot, context, tuple[4]) || tuple[4] >= total) { + return DesignCatalog.Boundary.fail('INVALID_CURSOR', 'Cursor does not match this search or result range'); + } + return tuple[4]; + } + function texts(entry, keys) { + const values = []; + for (const key of keys) { + const value = entry[key]; + if (typeof value === 'string') + values.push(normalize(value)); + else if (Array.isArray(value)) { + for (const item of value) + if (typeof item === 'string') + values.push(normalize(item)); + } + } + return values; + } + function score(entry, query) { + if (!query) + return 0; + const names = texts(entry, ['id', 'name', 'nameKr', 'alsoCalled', 'aliases']); + if (names.includes(query)) + return 10000; + const keywords = texts(entry, ['keywords', 'family', 'category']); + const prose = texts(entry, ['tagline', 'summary', 'summaryEn', 'description', 'descriptionEn', 'taglineEn', 'bestFor', 'useCases']); + let total = 0; + for (const token of new Set(query.split(' '))) { + if (names.some(value => value.includes(token))) + total += 16; + else if (keywords.some(value => value.includes(token))) + total += 8; + else if (prose.some(value => value.includes(token))) + total += 1; + else + return -1; + } + return total; + } + function search(snapshot, args = {}) { + const context = parseSearch(args); + const ranked = []; + for (const domain of context.domains) { + for (const entry of snapshot.catalogs[domain]) { + if (entry.kind === 'anti-pattern') + continue; + const weight = score(entry, context.query); + if (weight >= 0) + ranked.push({ domain, entry, score: weight, key: `${domain}/${String(entry.id)}` }); + } + } + ranked.sort((a, b) => b.score - a.score || compare(a.key, b.key)); + const offset = cursorOffset(snapshot, context, ranked.length); + const items = Object.freeze(ranked.slice(offset, offset + context.limit).map(row => DesignCatalog.summarize(row.domain, row.entry))); + const end = offset + items.length; + return Object.freeze({ + version: snapshot.version, total: ranked.length, items, + nextCursor: end < ranked.length ? cursorFor(snapshot, context, end) : null, + complete: end === ranked.length + }); + } + DesignCatalog.search = search; +})(DesignCatalog || (DesignCatalog = {})); diff --git a/assets/js/design-views.js b/assets/js/design-views.js new file mode 100644 index 0000000..2d563f4 --- /dev/null +++ b/assets/js/design-views.js @@ -0,0 +1,76 @@ +"use strict"; +/** Shared projections: authored implementation guidance and complete code, never image prompts. */ +var DesignCatalog; +(function (DesignCatalog) { + const GUIDE_FIELDS = { + color: ['palette', 'contrast', 'darkVariant', 'tone', 'useCases'], + typography: ['heading', 'body', 'mono', 'scale', 'supportsKorean', 'webfonts', 'specimen'], + layout: ['breakpoints', 'composition', 'responsive', 'bestFor', 'avoidWhen'], + motion: ['easing', 'duration', 'trigger', 'intensity', 'reducedMotion'] + }; + function guideData(snapshot, ref, entry) { + if (ref.domain === 'isms' || ref.domain === 'effects') { + const map = ref.domain === 'isms' ? snapshot.guides : snapshot.effectDocs; + if (!Object.prototype.hasOwnProperty.call(map, ref.id)) { + return DesignCatalog.Boundary.fail('VIEW_UNAVAILABLE', `No guide for ${ref.domain}/${ref.id}`); + } + return map[ref.id]; + } + const fields = GUIDE_FIELDS[ref.domain]; + const result = {}; + let hasImplementation = false; + for (const key of [...fields, 'sources', 'reviewedOn', 'relatedIsms', 'relatedEffects']) { + if (Object.prototype.hasOwnProperty.call(entry, key)) { + result[key] = entry[key]; + if (fields.includes(key)) + hasImplementation = true; + } + } + if (!hasImplementation) + return DesignCatalog.Boundary.fail('VIEW_UNAVAILABLE', `No guide for ${ref.domain}/${ref.id}`); + return Object.freeze(result); + } + function codeData(snapshot, ref, entry) { + let data; + if (ref.domain === 'effects') { + const snippets = snapshot.effectSnippets.snippets; + if (Object.prototype.hasOwnProperty.call(snippets, ref.id)) + data = snippets[ref.id]; + } + else if (ref.domain === 'layout' || ref.domain === 'motion') + data = entry.snippet; + if (data === undefined) + return DesignCatalog.Boundary.fail('VIEW_UNAVAILABLE', `No code for ${ref.domain}/${ref.id}`); + return DesignCatalog.Boundary.record(data, 'code', 'INVALID_SNAPSHOT'); + } + function get(snapshot, args) { + const raw = DesignCatalog.Boundary.record(args, 'Get arguments', 'INVALID_ARGUMENT'); + DesignCatalog.Boundary.keys(raw, ['domain', 'id', 'view'], 'INVALID_ARGUMENT'); + const ref = Object.freeze({ + domain: DesignCatalog.Boundary.domain(raw.domain, 'INVALID_ARGUMENT'), + id: DesignCatalog.Boundary.id(raw.id, 'INVALID_ARGUMENT') + }); + const view = raw.view === undefined ? 'summary' : raw.view; + if (view !== 'summary' && view !== 'guide' && view !== 'code' && view !== 'full') { + return DesignCatalog.Boundary.fail('INVALID_ARGUMENT', 'Unknown catalog view'); + } + const entry = DesignCatalog.resolve(snapshot, ref); + let data; + switch (view) { + case 'summary': + data = DesignCatalog.summarize(ref.domain, entry); + break; + case 'full': + data = entry; + break; + case 'guide': + data = guideData(snapshot, ref, entry); + break; + case 'code': + data = codeData(snapshot, ref, entry); + break; + } + return Object.freeze({ version: snapshot.version, ref, view, data }); + } + DesignCatalog.get = get; +})(DesignCatalog || (DesignCatalog = {})); diff --git a/devlog/_plan/260922_design_recipe_mcp/000_plan.md b/devlog/_plan/260922_design_recipe_mcp/000_plan.md index 3ea4c11..d315e37 100644 --- a/devlog/_plan/260922_design_recipe_mcp/000_plan.md +++ b/devlog/_plan/260922_design_recipe_mcp/000_plan.md @@ -73,3 +73,13 @@ Next direction: wp1 consumes 010_catalog_recipes.md to implement the shared core ## Owner steering: dev integration target The owner directed development to dev after PR #10 was opened. No remote dev branch existed, so dev was created at the unchanged origin/main baseline 28d6611. PR #10 now targets dev; subsequent ordinary PRs keep their manual dependency bases, with dev as the integration root. No main update or deployment is part of this change. + +## Owner steering: images and data-oriented tools + +The owner explicitly permits ima2 image generation when useful (inspect ima2 --help and ping before use). Approved existing image bytes remain unchanged unless a separately recorded replacement is justified. The owner also requests Lisp/Unix philosophy in MCP: small composable read primitives, first-class operation metadata/data, transparent composition, and pipe-friendly text boundaries. wp2 will provide an NDJSON CLI over the same operation registry; this adds no general-purpose language/runtime or new dependency. The exact wp2 amendment is re-audited at its P boundary; wp1 core interfaces are unchanged. + +## wp1 conclusion and next direction + +The shared pure catalog/recipe core, Node adapter, canonical source metadata and three authored recipes are implemented and independently reviewed. Eighteen persistent tests include384 bilingual combinations and complete pagination; generated parity reports29 matching outputs. Review-found accessor defects were reproduced and repaired without weakened assertions. Existing catalog/image/navigation contracts remain intact. Detailed evidence is in011_phase1_progress.md. + +Continue with020_codemode_mcp.md. At wp2 P, apply the owner's data-oriented Lisp/Unix refinement to a small common operation registry and NDJSON CLI. The selected recipe is an inspectable data value; no automatic page generator or hostile-code sandbox is claimed. diff --git a/devlog/_plan/260922_design_recipe_mcp/011_phase1_progress.md b/devlog/_plan/260922_design_recipe_mcp/011_phase1_progress.md new file mode 100644 index 0000000..4d35c45 --- /dev/null +++ b/devlog/_plan/260922_design_recipe_mcp/011_phase1_progress.md @@ -0,0 +1,49 @@ +# Phase 1 revalidation + +Previous D: the docs-only roadmap and accurate attribution are delivered in PR #10; next direction is the shared catalog and three recipe core, preserving Finder math. + +At wp1 P, production sources remain unchanged from the audited origin/main baseline. The source/license and D1-D8 design decisions still apply. Revalidated 010 against current package/build and canonical JSON. The same master-plan proposal/reflection remains applicable; no new architecture decision is introduced. + +Implementation split: catalog worker owns src/design-contracts.ts, src/design-catalog.ts and, if required to keep each file below 500 lines, src/design-search.ts/src/design-views.ts. Recipe worker owns src/design-recipes.ts and assets/data/recipes.json. Main owns scripts/design-core-loader.mjs, tests, docs, package scripts and generated build. Exact allowed auxiliary files are now recorded before dispatch; the loader allowlist must include only these pure generated files. + +New optional fields and stricter raw-data checks must tolerate real existing catalog differences (ISM summary uses tagline/description; other domains use summary). Core projection preserves complete source snippets and metadata; no prompts are fabricated. Future tests named in the plan are implemented and run in this phase, not retroactively claimed as existing checks. + +The same design consultant re-read 010/011 at parent26afefc and returned ALIGNED: optional search/view file splits preserve D1-D8 and the public contract. + +Independent wp1 A revalidation returned PASS, no API/data blockers. Owner's dev target amendment is resolved: remote dev created at28d6611, PR10 retargeted, foundation f576377 records it, and this unpublished child fast-forwarded to that parent. + +Worker handoff types: DesignCatalog exports Domain, Ref, Entry, Summary, SourceSnapshot and Snapshot; functions create/resolve/get/search/summarize and stable coded errors. Snapshot includes version, contractVersion, catalogs and the four auxiliary raw payloads; recipes own parse/compose/formatBrief. Compose slots carry compact summaries plus stable refs; full guide/code views stay separate so default tool output remains bounded. + +## B integration evidence + +Core and recipe workers delivered the declared pure namespaces and data; main added the shared Node loader, independent node:test contracts and verify integration. First 14 runtime contracts passed. Edge expansion caught a real sparse-domain-array boundary error (TypeError without a code); Array.from now validates holes as undefined and rejects them with INVALID_ARGUMENT. A separate English check was corrected to compare against authored host-side JSON rather than a cross-realm Array prototype. + +Cursor text was simplified from four-character-per-code-unit hex to a canonical JSON tuple with dc1 prefix, preserving exact query/filter/version/offset checks while reducing output overhead. This is data-oriented text, not an authentication token. New tests cover every independently enumerated source-version input, accessor refusal, unsafe recipe source URLs and English discovery. + +## Independent C review and repair + +Initial C review reproduced a Medium boundary issue: inherited query getters and indexed domains getters could run. Main added both regressions and observed the inherited case fail before the fix. Boundary.record now validates prototype descriptors and detaches own data into frozen null-prototype records; catalog clones use null prototypes; domain arrays are inspected by descriptors before values are read. + +Re-review returned PASS with no findings. Fresh core suite:18 passed,0 failed; generated parity:29 outputs match. Persistent tests include all384 bilingual recipe alternatives, three Node/browser-realm compositions, changing page sizes, exact error codes and zero getter invocations. No original assertion or source-quality threshold was weakened. + +Measured compact search (empty query,limit3):1351 UTF-8 JSON bytes, cursor154 bytes; prior hex cursor produced1783/604 bytes. Ten canonical source files total1369811 bytes. No compression ratio or latency claim is inferred beyond these exact observations. + +## Final ownership review + +DesignCatalog now owns CONTRACT_VERSION, DOMAINS and SOURCE_FILES once. Node reads their primitive metadata from the allowlisted generated contracts file in a fresh VM; the future browser adapter will use those same constants. Independent comparison confirmed the previous and current snapshot hashes are identical, with29 generated outputs matching and18 core tests passing. Final review verdict: PASS, no findings. + +## C evidence matrix + +| Surface | Activation | Observed evidence | +| --- | --- | --- | +| Input boundary | malformed fields, sparse arrays, own/inherited/indexed getters | coded refusal and zero getter calls; regression observed red then green | +| Search | KO/EN/alias/NFKC, empty matches, variable-size continuation | expected bottom-sheet identity,232 unique references, truthful completion | +| Versions | independently enumerate/mutate all10 source files | each changes snapshot identity; stale cursors rejected | +| Views | all domains and unavailable code views | complete authored code/guidance, exact VIEW_UNAVAILABLE | +| Recipes |384 bilingual allowed combinations, disallowed/prototype/source inputs | exact selected refs, all constraints/checks retained, invalid selection rejected | +| Runtime parity | pure generated core in separate realms | three compositions and search match; no DOM globals | +| Static deploy | pages:stage |7 HTML,331 PNG/WebP pairs,0 forbidden,751 files+manifest | + +Full verify also retains the existing106 quality-contract tests, image hashes, Finder144-combination oracle and seven-page navigation. This phase mounts no new UI, so rendered workflow proof belongs to wp3. No dependency versions, original data entries, images or their ledgers changed. + +Next: wp2 implements the small operation registry, Code Mode transport and the owner-requested NDJSON CLI over this core. Its P must incorporate the Lisp/Unix refinement and avoid returning duplicate composition+brief payloads that exceed the default response budget (measured compositions5280–6049B, briefs3451–4114B). diff --git a/devlog/_plan/260922_design_recipe_mcp/020_codemode_mcp.md b/devlog/_plan/260922_design_recipe_mcp/020_codemode_mcp.md index 6b24302..05f3af0 100644 --- a/devlog/_plan/260922_design_recipe_mcp/020_codemode_mcp.md +++ b/devlog/_plan/260922_design_recipe_mcp/020_codemode_mcp.md @@ -52,3 +52,11 @@ Build then full verify/stage; prove operational scripts absent from .pages. Publ - `AppLanguage.render({lang,searchPlaceholder,toggleLabel,footerTitle,footerGenerator}):void` owns DOM work only. RecipeChooser.mount returns setLang/dispose. Main mounts after ISM data readiness, passes openModal callback, updates language and disposes before remount. No MutationObserver or assumed hashchange handler; do not dispose on pagehide without bfcache restoration. The core tests and MCP tests must cover recipe discovery, all view availability branches, snapshot changes from each served source class, page shrinking followed by lossless continuation, nested-result rejection, and cross-realm constructor/prototype refusal without injecting host functions. Existing app.ts remains <=1050 lines through the declared language-owner extraction. + +## Owner-directed wp2 amendment: Lisp/Unix principles + +Add scripts/design-query.mjs as a thin NDJSON CLI and scripts/mcp/cli.test.mjs for CLI/MCP semantic equivalence. This is a data-oriented invocation surface: each line is `{"op":"design.search","args":{"query":"bottom sheet"}}`; reply is one complete JSON value per line. Errors carry stable codes, diagnostics use stderr, process exits nonzero if any input failed, and a failed line does not discard later valid lines. No banners or implicit filesystem/network actions. CLI also supports a deterministic --help showing registry operations. The same registry owns metadata, argument validation and dispatch for both CLI and guest wrappers. + +Expose operation specs as plain data through actions.find/describe. Core operations are small, immutable transformations; JS map/filter/reduce supply user composition in execute_code. compose resolves authored references and returns selections/constraints/brief; it does not hide generated implementation or inferred verification. Detailed docs/code remain separate get views. Keep field projection explicit and preserve pagination metadata when returning transformed pages. Reject malformed/unknown operations instead of silently guessing intent. + +Planned package script: design:query -> node scripts/design-query.mjs. Test actual stdin/stdout, multibyte complete lines, invalid line then recovery, stderr separation, nonzero failed-stream exit, and semantically equal CLI/MCP operations on one snapshot. This amendment does not alter the <=2000-byte MCP description or final-wire response budget. diff --git a/docs/ATTRIBUTION.md b/docs/ATTRIBUTION.md index 6f4095e..2f7597e 100644 --- a/docs/ATTRIBUTION.md +++ b/docs/ATTRIBUTION.md @@ -14,7 +14,7 @@ StyleGallery code uses MIT; its documentation uses [Creative Commons Attribution Referenced works: [Primitive To Recipe Matrix](https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/recipes/primitive-to-recipe-matrix.md), [Layout contract](https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/layout/index.md), and [Webpage Generation Workflow](https://github.com/changeroa/StyleGallery/blob/a89117593cbd1e7c5642f2e512a6a7bcf5e4ec0d/guides/webpage-generation-workflow.md). -Changes: the planned adaptation selects the essential/helper/substitutable role model and expresses it as bilingual recipes referencing this repository's own catalog IDs. It omits the upstream governance workflow and adapts constraints/checks for this site's existing patterns. Imported or adapted recipe prose must keep its source and license fields. No claim of endorsement or universal verification is made. +Changes: the adaptation selects the essential/helper/substitutable role model and expresses it as bilingual recipes referencing this repository's own catalog IDs. It omits the upstream governance workflow and adapts constraints/checks for this site's existing patterns. Imported or adapted recipe prose must keep its source and license fields. No claim of endorsement or universal verification is made. ## Research-only browser reference diff --git a/package.json b/package.json index cdac92d..9d2b872 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,13 @@ "qa:local:preserve:start": "node scripts/final-preservation.mjs start", "qa:local:preserve:final": "node scripts/final-preservation.mjs final", "verify:local-final": "node scripts/verify-final-qa.mjs", - "verify": "npm run typecheck && npm run verify:generated && npm run verify:nav && npm run verify:isms && npm run verify:effects && npm run verify:snippets && npm run verify:finder && npm run verify:content && npm run verify:catalog && npm run verify:assets && npm run verify:quality-contracts && npm run verify:image-quality && npm run verify:lines", + "verify": "npm run typecheck && npm run verify:generated && npm run test:design-core && npm run verify:nav && npm run verify:isms && npm run verify:effects && npm run verify:snippets && npm run verify:finder && npm run verify:content && npm run verify:catalog && npm run verify:assets && npm run verify:quality-contracts && npm run verify:image-quality && npm run verify:lines", "verify:snippets": "node scripts/verify-snippets.mjs", "verify:finder": "node scripts/verify-finder.mjs", "pages:stage": "node scripts/stage-pages.mjs", "serve": "node scripts/serve-static.mjs", - "verify:quality-contracts": "node --test scripts/editorial-revisions.test.mjs scripts/image-generation-profiles.test.mjs scripts/image-final-history.test.mjs" + "verify:quality-contracts": "node --test scripts/editorial-revisions.test.mjs scripts/image-generation-profiles.test.mjs scripts/image-final-history.test.mjs", + "test:design-core": "node --test scripts/design-core.test.mjs" }, "devDependencies": { "sharp": "^0.35.3", diff --git a/scripts/design-core-loader.mjs b/scripts/design-core-loader.mjs new file mode 100644 index 0000000..f0c1d2e --- /dev/null +++ b/scripts/design-core-loader.mjs @@ -0,0 +1,64 @@ +// Node adapter for the same pure classic-script core served by the site. +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; + +const CORE_NAMES = ['design-contracts', 'design-catalog', 'design-search', 'design-views', 'design-recipes']; +const OPTIONAL_CORE = new Set(['design-search', 'design-views']); +export const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +const metadataContext = vm.createContext(Object.create(null), { codeGeneration: { strings: false, wasm: false } }); +const metadataSource = readFileSync(join(REPOSITORY_ROOT, 'assets/js/design-contracts.js'), 'utf8'); +const metadata = JSON.parse(new vm.Script(metadataSource + '\nJSON.stringify({version:DesignCatalog.CONTRACT_VERSION,domains:DesignCatalog.DOMAINS,files:DesignCatalog.SOURCE_FILES})') + .runInContext(metadataContext, { timeout: 1000 })); +export const CONTRACT_VERSION = metadata.version; +export const DOMAINS = Object.freeze(metadata.domains); +export const SOURCE_FILES = Object.freeze(metadata.files); + +/** Version exact source bytes, including auxiliary documents and code snippets. */ +export function snapshotFromFiles(files) { + const pairs = SOURCE_FILES.map(path => { + if (!files.has(path)) throw new Error(`Missing snapshot source: ${path}`); + return [path, sha256(files.get(path))]; + }); + const parse = path => JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(files.get(path))); + return { + contractVersion: CONTRACT_VERSION, + version: sha256(JSON.stringify([CONTRACT_VERSION, ...pairs])), + catalogs: Object.fromEntries(DOMAINS.map(domain => [domain, parse(`assets/data/${domain}.json`)])), + recipes: parse('assets/data/recipes.json'), + guides: parse('assets/data/dev-guides.json'), + effectDocs: parse('assets/data/effects-docs.json'), + effectSnippets: parse('assets/data/effects-snippets.json') + }; +} + +export function readSourceSnapshot(root = REPOSITORY_ROOT) { + return snapshotFromFiles(new Map(SOURCE_FILES.map(path => [path, readFileSync(join(root, path))]))); +} + +/** Only fixed, trusted build outputs are evaluated; callers cannot select a script. */ +export function readCoreSources(root = REPOSITORY_ROOT) { + return CORE_NAMES.flatMap(name => { + const file = join(root, 'assets/js', `${name}.js`); + const source = join(root, 'src', `${name}.ts`); + if (OPTIONAL_CORE.has(name) && !existsSync(file) && !existsSync(source)) return []; + return [{ name, source: readFileSync(file, 'utf8') }]; + }); +} + +export function loadDesignCore({ root = REPOSITORY_ROOT, source = readSourceSnapshot(root) } = {}) { + const coreSources = readCoreSources(root); + const context = vm.createContext(Object.create(null), { codeGeneration: { strings: false, wasm: false } }); + for (const file of coreSources) new vm.Script(file.source, { filename: `${file.name}.js` }).runInContext(context, { timeout: 1000 }); + // Parse in the destination realm, rather than injecting host arrays/functions. + const input = new vm.Script(`JSON.parse(${JSON.stringify(JSON.stringify(source))})`).runInContext(context, { timeout: 1000 }); + const catalog = context.DesignCatalog; + const recipes = context.DesignRecipes; + if (!catalog || !recipes) throw new Error('Generated design core is missing; run npm run build'); + const snapshot = catalog.create(input); + const recipeList = recipes.parse(input.recipes, snapshot); + return { catalog, recipes, snapshot, recipeList, source, coreSources }; +} diff --git a/scripts/design-core.test.mjs b/scripts/design-core.test.mjs new file mode 100644 index 0000000..ebb8226 --- /dev/null +++ b/scripts/design-core.test.mjs @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { DOMAINS, SOURCE_FILES, REPOSITORY_ROOT, loadDesignCore, readSourceSnapshot, snapshotFromFiles } from './design-core-loader.mjs'; + +const core = loadDesignCore(); +const { catalog, recipes, snapshot, recipeList } = core; +const plain = value => JSON.parse(JSON.stringify(value)); +const freshSource = () => plain(core.source); +const keys = page => page.items.map(item => `${item.ref.domain}/${item.ref.id}`); +const rejects = (fn, code) => assert.throws(fn, error => code ? error.code === code : typeof error.code === 'string' && error.code.length > 0); + +test('invalid snapshot boundaries fail with coded errors', () => { + for (const input of [null, [], {}, { ...freshSource(), version: '' }]) rejects(() => catalog.create(input)); + const duplicated = freshSource(); + duplicated.catalogs.isms.push(duplicated.catalogs.isms[0]); + rejects(() => catalog.create(duplicated)); + const malformed = freshSource(); + malformed.catalogs.effects[0].id = '../escape'; + rejects(() => catalog.create(malformed)); +}); + +test('query boundaries reject malformed types, limits and unknown keys', () => { + for (const input of [null, { query: 1 }, { query: '', limit: 0 }, { query: '', limit: 31 }, + { query: '', limit: 1.5 }, { query: '', domains: ['unknown'] }, { domains: Array(1) }, + { query: '', path: '/tmp' }]) { + rejects(() => catalog.search(snapshot, input)); + } + for (const ref of [{ domain: 'unknown', id: 'minimalism' }, { domain: 'isms', id: 'missing' }, + { domain: 'isms', id: '__proto__' }, { domain: 'isms', id: 'minimalism', view: 'whatever' }]) { + rejects(() => catalog.get(snapshot, ref)); + } +}); + +test('accessor inputs are refused without executing the getter', () => { + let calls = 0; + const args = Object.defineProperty({}, 'query', { enumerable: true, get() { calls++; return 'minimalism'; } }); + rejects(() => catalog.search(snapshot, args)); + const source = freshSource(); + Object.defineProperty(source.catalogs.isms[0], 'name', { enumerable: true, get() { calls++; return 'bad'; } }); + rejects(() => catalog.create(source)); + const inherited = Object.create(Object.defineProperty(Object.create(null), 'query', { + get() { calls++; return 'bottom-sheet'; } + })); + rejects(() => catalog.search(snapshot, inherited), 'INVALID_ARGUMENT'); + const domains = []; + Object.defineProperty(domains, '0', { enumerable: true, get() { calls++; throw new Error('nested-getter-sentinel'); } }); + rejects(() => catalog.search(snapshot, { domains }), 'INVALID_ARGUMENT'); + assert.equal(calls, 0); +}); + +test('unknown, changed-query and stale cursors fail rather than restart silently', () => { + const first = catalog.search(snapshot, { query: '', limit: 2 }); + assert.ok(first.nextCursor); + rejects(() => catalog.search(snapshot, { query: '', cursor: 'not-a-cursor' }), 'INVALID_CURSOR'); + rejects(() => catalog.search(snapshot, { query: 'different', cursor: first.nextCursor }), 'INVALID_CURSOR'); + rejects(() => catalog.search(snapshot, { query: '', cursor: first.nextCursor + ' ' }), 'INVALID_CURSOR'); + const changed = catalog.create({ ...freshSource(), version: 'different-snapshot' }); + rejects(() => catalog.search(changed, { query: '', cursor: first.nextCursor }), 'STALE_CURSOR'); +}); + +test('anti-patterns are explicit-lookup-only', () => { + const result = catalog.get(snapshot, { domain: 'isms', id: 'ai-slop', view: 'full' }); + assert.equal(result.data.kind, 'anti-pattern'); + assert.equal(catalog.search(snapshot, { query: 'ai-slop' }).items.length, 0); +}); + +test('snapshots and returned entries cannot be mutated by a consumer', () => { + const source = freshSource(); + const isolated = catalog.create(source); + source.catalogs.isms[0].name = 'changed input'; + const entry = catalog.resolve(isolated, { domain: 'isms', id: 'minimalism' }); + assert.equal(entry.name, 'Minimalism'); + assert.ok(Object.isFrozen(entry)); + assert.throws(() => { entry.name = 'changed output'; }, TypeError); +}); + +test('real catalog has six domains and the current 233-entry source corpus', () => { + assert.deepEqual(DOMAINS.map(domain => snapshot.catalogs[domain].length), [49, 94, 25, 20, 25, 20]); + assert.equal(catalog.search(snapshot, { query: '' }).total, 232); + assert.equal(snapshot.version, core.source.version); +}); + +test('Korean alias, English name and exact ID resolve the intended effect', () => { + for (const query of ['바텀 시트', '아래 팝업', 'Bottom Sheet', 'bottom-sheet']) { + const page = catalog.search(snapshot, { query, domains: ['effects'] }); + assert.equal(page.items[0].ref.id, 'bottom-sheet', query); + } + const nfd = catalog.search(snapshot, { query: '바텀 시트'.normalize('NFD'), domains: ['effects'] }); + assert.equal(nfd.items[0].ref.id, 'bottom-sheet'); +}); + +test('empty matches and normalized queries report truthful deterministic results', () => { + assert.deepEqual(keys(catalog.search(snapshot, { query: ' MINImaLISM ' })), keys(catalog.search(snapshot, { query: 'minimalism' }))); + const none = catalog.search(snapshot, { query: 'not-a-real-design-9z9z9z' }); + assert.equal(none.total, 0); + assert.equal(none.nextCursor, null); + assert.equal(none.complete, true); + assert.equal(none.items.length, 0); +}); + +test('pagination visits every discoverable reference exactly once', () => { + const refs = []; + let cursor; + let complete = false; + for (let step = 0; step < 100 && !complete; step++) { + const page = catalog.search(snapshot, { query: '', limit: step % 2 ? 3 : 7, ...(cursor ? { cursor } : {}) }); + refs.push(...keys(page)); + complete = page.complete; + cursor = page.nextCursor; + assert.equal(complete, cursor === null); + } + assert.equal(complete, true); + assert.equal(refs.length, 232); + assert.equal(new Set(refs).size, 232); +}); + +test('views preserve complete source code, guidance and domain-specific fields', () => { + const effects = catalog.get(snapshot, { domain: 'effects', id: 'bottom-sheet', view: 'code' }); + assert.deepEqual(plain(effects.data), core.source.effectSnippets.snippets['bottom-sheet']); + const guide = catalog.get(snapshot, { domain: 'isms', id: 'minimalism', view: 'guide' }); + assert.deepEqual(plain(guide.data), core.source.guides.minimalism); + const history = catalog.get(snapshot, { domain: 'effects', id: 'bottom-sheet', view: 'guide' }); + assert.deepEqual(plain(history.data), core.source.effectDocs['bottom-sheet']); + for (const domain of ['layout', 'motion']) { + const entry = core.source.catalogs[domain][0]; + assert.deepEqual(plain(catalog.get(snapshot, { domain, id: entry.id, view: 'code' }).data), entry.snippet); + } + for (const domain of ['isms', 'color', 'typography']) { + const entry = core.source.catalogs[domain][0]; + rejects(() => catalog.get(snapshot, { domain, id: entry.id, view: 'code' }), 'VIEW_UNAVAILABLE'); + } + for (const domain of ['color', 'typography', 'layout', 'motion']) { + const entry = core.source.catalogs[domain][0]; + const data = catalog.get(snapshot, { domain, id: entry.id, view: 'guide' }).data; + assert.equal('guide' in data, false, 'implementation guide must not return image-generation guide'); + assert.ok(Object.keys(data).length > 0); + } +}); + +test('each served source file contributes to the snapshot version', () => { + assert.deepEqual([...SOURCE_FILES], [ + 'color', 'dev-guides', 'effects', 'effects-docs', 'effects-snippets', + 'isms', 'layout', 'motion', 'recipes', 'typography' + ].map(name => `assets/data/${name}.json`).sort()); + const files = new Map(SOURCE_FILES.map(path => [path, readFileSync(join(REPOSITORY_ROOT, path))])); + const original = snapshotFromFiles(files).version; + for (const path of SOURCE_FILES) { + const changed = new Map(files); + changed.set(path, Buffer.concat([files.get(path), Buffer.from('\n')])); + assert.notEqual(snapshotFromFiles(changed).version, original, path); + } + assert.equal(readSourceSnapshot().version, original); + assert.equal(snapshotFromFiles(new Map([...files].reverse())).version, original); +}); + +test('recipe source URLs, sparse slots and unsafe dictionary keys fail closed', () => { + const source = freshSource().recipes; + source.recipes[0].sources[0].url = 'javascript:alert(1)'; + rejects(() => recipes.parse(source, snapshot)); + const sparse = freshSource().recipes; + delete sparse.recipes[0].slots[0]; + rejects(() => recipes.parse(sparse, snapshot)); + const options = JSON.parse('{"recipeId":"product-landing","selections":{"__proto__":{"domain":"isms","id":"minimalism"}}}'); + rejects(() => recipes.compose(snapshot, recipeList, options)); +}); + +test('recipes reject unknown selections, languages, duplicates and anti-pattern refs', () => { + for (const input of [{ recipeId: 'missing' }, { recipeId: 'product-landing', lang: 'fr' }, + { recipeId: 'product-landing', selections: { unknown: { domain: 'isms', id: 'minimalism' } } }, + { recipeId: 'product-landing', unexpected: true }]) rejects(() => recipes.compose(snapshot, recipeList, input)); + const duplicate = freshSource().recipes; + duplicate.recipes.push(duplicate.recipes[0]); + rejects(() => recipes.parse(duplicate, snapshot)); + const bad = freshSource().recipes; + bad.recipes[0].slots[0].default = { domain: 'isms', id: 'ai-slop' }; + rejects(() => recipes.parse(bad, snapshot)); + const wrongDomain = freshSource().recipes; + wrongDomain.recipes[0].slots[0].alternatives.push({ domain: 'effects', id: 'bottom-sheet' }); + rejects(() => recipes.parse(wrongDomain, snapshot)); + rejects(() => recipes.compose(snapshot, recipeList, { recipeId: 'product-landing', selections: { + effect: { domain: 'effects', id: 'bottom-sheet' } + } }), 'INVALID_SELECTION'); +}); + +test('three recipes resolve their defaults and only declared alternatives', () => { + for (const recipe of core.source.recipes.recipes) { + const composition = recipes.compose(snapshot, recipeList, { recipeId: recipe.id, lang: 'ko' }); + assert.equal(composition.recipeId, recipe.id); + assert.equal(composition.slots.length, recipe.slots.length); + assert.equal(composition.version, snapshot.version); + const slot = recipe.slots.find(value => value.alternatives.length); + const selected = recipes.compose(snapshot, recipeList, { recipeId: recipe.id, selections: { [slot.id]: slot.alternatives[0] } }); + assert.deepEqual(plain(selected.slots.find(value => value.id === slot.id).ref), slot.alternatives[0]); + const brief = recipes.formatBrief(composition, 'ko'); + assert.ok(brief.includes(recipe.title.ko)); + assert.ok(brief.includes(recipe.slots[0].default.id)); + assert.ok(brief.includes('https://github.com/changeroa/StyleGallery/')); + } +}); + +test('recipe discovery and English brief expose the authored contract without claiming execution', () => { + assert.deepEqual(plain(recipes.list(recipeList)).map(item => item.id), ['product-landing', 'editorial-reading', 'settings-workspace']); + const recipe = recipes.detail(recipeList, 'settings-workspace'); + assert.equal(recipe.slots.length, 6); + const composition = recipes.compose(snapshot, recipeList, { recipeId: recipe.id, lang: 'en' }); + assert.equal(composition.title, recipe.title.en); + const authored = core.source.recipes.recipes.find(item => item.id === recipe.id); + assert.deepEqual(plain(composition.checks), authored.checks.map(item => item.en)); + assert.ok(recipes.formatBrief(composition, 'en').includes(recipe.title.en)); +}); + +test('all declared slot combinations preserve selected references in both languages', () => { + let checked = 0; + for (const recipe of core.source.recipes.recipes) { + let combinations = [{}]; + for (const slot of recipe.slots) { + combinations = combinations.flatMap(previous => [slot.default, ...slot.alternatives] + .map(ref => ({ ...previous, [slot.id]: ref }))); + } + for (const selections of combinations) for (const lang of ['ko', 'en']) { + const composition = recipes.compose(snapshot, recipeList, { recipeId: recipe.id, selections, lang }); + assert.equal(composition.title, recipe.title[lang]); + for (const slot of composition.slots) assert.deepEqual(plain(slot.ref), selections[slot.id]); + checked++; + } + } + assert.equal(checked, 384); +}); + +test('a browser-like realm and the Node adapter run the same compiled core', () => { + const context = vm.createContext(Object.create(null)); + for (const file of core.coreSources) vm.runInContext(file.source, context); + vm.runInContext(`globalThis.source = JSON.parse(${JSON.stringify(JSON.stringify(core.source))});`, context); + const page = vm.runInContext('DesignCatalog.search(DesignCatalog.create(source), {query:"bottom sheet",domains:["effects"]})', context); + assert.deepEqual(plain(page), plain(catalog.search(snapshot, { query: 'bottom sheet', domains: ['effects'] }))); + for (const recipe of core.source.recipes.recipes) { + const args = { recipeId: recipe.id, lang: 'en' }; + const composition = vm.runInContext(`(() => { + const snapshot = DesignCatalog.create(source); + return DesignRecipes.compose(snapshot, DesignRecipes.parse(source.recipes, snapshot), ${JSON.stringify(args)}); + })()`, context); + assert.deepEqual(plain(composition), plain(recipes.compose(snapshot, recipeList, args))); + } + assert.equal(vm.runInContext('typeof document + "/" + typeof window + "/" + typeof require', context), 'undefined/undefined/undefined'); +}); diff --git a/src/design-catalog.ts b/src/design-catalog.ts new file mode 100644 index 0000000..6cfc60e --- /dev/null +++ b/src/design-catalog.ts @@ -0,0 +1,168 @@ +/** JSON boundary and immutable catalog lookup. No adapters, DOM, or IO. */ +namespace DesignCatalog { + const indexes = new WeakMap>>>(); + const SNAPSHOT_FIELDS = ['contractVersion', 'version', 'catalogs', 'recipes', 'guides', 'effectDocs', 'effectSnippets']; + + // Clone descriptors rather than stringify: reject lossy values and never invoke toJSON/getters. + function cloneJson(value: unknown, ancestors = new Set(), depth = 0): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value !== 'object' || depth > 64 || ancestors.has(value)) { + return Boundary.fail('INVALID_SNAPSHOT', 'Snapshot must be finite, acyclic JSON with depth at most 64'); + } + if (!Array.isArray(value)) Boundary.record(value, 'Snapshot record', 'INVALID_SNAPSHOT'); + ancestors.add(value); + const result: Record | unknown[] = Array.isArray(value) ? [] : Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (Array.isArray(value) && key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (typeof key !== 'string' || !descriptor || !descriptor.enumerable || !('value' in descriptor)) { + return Boundary.fail('INVALID_SNAPSHOT', 'Snapshot supports enumerable JSON data properties only'); + } + if (Array.isArray(value) && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length)) { + return Boundary.fail('INVALID_SNAPSHOT', 'Snapshot arrays cannot have named properties'); + } + Object.defineProperty(result, key, { + value: cloneJson(descriptor.value, ancestors, depth + 1), enumerable: true + }); + } + if (Array.isArray(value) && Object.keys(value).length !== value.length) { + return Boundary.fail('INVALID_SNAPSHOT', 'Snapshot arrays cannot contain holes'); + } + ancestors.delete(value); + return Object.freeze(result); + } + + function validateEntry(domain: Domain, value: unknown): Entry { + const entry = Boundary.record(value, domain, 'INVALID_SNAPSHOT'); + Boundary.id(entry.id, 'INVALID_SNAPSHOT'); + Boundary.text(entry.name, 'name', 'INVALID_SNAPSHOT', 500); + Boundary.text(entry.nameKr, 'nameKr', 'INVALID_SNAPSHOT', 500); + Boundary.text(domain === 'isms' ? entry.tagline : entry.summary, 'summary', 'INVALID_SNAPSHOT'); + if (domain === 'isms') Boundary.text(entry.description, 'description', 'INVALID_SNAPSHOT'); + if (entry.kind !== undefined && entry.kind !== 'style' && entry.kind !== 'anti-pattern') { + Boundary.fail('INVALID_SNAPSHOT', 'Unknown catalog kind'); + } + if (entry.kind === 'anti-pattern' && (domain !== 'isms' || entry.id !== 'ai-slop')) { + Boundary.fail('INVALID_SNAPSHOT', 'Only isms/ai-slop may be an anti-pattern'); + } + if (domain === 'isms' && entry.id === 'ai-slop' && entry.kind !== 'anti-pattern') { + Boundary.fail('INVALID_SNAPSHOT', 'isms/ai-slop must be an anti-pattern'); + } + for (const field of ['keywords', 'alsoCalled', 'aliases', 'bestFor', 'useCases']) { + if (entry[field] !== undefined) Boundary.strings(entry[field], field, 'INVALID_SNAPSHOT'); + } + for (const field of ['descriptionEn', 'summaryEn', 'taglineEn', 'family', 'category']) { + if (entry[field] !== undefined) Boundary.text(entry[field], field, 'INVALID_SNAPSHOT'); + } + return entry; + } + + function validateGuides(entry: Entry): void { + for (const key of ['layout', 'typography', 'color', 'motion']) { + const fields = Boundary.record(entry[key], key, 'INVALID_SNAPSHOT'); + for (const value of Object.values(fields)) Boundary.text(value, key, 'INVALID_SNAPSHOT'); + } + for (const key of ['dos', 'donts']) Boundary.strings(entry[key], key, 'INVALID_SNAPSHOT'); + if (entry.implementation !== undefined) { + const implementation = Boundary.record(entry.implementation, 'implementation', 'INVALID_SNAPSHOT'); + Boundary.text(implementation.summary, 'implementation.summary', 'INVALID_SNAPSHOT'); + for (const key of ['components', 'build', 'checks']) { + Boundary.strings(implementation[key], key, 'INVALID_SNAPSHOT'); + } + } + } + + function validateDocs(entry: Entry): void { + for (const key of ['background', 'history']) Boundary.text(entry[key], key, 'INVALID_SNAPSHOT'); + for (const key of ['useWhen', 'anatomy', 'misuse', 'implementationNotes']) { + Boundary.strings(entry[key], key, 'INVALID_SNAPSHOT'); + } + for (const [key, fields] of [['examples', ['context', 'description']], ['researchRefs', ['label', 'url']]] as const) { + const rows = entry[key]; + if (!Array.isArray(rows)) Boundary.fail('INVALID_SNAPSHOT', `${key} must be an array`); + for (const row of rows) { + const record = Boundary.record(row, key, 'INVALID_SNAPSHOT'); + for (const field of fields) Boundary.text(record[field], field, 'INVALID_SNAPSHOT'); + } + } + } + + function validateSnippet(entry: Entry): void { + for (const key of ['html', 'css']) Boundary.text(entry[key], key, 'INVALID_SNAPSHOT'); + // Expansion snippets have HTML/CSS only; preserve absence rather than fabricate metadata. + if (entry.reducedMotion !== undefined) Boundary.text(entry.reducedMotion, 'reducedMotion', 'INVALID_SNAPSHOT'); + if (entry.js !== undefined && typeof entry.js !== 'string') Boundary.fail('INVALID_SNAPSHOT', 'js must be a string'); + for (const key of ['supports', 'a11yNotes', 'sourceRefs']) { + if (entry[key] !== undefined) Boundary.strings(entry[key], key, 'INVALID_SNAPSHOT'); + } + } + + function validateAuxiliary(raw: unknown, ids: ReadonlyMap, validate: (entry: Entry) => void): void { + const entries = Boundary.record(raw, 'Auxiliary records', 'INVALID_SNAPSHOT'); + for (const [id, entry] of Object.entries(entries)) { + Boundary.id(id, 'INVALID_SNAPSHOT'); + if (!ids.has(id)) Boundary.fail('INVALID_SNAPSHOT', 'Auxiliary record has no catalog entry'); + validate(Boundary.record(entry, id, 'INVALID_SNAPSHOT')); + } + } + + export function create(source: SourceSnapshot): Snapshot { + const raw = Boundary.record(cloneJson(source), 'SourceSnapshot', 'INVALID_SNAPSHOT'); + Boundary.keys(raw, SNAPSHOT_FIELDS, 'INVALID_SNAPSHOT'); + Boundary.text(raw.contractVersion, 'contractVersion', 'INVALID_SNAPSHOT', 80); + Boundary.text(raw.version, 'version', 'INVALID_SNAPSHOT', 256); + const catalogs = Boundary.record(raw.catalogs, 'catalogs', 'INVALID_SNAPSHOT'); + Boundary.keys(catalogs, DOMAINS, 'INVALID_SNAPSHOT'); + const index = {} as Record>; + for (const domain of DOMAINS) { + const entries = catalogs[domain]; + if (!Array.isArray(entries)) Boundary.fail('INVALID_SNAPSHOT', `${domain} must be an array`); + const byId = new Map(); + for (const value of entries) { + const entry = validateEntry(domain, value); + const id = Boundary.id(entry.id, 'INVALID_SNAPSHOT'); + if (byId.has(id)) Boundary.fail('DUPLICATE_REF', `Duplicate ${domain}/${id}`); + byId.set(id, entry); + } + index[domain] = byId; + } + validateAuxiliary(raw.guides, index.isms, validateGuides); + validateAuxiliary(raw.effectDocs, index.effects, validateDocs); + const snippets = Boundary.record(raw.effectSnippets, 'effectSnippets', 'INVALID_SNAPSHOT'); + Boundary.text(snippets.version, 'effectSnippets.version', 'INVALID_SNAPSHOT'); + validateAuxiliary(snippets.snippets, index.effects, validateSnippet); + const recipes = Boundary.record(raw.recipes, 'recipes', 'INVALID_SNAPSHOT'); + if (recipes.version !== 1 || !Array.isArray(recipes.recipes)) { + Boundary.fail('INVALID_SNAPSHOT', 'Recipes require version 1 and a recipes array'); + } + // Recipe slot and cross-reference validation belongs to DesignRecipes.parse. + // All public data was cloned and recursively frozen before this boundary cast. + const snapshot = raw as unknown as Snapshot; + indexes.set(snapshot, index); + return snapshot; + } + + export function resolve(snapshot: Snapshot, ref: Ref): Entry { + const args = Boundary.record(ref, 'ref', 'INVALID_ARGUMENT'); + Boundary.keys(args, ['domain', 'id'], 'INVALID_ARGUMENT'); + const domain = Boundary.domain(args.domain, 'INVALID_ARGUMENT'); + const id = Boundary.id(args.id, 'INVALID_ARGUMENT'); + const index = indexes.get(snapshot); + if (!index) return Boundary.fail('INVALID_SNAPSHOT', 'Use create() to construct the snapshot'); + const entry = index[domain].get(id); + return entry ?? Boundary.fail('UNKNOWN_REFERENCE', `Unknown ${domain}/${id}`); + } + + export function summarize(domain: Domain, entry: Entry): Summary { + // Entries originate at create()/resolve(); summary owns only projection. + const summary: Summary = { + ref: Object.freeze({ domain, id: entry.id as string }), + name: entry.name as string, + nameKr: entry.nameKr as string, + summary: (domain === 'isms' ? entry.tagline || entry.description : entry.summary) as string, + ...(entry.kind === 'style' || entry.kind === 'anti-pattern' ? { kind: entry.kind } : {}) + }; + return Object.freeze(summary); + } +} diff --git a/src/design-contracts.ts b/src/design-contracts.ts new file mode 100644 index 0000000..290a912 --- /dev/null +++ b/src/design-contracts.ts @@ -0,0 +1,127 @@ +/** Pure catalog contracts. Load before design-catalog/search/views classic scripts. */ +namespace DesignCatalog { + export const CONTRACT_VERSION = 'design-catalog/1'; + export type Domain = 'isms' | 'effects' | 'color' | 'typography' | 'layout' | 'motion'; + export interface Ref { readonly domain: Domain; readonly id: string; } + export type Entry = Readonly>; + export interface Summary { + readonly ref: Ref; + readonly name: string; + readonly nameKr: string; + readonly summary: string; + readonly kind?: 'style' | 'anti-pattern'; + } + export type CatalogPayload = Readonly>; + export interface SourceSnapshot { + readonly contractVersion: string; + readonly version: string; + readonly catalogs: CatalogPayload; + readonly recipes: unknown; + readonly guides: unknown; + readonly effectDocs: unknown; + readonly effectSnippets: unknown; + } + export interface Snapshot extends SourceSnapshot { + readonly guides: Readonly>; + readonly effectDocs: Readonly>; + readonly effectSnippets: Entry & { readonly snippets: Readonly> }; + } + export type View = 'summary' | 'guide' | 'code' | 'full'; + export interface GetArgs extends Ref { readonly view?: View; } + export interface GetResult { + readonly version: string; + readonly ref: Ref; + readonly view: View; + readonly data: Entry | Summary; + } + export interface SearchArgs { + readonly query?: string; + readonly domains?: readonly Domain[]; + readonly limit?: number; + readonly cursor?: string; + } + export interface SearchPage { + readonly version: string; + readonly total: number; + readonly items: readonly Summary[]; + readonly nextCursor: string | null; + readonly complete: boolean; + } + export type ErrorCode = 'INVALID_ARGUMENT' | 'INVALID_SNAPSHOT' | 'DUPLICATE_REF' + | 'UNKNOWN_REFERENCE' | 'INVALID_CURSOR' | 'STALE_CURSOR' | 'VIEW_UNAVAILABLE'; + export class CatalogError extends Error { + constructor(readonly code: ErrorCode, message: string) { + super(message); + this.name = 'CatalogError'; + } + } + export const DOMAINS: readonly Domain[] = Object.freeze([ + 'isms', 'effects', 'color', 'typography', 'layout', 'motion' + ]); + export const SOURCE_FILES: readonly string[] = Object.freeze([ + ...DOMAINS.map(domain => `assets/data/${domain}.json`), + 'assets/data/recipes.json', 'assets/data/dev-guides.json', + 'assets/data/effects-docs.json', 'assets/data/effects-snippets.json' + ].sort()); + + /** Shared ingress rules for the catalog's classic-script files, not retrieval APIs. */ + export namespace Boundary { + export function fail(code: ErrorCode, message: string): never { + throw new CatalogError(code, message); + } + export function record(value: unknown, label: string, code: ErrorCode): Entry { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return fail(code, `${label} must be an object`); + } + const prototype: unknown = Object.getPrototypeOf(value); + // Accept plain JSON records from either realm, but not class instances. + if (prototype !== null) { + const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value as unknown; + if (Object.getPrototypeOf(prototype) !== null || typeof constructor !== 'function' + || Object.getOwnPropertyDescriptor(constructor, 'name')?.value !== 'Object' + || Object.getOwnPropertyDescriptor(constructor, 'prototype')?.value !== prototype) { + return fail(code, `${label} must be a plain object`); + } + } + // Detach own data fields so inherited values can never supply omitted arguments. + const fields = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (typeof key !== 'string' || !descriptor?.enumerable || !('value' in descriptor)) { + return fail(code, `${label} must contain enumerable JSON data fields`); + } + fields[key] = descriptor.value as unknown; + } + return Object.freeze(fields); + } + export function keys(value: Entry, allowed: readonly string[], code: ErrorCode): void { + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowed.includes(key)) fail(code, 'Unknown argument field'); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !('value' in descriptor)) fail(code, 'Accessor fields are not supported'); + } + } + export function text(value: unknown, label: string, code: ErrorCode, max = Infinity): string { + if (typeof value !== 'string' || !value.trim() || value.length > max) { + return fail(code, `${label} must be a nonempty string within its length limit`); + } + return value; + } + export function id(value: unknown, code: ErrorCode): string { + const result = text(value, 'id', code, 128); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result)) fail(code, 'Invalid catalog id'); + return result; + } + export function domain(value: unknown, code: ErrorCode): Domain { + if (typeof value !== 'string' || !DOMAINS.includes(value as Domain)) { + return fail(code, 'Unknown catalog domain'); + } + return value as Domain; + } + export function strings(value: unknown, label: string, code: ErrorCode): void { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string' && item.trim())) { + fail(code, `${label} must be an array of nonempty strings`); + } + } + } +} diff --git a/src/design-recipes.ts b/src/design-recipes.ts new file mode 100644 index 0000000..482105e --- /dev/null +++ b/src/design-recipes.ts @@ -0,0 +1,207 @@ +/** Pure recipe contracts. Depends only on DesignCatalog; adapters own IO and hashing. */ +namespace DesignRecipes { + export type Lang = 'ko' | 'en'; + export type Role = 'essential' | 'helper' | 'substitutable'; + export interface Text { readonly ko: string; readonly en: string; } + export interface Source { readonly url: string; readonly license: string; readonly note: string; } + export interface Slot { + readonly id: string; readonly label: Text; readonly role: Role; + readonly default: DesignCatalog.Ref; readonly alternatives: readonly DesignCatalog.Ref[]; + } + export interface Recipe { + readonly id: string; readonly title: Text; readonly summary: Text; + readonly slots: readonly Slot[]; readonly constraints: readonly Text[]; + readonly checks: readonly Text[]; readonly sources: readonly Source[]; + } + export interface RecipeSummary { readonly id: string; readonly title: Text; readonly summary: Text; } + export interface ComposeOptions { + readonly recipeId: string; readonly selections?: Readonly>; + readonly lang?: Lang; + } + export interface CompositionSlot { + readonly id: string; readonly role: Role; readonly ref: DesignCatalog.Ref; + readonly item: DesignCatalog.Summary; + } + /** Title/constraints/checks use compose's language (ko by default). */ + export interface Composition { + readonly version: string; readonly recipeId: string; readonly title: string; + readonly slots: readonly CompositionSlot[]; readonly constraints: readonly string[]; + readonly checks: readonly string[]; readonly sources: readonly Source[]; + } + export type ErrorCode = 'INVALID_RECIPE' | 'RECIPE_NOT_FOUND' | 'INVALID_SELECTION' | 'INVALID_LANGUAGE'; + export class RecipeError extends Error { + constructor(readonly code: ErrorCode, message: string) { super(message); this.name = 'RecipeError'; } + } + + const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']); + const safeId = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + function fail(code: ErrorCode, message: string): never { throw new RecipeError(code, message); } + function record(raw: unknown, keys: readonly string[] | null, code: ErrorCode): Record { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) fail(code, 'Expected an object.'); + const proto: unknown = Object.getPrototypeOf(raw); + // Permit JSON objects from another realm as well as null-prototype dictionaries. + if (proto !== null) { + const ctor = Object.getOwnPropertyDescriptor(proto, 'constructor')?.value as unknown; + if (Object.getPrototypeOf(proto) !== null || typeof ctor !== 'function' || ctor.name !== 'Object') { + fail(code, 'Expected a plain object.'); + } + } + const result: Record = Object.create(null) as Record; + for (const key of Reflect.ownKeys(raw)) { + if (typeof key !== 'string' || forbiddenKeys.has(key) || (keys !== null && !keys.includes(key))) { + fail(code, 'Unknown or unsafe field.'); + } + const descriptor = Object.getOwnPropertyDescriptor(raw, key); + if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) fail(code, 'Expected JSON data fields.'); + result[key] = descriptor.value as unknown; + } + return result; + } + function string(raw: unknown, code: ErrorCode): string { + if (typeof raw !== 'string' || raw.trim().length === 0) fail(code, 'Expected non-empty text.'); + return raw; + } + function id(raw: unknown, code: ErrorCode): string { + const value = string(raw, code); + if (!safeId.test(value) || forbiddenKeys.has(value)) fail(code, 'Invalid identifier.'); + return value; + } + function array(raw: unknown, code: ErrorCode, nonempty = true): unknown[] { + if (!Array.isArray(raw) || (nonempty && raw.length === 0)) fail(code, 'Expected an array of entries.'); + const result: unknown[] = []; + const keys = Reflect.ownKeys(raw); + if (keys.length !== raw.length + 1) fail(code, 'Arrays cannot contain holes or named properties.'); + for (let index = 0; index < raw.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(raw, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) fail(code, 'Expected dense JSON arrays.'); + result.push(descriptor.value as unknown); + } + return result; + } + function text(raw: unknown): Text { + const value = record(raw, ['ko', 'en'], 'INVALID_RECIPE'); + return Object.freeze({ ko: string(value.ko, 'INVALID_RECIPE'), en: string(value.en, 'INVALID_RECIPE') }); + } + function language(raw: unknown): Lang { + if (raw !== 'ko' && raw !== 'en') fail('INVALID_LANGUAGE', 'Language must be ko or en.'); + return raw; + } + function ref(raw: unknown, snapshot: DesignCatalog.Snapshot, code: ErrorCode): DesignCatalog.Ref { + const value = record(raw, ['domain', 'id'], code); + const domain = string(value.domain, code); + if (!Object.prototype.hasOwnProperty.call(snapshot.catalogs, domain)) fail(code, 'Unknown catalog domain.'); + const reference = Object.freeze({ domain: domain as DesignCatalog.Domain, id: id(value.id, code) }); + let entry: DesignCatalog.Entry; + try { entry = DesignCatalog.resolve(snapshot, reference); } + catch { return fail(code, 'Unknown catalog reference: ' + domain + '/' + reference.id); } + if (reference.id === 'ai-slop' || entry.kind === 'anti-pattern') fail(code, 'Anti-patterns cannot enter recipes.'); + return reference; + } + function sameRef(a: DesignCatalog.Ref, b: DesignCatalog.Ref): boolean { + return a.domain === b.domain && a.id === b.id; + } + function slot(raw: unknown, snapshot: DesignCatalog.Snapshot): Slot { + const value = record(raw, ['id', 'label', 'role', 'default', 'alternatives'], 'INVALID_RECIPE'); + if (value.role !== 'essential' && value.role !== 'helper' && value.role !== 'substitutable') { + fail('INVALID_RECIPE', 'Unknown slot role.'); + } + const fallback = ref(value.default, snapshot, 'INVALID_RECIPE'); + const alternatives = array(value.alternatives, 'INVALID_RECIPE', false).map(item => ref(item, snapshot, 'INVALID_RECIPE')); + const seen = new Set([fallback.id]); + for (const alternative of alternatives) { + if (alternative.domain !== fallback.domain || seen.has(alternative.id)) { + fail('INVALID_RECIPE', 'Alternatives must be unique references in the slot domain.'); + } + seen.add(alternative.id); + } + return Object.freeze({ id: id(value.id, 'INVALID_RECIPE'), label: text(value.label), role: value.role, + default: fallback, alternatives: Object.freeze(alternatives) }); + } + function source(raw: unknown): Source { + const value = record(raw, ['url', 'license', 'note'], 'INVALID_RECIPE'); + const url = string(value.url, 'INVALID_RECIPE'); + // No URL/network globals are needed by this pure module. Whitespace/markup are not source URLs. + if (!/^https:\/\/[a-z0-9.-]+(?::[0-9]+)?(?:[/?#][^\s<>"\\]*)?$/i.test(url)) { + fail('INVALID_RECIPE', 'Source must be an HTTPS URL.'); + } + return Object.freeze({ url, license: string(value.license, 'INVALID_RECIPE'), note: string(value.note, 'INVALID_RECIPE') }); + } + function recipe(raw: unknown, snapshot: DesignCatalog.Snapshot): Recipe { + const value = record(raw, ['id', 'title', 'summary', 'slots', 'constraints', 'checks', 'sources'], 'INVALID_RECIPE'); + const slots = array(value.slots, 'INVALID_RECIPE').map(item => slot(item, snapshot)); + if (new Set(slots.map(item => item.id)).size !== slots.length) fail('INVALID_RECIPE', 'Duplicate slot ID.'); + return Object.freeze({ id: id(value.id, 'INVALID_RECIPE'), title: text(value.title), summary: text(value.summary), + slots: Object.freeze(slots), constraints: Object.freeze(array(value.constraints, 'INVALID_RECIPE').map(text)), + checks: Object.freeze(array(value.checks, 'INVALID_RECIPE').map(text)), + sources: Object.freeze(array(value.sources, 'INVALID_RECIPE').map(source)) }); + } + + /** Parse the version:1 source document once; return detached, immutable recipes. */ + export function parse(raw: unknown, snapshot: DesignCatalog.Snapshot): readonly Recipe[] { + const value = record(raw, ['version', 'recipes'], 'INVALID_RECIPE'); + if (value.version !== 1) fail('INVALID_RECIPE', 'Unsupported recipe schema version.'); + const recipes = array(value.recipes, 'INVALID_RECIPE').map(item => recipe(item, snapshot)); + if (new Set(recipes.map(item => item.id)).size !== recipes.length) fail('INVALID_RECIPE', 'Duplicate recipe ID.'); + return Object.freeze(recipes); + } + /** Adapters wrap this compact bilingual list with snapshot.version. */ + export function list(recipes: readonly Recipe[]): readonly RecipeSummary[] { + return Object.freeze(recipes.map(item => Object.freeze({ id: item.id, title: item.title, summary: item.summary }))); + } + /** Adapters wrap the complete authored contract with snapshot.version. */ + export function detail(recipes: readonly Recipe[], recipeId: string): Recipe { + const wanted = id(recipeId, 'RECIPE_NOT_FOUND'); + const found = recipes.find(item => item.id === wanted); + if (!found) fail('RECIPE_NOT_FOUND', 'Recipe not found: ' + wanted); + return found; + } + export function compose(snapshot: DesignCatalog.Snapshot, recipes: readonly Recipe[], options: ComposeOptions): Composition { + const input = record(options, ['recipeId', 'selections', 'lang'], 'INVALID_SELECTION'); + const lang = language(Object.prototype.hasOwnProperty.call(input, 'lang') ? input.lang : 'ko'); + const chosen = detail(recipes, id(input.recipeId, 'RECIPE_NOT_FOUND')); + const selections = Object.prototype.hasOwnProperty.call(input, 'selections') + ? record(input.selections, chosen.slots.map(item => item.id), 'INVALID_SELECTION') : Object.create(null) as Record; + const slots = chosen.slots.map(item => { + const selected = Object.prototype.hasOwnProperty.call(selections, item.id) + ? ref(selections[item.id], snapshot, 'INVALID_SELECTION') : ref(item.default, snapshot, 'INVALID_SELECTION'); + if (![item.default, ...item.alternatives].some(allowed => sameRef(allowed, selected))) { + fail('INVALID_SELECTION', 'Selection is not allowed for slot: ' + item.id); + } + return Object.freeze({ id: item.id, role: item.role, ref: selected, + item: DesignCatalog.summarize(selected.domain, DesignCatalog.resolve(snapshot, selected)) }); + }); + return Object.freeze({ version: snapshot.version, recipeId: chosen.id, title: chosen.title[lang], + slots: Object.freeze(slots), constraints: Object.freeze(chosen.constraints.map(item => item[lang])), + checks: Object.freeze(chosen.checks.map(item => item[lang])), sources: chosen.sources }); + } + + function markdown(value: string): string { + return value.replace(/[\\`*_{}\[\]()#+.!|>~-]/g, '\\$&').replace(/ = ko + ? { essential: '필수', helper: '보조', substitutable: '교체 가능' } + : { essential: 'essential', helper: 'helper', substitutable: 'substitutable' }; + const lines = ['# ' + markdown(composition.title), '', + (ko ? '레시피' : 'Recipe') + ': ' + markdown(composition.recipeId), + (ko ? '데이터 버전' : 'Data version') + ': ' + markdown(composition.version), '', + ko ? '설계 가이드입니다. 완성된 실행 코드나 현재 제품의 검증 결과가 아닙니다.' + : 'Design guidance. This is not an integrated implementation or verification of the consuming product.', '', + '## ' + (ko ? '구성' : 'Composition'), '']; + for (const slot of composition.slots) { + const name = ko ? slot.item.nameKr : slot.item.name; + lines.push('- ' + markdown(slot.id) + ' (' + roles[slot.role] + '): ' + markdown(name) + + ' — `' + slot.ref.domain + '/' + slot.ref.id + '`'); + } + lines.push('', '## ' + (ko ? '구현 제약' : 'Constraints'), '', ...composition.constraints.map(value => '- ' + markdown(value)), + '', '## ' + (ko ? '구현 후 확인할 항목' : 'Checks to run after implementation'), '', + ...composition.checks.map(value => '- [ ] ' + markdown(value)), '', '## ' + (ko ? '출처' : 'Sources'), ''); + for (const item of composition.sources) { + lines.push('- <' + item.url + '> (' + markdown(item.license) + '): ' + markdown(item.note)); + } + return lines.join('\n') + '\n'; + } +} diff --git a/src/design-search.ts b/src/design-search.ts new file mode 100644 index 0000000..1b43198 --- /dev/null +++ b/src/design-search.ts @@ -0,0 +1,126 @@ +/** Deterministic discovery and version-bound cursors, identical in Node and browser. */ +namespace DesignCatalog { + const QUERY_LIMIT = 512; + const CURSOR_LIMIT = 20000; + interface SearchContext { query: string; domains: Domain[]; limit: number; cursor?: string; } + + function normalize(value: string): string { + return value.normalize('NFKC').toLowerCase().replace(/\s+/gu, ' ').trim(); + } + function compare(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } + + function parseSearch(args: SearchArgs): SearchContext { + const raw = Boundary.record(args, 'Search arguments', 'INVALID_ARGUMENT'); + Boundary.keys(raw, ['query', 'domains', 'limit', 'cursor'], 'INVALID_ARGUMENT'); + const query = raw.query === undefined ? '' : raw.query; + if (typeof query !== 'string' || query.length > QUERY_LIMIT) { + return Boundary.fail('INVALID_ARGUMENT', `query must be a string of at most ${QUERY_LIMIT} characters`); + } + const normalized = normalize(query); + if (normalized.length > QUERY_LIMIT) Boundary.fail('INVALID_ARGUMENT', 'Normalized query is too long'); + const limit = raw.limit === undefined ? 6 : raw.limit; + if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 30) { + return Boundary.fail('INVALID_ARGUMENT', 'limit must be an integer from 1 to 30'); + } + let domains = [...DOMAINS]; + if (raw.domains !== undefined) { + if (!Array.isArray(raw.domains) || !raw.domains.length || raw.domains.length > DOMAINS.length) { + return Boundary.fail('INVALID_ARGUMENT', 'domains must contain between 1 and 6 domains'); + } + if (Reflect.ownKeys(raw.domains).length !== raw.domains.length + 1) { + return Boundary.fail('INVALID_ARGUMENT', 'domains must be a dense JSON array'); + } + const values: Domain[] = []; + for (let index = 0; index < raw.domains.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(raw.domains, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) { + return Boundary.fail('INVALID_ARGUMENT', 'domains must contain data values'); + } + values.push(Boundary.domain(descriptor.value, 'INVALID_ARGUMENT')); + } + domains = [...new Set(values)]; + } + domains.sort(compare); + if (raw.cursor !== undefined && (typeof raw.cursor !== 'string' || !raw.cursor.length || raw.cursor.length > CURSOR_LIMIT)) { + return Boundary.fail('INVALID_CURSOR', 'cursor must be a nonempty bounded string'); + } + return { query: normalized, domains, limit, ...(raw.cursor === undefined ? {} : { cursor: raw.cursor as string }) }; + } + + // Canonical JSON stays compact and escapes lone surrogates without platform globals. + // This continuation token provides no authentication or tamper-proof signature. + function cursorFor(snapshot: Snapshot, context: SearchContext, offset: number): string { + return 'dc1.' + JSON.stringify([snapshot.contractVersion, snapshot.version, context.query, context.domains, offset]); + } + + function cursorOffset(snapshot: Snapshot, context: SearchContext, total: number): number { + if (!context.cursor) return 0; + const token = context.cursor; + if (!token.startsWith('dc1.')) { + return Boundary.fail('INVALID_CURSOR', 'Malformed cursor'); + } + let tuple: unknown; + try { tuple = JSON.parse(token.slice(4)); } catch { return Boundary.fail('INVALID_CURSOR', 'Malformed cursor payload'); } + if (!Array.isArray(tuple) || tuple.length !== 5 || typeof tuple[0] !== 'string' || typeof tuple[1] !== 'string' + || typeof tuple[2] !== 'string' || !Array.isArray(tuple[3]) || !tuple[3].every(value => typeof value === 'string') + || typeof tuple[4] !== 'number' || !Number.isSafeInteger(tuple[4]) || tuple[4] < 1) { + return Boundary.fail('INVALID_CURSOR', 'Invalid cursor fields'); + } + if (tuple[0] !== snapshot.contractVersion || tuple[1] !== snapshot.version) { + return Boundary.fail('STALE_CURSOR', 'Cursor belongs to another snapshot version'); + } + if (token !== cursorFor(snapshot, context, tuple[4]) || tuple[4] >= total) { + return Boundary.fail('INVALID_CURSOR', 'Cursor does not match this search or result range'); + } + return tuple[4]; + } + + function texts(entry: Entry, keys: readonly string[]): string[] { + const values: string[] = []; + for (const key of keys) { + const value = entry[key]; + if (typeof value === 'string') values.push(normalize(value)); + else if (Array.isArray(value)) { + for (const item of value) if (typeof item === 'string') values.push(normalize(item)); + } + } + return values; + } + + function score(entry: Entry, query: string): number { + if (!query) return 0; + const names = texts(entry, ['id', 'name', 'nameKr', 'alsoCalled', 'aliases']); + if (names.includes(query)) return 10000; + const keywords = texts(entry, ['keywords', 'family', 'category']); + const prose = texts(entry, ['tagline', 'summary', 'summaryEn', 'description', 'descriptionEn', 'taglineEn', 'bestFor', 'useCases']); + let total = 0; + for (const token of new Set(query.split(' '))) { + if (names.some(value => value.includes(token))) total += 16; + else if (keywords.some(value => value.includes(token))) total += 8; + else if (prose.some(value => value.includes(token))) total += 1; + else return -1; + } + return total; + } + + export function search(snapshot: Snapshot, args: SearchArgs = {}): SearchPage { + const context = parseSearch(args); + const ranked: Array<{ domain: Domain; entry: Entry; score: number; key: string }> = []; + for (const domain of context.domains) { + for (const entry of snapshot.catalogs[domain]) { + if (entry.kind === 'anti-pattern') continue; + const weight = score(entry, context.query); + if (weight >= 0) ranked.push({ domain, entry, score: weight, key: `${domain}/${String(entry.id)}` }); + } + } + ranked.sort((a, b) => b.score - a.score || compare(a.key, b.key)); + const offset = cursorOffset(snapshot, context, ranked.length); + const items = Object.freeze(ranked.slice(offset, offset + context.limit).map(row => summarize(row.domain, row.entry))); + const end = offset + items.length; + return Object.freeze({ + version: snapshot.version, total: ranked.length, items, + nextCursor: end < ranked.length ? cursorFor(snapshot, context, end) : null, + complete: end === ranked.length + }); + } +} diff --git a/src/design-views.ts b/src/design-views.ts new file mode 100644 index 0000000..52a0c1d --- /dev/null +++ b/src/design-views.ts @@ -0,0 +1,62 @@ +/** Shared projections: authored implementation guidance and complete code, never image prompts. */ +namespace DesignCatalog { + const GUIDE_FIELDS: Readonly> = { + color: ['palette', 'contrast', 'darkVariant', 'tone', 'useCases'], + typography: ['heading', 'body', 'mono', 'scale', 'supportsKorean', 'webfonts', 'specimen'], + layout: ['breakpoints', 'composition', 'responsive', 'bestFor', 'avoidWhen'], + motion: ['easing', 'duration', 'trigger', 'intensity', 'reducedMotion'] + }; + + function guideData(snapshot: Snapshot, ref: Ref, entry: Entry): Entry { + if (ref.domain === 'isms' || ref.domain === 'effects') { + const map = ref.domain === 'isms' ? snapshot.guides : snapshot.effectDocs; + if (!Object.prototype.hasOwnProperty.call(map, ref.id)) { + return Boundary.fail('VIEW_UNAVAILABLE', `No guide for ${ref.domain}/${ref.id}`); + } + return map[ref.id] as Entry; + } + const fields = GUIDE_FIELDS[ref.domain]; + const result: Record = {}; + let hasImplementation = false; + for (const key of [...fields, 'sources', 'reviewedOn', 'relatedIsms', 'relatedEffects']) { + if (Object.prototype.hasOwnProperty.call(entry, key)) { + result[key] = entry[key]; + if (fields.includes(key)) hasImplementation = true; + } + } + if (!hasImplementation) return Boundary.fail('VIEW_UNAVAILABLE', `No guide for ${ref.domain}/${ref.id}`); + return Object.freeze(result); + } + + function codeData(snapshot: Snapshot, ref: Ref, entry: Entry): Entry { + let data: unknown; + if (ref.domain === 'effects') { + const snippets = snapshot.effectSnippets.snippets; + if (Object.prototype.hasOwnProperty.call(snippets, ref.id)) data = snippets[ref.id]; + } else if (ref.domain === 'layout' || ref.domain === 'motion') data = entry.snippet; + if (data === undefined) return Boundary.fail('VIEW_UNAVAILABLE', `No code for ${ref.domain}/${ref.id}`); + return Boundary.record(data, 'code', 'INVALID_SNAPSHOT'); + } + + export function get(snapshot: Snapshot, args: GetArgs): GetResult { + const raw = Boundary.record(args, 'Get arguments', 'INVALID_ARGUMENT'); + Boundary.keys(raw, ['domain', 'id', 'view'], 'INVALID_ARGUMENT'); + const ref = Object.freeze({ + domain: Boundary.domain(raw.domain, 'INVALID_ARGUMENT'), + id: Boundary.id(raw.id, 'INVALID_ARGUMENT') + }); + const view = raw.view === undefined ? 'summary' : raw.view; + if (view !== 'summary' && view !== 'guide' && view !== 'code' && view !== 'full') { + return Boundary.fail('INVALID_ARGUMENT', 'Unknown catalog view'); + } + const entry = resolve(snapshot, ref); + let data: Entry | Summary; + switch (view) { + case 'summary': data = summarize(ref.domain, entry); break; + case 'full': data = entry; break; + case 'guide': data = guideData(snapshot, ref, entry); break; + case 'code': data = codeData(snapshot, ref, entry); break; + } + return Object.freeze({ version: snapshot.version, ref, view, data }); + } +} diff --git a/structure/README.md b/structure/README.md index 734bffd..1399c9b 100644 --- a/structure/README.md +++ b/structure/README.md @@ -151,3 +151,12 @@ The `260510_nav_taxonomy_effect_docs` folder records the follow-up implementatio - `scripts/image-generation-profiles.mjs` preserves exact historical generation argv and admits the explicit `current-local` profile for the existing localhost:3333 OAuth Sol/high lane. - `scripts/image-final-history.mjs` validates predecessor receipt/sheets and approved-cell continuity. `images:finalize-quality -- --supersede --expected-previous-sha ` preserves prior bytes under `098_image_final_history//`, stores new sheets under `095_image_sheets/final-revisions//`, and atomically updates 098. Default finalization still rejects differing existing content. - `npm run verify:quality-contracts`, included in `verify`, runs editorial, generation-profile and final-history regression tests. Immutable 093–097 baseline files and original final sheets remain unchanged. + +## Shared catalog and recipe core + +- `src/design-contracts.ts`, `design-catalog.ts`, `design-search.ts`, `design-views.ts` own the pure `DesignCatalog` namespace; `src/design-recipes.ts` owns `DesignRecipes`. Classic-script outputs use the matching `assets/js/` names. No DOM, filesystem or network operations belong in these namespaces. +- `assets/data/recipes.json` is the source for three authored screen compositions. It contains references and new bilingual guidance, not copies of catalog objects. StyleGallery-derived roles/guidance retain CC BY attribution; no automatic product verification is implied. +- `scripts/design-core-loader.mjs` is a Node-only adapter over an explicit generated-script allowlist. Its snapshot identity covers the six catalog JSON files, recipes, ISM guides, effect docs and effect snippets. The hash uses exact file bytes and sorted repository-relative paths. +- Search excludes anti-patterns, has stable domain/id tie ordering and version/query/filter-bound cursors. `get` separates summary, implementation guidance, complete code and raw catalog views. +- `scripts/design-core.test.mjs` (`npm run test:design-core`, included in verify) exercises invalid inputs, real references, immutability, multilingual search, continuation, view availability, source identity and recipes. Existing Finder logic remains unchanged. +- Core consumers receive plain immutable data. Node operational scripts and documentation stay outside the Pages allowlist. Browser recipe UI and MCP transport are separate follow-up layers.