Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

components: element:text_input's label / placeholder / description are the INVERSE of #5637 — the renderer already resolves the locale map, the declaration still says 'string', so the gate warns on a legal write #5717

Closed

Description

@os-zhuang

Found while implementing #5637 (element:record_picker's label / placeholder). Out of that card's fence — filed, not fixed there.

This is the same family, opposite direction. #5590 and #5637 are "the contract admits the map, the renderer drops it, so the declaration correctly stays narrow until the render site catches up". element:text_input is the case where the render site already caught up and the declaration never moved.

Measured

packages/components/src/renderers/basic/text-input.tsx resolves all three keys at their read sites:

 96:  const label = pickLocalized(props.label, language);
 97:  const placeholder = pickLocalized(props.placeholder, language);
 98:  const description = pickLocalized(props.description, language);

The contract for all three is the I18nLabel union — measured on the installed @objectstack/spec 17.1.0 pin, ElementTextInputPropsSchema:

label        union [string | record] | map safeParse: OK
placeholder  union [string | record] | map safeParse: OK
description  union [string | record] | map safeParse: OK

But the ComponentMeta entries still declare one arm each (text-input.tsx:146, :147, :183):

{ name: 'label', type: 'string', label: 'Label' },
{ name: 'placeholder', type: 'string', label: 'Placeholder' },
…
{ name: 'description', type: 'string', label: 'Description' },

Driven through the same manifestFromConfigs + validateTree path the console specimen test uses, with { en: 'Owner', 'zh-CN': '负责人' }:

PROBE label       declaredArms="string" codes=["type-mismatch :: <element:text_input> prop \"label\" expected a string"]
PROBE placeholder declaredArms="string" codes=["type-mismatch :: <element:text_input> prop \"placeholder\" expected a string"]
PROBE description declaredArms="string" codes=["type-mismatch :: <element:text_input> prop \"description\" expected a string"]

Why this is the defect and not a harmless narrowing

ComponentInput.type in packages/types/src/base.ts states the rule in both directions: declare an arm the renderer drops and you advertise a shape that never reaches the screen; withhold an arm the renderer resolves and the manifest gate reports type-mismatch on a legal write. Its own words for the second half — "one platform authority contradicting itself on the write it just recommended, at warning severity — which is worse than it sounds, because noise on legal writes trains authors (AI authors included) to dismiss the unknown-prop and type-mismatch reports that ARE real."

Here the author writes a map, the renderer renders it correctly in the viewer's language, and the gate warns anyway. That is precisely the direction #5590's changeset called "the false declaration in the other direction".

Not a duplicate of the defaultValue specimen

apps/console/src/__tests__/component-input-union-specimens.test.ts:214 asserts that an I18N_MAP on element:text_input.defaultValue does report type-mismatch, and that assertion is correct and must stay: defaultValue's spec type is string | number, a key whose contract has no object arm. It is the control that keeps the widening per-key. The three keys above are a different set with a different contract, and none of them is pinned anywhere today.

What a fix would decide

Mechanically it looks like the emptyText move run backwards: widen the three inputs arms to ['string', 'object'] and rewrite the descriptions from what the renderer does. Two things worth a triage look rather than assuming:

  1. Whether all three travel together, or description is separable — it has a different destination than the two label-ish keys.
  2. Whether the console specimen file should gain a pin for this block, since the specimens are where "declared arms == contract arms" is currently guarded, and a fix with no pin can silently regress the same way.

Filed unassigned and unqueued.

Activity

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

    @os-zhuang
    ContributorAuthor

    Triage: pm:queue, domain:ui, bug. Declaration lags the renderer (renderer resolves the locale map, ComponentMeta still declares 'string'), so the gate warns on a legal write — the exact inverse of #5637 and the direction ComponentInput.type's own rule already condemns. Restores declared = enforced; no contract change (spec 17.1.0 already admits the map on all three keys, measured in the card). Scope: widen the three arms to ['string','object'], rewrite descriptions from what the renderer does, and answer the card's two look-items in the PR: whether description travels with the two label-ish keys, and add the console specimen pin for this block so it cannot silently regress. Note: #5637 (the inverse direction, record_picker) sits in the decision box — do not touch its keys here.


    Generated by Claude Code

  3. self-assigned this
    on Aug 23, 2026
  4. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 19
    Session: session_01EuPCi56cnGyykygi3z9w4m
    Branch: claude/issue-5717-text-input-declared-arms
    Worktree: objectui-issue-5717
    Domain: domain:ui
    File surface: packages/components/src/renderers/basic/text-input.tsx and its tests, plus apps/console/src/__tests__/component-input-union-specimens.test.ts for the pin triage requires (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus
    Clause-②: no
    Serial constraints cleared: packages/components is free — #5714 merged 20:43Z. Round-19 siblings fenced disjoint: #5703 (packages/auth/src/createAuthenticatedFetch.ts) and #5660 (apps/console/index.html + src/main.tsx). ⚠️ #5660 also lives in apps/console but touches different files; the specimen test is yours, index.html/main.tsx are its.

    Tier basis. No path-derived mandate. Content judgement: clause ② does not trip — triage measured that spec 17.1.0 already admits the map on all three keys, so widening the declaration restores declared = enforced rather than widening any accept set. This is the mirror of #5637, which cleared for the same reason.

    Premise re-verified on origin/main d8afbe519, every line reproduced exactly as the card cites it:

    • text-input.tsx:96 / :97 / :98 — pickLocalized(props.label|placeholder|description, language). The renderer already resolves all three.
    • :146 / :147 / :183 — { name: 'label'|'placeholder'|'description', type: 'string', … }. The declaration still says one arm.

    This is the inverse of #5637 and the direction ComponentInput.type's own rule condemns: the author writes a map, the renderer renders it correctly in the viewer's language, and the manifest gate warns type-mismatch anyway. Its own words for why that matters: "one platform authority contradicting itself on the write it just recommended, at warning severity — which is worse than it sounds, because noise on legal writes trains authors (AI authors included) to dismiss the unknown-prop and type-mismatch reports that ARE real."

    Triage's ruling, binding, with two items it requires answered in the PR: widen the three arms to ['string','object'], rewrite the descriptions from what the renderer does, and (1) state whether description travels with the two label-ish keys or is separable, (2) add the console specimen pin for this block so it cannot silently regress.

    ⛔ Do not touch element:record_picker's keys — #5637 (the inverse direction) sits in the decision box. ⛔ Do not weaken component-input-union-specimens.test.ts:214, which asserts an I18N_MAP on element:text_input.defaultValue does report type-mismatch. That assertion is correct and must stay: defaultValue's spec type is string | number, a contract with no object arm. It is the control that keeps this widening per-key rather than blanket — if your change makes it go green, you have widened too far.


    Generated by Claude Code

  5. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report

    {
      "issue": 5717,
      "status": "done",
      "branch": "claude/issue-5717-text-input-declared-arms",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5736",
      "premise_still_valid": true,
      "card_body_complete": "Yes — read in full, ends with 'Filed unassigned and unqueued.' Not truncated. Both comments read: triage first-touch (binding) and the round-19 claim.",
      "summary": "Widened the three declared arms on element:text_input to ['string','object'] and wrote descriptions for all three from what the renderer does. Every PM measurement re-measured and reproduced exactly on origin/main d8afbe519: renderer resolves all three at :96/:97/:98, declaration says 'string' at :146/:147/:183, and spec 17.1.0 admits string|record on all three (defaultValue: string|number, no object arm). Added the console specimen pin triage required, spec-derived trio assertions in the components parity test, and a new renderer read-site pin. No renderer behaviour changed.",
      "gates_run": [
        "pnpm --filter @object-ui/components --filter @object-ui/console type-check -> GREEN ('packages/components type-check: Done', 'apps/console type-check: Done'; script names echoed, so not a zero-match silent pass)",
        "pnpm --filter @object-ui/components --filter @object-ui/console lint -> GREEN ('0 errors, 910 warnings' / '0 errors, 202 warnings'; lint.yml sets no --max-warnings, this gate is about errors)",
        "vitest run packages/components/ -> GREEN ('Test Files 183 passed (183)', 'Tests 1654 passed (1654)')",
        "vitest run apps/console/ -> GREEN ('Test Files 72 passed (72)', 'Tests 790 passed (790)')",
        "node scripts/check-changeset-presence.mjs -> GREEN ('4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)')",
        "node scripts/check-changeset-fixed.mjs -> GREEN (exit 0)",
        "node scripts/check-changeset-no-major.mjs -> GREEN (exit 0)",
        "node scripts/check-control-bytes.mjs -> GREEN ('OK (scanned 4784 tracked text file(s); skipped 85 binary)')",
        "node scripts/check-doc-component-types.mjs -> GREEN ('Every documented component type is registered.')",
        "pnpm check:i18n-keys -> GREEN ('Every in-scope call-site key resolves against the en pack (2924 keys)')",
        "pnpm check:spec-symbols -> GREEN ('1295 files scanned against 4966 spec export names')"
      ],
      "gate_derivation_note": "dispatch-gates.mjs deliberately NOT used (objectstack-only; objectui has no scripts/pm). Set derived from objectui's own .github/workflows/ (ci.yml, lint.yml, control-bytes.yml, doc-component-types.yml, changeset-presence.yml) plus root package.json.",
      "union_head_sha": "9711f5ca8",
      "tests": "Gate measured through manifestFromConfigs + validateTree, the same path the card and the specimen test use — a type-level assertion cannot see this defect. BEFORE (d8afbe519): label/placeholder/description each declaredArms=\"string\" with one type-mismatch diagnostic apiece on { en: 'Owner', 'zh-CN': '负责人' }. AFTER: all three declaredArms=[\"string\",\"object\"] with diags=[] individually and on one whole node. ABLATION A (revert the three arms to 'string'): mutation confirmed on disk by anchored counts 3->0 widened / 0->3 narrow before the run; predicted red on the declaration assertions and green on the renderer pin; observed exactly that — 7 failed / 34 passed, the seven being the four new specimen cases and the three parity it.each arms, with text-input-i18n-label-arms.test.tsx entirely green. ABLATION B (drop pickLocalized at the three read sites): mutation confirmed on disk, 3 read sites replaced and markers counted; predicted red on the renderer pin only, failure mode a throw for the two React-child positions; observed 6 failed / 35 passed, all six in the renderer pin, mode 'Error: Objects are not valid as a React child (found: object with keys {en, zh-CN})', both declaration files green. NO REBUILD NEEDED and this was checked rather than assumed: the root vitest config aliases @object-ui/* to sibling src/, so tests run against source and no dist participates. Both legs restored via trap ... EXIT INT TERM; working tree verified byte-identical to HEAD afterwards.",
      "look_item_1_does_description_travel_with_label_and_placeholder": "YES — all three travel together, and the card's premise about the destination is correct but not load-bearing. The three destinations genuinely differ: label is a label element ABOVE the field (tied by htmlFor), placeholder is the native attribute INSIDE it, description is a p element BELOW it. Destination is not what decides an arm. ComponentInput.type names exactly two conditions — the contract accepts the shape AND the renderer resolves it — and description satisfies both identically to label: same pickLocalized call shape one line apart, same contract, each measured per key rather than inherited. Splitting on destination would have left description emitting type-mismatch on a value that renders correctly, i.e. this same defect kept alive on one third of it. The destinations are pinned anyway: one renderer case writes a DISTINCT map per key, because three identical maps cannot tell 'each key reached its own destination' from 'one value rendered three times'. One real behavioural difference does separate them and the arms cannot carry it — label is announced to assistive tech, description is not — so it went into the published description and became finding #5735.",
      "look_item_2_specimen_pin": "ADDED, in apps/console/src/__tests__/component-input-union-specimens.test.ts as its own describe, shaped like the neighbouring #4970 block: arms DERIVED from ElementTextInputPropsSchema's own verdicts rather than restated, so either side moving turns it red. Includes the derivation guard (a schema refusing every probe returns [] and would make the comparisons vacuous), a reachability case, per-arm controls on every specimen, a whole-node case reproducing the card's write, and a block-level assertion that the widening did not reach defaultValue. File grew 349 -> 550 lines.",
      "defaultValue_specimen_verdict_after_change": "UNCHANGED AND GREEN AS A FAILURE ASSERTION — an I18N_MAP on element:text_input.defaultValue still reports type-mismatch. Measured directly in the after-probe (declaredArms=[\"string\",\"number\"], diags non-empty) and green in every run of the file. Its arms did not move. Re-measured the contract independently: specAcceptedArms(ElementTextInputPropsSchema,'defaultValue') === ['string','number'], no object arm — so the separation is now asserted from the CONTRACT in both files rather than left as convention. I did not widen too far.",
      "controls_that_must_not_move": "Both honoured. defaultValue specimen untouched and green (run first, before any edit, and last). element:record_picker keys untouched — verified by diff: the only file changed under renderers/ is basic/text-input.tsx.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5735: element:text_input renders `description` as an unassociated paragraph — no aria-describedby, so assistive tech never announces it with the field, while packages/components/src/renderers/form/form.tsx wires the identical key correctly. Dedupe-searched first (semantic search plus an a11y-label listing, no open match). Filed unassigned, no labels, for PM triage.",
        "NOT filed, reported here instead — family sweep for the same defect class elsewhere came back EMPTY. Swept packages/** for a renderer resolving pickLocalized(props.X, ...) while declaring a single 'string' arm for X: no hits after this change. The instrument was controlled against the pre-fix file, where it returns exactly label, placeholder, description — so the empty result is a measurement, not a broken regex. The fence predicted hits were likely; they are not there."
      ],
      "files_changed": [
        "packages/components/src/renderers/basic/text-input.tsx (three arms widened, three descriptions written from renderer behaviour, block comment recording the inverse direction)",
        "apps/console/src/__tests__/component-input-union-specimens.test.ts (the pin triage required; +201 lines, no existing assertion weakened)",
        "packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts (spec-derived trio arms + description content assertions)",
        "packages/components/src/__tests__/text-input-i18n-label-arms.test.tsx (NEW — renderer read-site pin; the declaration now rests on 'the renderer resolves it' and nothing pinned that for these three keys)",
        ".changeset/text-input-i18n-label-arms-5717.md (patch, @object-ui/components)"
      ],
      "notes_for_pm": [
        "Branch cut from origin/main d8afbe519, not local main — local main was 9 commits behind, and d8afbe519 is the sha the PM measurements cite.",
        "skip-changeset NOT touched: that label does not exist in objectui and I did not create one. A real changeset is present.",
        "Sanitizer datapoint confirming the dispatch warning: the PR body's single angle-bracket span was eaten INSIDE BACKTICKS on create (`string | Record<string, string>` landed as `string | Record`). Reworded to remove angle brackets and re-verified. This report body contains no angle-bracket spans.",
        "Foreground cap hit once: a combined components+console vitest run was SIGTERMed at 10m (exit 143) after a 3m21s lock wait. Split into two acquisitions; both then completed and are reported above. No result was inferred from the killed run."
      ]
    }

    Generated by Claude Code

  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT (on merits) — PR #5736 · reviewer of record, domain:ui seat, round 19

    ⚠️ On merits; landing held. Gates by name at 02:41Z (PR started 02:38Z): 16 success + 3 skipped + 6 in_progress. ⛔ Not flipped ready until 19 + 3 is verified.

    Fence clean: 5 files — three in packages/components, the one granted apps/console specimen, one changeset. No governed path. The record_picker control holds: zero record-picker files in the diff, verified against the tree.

    Look-item 1 was answered with an argument, not a preference

    Yes, all three travel together — and the card's premise about destinations is correct but not load-bearing. The destinations really do differ (label is a label element above the field, tied by htmlFor; placeholder is the native attribute inside it; description is a p below it). But ComponentInput.type names exactly two conditions — the contract accepts the shape AND the renderer resolves it — and destination is not one of them. description satisfies both identically to label: same pickLocalized call shape one line apart, same contract, each measured per key rather than inherited.

    Splitting on destination would have left description emitting type-mismatch on a value that renders correctly — this same defect kept alive on one third of it.

    And the destinations are pinned anyway, with a control I want to name: one renderer case writes a distinct map per key, because three identical maps cannot tell "each key reached its own destination" from "one value rendered three times."

    One real behavioural difference does separate them, and the arms cannot carry it — label is announced to assistive tech, description is not. That went into the published description and was filed as #5735. A difference that exists but is not an arm difference, disposed of as documentation plus a card rather than smuggled into the type. Correct on both counts.

    The defaultValue control did better than I asked for

    I asked that it not break, and flagged it as the single most informative signal available. It is unchanged and green as a failure assertion — an I18N_MAP on element:text_input.defaultValue still reports type-mismatch, measured directly (declaredArms=["string","number"], diags non-empty), and run first before any edit and last.

    But it went further: it re-measured the contract independently — specAcceptedArms(ElementTextInputPropsSchema,'defaultValue') === ['string','number'], no object arm — so the separation is now asserted from the CONTRACT in both files rather than left as convention. I asked for a control to survive; what landed makes the control self-verifying. That is the difference between "we did not widen too far" and "widening too far now turns something red."

    The specimen pin checks its own instrument

    Arms are derived from ElementTextInputPropsSchema's own verdicts rather than restated, so either side moving turns it red. And it carries a derivation guard: a schema refusing every probe returns [], which would make the comparisons vacuous — so the vacuity check is applied to the instrument, not only to the assertions. Plus a reachability case, per-arm controls on every specimen, a whole-node case reproducing the card's write, and a block-level assertion that the widening did not reach defaultValue. 349 → 550 lines, no existing assertion weakened or re-pointed.

    Two ablations in orthogonal directions

    • A — revert the three arms to 'string': predicted red on the declaration assertions, green on the renderer pin. Observed exactly that: 7 failed / 34 passed, the seven being the four new specimen cases and the three parity arms, with the renderer pin entirely green.
    • B — drop pickLocalized at the three read sites: predicted red on the renderer pin only, failure mode a throw. Observed 6 failed / 35 passed, all six in the renderer pin, mode Objects are not valid as a React child (found: object with keys {en, zh-CN}), both declaration files green.

    Two ablations pointing in opposite directions separate "the declaration moved" from "the renderer resolves" as independent facts. A single ablation could not have.

    A new pin file exists because the fix creates a new dependency. text-input-i18n-label-arms.test.tsx was added on the reasoning that "the declaration now rests on 'the renderer resolves it' and nothing pinned that for these three keys." Noticing that a fix makes a previously-incidental fact load-bearing, and pinning it in the same change, is the thing that stops this defect class from recurring in the other direction.

    The family sweep came back empty, and that is a measurement. A sweep of packages/** for a renderer resolving pickLocalized(props.X, …) while declaring a single 'string' arm for X returns no hits after this change — and the instrument was controlled against the pre-fix file, where it returns exactly label, placeholder, description. So the empty result is a reading, not a broken regex.

    Sanitizer, confirmed again: one angle-bracket span inside backticks was eaten on PR-body create — a generic parameter list vanished, leaving a bare Record. Reworded to avoid angle brackets entirely and re-verified by read-back. Third independent confirmation that "backticks protect" is false.

    One foreground-cap kill (exit 143 at 10m, after a 3m21s lock wait) on a combined components+console run, split into two lock acquisitions and both completed. ⛔ No result was inferred from the killed run — stated explicitly, which is the part that matters.

    Landing blocked only on the by-name gate read.


    Generated by Claude Code

  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Gate release — PR #5736 enqueued.

    The landing hold stated in the ACCEPT is lifted. Gates read by name at 02:49Z, one call, no polling:

    19 success + 3 skipped = 22. Zero in_progress, zero failure.

    • success (19): Type Check · Lint · Test (shard 1/4) · Test (shard 2/4) · Test (shard 3/4) · Test (shard 4/4) · Build & E2E · Live E2E (informational) · Bundle Analysis · Build Docs · Doc Component Type Check · Doc Snippet Type Check · Internal Docs Link Check · Control Byte Scan · Skill Guide Path Check · Changeset Declaration · Changeset Bump Policy · Changeset Fixed Group Check · label
    • skipped (3, the standing no-ops): dependabot · Test (coverage) · the unexpanded Test (coverage shard ${{ matrix.shard }}/4) placeholder

    Shard window measured on this PR: started 02:38:06Z, last shard completed 02:48:42Z — 10m36s, the upper edge of this lane's three-round baseline of 8m54s–10m36s. That is why the read point is 11 minutes after the PR's own started_at, and why the 02:41Z read (16 success + 3 skipped + 6 in_progress) was correctly treated as not a gate reading.

    Fence re-confirmed against the tree before release — 5 files, nothing outside the granted surface, no governed path (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**), and the record_picker control holds at zero:

    .changeset/text-input-i18n-label-arms-5717.md
    apps/console/src/__tests__/component-input-union-specimens.test.ts
    packages/components/src/__tests__/text-input-i18n-label-arms.test.tsx
    packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts
    packages/components/src/renderers/basic/text-input.tsx
    

    Flipped ready → auto-merge (SQUASH). Landing verification against origin/main follows the merge.


    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

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions