Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에 유지한다. 코드 예제·설계 가이드·실행 검증을 구분한다.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,14 @@ CSS 레시피에는 필요한 JavaScript 상태 관리와 모션 감소 대응

기존 카탈로그 데이터와 이미지의 원본은 이 저장소에 있습니다. 위 프로젝트의 전체 자료나
프레임워크를 포함한다는 뜻은 아니며, 새 기능의 구현 상태는 해당 PR과 사용 문서를 따릅니다.

## 화면 레시피와 공통 검색 코어

`assets/data/recipes.json`은 제품 소개, 편집형 읽기, 설정 작업 화면의 세 레시피를 담습니다.
각 레시피는 기존 카탈로그 항목을 참조하며 필수·보조·교체 가능 역할, 허용 대안, 구현 제약과
확인할 항목을 제공합니다. 완성된 페이지 코드나 제품 검증 결과를 뜻하지 않습니다.

`src/design-*.ts`의 순수 코어는 브라우저와 Node에서 같은 검색·상세 조회·조합 규칙을 사용합니다.
검색 결과에는 데이터 버전과 다음 커서가 붙고, anti-pattern은 명시적 조회에서만 나옵니다.
`npm run test:design-core`로 검색·경계 입력·페이지 이동·레시피 계약을 검증합니다.
MCP와 사이트 선택 화면은 후속 PR에서 연결합니다.
114 changes: 114 additions & 0 deletions assets/data/recipes.json

Large diffs are not rendered by default.

184 changes: 184 additions & 0 deletions assets/js/design-catalog.js
Original file line number Diff line number Diff line change
@@ -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 = {}));
93 changes: 93 additions & 0 deletions assets/js/design-contracts.js
Original file line number Diff line number Diff line change
@@ -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 = {}));
Loading
Loading