@3xhaust/oh-my-design 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,180 @@
1
+ import type { Violation } from '../types.ts';
2
+ import { readFrame } from './index.ts';
3
+
4
+ export type UxSurface = 'marketing' | 'product' | 'editorial' | 'mixed';
5
+
6
+ const UX_SURFACES = new Set<UxSurface>(['marketing', 'product', 'editorial', 'mixed']);
7
+ const TASK_COVERAGE_VIEWPORTS = new Set(['desktop', 'mobile']);
8
+ const TASK_COVERAGE_REQUIREMENTS = new Set(['invalid-submit', 'transient']);
9
+ const TASK_COVERAGE_FIELDS = ['goal', 'start', 'actions', 'success', 'recovery', 'viewports', 'requirements'] as const;
10
+ const TASK_COVERAGE_MATRIX_HEADING = /^## Task coverage matrix[ \t]*$/gm;
11
+
12
+ /** Return the normalized supported UX surface, or null for a missing or invalid value. */
13
+ export function normalizeUxSurface(value: unknown): UxSurface | null {
14
+ if (typeof value !== 'string') return null;
15
+
16
+ const surface = value.trim().toLowerCase();
17
+ return UX_SURFACES.has(surface as UxSurface) ? surface as UxSurface : null;
18
+ }
19
+ function isNonEmptyString(value: unknown): value is string {
20
+ return typeof value === 'string' && value.trim().length > 0;
21
+ }
22
+ function validateUniqueCsvSubset(
23
+ id: string,
24
+ field: 'viewports' | 'requirements',
25
+ value: string,
26
+ allowed: ReadonlySet<string>,
27
+ allowsNone: boolean,
28
+ ): string | null {
29
+ const values = value.split(',').map((item) => item.trim());
30
+ if (values.some((item) => !item)) {
31
+ return `${id} ${field} must be a comma-separated list`;
32
+ }
33
+ if (allowsNone && values.includes('none')) {
34
+ return values.length === 1 ? null : `${id} requirements none cannot be combined with other values`;
35
+ }
36
+
37
+ const seen = new Set<string>();
38
+ for (const item of values) {
39
+ if (!allowed.has(item)) return `${id} ${field} contains unknown value: ${item}`;
40
+ if (seen.has(item)) return `${id} ${field} contains duplicate value: ${item}`;
41
+ seen.add(item);
42
+ }
43
+ return null;
44
+ }
45
+
46
+
47
+
48
+ /**
49
+ * `T1 | goal: … | start: … | actions: … | success: … | recovery: … | viewports: … | requirements: …`
50
+ *
51
+ * IDs must be unique. Every field must be non-empty. Only recovery may use
52
+ * `N/A: <reason>` or `N/A — <reason>`. Viewports are a unique subset of
53
+ * `desktop,mobile`; requirements are `none` or a unique subset of
54
+ * `invalid-submit,transient`.
55
+ */
56
+ export function validateTaskCoverageMatrix(matrix: string): string[] {
57
+ const rows = matrix.split('\n').map((row) => row.trim()).filter(Boolean);
58
+ const errors: string[] = [];
59
+ const ids = new Set<string>();
60
+
61
+ if (rows.length === 0) return ['task coverage matrix has no rows'];
62
+
63
+ for (const row of rows) {
64
+ const columns = row.split('|').map((column) => column.trim());
65
+ const id = columns.shift();
66
+ if (!id || !/^T[1-9]\d*$/.test(id)) {
67
+ errors.push(`malformed task coverage row: ${row}`);
68
+ continue;
69
+ }
70
+ if (ids.has(id)) {
71
+ errors.push(`duplicate task coverage id: ${id}`);
72
+ continue;
73
+ }
74
+ ids.add(id);
75
+
76
+ if (columns.length !== TASK_COVERAGE_FIELDS.length) {
77
+ errors.push(`malformed task coverage row: ${row}`);
78
+ continue;
79
+ }
80
+
81
+ for (const [index, field] of TASK_COVERAGE_FIELDS.entries()) {
82
+ const column = columns[index] ?? '';
83
+ const match = new RegExp(`^${field}:\\s*(.+)$`).exec(column);
84
+ const value = match?.[1]?.trim() ?? '';
85
+ if (!value) {
86
+ errors.push(`${id} is missing ${field}`);
87
+ } else if (/^N\/A\b/i.test(value)) {
88
+ if (field !== 'recovery') {
89
+ errors.push(`${id} ${field} must not be N/A`);
90
+ } else if (!/^N\/A\s*(?::|—)\s*\S/.test(value)) {
91
+ errors.push(`${id} recovery N/A requires a reason`);
92
+ }
93
+ } else if (field === 'viewports') {
94
+ const error = validateUniqueCsvSubset(id, field, value, TASK_COVERAGE_VIEWPORTS, false);
95
+ if (error) errors.push(error);
96
+ } else if (field === 'requirements') {
97
+ const error = validateUniqueCsvSubset(id, field, value, TASK_COVERAGE_REQUIREMENTS, true);
98
+ if (error) errors.push(error);
99
+ }
100
+ }
101
+ }
102
+
103
+ return errors;
104
+ }
105
+
106
+ function taskCoverageMatrixSections(body: string): string[] {
107
+ return body
108
+ .split(TASK_COVERAGE_MATRIX_HEADING)
109
+ .slice(1)
110
+ .map((section) => section.split(/^## /m)[0] ?? '');
111
+ }
112
+
113
+ /**
114
+ * Validate that the frame artifact has answered the UX anchor questions.
115
+ *
116
+ * `uxSurface` is one of marketing, product, editorial, or mixed. Product and mixed
117
+ * frames require exactly one valid `## Task coverage matrix`; marketing and editorial
118
+ * frames reject task coverage matrix contamination. Unsupported non-empty surfaces fail closed.
119
+ */
120
+ export function checkFrameUx(cwd: string): Violation[] {
121
+ const frame = readFrame(cwd);
122
+ if (!frame) return [];
123
+
124
+ const missing: string[] = [];
125
+
126
+ if (!isNonEmptyString(frame.uxTask)) missing.push('uxTask (--task)');
127
+ if (!isNonEmptyString(frame.uxFrequentAction)) {
128
+ missing.push('uxFrequentAction (--frequent-action)');
129
+ }
130
+ if (!isNonEmptyString(frame.uxCostliestError)) {
131
+ missing.push('uxCostliestError (--costliest-error)');
132
+ }
133
+
134
+ const rawSurface = frame.uxSurface;
135
+ const surface = normalizeUxSurface(rawSurface);
136
+ const hasSurface = typeof rawSurface === 'string' && rawSurface.trim().length > 0;
137
+ if (rawSurface === undefined || (typeof rawSurface === 'string' && !hasSurface)) {
138
+ missing.push('uxSurface (--surface)');
139
+ } else if (!surface) {
140
+ missing.push('uxSurface (--surface: marketing|product|editorial|mixed)');
141
+ }
142
+
143
+ const matrixSections = taskCoverageMatrixSections(frame.body);
144
+ const requiresTaskCoverageMatrix = surface === 'product' || surface === 'mixed'
145
+ || (hasSurface && !surface)
146
+ || (rawSurface !== undefined && typeof rawSurface !== 'string');
147
+ if (requiresTaskCoverageMatrix) {
148
+ if (matrixSections.length !== 1) {
149
+ missing.push(`taskCoverageMatrix (--task-matrix: requires exactly one section; found ${matrixSections.length})`);
150
+ } else {
151
+ const matrixErrors = validateTaskCoverageMatrix(matrixSections[0] ?? '');
152
+ if (matrixErrors.length > 0) {
153
+ missing.push(`taskCoverageMatrix (--task-matrix: ${matrixErrors.join('; ')})`);
154
+ }
155
+ }
156
+ } else if ((surface === 'marketing' || surface === 'editorial') && matrixSections.length > 0) {
157
+ missing.push('taskCoverageMatrix (--task-matrix: not allowed for marketing or editorial surfaces)');
158
+ }
159
+
160
+ if (missing.length === 0) return [];
161
+
162
+ return [
163
+ {
164
+ id: 'FRAME-UX-INCOMPLETE',
165
+ severity: 'warn',
166
+ layer: 1,
167
+ category: 'ux',
168
+ nodeId: 'page',
169
+ path: 'frame',
170
+ value: missing.join(', '),
171
+ message:
172
+ `The frame exists but the following UX anchor question${missing.length === 1 ? ' is' : 's are'} unanswered: `
173
+ + `${missing.join('; ')}. `
174
+ + 'A frame that cannot name the costliest error has not been interrogated. '
175
+ + 'Run `omd frame set --task "..." --frequent-action "..." --costliest-error "..." --surface "..."'
176
+ + `${requiresTaskCoverageMatrix ? ' --task-matrix "T1 | goal: … | start: … | actions: … | success: … | recovery: … | viewports: … | requirements: …"' : ''}`
177
+ + '` to complete it. See theory/ux.md §Task-first framing and §Surface types.',
178
+ },
179
+ ];
180
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import type { Frame } from '../types.ts';
5
+
6
+ const framePath = (cwd: string): string => join(cwd, '.omd', 'frame.md');
7
+
8
+ const isEnoent = (err: unknown): boolean =>
9
+ typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'ENOENT';
10
+
11
+ /**
12
+ * The frame is a record, not a gate. Nothing blocks on it.
13
+ *
14
+ * An earlier version made a human sign the reframing before any file could be written.
15
+ * That solved the wrong problem: a wrong reframing is caught by building something,
16
+ * rendering it, and looking — not by a signature. Worse, it made the loop impossible,
17
+ * since nothing could be built while approval was pending, so there was nothing to look
18
+ * at, so nothing could reveal the reframing was wrong.
19
+ */
20
+ export function readFrame(cwd: string): Frame | null {
21
+ let text: string;
22
+ try {
23
+ text = readFileSync(framePath(cwd), 'utf8');
24
+ } catch (err) {
25
+ if (isEnoent(err)) return null;
26
+ throw err;
27
+ }
28
+
29
+ if (!text.startsWith('---\n')) return { body: text };
30
+
31
+ const closeIndex = text.indexOf('\n---', 3);
32
+ if (closeIndex === -1) return { body: text };
33
+
34
+ const frontmatter = (parse(text.slice(4, closeIndex)) ?? {}) as Partial<Frame>;
35
+ const rest = text.slice(closeIndex + 4);
36
+ const body = rest.startsWith('\n') ? rest.slice(1) : rest;
37
+
38
+ return { ...frontmatter, body };
39
+ }
@@ -0,0 +1,168 @@
1
+ import { mkdirSync, writeFileSync, appendFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { stringify } from 'yaml';
4
+ import { readFrame } from './index.ts';
5
+ import { normalizeUxSurface, validateTaskCoverageMatrix } from './check-ux.ts';
6
+ import type { Choice } from '../types.ts';
7
+
8
+ const designDir = (cwd: string): string => join(cwd, '.omd');
9
+
10
+ export function writeFrame(cwd: string, frontmatter: Record<string, unknown>, body: string): void {
11
+ mkdirSync(designDir(cwd), { recursive: true });
12
+ writeFileSync(join(designDir(cwd), 'frame.md'), `---\n${stringify(frontmatter).trimEnd()}\n---\n\n${body.trim()}\n`);
13
+ }
14
+
15
+ /**
16
+ * Nobody signs this. The `--why` requirement is not a gate on the human — it is a gate on
17
+ * the agent, which will otherwise reframe a brief on a hunch and call the hunch insight.
18
+ *
19
+ * UX fields remain optional for backward compatibility. When a surface or matrix is
20
+ * supplied, this writer validates it before writing: surfaces are normalized to the four
21
+ * supported values; product or mixed surfaces require a valid seven-field task coverage
22
+ * matrix, while marketing and editorial surfaces reject one.
23
+ */
24
+ export function writeFrameRecord(cwd: string, opts: {
25
+ problem: string;
26
+ reframe: string;
27
+ why?: string;
28
+ uxTask?: string;
29
+ uxFrequentAction?: string;
30
+ uxCostliestError?: string;
31
+ uxSurface?: unknown;
32
+ taskCoverageMatrix?: unknown;
33
+ }): string {
34
+ if (!opts.why || opts.why.trim().length < 10) {
35
+ throw new Error(
36
+ 'A reframing without a cited observation is a guess. Pass --why with the review, '
37
+ + 'ticket, datum, or user sentence it rests on.',
38
+ );
39
+ }
40
+ const normalizedSurface = opts.uxSurface === undefined ? undefined : normalizeUxSurface(opts.uxSurface);
41
+ if (opts.uxSurface !== undefined && !normalizedSurface) {
42
+ throw new Error('uxSurface must be marketing, product, editorial, or mixed.');
43
+ }
44
+
45
+ if (opts.taskCoverageMatrix !== undefined && typeof opts.taskCoverageMatrix !== 'string') {
46
+ throw new Error('taskCoverageMatrix must be a string.');
47
+ }
48
+ const taskCoverageMatrix = opts.taskCoverageMatrix === undefined
49
+ ? undefined
50
+ : opts.taskCoverageMatrix.replace(/^## Task coverage matrix[ \t]*$/gm, '').trim();
51
+ if (taskCoverageMatrix !== undefined) {
52
+ const matrixErrors = validateTaskCoverageMatrix(taskCoverageMatrix);
53
+ if (matrixErrors.length > 0) {
54
+ throw new Error(`Invalid task coverage matrix: ${matrixErrors.join('; ')}`);
55
+ }
56
+ }
57
+ if ((normalizedSurface === 'marketing' || normalizedSurface === 'editorial') && taskCoverageMatrix !== undefined) {
58
+ throw new Error('Marketing and editorial surfaces must not include a task coverage matrix.');
59
+ }
60
+ if ((normalizedSurface === 'product' || normalizedSurface === 'mixed') && taskCoverageMatrix === undefined) {
61
+ throw new Error('Product and mixed surfaces require a valid task coverage matrix.');
62
+ }
63
+ const body = [
64
+ '## The given problem', '', opts.problem.trim(), '',
65
+ '## The reframing', '', opts.reframe.trim(), '',
66
+ '## Evidence', '', opts.why.trim(),
67
+ ...(taskCoverageMatrix ? ['', '', '## Task coverage matrix', '', taskCoverageMatrix] : []),
68
+ ].join('\n');
69
+
70
+ const frontmatter: Record<string, unknown> = { why: opts.why.trim(), writtenAt: new Date().toISOString() };
71
+ if (opts.uxTask?.trim()) frontmatter['uxTask'] = opts.uxTask.trim();
72
+ if (opts.uxFrequentAction?.trim()) frontmatter['uxFrequentAction'] = opts.uxFrequentAction.trim();
73
+ if (opts.uxCostliestError?.trim()) frontmatter['uxCostliestError'] = opts.uxCostliestError.trim();
74
+ if (normalizedSurface) frontmatter['uxSurface'] = normalizedSurface;
75
+
76
+ writeFrame(cwd, frontmatter, body);
77
+ return join(designDir(cwd), 'frame.md');
78
+ }
79
+
80
+ /**
81
+ * What the loop does when a rendered candidate reveals the problem was misunderstood.
82
+ * The old framing is kept, not overwritten: a frame that changed twice is the most
83
+ * interesting thing in the repository six months later.
84
+ */
85
+ export function reframe(cwd: string, opts: { to: string; because: string }): string {
86
+ const frame = readFrame(cwd);
87
+ if (!frame) throw new Error('No frame yet. Run `omd frame set` first.');
88
+
89
+ const { body, ...frontmatter } = frame;
90
+ const revision = (typeof frontmatter['revision'] === 'number' ? frontmatter['revision'] : 0) + 1;
91
+ const appended = [
92
+ body.trimEnd(),
93
+ '',
94
+ `## Reframing ${revision}`,
95
+ '',
96
+ `**Now:** ${opts.to.trim()}`,
97
+ '',
98
+ `**Because:** ${opts.because.trim()}`,
99
+ ].join('\n');
100
+
101
+ writeFrame(cwd, { ...frontmatter, revision, reframedAt: new Date().toISOString() }, appended);
102
+ return join(designDir(cwd), 'frame.md');
103
+ }
104
+
105
+ export function setGenerator(cwd: string, generator: string): void {
106
+ const frame = readFrame(cwd);
107
+ const { body = '', ...frontmatter } = frame ?? {};
108
+ writeFrame(cwd, { ...frontmatter, generator }, body);
109
+ }
110
+
111
+ export function logDecision(cwd: string, what: string, why: string): string {
112
+ mkdirSync(designDir(cwd), { recursive: true });
113
+ const path = join(designDir(cwd), 'decisions.md');
114
+ if (!existsSync(path)) {
115
+ writeFileSync(path, '# Decisions\n\nWhat was built is in the code. Why it was built that way is only here.\n');
116
+ }
117
+ const generator = readFrame(cwd)?.generator ?? '(none set)';
118
+ appendFileSync(path, `\n## ${what}\n\n- When: ${new Date().toISOString()}\n- Point of view: ${generator}\n- Why: ${why}\n`);
119
+ return path;
120
+ }
121
+
122
+ export function logChoice(cwd: string, opts: { among: string[]; chose: string; why?: string }): string {
123
+ if (!opts.among.includes(opts.chose)) throw new Error(`${opts.chose} is not among ${opts.among.join(', ')}`);
124
+ mkdirSync(join(designDir(cwd), 'taste'), { recursive: true });
125
+ const path = join(designDir(cwd), 'taste', 'preferences.jsonl');
126
+
127
+ const record: Choice = {
128
+ ts: new Date().toISOString(),
129
+ actor: 'agent',
130
+ kind: 'selection',
131
+ subject: opts.chose,
132
+ evidence: opts.why ?? '',
133
+ among: opts.among,
134
+ chose: opts.chose,
135
+ why: opts.why ?? null,
136
+ generator: readFrame(cwd)?.generator ?? null,
137
+ };
138
+ appendFileSync(path, `${JSON.stringify(record)}\n`);
139
+ return path;
140
+ }
141
+
142
+ export function logTaste(cwd: string, opts: {
143
+ subject: string;
144
+ kind: 'selection' | 'praise' | 'rejection' | 'overrule';
145
+ evidence: string;
146
+ fromUser: boolean;
147
+ }): string {
148
+ if (!opts.fromUser) throw new Error('Taste records require --from-user; inferred preference is not user evidence.');
149
+ if (!opts.subject.trim() || !opts.evidence.trim()) throw new Error('Taste subject and verbatim --evidence are required.');
150
+ mkdirSync(join(designDir(cwd), 'taste'), { recursive: true });
151
+ const path = join(designDir(cwd), 'taste', 'preferences.jsonl');
152
+ const record: Choice = {
153
+ ts: new Date().toISOString(), actor: 'user', kind: opts.kind,
154
+ subject: opts.subject.trim(), evidence: opts.evidence.trim(),
155
+ };
156
+ appendFileSync(path, `${JSON.stringify(record)}\n`);
157
+ return path;
158
+ }
159
+
160
+ export function tasteProfile(cwd: string, all = false): { n: number; records: Choice[] } {
161
+ const path = join(designDir(cwd), 'taste', 'preferences.jsonl');
162
+ if (!existsSync(path)) return { n: 0, records: [] };
163
+ const records = readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map((l) => {
164
+ const raw = JSON.parse(l) as Choice;
165
+ return { ...raw, actor: raw.actor ?? 'unknown' as const, kind: raw.kind ?? 'selection' as const };
166
+ }).filter((r) => all || r.actor === 'user');
167
+ return { n: records.length, records };
168
+ }
@@ -0,0 +1,189 @@
1
+ # CSS-only decorative illustration primitives
2
+
3
+ CSS shapes — blobs via `border-radius`, layered circles, rotated rectangles, clipped
4
+ polygons — that create abstract decorative elements without photography or SVG files.
5
+ These primitives soften flat backgrounds, frame content sections, and add a spatial
6
+ quality to otherwise bare layouts. The technique produces elements that read as
7
+ decorative fields rather than illustrations: they have presence without narrative.
8
+
9
+ The primitives are pure CSS: no image files, no JavaScript, no external dependencies.
10
+ They scale perfectly at any resolution and can be themed by changing custom properties.
11
+
12
+ ## When it earns its place / When it does not
13
+
14
+ Condition: sections that need visual weight without photography or purposeful illustration.
15
+ A hero background that should feel organic rather than grid-based, a feature highlight
16
+ where an abstract shape frames the text without competing with it, a footer that needs
17
+ warmth without adding content. Useful when the budget or timeline prohibits custom
18
+ illustration and stock photography would introduce a mismatched register.
19
+
20
+ The organic blob specifically earns its place when the concept's governing metaphor is
21
+ biological, human, or flowing — a health product, a creative tool, an emotional brand —
22
+ where geometric shapes would read as cold. `core/theory/layout.md` closure principle:
23
+ implied shapes created by border-radius have the same grouping effect as borders, with
24
+ less visual weight.
25
+
26
+ Condition against: when purposeful illustration exists and the CSS primitive would
27
+ compete with it. When the brand register is technical or precise — geometric CSS shapes
28
+ read as approximations of geometry; an actual SVG or canvas-drawn shape is more precise.
29
+ Also: avoid when the shapes are purely filling space with no compositional purpose. A
30
+ blob in the corner of a hero section that does not frame, separate, or accent anything
31
+ is a shape placed to fill space, and `core/theory/layout.md`'s restraint applies: space
32
+ is structural, not dead air to be decorated.
33
+
34
+ ## Parameters
35
+
36
+ ```css
37
+ :root {
38
+ /* Blob colour: typically a low-saturation tint of the brand palette.
39
+ At 20–40% opacity the blob reads as ambient colour, not a shape.
40
+ At 60–80% opacity it reads as a distinct coloured element — use intentionally. */
41
+ --blob-color: hsl(220deg 50% 70% / 0.25);
42
+ --blob-secondary: hsl(280deg 40% 65% / 0.18);
43
+
44
+ /* Blob size: relative to the container.
45
+ 60–80% produces an organic field that covers but does not dominate.
46
+ 100%+ (bleeding beyond the container) reads as a background wash. */
47
+ --blob-size: 70%;
48
+
49
+ /* Blur on the blob: softens edges for ambient quality.
50
+ 0: hard-edged shape — more deliberate, less ambient.
51
+ 40–80px: fully soft — reads as ambient colour field, not a shape. */
52
+ --blob-blur: 60px;
53
+ }
54
+ ```
55
+
56
+ ## Implementation
57
+
58
+ **Organic blob (single, border-radius)**
59
+
60
+ ```css
61
+ /* Applied to a pseudo-element so no extra HTML is needed. */
62
+ .blob-bg {
63
+ position: relative;
64
+ isolation: isolate;
65
+ overflow: hidden;
66
+ }
67
+
68
+ .blob-bg::before {
69
+ content: '';
70
+ position: absolute;
71
+ /* Off-center placement creates visual interest without symmetry. */
72
+ top: -20%;
73
+ right: -10%;
74
+ width: var(--blob-size);
75
+ aspect-ratio: 1 / 1.1; /* slightly non-square for organic quality */
76
+ background: var(--blob-color);
77
+ /* The border-radius blob: a high, uneven border-radius on all corners.
78
+ No two adjacent values identical — this produces the organic shape. */
79
+ border-radius: 62% 38% 46% 54% / 60% 44% 56% 40%;
80
+ filter: blur(var(--blob-blur));
81
+ z-index: -1;
82
+ pointer-events: none;
83
+ }
84
+
85
+ /* Second blob for depth — offset from the first. */
86
+ .blob-bg::after {
87
+ content: '';
88
+ position: absolute;
89
+ bottom: -15%;
90
+ left: -5%;
91
+ width: calc(var(--blob-size) * 0.7);
92
+ aspect-ratio: 1.2 / 1;
93
+ background: var(--blob-secondary);
94
+ border-radius: 40% 60% 70% 30% / 50% 60% 40% 50%;
95
+ filter: blur(calc(var(--blob-blur) * 1.2));
96
+ z-index: -1;
97
+ pointer-events: none;
98
+ }
99
+ ```
100
+
101
+ **Layered circle stack (explicit HTML elements for more control)**
102
+
103
+ ```html
104
+ <!-- Decorative circles: aria-hidden, pointer-events: none. -->
105
+ <div class="circle-stack" aria-hidden="true">
106
+ <div class="circle-stack__ring circle-stack__ring--1"></div>
107
+ <div class="circle-stack__ring circle-stack__ring--2"></div>
108
+ <div class="circle-stack__ring circle-stack__ring--3"></div>
109
+ </div>
110
+ ```
111
+
112
+ ```css
113
+ .circle-stack {
114
+ position: relative;
115
+ width: 400px;
116
+ height: 400px;
117
+ pointer-events: none;
118
+ user-select: none;
119
+ }
120
+
121
+ .circle-stack__ring {
122
+ position: absolute;
123
+ border-radius: 50%;
124
+ border: 1px solid currentColor;
125
+ opacity: 0.12;
126
+ }
127
+
128
+ .circle-stack__ring--1 {
129
+ inset: 0;
130
+ /* Full size ring */
131
+ }
132
+
133
+ .circle-stack__ring--2 {
134
+ inset: 15%;
135
+ /* Inset ring — concentric */
136
+ }
137
+
138
+ .circle-stack__ring--3 {
139
+ inset: 30%;
140
+ /* Innermost ring — the system reads as depth through concentric structure */
141
+ }
142
+ ```
143
+
144
+ **Clipped geometric accent (clip-path)**
145
+
146
+ ```css
147
+ /* A rotated square clipped to a diamond — reads as a geometric accent mark. */
148
+ .diamond-accent {
149
+ width: 120px;
150
+ height: 120px;
151
+ background: var(--color-accent);
152
+ clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
153
+ opacity: 0.15;
154
+ position: absolute;
155
+ pointer-events: none;
156
+ }
157
+ ```
158
+
159
+ ## Linter notes
160
+
161
+ - CSS illustration primitives composed in the flagged SLOP-GRADIENT hue bands (indigo–violet,
162
+ purple–pink) read as the same AI-default aesthetic as gradient meshes in those bands.
163
+ The `SLOP-GRADIENT` rule does not fire on border-radius shapes directly, but shapes in
164
+ these hue ranges without a brand justification carry the same compositional risk. Record
165
+ the colour choice with a brand citation if it lands in those bands.
166
+
167
+ - Blobs with `filter: blur()` applied to large elements are paint-intensive. A blob
168
+ covering 70% of a 1280px section requires the browser to blur a ~900px region. Set
169
+ `will-change: transform` only if the blob animates; for static blobs, `will-change`
170
+ adds GPU memory cost without benefit.
171
+
172
+ - CSS shapes are decorative. Every shape element must carry `pointer-events: none` and
173
+ `aria-hidden="true"` when implemented as standalone HTML elements. Content must never
174
+ be inside a decorative shape element; it must be a sibling above it in z-order.
175
+
176
+ ## Do not combine with
177
+
178
+ **svg-geometric-patterns.md behind the same element** — organic blobs and geometric
179
+ patterns are opposite registers (organic randomness vs deliberate order). Combining both
180
+ on the same background surface produces visual incoherence.
181
+
182
+ **gradient-mesh.md at full opacity on the same surface** — a mesh gradient and a blob
183
+ both create ambient colour fields. Two ambient fields on the same surface merge into a
184
+ single muddied background rather than two distinct spatial signals. Use the blob or the
185
+ mesh, not both; or confine one to a small spot and the other to the full background.
186
+
187
+ **Purposeful photography or illustration** — CSS illustration primitives are a substitute
188
+ for imagery when none exists. When real photography or illustration is present, CSS
189
+ shapes compete with it for compositional attention.