Changelog

Changelog

The packages are released independently: @hulianui/ui provides components and @hulianui/tokens provides design-token CSS. Changes follow semantic versioning and are generated from changesets.

Current version

v0.22.0
npmGitHub Releases
  1. v0.22.0

    @hulianui/uiFeatures

    MathText covers senior-high notation: vector arrows, blackboard-bold number sets, set and logic symbols, LaTeX escapes

    The previous symbol table was built from command frequencies in 22,000 characters of middle-school question text. The method was sound; the sample was too narrow. A consumer redid the count over 1,324 questions (stems plus explanations, spanning primary through senior high), where vector, set, and logic notation dominate and barely appear in middle-school samples — so all of it fell outside the table and rendered as raw backslashes on the page.

    Vector arrows `\vec` and `\overrightarrow`#83

    These two appear 282 times combined, third in the whole frequency table and 56 times more often than the already supported \overline (5). DECORATE_COMMANDS previously had only overline and hat; a miss fell through to literal output — correct in itself, and consistent with "never swallow unknown notation," except that these two should have been known.

    A new arrow style handles both. The arrow width follows the content: the shaft is a stretchable border and the head is a non-distorting SVG, so \vec{a} is short while \overrightarrow{AB} covers both letters. TeX gives \vec a fixed narrow arrow and reserves full width for \overrightarrow; that difference is flattened deliberately, because both mark a vector in question text and the width carries no information, while following the content lets \vec{AB} cover its letters. The arrow is an absolutely positioned overlay and does not increase line height, so like fractions it leaves surrounding line spacing intact.

    tsx
    <MathText>{"Given \\overrightarrow{AB} is collinear with \\vec{a}"}</MathText>

    `\mathbb{}` maps to blackboard bold rather than being unwrapped#84

    \mathbb{R} becomes ℝ, with all 26 capitals covered (C/H/N/P/Q/R/Z use the BMP letterlike symbols; the rest fall in the SMP mathematical alphanumerics). Unwrapping to a bare letter was rejected on purpose: the set of real numbers and a variable named R are different things, and collapsing them makes "the domain is ℝ" read as "the domain is R" with no visible sign that information was lost. Characters outside the table are kept as written one by one, so \mathbb{R+} yields ℝ+ instead of giving up over a single +.

    LaTeX escapes `\{` `\}` `\%` `\$` `\&` `\#` `\_`

    Set-builder notation such as \{x \mid x>0\} previously showed its braces with the backslashes attached; adding \mid alone would not have helped while both sides still leaked. Unlike the symbol table, escapes are a finite closed set rather than a long tail, so all of them are covered at once instead of being filtered by frequency.

    Other commands added by measured frequency

    \Leftrightarrow ⇔ (biconditional, 10) · \to → (limits, 4) · \mid ∣ (set-builder, 4) · \backsim ∽ · \varphi φ · \Gamma Γ · \langle ⟨ and \rangle ⟩ (inner products) · \forall ∀ · \frown ⌢.

    Two commands that take arguments are new as well. \underline{} underlines existing content, which is a different thing from an answer blank (that one is an empty slot). \overset{}{} places a mark above the content, so \overset{\frown}{AB} is arc AB — the canonical way to write an arc. \frown{AB} means "arc symbol followed by a group" in LaTeX and still renders literally as ⌢{AB}; looking wrong is the intent, and better than guessing a meaning the source never expressed.

    The mark above \overset is kept by mathToPlain (⌢AB), unlike \overline and \vec. Those are pure decoration lines with no corresponding character, whereas an overset mark is meaningful content that search would otherwise lose.

    Three items in the report do not hold

    \Rightarrow (52), \mathbf{} (8), and \quad (8) were already supported in 0.20.0, verified one by one. The report also warns that unwrapping \mathbf{} must respect command boundaries, since \cdot\mathbf{b} would collapse into a nonexistent \cdotb. That hazard does not exist here: the parser consumes commands left to right rather than substituting strings, so \cdot has already become · before \mathbf is reached. It is a hazard for consumers doing string replacement in their own ingestion pipeline.

    A performance problem fixed along the way: `MathText` is now `memo`ised

    MathText re-parses the entire question string on every render, and it was not memoised — so any unrelated update in an ancestor (filtering, pagination, selection state on a question-bank page) re-parsed every one of the dozens of instances on screen. The performance scan measured 3 avoidable renders in the "parent updates, props unchanged" step; memoisation brings that to zero. All props are primitives (children is a string), so a shallow comparison suffices, and locale comes from context, so switching languages still updates. Markdown in this library has worked this way for a long time; this brings MathText in line.

    A documentation defect fixed along the way

    The Chinese docs for MathText and QuestionCard titled their pitfalls section with a shorter heading than the one the conventions generator recognizes. As a result the Chinese pitfalls for these two components never reached `conventions.json` — the English side was complete all along, while the Chinese side had zero entries, so Chinese users querying conventions through MCP could not see them. The heading is now consistent with the other 369 components.

    a6249c8
  2. v0.21.0

    @hulianui/uiFeatures

    Three cases of "following the docs still gets it wrong": Navbar's center section actually centers, polar chart legends can be turned off, TreeSelect can select intermediate levels

    What the three issues have in common is that nothing throws: you write it the way the documentation says, the result is wrong, and it looks like your own mistake.

    Navbar: `NavbarBrand` now grows by default (default behavior change)#81

    NavbarContent justify="center" was not actually at the center of the navbar. The cause was asymmetric flexibility across the three sections: NavbarBrand was shrink-0 while the two NavbarContent sections each took flex-1 and split the remaining space, so the center section was only centered within its own share and the whole thing drifted left as the brand name grew (measured 265px off-center at 1440 wide with a 100px brand). The longer the brand, the larger the drift — the same code lands differently on each tenant's site.

    NavbarBrand now defaults to flex-1 basis-0, so all three sections are equal. Brand content still hugs the left via justify-start, and flex items default to min-width: auto so it will not be squeezed — the brand and end sections look unchanged; what changed is that the middle section now truly lands at the center.

    One layout does change: a brand followed by `justify="start"` content that hugs it (with no center section). Equal thirds push that content to the 1/3 mark. Pass grow={false} for that layout to restore the previous behavior:

    tsx
    <Navbar>
      <NavbarBrand grow={false}>Hulian</NavbarBrand>
      <NavbarContent justify="start">…</NavbarContent> {/* still hugs the brand */}
    </Navbar>

    Truncating the brand area on narrow screens still requires min-w-0 alongside truncate (to release the flex item's min-width: auto); that has not changed.

    Chart: `RadarChart` / `PieChart` / `RadialChart` gain `legend`, and all six gain `legendScroll`#80

    After 0.19.0 added legend to Area/Bar/Line, the three polar charts were left behind: their <Legend> was hard-coded inside the chart, so consumers could neither turn it off nor move it, and drawing your own produced two legends side by side (legendStyle is an internal constant and className only reaches the outer div). With 28 series the legend filled five rows and consumed more than half of height={320}, flattening the radar and covering the angular axis labels.

    All three now accept legend?: boolean | "top" | "bottom", matching the Cartesian trio. It defaults to `true` (these charts have always shipped a legend), so existing calls need no changes; pass legend={false} to remove it. Note this is the one prop in the library whose default varies by chart family: false for the Cartesian three, true for the polar three.

    The trade-off, stated plainly: the legend in these three is no longer recharts' <Legend> but the same self-drawn legend as the other three (Dot swatches plus token font sizes), so swatches change from squares to dots and spacing and font size differ slightly. It also no longer participates in recharts' internal height allocation; instead height gives up exactly one row. Swatch colors resolve through the same path as the slices and series, so they cannot disagree.

    Also added: legendScroll (all six charts, defaults to false), which keeps the legend on a single row with horizontal scrolling — the equivalent of echarts' legend.type: "scroll". "Just increase height" does not work when series wrap: a 28-series legend is five rows, and restoring the radar to a readable size would require doubling the total height. With this on, the legend always occupies one row (yielding 32px for a persistent thin scrollbar) and the canvas takes everything else:

    tsx
    {
      /* Turn off the built-in legend and draw your own */
    }
    <RadarChart legend={false} data={data} series={series} xKey="indicator" height={320} />;
    
    {
      /* 28 series: single-row scrolling legend that does not eat the canvas */
    }
    <RadarChart legendScroll data={data} series={series28} xKey="indicator" height={320} />;

    Entries beyond the first row require horizontal scrolling to reach — with dozens of series that is the trade-off, not a free win.

    TreeSelect: forwards `expandTrigger`, so single select can reach intermediate levels#78

    Single-select TreeSelect could previously only select leaf nodes: the internal Tree defaults expandTrigger to "row", so clicking a row with children only expanded it and returned early, never reaching setSelected. onChange never fired, the row could not be selected no matter how many times you clicked, and the capability was not exposed to consumers.

    TreeSelect now forwards expandTrigger?: "row" | "icon", still defaulting to "row" (existing behavior unchanged). To select an intermediate level — a department, a category, a specific volume — pass "icon": the arrow handles expansion and the rest of the row handles selection, mirroring the multi-select model where the checkbox selects and the row expands.

    tsx
    <TreeSelect nodes={NODES} expandTrigger="icon" value={v} onChange={setV} placeholder="Select a chapter" />

    Multi-select (checkable) is unaffected, since the checkbox is its own hit area. The pitfalls section of all three components now documents the corresponding behavior — none of these three were discoverable from the documentation before.

    61b47ea
  3. v0.20.0

    @hulianui/uiFeatures

    Runtime performance, round one: large-collection virtualization in Combobox plus 19 components that skip needless re-renders

    A new internal scanner (packages/hulian-scan, private and unpublished) ran all 372 public component scenarios through the React Profiler using react-scan and Playwright. The first pass produced 125 hard findings (55 avoidable-render, 41 cascade-fanout, 16 long-task, 13 dropped-frames). This release fixes the subset that still reproduced in a packed consumer environment, each verified in both the workspace and an out-of-repo tarball install.

    Combobox / Select / RemoteSelect: automatic virtualization for large collections (default behavior change)

    Once items reaches 100 entries the list virtualizes automatically and renders only the visible options (via @tanstack/react-virtual, already a dependency — no new package weight). Opening a thousand-option list now mounts a couple of dozen rows instead of a thousand <li> elements. The searchable skin of Select and the candidate list of RemoteSelect take the same path, so they benefit automatically — RemoteSelect accumulates pages remotely, so it switches over once enough pages have loaded.

    The trade-off must be stated plainly: row height is estimated at a fixed 32px with no per-item measurement. The default ComboboxItem / SelectItem is exactly 32px, so the vast majority of usage is unaffected. But if your options span two lines, carry an avatar, or change padding or font size through className, scrollbar length and item placement drift apart past 100 entries — nothing throws, and short lists never reproduce it; the jump only appears once you scroll into the later part of the list. All three components therefore gained a virtualized escape hatch; pass virtualized={false} for such options to return to full rendering:

    tsx
    {/* Single-line rows: change nothing, virtualization kicks in at 100 items */}
    <Combobox items={CITIES}>…</Combobox>
    
    {/* renderOption draws "name + email" on two lines: height ≠ 32px, so turn it off */}
    <RemoteSelect fetcher={searchUsers} virtualized={false} renderOption={…} />

    The same applies to tests that assume every option is in the DOM: after virtualization getAllByRole("option") returns only the visible window. Assert totals against data-hulian-virtual-count on the list container, or pass virtualized={false} for that test.

    19 components skip re-renders when props are stable

    Button, Calendar, Cascader, Checkbox, CodeDiff, CodeReviewThread, ColorSwatchPicker, ContributionGraph, CountrySelect, DatePicker, DateTimePicker, Gantt, Glimpse, Markdown, PricingTable, QRCode, Scheduler, TimePicker, and TreeSelect now use memo. The criterion was scan evidence rather than intuition: memo was added only where a shallow comparison can safely skip work, components taking function, ReactNode, or mutable-object props were judged individually, and no custom deep comparisons were introduced. External behavior and DOM are unchanged.

    Other targeted optimizations

    • Select: under the searchable skin, resolving a candidate by value moved from a linear find() per item to a Map lookup, removing an O(n) pass from every trigger and list render when the option set is large.
    • CircularGallery: removed geometry recomputation and texture encoding that repeated every frame.
    • GhostCursor: reduced per-frame shader cost.
    • React 18 compatibility: SelectTriggerProps now uses ComponentPropsWithoutRef plus an explicit ref, and SwipeAction's ref handling was adjusted to match — both previously only type-checked under React 19.
    0d9fb08

    Component built-in copy now reads from ConfigProvider locale throughout

    ConfigProvider's locale prop and the enUS dictionary already existed, but only some components actually read them; the rest had Chinese hard-coded. An English project wrapping its tree in <ConfigProvider locale={enUS}> therefore saw a mix of English and Chinese, with nothing reporting which components had not been converted.

    This release connects the built-in copy of 130 components — button labels, empty states, placeholders, aria-labels, date and weekday formats, units and separators — to the locale dictionary, which itself grew by 1,688 lines. Beyond straight translation, the differences that are linguistic rather than string-level were handled too: Scheduler formats weekdays and date ranges per locale (an English build renders Jun 1 – Jun 7 where the Chinese build renders its own date form), and CountrySelect decides from the locale whether country names and secondary labels appear in Chinese or English.

    Nothing changes for existing projects: without a locale prop everything stays in the original Chinese, and each missing dictionary section falls back to the component's built-in Chinese individually (so an older partial dictionary will not break on missing keys). To switch to English:

    tsx
    import { ConfigProvider, enUS } from "@hulianui/ui";
    
    <ConfigProvider locale={enUS}>{children}</ConfigProvider>;

    The documentation site ships in English as well: each of the 376 components has an .en.md companion (published with the package, so MCP's get_component_doc picks it up), and the block and page examples, changelog, and AI distribution artifacts such as llms.txt and registry.json all have English editions.

  4. v0.19.1

    @hulianui/uiFixes

    Disambiguate the semantics pitfall in nav-menu.md and add a site-navigation example (closes #76)

    When semantics was added in 0.19.0 (#69), the props table said to use `list` for site navigation, while the pitfalls section said that leaving site navigation in the default tree mode makes those links undiscoverable to screen-reader users. The latter was intended as a conditional warning—if you leave it as a tree, the links cannot be discovered—but the Chinese wording could also be read as an instruction to remain in tree, the opposite of the props table.

    The cost of that ambiguity was asymmetric: #69 was entirely about whether primary navigation should be a list or a tree. Reading the sentence incorrectly preserves the accessibility defect that the issue had just fixed, while both modes look identical and produce no error. Therefore:

    • The condition is now explicit: if you leave the menu in the default tree mode, a screen reader's “list all links on this page” command finds none of its entries, so pass semantics="list" explicitly in that scenario. The documentation also calls out that the wrong choice is visually undetectable.
    • None of the previous examples passed semantics, so copying one silently fell back to the default. “Site navigation” is now the first example, with semantics="list" and render connected to a router. The existing conversation-list example now explains why it does not need that mode: it is an imperative selection UI whose rows are <button> elements, not link navigation. If conversation items are real links, they need semantics="list" as well.

    This changes the component documentation shipped in the package (src/**/*.md, which MCP's local get_component_doc mode reads directly), so a patch release ensures that consumer agents receive the corrected guidance. The component implementation is unchanged.

    67038ed
  5. v0.19.0

    @hulianui/uiFeatures

    Add AuthPanel and six escape hatches that close consumer gaps (closes #67 #69 #70 #71 #72 #73)

    Two downstream projects reported six gaps at once: hulian-admin's split-screen login and registration pages, and cairn's exam-paper annotation workflow. They all had the same root problem: even after consulting the documentation, consumers still had to bypass the library. A split authentication page could express its gradient panel only with a raw <div> and inline styles; admin login fields required className overrides; primary navigation needed hand-written <Link> rows to preserve link semantics; legend swatches required raw <span> elements; and selection coordinates had to be wrapped in floor/ceil logic at every call site. All of these are application-side patches that the conventions explicitly prohibit. This release brings them into the library.

    New component

    • AuthPanel: the promotional panel on the left side of split login, registration, and password-recovery pages, combining a gradient background, brand, tagline, highlights, and footer. Its purpose is not merely to save a few flexbox lines, but to provide a supported way to express gradients. Tailwind utilities cannot express token-mixed recipes such as radial-gradient(125% 125% at 0% 0%, color-mix(in oklab, …), …), while the guard's no-style-override rule is an error. Together, those constraints previously left only a raw <div> plus inline styles—the official signup block itself used that workaround and now uses AuthPanel. All four recipes, radial / linear / mesh / none, mix from --color-bg, so dark mode follows automatically without a second set of styles. color is resolved through resolveTone, the same path used by Brand.color, Dot.color, and ChartSeries.color (#71).

    ```tsx
    <div className="grid min-h-dvh xl:grid-cols-2">
    <AuthPanel
    brand={<Brand name="Hanyun" />}
    title="Take your ideas to the global edge"
    highlights={["Start for free", "Go from git push to the global edge"]}
    className="hidden xl:flex"
    />
    <div className="grid place-items-center p-8">
    <LoginForm surface={false} /> {/ The left panel already carries the visual weight. /}
    </div>
    </div>
    ```

    Enhancements

    • LoginForm adds fields and surface. fields provides presentation slots for the two primary fields (label / placeholder / prefix / suffix / description / autoComplete) while the template continues to own values and validation, so changing a label does not break browser username or password autofill. When surface disables the built-in card, it now removes the border, background, shadow, and padding together. Removing only the first three would still force consumers to add a final xl:p-0 override, defeating the escape hatch (#70).
    • NavMenu adds semantics?: "tree" | "list", defaulting to tree so existing consumers do not change. The render escape hatch from #59 could render a real <a>, but the row's role="treeitem" overrode its implicit link role. Middle-clicking into a new tab and copying the URL from the context menu worked again, yet the accessibility tree still exposed a tree item, so the common screen-reader command to list every link on a page could not find any primary-navigation entry. list mode sets no role (<a> remains a link and <button> remains a button), uses aria-current="page" for the active item, and returns keyboard interaction to tabbing item by item plus native activation. The ARIA APG model for site navigation is a list of links; tree remains appropriate for file trees and outline trees (#69).
    • Dot adds color?: string, accepting arbitrary colors through resolveTone. Its five tone values cannot represent chart series colors, whose defaults are chart-1..6, while a legend swatch must match its series exactly. When both are supplied, color takes precedence over tone (#73).
    • AreaChart, BarChart, and LineChart add legend?: boolean | "top" | "bottom". Without a legend, readers cannot identify the series in a multi-series chart. The implementation reuses Dot with series.label, so the swatch and series share the same color source. height still means the total component height; enabling the legend reduces the plot area rather than increasing the overall height (#73).
    • RegionSelect adds errorPlaceholder and onError, providing an exit from image 404, 403, cross-origin, and network failures instead of remaining at “Loading image…” forever. Preloading previously attached only onload, not onerror. The preloader and the canvas <image> now share one failure state, including cases where authorization expires between requests and only the SVG request fails. Cached failures (complete with naturalWidth equal to 0) also enter the error state, and changing src resets it. On-demand backend images—pages not yet published to the current environment, expired signed URLs, or insufficient permissions—make this a routine case rather than an edge case (#67).

    Behavior change

    • RegionSelect.onChange now returns integer coordinates. It adds round?: "expand" | "nearest" | "none", defaulting to expand, and exports the pure roundBox function. Previously it returned floating-point values despite defining its coordinate system as original-image pixels. Floating-point coordinates do not fit integer database columns such as list[int], server-side crop APIs in PIL, OpenCV, or sharp—all of which require integers and use inconsistent implicit rounding—or equality checks such as box === savedBox that determine whether a value changed.

    expand uses floor for the top-left and ceil for the bottom-right instead of rounding to the nearest integer, so rounding never shrinks the box. Otherwise, a box dragged to exactly minSide could be reduced to minSide - 1; the user did enough work, yet the selection could not be saved and appeared to do nothing. The minSide check therefore moves after rounding so it evaluates the value that is actually emitted. Drag previews from onDrafting remain floating point for smoother feedback. Pass round="none" to retain subpixel coordinates and the previous behavior. Consumers that wrapped the callback in their own floor/ceil logic can remove that workaround (#72).

    Documentation

    Two pitfalls that fail silently and cannot be identified by reading the call site alone are now documented in the corresponding <slug>.md files:

    • <Dot style={{ color }} /> cannot change the dot because the dot uses a background color while the CSS color property controls text. That code compiles, the guard reports only no-style-override, and the UI shows a gray dot, misleading the author into thinking the override worked. Custom colors must use the color prop.
    • The RegionSelect rounding defect is invisible at 1:1 or integer scale factors because coordinates already land on integers. Consumer tests should use a non-integer scale ratio; the library tests 756→396.

    The claim in nav-menu.md that render makes screen readers announce entries as links now states that it must be paired with `semantics="list"`, matching the implementation and the guidance consumers use for component selection.

    126ace2

There are 31 earlier releases. Switch to All releases to view them.