Skip to content

Design spec

NX Design System

The complete reference for the NX product-family design system: foundations (color, type, space, motion), the token model, the mark, and every shared component. This is the spec; README.md is the install/usage guide and the source files (tokens.css, kit.css, mark.tsx, react.tsx) are the implementation. When they disagree, the source files win — update this doc.

  • One mark, one accent switch. The nx ligature + a neutral foundation are the family constant; each product differs only by its accent hue and wordmark.
  • Token-driven. Every component reads --nx-* custom properties, so a single data-nx-product attribute re-themes an entire subtree.
  • Alpha-aware. Colors are stored as R G B channels, so any token composes with an opacity (rgb(var(--nx-accent-600-rgb) / .7) or Tailwind bg-nx-accent/70).
  • Quiet & flat. Hairline borders and soft accent-tint fills carry hierarchy — no drop shadows.
  • certNX is standalone. It wears the mark + tokens but not the app component kit (see §8).

1. Foundations

1.1 Color — neutrals (shared by every product)

TokenHexR G BRole
--nx-ink#0B0E1411 14 20Primary text, the mark’s n, focus ring
--nx-slate-700#33415551 65 85Strong secondary text
--nx-slate-500#5B677491 103 116Muted text, labels, placeholders
--nx-slate-300#CBD5E1203 213 225Disabled / faint
--nx-hairline#E6E8EC230 232 236Borders, dividers
--nx-paper#F7F8FA247 248 250App background; the mark’s n on dark
--nx-white#FFFFFF255 255 255Card / input surfaces

1.2 Color — per-product accent ramps (partner-approved 2026-07-03)

Each product overrides only the four accent channels; everything else stays shared. The signature is the 600.

Product--nx-accent-50--nx-accent-100--nx-accent-600 (signature)--nx-accent-700 (deep)
ordNX — violet#F5F3FF#EDE9FE#7C3AED#6D28D9
CheckNX — blue (live)#EFF6FF#DBEAFE#2563EB#1D4ED8
PointNX — teal#E6F7F7#C4ECEC#0FA3A3#0A6A6A
certNX — azure (standalone)#DCE8FE#C7DBFD#2F6BEB#1E3A8A
FileNX — emerald#ECFDF5#D1FAE5#047857#065F46
FirmNX — ochre#FAF4E8#E8D6B0#7A5C1E#4A3712
SkyONEx — sky#F0F9FF#E0F2FE#0369A1#075985
NX — graphite (the suite itself)#E2E7EF#CBD5E1#334155#0B0E14

The NX row is not a signature (0.7.0) {#suite-accent}. data-nx-product="nx" themes the family’s own surfaces — nx-account’s single sign-in and the control plane behind it — and those belong to no product. Wearing one product’s accent there would misbrand the screen every other product redirects to; leaving data-nx-product unset is not the alternative, since that falls through to :root, which is ordNX’s violet. So this ramp is deliberately not a new hue: 600 is --nx-slate-700, 700 is --nx-ink, 100 is --nx-slate-300 — the shared neutrals promoted into the accent channels, no accent decision required. Identity comes from the mark instead: <NxMark variant="nx"> paints its x with the family spectrum, independent of --nx-accent.

It is also the one ramp whose 600 is not theme-invariant. A hue reads on both grounds; a neutral does not — #334155 on the dark page #0B0E14 is ~1.7:1, a primary button you cannot find. Under dark theme the suite ramp therefore inverts to a light graphite (#CBD5E1) with a literal ink --nx-on-accent, the mirror of the --nx-on-accent escape pointNX takes for the opposite reason. The table above is the light-theme ramp.

Semantic roles of the ramp (how the kit uses each step):

StepAliasUsed for
50--nx-accent-50Section/tile fills (header, KPI), ghost-button hover
100--nx-accent-100Chips/pills bg, row-icon bg, focus-ring glow, hairlines on tinted areas
600--nx-accent / --nx-accent-600Primary buttons, the mark’s x, active underline, links
700--nx-accent-700Text on tint (KPI number, pill/tab text), primary-button hover

Contrast: accent-600 on white and accent-700 on the accent-50/100 tints all meet WCAG AA for the sizes used. Never put accent-600 text on an accent-50 fill for body copy — use 700.

1.3 Typography

TokenStack
--nx-font-sansui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif
--nx-font-monoui-monospace, "Cascadia Code", "SF Mono", "Segoe UI Mono", Menlo, Consolas, monospace

The wordmark and marketing surfaces target Inter where available (see the lockup SVGs); the app kit uses the system sans for zero-latency rendering. The mono layer is for “system”/technical labels (paths, codes, timestamps).

Type scale in use (rem @ 16px base):

NameSizeWeightTrackingWhere
KPI number1.7rem780-0.03em.nx-kpi__num
Lockup word1em (inherits)780-0.02em.nx-lockup__word
Body / control0.875rem620buttons, row titles
Input text0.84rem400.nx-field__input
Tab0.82rem640 (active).nx-tabs
KPI label / sub0.78rem / 0.74rem400labels, list subtext
Field label0.72rem560.nx-field__label
Avatar / pill0.70rem / 0.68rem700 / 640avatar, .nx-pill

Note: weights use non-standard steps (560/620/640/780). With variable fonts they render as-is; with static fonts the browser rounds to the nearest cut (500/600/700).

1.4 Space & radius

Superseded by DS-1. The literal px below predate the DS-1 scale/motion tokens (Task 0) — button/input padding now resolve through the density role tokens, and motion adds —nx-dur-/—nx-ease- alongside —nx-ease. See the DS-1 changelog; DS-2 reconciles the full tables.

  • Radius scale: 8px (row icon) · 10px (button, input) · 12px (KPI) · 18px (card) · 999px (pill). App-icon tiles use 112 on the 512 canvas (≈ 22%).
  • Padding rhythm: card body 18px · header 16px 18px · KPI 14px 16px · input 9px 12px · button 9px 15px. Vertical stack gap inside a card body is 14px; tab gap 18px.

1.5 Motion

Superseded by DS-1 — see the note at the top of §1.4.

TokenValueUse
--nx-easecubic-bezier(.22,.68,.16,1)All kit transitions + the mark draw-on
  • Mark draw-on (.nx-mark.is-playing): the n strokes in over 0.6s, then the accent x follows (0.5s delay). Triggered when the mark scrolls into view.
  • Reduced motion: @media (prefers-reduced-motion: reduce) disables all mark animation and shows the final state. Honor this everywhere.

1.6 Elevation & borders

No shadows. Depth comes from 1px --nx-hairline borders and accent-tint fills (50/100). Cards clip their content (overflow:hidden) so tinted headers meet the rounded corner cleanly.


2. The token model

Tokens live in tokens.css. Two layers:

  1. Channel vars (--nx-*-rgb, e.g. --nx-accent-600-rgb: 37 99 235) — the source of truth. Space-separated RGB so they work with / <alpha>.
  2. rgb() aliases (--nx-accent-600, --nx-accent, …) — convenience, derived from the channels. Declared on both :root and every [data-nx-product] so each themed subtree recomputes its aliases from its own channels.
<!-- the whole theme switch — every nx-* class inside recolours -->
<div data-nx-product="check"> … </div> <!-- "ord" | "check" | "point" | "cert" | "file" | "firm" | "sky" | "nx" -->
<NxThemeProvider product="check">{children}</NxThemeProvider>

Runtime dark surfaces: set --nx-mark-stroke: var(--nx-paper) on a dark wrapper to flip the mark’s n to paper/white (the accent x stays constant).

Full alias list: --nx-ink, --nx-slate-700/500/300, --nx-hairline, --nx-paper, --nx-white, --nx-accent-50/100/600/700, --nx-accent (= 600), --nx-font-sans, --nx-font-mono, --nx-ease, and the runtime --nx-mark-stroke. See Dark theme and Status, chart & overlay tokens (below) for the added --nx-on-accent, --nx-surface-2, --nx-scrim, --nx-shadow-overlay, status (--nx-success/warning/serious/danger) and chart (--nx-chart-1…6) tokens.

Tailwind (optional): tailwind.nx.cjs maps the channels to nx-ink, nx-paper, nx-white, nx-hairline, nx-slate-{300,500,700}, nx-accent{,-50,-100,-600,-700} and the two font families — all alpha-aware, all reading the same vars (so data-nx-product re-themes utilities too).


Dark theme

Dark is a first-class theme — one attribute re-themes every token, component, chart, and the mark.

  • Switch: set data-theme="dark" (or "light") on <html> — an app toggle or an SSR cookie. prefers-color-scheme: dark applies automatically unless data-theme="light" is set. NxThemeToggle flips the attribute.
  • How it works: the dark block overrides the neutral channel vars on :root; because the rgb() aliases derive from those channels, every component recolours with no per-component dark CSS. The mark’s n flips to light for free (it reads --nx-ink).
  • Accent tints are derived, not hand-picked: in dark, --nx-accent-50/100 become a color-mix() of the product accent into the dark surface, and --nx-accent-700 (text-on-tint) becomes a lightened accent — so every product, including any future one, dark-themes with zero extra values. --nx-accent (signature 600) stays vivid; --nx-on-accent stays white.
TokenLightDark
--nx-paper (app bg)#F7F8FA#0B0E14
--nx-white (surface)#FFFFFF#141922
--nx-surface-2#F1F3F7#1B212B
--nx-ink (text)#0B0E14#E7EAF0
--nx-slate-700#334155#C3C9D4
--nx-slate-500#5B6774#8B94A3
--nx-slate-300#CBD5E1#3A424E
--nx-hairline#E6E8EC#262D38

App surfaces are defined in both themes. The #0B1220 “on-dark” strip in the mark section is a fixed demo, independent of the app theme.

Status, chart & overlay tokens

Fixed, product-independent tokens (not remapped by data-nx-product):

  • On-accent: --nx-on-accent (#FFFFFF) — text/icons on an accent-600 fill (buttons, avatars, solid badges). Constant in both themes.
  • Status: --nx-success #0CA30C · --nx-warning #FAB219 · --nx-serious #EC835A · --nx-danger #D03B3B · --nx-info (= active accent). CVD-validated; always paired with an icon + label — never colour alone.
  • Overlays: --nx-scrim (dimmer) + --nx-shadow-overlay — the one sanctioned elevation, for drawers/modals/menus only (flat components stay flat).
  • Second surface: --nx-surface-2 — table headers, menus, drawers, inset panels.
  • Charts: --nx-chart-1…6 — a CVD-validated categorical palette (the dataviz reference instance; the product accents are too hue-close to serve as series), with its own dark steps, plus --nx-chart-grid / --nx-chart-axis.

3. The mark — nx ligature

The master device: a monoline n that flows into a crossing x. Path geometry is the single source of truth (shared with the SVG assets):

n: M92 380 L92 232 C92 140 178 128 244 156 L424 380
x: M424 156 L244 380
viewBox 0 0 512 512 · stroke-width 60 · round caps + joins
  • n = family constant, painted in --nx-mark-stroke (default --nx-ink, flips to paper on dark).
  • x = per-product accent (--nx-accent). The parent nx variant paints the x with a blue→violet→teal spectrum gradient.
  • Differentiation is accent + wordmark only — there is no per-product glyph.

3.1 NxMark (React) — props

PropTypeDefaultNotes
variant"nx" | "ord" | "check" | "point" | "cert" | "file" | "firm" | "sky""nx"nx = neutral parent (spectrum x); others colour the x
animatedbooleanfalseDraw-on when scrolled into view; respects reduced-motion
tightbooleanfalseCrop viewBox to the glyph bbox (62 126 392 284) for inline/lockup use; default is the square canvas
titlestring"NX"Accessible label

The x colour resolves to var(--nx-accent, <variant signature>), so a standalone <NxMark variant="check" /> is blue even without a theme wrapper, while inside <NxThemeProvider> the provider’s accent wins.

3.2 Standalone SVG assets

Under assets/<member>/ (checknx · ordnx · pointnx · certnx · filenx · firmnx · skyonex · nx):

FileWhatWhen
symbol.svg / favicon.svgAdaptive — n flips white in dark mode via prefers-color-scheme; accent x constantDefault favicon / inline symbol on light-or-auto surfaces
symbol-light.svgForced ink nKnown-light backgrounds
symbol-reversed.svgForced white nKnown-dark backgrounds (no media query)
symbol-mono.svgSingle currentColorInherits text colour; one-colour contexts
icon.svgRounded Ink tile + white n + accent xOS/app icon, avatar
lockup.svg / lockup-reversed.svgWordmark (prefix + accent NX), adaptive / darkHorizontal logo (not for parent nx)
png/…Raster 16/32/48/180/192/512/1024 (+-dark) + lockupsWhere SVG isn’t accepted

PNG status: exports exist for checknx/ordnx/pointnx; certnx, filenx, firmnx, skyonex, nx, and all icon.svg are SVG-only until re-exported through a renderer (follow-up — no SVG→PNG tool currently available).

FileNX assets were re-cut on 2026-08-02 (§4.1): the folder moved assets/doclinq/assets/filenx/, both lockups now read “FileNX” in the family’s two-tspan form (ink File + emerald NX, viewBox 960), and every aria-label in the set was updated from “DocLinQ”.

Clear space & min size: keep at least the height of the n’s stem of clear space around the mark; symbol legible from 16px, wordmark from ~80px tall.


4. The lockup

Product labels render the prefix as ink text + the ligature standing in for “NX” — e.g. Check + mark.

<NxLockup product="check" size={20} /> // "Check" + blue nx mark
  • Prefix casing (as shipped, PREFIX in react.tsx): Check · ord · Point · Cert · File · Firm · SkyONEx · (none for nx).
  • .nx-lockup__word — weight 780, tracking -0.02em, --nx-ink.
  • The mark is height: 1.05em, margin-left: .06em, NxMark tight.
  • size (px) sets the lockup font-size; the mark scales with it.
  • Accessible name is the full product name; the inline SVG is aria-hidden.

4.1 FileNX — the former lockup exception (resolved)

There is no longer an exception here. <NxLockup product="file" /> renders File + ligature and is correct, exactly like Check / Point.

History, kept because it explains the assets. When the product was called DocLinQ, it was the one family member whose name didn’t end in “NX”, so the ligature couldn’t stand in for the trailing letters. Its lockup rendered the full word in ink followed by the ligature as a companion glyph, and NxLockup was deliberately unused for it. The 2026-07-22 rename to FileNX removed that premise — the name ends in “NX” — and the 2026-08-02 rename pass finished the job: the theme key became file, PREFIX["file"] = "File", and assets/filenx/lockup.svg / lockup-reversed.svg were re-cut to the family’s two-tspan convention (ink File + emerald NX) with the viewBox re-measured to 960 to match the narrower word.

FileNX’s app still ships its own Wordmark (apps/filenx/apps/main/src/components/wordmark.tsx), now by preference rather than necessity: it renders the full word and the ligature, which reads stronger in the app shell than the substitution form.

(Related follow-up, kit-behaviour gate: if the family ever wants a generic full-word lockup as a kit component, extend NxLockup to accept a word override rather than the ${word}NX template.)

4.2 SkyONEx — companion-mark lockup

SkyONEx is a family member whose established name does not end in a literal NX. <NxLockup product="sky" /> therefore renders the nx ligature first as a companion mark, followed by Sky in Ink and ONEx in the sky accent. It keeps the family mark and color grammar without changing or duplicating the product name. Static assets in assets/skyonex/ use the same composition.


5. Components

All class names mirror the React components and require tokens.css + kit.css. Values below are the shipped defaults.

5.1 NxThemeProvider

Wraps a subtree, sets data-nx-product + .nx-app (applies --nx-font-sans, --nx-ink, font-smoothing). The single re-theming switch.

<NxThemeProvider product="ord"> … </NxThemeProvider>

5.2 NxButton.nx-btn

Base: font-size .875rem, weight 620, radius 10px, padding 9px 15px, 1px transparent border.

VariantRestHover
primary (.nx-btn--primary)bg --nx-accent, text whitebg --nx-accent-700
ghost (.nx-btn--ghost)transparent, text --nx-accent-700, border --nx-accent-100bg --nx-accent-50
outline (.nx-btn--outline, P0.5)transparent, text --nx-ink, border --nx-hairlinebg --nx-state-hover

--outline is the NEUTRAL secondary-action look (Cancel/Back/Retry/Download) — no accent tint, unlike --ghost. It’s a CSS-only class (apply directly via className="nx-btn nx-btn--outline", framework-agnostic like the rest of the kit); the bundled NxButton component’s variant union is unchanged (still "primary" | "ghost") since this pass didn’t touch react.tsx. Active/press (background:var(--nx-state-active)) lives in kit.css’s DS-1 component-states block, next to --ghost/--danger’s.

Focus: :focus-visible2.5px solid --nx-ink outline, 2px offset.

<NxButton>Start job</NxButton>
<NxButton variant="ghost">Assign</NxButton>

5.3 NxCard.nx-card / .nx-card__body

Surface: white, 1px --nx-hairline border, radius 18px, clipped. Body: padding 18px, vertical flex, gap 14px. The card is the standard container for a themed panel (usually NxProductHeader + body).

Sub-parts (P0.5, 2026-07-14 — DocLinq audit): .nx-card__header (padding 18px, vertical flex, gap 6px — consumers override flex-direction/alignment per instance, e.g. a KPI card’s row layout vs. a form card’s centered column) wraps .nx-card__title (1rem/700, tight leading, -0.01em tracking — same recipe as .nx-panel__title) and an optional .nx-card__description (.85rem, --nx-slate-500, normal leading). No .nx-card__footer — audited as unused across DocLinq’s real Card call sites, so YAGNI-cut; add it the same way if a future consumer needs it.

5.4 NxProductHeader.nx-header / .nx-header__avatar

Tinted header bar: bg --nx-accent-50, bottom border --nx-accent-100, padding 16px 18px, space-between. Holds an NxLockup and an optional avatar (26×26 circle, bg --nx-accent, white .7rem/700 initials).

<NxProductHeader product="check" avatar="AC" />

5.5 NxKpiTile.nx-kpi

Tinted stat tile: bg --nx-accent-50, radius 12px, padding 14px 16px. __num = 1.7rem/780, tracking -0.03em, color --nx-accent-700; __lbl = .78rem, --nx-slate-500.

<NxKpiTile value="14" label="Jobs today" />

5.6 NxListRow.nx-row

Row: flex, gap 12px, padding 10px 0, bottom 1px --nx-hairline divider. __icon = 30×30, radius 8px, bg --nx-accent-100, text --nx-accent-700, 700/.78rem. __title = .875rem/560, single-line ellipsis; __sub = .74rem, --nx-slate-500. Optional trailing NxStatusPill.

<NxListRow icon="NK" title="Nordküste — window clean" sub="09:30 · A. Berger" pill="On site" />

5.7 NxStatusPill.nx-pill

Chip: .68rem/640, padding 4px 9px, radius 999px, bg --nx-accent-100, text --nx-accent-700, no-wrap. For short states/counts.

5.7a NxGettingStarted.nx-getting-started

Onboarding checklist composed as a titled card of full-row actions. Each task is one semantic anchor or button—not a row containing a tiny trailing link—so its complete 48px-high surface is clickable and keyboard focusable. Hover animates the status marker and logical-direction arrow; reduced-motion disables both. Completed tasks remain navigable and use a check plus struck label.

<NxGettingStarted
title="Get started"
items={[
{ label: "Create your first agent", href: "/agents" },
{ label: "Add a widget or channel", href: "/widget", done: true },
{ label: "Upload a knowledge source", href: "/sources" },
]}
/>

5.8 NxField.nx-field

__label = .72rem/560, --nx-slate-500, 5px below-gap. __input = full-width, 1px --nx-hairline border, radius 10px, padding 9px 12px, .84rem, white bg; placeholder --nx-slate-500. Focus: border --nx-accent

  • 0 0 0 3px --nx-accent-100 glow (no default outline).
<NxField label="Search jobs" placeholder="Type to filter…" />

5.9 NxTabs.nx-tabs

Row of tabs: gap 18px, .82rem, --nx-slate-500, bottom 1px --nx-hairline, 10px bottom padding. Active (__item--active): color --nx-accent-700, weight 640, 2px accent underline flush to the divider. role="tablist" + per-tab role="tab" / aria-selected.

<NxTabs items={["Today", "Activity", "Settings"]} activeIndex={0} />

Extended components

All dark-aware and token-driven; class names mirror the React components in react.tsx. Framework-agnostic — the CSS classes work without React.

Form controls

NxCheckbox.nx-checkbox

Custom box, accent when checked, accent-100 focus glow, :has(input:disabled) dims. React: NxCheckbox.

NxRadio.nx-radio

Custom dot, accent when checked, accent-100 focus glow, :has(input:disabled) dims. React: NxRadio.

NxSwitch.nx-switch

38×22 track, accent when on, --nx-on-accent knob. React: NxSwitch.

NxSelect.nx-select

Styled native <select> with caret; focus = accent border + glow. React: NxSelect.

NxTextareatextarea.nx-field__input

The field input, min-height 88, vertical resize. React: NxTextarea.

NxFileUpload.nx-upload

Controlled single-file picker with a click/keyboard-accessible native input, drag-and-drop target, visible format/size guidance, and a selected-file summary with a labelled remove action. accept filters the picker but is never treated as validation; consumers validate type and size again on the server. React: NxFileUpload.

Data

NxTable.nx-table

(+ .nx-table__wrap for a rounded bordered card) — uppercase muted th on --nx-surface-2, hairline row rules, tr:hover → accent-50, .num right-aligns with tabular figures. React: NxTable.

NxDocumentPreview.nx-preview

Inline viewer for an already-authorized document (0.5.0): __frame (<iframe>, PDF), __img (<img>, raster, with a real data-nx-state="loading|ready|error"), __fallback for anything else, and a __foot carrying the bidi-isolated __name caption plus an optional __download link. Purely presentational — every string arrives via labels (no i18n inside), src is rendered as given and never fetched (so it serves an app URL and a blob: URL alike), and there is no new dependency. The PDF frame deliberately has no loading or error state (an <iframe> gives no trustworthy signal); the download link beneath it is unconditional instead. Pair with NxModal size="wide". React: NxDocumentPreview.

Status & feedback

NxBadge.nx-badge

Solid (default) + --soft / --success / --warning / --danger, optional __dot. React: NxBadge.

NxAlert.nx-alert

--info/--success/--warning/--danger — tinted surface, coloured left bar + icon, ink body text (never colour-alone). React: NxAlert.

NxProgress.nx-progress

__bar — accent bar on an accent-100 track. React: NxProgress.

NxSkeleton.nx-skeleton

Shimmer on --nx-surface-2, reduced-motion aware. React: NxSkeleton.

NxSpinner.nx-spinner

Small rotating ring, currentColor border-top, spin animation, reduced-motion aware — one of the primitives added in the DS-1 changelog. React: NxSpinner.

NxTooltip.nx-tooltip

__pop — CSS hover/focus tooltip; inverts per theme. React: NxTooltip.

Empty state — .nx-empty

Centered icon + title + copy + optional action for a list/table with no data yet — one of the primitives added in the DS-1 changelog. No dedicated NxKit wrapper — apply the class directly.

NxBreadcrumb.nx-breadcrumb

(+ __sep / __current). React: NxBreadcrumb.

NxMenu.nx-menu

__item (+ --active) / __sep — popover list with overlay shadow. React: NxMenu.

NxProfileMenu.nx-profilemenu

Avatar-first account menu composed from NxAvatar + NxMenu. It carries the user’s name/email in the panel and keeps profile, settings, and sign-out actions out of the top-bar action cluster. items[].icon, avatar, and chevron accept consumer-owned nodes, including Lucide icons, without adding an icon dependency to the kit. Outside click and Escape close it; Escape and item activation restore focus to the trigger. React: NxProfileMenu.

NxAppShell.nx-shell

Suite chrome (Phase 2 K1, 0.2.0): skip-link → sticky sidebar (__sidebar, __brand, __nav, __navlink/--active) → sticky top bar (__topbar, reuses .nx-header’s accent-tinted look) with __topbar-start (hamburger + NxAppSwitcher) / __topbar-center (search slot) / __topbar-end (themeToggle/localeSwitcher/user slots) → #main-content (__content). Below 768px the sidebar hides and the hamburger opens the same nav via NxDrawer (reused, not reimplemented) — unless the app owns mobile navigation itself (mobileNav="app", 0.6.0), which drops the hamburger AND the drawer so a consumer’s own bottom bar is not a second mobile nav; because the hidden sidebar is out of the accessibility tree too, that opt-out hands the consumer the duty of shipping a keyboard- and SR-reachable equivalent (see ADOPTION.md). contentClassName (0.6.0) appends classes to __content for layout the shell cannot know — e.g. bottom-bar clearance; both default to the previous markup exactly. Framework-agnostic — linkComponent prop, no next/* import. React: NxAppShell. See ADOPTION.md for the full prop table.

NxAppSwitcher.nx-appswitcher

Top-bar product menu (Phase 2 K1, 0.2.0): icon trigger (__trigger, aria-haspopup="menu") opens a 2-col grid __panel of __tile/--current product tiles (NxMark + label, role="menuitem"). Escape/outside-click close; focus-trap in and back out via the kit’s useFocusTrap. Renders every apps entry it’s given — entitlement filtering is the consumer’s job (Phase 3), not this component’s. React: NxAppSwitcher.

Overlays

Both below use --nx-scrim + --nx-shadow-overlay.

NxDrawer.nx-drawer

(+ --left), composed from the shared .nx-panel__head / __body / __foot. React: NxDrawer.

NxModal.nx-modal

__card, composed from the shared .nx-panel__head / __body / __foot. size="wide" (0.5.0, optional) adds __card--widewidth:min(920px,100%) for content the 460px form width cannot hold; the default is unchanged. React: NxModal.

Primitives

NxDivider.nx-divider

1px hairline rule for separating stacked content within a panel. React: NxDivider.

NxAvatar.nx-avatar

Circular accent-filled initials badge. React: NxAvatar.

Icon button — .nx-iconbtn

Square icon-only control for compact actions (close, more, toggle) — no dedicated NxKit wrapper, apply the class directly.

Other primitives in this group: .nx-btn--danger, .nx-btn--sm, .nx-btn--outline (P0.5) (Button modifiers — see NxButton). Theme control: NxThemeToggle (flips data-theme on <html>).


Charts

Charts follow the data-viz method — form → colour-by-job → validate → marks → hover → a11y — built as token-driven SVG.

  • Categorical palette: --nx-chart-1…6, the CVD-validated dataviz reference set (worst adjacent ΔE ≥ 12 in light; the floor band in dark, so ≥ 4 series lean on direct labels / legend). Assign in fixed order; a 7th series folds into “Other” or small multiples — never a generated hue.
  • Marks: thin (2px lines, ≥ 8px markers, 4px rounded bar data-ends), a 2px surface gap between adjacent/stacked fills, recessive grid (--nx-chart-grid) and axis (--nx-chart-axis), selective direct labels (never a number on every point), a legend whenever ≥ 2 series.
  • Text wears ink/slate, never the series colour. Status colours are reserved (never “series 4”). One y-axis only — never dual-axis.
  • Dark: the palette ships its own validated dark steps (auto via data-theme). Chrome classes: .nx-chart, .nx-grid line, .nx-axis, .nx-legend.
  • Magnitude/polarity: sequential = one-hue light→dark ramp; diverging = two poles + a neutral gray midpoint (see the dataviz skill for the ramps).

The showcase renders bar (single series), line + area (two series), a donut, and stat tiles against these tokens.


6. Accessibility

  • Focus: interactive controls show a visible focus indicator — buttons use a 2.5px ink outline (:focus-visible); inputs use the accent border + glow. Never remove focus styling without an equivalent.
  • Contrast: use accent-700 for text on accent-50/100 tints; accent-600 is for fills and large/graphic elements. Body text is --nx-ink on white/paper.
  • Motion: all mark animation is gated behind prefers-reduced-motion.
  • Semantics: tabs expose role="tablist"/"tab" + aria-selected; the lockup carries the full accessible name while its SVG is aria-hidden.
  • Profile actions: account actions live behind one labelled avatar trigger; the menu follows roving-focus keyboard behavior and returns focus on close.
  • Uploads: the drop target is also a labelled native file picker, so drag and drop is never the only interaction path.
  • Icon-only content (row icons, avatars) is decorative shorthand — pair with a text title/sub for meaning.

7. Do / Don’t

Do

  • Theme with one data-nx-product / NxThemeProvider; let tokens cascade.
  • Use accent tints (50/100) for surfaces and 600/700 for ink-on-tint + actions.
  • Reach for NxMark tight inside text (lockups); square elsewhere.
  • Keep the mark’s n neutral and let only the x carry colour.

Don’t

  • Hard-code hex in components — always go through --nx-*.
  • Add drop shadows or a second accent to a single product surface.
  • Recolour the mark’s n, add a per-product glyph, or outline the wordmark.
  • Build certNX’s certificate/portal surface from this kit (see §8).
  • Merge the DRAFT staging PRs (checknx#78, ordnx#28) — they carry the superseded June assets; regenerate from assets/ first.

8. certNX is standalone

certNX belongs to the family by mark and tokens — use <NxMark variant="cert" />, the azure ramp, and assets/certnx/ for badges/seals. But the shared app kit (NxProductHeader, NxKpiTile, list rows, everyday-app surface) belongs to the everyday-app members — ordNX, CheckNX, PointNX, FileNX, FirmNX. Certification carries disclosure/trust obligations and gets a more formal, document-grade surface, so certNX credentials and audit views should not be built from these components. Same family, deliberate distance.

Docs-site decision (DS-3): certNX is the family’s one non-family-app, standalone azure mark — it gets no dedicated component/foundation entry and no standalone certNX docs page in docs-site/; the caveat above (and the accent-ramp row in §1.2) is the complete, intentional extent of its coverage here.

(Open partner call: reconcile this family certNX mark vs the older templates/brand/assets/certnx.svg in the ipmnx-company vault — which is canonical? See that vault’s company/tasks.md.)


9. Where things live

packages/nx-family/
├── design.md ← this file (the spec)
├── README.md ← install + usage guide
├── FAMILY-PREVIEW.html ← canonical family preview
├── index.html ← living gallery: every brand + component (+ theme playground)
├── demo.html ← kit verification page
├── tokens.css ← foundations (source of truth for §1–§2)
├── kit.css ← component styles (source of truth for §5)
├── tailwind.nx.cjs ← optional Tailwind token map
├── mark.tsx ← the mark (§3)
├── react.tsx ← components (§4–§5)
├── dist/ ← compiled react/mark + types (what consumers import)
├── registry.js ← structured spec powering docs-site/ (keep in sync with §5)
└── assets/<member>/ ← standalone SVG (+ PNG) exports (§3.2)

DS-1 changelog

Foundations pass on top of §1–§9 above: scale tokens, interaction states, a density mode, a11y hooks/behaviors, an RTL logical-property sweep, and a dev-only axe self-audit in the showcase. Full reference tables (values, usage matrices) are deferred to DS-2 — this is a pointer to the new names plus the breaking list below.

New tokens (pointer only — see tokens.css for values):

  • Scale: --nx-space-*, --nx-size-*, --nx-radius-*, --nx-border*
  • Focus/ring: --nx-focus-outline-*, --nx-ring, --nx-ring-danger
  • Motion: --nx-dur-*, --nx-ease-*
  • Layering: --nx-z-* (incl. --nx-z-root, see breaking change 2 below)
  • Type: --nx-fw-*, --nx-leading-*
  • Layout: --nx-row-icon
  • Status: new dark overrides, plus --nx-success-solid, --nx-danger-50, --nx-danger-100
  • State: --nx-disabled-opacity, --nx-state-hover, --nx-state-active, --nx-press-y, --nx-selected-bg

New primitives & mechanisms:

  • Density: data-nx-density attribute + density-scoped role tokens (compact/comfortable).
  • Primitives: .nx-empty, .nx-spinner, .nx-sr-only.
  • A11y behaviors: useFocusTrap, useBodyScrollLock, roving-tabindex menu/tabs, live-region alerts, a hoverable + dismissible tooltip, and forced-colors + reduced-motion coverage.
  • RTL: logical-property sweep across the kit.
  • Showcase: a dev-only axe self-audit (see breaking change 4 for known, by-design findings).

Breaking changes (adopt-time)

Flag for checknx / ordnx / pointnx / cosmethicals maintainers before bumping:

  1. NxTabs markup changed <span><button role="tab">. Any consumer CSS/markup keyed on .nx-tabs span must move to the class/role selector. Roving tabindex means the tablist is now one tab stop (was N stops). aria-controls is now opt-in via a new panels prop (default off) — pass panels and render matching role="tabpanel" elements. Id contract: tab ${idBase}-${i} ↔ panel ${idBase}-panel-${i}.
  2. Z-index band moved from ~100 to a documented 1000–1600 band. Host chrome (toasts, banners) still living in the old 100–999 range now renders below kit overlays — mitigate with --nx-z-root to offset the whole band.
  3. .nx-drawer--left now means inline-start (auto-flips under RTL). The API name is unchanged; the semantic is no longer “always screen-left.”
  4. Contrast nudges are deferred (Task 10, partner-gated). The AA color-contrast fixes for --nx-slate-500, pointNX’s --nx-accent-700, and white-on-light-accent tiles are not part of DS-1 — applying them repaints live products. Until they land, the showcase’s axe self-audit reports color-contrast violations for these by design; don’t file them as DS-1 regressions.

Notes:

  • NxAlert live-region caveat: a polite (role="status") alert announces reliably only if the live region already exists in the DOM before its text changes. Render a persistent, empty alert container and inject text into it (mount-empty-then-populate) — don’t mount the alert already carrying its final text.
  • Spec reconciliation: the compact --nx-menu-item-min-h ships at 28px (= --nx-size-7); the DS-1 planning spec’s table said 30px. Both are ≥24px WCAG-safe — 28px is canonical, the planning doc is superseded here.

DS-added: the emerald family member — then doc/DocLinQ, renamed to file/FileNX 2026-08-02.

P0.5 — DocLinq re-skin primitive gap-fill (2026-07-14)

Task P0.5 audited DocLinq’s actual HeroUI component usage (apps/main/src, apps/website) ahead of its Phase 2 nx-* re-skin, to close only the primitive gaps real call sites need (YAGNI — no speculative variants). CSS-only; react.tsx/ mark.tsx untouched.

Added:

  • .nx-btn--outline (+ :hover/:active) — HeroUI variant="bordered" (dominant labeled-secondary-button variant in DocLinq, 24 call sites) and variant="flat" (3 call sites, all semantically-equivalent dialog Cancel buttons — consolidated onto the same class rather than adding a fourth near-duplicate neutral modifier). Neutral tone (--nx-ink text, --nx-hairline border, --nx-state-hover/ -active overlay) — deliberately distinct from the accent-tinted --ghost, matching the “NEUTRAL controls use --nx-state-*” rule already documented for the DS-1 state model.
  • .nx-card__header / __title / __description — DocLinq’s CardHeader always wraps CardTitle(+CardDescription); ~19 real call sites across dashboard tiles and auth/settings forms.

Audited, found already covered (no change): icon-only buttons (real usage is variant="ghost"/"bordered" + isIconOnly or icon-only sizing — maps onto the existing .nx-iconbtn, per the brief’s stated preference); the [aria-busy] + .nx-btn__label + .nx-spinner loading contract (DocLinq’s 4 auth submit buttons already wire aria-busy by hand — the existing contract is an exact match); Badge and Alert (each has exactly one real call site, neither passes a color/severity prop — the existing --soft/--success/--warning/--danger sets already exceed what’s used); Input/Textarea/NumberInput/Select field states (label, description, isRequired, isInvalid, errorMessage) — already fully carried by .nx-field__label/__hint/__error + [aria-invalid]; Tabs/Checkbox/Radio (default variant only); Progress/Switch/the design-system Tooltip (not used anywhere in DocLinq today).

Audited, found NOT needed despite being called “likely missing” in the task brief (i.e. the brief’s a-priori guess didn’t survive contact with real usage — flagging so the next audit doesn’t re-add these speculatively):

  • .nx-btn--md/--lg — DocLinq never passes an explicit Button size other than "sm" (already covered by the existing .nx-btn--sm).
  • .nx-btn--success/--warning — DocLinq’s only Button color values are "primary" and "danger" (and the untinted default), both already covered.
  • Avatar size modifiers — DocLinq’s one real Avatar usage renders at exactly 32px (h-8 w-8), i.e. the existing default .nx-avatar (--nx-avatar-md). No second size is used, so none was added.

Considered, deliberately deferred (not a kit.css concern):

  • Button leading/trailing icons (startContent/endContent, ~4 real call sites) and full-width text-centering (submit buttons, 4 call sites) both need display:inline-flex/gap/justify-content — properties .nx-btn doesn’t set today. Cross-repo precedent (PointNX’s Hero.tsx: className="nx-btn nx-btn--primary inline-flex h-12 min-h-11 gap-2 px-6 ...") shows the established convention is consumer-side Tailwind layout utilities alongside the kit’s color/shape classes, not baking layout into the shared .nx-btn rule — so DocLinq’s re-skinned Button wrapper should add its own inline-flex items-center justify-center gap-* base classes, not kit.css.
  • One-off shape overrides reached via className rather than a component prop — the nav-user Avatar’s rounded-lg (square, not circular) and a handful of Skeleton shapes (rounded-full/rounded-none) — are per-instance styling the same way arbitrary width/height already is, not a prop-level variant. Note for the re-skin: DocLinq’s global CSS loads kit.css unlayered (apps/main/src/app/[locale]/layout.tsx imports it before @workspace/ui/globals.css’s @import "tailwindcss"), and per the CSS Cascade Layers spec unlayered rules beat any @layer-wrapped rule (Tailwind v4 utilities included) regardless of source order — so a Tailwind shape utility alongside an nx-* class that already sets that same property will silently lose. The reliable override for a one-off is an inline style={{ borderRadius: "var(--nx-radius-full)" }} (still 100% token-driven), not a new shared modifier for a single call site.
  • A leading-icon affordance for .nx-field__input (one real call site: the search page’s search icon + clear button) — needs a wrapper/slot structure with no existing kit precedent to match; flagged for whoever re-skins that screen rather than invented here.
  • Select selectionMode="multiple" (approver/acknowledger pickers) — HeroUI’s default multi-select renderValue is plain comma-joined text (neither call site supplies a custom renderValue), so the existing single-line .nx-select styling already covers it visually; the multi-select behavior itself is a react.tsx / ARIA-listbox concern, out of scope for this CSS-only pass.

0.7.0 — the suite’s own key (2026-09-11)

Additive, no migration: no existing token, class, component or prop changes shape, so 0.6.00.7.0 per the semver convention in ADOPTION.md (minor = new token). Every existing consumer renders byte-for-byte what it rendered before — nothing outside [data-nx-product="nx"] is touched.

Driven by nx-account, the one NX surface the kit had no answer for. It is the IdP every product’s OIDC flow redirects to plus the control plane behind it, and it is not a product: there is no nx accent to theme it with, and the fallback for an unset data-nx-product is :root — ordNX’s violet. So the suite’s login shipped in hand-rolled, dark-only CSS that matched nothing, and adopting the kit would have meant choosing between misbranding it as ordNX and inventing an eighth brand hue.

Added — data-nx-product="nx" (the suite accent): the shared neutral foundation promoted into the accent channels (600 = --nx-slate-700, 700 = --nx-ink, 100 = --nx-slate-300), so it needs no partner accent decision — it introduces no colour the family had not already approved. "nx" joins NxProduct in react.tsx, which makes NxThemeProvider / NxAppShell / NxProductHeader accept it; NxVariant (mark.tsx) already had "nx", and NxLockup product="nx" already renders mark-only (PREFIX.nx is "").

Two details that are the whole reason this is a token change and not an app-local override:

  • Its 600 inverts in dark theme — the only accent in the kit that does. A neutral 600 has no hue to carry it onto a near-black ground (#334155 on #0B0E14 ≈ 1.7:1), so dark theme restates the 600 as a light graphite with a literal ink --nx-on-accent. Only those two declarations are restated; the 50/100/700 tints keep deriving from the 600, so selected rows, pills and KPI text re-resolve for free. The override is written as a compound :root[data-theme="dark"][data-nx-product="nx"] as well as the descendant form, because every real consumer puts both attributes on the same <html> element — a descendant combinator alone would have been dead code, which is the same reason the kit’s existing derive rule leads with :root[data-theme="dark"].
  • Its 50 is one step off --nx-surface-2, not equal to it. --nx-accent-50 is the “current/selected” fill and --nx-surface-2 is the neutral hover; at the same value NxAppSwitcher’s current tile is indistinguishable from a hovered one. #E2E7EF keeps --nx-slate-500 body text at 4.65:1 on it — the DS-1 Task 10 floor every accent-50 clears.

tools/gen-palette.mjs learned the key, and its block regex is now anchored at line start: the theme-scoped [data-theme="dark"] [data-nx-product="nx"]{…} override restates only the 600 channel, and an unanchored match let that partial block overwrite the full light-theme one and fail as --nx-accent-50-rgb missing. That guard also covers the next per-product dark override, whoever adds one.

0.6.0 — SkyONEx + account/upload patterns (2026-08-14)

Additive, no migration: the sky product theme and mark variant join the family with a #0369A1 signature, a companion-mark SkyONEx lockup, and the complete SVG asset set under assets/skyonex/.

Two shared interaction patterns move out of the SkyONEx app and into the kit: NxProfileMenu composes the existing avatar/menu primitives into compact suite chrome and accepts consumer-owned Lucide icons; NxFileUpload provides one controlled, keyboard-accessible picker/dropzone with guidance, selected-file feedback, and removal. The package version moves from 0.5.0 to 0.6.0.

0.5.0 — inline document preview (2026-08-04)

Additive, no migration: nothing existing changes shape, so 0.4.00.5.0 per the semver convention in ADOPTION.md (minor = new component/token/prop). Driven by firmNX, whose every document affordance was a download — an operator checking a scanned accusé had to leave the app for the OS downloads folder.

Added — NxDocumentPreview (react.tsx, .nx-preview in kit.css; see the component entry). Three deliberate constraints, all of them the reason it can live in the shared kit at all:

  • No i18n, no fetching, no dependency. Consumers disagree on i18n (firmNX has its own useT(), FileNX uses next-intl), so all six strings arrive as labels, already interpolated — a fragment the component concatenated with the filename would not survive translation into Arabic, and an accessible name assembled that way is exactly where that breaks. src is a plain string, never resolved: that is what lets one component render both a stored document and a not-yet-uploaded local file (URL.createObjectURL) whose revoke lifecycle stays with the caller that owns the <input type="file">.
  • Honest states. <img> gets a real loading|ready|error ladder off its own load/error events. The PDF <iframe> gets none — it fires no usable error event for content-level failures, onload fires for error documents too, and a browser-rendered PDF has no meaningful contentDocument to probe; any detection would be a heuristic that lies in both directions. So the download link is rendered beneath the frame whenever downloadHref is given: worst case is an empty frame above a link that works. Omitting downloadHref (the pre-upload case, where the file is on the user’s own disk) is legitimate but leaves no escape hatch, so a consumer that omits it must also pass error copy that does not say “download the file”. The <img> state is keyed by src and reset during render, never from an effect: a blob: or cached image can fire load before React flushes passive effects, so an effect-based reset would overwrite ready with loading and strand a perfectly good image at opacity:.45 forever.
  • No sandbox on the frame — Firefox’s pdf.js requires allow-scripts allow-same-origin to render, which grants back what a same-origin frame already has. The real control is the host app’s upload MIME allow-list plus X-Content-Type-Options: nosniff on the byte route. Recorded as a named residual rather than a silently-broken feature in one browser family.

A11y/RTL: the <iframe> carries title (an untitled frame is a WCAG 4.1.2 failure — that is why labels.pdfFrame is required) and the <img> an alt; the filename caption is dir="ltr" + unicode-bidi:isolate, since a filename is inherently LTR data inside Arabic chrome. Dialog semantics, Escape, focus trap and focus restoration are not reimplemented — compose with NxModal.

Added — NxModal size?: "default" | "wide". .nx-modal__card is width:min(460px,100%), right for a form and unusable for a page of A4; --wide is width:min(920px,100%). default renders byte-identical markup to 0.4.0, so no consumer is forced to move.

Fixed — overlays are now safe to compose INSIDE a <form>. firmNX’s pre-upload preview is the first consumer to mount an NxModal inside a form (previewing the file just picked in that form), which exposed two latent traps that every consumer inherited:

  • type="button" on the NxModal / NxDrawer close ”×”. A <button> with no type is a submit button owned by the nearest enclosing form, and these overlays render in place (no portal). So “dismiss this overlay” also submitted the form — in firmNX that meant uploading a document, or closing a fiscal obligation and archiving its accusé, from a click that meant nothing of the sort. e.stopPropagation() on the card cannot help: stopping propagation never cancels a default action. (NxThemeToggle got the same treatment for the same latent reason.) Purely additive — no consumer relied on the overlay chrome submitting anything.
  • useFocusTrap makes Escape terminal (e.stopPropagation() before onEscape). The listener is bound to the trapped node, so with nested overlays one Escape bubbled through every ancestor trap and collapsed the whole stack — dismissing a preview also unmounted the form underneath it and discarded the operator’s file selection. Innermost-dismisses-first is the correct semantic for nested traps and is a no-op for every single-level consumer.

Known limitation (not a kit change): NxModal does not portal. Mounted inside an element that creates a stacking context — e.g. a z-indexed role="menu" dropdown panel — its z-index: var(--nx-z-modal) is resolved inside that context and the whole overlay, scrim included, can paint below a sticky topbar at --nx-z-sticky. Nesting it in a menu panel is also invalid ARIA. Consumers must mount overlays outside dropdown panels; firmNX’s tests/unit/preview-placement.test.ts guards this on its side.

Not done (deliberate): no Office/spreadsheet rendering and no server-side conversion of any kind; no MIME→kind mapping in the kit (consumers need it server-side and every file this package emits carries a "use client" banner); no render test — this package has no test runner and firmNX’s unit env is node with no jsdom, so the component’s guarantees are tsc + a cross-consumer turbo run typecheck + manual browser verification, not a fake assertion.


Status: review — the mark + palettes are partner-approved (2026-07-03); adopting this kit as the official in-app identity across the products is an open partner gate (company/tasks.md, [ORG-14]).