Skip to content

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 29 Aug 04:32
· 42 commits to main since this release
a8a7691

The data-grid release: the operations a business grid is actually used
for, PRs #487–#577. Eight new recipes (44 total) — datagrid-sort,
datagrid-filter, datagrid-prefs, datagrid-tree, datagrid-edit-errors,
datagrid-edit-conflict, datagrid-bulk-errors, row-detail — a fourth
page template (Data grid page, 4 total), the hc-filterbar component
(67 stylesheets), four new behaviors (installRangeValue(),
installMultiValue(), installRowLink(), installSortList() — 57 total),
the .hc-fill layout utility, row ordinals, and collapsible splitter
rails.

Minor rather than patch because of one behaviour-default change: bare
<a> now takes the theme's link colour (see Changed). Everything else
is strictly additive. The CLI ships 0.4.2 to re-bundle the eight new
recipes; @hypermedia-components/editor-kit is unchanged and stays at
0.2.0.

The link work reached the email render target too, where it turned up
a defect of its own: the dark flavor had been leaving links and tables on
their light colours — 2.77:1 and 1.21:1 against the dark container — because
a fragment can only be re-coloured by the dark media query if it carries an
hc-em-* class, and neither had one (see Fixed).

Added

  • tokens / base: document-level link colours —
    --hc-color-link, --hc-color-link-hover and --hc-color-link-visited,
    generated per theme like every other token, plus bare-anchor rules in
    @layer hc.base. hc.base.css already owned the document's background
    and text but stopped short of <a>, so every anchor outside a component
    fell to the UA's -webkit-link blue and :visited purple — two colours
    that follow neither data-theme, data-color nor data-neutral, and on
    a dark surface the visited purple is close to illegible. This is what an
    app hits wherever it renders prose rather than components: a description
    field, a rendered markdown cell, an error page body.

    The :visited half is the part a consumer cannot write. Engines
    refuse to resolve var() in a visited-dependent declaration on purpose —
    resolving it would let a page read the history bit back out through the
    cascade — so the colour has to be a literal, which means it cannot be a
    token. The build bakes one literal per theme instead, straight off the
    same declaration it emits --hc-color-link-visited from, so the rule and
    the token cannot drift.

    Links are also the one accent value that is theme-dependent. Every other
    accent token holds one value in both themes, because ramp step 600 is
    white-text-safe on any hue; a link is text on the page surface instead,
    and no single rung clears 4.5:1 against both backgrounds (blue.500 is
    3.61:1 on light, blue.600 is 3.33:1 on dark). Light reads
    600/700/800, dark reads 400/300/200, and each non-default
    accent gained a color.<name>.dark.tokens.json emitted under the
    compound [data-theme="dark"][data-color="<name>"] selector — the same
    two-form selector the neutral ramps already use. All fifteen colours are
    pinned at AA against --hc-color-bg and --hc-color-surface by a spec, so
    a future re-ladder cannot quietly drop one below 4.5:1;
    --hc-color-muted-bg sits outside that guarantee on purpose, being a
    component tint whose foreground the component owns — and hc-chat now
    does own it: an assistant bubble is painted with muted-bg and is prose,
    so a link genuinely lands there, and the document's resting step would
    score 4.40:1 (light) / 3.85:1 (dark) on it. A bubble re-pins its links one
    rung further along the same ramp. It carries no :visited rule, which is
    not an oversight: a layer beats specificity and hc.components sits after
    hc.base, so the resting rule covers the visited state too. Visited is
    unified with unvisited inside a bubble — partly the console-not-a-browser
    argument, partly arithmetic, since the bubble's surface leaves only two
    usable rungs in dark (the third, accent.100, scores 1.08:1 against the
    bubble's own text and would read as body copy). The theme builder learned
    the same trio, so a custom theme re-themes its links too.
    (#569)

  • docs: the template says when a fixed-height grid is the right
    shape
    — and when letting the page scroll is. It is the operational
    pattern (Fiori list reports, Salesforce list views, ServiceNow, AG
    Grid, Ant Design's scroll.y) and it needs four things to be true:
    the screen is an app frame, there is a fallback under the breakpoint,
    print un-caps the scrollport, and the rows are paged rather than
    infinite. If any is false, page scroll plus a sticky header buys most
    of the benefit for none of the cost.

  • docs: the data-grid page template works now. Its rows,
    conditions bar, pager, failure summary and docked panel are answered
    by the docs demo API, so the contracts can be watched meeting each
    other instead of being described one page at a time: filter and
    remove a chip, sort (paging stays stable because ties break on the
    primary key), open a record from its identity cell — the peek carries
    the exit at the start and n / 24 ‹ › at the end, and Next crosses
    the page boundary
    because the server re-runs the query — save and
    see the row behind update out of band, then tick rows and press
    Approve to watch some fail: rows marked, one line of chrome with the
    moves and Show only failed, and the breakdown waiting in the docked
    panel's rail. The full-size preview is the same screen with the
    viewport to itself.

  • docs: the data-grid page template gets a full-size preview at
    its own URL. The template is about a screen that takes the whole
    viewport — chrome fixed, the grid taking the rest — and showing it
    inside a documentation column is a picture of the idea rather than
    the idea; at 36rem tall next to a 9rem sidebar it was simply too
    small to read. The embedded demo is taller now, and Open the
    full-size preview
    leads to a plain, chrome-free route where the
    template owns the screen. One markup source serves both: the demo
    component takes a standalone prop and swaps its wrapper.

  • layout: .hc-fill — take the remaining space of a flex
    column and let the children scroll. The composition every full-height
    app screen needs, previously written as a structural rule in the
    data-grid template (.page > form > .hc-datagrid), which was wrong
    twice: a page may hold several grids — a detail screen stacks a
    header grid, a lines grid and a history grid, and only one of them
    should take the remaining height — and a descendant rule stops
    matching the day someone wraps the grid in a <div>
    , silently,
    with the symptom (the page scrolls instead of the grid) showing up
    nowhere near the change. It carries both minimums, one per axis, and
    goes on every element between the column and the filling region,
    including a wrapping <form>. On an hc-datagrid it also switches
    --hc-datagrid-max-height from the default 70vh — right for a grid
    inside a scrolling page — to 100%, right for a grid that is
    the page.

  • components: hc-splitter panes can collapse to a rail —
    data-collapsed gives a pane its content's size, hands the freed
    space to its sibling (a fixed --hc-splitter-pos basis would leave a
    hole) and hides the drag handle, since there is nothing to drag. The
    state belongs to whoever owns the pane's content, so a server
    re-rendering that region sets it on the fragment it already sends —
    no client state, and the two panes cannot disagree. Collapse to a
    rail, not to nothing
    : a panel that disappears when closed is a dead
    end for the person who closed it. The datagrid-bulk-errors docked
    panel adopts it: collapsed is the default, the rail carries the
    count (Reasons (5)), and the response does not open it — the
    summary already said what happened, and giving away the grid's width
    is the reader's decision. A screen whose job is triage may start
    open; whether it is open is workspace state, remembered per user
    rather than in the URL.

  • docs: the datagrid-bulk-errors demo now shows the docked
    panel
    the contract describes, instead of only describing it. The
    chrome keeps one line — count, prev / next, Show only failed — and
    the grouped breakdown rides to an hc-splitter panel beside the
    grid, resizable by pointer or keyboard, collapsing when there is
    nothing to report. The panel is a server-owned region: hiding it
    is a response (GET /report?close=1), not client state, so the two
    surfaces cannot disagree.

  • recipes: the bulk-error summary is also the navigator.
    Twelve failures scattered through five thousand rows is a queue, so
    the O(1) line carries Previous · Error 3 of 12 — row 137 · Next as
    real #row-<id> fragment links with a server-rendered counter:
    Back works, the keyboard works, installDatagrid() lands the active
    cell
    on the row a fragment names, and there is no client state to
    drift from the panel. Rows are named by id and the ordinal is
    shown beside it, because ordinals move when the sort or the
    conditions change and ids do not. A Go to row control
    (?goto=137) covers the number somebody read out loud — the server
    resolves the ordinal to the page that contains it, since only it
    knows where row 137 currently is.

  • recipes: row-detail gains the walks, with a live demo. Two
    sequences, one shape: the result set (seq=list — the server
    resolves neighbours by re-running the list query, so Next crosses a
    page boundary without the client knowing pages exist) and the
    selection — tick rows, Open selected (N) posts the same ids
    every other bulk action sends, and the answer is a 303 to the
    first record of an ordered snapshot. A record that vanished
    mid-walk is a tombstone step with Next still working, because
    aborting at the first gap makes the feature untrustworthy exactly
    when data is moving; an unreadable or expired snapshot fails
    closed
    (410 + a way back), never a silent fallback to walking
    everything. The data-grid page template adopts the identity-cell link
    and Open selected.

  • recipes: row-detail + installRowLink() /
    data-hc-row-link — the most-used interaction on a business list,
    and the one every app reinvents: open this record, work on it, come
    back, open the next one
    . The link is an ordinary <a href> in the
    row's identity cell, so middle-click, ⌘-click, copy-address,
    Back, the keyboard and the no-JS path all work without any of them
    being re-implemented; the behavior adds only what an anchor cannot do
    itself, Enter anywhere on the row. Editing wins where it applies
    (the datagrid cancels the event before opening an editor), a control
    that owns its Enter keeps it, and a modifier means the user asked for
    something else. The row is deliberately not one big link — the
    datagrid ships text selection, range selection and TSV copy, and a
    stretched anchor eats all three. Coming back is the part everyone
    drops: the list URL already carries the conditions, sort, columns and
    page, so the detail's Back to list is that URL plus #row-<id>,
    which installDatagrid() lands the active cell on. After a save the
    detail **303**s there instead, because a restored snapshot shows
    the data as it was before the user's own edit. The contract also
    covers the peek rendering (canonical href kept), and walking a
    sequence — the result set (seq=list) or the selection (an
    ordered snapshot token, a tombstone step for a record that vanished,
    410 and fail-closed on expiry).

  • datagrid: row ordinals — data-row-no on a row and
    data-row-total on the grid, from which installDatagrid() derives
    aria-rowindex and aria-rowcount. A business grid is discussed out
    loud ("row 137 is the one that failed") and the record id is right
    for the system but wrong for the sentence. The ARIA numbers count DOM
    rows including header rows while a server counts matching records,
    so the offset is derived here rather than asked of every server —
    getting it wrong is an off-by-header nobody notices without a screen
    reader. It also fixes a lie the kit has been telling: without these
    attributes a paged grid announces "row 3 of 40" on page four. Two
    rules keep the number honest — the ordinal is a locator, the id is
    the identity
    (ordinals move when the sort or the conditions change,
    so anything stored names the id and merely displays the ordinal), and
    it counts the result set, not the page. An omitted
    data-row-total means unknown (aria-rowcount="-1", the honest
    answer for an infinite grid mid-load), and a row the server did not
    number is left unnumbered rather than given a position it does not
    have.

  • recipes: saved-views — saving asks three things, not one.
    A dialog that asks only for a name pushes the other two decisions onto
    whoever notices later, so the save form now carries scope
    (personal / shared — a department standard is the normal case in
    business software, and silently forking a colleague's view is an
    accident) and default (a screen that opens on the wrong question
    wastes a step every day; the bare list URL then 303s to it, so the
    address bar still shows the real conditions). The server owns the
    rules: at most one default — a screen that opens on two different
    questions has none — and PUT /views/<name> corrects what a view
    asks without ever re-homing it or moving the default, because scope
    and default are not conditions. The strip labels shared and default
    views, and every chip offers Copy link
    (data-hc-copy-text), because a view is a URL: sharing one costs
    nothing and needs no shared object at all. The data-grid page
    template's Save as new… now opens the dialog it always implied.

  • components: the filter panel's typography, as reusable API. A
    panel is read far more often than it is filled in, so hc-grid gains
    data-align="start" (items align to the top of their row instead
    of stretching — a three-row textarea stops inflating its neighbour
    into a tall empty box) and data-span="full" (an item takes a
    whole row; auto-fill means which items pair up changes with
    width, so nothing may depend on a pairing that holds at one width).
    hc-field gains data-applied, marking a set condition with a
    dot — a scanning aid, since the announcement is the conditions bar
    above the data — with --hc-field-applied-marker-color / -size.
    hc-input-group now strips a nested <select>'s chrome, so value +
    operator
    reads as one control with one ring, and data-quiet
    drops that select's voice (not its hit area or its keyboard) for the
    operator nearly every row leaves at its default. The data-grid page
    template adopts all four, settles on one vocabulary (Apply), and
    makes Cancel a native formmethod="dialog" submit instead of inline
    JS. fundamentals/icons gains the policy the screen follows: icon +
    label unless the meaning is universal, icon-only for close / overflow
    / pager chevrons, counts in the label rather than a badge, and a
    gear is not a columns icon
    — it reads as screen settings and
    collides with them.

  • docs: the data-grid page template gets a columns entry point.
    The datagrid-columns
    recipe already existed and the wiring map already named it — what the
    screen lacked was a way in, so the chooser was unreachable. A toolbar
    control opens it, with the count in the label (Columns (7 of 12)),
    because a grid missing the column you are looking for is
    indistinguishable from a grid whose data is missing. The template also
    now states the rule the recipe assumes: a column set is a
    preference, not a condition — it follows the user between screens,
    so resolution is URL → user preference → app default, which is what
    keeps a shared link authoritative.

  • recipes: datagrid-sort — the sort set as a control, plus
    installSortList() / data-hc-sort-list. Header clicks are the fast
    path and stay; what they cannot do is answer what the current sort
    set is
    . Shift-click for multi-sort is undiscoverable, with thirty
    columns the sorted one is usually scrolled out of view, a key on a
    hidden column has no header at all, and re-ordering keys means
    re-clicking headers in the right sequence. So sort gets what the
    conditions got: one surface that is both the read-out (the trigger's
    label is the applied set, server-rendered) and the editor (an
    ordered list reordered by installSortable(), pointer and
    keyboard, with per-key direction, remove, and an Add a column
    list that includes columns the grid is not showing). The order of
    the rows is the order of the keys
    — nothing duplicates that state.
    installSortList() joins the rows into the unchanged
    ?sort=-ship,order wire on the formdata event, in place, so a
    saved view's querystring comparison still works; without JavaScript
    the per-key dir-<col> controls carry the keys, directions and
    order, because form entries arrive in DOM order. Add and remove are
    server round trips, because which columns are available is the
    server's knowledge.

  • behaviors: installRangeValue() / data-hc-range — two
    controls, one range param on the wire
    . A date filter is a period,
    and a period is one condition: one chip in the applied-conditions bar,
    one thing to remove, one value a saved view stores. So the wire
    carries ?f-ship=2026-07-01..2026-07-31, not f-ship-from +
    f-ship-to — the two-param shape cannot let a preset set both ends
    from one control
    without a hidden input, and hidden controls keep
    submitting. Each end resolves on its own, so relative
    (@month-start-1m..@month-end-1m), mixed
    (@month-start-1m..2026-07-15) and open-ended (@month-start..)
    ranges all work without a special case. The pair of date inputs keeps
    real names, so the no-JS path submits a usable request and servers
    accept both shapes; the behavior joins them on the formdata event —
    the hook htmx and a native submit both fire — so editing either end
    costs no round trip. from > to is refused, never swapped: a
    native validity message blocks the submit (and the demo API answers
    400, because anyone can type a URL). The datagrid-filter demo's
    due-date condition is now a range with period presets, an absolute
    branch, and the offset composer.

  • docs: saved views move out of the filter dialog and onto the
    screen
    . A list screen is asked four questions — what am I looking
    at, narrowed how, in what order, showing which columns — and each
    reads best with exactly one home; the first is answered beside the
    title by an hc-menu whose label is the applied view's name.
    Recall was costing four interactions for the screen's most frequent
    act, and a view is a named URL, which makes recall navigation
    rather than filter editing. Items are role="menuitemradio" (exactly
    one view is applied, with Show everything as the none-of-them
    option) and still real <a href>s, so views stay bookmarkable,
    middle-clickable and no-JS. The panel keeps only the terminal actions
    of composing a condition set, Update and Save as new…, which
    also leaves one undo instead of two. The saved-views contract and
    recipe page document the menu shape beside the chips strip, and the
    data-grid page template shows it in place.

  • recipes: datagrid-filter documents (and demonstrates) how a
    relative date gets entered
    . The expressions shipped as a wire
    format with no affordance behind them — and nobody types
    @today-7d. The control is a server-rendered list of presets whose
    option values are the expressions, with the applied one rendered
    selected so a saved view reopens showing "This week" rather than a
    raw expression; the server owns the list because it knows which
    presets suit the column. Choosing Custom date… re-renders the
    field as a date input rather than revealing a hidden one: hidden
    controls keep submitting, so a hidden date input beside a visible
    preset select would send the param twice and leave the server
    guessing. One name, one control, always. Arbitrary offsets ("45 days
    ago") come from a composer — a number and a unit, deliberately not
    named after the condition, so nothing claims it until something is
    chosen — which the server composes into the expression and returns as
    a labelled selected option. A relative expression is never put in a
    date input: the browser shows an empty field there and the condition
    is lost on the next submit.

  • docs: the data-grid-page template adopts the filter-UX work
    (plans/hc-filter-ux-plan-en.md item G). Its illustrative hc-chip
    strip becomes a real hc-filterbar whose chips open their own
    editors and whose remove links drop one param each; one condition is
    relative, showing the stored expression and what it resolved to
    (start of this week (2026-08-10)); the filter panel takes a pasted
    list through data-hc-multi="lines", carries sort in a hidden
    data-hc-datagrid-sort field so a saved view captures it, and shows
    the Modified state with Update / Save as new / Reset. Export
    became a link carrying the current query and row count rather than a
    bare button. The wiring map gains a row per new contract.

  • recipes: datagrid-filter documents that export inherits the
    conditions
    (plans/hc-filter-ux-plan-en.md item F). In a business
    screen "download" means this question, these columns, every page —
    not the forty rows on screen — so the export link is the same query
    in another representation, with its href rendered by the server
    (only it knows the canonical form of the conditions, and a relative
    expression must travel as the expression so the export means what the
    screen means). Columns come along, resolved the way the grid resolved
    them; the label carries the row count, because an export is a
    commitment and the number is what separates "this page" from
    "everything"; and the page number is the one param dropped. Past the
    point where a request would time out, answer 202 with a job pointer
    — a silently truncated export is a wrong answer that looks like a
    right one.

  • recipes: datagrid-bulk-errors can act on everything that
    matches
    , not only on ticked ids (plans/hc-filter-ux-plan-en.md
    item E). Ticking rows stops working before the data does: when 4,873
    rows match, the wanted operation is "archive all of them", and 4,873
    ids fit in neither a querystring nor a form post. A request may now
    carry scope=matching plus the conditions themselves — the same
    f-* params the list URL uses — and the two shapes are mutually
    exclusive (400 if both). The count is part of the confirmation: the
    button says the number, the server re-counts, and a count-token
    pins the count the user was shown. If it has moved — someone else's
    edit, a relative date rolling over at midnight — the answer is 409
    with the old and new counts and a fresh token, never a silent run
    against a different set. Without the token, "archive all 4,873"
    executes against however many rows exist at execution time, which is
    a different operation from the one the user agreed to.

  • recipes: saved-views gains a modified state, update in
    place
    , and a persistence model (plans/hc-filter-ux-plan-en.md
    item D). Applying a view and changing one condition is the commonest
    thing users do with saved views and was the least served: nothing
    said whether what you were looking at was still the view, so the user
    either lost the tweak or trusted a saved version that was not on
    screen. The apply link now names its view (&from-view=<name>), the
    server compares normalized conditions (sorted params, so the same
    question compares equal whether it arrived from the form or from a
    link), and the chip renders data-modified with Update and
    Reset. PUT /views/<name> updates in place, so correcting a view
    keeps its name and every link already shared — previously the only
    route was delete-and-recreate. The contract also writes down what a
    view captures (filters, sort, pinned columns; never the page
    number), that columns resolve URL → user preference → app
    default
    , that scope may be shared rather than personal (so editing
    a colleague's view is a visible act, not a side effect), that a
    default view redirects with 303 so the address bar shows the real
    conditions, and that applying re-authorises and fails closed —
    quietly dropping a condition the user may no longer run would widen
    the result set.

  • recipes: filter conditions accept relative date expressions
    (plans/hc-filter-ux-plan-en.md item C). A saved view is a stored
    querystring, so an absolute date makes the view wrong tomorrow —
    "shipping this week" saved on Monday means last week by the following
    Monday, and time is what a large share of real saved views are about.
    Condition values may now be @today, @week-start / @week-end,
    @month-*, @quarter-*, @year-*, or an offset from any anchor
    (@today-7d, @month-start-1m), and the expression is what gets
    stored. The server resolves — never the client, whose clock and
    timezone would leak into the answer — against one instant per
    request, so a request near midnight cannot straddle two days.
    Absolute values stay ISO on the wire (2026-08-01, never
    01/08/2026, which means different days to different colleagues);
    localize on the way out. The applied-conditions bar shows both
    forms — start of this week (2026-08-10) — because the wording alone
    hides which rows are in, and the date alone hides that it will move.
    An expression the server does not understand answers 400, never
    the unfiltered list.

  • behaviors: installMultiValue() — one control, many values on
    the wire
    (plans/hc-filter-ux-plan-en.md item B). The filter wire
    already took repeated f-<col> params; nothing let a user enter
    them. data-hc-multi="lines" on a <textarea> (or commas) makes
    each line its own entry, so a column of order numbers pasted out of a
    spreadsheet becomes
    f-buyer=A&f-buyer=B&f-buyer=C. The split happens on the formdata
    event
    — the hook installFormat() already uses, which htmx's
    new FormData(form) and a native submit both fire, so one listener
    covers both transports and nothing wraps the network. Entries are
    rebuilt in place rather than appended, so the same conditions
    always serialize in the same order (a saved view compares
    querystrings). Values are trimmed and de-duplicated, and a control
    emptied of everything contributes no entry at all. Servers should
    still accept the raw newline-joined value, which is what the no-JS
    path sends. datagrid-filter's contract gains the section, including
    what to do when the list outgrows a URL: a stored condition set
    addressed by id — which must answer 404 rather than fall back to
    "no filter", since a silently dropped condition shows more data than
    was asked for.

  • recipes: datagrid-filter gains the applied-conditions bar
    (plans/hc-filter-ux-plan-en.md item A). Column popovers are how a
    condition is created; they are a poor way to find one again — in a
    wide grid the column may be scrolled out of view, and plenty of
    conditions do not belong to a column at all. The response now also
    renders an hc-filterbar: one item per applied condition, each chip
    opening an editor for only that condition, each remove control a
    real link to the current URL minus that one param (so it works
    without JavaScript, is shareable, and Back puts the condition back).
    Values arrive summarised — 2 values, not one chip per value —
    because only the server knows the label, the operator and the count.
    The contract also gains the empty-result rule: answer a
    filtered-to-nothing list with a link that drops the newest
    condition, since that is what the user just did. checks.json
    enforces that remove controls are links and name their condition.

  • datagrid: sort now travels with the form, so it survives an
    Apply and is captured by a saved view

    (plans/hc-filter-ux-plan-en.md PR-4). installDatagrid() writes the
    whole ordered sort set (name,-price — leading - for descending)
    into every input[data-hc-datagrid-sort] in the grid's closest
    <form> before dispatching hc:datagridsort, exactly as it
    already does for column widths, so an event-triggered request
    serializes the fresh value. Sort used to arrive from the grid's own
    data-hx-vals wiring, outside the filter form — which meant filtering
    could silently reset the order, and saved-views (which stores the
    form's fields) saved a view that had forgotten how the list was
    ordered. The docs also stop contradicting themselves: the page
    documented ?sort=name,-price and then wired a single-column
    { sort, dir } pair that cannot round-trip multi-column sorting.
    Additive — a grid without the input behaves exactly as before.

  • filterbar: new component — the applied-conditions bar above a
    list (plans/hc-filter-ux-plan-en.md PR-1). Applied filters had no
    component: hc-chip is documented as presentational and hc-chips
    wraps, while a condition bar is one line that scrolls and whose
    chips are controls. Each .hc-filterbar__chip is a
    <button popovertarget> that opens the editor for its own condition
    — reachable even when that column is scrolled out of the grid, and
    available for conditions that are not columns at all — and
    .hc-filterbar__remove is a real <a href> to the same URL minus
    that condition, so dropping a filter works without JavaScript, is
    shareable, and Back puts it back. Chips do not shrink (the bar
    scrolls instead) and .hc-filterbar__clear is pinned to the trailing
    edge, because clearing everything must not require first scrolling to
    the end of what you want to clear. The server owns the chip's text —
    label, operator and value — so a multi-value condition arrives
    summarised ("3 values") rather than as three chips or one 200-character
    one, and a long single value truncates at --hc-filterbar-value-max
    with the full text in the editor. New filterbar.* tokens.

  • docs: the datagrid page documents the scroll area. The grid has
    one scroll container holding header, body and footer, and the header
    holds still because its cells are sticky — so the vertical scrollbar
    necessarily runs alongside the header, not only beside the data.
    Records what a data-only scrollbar would cost (two tables, scripted
    column-width and horizontal-scroll sync, and explicit
    aria-colindex / aria-rowindex in place of the single accessible
    table role="grid" derives for free), and the cheap alternative when
    the goal is a quieter bar rather than a shorter one
    (scrollbar-width: thin, deliberately not a default). Also notes that
    sticky lives on the header cells, not the row — measuring the row
    reports a bug that is not there.

  • docs: a fourth page template — Data grid page
    (templates/data-grid-page). The business list screen: an hc-shell
    frame whose grid takes the remaining height so only the grid
    scrolls
    (both axes) under sticky multi-level headers and frozen
    columns, with a toolbar whose trailing group is pushed by
    data-hc-spacer, and filter input in a dialog. Documents the trap
    that makes or breaks the layout — every element between the page
    column and the grid needs flex: 1; min-block-size: 0, and the grid
    needs --hc-datagrid-max-height: 100%; miss one and the page
    scrolls instead, taking the toolbar with it. Includes a wiring map
    from each region to the recipe contract its endpoint implements.

  • recipes: datagrid-edit-errors gains a fourth outcome —
    confirmable warnings
    (plans/hc-datagrid-state-clarity-plan-en.md PR-4). The contract had
    accepted / 422 rejected / 409 conflict, and business apps need
    the case where the value is acceptable but unusual and only the
    server knows it needs asking about: a ship date in the future, a
    discount above policy. 422 would tell the user to change something
    that needs no changing, and a client-side confirm cannot express a
    rule discovered on the way in — so the branch is 200 (nothing
    failed; the server is continuing the conversation, and no
    htmx:beforeSwap allowance is needed) with the record re-rendered in
    a confirm-pending state: the proposed value in the cell marked
    data-attention="warning", and a data-tone="warning" message row
    with role="alert" offering Confirm and Cancel. Cancel is a plain
    GET of the record — nothing was written. The confirmation token is
    bound to (row, column, value[, version]), so a confirmation
    obtained for one value cannot commit another or replay past the
    409 guard; the buttons carry static data-hx-vals (not js:), so
    the value is pinned at render time and stays CSP-safe. Core adds the
    cell-level warning marking the state uses.

  • datagrid: opt-in zebra striping
    (plans/hc-datagrid-state-clarity-plan-en.md PR-3). data-hc-zebra
    on the grid makes installDatagrid() assign data-alt to alternate
    rows on every rebuild, because :nth-child() cannot express it: it
    counts rows hidden by a collapsed group (so the stripes shuffle the
    moment a group closes) and it cannot alternate per record — a
    .hc-datagrid__record spanning three physical rows must stripe as one
    block. Both are things rebuild() already knows. The stripe is the
    bottom rung of the tint ladder, so hover, selection and the attention
    bar stay visible over it, and frozen columns keep their stripe. New
    --hc-datagrid-row-alt-bg token. Without the opt-in the behavior
    leaves data-alt alone, so a server that renders it directly needs no
    JavaScript.

  • datagrid: editability states are now announced and afforded
    (plans/hc-datagrid-editability-plan-en.md §1.1, §1.2).
    installDatagrid() derives aria-required from the column
    editor template's required and aria-readonly from the absence
    of data-editable — per cell, so row-state-dependent editability
    (unshipped editable, shipped locked) works without a client-side
    rule; a server-rendered value always wins, and a wholly read-only
    grid says so once on the table (which carries role="grid") instead
    of on every cell. Editable
    cells gain a hover/focus affordance by default, * marks anything
    aria-required, and the opt-in data-hc-editable-hint="editable" | "readonly" marks whichever of the two is the exception in that grid
    (forced-colors fallback included).

  • recipes: datagrid-bulk-errors — bulk-action failures at scale
    (plans/hc-datagrid-bulk-errors-plan-en.md). Makes the execution
    semantics
    an explicit contract choice: best-effort (200, rows
    reflect what happened, failures marked with their reason, a
    "filter to the failed rows" retry link) vs atomic (409 / 422,
    rows unchanged, selection preserved, copy framed as refusal
    rather than partial completion), with a pre-flight step that
    reports executability before anything runs and offers to exclude the
    blockers. Failures are reported in one aria-live region grouped
    by reason
    with a hard cap plus a full-list escape hatch, and every
    named row links back into the grid (#row-<id>, or
    ?focus=<id>#row-<id> for another page). Machine-checked contract;
    live docs demo (en/ja).

  • recipes: datagrid-edit-conflict — the 409 wire for datagrid
    inline editing (plans/hc-datagrid-edit-feedback-plan-en.md §1.3).
    Optimistic locking per row: the record <tbody> carries
    data-version and the PATCH includes it; a stale version answers
    409 with the record re-rendered as a conflict presentation — the
    server's current values in the cells (data-tone="error"), the
    fresh version, and a role="alert" conflict row naming both values
    with Overwrite (static-vals re-submit against the fresh version)
    and Discard (GET of the row) actions. The row is the merge UI;
    overwrite is last-writer-wins by explicit consent. Machine-checked
    contract; live docs demo (en/ja).

  • recipes: datagrid-edit-errors — the 422 wire for datagrid
    inline editing (plans/hc-datagrid-edit-feedback-plan-en.md §1.2).
    Each row is its own record <tbody> carrying the persistence wiring
    (hc:datagridedit → PATCH → outerHTML); 200 answers
    the record with the row alone (server formatting confirms the
    optimistic commit and clears data-pending), 422 answers the
    record with the server's value restored, the cell marked
    data-invalid + aria wiring, and the __error-row naming the
    rejected input — one atomic swap unit, no stale error rows.
    Machine-checked contract; live docs demo (en/ja).

Changed

  • base: bare <a> now takes the theme's link colour instead of the
    UA's -webkit-link blue and :visited purple. This is the one
    behaviour-default change in the release, and the reason it is a minor
    rather than a patch: an app that relied on the UA defaults for anchors
    outside a component will see them re-coloured on upgrade. The rules land
    in @layer hc.base, so any unlayered app rule still wins, and every
    component anchor (hc-button, hc-breadcrumb__link, hc-toc__link,
    hc-pagination__item) is unaffected — hc.components is the later layer.
    To keep the old look, set the tokens to the UA colours or override a
    outside the hc layers.

  • docs / base: two stale claims went with it — hc.base.css's
    ::selection comment still described a "12 % (18 % for amber)" tint, but
    amber stopped being an accent axis in 0.2.0 and the ladder work removed
    its soft-tint special case, so all five axes have been a flat 12 % for a
    while; and the theming guide told you to mirror color.indigo.tokens.json,
    a file the same release deleted when the accents became the five-hue
    pentagon.

  • docs: in the working template, a row click is now a real
    navigation
    . The record has its own prerendered URL
    (/templates/data-grid-page-record/<id>/), built from the same data
    the demo API serves, so a click behaves the way business software
    does — Gmail replaces the screen, Fiori splits it, Salesforce gives
    the record a page. Back to list carries the list query and the
    row anchor, and the preview seeds its first request from that query,
    so the list really does come back as it was with the row under the
    cursor. The peek dialog stays as the enhancement layered on the same
    href, with a link to the page inside it. The previous excuse — that a
    documentation page is a single route — was only true until a second
    route was written.

  • docs: row-detail ranks the three renderings the way business
    software actually does, and says why. Opening a record replaces the
    screen
    in Gmail, splits it into columns in SAP Fiori, and is a
    page in Salesforce and ServiceNow; modals in those products are
    for short, self-contained tasks — create one thing, confirm, edit a
    field — not for the record, because a record is where the work
    happens and work needs room, a URL and its own error surfaces. The
    failure mode is named too: a modal with no URL, where Back closes
    something the user never opened, the link they send a colleague is
    the wrong screen, and a refresh loses their place. The template says
    plainly that it peeks because a documentation page is a single route
    — the row's href beside it is the real page.

  • recipes: row-detail states where a detail screen's navigation
    goes
    , since the list template's "navigation under the data" rule
    reads as "put a pager at the bottom" if left unqualified. Prev / next
    belong in the record's header: the decision to move on is usually
    made before reading to the bottom, a bottom control on a scrolling
    body either scrolls away or buys a second fixed strip, and the 303
    after a save lands the user at the top anyway. A long detail may
    repeat them below as a secondary copy — same links, no state. And a
    grid inside a detail pages itself, directly under itself: a
    page-level pager on a screen with three grids cannot say which grid
    it pages. The bottom of a detail carries its actions, not
    navigation. Within the header the arrangement is the one every mail
    client already taught users — the exit at the start, the walk
    (1 / 15,129 ‹ ›) at the end
    — the same rule the list's navigation
    strip follows.

  • docs: in the data-grid template's navigation strip, where you
    are
    stays at the start and where you go moves to the end. Two
    reasons about hands rather than taste: after scrolling the grid the
    pointer is already at the trailing edge, where the scrollbar lives,
    and Next is pressed far more often than anything else on the
    strip. The count keeps the start because it is a read-out and the
    frozen identity column it refers to is on that side. Logical
    properties, so RTL swaps both without a second rule.

  • docs: the data-grid page template groups its controls by what
    they change
    , because one strip holding four kinds of control reads
    as clutter however tidy each one is. Filters, Sort and Columns now
    sit together beside the title — they answer the same question, what
    am I looking at
    , and splitting them made the screen look busier than
    it was. The toolbar keeps only actions on the data (Refresh,
    Import, Export). Selection-scoped actions moved to their own bar,
    revealed by installDatagridActions() when rows are ticked: Approve
    and Reject apply for the minutes a selection exists and were being
    read all day for the rest of the time — and a bar that appears is a
    better cue than a button that greys out, because a disabled button
    explains nothing. Navigation (the pager, Go to row) moved under
    the grid
    , where the movement happens.

  • recipes: the datagrid-bulk-actions contract's "the selection
    clears by construction" is now scoped to the branch where the action
    actually ran, with a carve-out for refusals — an all-or-nothing
    refusal must re-render its rows with the checkboxes checked, or a
    hand-picked selection is destroyed when nothing happened.

  • datagrid: fragment navigation and the error-tooltip carve-out
    (plans/hc-datagrid-bulk-errors-plan-en.md §1.4, §1.5). A link to a
    row (#row-101 — a bulk-error report entry or a deep link) now moves
    the active cell to that row's first cell and focuses it (on load
    and on hashchange; unknown or unusable hashes are ignored), so
    keyboard and screen-reader users arrive where the eye does. The
    landing row is emphasised with :target and carries
    scroll-margin-block-start derived from the measured header heights
    so it never lands under the sticky header (forced-colors fallback
    included). A cell carrying its own message — server-rendered
    data-invalid, or aria-describedby pointing at an hc-tooltip —
    now suppresses the built-in overflow tooltip, so one hover never
    carries two meanings.

Fixed

  • email: the dark flavor left links and tables on their light
    colours
    . The layout's @media (prefers-color-scheme: dark) block flips
    the background, the container, headings, body copy, muted copy and the
    separator — but a fragment can only be reached by that block if it carries
    an hc-em-* class, and link and table had none. Email bakes every
    colour inline, so what survived the flip was a link at 2.77:1 against
    the dark container and table copy at 1.21:1, which is dark text on a
    dark surface. Both now carry classes (hc-em-link, hc-em-table /
    hc-em-th / hc-em-td) with matching dark rules, reaching 5.85:1 and
    13.34:1. Alerts, badges and buttons are untouched: each brings its own
    background and foreground, so it is legible either way.

    The link fragment also read color-action-primary-bg, which is the colour
    a button sits on with white text over it, not a colour text is painted
    in — and it holds the same value in both flavors, so no dark rule could
    have saved it. It reads color-link now (#569).

  • theme builder: a custom theme built in accent mode emailed a stock
    blue dark-mode link
    instead of its own accent. theme.dark is overlaid
    after the custom accent, and it now carries link tokens, so it won the
    resolution — a regression from adding them (#569). The builder's custom
    accent gained the dark counterpart the stock accents ship as
    color.<name>.dark.tokens.json, wired into all three outputs (the
    paste-ready block, the full token CSS, and the email maps), so a teal theme
    stays teal in dark.

  • tests: the two specs that follow a #row fragment link read the
    focus a task too early
    , and one of them failed roughly two full-suite
    runs in three while passing every time in isolation. Following the link is
    a same-document navigation: the browser blurs the anchor on the way
    through — the active element becomes <body> — and queues hashchange as
    its own task, so focusHashRow() has not run when click() resolves.
    Measured, the active element is still <body> through the next microtask
    and animation frame. Both specs now use retrying locator assertions
    (toBeFocused(), toHaveCount()) instead of a one-shot
    page.evaluate(() => document.activeElement). Nothing in the product
    changed: the cell was always focused, just after the assertion looked.

  • dialog: prefers-reduced-motion: reduce did not actually stop
    the dialog from animating in
    . The guard was written against the
    bare .hc-dialog, but the enter transition is declared on
    .hc-dialog[open] — one attribute more specific, so the guard was
    outranked and never applied. A reader who asked for no motion still
    got the 200ms fade-and-scale; only the exit was ever zeroed. The
    guard now lists the [open] states (and their backdrops) so it
    matches that specificity and wins on source order. This was also the
    root of an intermittent CI failure: axe samples rendered pixels, and
    mid-fade the primary button's blue composites toward the page behind
    it and scores ~3.5:1 against white instead of the 5.31:1 it resolves
    to at rest — Chromium and WebKit usually settled before the scan,
    Firefox did not. Pinned by a spec that opens a dialog under reduced
    motion and asserts it is fully opaque on the first visible frame.

  • print: a fixed-height datagrid printed only the rows that
    happened to be visible
    . The print sheet reset the wrapper, but the
    cap lives on the scrollport (.hc-datagrid__scroll), and
    max-height: none on a parent does not reach it — nor does the
    physical property override the logical max-block-size the component
    sets. On paper there is no scrolling, so whatever the cap hid was
    simply missing with nothing to say so. The scrollport now un-caps in
    print, pinned by a spec that checks the computed style under both
    media.

  • docs: in the working template, clicking a row still opened the
    modal
    — the record's name carried both an href and a
    data-hx-get, and htmx takes the click, so the real navigation added
    alongside it was reachable only by middle-click. The peek now has
    its own control (a button in a trailing column) and the record's
    name is a plain link, so a click navigates. The rule is in the
    row-detail contract and docs now, because the markup looks correct
    either way: one anchor cannot be both — an href under a
    data-hx-get is a claim nobody can act on.

  • docs: the working template opened records only as a modal,
    which contradicted the row-detail contract it is meant to
    demonstrate: a record reachable only through a dialog cannot be
    linked, bookmarked or opened in a second tab, and the peek was
    missing the Open full page link the contract requires — a peek that
    traps you is worse than no peek. The row's href is now the
    record's own page, which the demo API answers as a real page
    (Back carrying the list query and the row anchor); the dialog is the
    enhancement layered on top with data-hx-get, and it carries the way
    out. The recipe now also states plainly when each of the three shapes
    is right — page (default), peek (glance and go, short records), or a
    docked pane (when the work is comparing record and list).

  • docs: the template's full-size preview showed no data. The
    page is a plain Astro route, not a Starlight one, so it never got the
    DemoFrame that loads htmx for every other live demo — leaving every
    data-hx-* attribute on the screen inert: the grid's load request
    never fired, the rows stayed empty and the chrome kept its
    placeholder text. It loads htmx now (with the same 401 / 409 / 422
    swap allowance the recipe contracts document) and the two docs
    stylesheets Starlight applies through customCss, so the shell's own
    chrome stops rendering raw.

  • docs: the datagrid-filter live demo was missing two of the
    regions its own responses fill
    , so both landed nowhere: the
    applied-conditions bar and the due-date control — which is where
    relative dates (@today-7d, @month-start-1m..@month-end-1m, the
    preset list, the offset composer) are entered. An out-of-band swap
    with no target does not fail loudly: htmx drops the fragment, the
    page renders, and the feature is simply invisible. Both regions exist
    now, and a test checks the demo pages against the regions their APIs
    answer — from both ends, so the list cannot drift into fiction.

  • docs: a bulk-error report could squeeze the grid to nothing
    on the full-height list page. The chrome is fixed and the grid takes
    what is left, while the report's height is O(number of failure reasons) — so an action that failed in fifteen ways hid exactly the
    rows it was telling the user to go and fix. The template now states
    the corollary of its own layout rule — the chrome is O(1);
    anything that grows with the data lives in the scrolling area or an
    overlay — and carries a one-line summary with Show only failed
    (N)
    , with the failing rows marked data-attention="error". The
    datagrid-bulk-errors contract picks the surface by one question,
    is there work in the grid?: best-effort → the summary plus the rows
    (and the filter, which turns the grid into the report); the
    grouped breakdown → a docked panel beside the grid, because a
    side panel spends horizontal space, which this layout has; atomic →
    a modal, where blocking is the message. The region is bounded as
    a backstop (max-block-size: min(25vh, 12rem); overflow: auto), so
    even a full report scrolls inside its own box and the data never gets
    less room than the diagnostics.

  • tests: the session-expiry dialog's axe scan emulates reduced
    motion (the #342 pattern) — it could sample the dialog mid-transition
    and report a colour-contrast violation that is gone once it settles.

  • behaviors: data-hc-close-popover-on-success /
    data-hc-close-dialog-on-success gained a nearest-carrier opt-out
    (="false"). A panel that edits itself — a sort control adding a key,
    a column chooser — issues successful requests from inside the carrier,
    and every one of them dismissed the panel the user was still working
    in. The nearest carrier now wins, so an inner region can opt its own
    round trips out while Apply still closes the panel.

  • docs: two defects in the data-grid page template's filter panel.
    Its Reset was <button type="reset">, which restores the values
    the server rendered into the controls — after an apply plus a tweak,
    the modified state: the one control promising to undo the tweak was
    the one guaranteed not to. It is now a link to the view's own URL, as
    the saved-views contract now says explicitly. The Modified badge
    also rendered unconditionally next to a view select showing "—";
    it now lives on the view menu's label, where the comparison it reports
    actually happens. The condition chips carried
    popovertarget="…-filters" pointing at a <dialog> with no popover
    attribute, so clicking a chip did nothing at all — they open the panel
    now.

  • recipes: relative date expressions handle period offsets and
    stop overflowing. @month-end-1m — "end of last month", the phrase a
    business user actually reaches for — was rejected outright: offsets
    were only parsed from today and the -start anchors. Offsets now
    work from any anchor, and on a period anchor they shift the period
    and then take the boundary, so @month-end-1m is the end of the month
    a month back rather than this month's end minus a month — the same
    thing in August, not in March. Month and year arithmetic also
    clamps instead of rolling over: @today-1m evaluated on 31 March
    answered 3 March, so a filter asking for "the last month" quietly
    covered a month it was never asked for. It now answers 28 February
    (29 February in a leap year), and @today-1y on 29 February answers
    28 February. Two presets are added for the common case.

  • docs: the data-grid-page template scrolled its chrome
    horizontally
    . The layout rule documented min-block-size: 0 — the
    block-axis half of the trap — and missed its inline twin. A flex item
    will not shrink below its content on either axis, and the datagrid's
    table is inline-size: max-content, so the page column grew to the
    table's width and hc-shell__main became the horizontal scrollport:
    scrolling right dragged the title and the toolbar along, which is
    exactly what the grid's own scrollport exists to prevent (measured:
    542 px of overflow on hc-shell__main at a 1280 px viewport, and the
    grid never scrolled horizontally at all). Both minimums are now
    documented per axis, in the template and its demo. The template also
    restores a 70vh cap below hc-shell's 60rem breakpoint, where the
    shell deliberately becomes an ordinary scrolling page and 100% stops
    capping anything — without it the grid rendered every row at full
    height. A new datagrid-app-page browser fixture and spec pin the
    composition on both axes; removing either minimum fails them.

  • dialog: a dialog taller than the viewport now scrolls its
    body, not its header and footer. .hc-dialog had no column
    layout and no scrolling body, so an over-tall dialog scrolled as a
    whole under the browser's height cap — the title left the top of the
    screen and the footer, where the primary action lives, left the
    bottom (measured: at a 460 px viewport the Apply button sat at
    y = 604). An open dialog is now a flex column with an overflow: auto body, and the column passes through a form wrapping
    header/body/footer — the usual shape when the footer's button submits
    the body's fields, as in a filter panel. The rule is scoped to
    [open]: an unscoped display would beat the UA's
    dialog:not([open]) { display: none } and reveal every closed dialog.
    Markup is unchanged.

  • recipes: the atomic branch of datagrid-bulk-errors now marks
    the blocked rows
    . The rule was "never mark in the atomic branch —
    nothing changed, so marking would lie", which conflated two different
    claims: a failure would indeed be a lie, but data-attention="error"
    says "this row cannot proceed", and that is a fact about the row,
    equally true in the pre-flight, in the 409 refusal and after a
    best-effort failure. It does not go stale when the selection changes
    either ("already shipped" stays true whether or not the row is
    ticked). Without it, the report's row links landed on a row that
    looked like every other row. The pre-flight answers a report rather
    than rows, so it carries the marks as <template>-wrapped
    out-of-band row updates, rendered checked so the selection the user
    is about to act on survives. Row statuses are still never changed
    by the atomic branch. Documented alongside it: data-attention takes
    its severity from what the row needs — error when something must
    change (missing required value, invalid input, wrong state, no
    permission), warning when someone must decide (a future ship
    date) — not from when it was discovered, or the same unchanged row
    would read warning before an action and error after it.

  • recipes: the bulk retry copy no longer says "The other 1 need a
    change first" (missing singular).

  • recipes: a partial bulk failure now leaves the retry set
    selected
    (plans/hc-datagrid-state-clarity-plan-en.md PR-2). The
    datagrid-bulk-errors best-effort branch re-rendered every row
    unchecked, so the actions bar (which hides at zero) disappeared the
    moment anything failed and the user had to hand-pick the failures out
    of a full grid to try again — the very rows the server had just
    identified. Failures the server judges retryable (transient: lock
    held, upstream timeout, rate limit) now come back checked, so
    pressing the same button retries exactly those; succeeded rows and
    permanent failures (wrong state, not permitted, invalid data) come
    back unchecked, because re-submitting either is pointless. The report
    says which is which — a partially-checked grid reads as a bug
    otherwise. Contract, recipe scaffolds, docs-site demo endpoint and
    browser fixture all carry the rule.

  • datagrid: states no longer erase each other
    (plans/hc-datagrid-state-clarity-plan-en.md PR-1). Every state
    painted the same property — a background-image gradient on the cell
    — so exactly one won, and which one was decided by an accidental mix
    of specificity and source order: hover erased a failure tint,
    selection erased a rejected cell's ring, and a row-level data-tone
    erased the selection tint entirely, leaving no feedback while the
    user selected failed rows to retry them. There is now one paint
    fed by --hc-datagrid-cell-tint, and each state merely assigns it in
    a documented priority ladder (data-tone → :hover →
    data-pending → data-highlight → data-in-range →
    aria-selected → :target → data-invalid), with every selector
    specificity-normalised via :where() so source order alone decides.
    Anything that must survive a tint moved to an attention channel
    that never touches the background: the new
    data-attention="error" | "warning" (row / record / head cell)
    draws an inline-start edge bar composed from a shadow channel, so it
    coexists with a freeze line; the rejected cell's ring and corner flag
    now use --hc-datagrid-attention-error-bg (status.error.fg)
    instead of status.error.border, which was nearly invisible against
    the error tint it sat on. A selected failed row now reads as both.
    Failed rows in datagrid-bulk-errors and the conflicted row in
    datagrid-edit-conflict moved from data-tone="error" to
    data-attention="error" (data-tone keeps its meaning: the
    value is notable). Fragment navigation additionally accepts a
    cell id (#cell-101-ship-date), so a failure report can land on
    the offending column — which a wide grid otherwise hides — and
    data-attention on a header cell marks that column.

  • datagrid: cell state markers no longer change the column width.
    The table is max-content sized and cells do not wrap, so the
    saving spinner (shipped as an inline-block pseudo-element) widened
    its whole column — measured at 76 px → 121 px for one small inline
    addition — and would have been clipped away entirely in a
    data-resized column. The spinner, the new rejected-cell corner
    marker (data-invalid) and the per-cell required * are now
    absolutely positioned in the cell's padding gutter at zero layout
    cost, and the datagrid-bulk-errors recipe no longer puts an inline
    "details" link inside a data cell (Back returns to the report, which
    the row link already put in history). The rule is documented for
    app-authored markers too.

  • docs / recipes: English-facing surfaces are English again. The
    datagrid-bulk-errors theme shipped its demo API, recipe scaffolds,
    server contract, English docs page and browser mocks with Japanese
    message text, so the shared live demo and the canonical English
    source format answered in Japanese for every reader. Also cleaned
    two older leaks — the edit-conflict and autosave expanded
    scaffolds glossed their buttons in Japanese. Japanese remains where
    it belongs: the /ja/ docs mirror, the i18n message catalogs and
    their examples, and the Japanese-specific features (kana
    normalization, postal addresses, non-ASCII header escaping tests).

  • datagrid: a row replaced while its editor was open (an SSE
    update, another user's change, a pager refresh) left the internal
    editing state pointing at a detached node — and since the keyboard
    handler returns early whenever an edit is in progress, the grid's
    keyboard navigation stopped responding
    until an edit was started
    and finished again; a later commit would also have written into a
    node no longer in the document. The behavior now drops the editing
    state when the edited cell leaves the grid, so navigation and
    editing resume on the swapped-in rows. Row-state-dependent
    editability makes this a routine path, not a corner case.

  • docs / recipes: the datagrid-infinite live demo chain-loaded
    all 15 rows before the reader could scroll. revealed measures the
    window viewport, so on a tall screen every renewed sentinel was
    already visible and each batch fired immediately — the demo showed a
    full table instead of infinite scrolling. The demo is now the
    container-scrolled variant (the grid keeps its scrollbar; sentinels
    trigger on intersect once root:<scroll> threshold:0.5, with the
    root threaded through the cursor URL so renewed sentinels keep it).
    The recipe's page-scroll contract is unchanged and now carries a
    container-scrolled carve-out documenting the trigger swap and
    both failure modes (deadlock and chain-load); hc validate accepts
    either trigger.

  • datagrid: hc:datagridedit now dispatches from the edited
    cell
    (bubbling through row → record → grid) instead of the grid
    element — per-record htmx wiring can finally hear only its own
    edits; grid-, body- and document-level listeners are unaffected by
    the bubble. Record-tbody swaps (the edit-errors contract's unit) now
    trigger the structural rebuild too: the behavior additionally
    observes the table's children, so swapped-in records get roles, the
    navigation matrix, and editing wired without a full grid swap.

  • datagrid: inline-edit lifecycle states
    (plans/hc-datagrid-edit-feedback-plan-en.md §1.1). With
    data-hc-datagrid-pending on the grid wrapper, a changed commit
    marks the edited cell data-pending + aria-busy (busy tint +
    spinner) until the server's row re-render replaces it — opt-in
    because it assumes the re-render contract. data-invalid
    (server-rendered in a 422 re-render) paints the rejected cell with
    an error ring + tone; .hc-datagrid__error-row /
    .hc-datagrid__error
    is the message slot directly under the row
    (grid roles applied, out of keyboard navigation, role="alert" on
    an inner element). Forced-colors fallbacks; reduced motion stops the
    spinner.

  • datagrid: auto-size and opt-in client page sort
    (plans/hc-datagrid-enrichment-plan-en.md §1.11). Double-click a
    column's resize grip (or press Enter while it has focus) to fit the
    column to its widest rendered cell — the committed width flows
    through the normal hc:datagridcolumnresize pipeline.
    data-sortable="client" sorts the already-rendered page rows in
    the DOM (numeric when both values parse, data-value preferred over
    cell text, locale compare otherwise) — allowed by the v0.6 depth
    plan for small fully-loaded tables; any htmx swap restores the
    server's order, and bare data-sortable stays server-instructed.

  • datagrid: column-preference persistence + the datagrid-prefs
    recipe (plans/hc-datagrid-enrichment-plan-en.md §1.10). Widths:
    installDatagrid() mirrors every committed resize into any
    input[data-hc-datagrid-width="<col>"] (grid's closest form, else
    document-wide) before dispatching hc:datagridcolumnresize, so a
    debounced event-triggered form autosaves the fresh value; the server
    renders remembered widths back as inline widths + data-resized.
    Order: the datagrid-columns chooser upgraded with
    installSortable() — checkbox serialization follows DOM order, and
    the datagrid-columns contract now honors the submitted cols=
    sequence as the column order (absent/unknown params unchanged).
    Machine-checked contract; live docs demo (en/ja).

  • datagrid: tree rows + the datagrid-tree recipe
    (plans/hc-datagrid-enrichment-plan-en.md §1.7). Rows carry
    aria-level; an expandable row carries aria-expanded + a
    data-hc-datagrid-tree lead-cell toggle, and the table upgrades to
    role="treegrid". A lazy row's first expand marks data-loaded,
    sets aria-busy, and dispatches hc:datagridtreeload — htmx
    GETs the children and inserts them afterend, one level deeper
    (empty answers the contract's single empty-state row). Collapse /
    re-expand of loaded subtrees is client-side visibility (collapsed
    children respected; hidden rows leave keyboard navigation) and emits
    hc:datagridtreetoggle { row, expanded }. Levels 2–4 indent
    via --hc-datagrid-indent. Machine-checked contract; live docs demo
    (en/ja).

  • datagrid: native validation gates the inline-edit commit
    (plans/hc-datagrid-enrichment-plan-en.md §1.9). An editor template
    control carrying required / pattern / min / max / maxlength
    must satisfy them before the value is written back — the editor stays
    open with the native reportValidity() message, no hc:datagridedit
    fires, and type-to-edit on another cell won't abandon an invalid
    editor. Escape still cancels; combobox picks bypass (options are
    valid by construction). No new attributes — the constraints are the
    API.

  • datagrid / table: declarative conditional formatting via
    data-tone="info | success | warning | error" on a cell, a row, or a
    record <tbody> (plans/hc-datagrid-enrichment-plan-en.md §1.8).
    The server evaluates the rules and renders the outcome as the
    attribute; the CSS paints it — datagrid via the new token-backed
    --hc-datagrid-tone-<tone>-bg/-fg (frozen-safe gradient), hc-table
    via the shared --hc-color-status-* semantic colors. Dark-theme
    aware; under forced colors the tint becomes a dotted outline.

  • datagrid: grouped rows — server-rendered, client-toggled
    (plans/hc-datagrid-enrichment-plan-en.md §1.6). The server
    interleaves .hc-datagrid__grouprow heading rows (one colspan cell
    with the label and any aggregates it renders); installDatagrid()
    toggles the group on click / Enter / Space via the heading cell's
    aria-expanded (render "false" to start collapsed), with a ▸/▾
    caret, data-group-level="1…3" nesting (collapse runs to the next
    same-or-higher heading; re-expanding keeps collapsed sub-groups
    collapsed), and hc:datagridgrouptoggle { row, expanded }.
    Headings join keyboard navigation but are not selectable units, and
    collapsing never changes the selection. Nothing is grouped or summed
    client-side.

  • datagrid: multi-column sort
    (plans/hc-datagrid-enrichment-plan-en.md §1.4). Shift+Click /
    Shift+Enter on a data-sortable header adds the column to the
    sort set (a plain activation stays single-column and clears the
    rest). With two or more sorted columns each header carries
    data-sort-index="1…n" and the indicator shows the ordinal (↑1,
    ↓2). hc:datagridsort detail gains sorts — the full ordered
    set as [{ col, direction }, …] (the existing col / direction
    fields are unchanged); the conventional wire format is
    ?sort=name,-price.

  • recipes: datagrid-filter — per-column filter entry for the
    datagrid (plans/hc-datagrid-enrichment-plan-en.md §1.5). A
    filter-popover off a header cell's trigger button GETs the grid URL
    with namespaced f-<col> params; the server re-renders the grid
    filtered (the trigger rides back inside the fragment, data-filtered

    • an aria-label naming the active values) plus an OOB re-render of
      the form's fieldset with matching checked states. Filters compose
      across columns via server-rendered hidden f-<col> inputs. Zero new
      JavaScript; machine-checked contract; live docs demo (en/ja).
  • datagrid: sticky aggregate footer and trailing frozen columns
    (plans/hc-datagrid-enrichment-plan-en.md §1.3). A
    <tfoot class="hc-datagrid__foot"> pins to the bottom of the scroll
    viewport (multi-row footers stack via the measured
    --hc-datagrid-foot-1-h), styled like the header band — the server
    renders the aggregates, the CSS only pins them. data-frozen-end /
    data-frozen-end-edge mirror data-frozen on the trailing edge
    (RTL-aware, per-cell --hc-datagrid-right measured by the behavior;
    new --hc-datagrid-freeze-end-shadow / --hc-datagrid-foot-shadow
    knobs). Footer cells carry grid roles but stay out of keyboard
    navigation.

  • datagrid: spreadsheet-style range selection and clipboard copy
    (plans/hc-datagrid-enrichment-plan-en.md §1.2). Shift+Arrow /
    Shift+Click extend a rectangular cell range from the active cell
    (cells carry data-in-range, painted with the selection tint;
    Escape or any plain move clears it, as does an htmx row swap).
    Ctrl/Cmd+C copies the range — or the active cell alone — as TSV
    after a cancelable hc:datagridcopy ({ text, rows, cols };
    preventDefault() claims the clipboard write). Ctrl/Cmd+A selects
    every row on the page through the select-all path instead of
    selecting the document.

  • datagrid: type-to-edit is now IME-safe. Composition keystrokes on
    an editable cell (isComposing, key Process, or keyCode 229) open
    the editor unseeded and without preventDefault(), and a
    compositionstart listener covers engines that fire it before any
    usable keydown — CJK input is no longer swallowed by the cell or
    corrupted into a raw latin seed character.

Full details in CHANGELOG.md.