Skip to content

Releases: C9up/nebula

v0.2.2

Choose a tag to compare

@github-actions github-actions released this 01 Oct 10:22

release: nebula 0.2.2

Count the components the README lists

The introduction and the component list still said sixty-nine, from before
AccessibleIcon, Image, VisuallyHidden, Picture and Toolbar landed; the
atom and molecule counts and the test count under Development were stale
the same way.

Correct the last two stale parity lines

The parity section still said Aurora has no context (it has, and these
components use it) and that Toaster has four variants and no promise
toasts. Both were fixed further down the file and not here.

Say why compound components take data, correctly

Three places justified the data-driven API with "Aurora has no context".
Aurora has createContext / provide / inject, and these components use it —
Tabs provides a TabsApi that TabsTrigger and TabsContent inject. The
README used Tabs as the example while saying the opposite.

The real constraint was already written down in lib/children.ts: context
lives on a stack during a component's SETUP, which is synchronous. A part
handed in as a Slot thunk is built later, by the renderer, once that frame
has popped — so <Tabs><TabsList><TabsTrigger>, where a child reads the
parent it was nested inside, would read nothing. Taking data keeps every
part built while the context is still there.

It matters because the false premise pointed the next contributor at "just
use context" and hid the wall they would actually hit.

Give the toast an outcome: promise, update and dedup by id

shadcn ships two notification components: sonner, which wraps a
third-party library, and toast, its own, rebuilt on Base UI. The one this
follows is the second, and the gap against it was real — no way to report
an async call, which is most of what the pattern is for.

Added: toast.promise(work, { loading, success, error }), a loading variant
that has no countdown of its own because it is waiting for an outcome,
update(id, patch) which recomputes that countdown, a caller-supplied id
so showing the same toast twice updates it instead of stacking a ninth
"you are offline", and onClose.

promise() returns the SAME promise it was given, rejection included. It
reports, it does not handle: swallowing there would turn a failed call
into a red toast and nothing else, with the caller's own error path never
running. Its reporting chain handles its own rejection, so telling the
user about a failure never adds an unhandled one.

Names stay nebula's — show / dismiss / variant / duration, not add /
close / type / timeout, and variant keeps warning, which Base UI's type
does not have. variant is what every other component here calls this
(Button, Alert, Badge); renaming it in this one file would make the
package less consistent with itself than with a library it does not use.
The deviations, and the four fields left out, are named at the top of the
file rather than left to be discovered.

onClose belongs to application code and runs from a timer, a click and
clear() alike, so one that throws is caught and reported instead of
leaving the queue half updated.

show() now returns ToastId (number | string) rather than number — a
widening, so only a caller annotating : number is affected. Worth
naming at the next bump.

Close the last gaps against Radix's primitive set

nebula already covered the shadcn registry. What it did not cover was the part
of Radix that shadcn styles no equivalent for — easy to miss precisely because
nothing looks wrong without it.

Direction. floating read the writing direction off the anchor and
mirrored placement; rovingFocus did not, so ArrowRight moved to the next item
in every widget nebula ships, including in Arabic and Hebrew where the next
item is to the left. Both now ask the same helper. Vertical arrows are left
alone: dir does not turn a page upside down.

VisuallyHidden. The clip technique, in one place. Three components were
hand-writing sr-only and the next one to need it would have written a fourth
variant. It is a <span>: Radix takes asChild so a hidden heading can be a
real <h2>, and aurora fixes the tag at compile time, so the equivalent is to
put this inside the heading rather than to become one.

AccessibleIcon. An icon with no label is announced as nothing, which is how
a row of icon buttons becomes a row of unlabelled buttons. The glyph goes
aria-hidden so its own <title> is not read on top of the label.

Toolbar. The WAI-ARIA pattern: one tab stop, arrows inside. Without it a
formatting bar costs one press of Tab per control, and the keyboard user below
pays for every one. Items are found by the same tabbable selector the focus
trap uses — now exported rather than copied — so anything a keyboard could
reach on its own is reached here, with no marker attribute to remember.


Changes since v0.2.1.

v0.2.1

Choose a tag to compare

@github-actions github-actions released this 23 Sep 17:14

Generate the config from a stub

