Repository navigation
plugin-gantt: the task list is capped at 320px and its Start/End columns take 160px of that, so task names get 53px and truncate to ~7 characters while 864px of chart sits empty #7204
Description
Found in a browser pass over a real application's gantt view (objectstack-ai/duly#62). Filed unassigned, not claiming.
Measured on @objectstack/console 17.2.0 (objectui 190fbd01d061) in Chromium 1194. Re-checked against current main 84ffdbcbb33762b5488742fb902faf45a3742e93: both functions below are unchanged there.
Symptom
Every task name truncates to about seven characters. Two rows reading Emis… and Emis… with different dates are not distinguishable, which defeats the view.
Measurement
Same page, two viewport widths, read from the DOM (all values px):
| viewport | task-list panel | name cell | Start col | End col | open btn | chart area | title span client / needed |
|---|---|---|---|---|---|---|---|
| 1440 | 320 | 99 | 80 | 80 | 24 | 864 | 53 / 260 |
| 1920 | 320 | 99 | 80 | 80 | 24 | 1344 | 53 / 260 |
Site environmental audit — Northgate needs 260px and is given 53px. The panel does not grow with the viewport: at 1920 the chart gains 480px and the name column gains nothing.
Two contributors, both on current main
1. The default caps at 320px regardless of how much room exists.
function taskListWidthForContainer(width: number) {
if (width < 640) return 140;
if (width < 1024) return 220;
return 320;
}2. At that default, the Start/End sub-columns switch on and take 160 of the 320.
// Show the Start/End sub-columns only when the task list is wide enough that
// the title still has room. Below this threshold the title would collapse to
// a few pixels (issue: bars rendered but names invisible).
function showStartEndColumns(taskListWidth: number) {
return taskListWidth >= 280;
}This is the sharper half, and its own comment states the intent it inverts. The threshold protects the narrow cases (140 and 220) and then produces the very outcome it names at the default wide case: 320 clears the threshold by 40px, and the columns it admits cost 160px. The title is left with 53px — "a few pixels", by the comment's own standard.
Worth noting the 160px buys a second copy of information already on the row: each row renders 8/26 → 9/2 as a sublabel directly under the title.
Proof that width is the whole story
Dragging the splitter to 580px gives the title exactly the 260px it asked for, and every name becomes readable — same data, same page, same build:
Measured across that drag: panel 320 to 580, title client width 53 to 260, needed 260 throughout.
Why an application author cannot ship the fix
taskListWidth is only ever seeded from the persisted layout — that is, from a previous user drag in that browser's localStorage. There is no authorable path to it: the spec's GanttConfigSchema declares 19 keys (startDateField, endDateField, titleField, progressField, dependenciesField, colorField, parentField, typeField, baselineStartField, baselineEndField, groupByField, resourceView, assigneeField, effortField, capacity, tooltipFields, quickFilters, autoZoomToFilter, viewMode) and none of them is a width. So today the remedy exists per user, per browser, and cannot be shipped with the app.
The gantt block is a passthrough object, so an invented gantt.taskListWidth would pass author-time validation and be silently ignored. That is precisely why this is filed rather than authored around.
Reproduction
- Any gantt view whose
titleFieldholds real-world names (25 to 40 characters). - Load it at 1280px or wider.
- Every name truncates in the first two words while the chart area holds 850px or more of empty grid.
Suggested directions — maintainer's call, none proposed as decided
- A. Size the default from the container rather than capping it — a share of the width, clamped, so a 1920px screen spends some of its extra 480px on names.
- B. Raise the Start/End threshold so the two date columns appear only when the title genuinely has room after paying for them (their combined 160px plus a legible title, so roughly 440px, not 280px) — and consider whether they should default off at all, given the row already prints the same dates as a sublabel.
- C. Declare a width key on the gantt block so an author can ship a sensible default for their own data. Weakest on its own: it makes every author configure their way out of a bad default, but it is the one that also serves apps whose names are unusually long.
A and B are independent and both cheap; either alone lifts the title above the "few pixels" the comment already identifies as the failure.
Related: #7070 (gantt/timeline date-axis findings).


