@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.
- package/LICENSE +21 -0
- package/README.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- package/src/skills/omd-ultradesign/SKILL.md +521 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset sourcing policy core.
|
|
3
|
+
*
|
|
4
|
+
* OMD supports three asset paths, in order of precedence:
|
|
5
|
+
* 1. (default) User-provided assets, optionally treated with a duotone filter
|
|
6
|
+
* (see `core/graphics/duotone-image-presets.md`) for visual unification.
|
|
7
|
+
* 2. (conditional) AI-generated imagery — only when the host environment
|
|
8
|
+
* declares image-generation capability, and only for abstract/atmospheric
|
|
9
|
+
* zones. Factual carriers (team photos, product screenshots, real people,
|
|
10
|
+
* logos) may never be satisfied by AI-generated imagery.
|
|
11
|
+
* 3. (additive) WebGL/3D — gated on hand precedence (explicit user request OR
|
|
12
|
+
* greenfield concept necessity), a declared performance budget, and a
|
|
13
|
+
* non-canvas semantic fallback.
|
|
14
|
+
* When none of these apply, the design falls back to CSS/SVG treatments per
|
|
15
|
+
* `core/graphics/placeholder-policy.md` — a grey box is a defect, never the
|
|
16
|
+
* final answer.
|
|
17
|
+
*
|
|
18
|
+
* RUNTIME SCOPE NOTE: this module is a pure decision/gate core. It contains
|
|
19
|
+
* no I/O and calls no external services — it decides *whether* a path is
|
|
20
|
+
* permitted, never executes it. AI image generation IS wired at the agent
|
|
21
|
+
* layer: on a host that provides an image-generation capability (Codex,
|
|
22
|
+
* Claude Code), the composer/hand invoke it per `core/theory/imagegen.md`
|
|
23
|
+
* for image-first concept drafts and for abstract/atmospheric shipped assets,
|
|
24
|
+
* with this module as the admissibility gate and committed provenance recorded
|
|
25
|
+
* via `omd decision`. What remains environment-dependent is 3D/WebGL
|
|
26
|
+
* performance MEASUREMENT (render tracing, Lighthouse-style budgets): those
|
|
27
|
+
* adapters are intentionally NOT stubbed here and must not fabricate a fake
|
|
28
|
+
* "success" result for a capability the environment cannot exercise.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type AssetZone = 'factual' | 'abstract' | 'atmospheric' | 'structural';
|
|
32
|
+
|
|
33
|
+
export type AssetStrategy =
|
|
34
|
+
| 'user-asset-duotone'
|
|
35
|
+
| 'ai-image'
|
|
36
|
+
| 'webgl-3d'
|
|
37
|
+
| 'css-svg-fallback';
|
|
38
|
+
|
|
39
|
+
export interface AssetStrategyInputs {
|
|
40
|
+
hasUserAsset: boolean;
|
|
41
|
+
hostHasImageGen: boolean;
|
|
42
|
+
explicitWebglRequest?: boolean;
|
|
43
|
+
greenfieldConceptNecessity?: boolean;
|
|
44
|
+
perfBudgetDeclared?: boolean;
|
|
45
|
+
zone: AssetZone;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AssetStrategyDecision {
|
|
49
|
+
strategy: AssetStrategy;
|
|
50
|
+
reason: string;
|
|
51
|
+
fallbackChain: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decide the asset sourcing strategy for a single content slot. Pure
|
|
56
|
+
* decision function — no I/O, no runtime capability checks beyond the
|
|
57
|
+
* declared inputs.
|
|
58
|
+
*/
|
|
59
|
+
export function decideAssetStrategy(inputs: AssetStrategyInputs): AssetStrategyDecision {
|
|
60
|
+
const { hasUserAsset, hostHasImageGen, zone } = inputs;
|
|
61
|
+
const explicitWebglRequest = inputs.explicitWebglRequest ?? false;
|
|
62
|
+
const greenfieldConceptNecessity = inputs.greenfieldConceptNecessity ?? false;
|
|
63
|
+
const perfBudgetDeclared = inputs.perfBudgetDeclared ?? false;
|
|
64
|
+
|
|
65
|
+
if (hasUserAsset) {
|
|
66
|
+
return {
|
|
67
|
+
strategy: 'user-asset-duotone',
|
|
68
|
+
reason: 'A user-provided asset is available; it is the default source and takes precedence over AI generation or WebGL.',
|
|
69
|
+
fallbackChain: ['user-asset-duotone', 'css-svg-fallback'],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Factual carriers (team photos, product screenshots, real people, logos)
|
|
74
|
+
// may never be satisfied by AI-generated imagery, regardless of host
|
|
75
|
+
// capability. See `validateAiImageUsage` for the corresponding gate.
|
|
76
|
+
const aiImageEligibleZone = zone === 'abstract' || zone === 'atmospheric';
|
|
77
|
+
if (zone !== 'factual' && aiImageEligibleZone && hostHasImageGen) {
|
|
78
|
+
return {
|
|
79
|
+
strategy: 'ai-image',
|
|
80
|
+
reason: `No user asset is available; the "${zone}" zone permits AI-generated imagery and the host declares image-generation capability.`,
|
|
81
|
+
fallbackChain: ['ai-image', 'css-svg-fallback'],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handPrecedence = explicitWebglRequest || greenfieldConceptNecessity;
|
|
86
|
+
if (handPrecedence && perfBudgetDeclared) {
|
|
87
|
+
return {
|
|
88
|
+
strategy: 'webgl-3d',
|
|
89
|
+
reason: 'No user asset or AI image path applies; WebGL/3D escalation is permitted by hand precedence and a declared performance budget.',
|
|
90
|
+
fallbackChain: ['webgl-3d', 'css-svg-fallback'],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
strategy: 'css-svg-fallback',
|
|
96
|
+
reason: zone === 'factual'
|
|
97
|
+
? 'The zone is factual: AI-generated imagery is never permitted, no user asset was provided, and WebGL escalation was not authorised. Falling back to CSS/SVG per the placeholder policy.'
|
|
98
|
+
: 'No user asset, no eligible AI-image path, and no authorised WebGL escalation. Falling back to CSS/SVG per the placeholder policy.',
|
|
99
|
+
fallbackChain: ['css-svg-fallback'],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AiImageUsageInputs {
|
|
104
|
+
zone: AssetZone;
|
|
105
|
+
hostHasImageGen: boolean;
|
|
106
|
+
provenance?: {
|
|
107
|
+
prompt?: string;
|
|
108
|
+
provider?: string;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AiImageUsageValidation {
|
|
113
|
+
allowed: boolean;
|
|
114
|
+
violations: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Gate for whether a specific AI-generated image is permitted to ship.
|
|
119
|
+
* Pure validation — the actual image generation call is a Phase 2 runtime
|
|
120
|
+
* adapter concern (see module header); this only decides admissibility.
|
|
121
|
+
*/
|
|
122
|
+
export function validateAiImageUsage(inputs: AiImageUsageInputs): AiImageUsageValidation {
|
|
123
|
+
const violations: string[] = [];
|
|
124
|
+
|
|
125
|
+
if (inputs.zone === 'factual') {
|
|
126
|
+
violations.push('factual carrier: AI-generated imagery is never permitted for factual slots (team photos, product screenshots, real people, logos).');
|
|
127
|
+
} else if (inputs.zone !== 'abstract' && inputs.zone !== 'atmospheric') {
|
|
128
|
+
violations.push(`ineligible zone: AI-generated imagery is only permitted in abstract or atmospheric zones, not "${inputs.zone}".`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!inputs.hostHasImageGen) {
|
|
132
|
+
violations.push('host capability: the host environment does not declare image-generation capability.');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!inputs.provenance?.prompt) {
|
|
136
|
+
violations.push('provenance missing: prompt is required to record how the image was generated.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!inputs.provenance?.provider) {
|
|
140
|
+
violations.push('provenance missing: provider is required to record how the image was generated.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
allowed: violations.length === 0,
|
|
145
|
+
violations,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface WebglEscalationInputs {
|
|
150
|
+
explicitUserRequest: boolean;
|
|
151
|
+
greenfieldConceptNecessity: boolean;
|
|
152
|
+
perfBudgetDeclared: boolean;
|
|
153
|
+
semanticFallbackPresent: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface WebglEscalationDecision {
|
|
157
|
+
permitted: boolean;
|
|
158
|
+
reasons: string[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Gate for whether a WebGL/3D escalation is permitted. Pure decision —
|
|
163
|
+
* actual 3D performance measurement (render tracing, frame-budget
|
|
164
|
+
* verification) is a Phase 2 runtime adapter concern (see module header);
|
|
165
|
+
* this only decides admissibility from declared inputs.
|
|
166
|
+
*/
|
|
167
|
+
export function evaluateWebglEscalation(inputs: WebglEscalationInputs): WebglEscalationDecision {
|
|
168
|
+
const reasons: string[] = [];
|
|
169
|
+
|
|
170
|
+
const handPrecedence = inputs.explicitUserRequest || inputs.greenfieldConceptNecessity;
|
|
171
|
+
if (!handPrecedence) {
|
|
172
|
+
reasons.push('hand precedence not established: neither an explicit user request nor a greenfield concept necessity was declared.');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!inputs.perfBudgetDeclared) {
|
|
176
|
+
reasons.push('performance budget not declared: a WebGL/3D escalation requires an explicit performance budget.');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!inputs.semanticFallbackPresent) {
|
|
180
|
+
reasons.push('semantic fallback missing: a non-canvas semantic fallback is required before a WebGL/3D escalation ships.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
permitted: reasons.length === 0,
|
|
185
|
+
reasons,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CoachReport, Category, Recurring, Run } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
/** Below this many runs, a trend is noise, not a signal. */
|
|
4
|
+
const MIN_RUNS_FOR_CONFIDENCE = 4;
|
|
5
|
+
|
|
6
|
+
/** A rule must show up in at least this many distinct runs to be called recurring. */
|
|
7
|
+
const MIN_DISTINCT_RUNS = 2;
|
|
8
|
+
|
|
9
|
+
function categoryOf(ruleId: string): Category {
|
|
10
|
+
if (ruleId.startsWith('SLOP-')) return 'slop';
|
|
11
|
+
if (ruleId.startsWith('CONTRAST-') || ruleId.startsWith('HIT-')) return 'a11y';
|
|
12
|
+
return 'system';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mean(runs: Run[], ruleId: string): number {
|
|
16
|
+
if (runs.length === 0) return 0;
|
|
17
|
+
const sum = runs.reduce((acc, r) => acc + (r.counts[ruleId] ?? 0), 0);
|
|
18
|
+
return sum / runs.length;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The change between the first and second half of the runs.
|
|
23
|
+
*
|
|
24
|
+
* A rule absent from the first half has no baseline, so a percent change does not exist.
|
|
25
|
+
* Reporting one — "+500%" because it went from zero to five — invents a statistic. The
|
|
26
|
+
* direction is knowable; the percentage is not, and `null` says so.
|
|
27
|
+
*/
|
|
28
|
+
function changeFor(runs: Run[], ruleId: string): { changePct: number | null; trend: Recurring['trend'] } {
|
|
29
|
+
const mid = Math.floor(runs.length / 2);
|
|
30
|
+
const first = mean(runs.slice(0, mid), ruleId);
|
|
31
|
+
const second = mean(runs.slice(mid), ruleId);
|
|
32
|
+
|
|
33
|
+
if (first === 0) {
|
|
34
|
+
if (second === 0) return { changePct: 0, trend: 'flat' };
|
|
35
|
+
return { changePct: null, trend: 'worsening' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const changePct = Math.round(((second - first) / first) * 100);
|
|
39
|
+
if (changePct <= -10) return { changePct, trend: 'improving' };
|
|
40
|
+
if (changePct >= 10) return { changePct, trend: 'worsening' };
|
|
41
|
+
return { changePct, trend: 'flat' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Skill, not taste. Coach only ever looks at `history` (what the linter found) and
|
|
48
|
+
* `decisions` (what the user overruled and why it was written down) — never
|
|
49
|
+
* `.omd/taste/`, which records preferences that have no right answer.
|
|
50
|
+
*/
|
|
51
|
+
export function analyse(history: Run[], decisions: string[]): CoachReport {
|
|
52
|
+
const confident = history.length >= MIN_RUNS_FOR_CONFIDENCE;
|
|
53
|
+
|
|
54
|
+
const span = history.length === 0
|
|
55
|
+
? null
|
|
56
|
+
: { from: history[0]!.ts, to: history[history.length - 1]!.ts };
|
|
57
|
+
|
|
58
|
+
const ruleIds = new Set<string>();
|
|
59
|
+
for (const run of history) for (const ruleId of Object.keys(run.counts)) ruleIds.add(ruleId);
|
|
60
|
+
|
|
61
|
+
const recurring: Recurring[] = [];
|
|
62
|
+
for (const ruleId of ruleIds) {
|
|
63
|
+
const appearances = history.filter((r) => (r.counts[ruleId] ?? 0) > 0);
|
|
64
|
+
if (appearances.length < MIN_DISTINCT_RUNS) continue;
|
|
65
|
+
|
|
66
|
+
const total = history.reduce((acc, r) => acc + (r.counts[ruleId] ?? 0), 0);
|
|
67
|
+
|
|
68
|
+
if (!confident) {
|
|
69
|
+
recurring.push({
|
|
70
|
+
rule: ruleId, category: categoryOf(ruleId), total, runs: appearances.length,
|
|
71
|
+
trend: 'insufficient', changePct: null,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { changePct, trend } = changeFor(history, ruleId);
|
|
77
|
+
recurring.push({
|
|
78
|
+
rule: ruleId, category: categoryOf(ruleId), total, runs: appearances.length,
|
|
79
|
+
trend, changePct,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
recurring.sort((a, b) => b.total - a.total || byCodeUnit(a.rule, b.rule));
|
|
84
|
+
|
|
85
|
+
// One decision is one overrule, however many times it names the rule. A decision that
|
|
86
|
+
// mentions SLOP-GRADIENT in both its title and its reason was still a single choice.
|
|
87
|
+
const overruleCounts = new Map<string, number>();
|
|
88
|
+
for (const decision of decisions) {
|
|
89
|
+
for (const rule of new Set(decision.match(/\bSLOP-[A-Z-]+\b/g) ?? [])) {
|
|
90
|
+
overruleCounts.set(rule, (overruleCounts.get(rule) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const overrules = [...overruleCounts.entries()]
|
|
94
|
+
.map(([rule, count]) => ({ rule, count }))
|
|
95
|
+
.sort((a, b) => b.count - a.count || byCodeUnit(a.rule, b.rule));
|
|
96
|
+
|
|
97
|
+
return { runs: history.length, span, recurring, overrules, confident };
|
|
98
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# App shell workbench
|
|
2
|
+
|
|
3
|
+
> Candidate hypothesis only. Revalidate its condition, values, and responsive transition
|
|
4
|
+
> against the current composition contract; do not transfer this page recipe unchanged.
|
|
5
|
+
|
|
6
|
+
The default grammar for a `product` work surface: a persistent shell (top bar and/or side
|
|
7
|
+
navigation) framing a dominant work region that owns the viewport. The shell answers
|
|
8
|
+
"where am I, where else can I go"; the work region carries the task loop. Nothing here is
|
|
9
|
+
a scroll narrative — the page does not argue, it operates. `core/theory/ux.md` §Surface
|
|
10
|
+
types names the condition; `core/theory/layout.md` §Density governs how loud the data may
|
|
11
|
+
be.
|
|
12
|
+
|
|
13
|
+
## When it earns its place / When it does not
|
|
14
|
+
|
|
15
|
+
Condition: the frame's `uxSurface` is `product` and the primary screen serves a repeated
|
|
16
|
+
task loop over work objects — a queue, a dashboard, a console, an admin table, an editor.
|
|
17
|
+
The user returns to this screen many times a day; orientation cost must be near zero and
|
|
18
|
+
the frequent action reachable within the first viewport.
|
|
19
|
+
|
|
20
|
+
The shell earns a side navigation only when the product has 4+ peer destinations the user
|
|
21
|
+
switches between; below that, a top bar with inline destinations costs less horizontal
|
|
22
|
+
space and leaves more room for the work object (`theory/layout.md`: density follows task
|
|
23
|
+
frequency, not furniture convention).
|
|
24
|
+
|
|
25
|
+
Condition against: a single-surface marketing or editorial page (no destinations to hold),
|
|
26
|
+
or a focused flow (checkout, onboarding wizard) where global navigation invites
|
|
27
|
+
mid-task escape — use `form-wizard-stepper.md` there. Also against: using the shell as
|
|
28
|
+
decoration around content that is itself a landing page; an app shell does not convert a
|
|
29
|
+
persuasion page into a product.
|
|
30
|
+
|
|
31
|
+
**Anti-hero clause.** The work region's first viewport belongs to the work object at
|
|
32
|
+
representative data density. A display-scale headline, hero band, marketing gradient, or
|
|
33
|
+
illustration claiming that viewport is a grammar defect on this surface — the orientation
|
|
34
|
+
line (screen name + one-line state summary, e.g. "받은함 — 대기 12") is the entire
|
|
35
|
+
permitted "hero".
|
|
36
|
+
|
|
37
|
+
## Parameters
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
:root {
|
|
41
|
+
/* Shell chrome sizes. The top bar stays under 56px so the work region keeps
|
|
42
|
+
the viewport; the side nav collapses to icons at tablet width. */
|
|
43
|
+
--shell-topbar-h: 52px;
|
|
44
|
+
--shell-sidenav-w: 220px;
|
|
45
|
+
--shell-sidenav-w-collapsed: 56px;
|
|
46
|
+
|
|
47
|
+
/* Work region density. Row height decides rows-per-viewport: 44-48px serves
|
|
48
|
+
expert repeated scanning; 56px+ serves low-frequency browsing. */
|
|
49
|
+
--work-row-h: 46px;
|
|
50
|
+
|
|
51
|
+
/* One accent marks the primary action and the current nav location.
|
|
52
|
+
Chrome stays neutral so data carries the color. */
|
|
53
|
+
--shell-accent: var(--accent, #1f6f5c);
|
|
54
|
+
--shell-chrome-bg: var(--surface, #fff);
|
|
55
|
+
--shell-chrome-line: var(--line, #e5e7e5);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Implementation
|
|
60
|
+
|
|
61
|
+
```html
|
|
62
|
+
<!-- The shell is a grid, not a scroll sequence. DOM order: shell nav first
|
|
63
|
+
(it is the product's map), then the work region — focus order follows. -->
|
|
64
|
+
<div class="app-shell">
|
|
65
|
+
<header class="shell-topbar">
|
|
66
|
+
<span class="shell-product">제품명</span>
|
|
67
|
+
<p class="shell-orientation">받은함 — 대기 12 · 진행 4</p>
|
|
68
|
+
<nav class="shell-account" aria-label="계정"><!-- profile, settings --></nav>
|
|
69
|
+
</header>
|
|
70
|
+
|
|
71
|
+
<nav class="shell-sidenav" aria-label="주 탐색">
|
|
72
|
+
<!-- The user's nouns, current location marked with aria-current. -->
|
|
73
|
+
<a href="/inbox" aria-current="page">받은함</a>
|
|
74
|
+
<a href="/customers">고객</a>
|
|
75
|
+
<a href="/reports">리포트</a>
|
|
76
|
+
<a href="/settings">설정</a>
|
|
77
|
+
</nav>
|
|
78
|
+
|
|
79
|
+
<main class="shell-work">
|
|
80
|
+
<!-- The work object owns this region: toolbar (search/filters/primary
|
|
81
|
+
action), then the queue/table/canvas at working density. -->
|
|
82
|
+
</main>
|
|
83
|
+
</div>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```css
|
|
87
|
+
.app-shell {
|
|
88
|
+
display: grid;
|
|
89
|
+
grid-template-columns: var(--shell-sidenav-w) minmax(0, 1fr);
|
|
90
|
+
grid-template-rows: var(--shell-topbar-h) minmax(0, 1fr);
|
|
91
|
+
grid-template-areas:
|
|
92
|
+
"topbar topbar"
|
|
93
|
+
"sidenav work";
|
|
94
|
+
min-height: 100vh;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.shell-topbar {
|
|
98
|
+
grid-area: topbar;
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: 16px;
|
|
102
|
+
background: var(--shell-chrome-bg);
|
|
103
|
+
border-bottom: 1px solid var(--shell-chrome-line);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.shell-sidenav {
|
|
107
|
+
grid-area: sidenav;
|
|
108
|
+
border-right: 1px solid var(--shell-chrome-line);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.shell-sidenav a[aria-current="page"] {
|
|
112
|
+
color: var(--shell-accent);
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
/* Location is marked structurally (inset bar), not by tint alone. */
|
|
115
|
+
box-shadow: inset 3px 0 0 var(--shell-accent);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.shell-work {
|
|
119
|
+
grid-area: work;
|
|
120
|
+
min-width: 0; /* tables and canvases must be allowed to shrink, not overflow */
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The work region's internal order is the task loop: orientation line → locate controls
|
|
125
|
+
(search, filters — always visible on an expert surface, never buried in a menu) → the
|
|
126
|
+
work object → detail/action surfaces. Reachable states (loading skeleton, empty first-run,
|
|
127
|
+
filtered-to-zero, error with recovery) are layouts of this same region, not separate
|
|
128
|
+
pages.
|
|
129
|
+
|
|
130
|
+
## Responsive behavior
|
|
131
|
+
|
|
132
|
+
**1280px (desktop):** full shell — side nav expanded, work region at working density,
|
|
133
|
+
master-detail splits permitted inside the work region (see `master-detail-flow.md`).
|
|
134
|
+
|
|
135
|
+
**768px (tablet):** the side nav collapses to `--shell-sidenav-w-collapsed` icon rail with
|
|
136
|
+
labels on focus/hover; the work region keeps density. Two-region splits inside the work
|
|
137
|
+
area become stacked or overlay presentations.
|
|
138
|
+
|
|
139
|
+
**375px (mobile):** the shell inverts: top bar keeps the orientation line and a menu
|
|
140
|
+
disclosure; primary destinations move to a bottom tab bar within thumb reach
|
|
141
|
+
(`theory/ux.md` §Navigation — the frequent action must stay in the natural grip zone);
|
|
142
|
+
tables recompose to list rows. The frequent action stays within the first viewport —
|
|
143
|
+
sticky where the task demands it (e.g. a reply bar). Test 320px reflow: no horizontal
|
|
144
|
+
scroll except intentionally two-dimensional data.
|
|
145
|
+
|
|
146
|
+
```css
|
|
147
|
+
@media (max-width: 760px) {
|
|
148
|
+
.app-shell {
|
|
149
|
+
grid-template-columns: 1fr;
|
|
150
|
+
grid-template-areas: "topbar" "work" "tabbar";
|
|
151
|
+
}
|
|
152
|
+
.shell-sidenav { display: none; }
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Do not combine with
|
|
157
|
+
|
|
158
|
+
**split-screen-hero.md / typographic-hero.md** — hero grammars claim the first viewport
|
|
159
|
+
that belongs to the work object; combining them recreates the landing-page-on-a-tool
|
|
160
|
+
defect this recipe exists to prevent.
|
|
161
|
+
|
|
162
|
+
**bento-grid.md as the work region** — a bento mosaic imposes editorial hierarchy on
|
|
163
|
+
peer work objects; a queue or table's hierarchy is in the data (sort order, status),
|
|
164
|
+
not in cell sizes. Bento is permitted only for a genuine overview dashboard where
|
|
165
|
+
metrics have real priority differences.
|
|
166
|
+
|
|
167
|
+
**section-inversion.md inside the work region** — alternating section backgrounds is a
|
|
168
|
+
scroll-narrative rhythm; a work surface has regions, not narrative sections.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Asymmetric / diagonal grid
|
|
2
|
+
|
|
3
|
+
> Candidate hypothesis only. Revalidate its condition, values, and responsive transition
|
|
4
|
+
> against the current composition contract; do not transfer this page recipe unchanged.
|
|
5
|
+
|
|
6
|
+
A grid where one or more elements break from the column structure — rotated, offset, or
|
|
7
|
+
overlapping — to create tension and signal editorial intent. The effect reads as deliberate
|
|
8
|
+
when the deviation is singular; a page full of diagonal elements is not tension, it is noise.
|
|
9
|
+
|
|
10
|
+
`expressive.md` on this technique: "One diagonal or off-grid element is a decision; a page
|
|
11
|
+
full of diagonal elements is entropy."
|
|
12
|
+
|
|
13
|
+
## When it earns its place / When it does not
|
|
14
|
+
|
|
15
|
+
Condition: the concept requires editorial tension — a sense that the content is not
|
|
16
|
+
contained, that something is pressing against its frame. Agency portfolios, magazine-format
|
|
17
|
+
editorial pages, brand campaign layouts with a provocative position. The asymmetry must
|
|
18
|
+
follow from the concept's governing metaphor; a diagonal that does not correspond to a
|
|
19
|
+
conceptual gesture is decoration at the scale of the layout.
|
|
20
|
+
|
|
21
|
+
`core/theory/layout.md` — grid section: "Breaking the grid is only meaningful when the
|
|
22
|
+
deviation is singular and intentional — a full-bleed image in an otherwise columned
|
|
23
|
+
editorial layout announces itself as a break precisely because everything else held the
|
|
24
|
+
column."
|
|
25
|
+
|
|
26
|
+
Condition against: data-heavy layouts where scanability is the primary requirement.
|
|
27
|
+
Tables, forms, pricing structures, documentation — any content the user reads in order or
|
|
28
|
+
scans for a specific item. The asymmetric element interrupts the scan path without
|
|
29
|
+
contributing information. `core/theory/layout.md` confirms: the F-pattern (text-heavy
|
|
30
|
+
pages) depends on a stable left edge; an off-grid element that cuts across that edge
|
|
31
|
+
disrupts the pattern.
|
|
32
|
+
|
|
33
|
+
Also: avoid when the headline is already carrying heavy typographic weight. A typographic
|
|
34
|
+
hero at 9rem and a diagonal element compete for the same attention budget.
|
|
35
|
+
|
|
36
|
+
## Parameters
|
|
37
|
+
|
|
38
|
+
```css
|
|
39
|
+
:root {
|
|
40
|
+
/* The column structure underlying the layout.
|
|
41
|
+
An asymmetric grid is still a grid — the off-grid element breaks one rule
|
|
42
|
+
in a system where all others hold. Typical: 12 columns, 24px gutters. */
|
|
43
|
+
--grid-columns: 12;
|
|
44
|
+
--grid-gutter: clamp(16px, 2vw, 24px);
|
|
45
|
+
--grid-margin: clamp(16px, 5vw, 80px);
|
|
46
|
+
|
|
47
|
+
/* Rotation of the off-grid element.
|
|
48
|
+
Small angles (3–7°) read as intentional tilt.
|
|
49
|
+
Larger angles (15°+) read as a different object category.
|
|
50
|
+
Keep this value from the board's reference study — do not pick it arbitrarily. */
|
|
51
|
+
--diagonal-rotate: 4deg;
|
|
52
|
+
|
|
53
|
+
/* The off-grid element's column span and offset.
|
|
54
|
+
Extending beyond the grid margin by a fixed amount produces the overlap signal. */
|
|
55
|
+
--diagonal-bleed: 5vw;
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Implementation
|
|
60
|
+
|
|
61
|
+
```html
|
|
62
|
+
<!-- The grid provides the structure. The .diagonal element breaks it deliberately.
|
|
63
|
+
All other content holds the column. -->
|
|
64
|
+
<section class="asym-grid">
|
|
65
|
+
<div class="asym-grid__main">
|
|
66
|
+
<h2>Primary content holds the column.</h2>
|
|
67
|
+
<p>Body copy stays on the grid. The eye has a stable return point.</p>
|
|
68
|
+
</div>
|
|
69
|
+
<!-- This element is the single off-grid decision. -->
|
|
70
|
+
<div class="asym-grid__diagonal" aria-hidden="true">
|
|
71
|
+
<!-- Decorative content: a large number, a rotated label, a partial image. -->
|
|
72
|
+
<span class="diagonal-label">Editorial</span>
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```css
|
|
78
|
+
.asym-grid {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-columns: repeat(var(--grid-columns), 1fr);
|
|
81
|
+
gap: var(--grid-gutter);
|
|
82
|
+
padding-inline: var(--grid-margin);
|
|
83
|
+
position: relative;
|
|
84
|
+
overflow: hidden; /* prevent bleed from creating horizontal scroll */
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.asym-grid__main {
|
|
88
|
+
/* Main content occupies the left 7 columns. */
|
|
89
|
+
grid-column: 1 / 8;
|
|
90
|
+
/* Standard vertical rhythm — no transform applied to readable text. */
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.asym-grid__diagonal {
|
|
94
|
+
/* Diagonal element: right side, overlapping main content zone. */
|
|
95
|
+
grid-column: 6 / 13;
|
|
96
|
+
/* Extend beyond the right margin */
|
|
97
|
+
margin-right: calc(-1 * var(--diagonal-bleed));
|
|
98
|
+
transform: rotate(var(--diagonal-rotate));
|
|
99
|
+
transform-origin: center left;
|
|
100
|
+
/* Decorative — sits behind or at same level as content depending on concept */
|
|
101
|
+
z-index: 0;
|
|
102
|
+
/* pointer-events off: the rotated element must not obstruct interactive elements */
|
|
103
|
+
pointer-events: none;
|
|
104
|
+
user-select: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.diagonal-label {
|
|
108
|
+
display: block;
|
|
109
|
+
font-size: clamp(4rem, 10vw, 8rem);
|
|
110
|
+
font-weight: var(--weight-display, 700);
|
|
111
|
+
opacity: 0.08; /* background element: presence without competition */
|
|
112
|
+
letter-spacing: -0.04em;
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Responsive behavior
|
|
118
|
+
|
|
119
|
+
**375px (mobile):** The 12-column grid collapses to a single column; the diagonal element
|
|
120
|
+
moves out of the content flow. On narrow viewports the off-grid element is not the concept
|
|
121
|
+
— it is a distraction from the content that must fit in a reduced space. Hide it entirely
|
|
122
|
+
below 600px unless it carries essential meaning.
|
|
123
|
+
|
|
124
|
+
```css
|
|
125
|
+
@media (max-width: 600px) {
|
|
126
|
+
.asym-grid {
|
|
127
|
+
grid-template-columns: 1fr;
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.asym-grid__diagonal {
|
|
132
|
+
display: none; /* decorative element suppressed; content must stand without it */
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.asym-grid__main {
|
|
136
|
+
grid-column: 1 / -1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**768px (tablet):** Reduce from 12 to 8 columns. The diagonal element may occupy the right
|
|
142
|
+
4 columns; the main content the left 5. The rotation value stays constant — the angle
|
|
143
|
+
should not change with viewport width, as it reads as a different decision at each size.
|
|
144
|
+
|
|
145
|
+
```css
|
|
146
|
+
@media (min-width: 601px) and (max-width: 1024px) {
|
|
147
|
+
.asym-grid {
|
|
148
|
+
grid-template-columns: repeat(8, 1fr);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.asym-grid__main {
|
|
152
|
+
grid-column: 1 / 6;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.asym-grid__diagonal {
|
|
156
|
+
grid-column: 4 / 9;
|
|
157
|
+
margin-right: calc(-1 * var(--diagonal-bleed));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**1280px (desktop):** Full 12-column grid. The diagonal element's bleed beyond the margin
|
|
163
|
+
is now reading as a deliberate overextension — the composition's tension point. Ensure the
|
|
164
|
+
right edge of the diagonal element does not create horizontal scroll on the body; use
|
|
165
|
+
`overflow: hidden` on the section.
|
|
166
|
+
|
|
167
|
+
## Do not combine with
|
|
168
|
+
|
|
169
|
+
**bento-grid.md** — both recipes alter grid structure at the layout level; two competing
|
|
170
|
+
spatial systems in one section produce incoherence rather than tension.
|
|
171
|
+
|
|
172
|
+
**editorial-index-labels.md** in the same section — both add large, low-opacity display
|
|
173
|
+
elements in the peripheral field. Combined, they produce visual noise at the edges while
|
|
174
|
+
the readable content fights for attention in the centre.
|
|
175
|
+
|
|
176
|
+
**sticky-sidebar-scroll.md** — the sidebar annotation system depends on a stable spatial
|
|
177
|
+
contract (main column + margin column); a diagonal element breaking that contract makes
|
|
178
|
+
the annotation impossible to anchor correctly.
|