Skip to content

Add a provider icon vocabulary for Components-owned glyphs - #313

Merged
woksin merged 5 commits into
mainfrom
feat/provider-icon-set
Sep 22, 2026
Merged

woksin merged 5 commits into
mainfrom
feat/provider-icon-set

Conversation

@woksin

@woksin woksin commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Components draws a glyph in 66 places; 6 of them could be replaced before this change (Dialog.closeIcon, TagGroup.removeIcon, Breadcrumbs.separator, and the per-instance icon on Message and on a queued toast). A stable part styles the element around a glyph and cannot replace its children, so Display/Chip's × and DataTableCore's / had no hook at all. Rather than add sixty more per-component props one release at a time, the provider — which already owns locale and every Components-owned label — now owns a product-wide icon vocabulary, with the per-component props kept as the single-call-site override.

This is a minor (additive provider option, unchanged defaults, unchanged props). No label applied, as requested.

Added

Review request — the vocabulary names

The mechanism is settled by the decision on #299; the names are not, and a name is harder to withdraw than to add, so this PR is for review rather than merge. Each name earns its place by being a concept more than one component draws, or one that has been asked for:

Name Draws Default Sites it serves (from the inventory)
close Dismiss this surface × Dialogs/DialogImplementation.tsx:303, Notifications/Toaster.tsx:193, Chat/ChatSidebar.tsx:345, Chat/Kit/Chat.tsx:262, PivotViewer/components/DetailPanel.tsx:115 — 5 sites
remove Remove this value × Common/TagGroup.tsx:118, Display/Chip.tsx:51, CommandForm/fields/ChipsField.tsx:123 — 3 sites. Chip is the inventory's clearest "consumer would want this and can't"
expand Open a closed surface ( in ComboBox) Dropdown/DropdownImplementation.tsx:305,528,674, Common/ComboBox.tsx:395 — 4 sites, 2 components, 2 different built-in glyphs
sortAscending Ascending sort DataTables/DataTableCore.tsx:516 — 1 component, named in the decision, and previously not even styleable
sortDescending Descending sort DataTables/DataTableCore.tsx:517 — same
previous Step backwards DataTables/TablePaginatorImplementation.tsx:76, Common/DatePickerInputImplementation.tsx:342 — 2 components, same glyph, same concept
next Step forwards DataTables/TablePaginatorImplementation.tsx:94, Common/DatePickerInputImplementation.tsx:366 — same
clear Clear the current value × Dropdown/DropdownImplementation.tsx:326,463,549,696, Filter/FilterPanel.tsx:408 — 5 sites. Deliberately not merged into close: dismissing a surface and emptying a value are different concepts that happen to share a glyph today
busy Busy/loading mark spinner ring ( in a toast) Display/ProgressSpinner.tsx, Notifications/Toaster.tsx:173 — the decision's "busy mark"; the spinner's shape was previously unreachable

Open questions for the maintainer:

  1. expand vs chevron vs expandCollapse. expand names the affordance, not the shape, which is why the same name covers Dropdown's and ComboBox's . If the preferred convention is to name the mark rather than the action, say so before this ships.
  2. clear vs folding it into remove. Kept separate on purpose (see the table). Three names currently default to ×; a product registering one icon for all three writes three lines.
  3. previous/next vs previousPage/nextPage. Kept generic so the paginator and the calendar share them. The paginator's «/» (first/last page) are not in this cut, so a product re-iconing pagination still meets two hard-coded glyphs — deliberate, since no second component draws "first"/"last".
  4. busy vs spinner/loading. busy matches the existing data-busy attribute convention; loading matches data-loading and the toast's own loading flag.
  5. Plural grouping. messages groups by component (messages.dialog.close); icons is deliberately flat, because an icon concept is shared across components while a label belongs to one. Confirm this is the intended shape before the names become contract.