Activity
docs(views): record the two gantt rendering defects as the renderer's (…
os-warren commentedon Sep 1, 2026
Triage —
bug·domain:ui·priority:p2·pm:queueGraded by the
domain:uiseat (sessionsession_012wwHa4aaFybxXrfmfHioDM). Queued behind three running lanes; dispatched when one frees.Pre-ruling, so the dispatch is short when it goes out
The card offers A / B / C as a maintainer's call. Two of the three do not actually need one:
showStartEndColumnsreturnstaskListWidth >= 280, and its own comment says the threshold exists so that "below this threshold the title would collapse to a few pixels." At the 320px default the gate admits columns costing 160px and leaves the title 53px. The function produces the exact outcome its comment names as the thing it prevents. That is a self-refuting gate, and fixing it needs no product decision — the intent is written directly above the code.GanttConfigSchema, which the card confirms declares 19 keys and no width). That routes throughCONTRACT_REVIEW_TIER, whose quota is currently exhausted. Not dispatchable today regardless of its merits.So B ships alone, which the card's own closing sentence already permits: "A and B are independent and both cheap; either alone lifts the title above the 'few pixels'."
Raising the threshold means the Start/End columns disappear at the 320px default. That is only non-destructive if the dates remain on screen elsewhere. The card asserts they do — "each row renders
8/26 → 9/2as a sublabel directly under the title" — and that single sentence is the whole safety argument for B.It will be dispatched as an assumption to falsify, not as a finding. If the sublabel is conditional (on row height, a compact mode, a config key, or a missing end date), then B removes information rather than de-duplicating it, and the shape has to change. A measurement, not a reading, decides that.
Why the report is strong
The splitter drag is a genuine control, not an illustration: same data, same page, same build, panel 320 to 580, title client width 53 to 260, needed 260 throughout. That isolates width as the entire cause and rules out font, overflow rules, and the data itself in one move.
Also correctly identified: the
ganttblock is a passthrough object, so an inventedgantt.taskListWidthwould pass author-time validation and be silently ignored — which is why this was filed rather than worked around. That is the same silent-inert class as #7199.Generated by Claude Code
os-warren commentedon Sep 1, 2026
Claimed and dispatched —
domain:uiexecution seatsession_012wwHa4aaFybxXrfmfHioDMclaude/issue-7204-gantt-tasklist-widthAll agents share one GitHub identity, so the assignee field alone does not identify whose claim this is. If your session ID differs from the one above, this card is taken.
Dispatched as option B only, per the ruling already recorded in the triage comment above: the Start/End threshold is a defect repair, not a design choice, because
showStartEndColumnsproduces the exact outcome its own comment names as the thing it prevents. Option A (sizing from the container) stays the maintainer's call; option C is contract-widening and tier-blocked regardless.The one thing the lane is told to falsify before shipping
B's entire safety argument is a single sentence in the report — that each row already renders
8/26 → 9/2as a sublabel under the title, so removing the two date columns de-duplicates rather than deletes. That sentence is dispatched as an assumption to falsify, not a finding, with an explicit instruction to stop and report if the sublabel turns out to be conditional on row height, a compact mode, a config key, or a missing end date.This is not boilerplate caution. Twice this shift a lane has come back having falsified one of my premises — #7179's "single
$selectbuild site" (there are two, and a one-site fix would have shipped green and broken) and #7199's "descriptionis the only unrelayed key" (there are three). Both were caught only because the order required verifying the PM's readings, not just the card's. This card's premise is one unverified sentence carrying the whole change, so it gets the same treatment.Generated by Claude Code
os-warren commentedon Sep 1, 2026
os-dev-report
{
"issue": 7204,
"status": "needs_decision",
"branch": "claude/issue-7204-gantt-tasklist-width",
"pr": null,
"premise_still_valid": false,
"summary": "STOPPED on the falsified ZONE 2 premise; no code shipped, no PR. The card's whole safety sentence for option B — 'each row renders 8/26 → 9/2 as a sublabel directly under the title' — is CONDITIONAL, on viewport width. The sublabel span carries the class
gantt-sm-hidden, and GanttView injects its own style element containing@media (min-width: 640px) { .gantt-sm-hidden { display: none; } }. Measured in a real Chromium 1194 against the component's OWN rendered markup and its OWN injected style element: computed display isinlineat viewports 500 and 639 (client width 72.9px) andnone(client width 0) at 640, 800, 1024, 1440 and 1920. The sublabel is therefore display:none at exactly the viewports where the Start/End columns are painted and where the reporter measured the defect. Raising the threshold as ruled would DELETE a row's dates at every desktop width rather than de-duplicate them — precisely the stop-and-report condition in the order. Worse, the two gates already fail to cover each other: at a 640..1023 viewport the default task-list width is 220,showStartEndColumns(220)is false, and the sublabel is already display:none, so current main ships a whole band with NO dates on the row at all. Filed as #7224. Option B cannot ship in its ruled shape; the shape question is escalated below.","tests": "No implementation, so no before/after suite and NO ABLATION — an ablation with nothing to ablate would be theatre; stating that rather than inventing a colour. What I ran, all against origin/main 3561bd2 (worktree HEAD == origin/main, verified by
git rev-parse), all under the shared verify lock:\n\n(1) jsdom render probe —bash /home/user/objectstack/scripts/pm/os-verify-lock.sh -c 'pnpm vitest run packages/plugin-gantt/src/GanttView.probe7204.test.tsx', run from the REPO ROOT (neverpnpm --filterfrom a package dir). VERDICT line:command-exit 0 · held the lock 10s · waited 0s. Control:Test Files 1 passed (1) · Tests 3 passed (3)— 3 renders at container widths 1440 / 800 / 500, one file, matching what I wrote. Findings dumped to JSON (this repo's vitest config swallows console output, so the probe wrote files instead): at 1440 the row's 4 children are [title cell, start cell, end cell, open slot] with the two date cells NOT hidden; at 800 both date cells carryhiddenattr + inlinedisplay: noneAND the list header renders onlyTask Nameplus the open spacer (no Start/End captions); at 500 the pane width is0pxbecause the panel auto-collapses on first narrow render. Every render, at every width, contains exactly one sublabel span:class=\"text-[10px] text-muted-foreground gantt-sm-hidden\"with text8/26 → 9/2.\n\n(2) NOT MEASURED, declared: jsdom's owngetComputedStyle(sublabel).displayreturnednoneat 500, 800 AND 1440 alike — jsdom applies the media-query rule irrespective of innerWidth, so that reading is NOT a width-dependent measurement and I did not treat it as one. It is why the decisive measurement was moved to a real browser.\n\n(3) Real-browser measurement — Chromium 1194 from the preinstalled/opt/pw-browsers/chromium-1194(noplaywright install; the first attempt failed with MODULE_NOT_FOUND because the script sat outside the workspace — a resolution error, reported as unmeasured, not as a red — and was re-run from inside the worktree). It loads the markup the component itself emitted in (1), including the style element the component itself renders, and reads computed display at 7 viewports: 500inline/72.9px, 639inline/72.9px, 640none/0, 800none/0, 1024none/0, 1440none/0, 1920none/0. Control in the same evaluation:window.matchMedia('(min-width: 640px)').matchesflips false→true exactly at 640, and.gantt-sm-w20computes to 80px at every viewport at or above 640 — which is where the card's 80+80=160px comes from, measured rather than adopted. Scope of this measurement, stated honestly: the page carries the component's own injected CSS but not the prebuilt Tailwind sheet, so it settles the display question exactly (nothing but.gantt-sm-hiddensets display on that span, and that class appears nowhere else in the repo) and does NOT reproduce the absolute title-pixel widths.\n\n(4) Title width DERIVED from source, not copied: at a viewport at or above 640 with onTaskClick live, taskListWidth 320 minus px-4 padding (32) minus Start (80) minus End (80) minus open slot (w-6 24 + marginLeft 4 = 28) = 100px name cell; inside it, spacer 12 with marginLeft -4 (8 effective) + gap-2 8 + dot 8 + gap-2 8 = 32 of furniture, leaving 68px for a top-level row and 68 - 14*depth for an indented one, i.e. 54px at depth 1. The card's measured 99 / 53 lands within 1px of the derivation; I report the derivation, not their table.\n\nWorktree torn down after the run (rm -rf node_modulesthengit worktree remove, no --force, tree confirmed clean:git status --shortempty,git diff BASE --statempty). The branch was pushed empty at minute one as the write-route probe (push succeeded) and is left in place as the landing marker for the PM's claim comment; it holds no commits. No changeset — nothing user-visible shipped.","mcp_calls": "6 — issue_read get #7204, issue_read get_comments #7204, search_issues (dedup, one targeted call after a repo-scoped REST probe returned 403 with and without auth, so the REST lane was unavailable; the search returned #7204 itself as a live-channel control), issue_write create #7224, issue_read get #7224 (sanitizer read-back), add_issue_comment (this report).",
"open_questions": [
{
"question": "ZONE 2 is falsified: the date sublabel is display:none from a 640px viewport up, so removing the Start/End columns at the 320px default deletes a row's dates instead of de-duplicating them. Where should a row's start/end dates live at desktop widths when the task list is too narrow to pay 160px for the two columns? This is the shape change ZONE 2 anticipated, and it is outside option B's ruled scope, so it comes back rather than shipping.",
"options": [
"A. One predicate, container-driven: render the sublabel exactly when the Start/End columns are NOT painted, then raise the threshold as option B intended. Also closes #7224 (the 640..1023 band that today shows no dates at all). Cost: the sublabel becomes visible at desktop widths where it never was, adding a second line to every row inside the existing 40px row height (it already renders inside a 32px row below 640, so it fits). Care needed: 'the columns are painted' is currently the AND of a container test (showStartEndColumns) and a viewport test (Tailwind
hidden sm:block), and only the conjunction is a safe complement.","B. Move the sublabel's gate off the viewport onto the same container width the column gate reads, leaving both thresholds otherwise alone. Smallest diff, fixes #7224's mismatch at its root, but on its own does NOT fix #7204 — at a 1440 viewport the container is also wide, so the columns still win and the title still gets ~54px.",
"C. Ship option B exactly as ruled and accept that at 320px the dates leave the task list, on the argument that the bar itself encodes the dates and the hover tooltip prints them. Cheapest; but the tooltip fallback is conditional too — it prints dates ONLY when
task.fieldsis empty, so the documentedtooltipFieldsconfig key removes that fallback as well, and hover-only is not equivalent to an at-a-glance column. It also silently removes inline date EDITING, since the twotype=dateinputs live in those same two cells.","D. Do nothing on B; escalate the whole card (A/B/C of the original) to the maintainer as one layout decision, since option A of the card (sizing from the container) would make the 320px cap moot and dissolve the conflict without touching either gate."
],
"recommendation": "A, escalated for the maintainer's yes rather than shipped. Real business need: measured pull on both sides — the reporter's live app (objectstack-ai/duly#62) needs the names, and #7224 shows a band that today paints no dates at all, so both the title and the dates have demonstrated demand; C serves the names by spending the dates, which is a trade nobody has asked for. Long-term soundness for THIS project: the defect's root is that two gates read two different widths (ResizeObserver container vs CSS viewport media query) and neither is the complement of the other; A collapses them to one predicate and is the only option that cannot drift back apart — B and C leave the split standing. Making AI-written metadata apps hard to get wrong: an author has no way to express any of this (GanttConfigSchema declares 19 keys and no width, and the
ganttblock is a passthrough object, so an inventedgantt.taskListWidthvalidates and is silently ignored), so the correct behaviour has to be structural in the component; A also removes a state where declared, fetched data is rendered into the DOM and painted zero times, which is exactly the silently-inert class this repo keeps filing. Startup scope discipline: A adds no key, no capability and no authorable surface — it is a repair of an existing gate, so it does not widen the contract and does not touch the exhausted CONTRACT_REVIEW_TIER quota that blocks the card's option C. The one honest cost of A is visual: every desktop row gains a second line. That is a look-and-feel change across every gantt, which is why it is the maintainer's yes and not mine."},
{
"question": "If the shape above is settled and the threshold still needs raising, what should it be? The card suggests 'roughly 440'; that number is not derived, and no single constant is correct.",
"options": [
"Derived floor: 32 (px-4 both sides) + 160 (Start 80 + End 80, measured) + 28 (open-details slot w-6 plus its 4px margin, present whenever onTaskClick is live) + 32 (title-cell furniture: 8 effective spacer + 8 gap + 8 dot + 8 gap) = 252 of fixed cost, so threshold = 252 + MIN_TITLE, and MIN_TITLE = 160 (reusing TASK_LIST_MIN_W, the only existing 'narrower than this is useless' constant in the file) gives 412.",
"The card's 440, which implies MIN_TITLE = 188.",
"Make it a function rather than a constant, because the per-row indent is
depth * 14and is unbounded — no single threshold keeps a depth-4 row legible."],
"recommendation": "The derived 412, with the caveat stated in the third option. It is reproducible from the source (every term traced to a line) rather than estimated, and it reuses the file's own floor constant instead of inventing a second magic number. But note that at 412 the columns would be off at ALL THREE defaults (140 / 220 / 320) — the threshold would only ever be cleared by a user drag — which is exactly why the shape question above has to be answered first: a threshold that is never cleared by default is a removal of the feature by arithmetic, and the ruling explicitly did not ask for that."
}
],
"out_of_scope_findings": [
"filed as #7224: plugin-gantt shows NO dates on a task-list row at any viewport between 640px and 1023px — the Start/End columns are gated off by the container-driven showStartEndColumns(220) and the date sublabel is gated off by the viewport-driven @media (min-width: 640px) rule, so the two gates leave an uncovered band on current main. Also holds at 1024px and above whenever a persisted drag left the panel narrower than 280. Same two functions as #7204; probably wants to be decided together with it."
]
}
Generated by Claude Code
os-warren commentedon Sep 1, 2026
⛔ Option B CANNOT ship as I ruled it — my ruling was wrong, and the lane proved it
domain:uiseat, sessionsession_012wwHa4aaFybxXrfmfHioDM. No code shipped, no PR, and that is the correct outcome. Clearingpm:dispatched; re-grading toneeds-user-decision.What I ruled, and why it was wrong
I ruled option B a defect repair, not a design choice — on the argument that
showStartEndColumnsproduces the outcome its own comment names as the thing it prevents. That reading of the gate still stands.What does not stand is the safety argument I built on top of it. B is only non-destructive if removing the two date columns de-duplicates the dates rather than deleting them, and the card's one sentence for that was:
That sentence is conditional, and it is false at exactly the widths where this defect lives. The sublabel span carries
gantt-sm-hidden, andGanttViewinjects its own style element containing@media (min-width: 640px) { .gantt-sm-hidden { display: none; } }.Measured in a real Chromium against the component's own emitted markup and its own injected style element:
displayinlineinlinenonenoneSo at every desktop width — including the 1440 and 1920 where the truncation was reported — the sublabel is already
display: none. Raising the threshold as I ruled would delete a row's dates, not de-duplicate them. It would have traded a truncated name for a missing date and shipped looking like a fix.This is the third PM premise a lane has falsified this shift, and the first where the falsified premise was load-bearing for a ruling rather than a mechanism note. The order said a clean stop on a falsified premise is a full success. It is, and this is what one looks like.
The measurement discipline is why this was caught rather than confirmed
⭐ jsdom would have given a false confirmation, in the reassuring direction.
getComputedStyle(sublabel).displayreturnednoneat 500, 800 and 1440 alike, because jsdom applies the media rule irrespective ofinnerWidth. Reported as NOT MEASURED rather than as a result, and the decisive reading moved to a real browser. Had that been taken at face value, the conclusion would have been "the sublabel is never visible" — different from the truth, and it would have sent the fix somewhere else entirely.Two more things done right:
A live defect found on the way: #7224
At any viewport between 640 and 1023, the default task-list width is 220, so
showStartEndColumns(220)is false and the columns are off — while the sublabel is alreadydisplay: nonefrom 640 up. A row shows no dates at all. That is on currentmain, today, and it also holds at 1024+ whenever a persisted drag left the panel under 280.The root is that two gates read two different widths — a ResizeObserver container measurement and a CSS viewport media query — and neither is the complement of the other. That is the actual defect behind this card, and it is a better description of the problem than the one the card (or I) had.
Escalated, not decided
The shape question goes to the maintainer with a recommendation rather than being ruled from this seat, because the leading option changes how every gantt row looks:
Recommended: option A — render the sublabel exactly when the Start/End columns are not painted, then raise the threshold. One predicate instead of two that can drift apart; it also closes #7224 at its root. Cost, stated plainly: every desktop row gains a second line. That is a look-and-feel change across every gantt, which is why it needs a yes rather than my say-so.
⛔ Option C — ship B and let the dates leave the task list — is now clearly wrong, and the lane found the reason: the hover-tooltip fallback prints dates only when
task.fieldsis empty, so the documentedtooltipFieldskey removes that fallback too. It would also silently remove inline date editing, since the twotype="date"inputs live in those very cells.On the threshold, if one is still needed: the derived floor is 412 (32 padding + 160 columns + 28 open slot + 32 furniture + 160 reusing⚠️ But note what 412 implies: the columns would be off at all three defaults (140 / 220 / 320), so the threshold would only ever be cleared by a manual drag — a removal of the feature by arithmetic, which is not what the ruling asked for. That is another reason the shape has to be settled before any number is.
TASK_LIST_MIN_W), not the card's suggested 440, which is not derived from anything.Carried to #5560.
Generated by Claude Code
os-project-manager commentedon Sep 2, 2026
Maintainer ruling recorded — Y: one container-driven predicate, and the task-list default sized from the container (ruled together with #7224)
Director seat (objectstack #12708), summon #8, session
session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #5 in which this card and #7224 were items 2 and 3, presented as one ruling with the recommendation Y (fallback X). Verbatim reply: 「同意」.
Ruled: Y. Three parts, one PR:
gantt-sm-hiddengoes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closes plugin-gantt: between 640px and 1023px the task-list rows carry NO dates at all — the Start/End columns are gated off and the date sublabel is display:none from 640px up #7224's 640–1023 band (and the dragged-narrow case at any width) at the root.Option X (predicate + threshold only) is the recorded fallback; option C (an authorable width key) is not taken — no new key. The gantt-block passthrough (an invented
gantt.taskListWidthvalidates and is ignored) is noted as the same silent-inert class as #7199 and is not repaired here.Execution:
domain:uilane, one dispatch for both cards (same file, same two functions), branch may continue from the empty probeclaude/issue-7204-gantt-tasklist-width. Layout only, no contract change ⇒ Clause-② no. Tests: the #7224 viewport table in a real browser (sublabel visible iff columns hidden, at 500 / 639 / 640 / 800 / 1024 / 1440 / 1920), and the title client width at the three desktop widths above the legibility floor.State transition, same stroke:
needs-user-decision→pm:queue;bug,priority:p2retained. Ledger: objectstack director seat post #12708, summon #8.Generated by Claude Code
os-project-manager commentedon Sep 2, 2026
Claim:
domain:uiexecution seat, PM sessionsession_01EMrWaQw3XS5DxTHxp4yRyC, R2. Branch continues from the existing empty probeclaude/issue-7204-gantt-tasklist-width. Dispatching to anos-devsubagent at opus. One dispatch covering #7204 AND #7224, per the ruling ("same file, same two functions").Comments re-read before claiming — all five, including the previous seat's own retraction. ⛔ No competing claim with a different session ID; the branch holds no commits.
Ruling taken as given: Y (maintainer 2026-09-02, verbatim 「同意」, comment 5507936333). Clause-② no — layout only, no contract change, so no tier review is owed on delivery. ⛔ Option C (an authorable width key) is not taken; option X is the recorded fallback only.
Serial constraints cleared
packages/plugin-gantt/src/GanttView.tsxpackages/plugin-gantt/src/ObjectGantt.tsx⭐ The disjointness was re-verified, not inherited. My own seat note flagged that the "disjoint from
ObjectGantt.tsx" judgement predated the ceiling ruling. Measured against PR #7391's branch: it touches 6 files underpackages/plugin-gantt/, andGanttView.tsxis not among them — zero hits, with a firing control (33 changed files total). So the two can run side by side.plugin-ganttis not one of the three chunks inPER_CHUNK_GZIP_CEILINGSand did not appear in the gate's largest-eager-chunks list, so this should be byte-neutral to theframeworkceiling that #7399 is blocked on. ⛔ That is a reason to expect a clean result, not a substitute for measuring one — I recorded a correction against myself this shift for calling a card byte-neutral on exactly that kind of inference. The dispatch requires the measurement.What the previous round bought, and why this dispatch is short
This card was dispatched once before and came back with no code and a falsified premise — correctly. Option B's entire safety argument was one sentence ("each row renders the dates as a sublabel"), and it is false at every desktop width: the sublabel carries
gantt-sm-hidden, andGanttViewinjects@media (min-width: 640px) { .gantt-sm-hidden { display: none } }. Raising the threshold as first ruled would have deleted a row's dates while looking like a fix.⭐ That round also produced the trap this one must not fall into: jsdom gives a false confirmation here, in the reassuring direction — it applies the media rule irrespective of
innerWidth, returningdisplay: noneat 500, 800 and 1440 alike. The decisive reading has to come from a real browser. It is carried into the dispatch as a named hazard.And it produced #7224 as a live defect on
main: between 640 and 1023 the columns are gated off by a container test while the sublabel is gated off by a viewport test, so a row shows no dates at all. Two gates reading two different widths, neither the complement of the other — that is the actual root, and it is why the two cards ship together.Generated by Claude Code
os-project-manager commentedon Sep 3, 2026
os-dev-report
{ "issue": 7204, "also_fixes": 7224, "status": "done", "branch": "claude/issue-7204-gantt-tasklist-width", "pr": "https://github.com/objectstack-ai/objectui/pull/7420", "premise_still_valid": true, "summary": "All three parts of the 2026-09-02 ruling (option Y) landed in one PR on packages/plugin-gantt/src/GanttView.tsx. (1) The date sublabel now renders on exactly the complement of the Start/End columns, both from the same container-derived taskListWidth: the '@media (min-width: 640px) { .gantt-sm-hidden { display: none } }' rule and the class it hid are gone, and the two date cells lose their 'hidden sm:block' so 'the columns are painted' is no longer a conjunction of a container test and a viewport test. (2) taskListWidthForContainer takes 3/8 of the container clamped to [320, 560] from 1024 up, replacing the flat 320 cap. (3) showStartEndColumns compares against a derived START_END_COLUMNS_MIN_W = 32 + 160 + 28 + 32 + 160 = 412, each term a named constant traced to the markup that spends it. ObjectGantt.tsx (held by PR #7391) was not touched; content/docs/releases was not touched; no gate was weakened. A side effect worth naming: the header captions and the data cells now agree, where before a sub-640px viewport with a wide task list printed Start/End captions over cells that were not painted.", "tests": "All readings at commit b5f659e72 (branch head). RED-THEN-GREEN: origin/main's GanttView.tsx was restored over the committed fix; mutation proved on disk by blob hash (on-disk 8e7b4d8bc16a129ef58a3c58aba0b641d09be46c == origin/main blob, != HEAD blob e0cca573472e532521294bed7b067e3f49e1fffc) plus two anchored controls (gantt-sm-hidden lines back = 2, START_END_COLUMNS_MIN_W lines = 0); ablated run 'Test Files 1 failed (1) / Tests 14 failed (14)', VERDICT command-exit 1. Restore proved by STATE, not exit code: 'git diff HEAD' empty and on-disk blob == HEAD blob, under trap ... EXIT INT TERM with absolute paths. GREEN: same file 'Test Files 1 passed (1) / Tests 14 passed (14)'; whole plugin-gantt suite 'Test Files 59 passed (59) / Tests 471 passed (471)', VERDICT command-exit 0. All vitest runs went through os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=dev-7204-gantt. type-check: 'pnpm --filter @object-ui/plugin-gantt run type-check' exit 0 (script name echoed, so not a zero-match run), after 'pnpm --workspace-concurrency=2 --filter @object-ui/plugin-gantt^... run build' built the dependency closure (before that build it failed with TS2307 on every @object-ui import). lint: package lint exit 0, 0 errors; GanttView.tsx 19 warnings before and 19 after with an identical rule multiset, measured by linting the restored pre-fix blob, so the change adds none; the new test file adds 0. Gate family derived from the touched paths, all exit 0 with the exit captured before any pipe: check:control-bytes, check:i18n-keys, check:i18n-drift, check:i18n-dead-keys, check:vi-mock-specifiers, check:vi-mock-inherit, check:readme-exports, check:shell-escape-residue, check:sdui-registration-pins, check:element-data-source-declaration, check:eager-closure. NOTE on check:readme-exports: it first exited 1 with '5 self-import(s) could not be judged ... not on disk -- run pnpm build first' for @object-ui/cli and @object-ui/plugin-ai. That is an unbuilt-artifact reading, not a red gate; after building those two packages it exits 0. Reported rather than silently re-run. Control-byte self-scan over the diff and the PR body: no matches (grep exit 1), with a firing control on a file carrying a deliberate 0x01.", "browser_viewport_table": { "provenance": "REAL CHROMIUM, NOT jsdom. Chromium 1194 at /opt/pw-browsers/chromium (preinstalled; playwright install was never run), driven by Playwright against a Vite dev server rendering the real GanttView with the repo's own Tailwind build and the component's own injected style element. The measurement script was run from INSIDE the worktree (running it from outside gives ERR_MODULE_NOT_FOUND for 'playwright', which is unmeasured, not a red). Every viewport-dependent number below is a browser reading; nothing here was taken from jsdom, which applies the media rule irrespective of innerWidth and would have confirmed 'the sublabel is never visible'.", "legend": "pane = .gantt-task-list painted width; sub = date sublabel painted; cols = both Start/End cells painted", "container_equals_viewport": [ { "viewport": 500, "before": { "pane": 140, "sub": true, "cols": false }, "after": { "pane": 140, "sub": true, "cols": false } }, { "viewport": 639, "before": { "pane": 140, "sub": true, "cols": false }, "after": { "pane": 140, "sub": true, "cols": false } }, { "viewport": 640, "before": { "pane": 220, "sub": false, "cols": false }, "after": { "pane": 220, "sub": true, "cols": false } }, { "viewport": 800, "before": { "pane": 220, "sub": false, "cols": false }, "after": { "pane": 220, "sub": true, "cols": false } }, { "viewport": 1024, "before": { "pane": 320, "sub": false, "cols": true }, "after": { "pane": 384, "sub": true, "cols": false } }, { "viewport": 1280, "before": { "pane": 320, "sub": false, "cols": true }, "after": { "pane": 480, "sub": false, "cols": true } }, { "viewport": 1440, "before": { "pane": 320, "sub": false, "cols": true }, "after": { "pane": 540, "sub": false, "cols": true } }, { "viewport": 1920, "before": { "pane": 320, "sub": false, "cols": true }, "after": { "pane": 560, "sub": false, "cols": true } } ], "xor_violations": { "before": 2, "before_at": [640, 800], "after": 0 }, "container_decoupled_from_viewport_after_fix": [ { "viewport": 1440, "container": 800, "pane": 220, "sub": true, "cols": false }, { "viewport": 1920, "container": 700, "pane": 220, "sub": true, "cols": false }, { "viewport": 500, "container": 700, "pane": 220, "sub": true, "cols": false }, { "viewport": 800, "container": 1440, "pane": 540, "sub": false, "cols": true }, { "viewport": 1024, "container": 1097, "pane": 411, "sub": true, "cols": false }, { "viewport": 1024, "container": 1098, "pane": 412, "sub": false, "cols": true } ], "raw_computed_reads_before_fix": "getComputedStyle(sublabel).display was 'block' with width 91px at 500 and 639, and 'none' with width 0 at 640, 800, 1024, 1440 and 1920; window.matchMedia('(min-width: 640px)').matches flipped exactly at 640; each .gantt-sm-w20 cell measured 80.0px at every width from 640 up, which is where 80 + 80 = 160 comes from." }, "part2_derivation": { "rule": "taskListWidthForContainer(w) = w < 640 ? 140 : w < 1024 ? 220 : round(clamp(w * 0.375, 320, 560))", "share": "3/8 = 0.375", "clamp": [320, 560], "why_share": "The title's space with the columns painted is pane - 1 (the pane's own border-r, inside its border-box width) - 32 (sm:px-4) - 160 (the two date cells) - 28 (open-details slot) - 32 (title furniture) = pane - 253. A 40-character title MEASURES 262px in the row's sm:text-sm font (Chromium), so 'legible at 1440' needs pane >= 515, i.e. a share of at least 515/1440 = 0.3576. 3/8 is the smallest clean fraction above it and puts 1440 at 540, leaving 287px.", "why_floor_320": "The ruling's floor and the previous fixed default, so no container gets a narrower pane than before.", "why_ceiling_560": "560 - 253 = 307px of title, which still clears the measured 262px after one level of the row's depth*14 indent (262 + 14 = 276). Past that, more pane width buys no legibility the ruling asked for and the timeline - the view's primary content - pays for it. The ceiling binds from a 1494px container up.", "measured_widths": [ { "container": 1280, "pane": 480, "clears_412": true, "name_cell": 259, "title_space": 227, "fits_25_char_154px": true, "fits_40_char_262px": false }, { "container": 1440, "pane": 540, "clears_412": true, "name_cell": 319, "title_space": 287, "fits_25_char_154px": true, "fits_40_char_262px": true }, { "container": 1920, "pane": 560, "clears_412": true, "name_cell": 339, "title_space": 307, "fits_25_char_154px": true, "fits_40_char_262px": true } ], "412_is_cleared_at_all_three_desktop_widths": true, "legibility_floor": "Defined as the ruling's own lower bound, 25 characters, which measures 154px. Any pane that clears 412 leaves the title at least 412 - 253 = 159px, so the threshold itself guarantees the floor - the ruling's 160px minimum-title term is worth almost exactly 25 characters, measured.", "derivation_reproduces_the_cards": "At pane 320 the same arithmetic gives 67px for a flat leaf, 53px at depth 1 (67 - 14) and 63px for a summary row (its w-4 toggle costs 4 more than a leaf's w-3 spacer). Chromium read 67, 53 and 63. #7204 reported 53. No term is off by a pixel." }, "byte_reading": { "gate": "check:eager-closure exit 0", "per_chunk": { "framework": "511.5 KB / 511.7 KB ceiling, headroom 0.2 KB", "vendor-objectstack": "926.1 KB / 944.3 KB ceiling, headroom 18.2 KB", "ui-components": "387.3 KB / 389.6 KB ceiling, headroom 2.3 KB", "aggregate": "3178.3 KB / 3191.4 KB budget, headroom 13.1 KB" }, "measured_delta_vs_pre_fix_baseline": { "eagerGzipBytes": { "branch": 3254545, "baseline": 3254545, "delta": 0 }, "eagerRawBytes": { "branch": 11175307, "baseline": 11175307, "delta": 0 }, "eagerChunkCount": { "branch": 48, "baseline": 48, "delta": 0 }, "per_chunk_gzip_deltas": "none - every one of the 48 eager chunks identical, no chunk on one side only" }, "method": "The console was built twice through turbo (both builds exited 0 - a failed ablation build reads a stale artifact and reports a stripped input as free), once on the branch and once with GanttView.tsx restored to origin/main under a trap-guarded ablation with the mutation proved on disk by blob hash and the restore proved by git diff HEAD being empty. No ceiling was raised and no copy was trimmed.", "firing_control": "The zero is a reading, not a stale artifact: the entry chunk hash DIFFERS between the two builds (index-DUp49asL.js on the branch vs index-XRCMG0Ou.js on the baseline), so the two builds genuinely produced different bundles and the eager closure still weighed the same. No gantt chunk appears in the eager set at all." }, "deviations": [ "The ruling's 412 does not count the pane's own 1px border-r, which sits inside its inline border-box width, so the true break-even is 413. 412 is implemented AS RULED; the effect is that at exactly pane 412 the title gets 159px rather than the 160 the term names. Flagged, not silently corrected.", "New behaviour the ruling did not describe: between a 1024px and a 1097px container the Start/End columns are now OFF where they used to be on, and the dates ride the sublabel (0.375 * 1024 = 384 < 412). It is a net gain - the title goes 67px to 291px and the dates stay visible - but #7204's table never visits that band, so it is called out and pinned in the tests at 1097/1098.", "Below a 640px viewport the 412 terms are cheaper than 412 assumes: .gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so the columns cost 128 and the slot 0. The threshold is conservative there, never permissive. Making those unconditional would be a narrow-layout change nobody asked for; left alone.", "depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit in the source comment, the test file and the PR, per the dispatch - not chased.", "The three sub-1024 stepped defaults (140 / 220) were left unchanged; the ruling only asked for the container-sized default at 1440 and wider, with 320 as the floor." ], "mcp_calls": "5 - issue_read(#7204), create_pull_request(#7420), issue_write(create #7421), and the two add_issue_comment calls carrying this report (#7204 and #7224). Every other GitHub read (the ruling comments, #7224 and its comments, the 290 open issues for the duplicate check, the PR body read-back, the label read-back on #7421) went through the container's unauthenticated REST channel, probed green at the start of the run.", "open_questions": [], "out_of_scope_findings": [ "filed as #7421: `taskListWidth_LEGACY_REMOVED` in GanttView.tsx is a declared-and-never-read `const null` left behind by a finished refactor - one hit repo-wide, its own declaration, with a comment that reads like a live to-do. Unassigned, label `finding` only.", "NOT filed, already open as #7228: `columnWidthForContainer` branches on container width and returns 110 from all three arms. Found adjacent to my edit; the duplicate check hit it, so no second card was created.", "NOT filed, covered by the ruling: the `gantt`-block passthrough (an invented `gantt.taskListWidth` validates and is silently ignored, same silent-inert class as #7199) is recorded on #7204 and explicitly out of scope here." ] }Generated by Claude Code
os-project-manager commentedon Sep 3, 2026
ACCEPT — all three ruled parts delivered, both cards closed. One deviation accepted with the alternative recorded.
domain:uiexecution seat, PM sessionsession_01EMrWaQw3XS5DxTHxp4yRyC, R2. PR #7420 atb5f659e72. ⛔ Not yet enqueued — 23 checks green includingBundle Analysis, 4 test shards still running. Enqueue requires every check green.#7224 is measured closed, not argued closed
The defect was that two gates read two different widths and neither was the complement of the other. The real-browser table gives it as one number:
And the two gates are now provably one predicate: with the container decoupled from the viewport, a 1440px viewport with an 800px container renders the sublabel, and an 800px viewport with a 1440px container paints the columns. Before, the viewport decided one and the container the other. The 412 boundary is pinned at container 1097 → sublabel, 1098 → columns, which is the threshold itself under test rather than a nearby proxy.
⭐ Provenance is stated where it matters: real Chromium 1194, driven against a Vite dev server with the repo's own Tailwind build and the component's own injected style element, run from inside the worktree. The dispatch named jsdom as a hazard that would have confirmed "the sublabel is never visible" at every width; the report says explicitly that nothing here came from it.
The derivation reproduces the card to the pixel
At pane 320 the same arithmetic yields 67px for a flat leaf, 53px at depth 1, 63px for a summary row (its
w-4toggle costs 4 more than a leaf'sw-3spacer). Chromium read 67, 53, 63. #7204 reported 53. "No term is off by a pixel." An independent derivation agreeing with the original report is worth more than adopting its table — which is what the dispatch asked for.The share is derived, not chosen: a 40-character title measures 262px in the row's font, so legibility at 1440 needs pane ≥ 515, i.e. share ≥ 0.3576; 3/8 is the smallest clean fraction above it. The ceiling 560 is where more pane buys no legibility the ruling asked for and the timeline — the view's primary content — starts paying.
Between container 1024 and 1097 the Start/End columns are now OFF where they used to be ON (0.375 × 1024 = 384 < 412). The dev flagged and pinned it rather than letting it pass.
This matters because ruling Y part 3 asserted a property: "With part 2 in place the desktop defaults clear it; below it the sublabel carries the dates, so inline date editing stays reachable at desktop defaults." In that band the defaults do not clear 412, so inline date editing — the two
type="date"inputs live in those cells — is not reachable there. The ruling's stated premise does not hold across the whole band it speaks about.⭐ I am accepting it, and here is the reasoning rather than a verdict. At container 1024 before this PR: columns on, title 67px — that is precisely the defect #7204 was filed about, truncating names to ~7 characters. After: title 291px, dates still on screen via the sublabel, editing moved off that band. So this is not a regression against the card's intent; it is the card's intent applied at a width the ruling's own table never visited. And the share was explicitly delegated — "the exact share and clamp bounds are the dev's to derive and pin" — so choosing 0.375 is inside the delegation, not outside it.
The alternative, recorded so it is a one-line change if the maintainer prefers the asserted property: the smallest share that makes 1024 clear 412 is 412/1024 = 0.4024, so 13/32 = 0.40625 would give 1024 → 416, 1280 → 520, 1440 → 560 (clamped), 1920 → 560. That preserves "editing reachable at every desktop default" and still clears the 515 legibility floor. Its cost is ~40px of timeline at 1280 and ~20px at 1440. ⛔ I am not making that change on my own say-so, because it trades away primary-content width the dev deliberately protected — but it is a single constant, and the pins at 1097/1098 already localise it.
The other deviations, all correctly handled
border-r, which sits inside its border-box width, so the true break-even is 413 and at exactly pane 412 the title gets 159px rather than the 160 the term names. Implemented as ruled and flagged, not silently corrected — exactly right. A dev quietly "fixing" a ruled number is worse than a 1px gap.gantt-sm-w20and the open-details slot only apply from 640 up), so the threshold is conservative there, never permissive. Making them unconditional would be a narrow-layout change nobody asked for; left alone. Correct.depth * 14is unbounded, so no single default keeps a deeply nested row legible — stated as a known limit in source, tests and PR, per the dispatch. ⛔ Not chased.The byte reading, and why its control is the good part
check:eager-closureexit 0 —framework511.5 / 511.7 KB, headroom 0.2 KB, unchanged. Measured, not assumed: two full console builds, branch vs.GanttView.tsxrestored toorigin/main, both exiting 0.⭐ And the zero has a firing control: the entry chunk hash differs between the two builds (
index-DUp49asL.jsvsindex-XRCMG0Ou.js). So the two builds genuinely produced different bundles and the eager closure still weighed exactly the same — which rules out the failure mode a delta of 0 usually hides, reading the same artifact twice.plugin-gantt's own dist did grow (166.93 → 167.03 KB) and no gantt chunk appears in the eager set at all.⇒ This is the measurement that also validates passing over #7178: the same ~100 bytes lands harmlessly in a lazy plugin and would have landed in the contested
frameworkchunk frompackages/core.Also correct
check:readme-exportsfirst exited 1 with "not on disk — run pnpm build first" and was reported as an unbuilt-artifact reading, not a red gate, then cleared by building. Second dev today to get that distinction right.ObjectGantt.tsx(held by PR #7391) untouched, as required.Filed: #7421 —
taskListWidth_LEGACY_REMOVED, a declared-and-never-readconst nullleft by a finished refactor, with a comment that reads like a live to-do. ⭐ And a duplicate was correctly not filed forcolumnWidthForContainerreturning 110 from all three arms — the dedup hit #7228, which is already open.Generated by Claude Code
os-project-manager commentedon Sep 3, 2026
LANDED — verified BY CONTENT on
origin/mainwith live controls, not by the PR's head SHAdomain:uiexecution seat, PM sessionsession_01EMrWaQw3XS5DxTHxp4yRyC, R2. PR #7420 merged through the merge queue at 00:56:06Z;origin/mainis nowbf244f400.pm:dispatchedstripped from both #7204 and #7224 in the same stroke (bug,priority:p2,domain:uiretained — belonging, not state).The probes, each with a control that must fire
origin/mainSTART_END_COLUMNS_MIN_W(the derived 412)taskListWidthForContainer(part 2)gantt-sm-hidden— the class the viewport rule hidgantt-sm-w20= 6 ⇒ the zero is a readingObjectGantt.tsx(held by PR #7391)e75f4c986(#7338), not by this landing⭐ The reading that would have been wrong from a count
min-width: 640pxstill returns 2 occurrences, which read as a bare number says the media rule survived — the opposite of the truth. Reading the lines:sm:viewport test while the sublabel was gated by a(min-width: 640px)media rule alone. Two gates on two different widths are not complements…".gantt-sm-h50 { height: 50px }and.gantt-sm-w20 { width: 80px }, row height and date-cell width..gantt-sm-w20.⇒ The rule that gated date visibility is gone (its class is at zero); what remains is documentation of the old design and unrelated sizing. This is the fifth time this shift a bare count would have produced the opposite conclusion — recorded again because it keeps arriving in new clothes.
What landed
#7204: the task list is no longer capped at 320px —
taskListWidthForContainertakes 3/8 of the container clamped to [320, 560] from 1024 up, so a 40-character title (measured at 262px in Chromium) fits at 1440 and wider while the Start/End columns stay on. The threshold is the derived 412, each term traced to the markup that spends it.#7224: closed at the root. The date sublabel and the Start/End columns now read the same container width, so they are complements by construction — XOR violations went 2 → 0, and the boundary is pinned at container 1097 → sublabel / 1098 → columns rather than at a nearby proxy.
Carried forward
border-r, so at exactly pane 412 the title gets 159px rather than the named 160. Implemented as ruled and flagged rather than silently corrected.taskListWidth_LEGACY_REMOVEDis a declared-and-never-readconst nullleft behind by a finished refactor #7421 filed —taskListWidth_LEGACY_REMOVED, a declared-and-never-readconst nullwith a comment that reads like a live to-do. Unassigned,findingonly; grading is triage's.Generated by Claude Code