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 singledata-nx-productattribute re-themes an entire subtree. - Alpha-aware. Colors are stored as
R G Bchannels, so any token composes with an opacity (rgb(var(--nx-accent-600-rgb) / .7)or Tailwindbg-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)
| Token | Hex | R G B | Role |
|---|---|---|---|
--nx-ink | #0B0E14 | 11 14 20 | Primary text, the mark’s n, focus ring |
--nx-slate-700 | #334155 | 51 65 85 | Strong secondary text |
--nx-slate-500 | #5B6774 | 91 103 116 | Muted text, labels, placeholders |
--nx-slate-300 | #CBD5E1 | 203 213 225 | Disabled / faint |
--nx-hairline | #E6E8EC | 230 232 236 | Borders, dividers |
--nx-paper | #F7F8FA | 247 248 250 | App background; the mark’s n on dark |
--nx-white | #FFFFFF | 255 255 255 | Card / 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):
| Step | Alias | Used for |
|---|---|---|
| 50 | --nx-accent-50 | Section/tile fills (header, KPI), ghost-button hover |
| 100 | --nx-accent-100 | Chips/pills bg, row-icon bg, focus-ring glow, hairlines on tinted areas |
| 600 | --nx-accent / --nx-accent-600 | Primary buttons, the mark’s x, active underline, links |
| 700 | --nx-accent-700 | Text 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
| Token | Stack |
|---|---|
--nx-font-sans | ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif |
--nx-font-mono | ui-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):
| Name | Size | Weight | Tracking | Where |
|---|---|---|---|---|
| KPI number | 1.7rem | 780 | -0.03em | .nx-kpi__num |
| Lockup word | 1em (inherits) | 780 | -0.02em | .nx-lockup__word |
| Body / control | 0.875rem | 620 | — | buttons, row titles |
| Input text | 0.84rem | 400 | — | .nx-field__input |
| Tab | 0.82rem | 640 (active) | — | .nx-tabs |
| KPI label / sub | 0.78rem / 0.74rem | 400 | — | labels, list subtext |
| Field label | 0.72rem | 560 | — | .nx-field__label |
| Avatar / pill | 0.70rem / 0.68rem | 700 / 640 | — | avatar, .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 use112on the 512 canvas (≈ 22%). - Padding rhythm: card body
18px· header16px 18px· KPI14px 16px· input9px 12px· button9px 15px. Vertical stack gap inside a card body is14px; tab gap18px.
1.5 Motion
Superseded by DS-1 — see the note at the top of §1.4.
| Token | Value | Use |
|---|---|---|
--nx-ease | cubic-bezier(.22,.68,.16,1) | All kit transitions + the mark draw-on |
- Mark draw-on (
.nx-mark.is-playing): thenstrokes in over0.6s, then the accentxfollows (0.5sdelay). 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:
- 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>. rgb()aliases (--nx-accent-600,--nx-accent, …) — convenience, derived from the channels. Declared on both:rootand 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: darkapplies automatically unlessdata-theme="light"is set.NxThemeToggleflips the attribute. - How it works: the dark block overrides the neutral channel vars on
:root; because thergb()aliases derive from those channels, every component recolours with no per-component dark CSS. The mark’snflips to light for free (it reads--nx-ink). - Accent tints are derived, not hand-picked: in dark,
--nx-accent-50/100become acolor-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-accentstays white.
| Token | Light | Dark |
|---|---|---|
--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 380x: M424 156 L244 380 viewBox 0 0 512 512 · stroke-width 60 · round caps + joinsn= family constant, painted in--nx-mark-stroke(default--nx-ink, flips to paper on dark).x= per-product accent (--nx-accent). The parentnxvariant paints thexwith a blue→violet→teal spectrum gradient.- Differentiation is accent + wordmark only — there is no per-product glyph.
3.1 NxMark (React) — props
| Prop | Type | Default | Notes |
|---|---|---|---|
variant | "nx" | "ord" | "check" | "point" | "cert" | "file" | "firm" | "sky" | "nx" | nx = neutral parent (spectrum x); others colour the x |
animated | boolean | false | Draw-on when scrolled into view; respects reduced-motion |
tight | boolean | false | Crop viewBox to the glyph bbox (62 126 392 284) for inline/lockup use; default is the square canvas |
title | string | "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):
| File | What | When |
|---|---|---|
symbol.svg / favicon.svg | Adaptive — n flips white in dark mode via prefers-color-scheme; accent x constant | Default favicon / inline symbol on light-or-auto surfaces |
symbol-light.svg | Forced ink n | Known-light backgrounds |
symbol-reversed.svg | Forced white n | Known-dark backgrounds (no media query) |
symbol-mono.svg | Single currentColor | Inherits text colour; one-colour contexts |
icon.svg | Rounded Ink tile + white n + accent x | OS/app icon, avatar |
lockup.svg / lockup-reversed.svg | Wordmark (prefix + accent NX), adaptive / dark | Horizontal logo (not for parent nx) |
png/… | Raster 16/32/48/180/192/512/1024 (+-dark) + lockups | Where SVG isn’t accepted |
PNG status: exports exist for
checknx/ordnx/pointnx;certnx,filenx,firmnx,skyonex,nx, and allicon.svgare 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 (inkFile+ emeraldNX,viewBox960), and everyaria-labelin 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,
PREFIXinreact.tsx):Check·ord·Point·Cert·File·Firm·SkyONEx· (none fornx). .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 lockupfont-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.
| Variant | Rest | Hover |
|---|---|---|
primary (.nx-btn--primary) | bg --nx-accent, text white | bg --nx-accent-700 |
ghost (.nx-btn--ghost) | transparent, text --nx-accent-700, border --nx-accent-100 | bg --nx-accent-50 |
outline (.nx-btn--outline, P0.5) | transparent, text --nx-ink, border --nx-hairline | bg --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-visible → 2.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-100glow (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.
NxTextarea — textarea.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.
Navigation
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--wide → width: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
datavizskill 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.5pxink 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-inkon 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 isaria-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/subfor 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 tightinside text (lockups); square elsewhere. - Keep the mark’s
nneutral and let only thexcarry 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-densityattribute + 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, andforced-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:
NxTabsmarkup changed<span>→<button role="tab">. Any consumer CSS/markup keyed on.nx-tabs spanmust move to the class/role selector. Roving tabindex means the tablist is now one tab stop (was N stops).aria-controlsis now opt-in via a newpanelsprop (default off) — passpanelsand render matchingrole="tabpanel"elements. Id contract: tab${idBase}-${i}↔ panel${idBase}-panel-${i}.- 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-rootto offset the whole band. .nx-drawer--leftnow means inline-start (auto-flips under RTL). The API name is unchanged; the semantic is no longer “always screen-left.”- Contrast nudges are deferred (Task 10, partner-gated). The AA
color-contrastfixes 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 reportscolor-contrastviolations for these by design; don’t file them as DS-1 regressions.
Notes:
NxAlertlive-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-hships 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) — HeroUIvariant="bordered"(dominant labeled-secondary-button variant in DocLinq, 24 call sites) andvariant="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-inktext,--nx-hairlineborder,--nx-state-hover/-activeoverlay) — 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’sCardHeaderalways wrapsCardTitle(+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 Buttonsizeother than"sm"(already covered by the existing.nx-btn--sm)..nx-btn--success/--warning— DocLinq’s only Buttoncolorvalues 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 needdisplay:inline-flex/gap/justify-content— properties.nx-btndoesn’t set today. Cross-repo precedent (PointNX’sHero.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-btnrule — so DocLinq’s re-skinned Button wrapper should add its owninline-flex items-center justify-center gap-*base classes, not kit.css. - One-off shape overrides reached via
classNamerather than a component prop — the nav-user Avatar’srounded-lg(square, not circular) and a handful ofSkeletonshapes (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 loadskit.cssunlayered (apps/main/src/app/[locale]/layout.tsximports 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 annx-*class that already sets that same property will silently lose. The reliable override for a one-off is an inlinestyle={{ 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-selectrenderValueis plain comma-joined text (neither call site supplies a customrenderValue), so the existing single-line.nx-selectstyling already covers it visually; the multi-select behavior itself is areact.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.0 → 0.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 (
#334155on#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-50is the “current/selected” fill and--nx-surface-2is the neutral hover; at the same valueNxAppSwitcher’s current tile is indistinguishable from a hovered one.#E2E7EFkeeps--nx-slate-500body 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.0 → 0.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 aslabels, 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.srcis 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 realloading|ready|errorladder off its own load/error events. The PDF<iframe>gets none — it fires no usable error event for content-level failures,onloadfires for error documents too, and a browser-rendered PDF has no meaningfulcontentDocumentto probe; any detection would be a heuristic that lies in both directions. So the download link is rendered beneath the frame wheneverdownloadHrefis given: worst case is an empty frame above a link that works. OmittingdownloadHref(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 bysrcand reset during render, never from an effect: ablob:or cached image can fireloadbefore React flushes passive effects, so an effect-based reset would overwritereadywithloadingand strand a perfectly good image atopacity:.45forever. - No
sandboxon the frame — Firefox’s pdf.js requiresallow-scripts allow-same-originto render, which grants back what a same-origin frame already has. The real control is the host app’s upload MIME allow-list plusX-Content-Type-Options: nosniffon 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 theNxModal/NxDrawerclose ”×”. A<button>with notypeis 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. (NxThemeTogglegot the same treatment for the same latent reason.) Purely additive — no consumer relied on the overlay chrome submitting anything.useFocusTrapmakes Escape terminal (e.stopPropagation()beforeonEscape). 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]).