@feugene/granularity

v0.41.02026-08-31

Removed

  • GrCodeBlock moved to @feugene/granularity-code. Breaking: the @feugene/granularity/components/GrCodeBlock subpath and the root-barrel export are gone.

    - import { GrCodeBlock } from '@feugene/granularity'
    + import { GrCodeBlock } from '@feugene/granularity-code/components/GrCodeBlock'

    Props are unchanged and so are the --gr-code-block-* tokens, so an application that themed the block keeps its overrides; the import is the whole migration.

    The reason is a ceiling, not tidiness. Inside the core the block could never gain highlighting for an arbitrary language: that needs a dependency, and the core takes none for this — hence its own tokenizeJson and a language prop that only ever accepted 'json' | 'text'. In the companion the ceiling lifts, and the same component now shows TypeScript, SQL or a Dockerfile through a highlighter the application supplies.

    Doing it before 1.0 is the point: PACKAGES.md states that taking something out of a frozen core costs a major, and after 1.0 this move would cost one across the whole ecosystem. Technically it was cheap — the block was a leaf here, nothing in the core depended on it, and GrJsonViewer (which stays) never used it.

v0.40.02026-08-30

Added

  • Components now declare the tokens whose names they build at runtime. A var() assembled from a variable is invisible to every static analysis: the name arrives as a parameter and var(--gr-z-dropdown) never appears in the sources. A consumer who turns on token pruning in the preset (pruneTokens, @feugene/unocss-preset-granular 0.16.0) therefore sees such a token as unused and drops its declaration — silently. The build stays green, z-index resolves to unset, and a panel slides under its neighbour. No existing gate could see that, because the CSS remains valid.

    The package has exactly three such sites and all of them lead into composables/internal/overlayStack.ts. Eight components declare what they read: GrPopover, GrAutocomplete, GrTreeSelect and GrSelect their own dropdown layer, GrTooltip its tooltip layer, and GrModal, GrDrawer and GrImageViewer the modal one. Every caller of useFloating also declares gr-z-modal: the calc(var(--gr-z-modal) + N) branch is what lifts a panel opened inside a modal above it.

    The field is about consumption, not ownershipGrPopover declares a token owned by GrModal because it reads it. And it lives on the component rather than on the provider on purpose: a provider-level list would hold the token in every application, including the ones that never pull the component, which is exactly the overhead granular selection exists to remove.

    GrLoading deliberately declares nothing: its zIndexVar prop is documented as an escape hatch, the name belongs to the application, and keeping it is the consumer’s business. Its own layer is a static z-[var(--gr-z-loading)].

    The gate is src/__tests__/dynamicTokens.test.ts. It knows about both composables, catches any new source of runtime var() assembly, and checks that every declared name still exists. Verified by falsification: removing one declaration turns two tests red.

v0.39.02026-08-29

Added

  • A gate for what the package is named after. Taking @feugene/granularity/components/GrX is supposed to bring GrX and its declared dependencies — and nobody’s markup beyond that. Nothing checked it. The existing gates look elsewhere: componentDependencies reads the sources and watches the opposite direction (that config.dependencies does not fall behind the imports), and an edge that only appears at build time — a shared chunk that took a stranger along with what was needed — is invisible in sources. granular doctor reads dist but diagnoses tokens and classes, not module reach.

    The new gate reads dist and walks the module graph from every component entry. A foreign .ts is not a leak: the GrConfigProvider context, the scales in components/shared/, the composables are the package’s shared runtime and ship by definition (docs/packaging.md). A leak is foreign markup — an SFC drags along its layout, its classes and its safelist, which is exactly the weight the subpath exists to avoid. Attribution goes through source maps, since a minified chunk holds no component name.

    A gate that cannot see must not report success, so blindness is a failure of its own: a chunk with no map and code of its own fails the run rather than passing quietly. Build helpers with no file on disk (\0-prefixed virtual modules) are recognised structurally, from the module ids the build writes into the chunk, not from a file name — the name carries a content hash and changes on every build.

    Runs as part of yarn build; yarn isolation runs it alone for diagnosis. All 78 entries pass today, so the gate freezes a property that already holds rather than fixing a defect. Verified by falsification: dropping one declared dependency turns 4 entries red through the transitive closure.

  • The weight report is no longer the core’s alone. scripts/entrySizes.mjs and its runner moved to the repository root and take a package as an argument, the way granular doctor already does. Six companion packages sell the same subpath granularity and measured nothing; they measure now. The walk is worth sharing rather than copying — a component entry is nearly empty and the code lives in shared chunks, so a report that stops at the entry file is wrong by a factor, and six copies of that walk would drift apart silently.

    yarn sizes:check at the root keeps a package with component subpaths from shipping unmeasured: it reads manifests, needs no dist, and runs beside the other cheap checks. Without it the next companion is founded unmeasured in silence — which is exactly how all six got there.

  • The granularity claim now carries a number, on the npm page itself. A subpath export is the reason this package exists, and the README argued it in prose. yarn sizes:docs puts a generated block there instead: the barrel, the lightest component, the median one, and the five heaviest taken together. Every package with component subpaths gets the same block; the per-component table lands in docs/entry-sizes.md, which is generated whole, like docs/tokens.md.

    The number published is a union, not a sum. Per-entry weights do not add up — shared code is counted again in every row but paid for once — and a list of them invites the reader to add five rows of the core, land on 381 kB and walk away from a set that actually costs 171. Publishing them without the union would make the package look heavier than it is, which is the opposite of what the block is for.

    The rows are derived, not chosen: picking “representative” components would be fitting the shop window to the number we wanted. The worst case, named as the worst case, argues better anyway — the five heaviest components of the core together are 32 % of the barrel. And the figure is stated for what it is: an upper bound, the gzip of everything a subpath pulls out of dist, before the application bundler shakes it further.

    What CI gates is the version in the table header and the set of components in it, not the kilobytes: zlib compresses one and the same dist differently across environments — the barrel measures 542.9 kB on macOS and 549.3 kB on a Linux runner, off a module graph identical down to the file. A gate no two environments can satisfy at once is fixed by changing what it checks, not by fudging the number. Freshness rides on the version instead: a bump forces a regeneration, so the weights are never more than one release old.

