@nomiedge/ui
v0.1.1
Published
Nomiedge UI component library — React, TypeScript, Tailwind, built on @nomiedge/tokens.
Readme
@nomiedge/ui
The Nomiedge component library, per prompts/Pack-04-UI-Library/31-ui-package.md.
Stack
React + TypeScript. No other framework target.
Tailwind, via
@nomiedge/tokens' generated v4 theme (tailwind-theme.css+semantic.css) — this package doesn't run its own Tailwind build. Components authorclassNamestrings using utilities that theme provides (bg-neutral-500, spacing/radius/shadow scale classes, etc.); the consuming app's own CSS entrypoint compiles the actual CSS. A consumer must@importboth@nomiedge/tokens/tailwind-theme.cssand@nomiedge/tokens/semantic.cssand add an@sourcepointing at this package'sdist, or the utility classes this package uses won't be generated:/* src/app/globals.css */ @import "tailwindcss"; @import "@nomiedge/tokens/tailwind-theme.css"; @import "@nomiedge/tokens/semantic.css"; @source "../../node_modules/@nomiedge/ui/dist";(adjust the
@sourcepath's relative depth to wherever your app's CSS entrypoint actually lives — seeapps/brand-portal/src/app/globals.cssfor the real pattern in use elsewhere in this repo.)class-variance-authority (CVA) for variant-based styling (size, tone, state) — declared once per component, not
className={size === "lg" ? "..." : "..."}chains repeated ad hoc.cn()(src/lib/cn.ts,clsx+tailwind-merge) — every component composes its finalclassNamethrough this, so a consumer's override always wins deterministically over the component's own defaults, not by CSS source-order luck.Radix UI primitives where a component has real accessibility-critical interaction behavior to get right (focus trapping, keyboard nav, ARIA state) — Dialog and Tabs, concretely, later in this pack. Not used for presentational components (Button, Card) that don't need it — adding a Radix dependency for something with no complex interaction model would be the opposite of "one job per component."
Components
Button (31-ui-package.md → 32-button.md)
Three variants (primary/secondary/ghost — not four; see the comment in button.tsx for why
a fourth would be redundant in a monochrome + one-accent system), three sizes (sm/md/lg),
loading state (loading prop — swaps in a spinner, sets disabled and aria-busy together),
leading/trailing icon slots.
<Button variant="primary" size="md">
Continue
</Button>
<Button variant="secondary" loading>
Saving
</Button>Colors reference the raw neutral/accent Tailwind scale directly, with dark: variants
matching the exact steps @nomiedge/tokens' colors.ts already chose for dark mode (see
KNOWN_ISSUES.md — there's no CSS-variable bridge from semantic tokens to Tailwind yet, so this
component's dark: classes and colors.ts's color.dark values must be kept in sync by hand
until that bridge exists).
Card (33-card.md)
Card, CardHeader, CardTitle, CardContent, CardFooter — a compound API, not one
component with a dozen layout props.
<Card>
<CardHeader>
<CardTitle>Plan</CardTitle>
</CardHeader>
<CardContent>Details here.</CardContent>
<CardFooter>
<Button size="sm">Confirm</Button>
</CardFooter>
</Card>No shadow by default — border alone is the separation, per 27-shadow-elevation.md's "minimal
shadows and borders" taken literally. elevated (a boolean prop on Card) opts into
shadow.sm for contexts that need to lift above other content (e.g. a card inside a popover).
Input, Textarea, Label (34-inputs.md)
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="[email protected]" />Focus states use "Focus Line language" — the requirement's own phrase, taken literally: the
Focus Line mark's identity is a clean rectangular negative-space gap, not a glow, so focus here is
a solid 2px ring with a visible offset (focus-visible:ring-2 ... ring-offset-2), the same
treatment Button uses. One consistent "this is focused" signal, not a per-component blur.
No visual invalid/error state yet — @nomiedge/tokens has no status color (KNOWN_ISSUES.md),
so there's nothing non-arbitrary to color an invalid border with. Input/Textarea pass through
native aria-invalid for assistive-tech semantics; the visual treatment is blocked on that color
decision, not overlooked here.
Tabs (35-tabs.md)
Built on @radix-ui/react-tabs — the first Radix-based component (keyboard navigation, roving
tabindex, ARIA tablist/tab/tabpanel relationships; exactly the "real accessibility-critical
interaction behavior" this README's Stack section reserves Radix for).
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview">...</TabsContent>
<TabsContent value="settings">...</TabsContent>
</Tabs>Active state uses the Focus Line concept: a solid horizontal accent-colored underline under the active tab — one line marking one selection, the same idea as the mark's single negative-space line between two blocks — not a filled pill or a box border.
Dialog (36-dialog.md)
Built on @radix-ui/react-dialog — focus trapping, Escape to close, focus return on close.
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm</DialogTitle>
<DialogDescription>Are you sure?</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<Button>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>Calm motion, no new dependency: entrance/exit transition opacity/scale directly off
Radix's data-state attribute at 200ms with a decelerate-style easing — plain transition-*
utilities, not the tailwindcss-animate plugin's animate-in/fade-in-0/zoom-in-95 classes.
Those were the first draft; caught during build that the plugin isn't installed or part of
@nomiedge/tokens' generated Tailwind theme, so those classes would have generated no CSS at all.
Fixed before it shipped, not after. motion-reduce: handles reduced-motion, same mechanism
reducedMotionDuration documents.
Sidebar (37-sidebar.md)
Sidebar, SidebarHeader, SidebarNav, SidebarNavItem, SidebarFooter — minimal,
dashboard-ready: a fixed-width panel, a nav list, an active-item indicator. Not a mega-nav with
collapsible groups or search — extend when a real dashboard needs more, not speculatively now.
<Sidebar>
<SidebarHeader>{/* logo */}</SidebarHeader>
<SidebarNav>
<SidebarNavItem href="/" active>
Overview
</SidebarNavItem>
<SidebarNavItem href="/settings">Settings</SidebarNavItem>
</SidebarNav>
<SidebarFooter>{/* user menu */}</SidebarFooter>
</Sidebar>The active item's indicator is a vertical accent line on its leading edge — Tabs' horizontal
underline, rotated for a vertical layout, not a second unrelated "this one's active" treatment.
Concretely, SidebarNavItem renders <FocusLine orientation="vertical" active={active} />, the
shared primitive below, rather than its own pseudo-element.
FocusLine (38-focus-line.md)
The UI-facing counterpart to @nomiedge/logo's FocusLine (the brand mark's raw SVG geometry) —
same name, same idea, different job: a reusable line element for active-state indicators, loading
bars, and separators.
<FocusLine variant="separator" />
<FocusLine variant="loading" />
<FocusLine variant="indicator" active={isActive} orientation="vertical" />SidebarNavItem uses variant="indicator" directly (its active state is a controlled prop, so
FocusLine's active prop wires up cleanly). Tabs' active underline deliberately stays a
data-[state=active]:after: pseudo-element instead — Radix owns "active" there via its own
data-state DOM attribute, which a CSS selector reads natively; threading that into a nested
FocusLine would need a fragile group-data-[state=active]: override with !important. Same
visual language, different implementation for a real, documented reason (see tabs.tsx,
DECISIONS.md).
The loading variant's sweep animation (animate-[focus-line-sweep-x_...]) needed real
@keyframes added to tailwind-theme.ts (packages/tokens, emitted into the generated
tailwind-theme.css) — an arbitrary Tailwind animation name with no matching keyframes definition
compiles to CSS that animates nothing. Caught and fixed during this milestone, the same category
of bug Dialog's tailwindcss-animate catch was.
Logo (39-logo-component.md)
@nomiedge/logo used inside @nomiedge/ui — a thin wrapper, not a reimplementation. This is the
first real dependency from @nomiedge/ui to @nomiedge/logo (there was none before).
<Logo size="md" />
<Logo size="sm" orientation="stacked" />
<Logo size="lg" markOnly />Three named sizes (sm/md/lg), not an arbitrary pixel prop every consumer has to guess a
sensible value for. markOnly renders just LogoMark for icon-only placements (a collapsed
sidebar rail) with an always-present accessible label — which required fixing a real bug:
LogoMark defaults to aria-hidden="true" (correct when the mark sits next to visible text), so
passing aria-label alone would still leave the element hidden from the accessibility tree.
Logo's markOnly branch explicitly clears aria-hidden so the label is actually reachable —
caught by reasoning through what aria-hidden="true" really does, not just adding a label and
assuming it worked.
Rules
- No component hard-codes a color, spacing, radius, shadow, duration or font value that
@nomiedge/tokensalready defines (docs/quality-bar.md). - No component ships without a
size/variant story that exercises every CVA variant it defines — a variant nobody demonstrates is a variant nobody's verified works. (Concrete story wiring lands with the docs/brand-portal in Pack 05; until then, every component's own file includes the full variant matrix as a comment or type, so it's visible in code.)
