Bristlenose · mockup · signals sidebar

Signal rows: ten layouts, one width slider

The Signals list is the narrowest content in the app — a name plus a right-aligned badge, in a panel that must stay thin because Bristlenose competes for the screen with the product under test, the report and the client mail. Width alone can't fix it: the row label spans a wide range. Measured across the corpus: a section name runs 12 characters at the median, an elaborated signal name 23, and a theme label 21 at the median but 51 at its longest. The ikea, uxfriends and theme-label sets are real, read from the projects’ own databases; only "torture" is synthetic.

We have more depth than width. These variants spend the depth differently. Every panel is the real design system — the V0 rules are copied verbatim from sidebar.css.

Artefact — real pixels, product only Commentary — measurements and argument, never ships
Panel width
240px
Content
Appearance
Tags per row (V9)
Click target (V6 / V7)
click a name or a badge in V6 / V7

Width cannot fix the single-line row

Each V0 panel prints how many characters fit on one line at the current width, and what share of the 347 real labels in the local corpus that covers — 27 project databases, section names, theme labels and elaborated signal names together. Drag the slider and watch it:

width fits corpus covered
200px min12 chars23%
240px now18 chars51%
280px24 chars75%
320px30 chars86%
360px36 chars93%
480px max54 chars100%

Covering the corpus on one line costs the entire drag range. Not the default — the maximum, 480px, the widest a user can make the panel at all, on every lens, permanently. 360px still drops 7%. That is the whole argument for spending depth instead: there is no width worth paying that is also a width that works.

The tail has a specific cause, and it is not the LLM signal names. Those are capped — signal-elaboration.md says “Signal name MUST be 2–4 words” and the schema repeats it, and the corpus obeys: median 23, max 31. Theme labels have no number, only the adjective “concise” in thematic-grouping.md and ThemeGroupItem — and they run median 21, p90 41, max 51. The field with a number keeps to it; the field with an adjective does not. Bounding it is a one-line prompt change, but it would not settle the layout: theme labels are user-editable, so the row has to survive whatever someone types regardless.

The list is bounded and ranked — which makes depth cheap

Two facts about the shipped list change the arithmetic of everything above, and neither is visible from the sidebar itself.

It is capped at six per section. MAX_SIGNALS = 6 in AnalysisPage.tsx, applied as slice(0, MAX_SIGNALS) to the sentiment and tag lists — and the sidebar reads those same capped arrays, because setAnalysisSignals is handed the capped ones. So the panel is never more than twelve rows. Depth is not an unbounded scroll here; it is a bounded, computable worst case, which is a much weaker argument against wrapping than it first looks.

