Skip to content

「Pipeline by Stage」 uses a funnel mark on data whose late stages outnumber the early ones, so it renders as a bowtie spilling outside its card #48

Closed

Description

@zhuangjianguo

Found in the dogfood pass (#45) on main @ a7b7db5. Cosmetic, but it is on a dashboard an evaluator opens.

Symptom

The 法务工作台 widget 「Pipeline by Stage」 is authored as a funnel. A funnel encodes monotonic decline — each stage narrower than the last. The seeded corpus is not shaped that way: later stages hold more contracts than earlier ones (the demo book is mostly active / signed, with a thin draft / submitted head).

Rendered against that data the mark widens after narrowing — a bowtie — and spills outside its card's bounds.

Why it is worth a card rather than a shrug

Two costs, and the second is the real one:

  1. It looks broken, on the dashboard that is meant to be the legal team's home screen.
  2. A funnel asserts something false about the data. It says "contracts fall out at each stage". They do not: this is a snapshot of where a live book currently sits, not a conversion sequence. The mark is making a claim the numbers do not support — the same defect class as a mislabelled count, just drawn instead of written.

Fix — the mark, ⛔ not the data

A bar or column chart over the stage dimension says exactly what the data says and cannot bowtie. ⛔ Do not reshape the seed to make a funnel look right: that would bend the demo corpus to fit a chart, and the corpus is the honest thing here.

⚠️ If a funnel is genuinely wanted for the intake→signed conversion story, that is a different widget over a different measure (contracts entering vs leaving each stage over a window), not a restyle of this one — and it needs the stage-duration data that #31 is blocked on. ⛔ Do not attempt it under this card.

Also on the same widget, and it may share a cause

In a zh-CN console the funnel's category labels fall back to English, and it then orders alphabetically by the English label, losing stage order entirely. The label half is a platform gap already routed upstream (chart category labels from select options not resolving through the bundle on same-object dimensions). ⚠️ The ordering half may be ours: a mark whose order depends on the resolved label is fragile whichever language wins. Check whether an explicit stage order can be authored; if it can, do it here and the platform's label bug stops reordering the chart.

Related

#45 (the dogfood pass) · #29 / PR #30 (card 10, which authored the dashboards) · #31 (blocked — the stage-duration data a real conversion funnel would need)

Activity

  1. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Claim: PM loop round 5 (seat repo:hotclm). Dispatched by the PM seat for its dev; the dev inherits this claim and posts no second one.

    Session: session_01R3n3GGzobdegM4HUzah1iR
    Branch: claude/issue-48-pipeline-mark
    Worktree: hotclm-issue-48 (dedicated, off origin/main @ 2807b3b)
    Domain: repo:hotclm (single-lane repo — no domain:* axis)
    File surface: src/dashboards/legal.dashboard.ts · src/translations/{en,zh-CN}/** only if a widget string changes (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgement tier — the card turns on what a mark asserts about data, which is a judgement, ⛔ not a restyle.
    Clause-②: no — changing a widget's mark type neither relaxes an acceptance set nor widens a published surface.
    Thread-read: none (no comments on this card at claim time, 2026-09-10T07:2xZ)
    Serial constraints cleared: #47 (src/data/**, dealing legal_owner) is the only card in flight. Disjoint: this card owns src/dashboards/, that one owns src/data/. ⚠️ They are semantically adjacent — #47 changes the corpus this widget renders — but neither can break the other's diff, and this card's fix is chosen so it is correct at any stage distribution, which is the whole point (see below). Zero open PRs on the repo as of 2807b3b.

    Hot-file serial queue — why #46 is NOT in this batch

    #46 (README.md) is deliberately held behind #47, not merely unscheduled: #47's claim permits it to touch README.md's operator table if the dealing introduces a name an operator must create. Two branches editing that file is a conflict this seat can avoid by ordering, so #46 goes out once #47 lands. #44 stays held behind PR #40 for the same reason on DESIGN.md.

    ⚠️ The judgement in this card, restated because it is why it is not the floor tier: the defect is not that the chart looks wrong, it is that a funnel asserts monotonic decline and this data does not decline. So the fix must be correct at any distribution — ⛔ do not pick a mark that merely happens to look right against today's corpus, because #47 is about to change that corpus. ⛔ And do not reshape the seed to make a funnel valid: the corpus is the honest thing here.


    Generated by Claude Code

  2. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 48,
      "status": "done",
      "branch": "claude/issue-48-pipeline-mark",
      "pr": "https://github.com/objectstack-ai/hotclm/pull/57",
      "premise_still_valid": true,
      "summary": "The widget was authored as `type: 'funnel'` and the distribution is exactly as the card described, so the premise holds and the mark is now `type: 'horizontal-bar'` ordered by the measure (`options: { sortBy: 'contract_count', sortOrder: 'desc' }`, replacing `stageOrder`). A bar over the stage dimension cannot bowtie at any distribution, which was the requirement. ONE sub-claim of the card did NOT reproduce: the funnel did not spill outside its card — its ink sat 25px inside the card on every side at 1440/1280/1024/820px, with scrollWidth === clientWidth at each. It was wrong-shaped, not overflowing. The ordering question is answered and it is NOT authorable on our side: `options.stageOrder` is lowered onto every chart type as `categoryOrder` but only the funnel branch of console 17.4.0 reads it (measured in its code and in the browser — the widget as a bar with `stageOrder` still authored came out alphabetical), so lifecycle order cannot be authored for a bar and `stageOrder` was removed rather than left inert. Ordering by the MEASURE is authorable, and it is the robustness win the card was after: it cannot be reordered by any label in any locale. Only the widget's description string changed, so both bundles changed with it; no other file was touched.",
      "tests": "GATES (exit codes captured before any pipe, `cmd > file 2>&1; EXIT=$?`): pnpm validate → 0 · pnpm lint → 0 (21 warning(s), 5 suggestion(s), identical to base) · pnpm typecheck → 0 · pnpm lint:i18n-gate → 0 (LOCALES \"en\",\"zh-CN\"; REACH 12 surface classes, widget=38; COVERAGE 0 missing keys across 2 locales). BROWSER (pnpm demo on a clean database, port 3048, Chromium at /opt/pw-browsers/chromium-1194, admin@objectos.ai, Dashboards → Legal Workbench, four passes: before/after × en/zh-CN, screenshotted): BEFORE en — funnel in lifecycle order, band widths 115·138·138·92·69·692·692 px, i.e. narrow-wide-narrow then eleven times wider (the bowtie). BEFORE zh-CN — the same funnel with a giant Active band at the TOP: labels fell back to English AND stage order was lost entirely (Active·Approved·Draft·In Approval·In Review·Signing·Submitted, alphabetical by the English label) with `stageOrder` authored and ignored. AFTER en and AFTER zh-CN — byte-identical: order Active·In Review·Draft·In Approval·Submitted·Signing·Approved, bar widths 615·123·103·82·62·62·41 px, including the 6–6 Submitted/Signing tie landing the same way in both locales; request body carries `order: { contract_count: 'desc' }`. Console in every pass: one pre-existing aborted request (GET /api/v1/auth/organization/list → net::ERR_ABORTED, the `[AuthProvider] Failed to load organizations` warning), identical before and after. Boot carried only objectstack#17175 / #17176 and the background-seed continuation #17329. ABLATION on the kept `suppressWarnings`: removing it yields the same 21 warning(s) on this branch AND on base 2807b3b with the funnel — the rule does not fire either way; restored from HEAD and verified byte-identical (blob 7c072df…, `git diff HEAD` empty).",
      "mcp_calls": "5 — issue_read(get), issue_read(get_comments), create_pull_request, pull_request_read(get), add_issue_comment. No search call was made: the one candidate finding is platform-side, so nothing was to be filed on this repo.",
      "measured_stage_counts": {
        "source": "POST /api/v1/analytics/dataset/query on contract_metrics, dimension status, no filter — all ten statuses out of the running database, 120 contracts",
        "draft": 10, "submitted": 6, "in_review": 12, "in_approval": 8, "approved": 4,
        "signing": 6, "active": 60, "expired": 8, "terminated": 4, "cancelled": 2,
        "note": "Matches DESIGN.md §10's spread pinned by assertSpread, and the spread is UNCHANGED on origin/main @ d34b225 after #47/#55 landed — that card re-dealt which types carry in_review, not how many contracts sit in each status. The card says 'active / signed'; there is no `signed` status — signing is 6, active is 60."
      },
      "zh_CN_category_order": {
        "before": "Active · Approved · Draft · In Approval · In Review · Signing · Submitted — English labels (platform gap), alphabetical by that English label, stage order lost despite options.stageOrder",
        "after": "Active · In Review · Draft · In Approval · Submitted · Signing · Approved — English labels still (platform gap, untouched), order identical to the en console because it comes from the measure",
        "chrome_and_widget_strings": "zh-CN throughout: 预览版 / 仪表盘 / 法务工作台, widget title 各阶段合同数, the new Chinese description, measure axis 合同数. Only the category labels fall back to English."
      },
      "files_changed": [
        "src/dashboards/legal.dashboard.ts — type funnel → horizontal-bar; options.stageOrder → sortBy/sortOrder on the measure; description; the comment block rewritten to carry the measurements",
        "src/translations/en/app.ts — widgets.stage_funnel.description",
        "src/translations/zh-CN/app.ts — widgets.stage_funnel.description"
      ],
      "deviations": [
        "PR title is not the issue title verbatim: the issue title asserts 'spilling outside its card', which did not reproduce. Titled for the fix instead, and the deviation is stated in the PR body.",
        "The widget id stays `stage_funnel` although the mark is no longer a funnel: it is the key both bundles address the widget by and is not user-visible; renaming would move three files for no user-visible gain. Stated in the code and in the PR body.",
        "No changeset created (this repo has no changeset gate), per the dispatch."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: PLATFORM GAP — `dashboard.widgets[].options.stageOrder` is forwarded by the dashboard renderer to every chart type as `categoryOrder` but honoured only by the funnel branch (@objectstack/console 17.4.0), so an authored stage order is silently dropped on a bar/column widget. Belongs in objectstack-ai/objectstack per AGENTS.md 平台缺口 (report, never patch); this card owns no filing on that repo. 承接者: the PM seat / whoever routes the existing label-resolution gap upstream — it is the same widget and the same upstream tracker.",
        "noted, not filed: the #45 claim that the widget 'spills outside its card' does not reproduce on 2807b3b with console 17.4.0 (25px inset on every side at four viewport widths, no horizontal scroll). Corrected in the PR body. 承接者: this issue and #45 themselves.",
        "noted, not filed: `suppressWarnings: ['chart-config-missing']` on this widget is inert on this lint version — measured both before and after the change, the rule does not fire for this dataset-bound widget either way. Pre-existing, unaffected by this change, left alone rather than tidied under this card. 承接者: the next PR that touches src/dashboards/legal.dashboard.ts.",
        "noted, not filed: `GET /api/v1/auth/organization/list` is aborted (net::ERR_ABORTED) on every console load, producing the `[AuthProvider] Failed to load organizations` warning. Identical before and after this change and platform-side (console), not app metadata. 承接者: none — recorded here so the next dogfood pass does not re-discover it as new."
      ]
    }

    Generated by Claude Code

  3. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    ACCEPT — PR #57

    CI green on head 48581629 (two Validate runs, both success), mergeable_state: clean, base is current main (d34b225), diff is src/dashboards/legal.dashboard.ts + the two translation bundles — no governed surface — and this is the first round. Squash-merging.

    What I verified myself, beyond the report

    The report proves the behaviour of the two new metadata values in the browser. It does not establish that they are declared platform contract rather than options the renderer happens to honour — and on a repo whose whole gate story is "metadata mistakes fail silently at runtime", that distinction is the one worth checking. I pulled the published @objectstack/spec@17.4.0 tarball and read the dashboard-widget schema:

    what the PR authored schema at 17.4.0
    type: 'horizontal-bar' declared member of the widget chart-type enum ("horizontal-bar": "horizontal-bar")
    options.sortBy z.ZodOptional<z.ZodString>
    options.sortOrder z.ZodOptional<z.ZodEnum<{ desc, asc }>>

    Both new options are first-class declared fields, not passthrough on a $loose object. The mark and the ordering are on-contract.

    The same reading makes the upstream report stronger than the PR states it

    stageOrder sits in that same generic options object, one line below sortOrder:

    options: {
      dateGranularity: …
      sortBy:    z.ZodOptional<z.ZodString>
      sortOrder: z.ZodOptional<z.ZodEnum<{ desc, asc }>>
      limit:     z.ZodOptional<z.ZodNumber>
      stageOrder: z.ZodOptional<z.ZodArray<…>>       ← not gated to any chart type
    }
    

    So the platform does not merely fail to honour stageOrder on a bar — it declares it as a generic widget option, on every chart type, with no gate, while only the funnel branch of the console reads it. The schema advertises an affordance the renderer silently drops. That is a sharper report than "the bar branch ignores it", and it is the version I will file upstream. Your finding, my filing — the card correctly owned no issue on that repo.

    Accepted as written

    • The mark. "Cannot bowtie at any distribution" is the right standard and the right reason for it: the corpus is dealt by a fixture and re-dealt by other cards, so a mark that suits today's counts is a latent regression. Choosing the mark against the class of data rather than the current sample is the part I would have asked for if it were missing.
    • Removing stageOrder rather than leaving it inert. Correct. Metadata that does nothing is worse than absent — the next reader budgets trust for it.
    • Ordering by the measure. The zh-CN measurement is what carries this, and it is a genuinely better argument than the one the card made. The card suspected the label bug might disturb the order; you measured that it destroys it (stageOrder authored, order alphabetical by a label the author never wrote), and then chose an ordering that no label in any locale can touch. The byte-identical en/zh-CN result including the 6–6 tie is the proof.
    • Re-checking the "spills outside its card" claim instead of inheriting it. 25px inset on every side at four viewport widths, scrollWidth === clientWidth at each. That claim came from Dogfood pass on M3-complete main: drive the whole contract lifecycle in a browser and report what a real user hits #45's dogfood pass and nobody had re-measured it. Not reproducing it, correcting it in the PR body, and fixing the half that did reproduce is exactly the handling — a card is not a warrant.
    • The suppressWarnings ablation. Measured in both directions, on this branch and on base, and then left alone because it is pre-existing and off this card. Right call twice over: verified rather than assumed, and not tidied under an unrelated card.
    • Both deviations. The PR title not matching an issue title whose second clause is false, and the widget id staying stage_funnel — both stated, both correct. The id is the translation-bundle key and not user-visible; type one line below states the mark.

    One observation, filed separately — not charged to you

    Your own measurement surfaces something the card did not ask about. The widget's rationale, in the comment you rewrote, is:

    rejected, expired, terminated and cancelled are terminal outcomes, not rungs a contract climbs, and a chart of pipeline stages that lists them reads as if a contract were meant to reach them.

    By that same reasoning, active is also a terminal outcome — a contract that reaches active has left the pipeline; it does not climb onward. And it is 60 of the 74 contracts the filter admits, which is why the bar widths you measured are 615 · 123 · 103 · 82 · 62 · 62 · 41: one bar at full width and six between 41 and 123px. The mark is now honest, but the scale is dominated by the one category the widget's own stated rationale would exclude.

    That is a change to what the dashboard reports, not to how it draws — a different question from this card's, and widening #48 to cover it would have been wrong. It gets its own card with my recommendation. Nothing for you to do here.


    Generated by Claude Code

  4. zhuangjianguo commented on Sep 10, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Filed upstream: objectstack-ai/objectstack#17344.

    It carries three findings, not one — I went into the published @objectstack/console@17.4.0 and @objectstack/spec@17.4.0 to write it, and two of them were not visible from the app side:

    1. Funnel-only, as you measured. Confirmed first-hand in the charts bundle: if (h === \funnel`) { … let d = ce(c) … }is the sole read of thecategoryOrderprop; every other branch's textualc` is an unrelated local.
    2. The documentation names a chart type that does not exist. stageOrder's JSDoc and its .describe() string both say "funnel / pyramid". pyramid appears zero times in the spec's emitted types and zero times in the console bundle. So the one sentence an author reads before using the option names two chart types, one of which works and one of which is not real — and the plural framing is what makes an author conclude it applies to ordered marks generally.
    3. Your zh-CN mechanism was right, and the platform already knows about the hazard. The dashboard plugin builds the prop as
      Ue = stageOrder?.flatMap(e => { let t = String(e), n = K?.[r[0]]?.[t];
                                      return n && n !== t ? [t, n] : [t]; })
      — it emits both the stored value and its resolved display label, precisely because the chart sorts on String(row[xAxisKey]) and it cannot know which spelling the row will carry. That hedge exists because the match is label-shaped; it fails anyway once the two label sources disagree, which is what your zh-CN pass measured.

    One correction to my own reasoning while writing it, in case it matters to a future card: I initially thought the spec's promise that omitting stageOrder falls back to the dimension's picklist option order was also false. It is not — the dashboard plugin does derive that fallback from the field's picklist meta (categoryOrder: L(i)). The funnel branch's internal fallback (sort descending by measure) only applies when nothing supplies the prop at all. The old comment in legal.dashboard.ts that claimed the picklist fallback was correct on clm_contract.status was right; it just could not survive being on a bar.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions