fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container - #7420

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width
Sep 3, 2026
Merged

fix(plugin-gantt): one container-driven predicate for a row's dates, and a task list sized from the container#7420
os-project-manager merged 1 commit into
mainfrom
claude/issue-7204-gantt-tasklist-width

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#7204
Fixes#7224

The ruling this implements

Maintainer, 2026-09-02, replying to decision batch #5 where these two cards were items 2 and 3, presented as one ruling with recommendation Y. Verbatim reply: 「同意」 (recorded on #7204, comment 5507936333). Option C (an authorable width key) is not taken — no new key. Option X (predicate + threshold, without part 2) was the recorded fallback, not the plan.

  1. One predicate, container-driven. The date sublabel renders exactly when the Start/End columns are not painted, and both read the same container width — the viewport media rule on gantt-sm-hidden goes; "the columns are painted" stops being the conjunction of a container test and a viewport test. This closesplugin-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.
  2. The task-list default is sized from the container, a clamped share rather than the 320px cap: at 1440 and wider the panel takes enough of the extra width that a 25–40 character title is legible while the Start/End columns stay on; the exact share and clamp bounds are the dev's to derive and pin at 1280 / 1440 / 1920, with 320 as the floor.
  3. The Start/End threshold is the derived 412 (32 padding + 160 columns + 28 open-details slot + 32 title furniture + 160 minimum title, each term traced to source), not the estimated 440.

All three parts are in this PR. The gantt-block passthrough defect (an invented gantt.taskListWidth validates and is silently ignored) is, per the ruling, not repaired here.


Part 1 — one predicate

Before, two gates decided whether a row's dates were painted and they read two different widths:

gatewhat it readwhere
Start/End columnsshowStartEndColumns(taskListWidth), container-derived — and Tailwind hidden sm:block, viewportJS + class
date sublabel@media (min-width: 640px) { .gantt-sm-hidden { display: none } }, viewportthe component's own injected style element

Two gates on two different widths are not complements. Between a 640px and a 1023px container both were shut: the row's dates were in the DOM twice and painted zero times. The same hole opened at any width once the splitter was dragged under the threshold.

Now the sublabel renders on exactly !showSEColumns, the media rule and the class it hid are gone, and the two date cells lose their hidden sm:block so the columns are painted on the same single predicate as the header captions above them. A row always carries its dates one way or the other.

Two smaller things fall out of that, unasked for but not separable: the header captions and the data cells now agree (before, a viewport under 640px with a task list over the threshold printed Start / End captions over cells that were not painted), and the cells' hidden attribute plus inline display: none are replaced by conditional rendering, which removes an inline style.

Part 2 — the default, sized from the container

From a 1024px container up the pane takes 3/8 of the container, clamped to [320, 560], rounded to a whole pixel. Below 1024 the two stepped defaults (140 / 220) are unchanged.

  • floor 320 — the ruling's floor, and the previous fixed default, so nothing gets narrower.
  • share 3/8 — 3/8 of 1440 is 540, which leaves the title 287px once the row's fixed cost is paid. A 40-character title measures 262px in the row's sm:text-sm font (measured in Chromium, below), so the top of the ruling's 25-to-40 character band is legible at 1440 and wider with the columns still painted. 3/8 is the smallest clean fraction that does it: 515/1440 = 0.3576 is the bare requirement.
  • ceiling 560 — leaves the title 307px, still clearing 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.

Resulting pane widths and title space, all three desktop widths clearing 412:

containerpanecolumns paintedtitle space25-char title (154px)40-char title (262px)
1280480yes227fitstruncates (~35 chars shown)
1440540yes287fitsfits
1920560yes307fitsfits

Before this change all three were pane 320 / title 67.

Part 3 — the threshold is the derived 412

START_END_COLUMNS_MIN_W = TASK_LIST_ROW_PADDING_W + START_END_COLUMNS_W + OPEN_DETAILS_SLOT_W + TITLE_FURNITURE_W + TASK_LIST_MIN_W, each term named in the source and traced to the markup that spends it:

termpxtraced to
row padding32the row and header carry px-2 sm:px-4 — 16px a side from 640px up
the two date cells160each is w-16 gantt-sm-w20, and the injected rule sets .gantt-sm-w20 { width: 80px } from 640px up
open-details slot28the trailing w-6 (24px) with its marginLeft: 4
title furniture32inside the name cell, ahead of the title: the w-3 collapse spacer pulled back 4px (8), gap-2 (8), the w-2 colour dot (8), gap-2 (8)
minimum title160TASK_LIST_MIN_W, the pane's own drag floor, reused

32 + 160 + 28 + 32 + 160 = 412. Not the card's estimated 440, which is derived from nothing.

The derivation was checked against the real thing rather than asserted. For a flat leaf row at a 640px-or-wider viewport with onTaskClick live, the title's space is

pane − 1 (the pane's own border-r, inside its border-box width)
− 32 (sm:px-4)
− 160 (the two date cells, when painted)
− 28 (open-details slot)
− 32 (title furniture)

which reproduces #7204's reported numbers exactly, in Chromium: at pane 320 that is 67px for a flat leaf and 53px at depth 1 (67 − 14) — the card measured 53. A summary row's w-4 toggle costs 4 more, giving 63, which is what the harness read for the summary row. No term is off by a pixel.


Measurements — real Chromium, and why that matters here

⚠️jsdom confirms the wrong answer on this card. jsdom applies @media rules irrespective of window.innerWidth, so while the old rule was live getComputedStyle(sublabel).display read none at 500, 800 and 1440 alike — including widths where a real browser painted the sublabel. Taken at face value that reads "the sublabel is never visible", which is false and sends the fix somewhere else. Every viewport-dependent number in this PR comes from Chromium 1194 (/opt/pw-browsers/chromium, preinstalled — no playwright install), driving the real component through a Vite dev server with the repo's own Tailwind build and the component's own injected style element, at real viewports.

#7224's table, before and after (sub = date sublabel painted, cols = both Start/End cells painted; container equals viewport):

viewportpane beforesub beforecols beforepane aftersub aftercols after
500140yesno140yesno
639140yesno140yesno
640220nono220yesno
800220nono220yesno
1024320noyes384yesno
1280320noyes480noyes
1440320noyes540noyes
1920320noyes560noyes

Before: 2 rows where sublabel and columns are both off — 640 and 800, the #7224 hole. After: 0. At every row exactly one of the two is painted.

The same run, with the container decoupled from the viewport — the case the two gates could never agree on:

viewportcontainerpanesubcols
1440800220yesno
1920700220yesno
500700220yesno
8001440540noyes
10241097411yesno
10241098412noyes

Zero violations of the complement, and the crossover is pinned to the pixel: a 1097px container gives pane 411 and the sublabel; 1098 gives pane 412 and the columns.

Title width, measured (flat leaf, columns painted): name cell 259 / 319 / 339 at 1280 / 1440 / 1920, which is exactly pane − 1 − 32 − 160 − 28; minus the 32px of furniture that gives 227 / 287 / 307. A 25-character title measures 154px and a 40-character title 262px in that font, so 1440 and 1920 paint a 40-character name in full and 1280 paints about 35 characters of it. Before: 67px at all three.

Verification

  • Red-then-green. The new pins were run against the pre-fix source by restoring origin/main's GanttView.tsx over the committed fix. The mutation was proved on disk by blob hash (8e7b4d8b… = origin/main blob, ≠ the HEAD blob e0cca573…) plus two anchored controls (gantt-sm-hidden back, START_END_COLUMNS_MIN_W gone); the run came back 14 failed / 14 with the 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 complement red at 500 / 639 / 640 / 800 and every width pin red at 1024–1920. The restore was proved by state — git diff HEAD empty and blob equality — not by an exit code, under a trap ... EXIT INT TERM with absolute paths.
  • packages/plugin-gantt suite after the fix: 59 files, 471 tests, all passing (the 14 new ones included).
  • type-check for the package: exit 0, after building its dependency closure first (a stale dist/*.d.ts lies in both directions).
  • lint for the package: exit 0, 0 errors. GanttView.tsx carries 19 warnings before and 19 after, identical rule multiset — measured the same way, by linting the restored pre-fix blob. The new test file adds 0.
  • Repo-wide lint is CI's run; the narrowing here is declared and measured, not skipped: eslint's own config resolved 89 files for this package (count read from --format json), it is the only package the diff touches (the other changed path is a .changeset/*.md, which eslint does not lint), and the config enables no type-aware linting (no parserOptions.project, no projectService, no recommendedTypeChecked), so this diff cannot move a verdict on a file it does not contain.
  • Gate family derived from the touched paths, all exit 0: 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.
  • Every reading above was taken at b5f659e72.

Bytes — measured, not inferred

check:eager-closure is green: framework 511.5 KB / 511.7 KB ceiling (headroom 0.2 KB), vendor-objectstack 926.1 / 944.3, ui-components 387.3 / 389.6, aggregate 3178.3 KB / 3191.4 KB. No ceiling was touched.

Byte-neutrality was measured rather than argued from "plugin-gantt should be lazy". The console was built on this branch and again with GanttView.tsx restored to origin/main (both builds exited 0 — a failed ablation build reads a stale artifact and reports a stripped input as free):

branchpre-fix baselinedelta
eagerGzipBytes325454532545450
eagerRawBytes11175307111753070
eager chunks48480

Every one of the 48 eager chunks is identical in gzip bytes; no chunk appears on one side only. The zero is a reading, not a stale artifact: the entry chunk hash differs between the two builds (index-DUp49asL.js vs index-XRCMG0Ou.js), 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 and notes, flagged rather than folded in

  1. The ruling's 412 does not count the pane's own 1px border-r. The pane's width is set inline under box-sizing: border-box, 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. One pixel, stated rather than silently corrected.
  2. New behaviour between a 1024px and a 1097px container: the columns are now off there where they used to be on, and the dates ride the sublabel. This is the ruled design working (0.375 × 1024 = 384 < 412), and it is a net gain — the title goes from 67px to 291px and the dates stay visible — but it is a change 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's table never visits, so it is called out. The crossover is pinned in the tests at 1097/1098.
  3. The 412 terms are viewport-conditional below 640px..gantt-sm-w20 (80px) and the open-details slot (hidden sm:block) only apply from 640px up, so below that the columns cost 128 and the slot 0 — the threshold is conservative there, never permissive. Left alone: making those unconditional is a narrow-layout change nobody asked for.
  4. depth * 14 is unbounded, so no single default keeps a deeply nested row legible. Stated as a known limit of container-based sizing, per the dispatch — not chased here. The tests and the source comment both say so.

Test surface

packages/plugin-gantt/src/GanttView.tasklistDates-7204.test.tsx, 14 pins. Every assertion is about the DOM, never about computed style, and the file says why: the predicate under test is a render decision, which jsdom answers exactly, whereas a computed-style assertion there would confirm the wrong answer in the reassuring direction. The pixel readings that go with it were taken in Chromium and live in this PR. The suite also pins that the component's own stylesheet contains no gantt-sm-hidden and that no surviving media block hides anything, so re-adding a viewport gate on a row's dates has to fail here whatever it gets called next time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…and a task list sized from the container (objectui#7204, objectui#7224)
Two gates decided whether a gantt row's dates were painted and they read two
different widths: the Start/End columns were gated on the container-derived
task-list width, the date sublabel under the title on the component's own
`@media (min-width: 640px) { .gantt-sm-hidden { display: none } }` rule, which
reads the viewport. Between a 640px and a 1023px container both were shut and
the row showed no dates at all; the same hole opened at any width once the
splitter was dragged under the threshold.
The sublabel now renders on exactly the complement of the columns, both from
the same container width, and the media rule is gone. The two date cells lose
their `hidden sm:block` viewport test and render on the same predicate as the
header captions they sit under.
The task-list default is sized from the container instead of capped at 320px:
from 1024 up it takes 3/8 of the container clamped to [320, 560], which at 1440
leaves the title 287px with the columns still painted (a 40-character title
measures 262px in the row's font). The Start/End threshold moves from an
estimated 280 to a derived 412 = 32 row padding + 160 columns + 28
open-details slot + 32 title furniture + 160 minimum title, each term traced to
the markup that spends it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.3 KB3191.4 KB
Main entry chunk (gzip)143.2 KB350 KB
Entry fileindex-DUp49asL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.67KB5.75KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)514.87KB117.50KB
core (index.js)5.80KB2.32KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.08KB61.71KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)47.00KB12.97KB
plugin-charts (index.js)70.31KB19.55KB
plugin-chatbot (index.js)196.19KB46.43KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)251.20KB64.18KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.87KB32.66KB
plugin-gantt (index.js)167.03KB40.87KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)30.21KB8.66KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.74KB2.25KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)8.11KB3.32KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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