Skip to content

Foundation

Guides

What to do and what not to do when building with Nodus. Each rule includes a rationale — if the rationale does not apply, the rule does not apply.

01Token Usage

Every visual property should trace to a token. Semantic tokens communicate intent; primitives are fallbacks; hardcoded values are banned.

Use semantic tokens over primitives

DO

Semantic token communicates intent:

style={{ color: "var(--ds-text-primary)" }}
DON'T

Hardcoded hex couples to a single theme:

style={{ color: "#18181B" }}

Semantic tokens adapt across themes (NueBauhaus, dark mode). Hex values create maintenance debt and break theme portability.

Reference the token hierarchy

DO

Semantic > primitive > never raw:

// Best — semantic
background: "var(--ds-surface-raised)"

// Acceptable — primitive, when no semantic exists
background: "var(--bh-gray-1)"
DON'T

Skipping the token layer entirely:

background: "#F4F4F5"
background: "rgb(244, 244, 245)"
background: "gray"

The three-layer hierarchy (semantic → primitive → raw) exists because each layer adds meaning. Always start at the highest layer available.

Use spacing tokens for layout

DO

Spacing token keeps rhythm consistent:

padding: "var(--ds-space-inset-sm)"
gap: "var(--ds-space-stack-xs)"
DON'T

Magic numbers break the spatial grid:

padding: "13px"
gap: "7px"

Spacing tokens enforce an 8px grid. Arbitrary pixel values create visual noise and inconsistent component spacing.

02Component Composition

Build interfaces by composing existing components. Every new div is a missed reuse opportunity.

Compose existing DS components

DO

Reuse StatusDot, Badge, Tag from the library:

<div style={{ display: "flex", gap: "8px" }}>
  <StatusDot state="active" animate />
  <Badge variant="outlined">NOVA-7B</Badge>
  <Tag>orchestration</Tag>
</div>
DON'T

Rebuilding primitives with raw markup:

<div style={{ display: "flex", gap: "8px" }}>
  <span style={{ width: 8, height: 8,
    borderRadius: "50%", background: "green" }} />
  <span style={{ border: "1px solid #ccc",
    padding: "2px 6px", fontSize: 11 }}>NOVA-7B</span>
</div>

DS components carry accessibility, animation, token binding, and theme support. Raw markup gets none of that for free.

Prefer flat composition over deep nesting

DO

Adjacent siblings with clear roles:

<AgentCard agent={agent}>
  <ConfidenceMeter value={0.92} />
  <IntegritySeal status="verified" />
</AgentCard>
DON'T

Wrapper divs that add no semantic value:

<div className="card-wrapper">
  <div className="card-inner">
    <div className="card-content">
      <AgentCard agent={agent} />
    </div>
  </div>
</div>

Every wrapper div dilutes semantic meaning and complicates DOM traversal. Components should compose directly.

03Accessibility

Every component is used by screen readers, keyboard navigators, and automation tools. Accessibility is not a feature — it is the baseline.

Use ARIA roles on compound components

DO

ReasoningTree: full ARIA tree pattern:

<div role="tree" aria-label="Reasoning chain">
  <div role="treeitem" aria-expanded={expanded}
       tabIndex={0} onKeyDown={handleKey}>
    {node.label}
  </div>
</div>
DON'T

Div soup with no semantic roles:

<div className="tree">
  <div className="node" onClick={toggle}>
    {node.label}
  </div>
</div>

ARIA roles tell assistive technology what the structure IS. Without them, a tree is just a stack of anonymous divs.

Support keyboard navigation

DO

Enter/Space to toggle, arrow keys to navigate:

onKeyDown={(e) => {
  if (e.key === "Enter" || e.key === " ") {
    e.preventDefault();
    toggle();
  }
}}
tabIndex={0}
DON'T

Click-only interaction on non-button elements:

<div onClick={toggle} style={{ cursor: "pointer" }}>
  {label}
</div>

Keyboard users cannot click. Every interactive element needs an onKeyDown handler and a tabIndex to be reachable.

Provide aria-label for non-text content

DO

Label communicates meaning to screen readers:

<div role="meter" aria-label="Confidence score"
     aria-valuenow={92} aria-valuemin={0}
     aria-valuemax={100}>
  <div style={{ width: "92%" }} />
</div>
DON'T

Visual-only meter with no accessible name:

<div className="meter">
  <div style={{ width: "92%" }} />
</div>

A progress bar without aria-label is invisible to screen readers. The visual width means nothing without a semantic value.

04Styling Patterns

Pattern components use inline styles with CSS custom properties. Site/layout components use Tailwind. Never cross the streams.

Inline styles with tokens in pattern components

DO

Pattern component uses inline styles + tokens:

<div style={{
  fontFamily: "var(--ds-type-label-font)",
  fontSize: "var(--ds-type-meta-size)",
  color: "var(--ds-text-secondary)",
  letterSpacing: "var(--ds-type-label-tracking)",
}}>
  AGENT STATUS
</div>
DON'T

Tailwind classes in pattern components:

<div className="font-mono text-xs text-gray-400
  tracking-wider uppercase">
  AGENT STATUS
</div>

Pattern components are framework-portable. Inline styles with CSS variables work in React, Vue, Svelte, or vanilla HTML. Tailwind couples to a build tool.

Tailwind in site/layout components only

DO

Site shell, nav, page layout use Tailwind:

<div className="grid grid-cols-[48px_1fr] gap-8
  py-10 border-b border-[var(--ds-border-separator)]">
  {/* Page layout */}
</div>
DON'T

Mixing Tailwind into reusable DS components:

// In components/patterns/AgentCard.tsx
<div className="flex items-center gap-3 p-4
  border rounded-lg shadow-sm">

Site components are Next.js-specific. Pattern components are the exportable library. Keep the dependency boundary clean.

05Content for AI Interfaces

AI-native interfaces need content patterns that are legible to both humans and machines. Labels should be unambiguous, timestamps machine-readable.

Use uppercase status labels

DO

Unambiguous machine-readable status:

<span style={{
  fontSize: "var(--ds-type-micro-size)",
  fontWeight: 700,
  letterSpacing: "var(--ds-type-label-tracking)",
  textTransform: "uppercase",
}}>
  GENERATING
</span>
DON'T

Casual, ambiguous status text:

<span className="status">
  thinking...
</span>

Uppercase labels with fixed tracking are the Nodus content idiom. They communicate machine state, not human emotion. 'GENERATING' is a fact; 'thinking...' is anthropomorphism.

Use ISO timestamps, not relative time alone

DO

Absolute timestamp with optional relative:

<time dateTime="2026-03-04T14:32:00Z"
  style={{ fontFamily: "var(--ds-type-mono-font)" }}>
  2026-03-04 14:32:00
</time>
DON'T

Relative-only time that decays:

<span>2 minutes ago</span>

Relative timestamps lose meaning in logs and screenshots. 'Two minutes ago' is meaningless tomorrow. Absolute timestamps are auditable (Principle 03).

Label content provenance

DO

Explicit source attribution:

<div style={{
  borderLeft: "3px solid var(--ds-color-temporal)",
  paddingLeft: "12px",
}}>
  <span style={{ /* micro label */ }}>SOURCE</span>
  <p>Account balance retrieved from vault.</p>
</div>
DON'T

Unlabeled content with no origin:

<p>Account balance retrieved from vault.</p>

In AI interfaces, every piece of content should declare whether it came from a human, an AI, a database, or is unverified. This is Principle 01 — Visibility Over Invisibility.

06Motion Discipline

Motion in Nodus reveals process — it never decorates. Every animation must use a duration token and justify its existence.

Use duration tokens, not arbitrary values

DO

Named duration communicates intent:

transition: "opacity var(--ds-motion-smooth)
  var(--ds-easing-smooth)"
animation: "ds-fade-in var(--ds-motion-smooth)
  var(--ds-motion-curve)"
DON'T

Arbitrary duration with no semantic meaning:

transition: "all 0.5s ease-in-out"
animation: "fadeIn 800ms cubic-bezier(0.1, 0.9, 0.2, 1)"

Duration tokens enforce the 200ms ceiling. Arbitrary values drift toward entertainment — bouncy, slow, attention-seeking. Nodus motion is functional.

Never exceed 300ms for UI transitions

DO

Deliberate (300ms) is the maximum:

// Modal open — the slowest allowed
animation: "ds-fade-in var(--ds-motion-deliberate)
  var(--ds-motion-curve)"
DON'T

Slow, decorative animation:

animation: "slideIn 1.2s ease"
transition: "transform 600ms spring(1, 80, 10, 0)"

Animations longer than 300ms feel sluggish in data-dense interfaces. Spring physics and long durations belong in consumer apps, not operational tools.

Respect prefers-reduced-motion

DO

System handles it — verify your component works:

// globals.css already sets:
// @media (prefers-reduced-motion: reduce) {
//   *, *::before, *::after {
//     animation-duration: 0.01ms !important;
//     transition-duration: 0.01ms !important;
//   }
// }
// Your component needs no extra code.
DON'T

Overriding the system-level reduced motion rule:

animation: "pulse 1.5s infinite !important"

The global reduced-motion rule protects vestibular-sensitive users. Never override it with !important on animation properties.