Skip to content

finding(components): ui:context-menu never reads an item's authored icon — dropdown-menu's identical twin, left behind by #5930 #6278

Description

@yinlianghui-tw

Found while re-measuring the lucide gate's header parenthetical for #5992 (PR #6277). Filed unassigned; out of that card's fence — #5992 changes what the gate judges, this is a renderer repair.

Sub-issue of #5931, which already owns "catalog fixtures declare child icon keys the renderer never reads" for button-group, breadcrumb and command. This adds a fourth container to that set, and it is the one with a materially different answer available.

What was measured

At ef2a3bd8d, every untyped node carrying a string icon in the schema catalog was enumerated and grouped by its nearest typed ancestor, then each container's renderer was read to see what it does with the key:

container names what the renderer does with icon
dropdown-menu 3 resolveIcon(item.icon) — repaired by #5930
context-menu 4 never reads icon
button-group 8 never reads button.icon (#5931)
breadcrumb 3 never reads icon (#5931)
command 9 never reads icon (#5931)
timeline 4 raw text, <span>{item.icon}</span> — the authored names are emoji, so this is arguably correct as-is
tree-view 30 a two-valued literal switch, node.icon === 'folder' — never a name lookup

packages/components/src/renderers/overlay/context-menu.tsx has no reference to icon at all. Its renderContextMenuItems is line-for-line the shape dropdown-menu.tsx had before #5930, including the same submenu recursion:

if (item.type === 'separator') return <ContextMenuSeparator key={i} />;
if (item.type === 'label') return <ContextMenuLabel key={i}>{item.label}</ContextMenuLabel>;
if (item.children) { … <ContextMenuSubTrigger inset={item.inset}>{item.label}</ContextMenuSubTrigger> … }
return (
  <ContextMenuItem key={i} …>
    {item.label}
    {item.shortcut && <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>}
  </ContextMenuItem>
);

The four authored names live in examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json — copy, scissors, clipboard, trash. All four are live icons keys today, so nothing renders wrong; they simply render nothing.

Why this one is different from #5931's other three

For button-group/breadcrumb/command the open question is whether the key should exist at all. For context-menu it is not open: its twin was answered three weeks ago. #5930 decided the shape (route through resolveIcon, i.e. the RECORD surface; LazyIcon explicitly ruled out because it degrades an unknown name to the Database glyph, trading a no-icon failure for a wrong-icon one) and decided that repairing only the leaf arm and not the submenu-trigger arm would be a narrower version of the same bug. A context-menu repair is that diff again, in a file next door.

Note on what will and will not catch it

#5992/PR #6277 makes the gate judge icon names on untyped child items of a container that declares it reads them. context-menu will correctly not declare that while its renderer does not read the key — so the gate stays silent here by construction, and correctly so. When this card is repaired, the census entry should be added in the same PR: one line, 'context-menu': { paths: [], descendants: true, min: 1, resolver: … }, and the fixture's four names come under judgement.

References

Generated by Claude Code

Activity

  1. yinlianghui-tw commented on Aug 25, 2026

    @yinlianghui-tw
    CollaboratorAuthor

    Triage: pm:queue + domain:ui, type Bug — four authored icon names render nothing today, and unlike the parent #5931's other three containers this one's question is already answered: inherit the #5930 twin ruling with its reasons (route through resolveIcon, the RECORD surface; LazyIcon stays ruled out — it trades a no-icon failure for a wrong-icon one; repair BOTH the leaf arm and the submenu-trigger arm, since #5930 records that a leaf-only repair is a narrower version of the same bug). Add the census entry ('context-menu': { … }) in the same PR so the four fixture names come under the gate's judgement, per the card's own note. Only a real semantic difference from the dropdown-menu shape re-opens the ruling — bounce, don't improvise. Parent #5931 keeps the three genuinely-open containers; this card does not decide them.


    Generated by Claude Code

  2. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 25, 2026
  3. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round R1 (wave 3 — Bug-first, on the maintainer's standing order 「本车道应该优先处理bug」)
    Session: session_011SfZeFWrhGLHmfq61xbz4q
    Branch: claude/issue-6278-context-menu-icon
    Worktree: objectui-issue-6278
    Domain: domain:ui
    File surface: packages/components/src/renderers/overlay/context-menu.tsx + its tests (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — a renderer starts reading a key its authored fixtures already carry and its twin already honours. No contract schema, no accept set, no public surface.
    Serial constraints cleared — checked by file, ⛔ not by package name:

    裁决 (binding)

    Mirror #5930's repair, which decided this exact shape three weeks ago on this file's twin. Route the item's authored icon through resolveIcon — the RECORD surface.

    ⛔ LazyIcon is explicitly ruled out and this is not reopenable: it degrades an unknown name to the Database glyph, trading a no-icon failure for a wrong-icon one. #5930 made that call for dropdown-menu; the same reasoning governs here.

    ⭐ Repair BOTH arms — the leaf item AND the submenu trigger. #5930 also decided that fixing only the leaf arm and leaving the submenu-trigger arm is "a narrower version of the same bug". renderContextMenuItems carries the same submenu recursion dropdown-menu.tsx had before #5930.

    ⭐ Read #5930's diff before writing. This card's own framing is that the repair is "that diff again, in a file next door". ⛔ Do not reinvent the shape; port it.

    PM 机制假设 (verify — rebut freely; 实测优先)

    1. Card measured at ef2a3bd8d; main is now well past it. ⛔ Re-derive every line number, and re-confirm context-menu.tsx still has no reference to icon at all. ⭐ A zero-hit is only a reading once a known-present control has been probed on the same pathspec — probe something you know is there (shortcut, label) and quote both numbers.
    2. The four authored names live in examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json — copy, scissors, clipboard, trash. The card says all four are live icons keys today, so nothing renders wrong, they simply render nothing. Verify they still resolve — if any has been retired since, the fixture needs its own answer and that is worth reporting before you build.
    3. ⚠️ This is a sub-issue of Catalog fixtures declare child icon keys that button-group, breadcrumb and command never read — two of them are named with-icons.json and render none #5931, which owns the same shape for button-group, breadcrumb and command. ⛔ Those three are NOT in scope — for them the open question is whether the key should exist at all, and Catalog fixtures declare child icon keys that button-group, breadcrumb and command never read — two of them are named with-icons.json and render none #5931 is pm:blocked on that. This card is separable only because its twin was already answered. ⛔ Do not touch them, and ⛔ do not "generalise" the fix across containers.
    4. timeline (raw text, emoji) and tree-view (a two-valued literal switch) are deliberately different per the card's own table. ⛔ Not in scope.

    Verification

    ⭐ The assertion must distinguish the two worlds. Assert the rendered icon element for an item with an authored icon, ⛔ not "the menu renders" and ⛔ not that the label is present — both pass today. Prove it red before the fix, on both arms (leaf item and submenu trigger) as separate rows, so a leaf-only repair cannot read as complete.

    ⭐ Ablation: revert each arm alone, prove the mutation on disk in both directions (grep the injected text and separately the removed text — a mutation that silently no-ops while exiting 0 has already cost a run in this lane today), show only that arm's row go red. Restore under trap … EXIT INT TERM with a cwd-independent command; git diff HEAD --stat empty afterwards. Commit before you ablate.

    ⚠️ Trap species by name: ghost assertion (cannot fail in either world) · degenerate control (control reads the same as subject because the harness did nothing — a positive control is the only catch) · blind instrument (asserting a className rather than the resolved icon) · stale dist/ (a cached turbo green is not a measurement — use --force) · cross-test leakage.

    ⚠️ Repo facts: vitest from the repo root (pnpm exec vitest run PATHS) — ⛔ --filter / cd packages/x forms hit a guard (objectui#3378) that in one observed form silently ran the wrong package and reported green; sanity-check the reported file/test count against the target's own. vitest's console is silenced here — write results to a file. type-check is hyphenated. ⚠️ The full packages/components suite (190 files) has twice today failed to produce a result inside the container's ~10-minute foreground cap under contention — if you narrow, narrow on a measured population and declare it; ⛔ never report an unrun suite as green. Changeset: node scripts/check-changeset-presence.mjs; ⛔ never major.

    ⭐ Read-channel clause — CORRECTED this round, use the new order

    1. git first — free. Shallow clone: git fetch --deepen=500 origin before any merge-base/A...B work.
    2. ⭐ UNAUTHENTICATED REST — https://api.github.com/repos/objectstack-ai/objectui/issues/6278 and .../comments?per_page=100. Measured working today: HTTP 200, complete. The seat post's "every repo-scoped REST path is 403" holds only with a token — drop the auth header and it works. Wrapper: python3 /home/user/pmtools/gh_issue.py 6278, which now reads REST and prints a completeness verdict.
    3. ⛔ The rendered HTML page is FALLBACK ONLY and it silently truncates — measured today it served 15 of 31 timeline items while reporting no error, which is how a comment went missing and got misdiagnosed as lag. ⛔ Never treat a record count as complete without comparing it to a declared total.
    4. MCP/GraphQL for writes, plus the one dedup search before filing an out-of-scope finding (explicitly exempt — REST /search/issues is 403 through this proxy, so MCP search_issues is the only route; validate its zero-hit with a positive control).

    Deliverables


    Generated by Claude Code

  4. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Dev claim (os-dev seat, dispatched by the PM claim above).

    Scope taken exactly as ruled: mirror #5930 (resolveIcon, the RECORD surface; LazyIcon ruled out), both arms — leaf item and submenu trigger. Not touching button-group/breadcrumb/command (#5931, pm:blocked), timeline, tree-view, or the #5935 resolver consolidation.


    Generated by Claude Code

  5. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    os-dev-report

    {
      "issue": 6278,
      "status": "done",
      "branch": "claude/issue-6278-context-menu-icon",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6324",
      "premise_still_valid": true,
      "summary": "Ported #5930's repair from dropdown-menu.tsx into context-menu.tsx: the item's authored `icon` is resolved once per item through `resolveIcon` (the lucide RECORD surface) and rendered on BOTH arms — the leaf ContextMenuItem and the ContextMenuSubTrigger. LazyIcon stays ruled out. Added the `'context-menu'` census entry to scripts/check-lucide-icon-record-names.mjs (as the card's note asks) plus two pins in the gate's own suite, an `## Icons` section in the component docs, and a patch changeset. All three PM premises re-verified at 090927f4f and all three held. Two places I predicted wrong, both reported below.",
      "tests": "RED-BEFORE (measured with `grep -c icon` on the renderer reading 0): `pnpm exec vitest run packages/components/src/__tests__/context-menu-item-icon.test.tsx` -> 'Tests 3 failed | 5 passed (8)'; the two arms fail as SEPARATE rows — 'ContextMenuItem arm (leaf) > renders the resolved glyph for a live icon name' and 'ContextMenuSubTrigger arm (submenu) > renders the resolved glyph for a live icon name' — plus the fixture row. GREEN-AFTER: same command -> 'Tests 8 passed (8)'. ABLATION (3 arms, each committed-first, mutated via python with an anchor-count assertion that aborts on a zero hit, proved on disk in BOTH directions — injected marker grepped AND removed text grepped AND working blob hash compared against the HEAD blob — restored under `trap ... EXIT INT TERM` with cwd-independent `git -C <root> checkout HEAD -- <abs path>`; afterwards `git diff HEAD --stat` was 0 lines and both blob hashes equalled HEAD): leaf arm alone -> 2 red (leaf row + fixture row), submenu rows stayed GREEN; submenu-trigger arm alone -> EXACTLY 1 red (the submenu row), leaf and fixture stayed GREEN; census entry deleted -> gate stays exit 0 but judges 163 names instead of 167 (precisely the fixture's four) and the gate's own suite goes red on the two new pins. NO REBUILD was needed for the ablation and none is claimed: vitest resolves the mutated file from SOURCE (root vitest.config.mts aliases `@object-ui/components` -> packages/components/src, and the suite imports the renderer by relative path), so no `dist/` sits in the resolution path and there is no turbo cache to go stale. WRONG PREDICTION 1 (reported, not hidden): my first ablation round used a bare `/* MARKER */` inside JSX CHILDREN, which React renders as a TEXT NODE; that broke `getByText` and produced 5 red / 3 red instead of 2 / 1, and I had predicted 2 / 1. Those runs were artifacts of my instrument, not readings, and were discarded and re-run with a JSX-valid no-op `{/* MARKER */}`. WRONG PREDICTION 2: for the census arm I predicted gate exit 0; renaming the key instead gave exit 1 by tripping the gate's own `min: 1` non-vacuity rule ('the authored-node walk reached 0 of them'), which is a different world from 'entry absent' — re-run as a true deletion it does exit 0. FULL SUITE: `pnpm exec vitest run packages/components/` -> 'Test Files 188 passed (188) / Tests 1716 passed (1716)', exit 0 — I had predicted this would likely hit the container's ~10-minute cap (PM reported two such failures today) and it did not, so no narrowing was needed or declared. Also green: the twin's dropdown-menu-item-icon.test.tsx (no regression next door) and scripts/__tests__/check-lucide-icon-record-names.test.ts (39 tests). GATES, all re-run on the final commit ad54f7587 with exit codes captured BEFORE any pipe: check:icon-record-names exit 0 ('OK  lucide icon names: 167 authored/declared names reaching 8 record-reading resolvers are live `icons` keys'), check:control-bytes exit 0, check:doc-types exit 0, check:doc-fences exit 0, check:doc-snippets exit 0 ('Semantic phase: 267 of 267 block(s) judged, 0 failed'), check:vi-mock-specifiers exit 0, check-changeset-presence exit 0. NOT-MEASURED HANDLED: check:doc-snippets first exited 2 with its own banner 'PRECONDITION NOT MET (exit 2) ... This is I could not run, NOT I ran and found errors' — I built the 32 packages it needs (4m22s, 32/32 successful) and re-ran it to a real exit 0 rather than reporting the 2 as a failure. TYPE-CHECK: turbo type-check --filter=@object-ui/components -> 9/9 tasks successful; pnpm type-check:scripts exit 0. Checked rather than assumed: packages/components/tsconfig.json EXCLUDES src/__tests__ and **/*.test.tsx, so `--listFiles` on that program shows 0 hits for the new test file; the package script is `tsc --noEmit && tsc -p tsconfig.test.json` and `--listFiles` on the SECOND program shows 1 hit for the test file and 1 for the renderer, and tsconfig.scripts.json shows 1 hit for the edited gate test. LINT (not type-aware — no projectService/project in eslint.config.js, so this diff cannot move the verdict on any untouched file): @object-ui/components 402 files judged, 0 errors; lint:root 196 files judged, 0 errors; both edited scripts/ files and both edited packages/components/ files confirmed PRESENT in the judged populations via --format json. PREMISE CHECKS: `icon` in context-menu.tsx = 0 hits (case-insensitive 0) against positive controls shortcut=2 and label=12 on the same pathspec; all four authored names (copy, scissors, clipboard, trash) resolve against lucide-react 1.31.0's 1767-key record while `edit` is null (used as the live retired-spelling control); resolveIcon confirmed by reading packages/components/src/renderers/action/resolve-icon.ts, not by trusting the name. ISSUE-BODY SANITIZER CHECK: intact — 3966 bytes, 2 fence markers (balanced), all 11 angle-bracket fragments present, footer present, no truncation found.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6326: `ui:menubar` never reads an item's `icon` either — its items are typed `MenuItem[]` (the same interface, which declares `icon`) and the docs publish the key, but the renderer has 0 references to it across all three arms; invisible to #6278's census because no fixture authors the key, so no authored-name gate can ever see it",
        "filed as #6327: three near-identical menu-item recursions in renderers/overlay/ (dropdown-menu, context-menu, menubar) over the same item shape — the identical icon defect has now been diagnosed and repaired once per copy (#5930, #6278, still open in #6326); filed as an observation, NOT queued, because it has no pull yet and #5935 would rewrite the same lines first"
      ]
    }

    Generated by Claude Code

  6. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    ✅ ACCEPT — PR #6324. Both arms, LazyIcon verified absent, and my own verification probe was broken first.

    Reviewer of record: domain:ui @ objectui execution seat, PM session session_011SfZeFWrhGLHmfq61xbz4q (os-support-ai). Verified against the tree at ad54f7587, ⛔ not against the report.

    claim my reading (origin/main @ 864154e77 vs branch)
    the renderer never read icon main 0 → branch 10
    …and that zero is a reading control on main, same pathspec: shortcut = 2
    routed through resolveIcon 2 occurrences; resolveIcon(item.icon) called once at :49, above the arm split — as the twin does
    both arms ContextMenuSubTrigger :53-56 and ContextMenuItem :65
    census entry added ✅ in scripts/check-lucide-icon-record-names.mjs
    scope 6 files, +252/−2 — matches the declaration exactly

    ⭐ LazyIcon — I checked this specifically, because it is the one thing my ruling forbade. The grep returns 1 hit, which looked wrong. It is a comment explaining why the dynamic surface is deliberately not used — "it degrades an unknown name to the Database glyph, trading a no-icon failure for a WRONG-icon one" — citing #5622, #5633 and #5930. No import. That is better than silence: the ruling now lives at the site where the next reader will be tempted to undo it.

    ⛔ My verification probe was broken before it was right

    My first tree check reported icon = 0 on the branch and an empty diff — i.e. "this PR changes nothing". Cause: I ran git fetch origin <branch> and then git fetch origin main, and the second fetch overwrote FETCH_HEAD, so every reading labelled "branch" was actually main. Re-run against a named ref, everything above is what the branch really contains.

    ⭐ That is the same species this lane has paid for four times today — a probe that answers a different question than the one asked, and returns a confident number while doing it. It was caught only because "this PR changes nothing" contradicted a PR I had already read as 6 files / +252. ⛔ Had the expected answer been "0 changes", I would have shipped a false REWORK.

    The verification is exactly what the dispatch asked for

    ⭐ Two arms as separate rows, and proven independently reddenable — which is the whole reason I required separate rows:

    ablation result
    leaf arm alone 2 red (leaf row + fixture row); submenu rows stayed green
    submenu-trigger arm alone exactly 1 red (the submenu row); leaf + fixture stayed green
    census entry deleted gate exit 0 but judges 163 names instead of 167 — precisely the fixture's four — and the gate's own suite reds on the two new pins

    ⇒ A leaf-only repair could not have read as complete. Red-before 3 failed / 5 passed, green-after 8/8.

    ⭐ Three self-caught instrument defects — the most valuable content in the report

    1. A JSX marker that changed the measurement. The first ablation round used a bare /* MARKER */ inside JSX children, which React renders as a text node, breaking getByText and producing 5-red/3-red against a predicted 2/1. ⭐ Correctly called "artifacts of my instrument, not readings", discarded, and re-run with a JSX-valid {/* MARKER */}. ⛔ Reporting a 5/3 as a finding would have been plausible and wrong.
    2. Rename ≠ delete. For the census arm it predicted gate exit 0; renaming the key gave exit 1 by tripping the gate's own min: 1 non-vacuity rule — "a different world from 'entry absent'". Re-run as a true deletion, exit 0. That distinction is subtle and is exactly how an ablation quietly measures the wrong world.
    3. ⭐ An exit code that means "I could not run". check:doc-snippets first exited 2 with its own banner — "PRECONDITION NOT MET (exit 2) … This is 'I could not run', NOT 'I ran and found errors'". It built the 32 packages the gate needs (4m22s) and re-ran to a real exit 0, rather than reporting the 2 as either red or green. Both misreadings were available and it took neither.

    And it did not narrow when it did not need to. I warned twice that the full packages/components suite has hit the container's ~10-minute cap today; it predicted a cap failure, ran it anyway, and got 188 files / 1716 tests, exit 0 — so no narrowing was declared. ⛔ Pre-emptively narrowing on my warning would have been the easy, wrong move.

    Type-check hole checked rather than assumed: packages/components/tsconfig.json excludes tests, so --listFiles on that program shows 0 hits for the new test — the package script's second program (tsconfig.test.json) shows 1 hit for the test and 1 for the renderer. That is the difference between a green that covers the change and one that does not.

    Out-of-scope findings — and one deliberately not queued

    Landing

    Path face clean of governed surfaces (packages/**, scripts/**, content/docs/**, .changeset/**) → the queue is its route. Enqueuing once a by-name read shows every check green and mergeable_state: clean.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions