Skip to content

Deliver icons as CSS, not as React - #48

Open
ariofrio wants to merge 18 commits into
mainfrom
ariofrio/ribbon-icons-css
Open

Deliver icons as CSS, not as React#48
ariofrio wants to merge 18 commits into
mainfrom
ariofrio/ribbon-icons-css

Conversation

@ariofrio

@ariofrio ariofrio commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Summary

Icons publishes every chosen icon as one stylesheet, keyed by an attribute a consumer puts on a box it draws itself. Ribbon sidebar draws its icons that way instead of over RPC, and Icons draws its own read-only placements that way too. React is left only where the icon opens the picker.

Nothing looks different.

Purpose

An icon was drawn by rendering it: one React portal per place, and any other plugin had to fetch it and draw its own SVG per row. That costs per row, and it puts React-owned nodes into DOM React does not own — which bb's foreign-DOM guard refuses, 68 times in one measured run.

Over 300 rows and 10 list changes:

approach observer wakes plugin work nodes
CSS mask 0 0 ms 600
DOM injection, no React 6 3.4 ms 1800
React portals (before) 11 34.2 ms 1800

Changes

The contract, in the Icons README as an additive-only extension point:

  • a consumer marks a box data-ribbon-icons-project="…" or -section="…"
  • Icons sets --ribbon-icons-{kind}-{glyph,color} on that element
  • documentElement carries data-ribbon-icons-ready once the sheet is in

Box, size and fallback belong to the consumer, and each kind carries its own properties — so precedence is whatever var() chain the consumer writes, and a later chain (a thread's icon before its project's) needs no version bump.

Icons publishes the sheet from its content script, independent of any placement, and gives its own read-only placements a marked span instead of a portal.

Ribbon sidebar consumes the contract, dropping the cross-plugin RPC, a mirror of the Icons response schema, a second copy of its colour palette, and a per-row SVG.

Unrelated, but it blocked npm test: heading-icons.mjs crashed on any directory beside the plugins without a package.json, such as an agent's gitignored .claude/.

Evidence

jsdom applies no stylesheet, so a harness boots the throwaway bb the screenshots use, seeds the same fixture, and reads getComputedStyle:

