@nighthq/components
v0.5.0
Published
Nightglass Svelte 5 primitives — the design language as components.
Readme
@nighthq/components
43 Svelte 5 primitives.
Quickstart
From nothing to a themed control:
pnpm create vite my-app --template svelte-ts
cd my-app
pnpm add @nighthq/components @nighthq/tokensAdd the one setting from Using it with Vite to
vite.config.ts — dev will not start without it — then:
<script lang="ts">
import '@nighthq/tokens/themes/all.css';
import '@nighthq/tokens/styles/base.css';
import { Badge, Button, type BadgeTone } from '@nighthq/components';
let tone: BadgeTone = 'ok';
</script>
<Badge {tone}>ready</Badge>
<Button variant="primary">Start</Button>The theme and the base stylesheet are imported once, at your app root. Import
the prop vocabularies (BadgeTone here) rather than writing the strings by
hand: they are the words these components accept, and a wrong one is otherwise
silent.
tone is the name for severity everywhere — Badge, Callout and
toasts.push — and status is accepted on all of them as an alias, so code
written either way keeps working. Badge shipped with status; the two names
for one concept are what put 614 badges on their default across a deployed app,
because Svelte drops the name a component does not have without a word.
This package publishes source. Svelte components have to be compiled by the consumer's Svelte version anyway, so a prebuilt bundle would add a version-skew failure mode without removing a build step from anyone.
Using it with Vite
vite build works untouched. vite dev does not, until this package is kept out
of dependency pre-bundling. Either line does it, and the first needs no list:
// vite.config.ts — preferred: nothing to keep in step with us
export default defineConfig({
plugins: [svelte({ prebundleSvelteLibraries: false })]
});// or name the package, if you want prebundling left on for other Svelte libraries
export default defineConfig({
plugins: [svelte()],
optimizeDeps: { exclude: ['@nighthq/components'] }
});@nighthq/components is the only entry that does anything, and the wider
['@nighthq/components', '@nighthq/icons', '@nighthq/model', '@nighthq/tokens']
list this section used to prescribe was three names too long. It is the only
@nighthq package that ships Svelte source at all — 45 .svelte and
.svelte.ts files, against none in icons, tokens or model, which ship
compiled ESM with declarations and pre-bundle perfectly well.
⚠ The error will tempt you to exclude @nighthq/model. Don't bother. It is
named on the failing line as the import target, not as the file that failed to
parse — the file is always one of ours. Measured against the published packages:
excluding only @nighthq/components survives an app that imports model
directly, and dies the moment that one name is removed.
Without one of the two settings, dev dies before the first frame:
[plugin vite-plugin-svelte:optimize-module] …/@nighthq/components/src/selection/connect.svelte.ts:1:12
RolldownError: Unexpected token
https://svelte.dev/e/js_parse_error
1 | import type { Model } from '@nighthq/model';
^Vite's dependency optimizer hands .svelte.ts rune modules to the Svelte parser
without running the TypeScript preprocessor first, so it stops at the first piece
of TypeScript it meets. The import type above is the example, not the cause:
all three of our rune modules fail, and two of them on an export type or
export interface instead. Production builds never go through the optimizer,
which is why this bites only in dev — the mode you spend the day in.
That is a direct consequence of publishing source, so it is part of this
package's contract rather than a defect in yours. It is written down because the
failure gives no route to the fix: it names a file inside node_modules, points
at a line that is valid TypeScript, and the word optimizeDeps appears nowhere
in it.
We cannot fix this from the package, and it is not for want of metadata.
This package already declares the svelte export condition, which is exactly
what vite-plugin-svelte reads to put a Svelte library on its automatic exclude
list — measured: with prebundleSvelteLibraries: false the package needs no
config at all, and deleting that one condition from its manifest brings the
failure straight back. The list is then discarded before it is used, because
prebundleSvelteLibraries defaults to true in dev. So the switch that would
spare you this is on your side of the line, which is why this section asks you
for a line of config instead of shipping one.
It is filed upstream, because the asymmetry looks like an oversight rather than
a design choice.
sveltejs/vite-plugin-svelte#1396.
In setup-optimizer.js, compileSvelte preprocesses before compiling and its
sibling compileSvelteModule hands the raw source straight to
svelte.compileModule — a JS parser — so TypeScript in a dependency's
.svelte.ts cannot survive prebundling. The two functions sit in one file and are
called from the same hook; only one of them consults options.preprocess.
A fix there removes this section's requirement for every Svelte library
shipping .svelte.ts, not just this one. Until it lands, the line of config above
is still the answer — this package deliberately did not take the other road of
shipping those three modules as compiled JavaScript, which would have bought you
that one line at the price of a build step, load-bearing .d.ts emission, and a
partial walk-back of publishing source (nightglass #365).
The error is quoted above in full, vite-plugin-svelte:optimize-module and
js_parse_error included, so that searching either string — in this file, in
llms.txt, or on the open web — reaches this section. Those two are the only
parts of the message that are the same for everyone: the path names your
node_modules, and the line and column move with our source.
Requirements
This package ships source, so your tsconfig compiles it — ours never runs at
your site. check-consumer-types holds that floor on every run here, which makes
it a promise rather than a description:
lib: ["ES2022", "DOM"], and no more.DOM.Iterableis deliberately not required: it is common but not universal, and avoiding it costs us oneArray.from. A published library does not get to require a lib entry it could have avoided.- Svelte 5 — the components are runes-based.
- A bundler that compiles Svelte and TypeScript out of
node_modules, via thesvelteexport condition.@sveltejs/vite-plugin-sveltedoes this; see Using it with Vite for the one setting it still needs.
Every source-shipping package here is type-checked against exactly that lib set before release, so if it holds for us it holds for you.
What is here
43 interface components:
ActivityRegion Badge Button Callout Card Checkbox Chip Compare Confirm Empty Expandable
Field FilePreview Icon Input List ListRow Mark Menu MenuItem Modal MonoId
Pane Panel Pending Popover ScrollRegion Select Spinner Split Stat Table Tabs Terminal
Textarea TextLink ThemeSwitcher Timeline TimelineEvent TimelineGroup Toaster Toggle
Tooltip, plus the toasts store
and the activity-region lease.
9 atmospheric components, behind the opt-in /cinematic subpath:
Constellation Cue Grain Halo Nightfield Readout Reticle Scene Vitrine
They are separately imported because they are a different tier: the interface answers in 180ms and the atmosphere breathes over five seconds, and a control carrying cinematic timing is a bug.
And connect, the bridge from a @nighthq/model model to Svelte
reactivity. The adaptive components require you to own the model — Selection
creates nothing, so that crossing a breakpoint cannot destroy the person's
choice — which means you hold one and need to read it in your own components:
const view = connect(() => model); // a GETTER, never the model itselfPass the value instead and you capture the instance that existed at setup: a swapped model then leaves the view subscribed to the old one, silently, because the stale model still answers every read.
Rules the components carry
These are enforced in code, not left to review:
Buttonshows accent motion only whileworking.primaryis the action that starts work, not the prettiest one; a screen with two primary buttons has a hierarchy problem, not a styling problem.- One pulse per view region, enforced by a lease. Wrap a list or panel in
ActivityRegionand the first live indicator inside animates while the rest hold still — not by convention, but because there is one lease and it is already taken. Motion repeated forty times has stopped being a signal and become texture. Outside a region thepulseprop still rules, so a single live badge needs no ceremony. A second claimant goes static rather than throwing: a design system that breaks a consumer's production build over a presentational rule has done more damage than the rule prevents. ToggleandCheckboxuse the selection tokens, not the accent. Checking a box is the person acting; the accent is the system acting.Spinneris not accent-coloured. "This interface is waiting" is a different claim from "an agent is acting".- A confirmation names its action, and calls its way out
Cancel. The control that performs a destructive action says what it does — "Drain node", "Revoke token" — never "Confirm", "OK" or "Yes", and the title asks it as a question using the same verb. The way out is the opposite on purpose: "Cancel", plainly, first in the row. Spend the specificity on the thing that changes something. toastshas no error tone. An error belongs in aCalloutthat stays on the page; a toast is gone in three seconds and the person who looked away has lost it. Making the wrong thing hard to type outlasts documenting that it is wrong.MonoIdnever truncates in the DOM — only visually. A shortened id that looks complete is worse than an obviously elided one.Terminal's copy action strips the prompt.- A component that owns a bindable prop warns, in dev, about props it does not
have. Svelte drops an unknown prop silently, which is harmless until the name
you got wrong was the bindable one: the strip renders, the underline moves on
click, and your bound variable never changes — the visible state and your model
disagree with nothing saying so. It warns rather than throws, and says nothing
in a production build. Components with a deliberately open prop set (
Input,Select, which accept HTML attributes) are excluded, since for them an unrecognised prop is the feature. - One name per concept, with the other accepted as an alias.
toneandstatusboth reachBadgeandCallout;activeandselectedboth reachTabs, and both are bindable —bind:selectedwrites back exactly asbind:activedoes. The canonical names aretoneandactive; the aliases exist so that nothing written against the older names breaks. Bindingselectedon a tab strip was the case that motivated this: it used to render, highlight the clicked tab, and leave the caller's variable untouched. - Reduced motion is guarded per component as well as in the shared keyframes, so a consumer who skips the base stylesheet still gets it.
Restyling one: --ng-<component>-* is the seam
Some components read a custom property you may set, with the shipped value as its
fallback. That property is the supported surface. The class names are not —
.ng-chip, .ng-table-cell and the rest are implementation details we rename.
| property | on | what it sets |
| ---------------------------- | -------------- | ----------------------------------------- |
| --ng-chip-color | Chip | the label colour |
| --ng-chip-border-color | Chip | the border colour |
| --ng-expandable-surface | Expandable | the ground a nested expandable sits on |
| --ng-table-sticky-bg | ScrollRegion | the ground behind a Table's sticky head |
| --ng-nightfield-min-height | Nightfield | the band's height, when 100dvh is wrong |
Set one wherever it will inherit — a wrapper, a :root rule, or a class you pass
through the class prop every component accepts:
<Chip class="chg-breaking">breaking</Chip>
<style>
:global(.chg-breaking) {
--ng-chip-color: var(--ng-bad);
}
</style>Why a property and not a documented class recipe. A class recipe would be a
cascade fight, and one you would lose: Svelte scopes our rules, so what ships is
.ng-chip-neutral.svelte-<hash> at (0,2,0). Your own class is (0,1,0) and
loses outright; the two-class form you would reach for next,
:global(.ng-chip.chg-breaking), only ties at (0,2,0) and is then decided
by your bundler's import graph — which passes locally and changes under a
dependency bump.
⇒ A custom property does not win that contest, it removes it. We never
declare these properties, only read them, so yours is the only declaration and
its specificity stops mattering: (0,0,1) serves as well as (0,2,0). That
"never declared" property is pinned by a test, because it is the whole of why
this works.
A tone is not a status. ChipTone stays neutral | emphasis deliberately —
a chip labels a CATEGORY and a badge asserts a STATE, and a chip with status
tones would be a second Badge. The property is there so your domain's
categories (breaking, security, whatever they are) can look like themselves
without becoming part of our vocabulary. That exclusion is our position and the
asking consumer's, not a limitation anyone regrets.
⚠ The list above is the whole of it. A --ng-* name you find by reading our
source is not automatically a seam: several are internal channels a component
writes to itself, and setting those from outside does nothing useful. If you need
one that is not here, ask — the gap that produced this section was a seam that
existed in the source and in no place a consumer would look.
The family
Five packages, published together from one repository:
| package | what it is |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| @nighthq/tokens | the colour system, and the base stylesheets that depend only on it |
| @nighthq/components | the Svelte 5 interface primitives, plus an opt-in atmospheric tier |
| @nighthq/icons | line-work icons and the brand mark, as path data — no framework |
| @nighthq/model | the framework-neutral view kernel: one semantic model, two presentations |
| @nighthq/host | the effect boundary — history, titles, announcements, focus |
Start with tokens + components. Add icons when you need glyphs, and
model + host when one screen has to render as both a desktop and a compact
presentation.
The repository is private, so homepage and repository links on these npm
pages will not resolve for you — these READMEs and the tarballs are the whole
documented surface, which is why they carry more than a link would.
(No counts in this table on purpose: a number repeated across five files is
five places for it to go stale, and only each package's own README has a
check-docs claim behind its figures.)