Fixed

  • The family roster is now generated, like every other registry in this package. docs/getting-started.md — the page a consumer starts from — was frozen at core 0.20.0 with 68 subpaths, listed four packages out of eleven, and every version in its table was several minors behind: chrono 0.2.1 against 0.10.0, the preset 0.9.1 against 0.15.0. The README shipped to npm mentioned no companion package at all, so nothing on the package page said the other ten exist.

    A roster is a registry, and this repository generates its registries. yarn docs:ecosystem now derives one from the workspace — name, version, components, i18n block, resolver — and writes it into all three places that need it: this README, the repository README and the getting-started page. Descriptions come from each package.json#description, the same text npm shows, so the two cannot say different things about one package.

    yarn docs:ecosystem:check gates it in CI, and additionally rejects any @feugene/* version named anywhere in the docs that excludes the version in the repository. That is the failure mode a reader hits first and a writer never sees: an example range stays syntactically valid forever and only breaks at yarn add. It found three on the first run, all of them fixed here.

v0.38.02026-08-28

Added

  • Dev channel now carries who opened a layer and what happens to focus. A layer used to be a bare id, so on a page with a dozen selects “which one holds the top layer” had no answer. The snapshot now names the component (GrSelect, GrModal) and describes focus: is it still inside the layer, will it be restored on close, and to what element. The restore rule is non-trivial — “only if focus is still inside at closing time” — and was unobservable from outside; the predicate is now one function used both by the restore itself and by the observer, so the two cannot drift apart.
  • Virtual lists register themselves in the dev channel. A registry rather than events: the window changes on every scroll frame, and streaming that would flood the channel. Each entry reports the owning component, the rendered range against the total, the size estimate the window is computed from and the average measured size — a drift between the last two is what makes a list jump while the DOM still holds the “correct dozens” of nodes.
  • The event buffer depth is now configurable through the hook: how much history is needed is known only by whoever reads it.
  • __GR_DEV_HOOK__.readLayers() — a fresh snapshot on demand. Events describe the stack at the moment it changed, but focus moves on an ordinary click with no stack event at all: an observer reading only the event log would show a stale picture. All of it stays behind __GR_DEV__.

v0.37.02026-08-27

Fixed

  • Development warnings never reached the browser. The __GR_DEV__ guard expanded to typeof process !== 'undefined' && process.env.NODE_ENV !== 'production', and process is undefined in the browser, so the whole expression collapsed to false — in development too. Measured on apps/playground: the consumer’s bundler replaces the text process.env.NODE_ENV with "development" but leaves typeof process alone. The guard now expands to process.env.NODE_ENV !== 'production', the same shape Vue uses in its esm-bundler build; the substitution also removes the ReferenceError risk the typeof check appeared to guard against. All 36 guarded warnings across 29 files start working for consumers; production bundles are unaffected — the branch is still dropped.

Added

  • Dev channel (internal/devHook.ts). Under __GR_DEV__ the overlay layer stack now publishes what it does — overlay:push, overlay:remove, overlay:escape and a full overlay:sync snapshot (who owns Esc, which modals go inert, each modal’s depth) — to globalThis.__GR_DEV_HOOK__. The stack stays private: observers see the picture, not the levers. The hook buffers the last 50 events so an observer attaching later does not start from an empty screen, and a throwing listener cannot break the package it watches. Consumed by @feugene/granularity-devtools.

v0.36.02026-08-27

Fixed

  • GrModal swallowed clicks for 150 ms after closing. While the leave animation played, the layer stayed in the DOM stretched across the viewport and kept pointer-events: auto, so a click landing in that window went to the disappearing overlay instead of the element beneath it and was lost — no error, no trace, and the user simply clicks again. The root now carries pointer-events: none while the layer is present but no longer visible.

    Taking hit-testing off the backdrop alone would not have been enough: the layer has three full-viewport elements (root, shell, backdrop), and neutralising the topmost one just hands the interception to its neighbour one level up — measured. GrDrawer and GrImageViewer share the structure and the defect; they are left as they are for now, recorded with the measurement.

  • GrSelect never emitted change in its default rendering mode. The event is declared on the type and documented as “the same value on a separate channel”, with no exception noted — but it was emitted from exactly one place, emitValue, and the native <select> handler went straight past it to emit('update:modelValue'). Since optionsView defaults to native, the defect sat on the most travelled path: a consumer’s @change simply never fired.

    It failed silently rather than loudly, which is why it survived. A declared emit is removed from $attrs, so @change never becomes a native listener on the root either, and the <select>’s own bubbling change does not reach the consumer. Not a wrong payload — nothing at all. Found on live consumer code (a locale switcher that changed value and did nothing), where the silence cost several rounds of diagnosis. The native branch now goes through emitValue, so both rendering modes share one event channel, and a gate covers both — the panel-only tests were green throughout.

  • A missing required prop no longer renders plausible-looking nonsense. GrPagination given no page printed Page NaN of 5 and a page row of 1 2 4 5 — the number three simply absent out of five — because Math.trunc(undefined) is NaN and it spread through the clamp, the status line and the boundary/sibling algorithm. GrProgressBar emitted aria-valuenow="NaN", which a screen reader announces and axe flags: its guard tested Number.isNaN(value), and Number.isNaN(undefined) is false. GrSlider did the same, and put left: NaN% on the thumb as well. GrStatistic printed the word undefined on the tile. All four now substitute a sane default — page one, zero, the lower bound, an em dash — and say what went wrong under __GR_DEV__.

    These four are the whole silent class, established by rendering every one of the 37 components that declare a required prop with no props at all: eight others throw outright, which a consumer sees immediately, and the rest render cleanly. GrJsonViewer shows undefined on purpose — it inspects arbitrary data, and undefined is part of what it is asked to display.

    Nothing warned before. The production SFC compile strips type and required from the runtime props declaration, so required: true does not appear anywhere in dist: the consumer never gets Vue’s “Missing required prop”, in development or in production. vue-tsc catches it only in projects with template type-checking — a JS project, a v-bind spread or an auto-import catches nothing. Restoring it at build level is not an option: @vitejs/plugin-vue feeds one isProduction flag to both the script and the template compiler, and the template side would lose static hoisting and start shipping source comments.

  • GrPagination explains the v-model mistake instead of rendering it. The prop is named page, so v-model="page" sends modelValue, which falls through to the root element as modelvalue="2" and leaves the component without a page. In development the component now names the mistake directly. This is the call that produced every symptom above.

Changed

Changed

  • Clearing a GrInput now emits change as well. Clearing is a value commitment just like losing focus, and a listener watching change for “the value settled” was missing exactly that. GrInput was the only control in the ecosystem whose clear button stayed silent: GrAutocomplete, GrFormFile, GrInputTag, GrNumberInput, GrSelect, GrTextarea, GrTreeSelect, GrRating and all four chrono pickers already emitted it — including GrTextarea, whose event contract is worded identically. The native analogue agrees: the clear affordance on <input type="search"> fires input and change.

    The order is update:modelValuechangeclear. If you subscribe to change on a text field and treat every occurrence as user typing, the clear button now reaches that handler too.

v0.35.02026-08-25

Fixed

  • GrTabs: the height scale now matches GrButton, as its own code claimed it did. grTabsStyles.ts states “the tab height repeats the GrButton scale: tabs often stand next to one in the same row”. That held for xs and sm and had quietly drifted for the other two: md was 36px against the button’s 40, lg was 40 against 44.

    Two consequences, and the second is the one that bit. A tab row next to a button was four pixels short, which reads as a layout mistake. And no step reached 44px — the target size WCAG 2.5.5 and Apple HIG ask for on touch — so a consumer could not get one out of GrTabs by any prop.

    md is now h-10 and lg is h-11. Type sizes were already correct and are untouched, so this restores a stated invariant rather than introducing a new one. A gate in src/__tests__/componentSize.test.ts compares the two scales step by step, because a comment does not check itself.

    Tab rows get taller wherever md or lg is in use.

Added

  • GrNavbar: a size prop for the menu button. The burger was rendered with a hardcoded size="sm", and GrNavbar had neither a size prop nor a defaults.ts — so it was absent from the componentDefaults registry and a consumer had no way to reach it short of fighting the cascade through menuButtonClass. On a phone that left a 32×32 target with no lever.

    The prop resolves through GrConfigProvider like everywhere else, and the icon inside now follows the step instead of a hardcoded 16px.

    Its fallback is sm, not the package-wide md: the button has been 32px since it was introduced, and raising it by default would have shifted every consumer’s header. size="lg" gives 44px and fits the 56px row.

v0.34.22026-08-25

Fixed

  • GrTabs now shows that the row continues past the edge. The tab row scrolls, but its scrollbar is hidden on purpose, so a row that did not fit gave no sign of it: tabs beyond the edge stayed reachable with the arrow keys, yet nothing suggested they were there. Measured on a 375px screen, a five-tab row was 487px wide against 341px of space — two tabs invisible and unannounced.

    The edge that has more tabs behind it now fades out: the right one at the start of the row, the left one at the end, both in the middle, none at all when the row fits.

    It is a mask rather than a gradient underlay, and that follows from the row’s own background differing per variant: pills carries an opaque --gr-muted of its own, while line is transparent and sits on whatever the parent paints — which the component cannot know. A gradient would have nowhere to take that colour from; a mask fades the content regardless of what is behind it.

    The fade width is the --gr-tabs-scroll-fade hook (1.5rem by default). The same value is the row’s scroll-padding, so a tab pulled into view by keyboard navigation never comes to rest under the fade and its focus ring stays readable.

    The keyboard contract is untouched: tabs were always reachable, and scrollIntoView on focus and on external modelValue changes already worked. A vertical row does not scroll and has no fade.

  • GrTimeline now shrinks in a narrow column instead of pushing itself out of it. Every row is a grid whose flexible tracks were declared as a bare 1fr. That is minmax(auto, 1fr), and the minimum of such a track is the largest minimum contribution of its items — which, for a grid item with overflow: visible, is its min-content size. With white-space: nowrap coming from a consumer’s truncate, that is the full width of the string.

    So the track grew to fit the text and the row overflowed its container, while the truncate that was supposed to prevent exactly this never engaged: there was nothing to truncate. Measured on a 260px column, an layout="time" timeline overflowed by 278px.

    Flexible tracks are now minmax(0, 1fr) and the items standing in them ([data-gr-timeline-content], [data-gr-timeline-group-title], [data-gr-timeline-aside]) carry min-width: 0. Both are needed: the first lifts the track’s minimum, the second lifts the item’s. Truncation stays where it belongs — with the consumer — but it now works.

    Nothing changes at widths where the content fits: minmax(0, 1fr) differs from 1fr only once the content no longer does.

Earlier versions

The bodies of these releases are not shown: the full feed weighs more than a page is allowed to. The file in the repository is always complete.

  • v0.34.12026-08-25
  • v0.34.02026-08-25
  • v0.33.02026-08-25
  • v0.32.02026-08-24
  • v0.31.12026-08-23
  • v0.31.02026-08-23
  • v0.30.02026-08-23
  • v0.29.12026-08-22
  • v0.29.02026-08-22
  • v0.28.42026-08-22
  • v0.28.32026-08-22
  • v0.28.22026-08-22
  • v0.28.12026-08-22
  • v0.27.22026-08-20
  • v0.27.12026-08-19
  • v0.27.02026-08-19
  • v0.26.02026-08-18
  • v0.25.02026-08-18
  • v0.24.12026-08-18
  • v0.24.02026-08-18
  • v0.23.02026-08-17
  • v0.22.02026-08-17
  • v0.21.12026-08-17
  • v0.21.02026-08-17
  • v0.20.02026-08-13
  • v0.19.02026-08-13
  • v0.18.02026-08-12
  • v0.17.02026-08-12
  • v0.16.02026-08-11
  • v0.15.02026-08-07
  • v0.14.02026-08-05
  • v0.13.02026-07-28
  • v0.12.02026-07-20
  • v0.11.02026-07-19
  • v0.10.02026-07-17
  • v0.9.42026-06-23
  • v0.9.32026-06-09
  • v0.9.22026-06-08
  • v0.9.12026-06-07
  • v0.9.02026-06-02
  • v0.8.02026-06-01
  • v0.1.0

The package CHANGELOG in full