Verification

  • Source: yarn ci — 445 spec files, 1660 tests, green. node scripts/verify-api-docs.mjs — green.
  • Storybook: yarn build && yarn verify-indexes && yarn test-storybook — 1704 story/appearance/axe cases, green. Story counts are unchanged (327 stories / 74 docs), so verify-storybook-indexes.mjs needed no edit.
  • Documentation/verify-markdown.sh — green.
  • Zero-change proof. A temporary harness rendered every wired component with no icons registeredDialog, two TagGroups (one with removeIcon), Chip, all three Dropdown branches with showClear, ComboBox, DataTableCore unsorted/ascending/descending, TablePaginator, ProgressSpinner, DatePickerInput with the calendar open, ChatSidebar, and a loading plus a success toast — and captured document.body.innerHTML at five points. The capture was taken with this change reverted and again with it applied: both are 90,983 bytes and sha256 21d7b0cd90cb12f33c17dea0b4cb268967882e5612c0a409e907d2ec86458bc0, diff clean. The markup contains every glyph under test (×, , , , , , , , the spinner <svg>), so the comparison is meaningful rather than vacuously equal. Common/for_CratisComponentsProvider/when_no_icons_are_registered.tsx keeps that property under CI.
  • Specs proven by reverting. With the implementation reverted (specs kept), 11 assertions fail across when_every_first_cut_icon_is_overridden, when_an_icon_prop_and_a_provider_icon_are_both_set ("should use the provider icon for a sibling site that sets no prop") and when_merging_icons ("should carry an element icon over by identity"). when_no_icons_are_registered passes in both states, which is exactly its job.

Not in this change

Every remaining inventory site keeps its glyph and stays unwired, because no second component draws the same concept: the paginator's «/», the date picker's calendar trigger, Checkbox/CheckboxField's , NumberInput's /+, PasswordField's /, RatingField's , ColumnFilterMenu's , Breadcrumbs.separator, Message/Toaster severity marks (they already take a per-instance icon), FilterPanel's and its CSS-border chevron (not a glyph), and the whole react-icons-based composite set — SchemaEditor (9), Chat/Chat/Kit (9 remaining), Canvas (5), TimeMachine (4), ObjectContentEditor/ObjectNavigationalBar (4), PivotViewer's toolbar (3). Those name product actions rather than generic affordances and are a separate scoping question, as the inventory notes.


Measured after review (maintainer notes)

Two review findings were folded in, and one of them turned out to be a win rather than a cost.

The configuration seam moved out of Common. Placing useCratisIcon in Source/Common/ made Chat, Filter and PivotViewer reach the Common area for the first time, and because per-area stylesheets are derived from each subpath's runtime import closure (#301), their sheets swallowed the whole of Common's CSS — Chat 6 656 → 9 642 gzip, Filter 3 584 → 6 651, PivotViewer 7 680 → 10 655, failing 31 checks. A CSS-free hook must not carry a stylesheet, so the shared seam now lives in Source/configuration/. Every per-area budget passes without any ceiling being raised, and two areas came out smaller than on main for the same reason the others had grown: Dialogs 6 582 → 2 972 and Notifications 4 988 → 1 099, because they no longer reach Common either.

The setup-only root allowlist. CratisComponentsIcons, CratisIconResolver and useCratisIcon are exported from the setup-only root barrel, which both the codemod and the ESLint rule gate with an allowlist that a spec holds identical across the two copies. They are on it now.

Gates after both: ESLint, Migrator and Source yarn ci (449 files / 1 672 specs), the packed archive with all 21 per-area sheets in budget, Storybook build, index attestation and the axe matrix — all green.

Still awaiting a decision on the vocabulary names before this merges.


Vocabulary reviewed: eight names, not nine

busy is out. It reads like a glyph and is not one: ProgressSpinner draws an svg carrying the svg, track and range parts, and a registered icon replacing it would silently delete three parts a product may already be styling. Where the parts contract and the icon vocabulary disagree, the parts contract wins — a consumer can restyle a parted subtree, but cannot get a deleted part back. The spinner is restored byte for byte against main, the loading toast keeps its own , and the name goes with them, since a vocabulary entry serving one glyph in one component has not earned a place.

The rule that falls out is now documented and pinned by the spec that guards the vocabulary:

The vocabulary replaces a glyph, never an element that publishes a part.

That matters beyond this PR: several of the inventory's 66 sites are parted subtrees rather than glyphs, and the same question will arrive with each of them.

The other eight are verified site by site to render the icon as a child of their parted element, so every part survives a registration. sortAscending and sortDescending serve one component each and are kept deliberately: they are the clearest case in the inventory of a glyph a product cannot reach at all today. clear stays separate from close despite sharing × today, because emptying a value and dismissing a surface are different concepts that need not share a shape forever.

One inconsistency is documented rather than fixed: expand has two built-in glyphs, on a Dropdown trigger and on a ComboBox trigger. Registering expand makes them consistent; registering nothing changes nothing. Unifying the defaults would move pixels for every consumer, which is a separate decision and should not ride along inside an additive change.

Gates after the revision: Source yarn ci (449 files / 1 670 specs), API-docs coverage, Migrator and ESLint ci, the packed archive with the aggregate and all 21 per-area sheets in budget with no ceiling raised, Storybook build, index attestation and the axe matrix — all green.

