Skip to content

Axis Interaction Matrix

Laith0003 edited this page May 28, 2026 · 1 revision

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.

Why this exists

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.

The four documented interaction pairs

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.

Spacing: density × formality

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.

Radius: geometry × formality

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).

Motion timing: motion × formality

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.7cubic-bezier(0.34, 1.56, 0.64, 1) — spring overshoot
  • motion > 0.4cubic-bezier(0.22, 1, 0.36, 1) — ease-cinema
  • else → cubic-bezier(0.4, 0.0, 0.2, 1) — standard ease

Accent register: warmth × contrast

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.

Why "first axis wins" was the wrong default

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.

Adding a new interaction

The pattern:

  1. Identify two axes that compete for a token.
  2. Define the conflict cases concretely with real-world archetypes (so tests can pin them).
  3. Write a pure function <token>_for(axes) in engine/synthesizer/interactions.py.
  4. Add the function name to DOCUMENTED_INTERACTIONS tuple.
  5. Add test cases for each documented archetype.
  6. 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.

Related

Clone this wiki locally