@feugene/granularity
Записи релизов — авторский текст библиотеки, и он английский. Портал его не переводит: журнал изменений — история чужого репозитория, а переписанная история перестаёт быть историей.
v0.41.02026-08-31
Removed
-
GrCodeBlockmoved to@feugene/granularity-code. Breaking: the@feugene/granularity/components/GrCodeBlocksubpath 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
tokenizeJsonand alanguageprop 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.mdstates 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, andGrJsonViewer(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 andvar(--gr-z-dropdown)never appears in the sources. A consumer who turns on token pruning in the preset (pruneTokens,@feugene/unocss-preset-granular0.16.0) therefore sees such a token as unused and drops its declaration — silently. The build stays green,z-indexresolves tounset, 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,GrTreeSelectandGrSelecttheir own dropdown layer,GrTooltipits tooltip layer, andGrModal,GrDrawerandGrImageViewerthe modal one. Every caller ofuseFloatingalso declaresgr-z-modal: thecalc(var(--gr-z-modal) + N)branch is what lifts a panel opened inside a modal above it.The field is about consumption, not ownership —
GrPopoverdeclares a token owned byGrModalbecause 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.GrLoadingdeliberately declares nothing: itszIndexVarprop 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 staticz-[var(--gr-z-loading)].The gate is
src/__tests__/dynamicTokens.test.ts. It knows about both composables, catches any new source of runtimevar()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/GrXis supposed to bringGrXand its declared dependencies — and nobody’s markup beyond that. Nothing checked it. The existing gates look elsewhere:componentDependenciesreads the sources and watches the opposite direction (thatconfig.dependenciesdoes 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 doctorreadsdistbut diagnoses tokens and classes, not module reach.The new gate reads
distand walks the module graph from every component entry. A foreign.tsis not a leak: theGrConfigProvidercontext, the scales incomponents/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 isolationruns 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.mjsand its runner moved to the repository root and take a package as an argument, the waygranular doctoralready 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:checkat the root keeps a package with component subpaths from shipping unmeasured: it reads manifests, needs nodist, 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:docsputs 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 indocs/entry-sizes.md, which is generated whole, likedocs/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:
zlibcompresses one and the samedistdifferently 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 core0.20.0with 68 subpaths, listed four packages out of eleven, and every version in its table was several minors behind: chrono0.2.1against0.10.0, the preset0.9.1against0.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:ecosystemnow 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 eachpackage.json#description, the same text npm shows, so the two cannot say different things about one package.yarn docs:ecosystem:checkgates 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 atyarn 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 totypeof process !== 'undefined' && process.env.NODE_ENV !== 'production', andprocessis undefined in the browser, so the whole expression collapsed tofalse— in development too. Measured onapps/playground: the consumer’s bundler replaces the textprocess.env.NODE_ENVwith"development"but leavestypeof processalone. The guard now expands toprocess.env.NODE_ENV !== 'production', the same shape Vue uses in its esm-bundler build; the substitution also removes theReferenceErrorrisk thetypeofcheck 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:escapeand a fulloverlay:syncsnapshot (who owns Esc, which modals goinert, each modal’s depth) — toglobalThis.__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
-
GrModalswallowed clicks for 150 ms after closing. While the leave animation played, the layer stayed in the DOM stretched across the viewport and keptpointer-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 carriespointer-events: nonewhile 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.
GrDrawerandGrImageViewershare the structure and the defect; they are left as they are for now, recorded with the measurement. -
GrSelectnever emittedchangein 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 toemit('update:modelValue'). SinceoptionsViewdefaults tonative, the defect sat on the most travelled path: a consumer’s@changesimply never fired.It failed silently rather than loudly, which is why it survived. A declared emit is removed from
$attrs, so@changenever becomes a native listener on the root either, and the<select>’s own bubblingchangedoes 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 throughemitValue, 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.
GrPaginationgiven nopageprintedPage NaN of 5and a page row of1 2 4 5— the number three simply absent out of five — becauseMath.trunc(undefined)isNaNand it spread through the clamp, the status line and the boundary/sibling algorithm.GrProgressBaremittedaria-valuenow="NaN", which a screen reader announces and axe flags: its guard testedNumber.isNaN(value), andNumber.isNaN(undefined)isfalse.GrSliderdid the same, and putleft: NaN%on the thumb as well.GrStatisticprinted the wordundefinedon 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.
GrJsonViewershowsundefinedon purpose — it inspects arbitrary data, andundefinedis part of what it is asked to display.Nothing warned before. The production SFC compile strips
typeandrequiredfrom the runtime props declaration, sorequired: truedoes not appear anywhere indist: the consumer never gets Vue’s “Missing required prop”, in development or in production.vue-tsccatches it only in projects with template type-checking — a JS project, av-bindspread or an auto-import catches nothing. Restoring it at build level is not an option:@vitejs/plugin-vuefeeds oneisProductionflag to both the script and the template compiler, and the template side would lose static hoisting and start shipping source comments. -
GrPaginationexplains thev-modelmistake instead of rendering it. The prop is namedpage, sov-model="page"sendsmodelValue, which falls through to the root element asmodelvalue="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
GrInputnow emitschangeas well. Clearing is a value commitment just like losing focus, and a listener watchingchangefor “the value settled” was missing exactly that.GrInputwas the only control in the ecosystem whose clear button stayed silent:GrAutocomplete,GrFormFile,GrInputTag,GrNumberInput,GrSelect,GrTextarea,GrTreeSelect,GrRatingand all fourchronopickers already emitted it — includingGrTextarea, whose event contract is worded identically. The native analogue agrees: the clear affordance on<input type="search">firesinputandchange.The order is
update:modelValue→change→clear. If you subscribe tochangeon 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 matchesGrButton, as its own code claimed it did.grTabsStyles.tsstates “the tab height repeats theGrButtonscale: tabs often stand next to one in the same row”. That held forxsandsmand had quietly drifted for the other two:mdwas 36px against the button’s 40,lgwas 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
GrTabsby any prop.mdis nowh-10andlgish-11. Type sizes were already correct and are untouched, so this restores a stated invariant rather than introducing a new one. A gate insrc/__tests__/componentSize.test.tscompares the two scales step by step, because a comment does not check itself.Tab rows get taller wherever
mdorlgis in use.
Added
-
GrNavbar: asizeprop for the menu button. The burger was rendered with a hardcodedsize="sm", andGrNavbarhad neither asizeprop nor adefaults.ts— so it was absent from thecomponentDefaultsregistry and a consumer had no way to reach it short of fighting the cascade throughmenuButtonClass. On a phone that left a 32×32 target with no lever.The prop resolves through
GrConfigProviderlike everywhere else, and the icon inside now follows the step instead of a hardcoded 16px.Its fallback is
sm, not the package-widemd: 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
-
GrTabsnow 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:
pillscarries an opaque--gr-mutedof its own, whilelineis 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-fadehook (1.5remby default). The same value is the row’sscroll-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
scrollIntoViewon focus and on externalmodelValuechanges already worked. A vertical row does not scroll and has no fade. -
GrTimelinenow shrinks in a narrow column instead of pushing itself out of it. Every row is a grid whose flexible tracks were declared as a bare1fr. That isminmax(auto, 1fr), and the minimum of such a track is the largest minimum contribution of its items — which, for a grid item withoverflow: visible, is its min-content size. Withwhite-space: nowrapcoming from a consumer’struncate, that is the full width of the string.So the track grew to fit the text and the row overflowed its container, while the
truncatethat was supposed to prevent exactly this never engaged: there was nothing to truncate. Measured on a 260px column, anlayout="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]) carrymin-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 from1fronly once the content no longer does.
Более ранние версии
Тела этих релизов не показываются: лента целиком весит больше, чем страница имеет право весить. Файл в репозитории всегда полон.
v0.34.12026-08-25v0.34.02026-08-25v0.33.02026-08-25v0.32.02026-08-24v0.31.12026-08-23v0.31.02026-08-23v0.30.02026-08-23v0.29.12026-08-22v0.29.02026-08-22v0.28.42026-08-22v0.28.32026-08-22v0.28.22026-08-22v0.28.12026-08-22v0.27.22026-08-20v0.27.12026-08-19v0.27.02026-08-19v0.26.02026-08-18v0.25.02026-08-18v0.24.12026-08-18v0.24.02026-08-18v0.23.02026-08-17v0.22.02026-08-17v0.21.12026-08-17v0.21.02026-08-17v0.20.02026-08-13v0.19.02026-08-13v0.18.02026-08-12v0.17.02026-08-12v0.16.02026-08-11v0.15.02026-08-07v0.14.02026-08-05v0.13.02026-07-28v0.12.02026-07-20v0.11.02026-07-19v0.10.02026-07-17v0.9.42026-06-23v0.9.32026-06-09v0.9.22026-06-08v0.9.12026-06-07v0.9.02026-06-02v0.8.02026-06-01v0.1.0