Repository navigation
v0.1.6
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 accessor and
children as a value. A Slot may be either, and a value is captured once by
definition — a static title does not update either. Both cases are pinned so the
contract stops being something to rediscover.
fix: four defects found by using the components
A pre-filled <select> showed the wrong value, silently. The .value
binding sits on the opening tag, so the renderer assigned it before the options
existed -- select.value = 'x' against a childless <select> is a no-op the DOM
does not report, and nothing re-ran it. The control displayed its first option
while holding the value it was given, so what the user confirmed was not what
the form submitted: editing a French PEA showed a plain brokerage account, and
saving would have written that. Marking each option instead is
order-independent, and it is also what makes the choice correct in
server-rendered HTML before any script runs.
An overlay could stay in the DOM forever. animationName is a
DECLARATION, not a promise that one will run: it reads back whatever the
stylesheet said whether or not those keyframes exist anywhere. An application
that has not imported the stylesheet declares an animation the browser never
runs, animationend never fires, and every closed Dialog, Select, Popover and
Tooltip piles up as an invisible layer swallowing the clicks underneath. A
missing stylesheet is cosmetic; a page that stops responding is not. Waiting is
now bounded by the duration the element itself declares.
A floating surface reused across anchors showed the wrong one. Content is
built once per open, and show() returned early whenever something was live
without asking whether the anchor had changed -- one menu shared by the rows of
a table showed the first row's entries however many rows were clicked, with its
position, dismissal listeners and focus trap still bound to the old anchor.
SidebarMenuItem accepted no class. Every other component here merges
one; in a port where a caller overrides through className that is a gap, not
a decision.
Name the CI workflow after the package
Every workflow already declared name: <pkg>-ci inside — twenty-eight of
twenty-nine — while the file was ci.yml almost everywhere and
<pkg>-napi-ci.yml in three places, where the -napi said nothing: half the
packages with a Rust engine did not carry it.
The file now matches the name it has always had, so one rule covers every
repository and the publish command no longer depends on remembering which
three were spelled differently.
GitHub keys run history by file path, so the runs recorded under the old name
stay reachable under it and this workflow starts a fresh history.
Changes since v0.1.5.