Components draws a glyph in sixty-six places, and six of them could be
replaced: Dialog's closeIcon, TagGroup's removeIcon, Breadcrumbs' separator,
and the per-instance icon on Message and on a queued toast. Everywhere else
the mark is a hard-coded child. A stable part styles the element that wraps a
glyph and cannot replace its children, so Display/Chip's remove sign and
DataTableCore's sort arrows offer a consumer no hook at all.

Adding one prop per site as each product meets each wall would mean sixty more
props released one at a time, and a product with its own iconography would
still pass dozens of props at dozens of call sites while missing the sites it
has not met yet. The provider is where a product already says what it is: it
owns locale and every Components-owned label, so it is where a product-wide
icon set belongs too.

CratisComponentsProvider now takes an icons map, resolved per site as
per-component prop, then provider icon, then built-in glyph - the precedence
messages already has for strings. A named prop addresses one call site and
keeps winning; the provider speaks for the whole product. The built-in glyph
stays at its call site and is deliberately absent from cratisDefaults, so a
consumer that registers nothing renders exactly the markup it renders today,
and a partial map leaves every name it omits alone.

This registers a first cut of the vocabulary - close, remove, expand,
sortAscending, sortDescending, previous, next, clear and busy - covering the
concepts that repeat across components rather than naming all sixty-six sites.
An icon name is public contract and harder to withdraw than to add, so the set
grows as products ask for a concept by name.

(#299)
Putting useCratisIcon in Source/Common/ made three per-area stylesheets
swallow the whole of Common's CSS. The per-area sheets that main now
publishes are derived, not written: a subpath's stylesheet is the union of
the stylesheets owned by every source directory that subpath's built
runtime import closure reaches, keyed on the first path segment
(Source/scripts/lib/area-stylesheets.mjs). Common/ owns nine stylesheets,
so an import of any module under Common/ puts all nine into the importer's
sheet - whether or not the module imported has a single rule of its own.

Chat, Filter and PivotViewer had reached no Common/ module before. One
import of a CSS-free hook took styles.Chat.css from 5864 to 9642 gzip
bytes, styles.Filter.css from 2742 to 6651, and styles.PivotViewer.css from
6840 to 10655, and the per-area budgets in verify-package-archive.mjs
failed - correctly. The budget was not the problem: a hook that draws
nothing must not decide what a consumer downloads, and if it may, the split
that just shipped stops meaning anything the first time a shared utility
lands in a component area.

So the shared, CSS-free configuration seam moves to its own directory:
Source/configuration/ holds CratisComponentsContext, cratisDefaults and
useCratisIcon, the three modules a consuming component imports at runtime.
CratisComponentsProvider stays in Common/ with the configuration types it
declares, which reach configuration/ only through erased `import type`
edges. The directory follows the rule types/ and renderer/ already follow -
cross-cutting code that owns no CSS lives outside every area that does -
and its README states it for whoever adds the next shared utility.

The three sheets return to their pre-PR composition and byte size, with no
budget number raised. Two sheets also got smaller than on main:
styles.Dialogs.css 6582 -> 2972 and styles.Notifications.css 4988 -> 1099
gzip bytes. Dialogs and Notifications reached Common only through
CratisComponentsContext, and neither renders a Common component or uses a
Common-owned class, so that CSS was never theirs to ship. Their ceilings are
left where they are; only the recorded measurements are updated.

useCratisIcon and CratisIconResolver stay exported from the setup-only root
barrel exactly as before. The incidental re-export from ./Common is dropped:
the resolver is not a Common component, and the root barrel is where the PR
promises it.
The root barrel is setup-only, and both the codemod and the ESLint rule keep
an allowlist of the symbols a consumer may import from it; a spec asserts the
barrel's exports equal that list and that the two copies agree. The icons
map's type, the resolver and its hook are exported there deliberately, so
they belong on the list rather than the list needing a special case.
ProgressSpinner's ring looks like an icon and is not one: it is an svg
carrying the svg, track and range parts, so a registered icon replacing it
would delete three parts a product may already be styling. The parts
contract has to win that argument. The spinner is restored byte for byte,
the loading toast keeps its own mark, and busy leaves the vocabulary —
its only other site was that spinner, and a name serving one glyph in one
component has not earned a place.

The rule this draws is now in the documentation and in the spec that
guards it: the vocabulary replaces a glyph, never an element that
publishes a part. Several sites in the inventory are parted subtrees
rather than glyphs, so this will come up again.
@woksin woksin added the minor label Sep 22, 2026
@woksin
woksin merged commit 366dee8 into main Sep 22, 2026
50 of 51 checks passed
@woksin
woksin deleted the feat/provider-icon-set branch September 22, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant