-
Notifications
You must be signed in to change notification settings - Fork 7
Axis Interaction Matrix
A documented table of how the 7 synthesizer axes resolve conflicts when they disagree about a single token. Added in v3.0 to eliminate the "first axis wins" implicit precedence that ChatGPT (in T's review) flagged as the source of unpredictable output under load.
The 7 axes are independent dimensions, but they all influence overlapping token categories. When density=0.85 says "tight 4px spacing" and formality=0.85 says "breathing room for luxury feel," somebody has to win. Before v3.0, whichever axis the primitive consulted first won silently. After v3.0, the resolution is documented and tested.
| Token category | Interacting axes | Function | Documented in tests |
|---|---|---|---|
| Spacing base | density × formality |
spacing_base_for(axes) |
yes |
| Radius base | geometry × formality |
radius_base_px_for(axes, vocab_mean) |
yes |
| Motion timing | motion × formality |
motion_timing_for(axes) |
yes |
| Accent register | warmth × contrast |
accent_register_for(axes) |
yes |
All four live in engine/synthesizer/interactions.py. The DOCUMENTED_INTERACTIONS tuple at module top is a locked public contract — every test asserts this list is closed.
def spacing_base_for(axes):
dense = axes.density > 0.65
airy = axes.density < 0.4
formal = axes.formality > 0.7
if dense:
return 4 # density wins outright: Bloomberg-school
if airy and formal:
return 12 # formality wins: luxury breathing room
if airy:
return 8 # standard airy
return 6 # balanced middle| density | formality | base | Real-world archetype |
|---|---|---|---|
| > 0.65 | any | 4px | Bloomberg terminal, Datadog, fintech-trading |
| < 0.4 | > 0.7 | 12px | Aman resorts, Aesop, Apple stores |
| < 0.4 | < 0.7 | 8px | Aesop product cards, Glossier |
| else | any | 6px | Most SaaS |
The non-trivial case is "airy + formal." Without explicit resolution, you'd typically get airy → 8 (density rule) because density is the conventional spacing axis. v3.0 says no — when formality is also high, the brief is asking for luxury restraint and we widen.
def radius_base_px_for(axes, vocab_mean):
if axes.geometry < 0.3 and axes.formality > 0.7:
return 2 # sharp + corporate: NYT, Bloomberg
if axes.geometry > 0.7 and axes.formality < 0.4:
return 18 # soft + playful: Glossier, Hims
# Weighted blend
target = 0.5 * axes.geometry + 0.3 * vocab_mean + 0.2 * (1.0 - axes.formality * 0.5)
return max(2, int(round(target * 16)))| geometry | formality | radius | Archetype |
|---|---|---|---|
| < 0.3 | > 0.7 | 2px | NYT, Bloomberg, Saudi Aramco |
| > 0.7 | < 0.4 | 18px | Glossier, Hims, Mailchimp Y2K era |
| else | else | Blend | Most SaaS, fintech, e-commerce |
The blend formula: 50% direct geometry axis, 30% vocabulary mean from the brand exemplars (so if the synthesizer pulled 8 sharp brands, the radius defaults sharp regardless of the brief's geometry value), 20% formality dampener (high formality slightly reduces radius across the board).
def motion_timing_for(axes):
base_ms = int(220 - 80 * axes.motion)
if axes.formality > 0.7 and axes.motion > 0.5:
base_ms = int(base_ms * 1.25) # corporate dampening
return {
"base_ms": base_ms,
"fast_ms": max(80, int(base_ms * 0.6)),
"slow_ms": int(base_ms * 1.8),
}| motion | formality | base_ms | Archetype |
|---|---|---|---|
| 0.85 | 0.85 | ~175ms (dampened) | Bloomberg with hover animations |
| 0.85 | 0.30 | 140ms (full snap) | Discord, gaming UI |
| 0.30 | any | 196ms | Standard SaaS |
| 0.10 | 0.85 | 212ms | Legal, healthcare |
Corporate dampening exists because high motion + corporate formality is a real signal — Bloomberg wants kinetic interactions but slower than gaming UI. Without dampening, the synthesizer would emit gaming-fast timings on a financial product.
The motion curve is still axis-driven independently:
-
motion > 0.7→cubic-bezier(0.34, 1.56, 0.64, 1)— spring overshoot -
motion > 0.4→cubic-bezier(0.22, 1, 0.36, 1)— ease-cinema - else →
cubic-bezier(0.4, 0.0, 0.2, 1)— standard ease
def accent_register_for(axes):
return {
"saturation_target": min(1.0, 0.4 + 0.6 * axes.contrast),
"lightness_target": 0.55 - 0.10 * (axes.contrast - 0.5),
"warmth_register": (axes.warmth - 0.5) * 2,
}The accent color (CTAs, links, highlights, brand-mark) needs to land on a 2-D plane:
- Warmth register centered: -1 cool, 0 neutral, +1 warm
- Saturation target rises with contrast (loud → saturated, quiet → muted)
- Lightness target drops with contrast (loud → darker bolds)
This isn't a hard branch — it's three smooth scalars the palette synthesizer interpolates from.
Before v3.0, the spacing primitive checked density and picked a base. The formality axis didn't get consulted at all for spacing. Same with radius — geometry was the entire decision. This meant briefs like:
-
industry=luxury(formality=0.75 from seed) -
tone=["airy", "minimal"](density=0.3)
would produce 8px spacing (the airy branch of density-only logic). But luxury should be 12px or more — the airy tone tags REINFORCED the formality direction, not subverted it.
After v3.0, the same brief routes through spacing_base_for(axes) which sees BOTH axes and picks 12px. The output now matches what a designer would actually ship.
The pattern:
- Identify two axes that compete for a token.
- Define the conflict cases concretely with real-world archetypes (so tests can pin them).
- Write a pure function
<token>_for(axes)inengine/synthesizer/interactions.py. - Add the function name to
DOCUMENTED_INTERACTIONStuple. - Add test cases for each documented archetype.
- Wire the function into the primitive in
engine/synthesizer/core.py.
The locked tuple + test enforcement means new interactions are explicit and reviewed, not silently shipped.