The file this package writes lived as a template literal inside its own
TypeScript — every backtick and every ${ escaped, and no way for an
application to change it without forking the package.

It is a stub now, read through codemods.makeUsingStub, the same route
ream-cli has always used for the make: generators. An application that
publishes stubs/<path> gets its copy instead, and the generated file is
byte-identical to what the literal produced.

The ream peer moves to ^0.2.26: that is the release the codemod appears in.

release: nebula 0.2.1

Mint ids from aurora's counter, not a second one

lib/id.ts had its own module-level counter. Aurora resets its sequence at the
start of each render pass — renderPage on the server, hydrate in the
browser — and a second counter living here saw none of those resets.

So a long-lived server shipped tooltip-trigger-14 while the browser,
hydrating from zero, minted tooltip-trigger-1 and looked it up. byId
answered null, floatingSurface had no anchor and returned before it opened
anything — on a page where the mount hook HAD run and the trigger WAS wired,
which is what made it look like a lifecycle bug.

Every id nebula mints now comes from the counter aurora resets. The names are
re-exported, so nothing importing them changes.

The suites all rendered client-side, which is the path that works; the new one
does what a ream page does — server-render, then adopt — and covers a tooltip
on its own and one inside another component's children.

The aurora peer moves to ^0.1.47: uid / resetIds / byId appear there.


Changes since v0.2.0.

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 22 Sep 19:03

Require aurora 0.1.45: the context primitive the parts are built on

Add AlertDialogPortal, the last part upstream exports

Split NavigationMenu and Sidebar into their upstream parts

NavigationMenu keeps one surface for the whole bar, as Menubar does: only one
panel is ever open, and moving between triggers is a change of anchor and
contents rather than a close and an open. Its delays are what make it usable
with a pointer — opening waits so crossing the bar does not flash every panel,
closing waits longer so travelling diagonally into a panel does not dismiss it
halfway. NavigationMenuViewport is a NAMED DEVIATION: inert, because the
panel is portalled and positioned by floatingSurface rather than drawn into
a viewport element the way Radix animates its width and height.

Sidebar goes from eleven exports to twenty-four, and one of them is a rename
that had to happen: what nebula called SidebarMenuItem was upstream's
SidebarMenuButton — the control — while upstream's SidebarMenuItem is the
<li> that holds it, an action and a badge. Keeping the old name would have
made every layout copied from upstream silently wrong.

SidebarProvider and useSidebar are new, and the state stays in the
cookie-backed module singleton behind them: the shell renders once and a
trigger may sit in a header that is not inside the provider's subtree. The
cookie is still what makes the server render the right width — with
localStorage the sidebar renders expanded and snaps shut on hydration, on
every page load.

Also new: the rail, the inset, the input, the separator, the group action and
content, the menu badge, the skeleton and the sub-button — the parts an
application shell is actually assembled from.

Split Command into its nine upstream parts

Filtering happens WITHOUT rebuilding the list. Each item registers itself as
it is built and then hides itself when the query stops matching — Aurora never
re-runs a component's setup, so a palette that rebuilt its results on every
keystroke would lose the DOM its highlight and its scroll position live in. A
group hides itself the same way once none of its own items are showing, so an
empty heading never sits over nothing.

That changes what a test counts: the filtered-out options are still in the
document, behind hidden, which also takes them out of the accessibility tree.

One bug found by making the change, and it was invisible in isolation. The
palette seeded its highlight by WRITING a signal while its items registered —
a write during the render that was building them. A Combobox rebuilds its
content inside an effect, so the write re-entered that effect and the palette
recursed until the stack gave out; a Command mounted on its own has no
enclosing effect to re-enter and never showed it. The highlight is derived now:
the explicit value when the user has moved, otherwise the first match. A
regression test opens a Combobox.

Combobox and CommandDialog compose the parts rather than passing an array
through. Combobox gathers consecutive options sharing a group under one
heading, in the order given — a group that repeats after an interruption gets a
second heading, because reordering the caller's list to merge them would change
which option the arrows reach next.

Split Pagination, Carousel, Resizable and InputOTP into their parts

Four components whose descriptions had each fixed something the composition
leaves open:

  • Pagination described a page LIST. pageWindow still decides which pages are
    shown — first, last, a run around the current one and the gaps — and is
    still exported, because that is the part worth having; DataTable now
    composes the parts around it. A link stays an <a> whether or not it has an
    href, and onSelect intercepts only when given, so a server-rendered
    pagination keeps working with JavaScript off.
  • Carousel took an array of slides. Each CarouselItem now claims its
    position as it is built, and reads the total lazily — the count is only
    final once every slide exists, so "3 of 7" cannot be captured early.
  • Resizable becomes upstream's ResizablePanelGroup / ResizablePanel /
    ResizableHandle, with a NAMED DEVIATION: the group holds one split, so the
    first panel takes the share and the second takes the rest.
    react-resizable-panels distributes an array across any number of them.
  • InputOTP's boxes claim their own index, so the slots need no index prop and
    cannot be numbered wrongly. A real <input> per box stays: it is what gives
    each one its own caret and the one-time-code hint the platform fills from an
    SMS.

Complete Field and InputGroup against their upstream parts

Field had four exports where upstream has ten, and the six missing ones were
the ones that carry the accessibility: a label with a real for, a description
and an error whose ids the control names, a legend and a fieldset for a group
of fields that answer one question together.

fieldIds() stays and is now shared through context rather than threaded into
every part. Upstream leaves the ids to the caller — <FieldLabel htmlFor="x">
beside <Input id="x"> — which works until the two drift; here the caller
needs the set only for the CONTROL, and the label, the description and the
error read it themselves. The error node is always in the DOM behind
empty:hidden, because the id describedBy names has to resolve to something
the moment an error appears and a node added later is announced late or not at
all.

InputGroup gains the button, the static text and the textarea, plus align on
the addon so one can sit above or below the control rather than only beside it.
The button defaults to ghost and the smallest size: a filled button the height
of the field turns the group into two controls side by side, which is exactly
what the group exists to avoid looking like.

Split AlertDialog and Drawer into their upstream parts

AlertDialog keeps everything that distinguishes it from a Dialog, all of which
is about refusing to let the question be dismissed by accident: role ="alertdialog", no outside-click dismissal, and focus landing on CANCEL rather
than on the confirming action — the safe choice is the one a hurried Enter
should hit. Escape cancels rather than confirms, for the same reason.

AlertDialogMedia and the size on the content are new; upstream's header
grid rearranges around both, which is why the classes carry
has-data-[slot=alert-dialog-media].

Drawer keeps its drag-to-dismiss, on distance OR velocity: a slow long drag
and a quick flick mean the same thing, and requiring distance alone makes a
flick feel ignored. The handle stays decorative and aria-hidden — everything
the gesture does, Escape and the backdrop already do, because a gesture must
never be the only way out of a modal surface.

Split ContextMenu and Menubar, on rows shared with DropdownMenu

Upstream repeats all fifteen row components in each of dropdown-menu.tsx,
context-menu.tsx and menubar.tsx, because each file is copied on its own and
has to stand alone. The three already share primitives/menu.ts here — its
class strings, its keyboard model, its submenu registry — and the registry
copies it with them, so createMenuParts(prefix, context) builds the rows once
and the three menus differ by exactly what differs between them: the
data-slot prefix and which context tells a row how to close the menu it is
in. DropdownMenu was rewritten onto it, and its sixty tests did not move.

What is specific survived:

  • ContextMenu opens at the POINTER, so a zero-size anchor is parked at the
    click coordinates and the surface positioned against that — which is what
    lets the menu flip and shift off a viewport edge like any other. Touch keeps
    its long press, cancelled by movement so a scroll that starts on the region
    does not open a menu under the thumb.
  • Menubar keeps ONE surface for the whole bar. Only one menu is ever open, and
    moving between them is a change of anchor and contents rather than a close
    and an open — that is what makes left/right feel like walking a bar. Each
    MenubarMenu claims an index as it is built, which is the order the arrows
    walk, and provides it to its trigger and its content so neither has to be
    told which menu it belongs to.

Split RadioGroup, ToggleGroup and HoverCard into their parts

RadioGroup keeps its named deviation and gains a reason to state it plainly:
upstream renders a button with role="radio" because Radix needs an element
it fully controls, and that costs arrow-key movement, single-checked
enforcement and posting with a plain HTML form — all of which a real
<input type="radio"> sharing a name does for free. The input stays; the
slot names and the indicator are upstream's.

ToggleGroup gains upstream's spacing, which decides whether the items join
into one bar or sit apart, and passes variant and size down through the
context so a grouped button cannot drift from a standalone Toggle.

HoverCard's trigger is an <a> when given an href and a <span> otherwise.
Upstream wraps a link with asChild; a <span> around one would put the hover
target and the link in different elements, and the card would open from
something the keyboard never reaches.

Split Tabs, Accordion, Collapsible, Avatar and Breadcrumb into parts

Collapsible's own doc-comment named the reason it was not composed: "Aurora
has no context". It does now, so the deviation is gone rather than explained.

Each conversion keeps what the description was doing and loses only the
description:

  • Tabs mints one id pair per value, so a trigger and its panel — built at
    different moments, knowing only the value they share — name the same pair.
    The implicit default is the first ENABLED trigger, since a disabled tab
    selected by default shows a panel nothing can navigate back to. Upstream's
    tabsListVariants comes with it: the underlined line look nebula had no
    way to ask for.
    -...
Read more

v0.1.9

Choose a tag to compare

@github-actions github-actions released this 22 Sep 16:58

release: nebula 0.1.9

Require Node 24, and build the crates for production

Node 24, not because it is the current LTS — that is AdonisJS v7's own
stated reason and it is not one for us, since 22 still receives security
fixes until 2027, npm 11 is irrelevant under pnpm, and node:sqlite is
not what atlas uses. The reason is measurable and it is the framework's:
AsyncLocalStorage is on the request hot path, and Node 24 backs it with
AsyncContextFrame by default — 0.61 us per request instead of 1.55 us on
that exact pattern. Before 24 the same mechanism sat behind an
experimental flag, and a framework cannot base its performance on a flag
the application has to remember to pass. The reason travels with the
constraint, in a "//engines" key beside it.

Where there are crates: the default release profile leaves lto = false
and codegen-units = 16, so nothing inlines across crate boundaries —
and here the hot loop and the N-API binding that calls it are always two
different crates. Measured on atom, a scalar call through the binding
went from 18.85 ms to 13.59 ms for 50 000 operations. No panic = "abort": napi-rs catches panics and turns them into JavaScript
exceptions.

CI moves to Node 24 with them, since that is what the packages now ask
for.


Changes since v0.1.8.

v0.1.8

Choose a tag to compare

@github-actions github-actions released this 19 Sep 14:52

Declare the dependency the keyframes were hiding

The four bespoke keyframes came back in 0.1.7 to fix overlays that
stayed mounted after closing. They were the wrong answer, and the
reasoning is already written at the top of lib/motion.ts: they were
referenced through Tailwind ARBITRARY values, which Tailwind compiles
unconditionally, so animation was set while the keyframes behind it
might exist nowhere -- the browser then never fires animationend. A
theme-registered utility is simply not emitted instead, which is why
0.1.6 moved to tw-animate-css and dropped them.

Measured rather than argued: nothing in src/ or dist/ references
nebula-fade-* or nebula-zoom-* as code -- the one hit in the published
dist/lib/motion.js is the doc comment saying nebula USED to. And
presence.js gained armDeadline in 0.1.6 (3 occurrences; none in
0.1.5), so a surface whose animationend never arrives is now removed
on a deadline rather than left in the document.

What is real is the dependency underneath. tw-animate-css was a
devDependency, inlined only into the compiled nebula.css, so every
consumer on the tailwind or unocss adapter had to supply it without
being told -- and the README said the opposite, listing the keyframes
as its replacement. It is now an optional peer, the README says what
to import, and the section explains why the old arrangement failed
badly rather than cosmetically.

Put back the keyframes every overlay closes with

An overlay leaves the DOM when its exit animation ends — the component
waits for animationend. Without a @keyframes to match the
animation: nebula-fade-out … the utility emits, the animation never
starts, the event never fires, and a closed dialog stays on the page as a
full-screen backdrop nobody can see and every click hits. The application
appears to stop responding, with nothing in the console.

These were in this file through 0.1.5 and went missing in 0.1.6, while the
README kept listing them as what this file contributes in place of
tw-animate-css. Consumers on the css adapter never noticed: nebula.css
is compiled here and carries its own copy. Everyone on tailwind or
unocss lost their overlays on upgrade.

Restored exactly as 0.1.5 had them rather than rewritten from the names.


Changes since v0.1.7.

v0.1.7

Choose a tag to compare

@github-actions github-actions released this 19 Sep 08:40

Draw every offered width from the list the endpoint serves

A declared width was emitted literally, so width: 1200 asked for 1200 and
2400 — two widths no allow-list contains, and a srcset where those entries
400. Widths now round up onto a fixed ladder, which also gains a small
rung set so a 48px avatar stops being offered 640.

Rounding up rather than down: down is how a dense screen is handed fewer
pixels than it can show, and the softness is hard to attribute later.

Add Image and Picture, responsive by default

A bare ships desktop pixels to a phone and reserves no space
until it arrives. Both are fixed by attributes nobody writes by hand, so
these components write them: a srcset of a dozen widths, a matching
sizes, width/height for the ratio, loading and decoding.

The bytes come from a resolver the app installs, defaulting to the
/__image endpoint @c9up/prism registers — a component that imported an
image processor would drag a native module into a package whose only
peer is Aurora.

One deviation from the usual ladder-and-filter, commented where it
lives: a constrained image also offers the source width itself, so a
500px source displayed at 400 is not handed to a dense screen upscaled.


Changes since v0.1.6.

v0.1.6

Choose a tag to compare

@github-actions github-actions released this 10 Sep 14:30

Build the stylesheet where its imports can be resolved

The generated entry lives in a scratch directory and imports tailwindcss and
tw-animate-css by bare specifier. Tailwind resolves those relative to the
file doing the importing, and the scratch directory was under the system temp
dir, where there is no node_modules to walk up to — publish failed with
"Can't resolve 'tw-animate-css'". Every other path in that entry is absolute,
which is why those two alone broke, and why this machine happened to build
while CI did not.

The scratch directory now sits under the package's own node_modules/.cache.

Resolving the stylesheet to an absolute path was tried first and rejected: it
builds, but Tailwind treats a resolved path differently from the specifier, so
the bare form stays.

The regenerated nebula.css differs by one theme token from the copy committed
on the 7th — that is the dependency refresh since, not this change: the same
difference appears whichever way the entry is written.

Keep the dev-dependency alignment, drop the workspace: protocol

The internal ranges had been rewritten to workspace:^. That resolves inside
this monorepo and nowhere else: every package CI checks out its own repository
alone and runs pnpm install, where the protocol has no workspace to point at
and fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND before a single test runs. The
concrete ranges are back; the dev-dependency bumps that came with the same edit
are kept, and now match what the lockfile already resolved.

fix(presence): read the exit duration per CSS entry, and count what CSS counts

Three defects in the same place, one of them mine from the previous round.

The deadline ignored animation-iteration-count: 120ms played twice is
240ms, and it fired at 220 — the second pass was cut off. It also took the
longest duration and the longest delay INDEPENDENTLY, so a short-but-late
animation beside a long-but-immediate one produced a deadline neither of them
needed. Both are now read per entry, with a list shorter than the animation
list repeating to cover it, the way CSS reads one.

And the multiset I added last round over-corrected. An animation named twice
IS two animations and reports twice — that part was right — but a property
named twice in transition-property is ONE transition: the last entry is the
one that runs (CSS Transitions Level 1), and exactly one transitionend
arrives. Waiting for two meant every such exit ran to the deadline instead of
ending when the browser said it had.

splitList tolerates an absent longhand, because a computed style is not
always the complete one.

Three mutations, each falling on its own test.

fix(presence): two animations sharing a name are two animations

animation-name: a, a with two durations declares two animations, and the
outstanding set was keyed by name — so the first animationend("a") emptied
it and the node went away while the longer one was still running. It counts
them now, and each event marks one report off.

Both call sites go through it: presence() for the surfaces that own their
mounting, onExitFinished for the portalled ones.

Collapsing duplicates again fails exactly those two tests.

fix(presence): a deadline belongs to the close that armed it

Reopening a surface cleared pendingUnmount and nothing else: the first
close's deadline stayed armed, and the next close overwrote the handle
without clearing it. The older timer then fired on the FIRST close's
schedule and unmounted a surface that was still animating shut — the closer
the reopen was to the original deadline, the more of the exit was cut off.

open() now cancels the close outright, and arming goes through one place
that clears whatever the last one left behind.

onExitFinished had the other half of a bug presence() already fixed: it
finished on the FIRST animation or transition event, so a surface running a
fade and a slide together had its node removed when the shorter one ended.
It now waits for every declared name, exactly as presence() does — which
matters because the portalled surfaces (Dialog, Popover, Select, Tooltip)
go through here and not through presence().

Two tests, each falling on its own guard: restoring the leaked deadline
fails the reopen test alone, and restoring the first-event exit fails the
portalled-node test alone.

fix(presence): wait for every animation, and stop mis-detecting the missing ones

Four ways an exit was cut short, stranded or wrongly diagnosed.

The first event ended the close. A surface running a fade and a slide
together was unmounted when the shorter one finished, cutting the other off
mid-flight. Each declared name now reports for itself.

One missing animation truncated the ones that existed. Requiring ALL of them
to be defined meant a single typo'd name skipped the wait entirely. Any defined
animation is a reason to wait; the missing ones are still named, because they
are still a mistake.

The cache missed every change that adds no stylesheet. Keyed on the sheet
COUNT, it never saw insertRule, replaceSync, HMR or an edited <style>, so
a negative computed early stayed wrong for the life of the page. Only positives
are cached now — a "found" is durable, a "missing" is exactly what a later sheet
turns around, and re-walking costs a scan only when something is already wrong.

attach() mid-close disarmed the deadline without arming another. Handing
the surface a new element while it was closing left it mounted for good — the
failure the deadline exists to prevent, reintroduced by the handover.

fix(presence): bound the public API too, and detect keyframes correctly

Five defects, all in yesterday's detection.

presence() had no deadline at all. onExitFinished was bounded and the public
API was not, so a declared animation the browser never runs left the surface
mounted for good — the exact failure the deadline exists to prevent, on the path
most components take.

The cache was one global map, wrong three ways: an answer computed before the
stylesheet finished loading stayed wrong for the life of the page, an iframe and
its parent shared a cache while sharing neither styles nor document, and a sheet
added later never took effect. It is now per-document and keyed on the
stylesheet count, so a new sheet recomputes.

animation-name is a LIST. fade-out, slide-out was looked up as a single name,
found nothing, and reported both missing — warning about a stylesheet that was
there. And @keyframes nested in @media, @supports or @layer is not a
top-level rule; a flat scan of the sheet missed it for the same reason.

fix(motion): use shadcn's animation classes instead of reimplementing them

The components emitted animate-[nebula-zoom-out_120ms_ease-in] — an ARBITRARY
value naming four keyframes nebula declared itself — to avoid depending on the
plugin shadcn uses. That is the divergence, and it is what turned a missing
stylesheet from cosmetic into dangerous.

Tailwind compiles an arbitrary value unconditionally. The animation property
was therefore always set while the keyframes behind it might exist nowhere, and
presence waited on an animationend the browser would never fire: every
closed Dialog, Select, Popover and Tooltip stayed in the document, invisible,
swallowing the clicks underneath. With shadcn's setup the same mistake cannot
produce that — a theme-registered utility is not emitted when its theme is
absent, so the element animates not at all and closing is instant.

So the classes are now shadcn's, verbatim: animate-in / animate-out with
fade-*, zoom-* and slide-*, taken from its registry rather than recalled.
They come from tw-animate-css under Tailwind and unocss-preset-animations
under UnoCSS; both adapters import the right one and list it among the packages
an application installs, and the prebuilt nebula.css bakes them in.

motion-reduce:animate-none stays — it is ours, and it also makes
onExitFinished see no animation and remove the node at once, so reduced motion
closes instantly with no branch in any component.

fix(presence): name the missing stylesheet instead of only surviving it

The deadline stops a declared-but-undefined animation stranding the node, but
it explains nothing: the symptom becomes 'the floating layer behaves oddly',
which points everywhere except at the stylesheet nobody imported.

animationName is a declaration, so the only honest answer comes from looking
for the @Keyframes rule itself. When it is absent there is no reason to wait at
all — and every reason to say so, once, naming the import that fixes it.

It answers 'assume it exists' when it cannot tell. A cross-origin stylesheet
throws on cssRules, and refusing to animate because a font sheet was unreadable
would be the worse trade: the deadline already bounds the cost of being wrong.

Warned, not thrown. A missing stylesheet is a cosmetic dependency and taking an
application down over one is worse than a line in the console.

fix(dialog): type title and description as Slot, like children beside them

They were typed Child, which refuses a function — so a Dialog driven between
two contents could not declare a reactive title without a type error, even
though the renderer binds one perfectly well. The type disagreed with the
runtime and with children sitting next to it.

This is what the report of a 'Dialog reused for two contents' was actually
touching: children passed as a function DO track a signal, and the asymmetry a
caller sees comes from title being an accessor while children was a value.

test(dialog): pin that children track a signal, and that a value does not

Reported as a Dialog reused between two contents keeping the first form while
its title followed. It does not reproduce: children passed as a function track
the signal exactly as the title does.

The asymmetry is what a caller sees when title is passed as an a...

Read more

v0.1.5

Choose a tag to compare

@github-actions github-actions released this 06 Sep 07:46

Apply the border token instead of defining it

Tailwind v4 dropped v3's default border colour: a bare border sets a width
and leaves border-color at currentColor. nebula's components are written
the way shadcn writes them — the colour left to the base layer — but no base
layer was emitted, so every one of them painted its border in its own text
colour. Measured in Chromium under .dark: border oklch(0.985 0 0), text
oklch(0.985 0 0). White borders, in a dark app, with every token correctly
defined and none of them applied. The page background was unclaimed for the
same reason.

All three adapters were affected and all three are fixed: the Tailwind
stylesheet, the UnoCSS preflight (written out, since a preflight has no
@apply), and the prebuilt nebula.css the css adapter ships verbatim.

Lint this package the way its own repository will

biome's configuration lived only at the workspace root. This package is
built from its own repository, where that file does not exist and biome
falls back to its defaults — so lint in CI has been checking a different
set of rules from lint here, and the bans this project actually cares
about were never enforced where it counts.

The config is now the package's own, and says the same thing the root one
did.

Declare what CI has to install

Each package is its own repository: pnpm install there sees only this
file, so a dependency the workspace happened to hoist locally is simply
absent in CI. --coverage needs @vitest/coverage-v8 named here, and an
optional peer a test imports has to be a devDependency as well — optional
is exactly what keeps it from being installed.

Run the gates the package already declared, and declare the missing one

Three guard-rails were configured and never reached CI:

  • tsconfig.json includes tests, but CI typechecked only
    tsconfig.build.json — every type a test relied on went unchecked.
  • coverage was measured and reported, with no thresholds at all, so
    test:coverage could not fail whatever the suite stopped covering.
  • lint pointed at src/ alone, so no test file was ever linted.

CI now runs pnpm typecheck, coverage, and a lint covering tests/.
The thresholds are a floor set just under what the suite covers today.


Changes since v0.1.4.

v0.1.4

Choose a tag to compare

@github-actions github-actions released this 04 Sep 15:28

Turn on noUncheckedIndexedAccess

It was not missing here — it was explicitly false, in sixteen of the
seventeen tsconfigs. eon alone had it on, which is why nobody had seen
what it finds.

It stays a named deviation from upstream: @adonisjs/tsconfig sets
strictNullChecks and noImplicitAny but not this one. We keep it because
turning it on is what caught an as asserting a possibly-absent regex
group was a known value — the exact shape the flag exists to find. Doing
better than upstream is kept and written down, not reverted to parity.

Every site is restated rather than silenced: no !, no cast, no ?? 0
standing in for a branch that cannot happen. A reversed copy read by
value where an index walked a callback list backwards, the winner of a
scan kept as the value it found rather than its position, destructuring
where a length check was doing the proving, and an explicit break where a
loop condition already bounds the read.

Report copied components the package has since changed

Copying is one-way by design: nebula:add hands over the source and steps
back. The bill for that is a fix released here never reaching a project, with
nothing anywhere reporting it — the files are local, no lockfile names them,
pnpm update does not touch them. shadcn has the same gap.

nebula:diff closes it, and it answers with the reason a file differs rather
than the fact that it does. Every copied component is expected to diverge
eventually — that is what owning the source means — so "differs from upstream"
would flag the whole tree and say nothing useful.

Separating the two needs a reference point, so add now records the hash of
what it copied, beside the components it describes. Four answers come out of
it: edited is yours and needs nothing, outdated means the package moved
while the copy stayed put and re-copying loses nothing, conflict means both
moved and a person has to read them, and unknown is a component copied
before anything was recorded — which is the honest answer, since there is no
way to tell which side moved.

Say what an app's own stylesheet is missing instead of skipping in silence

The adapters mark the stylesheet skipIfExists, which is right — it is the
app's once it exists. But nothing said so, and nothing said what had therefore
not been written. A project that already had an app.css got the components
copied in and no tokens for them to resolve against: every class name present
in the markup, every colour resolving to an undefined custom property, and a
page that renders grey with nothing raised anywhere.

configure now reads the file back and names the tokens it does not define.

Tokens rather than directives, because there is more than one correct way to
supply them: importing nebula's theme, or declaring --color-* directly in an
@theme block. Checking for the directives would have reported a complete,
working stylesheet as broken.

skipIfExists is also passed through to the codemod now, rather than the
contract being decorative and the default deciding.

Write the Tailwind variants out instead of building them at runtime

Slider and InputGroup composed their variants by joining a string:

`[&::-webkit-slider-thumb]:${THUMB.split(" ").join(" [&::-webkit-slider-thumb]:")}`
`[&_input]:${inputGroupControlClasses.split(" ").join(" [&_input]:")}`

Tailwind finds class names by scanning the source as text. It never evaluates
the expression, so [&_input]:border-0 was never a literal anywhere and no
rule was generated for it. The elements carried class names no stylesheet
defined. Nothing threw and the markup read as correct.

The shipped nebula.css proves it: no [&_input]: rule at all, and exactly one
slider-thumb rule — the focus-visible: pair on the line below, which is
written as a literal. The literal survives and every computed neighbour is
missing. Rebuilt, the thumb goes from 6 occurrences to 22.

The Slider was the worse of the two: its thumb got no styling whatsoever and
fell back to the browser's native control.

InputGroup now follows shadcn and ships an InputGroupInput. The stripping
belongs on the input, which is where shadcn puts it; moving it onto the parent
as a computed descendant variant is the deviation that could not survive a
static scanner. inputGroupControlClasses stays exported for a caller passing a
plain , documented as the path rather than as a workaround.

A test now walks the sources and fails on any variant built by interpolation.
This failure is silent by construction — nothing raises, only the pixels are
wrong — so it needs a gate rather than a review habit.

Reject three kinds of element the browser refuses to focus

isVisible() answered true for a node inside a collapsed

, for one
under a display:none shadow host, and for one detached from the document.
Chromium refuses focus on all three, so a focus trap could hand focus to a
node that never takes it, leaving it wherever it was and letting Tab walk
straight out of the overlay.

The collapsed

is the case layout actively lies about rather than
declines to answer: Chromium reports offsetParent , a 62x21 rect and
display: inline-block for a button inside one, while checkVisibility() says
false and focus() does nothing. The fast path therefore returned true before
the styles were ever consulted, so this has to be read from the markup. The

stays focusable in both states, and the walk continues past an open
because a nested one can still sit inside a collapsed parent.

The ancestor walk now leaves a shadow tree by its host, which parentElement
never does, and a detached node is rejected outright: it has no computed
styles at all, so nothing along the walk could ever be display: none.

Verified against Chromium: isVisible now agrees with whether focus() actually
lands, on all eight cases.

Release 0.1.4

Fix a visibility check that happy-dom had made unconditional

isVisible() decided whether a node is rendered from offsetParent !== null. happy-dom does not implement offsetParent, so it read undefined,
and undefined !== null is true: every element was visible, always. The
focus filtering behind focus traps, roving groups, menus and modals was
never exercised, and isVisible had no test of its own.

offsetParent stays the cheap fast path when it yields an element, which
is positive proof that a layout engine ran. Its absence proves nothing:
a browser reports null for position: fixed nodes and for too.
So the check falls through to the styles, walking ancestors for display
because it does not inherit, and reading the element's own visibility
because it does. Browser behaviour is unchanged; the check becomes
answerable without a layout engine.

The suite moves to jsdom, which declines to fake layout rather than
faking it wrongly. scrollIntoView is stubbed as a no-op: jsdom ships
none, and a headless DOM has no scrolling to assert. Seven tests now
cover the cases that were passing vacuously.

The Form unmount skip is lifted — it worked around an
HTMLFormElement.remove() bug in happy-dom that jsdom does not have, so
that component is demounted by the smoke test again.


Changes since v0.1.3.

v0.1.3

Choose a tag to compare

@github-actions github-actions released this 01 Sep 15:41

Release 0.1.3

Turn on noUnusedLocals/noUnusedParameters

Bump happy-dom past the VM context escape

happy-dom <20 has the VM context escape (GHSA-37j7-fg3j-429f), and 15.x also
carries the unsanitized-export compiler flaw and the page-origin fetch
credentials. Dev-only; 406 tests pass on 20.

Give focus traps a stack, so two portalled siblings stop fighting

focusTrap guarded on container.contains, on the assumption that a nested
dialog is a DESCENDANT of the one it opened over. Every modal surface here
portals to document.body, so two open dialogs are SIBLINGS: each trap read the
other's focus as outside itself and pulled it back, and the focus bounced
between them until it settled in the outer one.

Only the topmost trap acts now. The token is pushed BEFORE the initial focus —
moving focus fires focusin, and a trap not yet on the stack had its own initial
focus reclaimed by the one below it. Release splices rather than pops, because
surfaces do not always close innermost-first.

Form's @submit also swallowed a rejection: command.run always resolves, but
validate() runs first and a validator that throws rejects there, leaving the
form doing nothing and a bare Uncaught (in promise) behind.


Changes since v0.1.2.