It is sorted strongest first. Both analysis modules sort composite_signal descending, and the client sorts again. Signal weakens top to bottom. The card shows its rank explicitly (.signal-rank, “#2”); the sidebar shows no number, so position is the only rank cue it has.

That is a real strike against the regrouping variants, and it lands hardest on the ones I liked most. V4 buckets rows by badge value and V6/V7 pair them by location — both reorder a list whose order is its ranking. Pairing puts a location at the rank of its strongest signal and silently drags the weaker one up with it; grouping abandons rank entirely. On the ikea sentiment set the four rows happen to pair adjacently so nothing moves, which is exactly the kind of luck that hides the problem. V9 inherits it, since it pairs the sentiment half. Any of these needs an answer to “what happened to the ranking?” before it ships — showing the rank number, or accepting that the sidebar is a directory rather than a ranked list.

And there are two caps, both of them the same kind. The meaningful one — “don't surface a card if the signal is too weak” — does not exist. MIN_QUOTES_PER_CELL = 2 is a volume floor, not a strength one, and confidence (strong / moderate / emerging) computes exactly that judgement, is passed to the client, and gates nothing. Measured across both axes of every real project, ranked by composite_signal, of the cards the sidebar surfaces: 2% strong, 5% moderate, 93% emerging — “emerging” meaning it failed both bars. In ten of eleven projects the navigation cap never binds at all, because fewer than six signals exist to cut.

And the binding constraint is participants, not quotes. Taking the one analysed project with real interview-length transcripts (20 sessions, 136k words, 9 participants, 484 quotes): 334 quotes carry a sentiment, landing in 58 populated cells at a median of 4 quotes each. 38 cells clear the “moderate” quote bar of ≥4. Only 10 clear its ≥3-participant bar, and exactly 1 clears “strong”'s ≥5. Quotes are not scarce; breadth of agreement is — which is what simpsons_neff exists to measure, and it is the right thing to be scarce.

That reprices the whole exercise. Scaled to a five-interview study, the same funnel yields something like two to four signals worth surfacing — not twenty, not even six. So a panel built to rank twelve is, on realistic data, showing three rows. Which makes depth very cheap and truncation very expensive: each row is a large share of everything the researcher gets. (Caveats: that project is oral history rather than usability, its quotes are overwhelmingly themed rather than sectioned, and one project is not a corpus. Also: an earlier version of this paragraph counted the section axis only and read 96% / 4% / 0%. The method was wrong; the conclusion was not.)

The shipped layout on a real project

rockclimbing · shipped is not a stress test. It is a screenshot: the twelve signals that project actually renders, 8 participants, 6 per section, with its real theme labels and its real elaborated signal names. At the 240px the panel now opens to, V0 ellipses 11 of those 12 rows. At 320px — well up the drag range — it still ellipses 11. V6 and V8 do the same, because they keep the badge on the name's line.

rockclimbing · long tail is the same project's longest real theme labels, the ones ranking happened not to surface: “Inclusion, Representation, and Gym Infrastructure” (49), “Training Structures and Progression Strategies” (46), “Footwork, Technique, and Movement Fundamentals” (46). Nothing invented, nothing padded. V0 ellipses 12 of 12. Its codebook rows carry that project's real groups and codes — Control and freedom with undo available, emergency exit, destructive action — so V9 is showing real open-vocabulary tags rather than sentiment words wearing a different hat.

These labels also carry punctuation the synthetic sets did not: an en dash in “The Physical–Mental Duality of Climbing”, a slash in “Unrelated / Out-of-Scope Content”, hyphenated compounds in “Long-Term” and “Out-of-Scope”. Each is a different break opportunity, and they are the reason a wrapping variant behaves better on this set than the character counts alone would predict.

Stress testing that is calibrated, not invented

Two sets push both axes at once, at measured percentiles rather than at made-up extremes:

The tag names are real, lifted from the corpus: subcommand-hierarchy-confusion, multi-terminal-context-switching, Jakob: convention expectation, destructive-action-protection. Measured over 620 tag names: median 14 characters, p90 23, max 32, and up to 11 per group. That is roughly double a sentiment word, in an open vocabulary, where sentiment is a closed set of seven running 5–12.

At stress · max, 240px: V0 and V8 clip 6 rows, V6 clips 5, and every variant that gives the name its own line clips nothing. V9 clips nothing and carries 9 distinct badges against V0's 3 — and note these sets deliberately model a project with three codebook groups, the case where the group name is at its most informative. It still loses three to one.

The cap is the whole design decision in V9

Uncapped, the largest real group puts eleven pills under one name. Measured at stress · max, 240px:

2     588px  6 of 28 codes shown
3     655px  9 of 28
5     770px  15 of 28
all   1036px  28 of 28

Uncapped nearly doubles the panel. Three per row costs 67px over two and shows half again as many codes, which is where I would start — but this is a call to make by looking, which is what the control is for.

The overflow indicator is not a new control. The string is the shipped analysis.more (“+{{count}} more”, already translated in all 21 locales) and the treatment is .cell-tooltip-footer from analysis.css, where it already means “there are N more than shown” — in this same lens, on the heatmap cell tooltip. The only invention here is placement: the shipped use is a footer line beneath a list, and a footer line per row is a line per row in a sidebar, so it rides inline at the end of the pill flow instead. If V9 ships, that treatment should be promoted to a shared atom rather than copied a third time — .cell-tooltip-footer is named for its only use site. The 2 / 3 / 5 / all buttons are a playground instrument, not a proposed affordance; the product would carry one cap, not a picker.

The pill is the shipped quote-card tag pill, and that is an argument rather than a style choice. The sentiment badges above are a partition: Quote.sentiment is “a single dominant sentiment, or None”, so those badges genuinely decompose the location and their counts sum. Codebook tags are a relationquote_tags is many-to-many, and analysis.py's own trade-off note says quotes tagged from several groups “count in each group column”. So the codebook badges are a cover, not a decomposition. Borrowing the quote card's pill — where badges have always meant “codes present on this thing” — lets the two sections claim different things without inventing an element to say so.

V8 is a different axis from the rest

V0–V7 argue about where the badge sits. V8 asks what it says, and keeps V0's geometry exactly. The commentary strip counts distinct badge values in the Codebooks section — the measure of whether that column carries information or merely repeats the heading above it:

V0 … V7  1 distinct badge
V8       3 distinct badges

One is not a column, it is a caption printed once per row. The codebook badge shows group_name — and measured across every real project in the corpus, 11 of 11 have exactly one substantive codebook group once Uncategorised is excluded. So that badge prints the same word on every row of every real project. pattern does not: 30 tension, 12 gap, 12 success, 8 recovery across 62 elaborated signals, and it varies inside a single project (project-ikea alone runs 5 tension, 1 gap, 1 success, 1 recovery).

V8 is strictly better than V0 on identical geometry — same height, one fewer truncation on the elaborated set, because “GAP” and “SUCCESS” are shorter than the group name they replace. And on the un-elaborated set it is shorter than V0 (−12px) with zero badges: no pattern exists yet, so nothing is drawn and the name takes the full width exactly when the name is a short location. Absence carries the information.

So V8 composes with the others rather than competing. The honest recommendation is V8's badge inside V1's wrapping — the badge stops lying, and the name stops being clipped. Nothing here measures that pair yet; it is the next panel to draw.

What the first run measured

Numbers below are from ikea · elaborated at 240px — the real content that ships once elaboration has run, at the width the panel now opens to. They are reproduced live in each panel's commentary strip, so a change to the CSS re-measures rather than re-asserting.

V0 is the only variant that loses words — 7 of 11 rows clipped at 240px, 11 of 11 at the 200px drag minimum. Its height never changes, because that is exactly what it is buying. Every other variant trades depth for the words back, and the question is only what the depth costs.

On the ikea set, just letting it wrap looks like the cheapest fix — and the Rockclimbing set says it is not enough. V1 costs +138px there and clips nothing, but on real Rockclimbing labels it clips 3 rows at 240px and 8 at 200px: the badge holds the first line, so the name gets roughly half the width for as long as it is sharing that line. Only the variants that give the name a line of its own — V2, V7, V9 — hold at zero across every set. That is a reversal of what this file said an hour earlier, and the real data is what reversed it. I also expected V4's regrouping to win, and it does not: at +159px it is taller than simply wrapping. The reason is visible in the panel — the Codebook-tags group has only one distinct badge value (“Sentiment”), so promoting it to a sub-heading removes six identical pills and adds a heading row, netting nothing. Grouping only pays where the column actually varies, which here is the Sentiment group and not the tag group. That is an argument for applying it per-group, not for the list as a whole.

V3 is nearly free on depth — −3px, because dropping the pill buys back most of what wrapping costs. It also reads best. It should not ship alone: colour as the sole carrier of sentiment fails a colour-vision read and needs a legend. It is here because rail + grouped sentiment is a real combination, where the sub-heading spells out what the rail merely echoes.

V2 and V5 both cost more than V1 for no additional words. V2 (+235px) is the most legible two-line row and the most expensive. V5's inline tail (+176px) forces a wrap wherever the badge doesn't fit after the last word, which on this content is most rows — it flatters short names and punishes long ones, which is the wrong way round for the set that has the problem.

Set the content picker to ikea · plain to see the other half of the bind: at 240px nothing truncates in any variant, so every one of these layouts is pure cost. The row is bimodal, and any layout chosen here is being chosen for the elaborated case while the plain case pays for it.

Lifting a winner into the app

Each variant shares one markup contract, so the shipped component only has to grow a class:

Whatever wins, turn “break long words” on and check the torture set. The shipped .signal-entry-name sets no overflow-wrap, which costs nothing while the name is ellipsed on one line and starts mattering the moment it is allowed to wrap. An unbreakable token — a compound identifier, a URL, a German noun — overflows the panel silently in every wrapping variant until that property is set.

The width slider stays useful after a variant wins — it's the check that the chosen layout still holds at the 200px drag minimum, which is where a user who wants their content back will put it.