row              16x16, mask resolved, oklch(0.522 0.175 256)  (a picked blue)
group heading    16x16, mask resolved, oklch(0.522 0.175 256)
filter, project  15x15, mask resolved
filter, section  15x15, mask resolved, oklch(0.6 0.279 306)    (its own colour, not the project's)
menu, section    16x16, mask resolved  (portaled out of the plugin's tree)
Icons' own box   16x16, mask resolved
collapse         display:none, width 0  (with data-ribbon-icons-ready removed)

Both plugins and the root pass their checks; CI recaptures the screenshots.

Caveats

  • Picker surfaces keep their portals: a bounded handful, where delegating clicks buys no measurable time and re-anchoring Radix by hand would cost its focus and keyboard behavior.
  • -webkit-mask is emitted beside mask for the web client; bb's own Chromium needs neither.

🤖 Generated with Claude Code

ariofrio and others added 18 commits August 25, 2026 03:06
heading-icons.mjs read a package.json from every directory beside the plugins
and crashed on the first one that had none. Tooling leaves such directories
there — an agent's gitignored `.claude/`, for one — which took down both
`check:heading-icons` and `npm test` in a working tree that was otherwise
clean.

Carries over the test plugin-layout.mjs already makes for the same question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An icon was drawn by rendering it: one React portal per place, and a plugin
that wanted one had to fetch it and draw its own. Both cost per row, and both
put React-owned nodes into DOM React does not own — which is the thing bb's
foreign-DOM guard refuses, and which it refused 68 times in one measured run.

The glyph now travels as a custom property instead. The content script
publishes one stylesheet holding every icon anyone has picked, keyed by an
attribute a consumer puts on a box it draws itself, and the cascade delivers
it. That costs nothing per row — no scan, no observer, no node — so a list of
any length is the same work as one row.

This plugin's own placements read it the same way any other plugin's would.
Where bb has already given a row a job, the placeholder is a plain span the
stylesheet fills in; only where the icon has to answer a click and open the
picker is anything still rendered into it.

Measured over 300 rows and 10 list changes: 0 ms of plugin work per change
against 34.2 ms of React portals, and 600 nodes against 1,800.

The contract is documented in the README as an extension point. Nothing chains
one owner kind to another there — each carries its own properties, so the
`var()` chain a consumer writes is the whole of the precedence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidebar fetched project icons from the Icons plugin over an RPC that bb
relayed, mirrored that plugin's response shape in its own schemas, listened on
a broadcast channel for edits, held two maps of state, and rendered an SVG per
row. All of it is gone. A row names its project on its own box and the glyph
arrives through the cascade, so a list of any length costs what one row costs.

One stylesheet carries it, inserted once. It is plain CSS rather than classes
on the spans because the rule that collapses a row's icon has to name `:root`,
which is outside the scope root bb compiles a plugin's own stylesheet into.

Nothing looks different. A row still shows its project's icon, the filter still
falls back to a folder, and a sidebar without the Icons plugin still lays out
as it did before there were icons — the row boxes collapse rather than leaving
an empty gutter down the list.

Verified against a running bb rather than only in jsdom, which applies no
stylesheet: rows and filter rows resolve a mask, a size, and a picked colour,
and every row box collapses the moment the plugin's marker is taken off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-css

# Conflicts:
#	plugins/bb-plugin-thread-stages/src/app.tsx
#	plugins/bb-plugin-thread-stages/src/components/ThreadFilter.tsx
The same three figures had accreted across the README, the changeset, and six
comments: a list costing what one row costs, a thing being the whole of some
other thing, and a closing clause restating the sentence before it. Each one is
kept where it explains something and dropped everywhere else.

One of them was also wrong. The ready marker's comment said an early marker
would offer a picker that could not open; what it actually guards is a consumer
drawing empty boxes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-css

Only the two Icons screenshots conflicted; both sides had recaptured them, and
neither side's bytes are right for the merge. Takes main's and leaves the
correct ones to CI, which owns them.
…-css

#58 added a third consumer of the icon maps this branch deletes: the section
rows in a thread's own menu. It reads the contract like the other two now — the
row names its section, and the map, the prop threaded down to it, and the
fetched glyph all go.

That menu is portaled out of the plugin's tree by Radix, so it is the first
surface to test the claim that a global rule reaches anywhere. Measured in a
running bb: 16x16, mask resolved, and the section's own colour rather than its
project's.
…-css

#54 and this branch both added a test where the last one ended; both are kept.
…-css

Only screenshots conflicted; #36 recaptured them for the suite icon it added to
the README, and this branch had its own recapture. Takes main's and leaves the
correct ones to CI, which owns them.
The filter, the thread menu, and the menu's test each restated the mechanism
icon-styles.ts already documents. Each keeps a line saying why its span is
empty, and points there.
…-css

#49 moved the sidebar out of Thread stages and into Ribbon sidebar, taking the
fetch-based icon path with it: a copy of the deleted icons.ts, the same mirrored
schemas, the same maps, and a fourth consumer the old sidebar never had in the
group headings.

So the Thread stages half of this branch is dropped rather than resolved — every
file it edited is gone or no longer holds that code — and rebuilt on Ribbon
sidebar. The Icons half is untouched by the move.
The sidebar's module docstring restated the contract its README already
documents, three call-site comments described the same mechanism at three
different lengths, and two comments carried a clause restating the sentence
before them.

Also corrects the Icons README: the sidebar draws these icons on group
headings, in the scope filter and in a thread's menu, not only on rows.
A reader stopped to ask what the word meant, which is enough to lose it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant