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.
01 — Token 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
Semantic token communicates intent:
style={{ color: "var(--ds-text-primary)" }}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
Semantic > primitive > never raw:
// Best — semantic
background: "var(--ds-surface-raised)"
// Acceptable — primitive, when no semantic exists
background: "var(--bh-gray-1)"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
Spacing token keeps rhythm consistent:
padding: "var(--ds-space-inset-sm)"
gap: "var(--ds-space-stack-xs)"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.
02 — Component Composition
Build interfaces by composing existing components. Every new div is a missed reuse opportunity.
Compose existing DS components
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>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
Adjacent siblings with clear roles:
<AgentCard agent={agent}>
<ConfidenceMeter value={0.92} />
<IntegritySeal status="verified" />
</AgentCard>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.
03 — Accessibility
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
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>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
Enter/Space to toggle, arrow keys to navigate:
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
}}
tabIndex={0}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
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>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.
04 — Styling 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
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>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
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>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.
05 — Content 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
Unambiguous machine-readable status:
<span style={{
fontSize: "var(--ds-type-micro-size)",
fontWeight: 700,
letterSpacing: "var(--ds-type-label-tracking)",
textTransform: "uppercase",
}}>
GENERATING
</span>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
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>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
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>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.
06 — Motion 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
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)"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
Deliberate (300ms) is the maximum:
// Modal open — the slowest allowed
animation: "ds-fade-in var(--ds-motion-deliberate)
var(--ds-motion-curve)"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
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.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.