@maker428/ui
v0.2.2
Published
Shared Maker 428 UI foundation — semantic tokens, glow/elevation material, safe-area helpers, and the family's chrome primitives.
Downloads
697
Readme
@maker428/ui
The shared implementation foundation for Maker 428 apps — Marked., Noted., and It's Tacos.
This package is code. STANDARD.md is the rule. Where they disagree, the standard wins and this package has a bug. Nothing here re-decides anything; every value ships with a citation to the section that fixed it.
Status:
0.2.2prepared, not yet published.@maker428/[email protected]is the current release on the public npm registry under the@maker428scope, MIT licensed;0.1.0,0.1.1, and0.2.0remain published and superseded. Marked.'s Phase 4.1 and Noted.'s Phase 4.2 are merged (marked-app#2,noted-app#5); It's Tacos' Phase 4.3 (Its-Tacos#4) and Noted.'s0.2.1follow-up (noted-app#6) are open.
0.2.0fixed the two defects It's Tacos surfaced:Sheetplayed the canonical dismiss animation for a committed drag but for no other close route, andIconButtondropped a forwardedref.0.2.1closed the one Noted.'s follow-up audit then found in that new lifecycle — the exiting sheet stayed interactive while it left, so a destructive control reached mid-exit still ran.0.2.2closes one more, found by that same follow-up's own real-browser verification: at desktop widths the sheet's transform wrap spans the full viewport to center the panel, which covers the backdrop's visible scrim entirely, so a real click outside the panel never reached the element that dismisses it. See The dismissal lifecycle and The ref is the button.
Install
npm install @maker428/uiRequires React and React DOM 18.3+ or 19 (declared as peer dependencies; react-dom is needed because Sheet portals). No runtime dependencies — no animation library, no focus-trap library, no UI framework.
Use
// Once, at your app entry — before your own stylesheet.
import '@maker428/ui/styles.css'import { IconButton, Icon, MakerMark, useReducedMotion } from '@maker428/ui'
import { Plus, Settings } from 'lucide-react'
<IconButton label="New note" onClick={create}>
<Icon icon={Plus} />
</IconButton>
<IconButton label="Formatting" pressed={menuOpen} onClick={toggle}>
<Icon icon={Settings} />
</IconButton>
<footer>
<MakerMark version={__APP_VERSION__} />
</footer>Granular CSS imports work too, if an app wants only part of the layer:
import '@maker428/ui/styles/tokens.css'
import '@maker428/ui/styles/glow.css'styles.css is the same seven stylesheets — tokens, glow, elevation, safe-area, icon-button, maker-mark, sheet — concatenated in dependency order. Import it or the parts, not both.
What belongs in this package
Family-level chrome, material, and generic interaction infrastructure — the things STANDARD.md §22.2 lists as extractable because they have a credible single implementation proven across at least two apps:
- semantic theme tokens and the light/dark material base pair (§5.1, §5.3)
- the radius token family (§5.8)
- glow / shine / halo and their per-theme tuning (§5.5)
- the raised-surface elevation recipe (§7)
- safe-area helpers, including the composite spacing token (§5.7)
- reduced-motion behavior (§11.3)
- the Maker 428 attribution mark (§18)
- the icon-button / touch-target primitive (§9)
- a Lucide wrapper convention (§6)
What explicitly does not
Product identity, per §22.3 — even when the visual chrome around it is shared:
a ledger or ledger state · note-editor behavior · note rows · taco/feed cards · profile systems · social-graph logic · XP, tiers, or any gamification · Tacoberto · onboarding content · product-specific navigation shells · destructive-action flows · an app's own accent palette beyond the configurable token
The working test: if a component only makes sense once you say the name of one product, it does not belong here. A test in this package enforces the CSS half of that automatically.
Exports
| Export | What it is |
|---|---|
| IconButton | 44×44 icon-button primitive — required label, optional pressed toggle, opt-in glow, and a forwarded ref to the real <button>. Wraps children in an internal content element (.m428-icon-button__content) so press feedback never animates transform on the same element the glow's box-shadow lives on — see §5.5 and patterns/buttons-and-controls.md. Don't remove that wrapper; it's load-bearing, not incidental markup. |
| Icon | Lucide-shaped glyph wrapper — family default size/stroke, decorative by default |
| MakerMark | The attribution mark; version is required and supplied by the app |
| MAKER_MARK_PREFIX | The canonical string prefix, exported so apps can assert against it |
| Sheet | The bottom sheet — dialog semantics, focus/inert/scroll-lock, backdrop + Escape + drag dismissal. onClose fires when the exit has finished, not when the close was requested |
| useOverlay() | The modal-overlay contract on its own, for any hand-rolled overlay that isn't a sheet |
| useSheetDrag() | The drag engine on its own, for an app migrating its gesture before its markup. Its result also carries requestDismiss, the one entry point every close route uses |
| useSheetDismiss() | Called from inside a Sheet's content: asks that sheet to close, playing the canonical exit instead of bypassing it |
| SHEET_GESTURE | The canonical gesture constants, frozen |
| useReducedMotion() | Live prefers-reduced-motion boolean, no animation library |
| @maker428/ui/styles.css | The whole style layer |
| @maker428/ui/styles/*.css | tokens · glow · elevation · safe-area · icon-button · maker-mark · sheet |
Types: IconButtonProps, IconProps, IconGlyph, IconGlyphProps, MakerMarkProps, SheetProps, UseOverlayOptions, UseSheetDragOptions, UseSheetDragResult, SheetDismissal.
The ref is the button
IconButton forwards a ref to the real <button> it renders. There is no
wrapper element to forward to instead, and none was added: a wrapper would
change what a consumer measures — its box, not the control's — and put a second
node between the button and its glyph for nothing.
const triggerRef = useRef<HTMLButtonElement>(null)
<IconButton ref={triggerRef} label="More options" onClick={openMenu}>
<Icon icon={MoreVertical} />
</IconButton>
// Anchor a portalled menu to the trigger, and put focus back on it after.
const rect = triggerRef.current?.getBoundingClientRect()
triggerRef.current?.focus()Both of those are why this exists. It's Tacos' feed-card menu positions a
portalled popover from the trigger's viewport rect and returns focus to the
trigger when it closes; against 0.1.1 it had to keep a hand-rolled <button>,
because the component was a plain function component and dropped the ref.
Worth knowing if you only test on React 19: there, ref is an ordinary prop and
a plain function component receives it, so the gap is invisible. On React
18.3 — which this package supports and which It's Tacos runs — React discards
it with a warning. forwardRef is what makes the same code correct on both.
Overlays and sheets
What Sheet owns, and what stays yours
Sheet owns the parts every modal surface must get right and no app should re-derive: role="dialog" + aria-modal, focus entry, a Tab/Shift+Tab trap, focus return, stacked-Escape (topmost only), inert background, body scroll lock, backdrop dismissal, and the canonical drag-dismiss gesture.
You keep content, copy, accent identity, sizing (className), desktop placement (variant), and which dismissal routes are allowed:
<Sheet
label="Note info" // REQUIRED — or labelledBy="some-heading-id"
onClose={close} // fires AFTER the exit, not on the close request
dismissOnBackdrop // default true
dismissOnEscape // default true
dismissOnDrag // default true — mobile widths only
variant="side-panel" // adds --side-panel modifiers to every part
className="app-note-info" // your sizing and content styling
/>An accessible name is required
Exactly one of label or labelledBy must be supplied — either is fine, both together is fine, neither is not. TypeScript rejects omitting both, and Sheet throws at runtime if a plain-JavaScript caller does it anyway. Substituting a generic fallback like aria-label="Dialog" would satisfy the role while telling a screen-reader user that something took over the screen and declining to say what: a defect that looks correct in the markup and only shows up when someone actually uses the app with a screen reader.
labelledBy is usually the better choice when the sheet already renders a heading — the name and the visible title then cannot drift apart.
Sheets always portal to document.body
There is deliberately no custom portal-target prop. Background isolation works by marking every document.body child outside the overlay's branch inert; a sheet portalled inside the app root would make that body child "the branch containing the active overlay", leaving the entire app interactive behind a supposedly modal surface. Accessibility must not depend on undocumented DOM topology, so the knob does not exist. A supported custom target can be added when there is a concrete consumer and an isolation model that covers it.
A sheet is not the universal answer. STANDARD.md §10.1 keeps a centered native <dialog> fully valid for a small, simple confirmation. The diagnostic question is whether a user would ever want to drag the thing away rather than press a button to close it — if not, a dialog is the more honest surface, and drag behavior nobody uses is not a free addition.
Stacking
Overlays register in one module-level stack. Escape dismisses only the topmost — and is swallowed rather than passed down when the topmost opts out of Escape, so one keypress can never close a parent while a modal child is still on top of it. Closing a child restores its parent as the active surface: the parent stops being inert, and the scroll lock stays held.
The topmost overlay is the outermost owner of an unclaimed Escape — not the first thing to see the key. The order is:
focused descendant → overlay boundary → outside applicationA control inside the sheet gets the first opportunity. An ordinary combobox, menu, autocomplete popup, or inline editor can cancel itself on Escape with a plain onKeyDown and does not have to register as a Maker 428 overlay to receive the key. A descendant claims it by calling preventDefault() (or stopPropagation(), which is the same claim by a different route); the sheet then stays open, and the keypress still goes no further.
Only an Escape no descendant wanted reaches the overlay boundary, and there the topmost entry owns it completely: propagation stops before any application-level document or window listener runs — the situation Phase 4 makes real, when an app's existing overlay code and this package's temporarily coexist — and the sheet closes, or, with dismissOnEscape: false, swallows the key without closing. A non-topmost overlay is never dismissed by the keypress. Non-Escape keys are never touched at all.
The boundary listener sits on <html>, which is the one node above every React root and portal container — so every handler inside the application, native or React, has already run and defaultPrevented is a truthful answer — and still below document and window, where applications put their own handlers. Because it is a different node from document, plain stopPropagation() suffices and correctness does not depend on whether the app registered its listener before or after this one. useOverlay remains the right tool for a genuinely stacked overlay surface that needs modal/stack semantics of its own; it is not a prerequisite for reading a key.
Mixed modal and non-modal stacks work. A popover opened from inside a sheet with isolateBackground: false stays fully interactive: isolation anchors to the topmost overlay that asks for it and keeps everything above that anchor active, so the application behind the modal is inert while every overlay above it is not.
Configuration changes on an open overlay (dismissOnEscape, lockScroll, isolateBackground, trapFocus) apply in place. Stack position belongs to open order, never to prop churn — a parent whose props change does not jump above its own open child, and focus is not returned and re-entered.
Focus
On open: an explicit initialFocus → the first focusable element → the sheet container itself. That last fallback matters — an overlay with nothing focusable inside must still take focus, or a keyboard user is stranded behind the backdrop. A requested target that turns out to be disabled, hidden, or inert falls through to the next option rather than swallowing focus entry: .focus() on such an element is a silent no-op, so trusting the request would leave focus outside the overlay with both fallbacks skipped. On close, focus returns to whatever opened the sheet, or to returnFocusTo() when the sheet's content changed identity while it was open. An opener that has since been removed degrades silently rather than throwing.
The opener is captured during render (0.1.1). Every stage of React's commit runs children before parents, so an overlay whose content focuses itself on mount — a coach-mark's primary action, an editor's field, a search input — moves focus into the overlay before the hook's own effect runs. 0.1.0 captured the opener in that effect and so recorded an element inside the overlay, aiming focus return at a node that was about to unmount; in practice focus landed on <body>. Marked. hit this immediately as the first Phase 4 consumer. The capture now happens during render, ahead of the entire commit phase, which is the only placement that beats a descendant's mount effect of either kind — a layout effect would beat a passive one and still lose to a layout one. Consumers need to do nothing: the default is correct on its own, and returnFocusTo is for genuinely wanting a different target, not for working around this.
The dismissal lifecycle
patterns/bottom-sheets-and-dialogs.md §4 gives the dismiss animation to a
committed drag and to "any other method — a tap on the backdrop, an Escape
key, a close button": cubic-bezier(0.4, 0, 1, 1) over ~220ms, sheet to fully
off-screen, backdrop to opacity 0, unmounting slightly before the transition
visually ends. Under reduced motion it jumps straight to that final state and
the close still happens, immediately.
Every close route in this package converges on one controller:
drag commit ─┐
backdrop tap ─┤
Escape ─┼──▶ requestDismiss() ──▶ paint the exit ──▶ onClose()
in-sheet control ─┤ (idempotent) (≈200ms,
programmatic ─┘ or at once
under reduced
motion)onClose is therefore called when the sheet has finished leaving, not when
the close was requested. That is the only change a consumer sees, and it is
what makes the exit possible at all: a consumer that stops rendering the sheet
the instant it is asked to close leaves nothing to animate. Conditional
rendering ({open && <Sheet …>}) is still exactly right — the delay lives
inside the sheet, before your state changes.
The sheet stays fully modal for the whole exit. It keeps its place in the Escape stack, its inert background, and the body scroll lock until it actually unmounts; focus returns to the opener only then. Releasing any of that when the close was merely requested would hand the keyboard back to a page still sitting behind a visible sheet.
Repeat requests are free: the first call owns the exit, and a second Escape, a
backdrop tap mid-exit, or a drag completing alongside a button press cannot
produce two exits or call onClose twice.
"Backdrop tap" means whatever a real pointer reaches, at every width. Below
720px .m428-sheet__wrap is sized to its own content and pinned to the bottom
edge, so .m428-sheet__backdrop is directly under a tap anywhere above the
sheet. At 720px and up the wrap switches to inset: 0 so it can center the
panel (variants aside), which covers the backdrop's visible scrim entirely —
so the wrap itself is also a dismiss target there, gated by
dismissOnBackdrop exactly like the backdrop, and only for a click that lands
on the wrap and nowhere inside the panel. You never wire this yourself; it is
part of what "backdrop tap" already means.
And from that same instant the sheet's content stops accepting interaction. Staying modal and staying interactive are different promises, and only the first one survives the exit: a leaving sheet is still mounted, still focused and still on screen for ~200ms, so without this a control reached during that window ran its handler — a destructive button pressed mid-exit executed its action even though the sheet had correctly refused the second dismissal request. The lifecycle was single-shot; the content was not.
So, once dismissal is claimed:
- the leaving surface leaves hit testing, so no pointer, mouse or touch event
can reach it — including one bound with your own
addEventListener; - activation from the keyboard is refused too, because focus legitimately
stays inside the sheet and a focused button could otherwise still be pressed —
on
keydownandkeyup, and whether the handler is your ownonKeyDown/onKeyUpor a capture variant on a descendant of the sheet; - the action that requested the dismissal still runs — it is what asked, and it ran before the gate closed. Only what comes after it is refused.
Navigation is deliberately untouched: Tab still moves within the trap and Escape still reaches the stack, because the sheet is still modal and those two are what modality means. The backdrop also stays hit-testable on purpose — it is what stops a click landing on a sheet stacked underneath one that has not finished leaving.
None of this is configurable and none of it needs to be: there is no correct reason to act on a surface the user has already dismissed.
Closing from inside the sheet
Sheet owns backdrop, Escape and drag because it can see them. A close
button lives in your content, and the obvious implementation bypasses the
sheet entirely:
{open && (
<Sheet label="Details" onClose={() => setOpen(false)}>
<button onClick={() => setOpen(false)}>Close</button> {/* no exit */}
</Sheet>
)}Nothing about setOpen(false) is a sheet API call, so no amount of work inside
Sheet can intercept it. useSheetDismiss() is the way to ask instead of
bypass:
import { useSheetDismiss } from '@maker428/ui'
function CloseButton() {
const dismiss = useSheetDismiss()
return <button onClick={dismiss}>Close</button>
}Your onClose still runs and your state still changes — after the exit. The
same call covers a programmatic close originating in sheet content: finish
saving, then dismiss().
Called outside a Sheet it throws. A silent no-op would be a close button
that looks wired up and does nothing — the invisible failure this package
refuses elsewhere. A control that must work in both places should take its
dismissal as a prop at that boundary.
The gesture contract
Fixed by STANDARD.md §10.1 and exported as SHEET_GESTURE: 8px axis lock, 90px dismiss distance, 0.5px/ms dismiss velocity (from the trailing two samples only, never a gesture average), distance OR velocity — whichever fires first, never a weighted blend. Below both, the sheet settles back. A cancelled gesture always settles, never dismisses.
Scroll-vs-drag: content scrolls normally while its nearest real scroll container has room above; the sheet only takes the gesture once that container is at its top and the user pulls further. That scroll position is read at the moment intent commits (the 8px axis lock), not at touchstart — a finger that lands mid-scroll and reaches the top before crossing the threshold has, at the point of decision, nothing left to scroll, and the sheet correctly takes the drag. Once committed, the gesture stays committed however the page moves underneath it. Dragging the handle always drags. Horizontal intent never dismisses. A gesture that starts on a button, link, or field is never hijacked, and a completed drag never lands as a click on the control under the finger.
The handle's visible pill stays small, but its hit region is a full-width, 44px touch target (STANDARD.md §9). Margin around a 4px pill is not part of its hit region, and this is the one control where missing it silently changes meaning — a handle-origin drag is the only gesture that skips scroll arbitration.
Drag engages only below 720px, and that boundary is live: the media query is consulted at every gesture start, so rotating or resizing after the sheet opens keeps JS and CSS in agreement. Crossing the breakpoint mid-drag settles the sheet back rather than completing an interaction that is no longer valid — it never dismisses, because the user did not ask to close anything. Above the breakpoint the package un-pins the sheet and stops advertising a handle; where it goes instead is your call via variant. Shipping a desktop placement here would make this a layout system, which it deliberately is not.
Reduced motion
Live drag tracking is direct manipulation and is never suppressed. What changes is the terminal animation: under prefers-reduced-motion: reduce, settle-back and dismiss jump straight to their final state. The state change still happens — the sheet still closes, immediately. The preference is read fresh at release, so flipping it while a sheet is open takes effect at once.
useOverlay for anything that isn't a sheet
import { createPortal } from 'react-dom'
const panelRef = useRef<HTMLDivElement>(null)
useOverlay(panelRef, { onDismiss: close })
// Portalled to document.body, so background isolation applies — see below.
return createPortal(
<div ref={panelRef} role="dialog" aria-modal="true" tabIndex={-1}>…</div>,
document.body,
)Prefer a native <dialog> with showModal() where the visual and gesture behavior allow it — STANDARD.md §8.3 gets most of this contract from the platform for free. useOverlay is for the cases it genuinely can't cover: a spotlight coach-mark, a custom popover, a gesture-driven surface.
Background isolation depends on where you mount it
This is the one part of the overlay contract your own markup decides, so choose it deliberately rather than inheriting the default and assuming. Isolation operates on document.body's children: every body-level branch that doesn't contain an active overlay is marked inert + aria-hidden. An overlay nested inside the app-root branch, alongside the content it covers, therefore cannot use isolateBackground to make that sibling content inert — its own branch is the app root, and the app root is precisely what stays interactive.
That leaves two correct arrangements, and one mistake:
| | Arrangement | isolateBackground | Who isolates |
|---|---|---|---|
| ✅ | Sheet | (not a prop — always portalled) | The package, automatically |
| ✅ | Your overlay, portalled to document.body | true (default) | The package |
| ✅ | Your overlay, deliberately nested in the app tree | false | You — completely |
| ❌ | Your overlay, nested in the app tree | true | Nobody. Silently. |
Sheet is the easy case: it always portals to document.body with no opt-out, exactly so this can't be got wrong. A hand-rolled overlay should normally do the same — one createPortal call and the package handles isolation for you.
Keeping the overlay nested is legitimate when it must stay inside a particular stacking or layout context. In that case set isolateBackground: false and own isolation yourself — and completely. Marking one convenient subtree inert while other covered content stays reachable is a partial trap rather than isolation: a keyboard or screen-reader user will find the gap even though the screen looks correct.
Everything else in the contract — focus entry, trap, return, Escape, scroll lock — works identically wherever the overlay is mounted. Only isolation is topology-dependent.
The wrap/panel split is load-bearing
Sheet renders .m428-sheet__wrap (owns every transform) around .m428-sheet__panel (owns background, radius, shadow, and any glow you add). They are separate elements because §5.5 bans animating transform on a glow-bearing element — the documented WebKit ghosting bug. Do not merge them, and do not move transform onto the panel; a test enforces both.
Lucide is not a dependency
Icon takes the glyph as a prop rather than importing it. This is deliberate: the three apps currently span not installed at all, [email protected], and [email protected]. No peer range honestly covers that, and declaring one would force Marked. to install an icon library it does not use. Passing the component in works with every version, and with anything else shaped like a Lucide icon.
Appearance strategy (light / dark)
Every theme-calibrated primitive — tokens, glow tuning, elevation tuning — supports three mutually exclusive states, set by an optional data-theme attribute on the root element:
| | Behavior |
|---|---|
| no data-theme | Follows the system: prefers-color-scheme decides, live. |
| data-theme="light" | Forces canonical light, regardless of system preference. Sets native color-scheme: light so browser-owned controls (scrollbars, form controls) agree. |
| data-theme="dark" | Forces canonical dark, regardless of system preference — including when the system reports light. Sets native color-scheme: dark. |
<html data-theme="dark">This is a token-level capability only. The package renders no toggle UI and remembers no choice across visits — whether an app exposes a control for this, and how, is entirely the app's own decision. §5.9 explicitly does not require a manual light/dark toggle; this exists so an intentionally dark-first or dark-only product can force its presentation without fighting the system-following default, not so every app needs to add a switch.
Token override rules
Everything this package ships is wrapped in the maker428 CSS cascade layer. Unlayered CSS always beats layered CSS, regardless of source order — so an app overrides any token just by declaring it normally:
/* app.css — wins, no !important, no import-order juggling */
:root {
--color-accent: #cf9732;
--color-accent-text: #362a12;
}Two rules worth stating explicitly:
- Accent hue is yours.
--color-accent/--color-accent-textship as the family's neutral ink so the package works out of the box. They are a starting point, not a brand you inherit — §5.3 makes accent hue per-app. Everything accent-derived in this package reads through those two tokens, so redefining them is the whole integration. - Token names are unprefixed on purpose.
--color-bg,--radius-control,--space-2are the canonical family names §5.3 and §5.8 fix, so an app adopting this package can delete its own copy of the ladder and keep every existingvar()call site working. Component class names are namespaced (.m428-*) — those are this package's own surface, not shared vocabulary.
TypeScript and plain JavaScript
Both work. The package is ESM with generated .d.ts files alongside the JS. TypeScript apps get full types; plain-JavaScript apps (It's Tacos) import the same modules and the declarations are simply ignored. No jsx pragma, bundler plugin, or tsconfig change is required either way.
Versioning
This package versions independently of the standard document, and the two numbers are not expected to match.
@maker428/[email protected]— this package (prepared, unpublished)@maker428/[email protected]— the current published release- Maker 428 App Standard v1.0.1 — the document it implements
The standard's version changes when a rule changes. This package's version changes when the code changes — a bug fix, a new primitive, a breaking API change — which can happen several times against one unchanged standard version, and vice versa. Package releases follow semver; the CHANGELOG for rules stays in the repository root.
Distribution
Intended mechanism: the public npm registry, under the @maker428 scope.
The three apps live in three separate repositories and deploy through CI (Vercel) that runs a bare npm install with no configured credentials. A public registry is the only mechanism that needs zero auth at install time. GitHub Packages requires a token even to read public packages; npm cannot install a package from a subdirectory of a git repo; git submodules are ruled out by the migration brief.
Published. @maker428/[email protected] is live on the public npm registry, MIT licensed, installable anonymously — npm install @maker428/ui needs no auth, no .npmrc, and no token, which is the whole reason §7 chose public npm over GitHub Packages. Releases go out as a manual, interactive npm publish confirmed by the publishing account's 2FA prompt; an automation token and a CI publish workflow remain deliberately deferred, and nothing in this repository stores a publishing credential. See reference/shared-package-architecture.md §9 for the full release history and rationale.
License
MIT — see LICENSE.
What's not here
Deliberately absent, and not planned:
- A desktop layout system.
variantis a styling hook, not a placement engine. Dropdown and side-panel treatments stay in the apps that need them (STANDARD.md§10.2). - A generic gesture framework. The drag engine does one gesture. Swipe-back, row-swipe, and tab-swipe stay app-local.
- Product components — a ledger, a note editor, feed cards, profiles, gamification (§22.3).
- An animation library. Motion is CSS plus imperative
style.transformfrom the gesture handler, which is what lets two of the three apps keep a zero-animation-dependency footprint.
Development
Requires Node 20+ to develop and build this package — Vitest 3, jsdom, and the TypeScript toolchain need it. This is a development-time requirement only: package.json declares no engines.node, so it imposes nothing on a consuming app. The published package is browser-facing ESM with React as its only peer; nothing in dist/ runs in Node at all, let alone needs a specific version of it.
npm install
npm run typecheck
npm test
npm run build # tsc → ESM + .d.ts, then concatenate CSS
npm pack --dry-runNo bundler. tsc emits per-file ESM, which is inherently tree-shakeable; a ~40-line node script copies and concatenates the hand-authored CSS. That is the whole toolchain, and it is deliberately the smallest thing that reliably produces the required artifacts.
