@exxatdesignux/ui 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +16 -5
  3. package/consumer-extras/cursor-rules/exxat-ux-discovery-protocol.mdc +122 -0
  4. package/consumer-extras/cursor-rules/exxat-ux-principles.mdc +186 -0
  5. package/consumer-extras/cursor-skills/exxat-senior-ux/SKILL.md +145 -0
  6. package/consumer-extras/patterns/jobs/README.md +59 -0
  7. package/consumer-extras/patterns/jobs/record-detail.md +177 -0
  8. package/consumer-extras/patterns/modern-saas-patterns.md +165 -0
  9. package/dist/components/data-table/index.js +28 -22
  10. package/dist/components/data-table/index.js.map +1 -1
  11. package/dist/components/data-table/pagination.js +28 -22
  12. package/dist/components/data-table/pagination.js.map +1 -1
  13. package/dist/components/data-table/use-table-state.js +20 -17
  14. package/dist/components/data-table/use-table-state.js.map +1 -1
  15. package/dist/components/data-views/hub-table.js +28 -22
  16. package/dist/components/data-views/hub-table.js.map +1 -1
  17. package/dist/components/data-views/index.js +28 -22
  18. package/dist/components/data-views/index.js.map +1 -1
  19. package/dist/components/ui/badge.d.ts +1 -1
  20. package/dist/components/ui/banner.d.ts +3 -3
  21. package/dist/components/ui/button.d.ts +2 -2
  22. package/dist/components/ui/tabs.d.ts +1 -1
  23. package/dist/hooks/use-app-theme.d.ts +1 -1
  24. package/dist/index.js +28 -22
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/data-table/index.tsx +10 -6
  28. package/src/components/data-table/use-table-state.ts +33 -26
  29. package/template/docs/jobs/README.md +59 -0
  30. package/template/docs/jobs/record-detail.md +177 -0
  31. package/template/docs/modern-saas-patterns.md +165 -0
  32. package/tokens/hooks-index.json +2 -2
@@ -0,0 +1,177 @@
1
+ # Job: Review a record's full state
2
+
3
+ > **Surface type:** Standalone route OR side-panel sheet.
4
+ > **Examples in product:** Student profile, Placement record, Library item,
5
+ > Site profile, Team member profile.
6
+
7
+ ## 1. The job
8
+
9
+ The user lands on a single record and needs to **understand its current
10
+ state** in order to **decide what to do next**. Decisions can include:
11
+ contact someone, edit a field, change status, place / unplace, archive,
12
+ follow up later, or simply confirm correctness before another action.
13
+
14
+ This job is **not**: edit (separate flow), bulk action (lives on the list),
15
+ configure workspace settings.
16
+
17
+ ## 2. When this job applies
18
+
19
+ - The record has > ~5 displayable fields.
20
+ - The user reaches it from a list, search, deep link, or notification.
21
+ - The user's next action depends on the record's state (status, ownership,
22
+ data freshness).
23
+ - The record may have children (placements, attachments, comments) that
24
+ matter to the decision.
25
+
26
+ ## 3. Pattern: route vs sheet vs inline
27
+
28
+ | Pattern | When | DS comp |
29
+ |---------|------|---------|
30
+ | **Standalone route** (`/students/[id]`) | Deep link from email / notifications / reports; user may keep tab open; record has > 10 fields | `PrimaryPageTemplate` + `PageHeader` |
31
+ | **Sheet over hub** | User is triaging from a list and may open many records in sequence | `Sheet` over `ListPageTemplate` |
32
+ | **Inline expansion** (`HoverCard`) | Read-only quick preview; < 5 fields | `HoverCard` on row hover |
33
+
34
+ Pick **route** by default. Pick **sheet** if the user's primary path is
35
+ triaging from a list and they typically open many in a single session.
36
+
37
+ ## 4. Information architecture
38
+
39
+ ```
40
+ ┌─ SiteHeader (breadcrumb only) ──────────────────────────────────┐
41
+ │ Dashboard › Students › Jordan Lee │ ← ancestors + title (P1, P2)
42
+ └──────────────────────────────────────────────────────────────────┘
43
+ ┌─ Identity row ──────────────────────────────────────────────────┐
44
+ │ [Avatar] Jordan Lee [Edit] [⋯] │ ← name, ID, primary action + overflow
45
+ │ STU-2026-1042 · jordan.lee@example.edu │
46
+ └──────────────────────────────────────────────────────────────────┘
47
+ ┌─ Status row (above the fold, P13) ──────────────────────────────┐
48
+ │ ● Active ✓ Compliant ⊕ Placed at Sinai │
49
+ └──────────────────────────────────────────────────────────────────┘
50
+ ┌─ Field groups (2-col card grid) ────────────────────────────────┐
51
+ │ Program │ Academic │
52
+ │ Placement │ Compliance │
53
+ └──────────────────────────────────────────────────────────────────┘
54
+ ┌─ Activity timeline (M7, optional) ──────────────────────────────┐
55
+ │ Today · Compliance status updated by Maria │
56
+ │ Tue · Placement assigned · Sinai Hospital │
57
+ └──────────────────────────────────────────────────────────────────┘
58
+ ```
59
+
60
+ ### Layers, in priority order
61
+
62
+ 1. **Identity** — name, system ID (mono), avatar, primary contact link.
63
+ 2. **Status** — every status badge that drives a decision; visible without
64
+ scrolling (P13).
65
+ 3. **Field groups** — 2-column card grid by default. Group by **conceptual
66
+ coherence** (Program / Academic / Placement / Compliance), not by table
67
+ structure.
68
+ 4. **Activity timeline** — if the record changes over time and the history
69
+ matters (M7).
70
+ 5. **Related lists** — placements, comments, attachments — if they exist for
71
+ this entity.
72
+
73
+ ### When to use section tabs
74
+
75
+ - **Don't tab** if total field count is ≤ ~20 and groups are ≤ 4. Single
76
+ scroll is faster.
77
+ - **Do tab** if the record has rich children (placements history,
78
+ supervisors, contracts, conversation threads) and the user typically
79
+ deep-dives into one section.
80
+ - Tabs use `Tabs` + `TabsList` `w-fit` `variant="line"` — never full-width
81
+ (`exxat-tabs-chrome.mdc`).
82
+
83
+ ## 5. Navigation — the way back
84
+
85
+ **Exactly one path back** (P1).
86
+
87
+ | Reached from | Way back |
88
+ |--------------|----------|
89
+ | List route | `SiteHeader` breadcrumb (`Students`) |
90
+ | Deep link / email | `SiteHeader` breadcrumb to the canonical parent |
91
+ | Sheet from hub | `Sheet` close (X / Esc) |
92
+ | Notification | Breadcrumb to canonical parent (not "Back to notification") |
93
+
94
+ **Never** add a body-level "Back to <parent>" button when the breadcrumb is
95
+ present (`exxat-breadcrumbs-no-back.mdc`).
96
+
97
+ ## 6. Actions
98
+
99
+ | Slot | Component | What goes here |
100
+ |------|-----------|----------------|
101
+ | Primary | `PageHeader.primaryAction` (`Button variant="default" size="lg"`) | The single most common next action — "Edit", "Place", "Approve" |
102
+ | Overflow | `Button variant="outline" size="icon-lg"` → `DropdownMenu` | Export, Archive, Duplicate, Share, dangerous-but-rare actions |
103
+ | Inline | Icon-only buttons with tooltip | Copy email, dial phone, open site |
104
+ | Status flip | `Sheet` or inline `Select` | Status changes that are audited (multi-step) |
105
+
106
+ Exactly **one** filled CTA per surface (P3).
107
+
108
+ ## 7. States
109
+
110
+ | State | What to show |
111
+ |-------|--------------|
112
+ | **Loading** | `Skeleton` matching the IA shape (identity → status → cards) — M9 |
113
+ | **Empty / not found** | `EmptyState` + "Return to <list>" outline button |
114
+ | **Error** | `LocalBanner` inside the body; identity row still renders if available |
115
+ | **Stale data** | Subtle "Updated <relative>" in identity meta |
116
+
117
+ ## 8. Accessibility
118
+
119
+ - One `<h1>` (P2) — typically `PageHeader.title`.
120
+ - Status row is a `role="list"`; each badge is `role="listitem"` (already in
121
+ `ListHubStatusBadge`).
122
+ - Inline mailto / tel links are real `<a>`s, not buttons.
123
+ - Tab order: breadcrumb → identity → primary action → overflow → status →
124
+ fields → activity → related.
125
+ - Icon-only actions carry `aria-label` + a tooltip with the same text.
126
+
127
+ ## 9. Modern SaaS analogues
128
+
129
+ | Product | What to study |
130
+ |---------|---------------|
131
+ | **Linear** issue detail | Identity + status + properties + activity; sheet variant for triage |
132
+ | **Stripe Dashboard** customer / charge | Identity + status badges + grouped data + audit log |
133
+ | **Notion** page-as-database row | Inline editing (M5), property panel |
134
+ | **Plain / Pylon** ticket | Conversation-as-document for support records |
135
+
136
+ Cite these in the design brief by name + Mx codes:
137
+ `Linear issue detail (M1, M4, M7)`.
138
+
139
+ ## 10. Anti-patterns
140
+
141
+ | Anti-pattern | Use instead |
142
+ |--------------|-------------|
143
+ | `<h1>` + `PageHeader title` + breadcrumb leaf all repeating the name | One carrier: `PageHeader title` + ancestors-only breadcrumb (P2) |
144
+ | "Back to <parent>" button alongside the breadcrumb | Breadcrumb only (P1) |
145
+ | Two filled CTAs in the header ("Save" + "Submit") | One filled, others outline (P3) |
146
+ | Status only in detail (hidden on list / breadcrumb count) | Status visible everywhere the record appears (M4) |
147
+ | Status communicated by color only | Color + icon + label (`ListHubStatusBadge`) |
148
+ | Full-width section tab bar | `Tabs` `w-fit` (`exxat-tabs-chrome.mdc`) |
149
+ | Centered modal for "Edit name" | Inline edit (M5) or `Sheet` (M3) |
150
+ | Spinner overlay for initial load | `Skeleton` matched to IA (M9) |
151
+ | Activity timeline buried in a tab nobody opens | Show inline below cards, or remove it |
152
+ | New shared "ProfileHero" component invented per entity | Compose: `PageHeader` + identity-row primitives (P8) |
153
+ | `toast()` on save | Inline button label change + `LocalBanner` if persistent |
154
+
155
+ ## 11. Quick checklist (post-build audit)
156
+
157
+ - [ ] Breadcrumb shows ancestors + title; no duplicate name.
158
+ - [ ] No body "Back to <parent>" button.
159
+ - [ ] One H1.
160
+ - [ ] Status row visible without scrolling.
161
+ - [ ] One filled primary action; overflow has the rest.
162
+ - [ ] 2-col card grid for fields (or tabs if ≥ 4 sections / 20+ fields).
163
+ - [ ] `Skeleton` matches the IA on load; empty state designed for "not found".
164
+ - [ ] Tab order: breadcrumb → identity → primary → overflow → fields.
165
+ - [ ] All status chips use `ListHubStatusBadge` + `lib/list-status-badges.ts`.
166
+ - [ ] No `toast()`, no Vaul, no pixel-copy of legacy.
167
+
168
+ ## 12. Reference
169
+
170
+ - [`../../../.cursor/skills/exxat-senior-ux/SKILL.md`](../../../.cursor/skills/exxat-senior-ux/SKILL.md)
171
+ - [`../../../.cursor/rules/exxat-ux-discovery-protocol.mdc`](../../../.cursor/rules/exxat-ux-discovery-protocol.mdc)
172
+ - [`../../../.cursor/rules/exxat-ux-principles.mdc`](../../../.cursor/rules/exxat-ux-principles.mdc)
173
+ - [`../../../.cursor/rules/exxat-breadcrumbs-no-back.mdc`](../../../.cursor/rules/exxat-breadcrumbs-no-back.mdc)
174
+ - [`../../../.cursor/rules/exxat-tabs-chrome.mdc`](../../../.cursor/rules/exxat-tabs-chrome.mdc)
175
+ - [`../modern-saas-patterns.md`](../modern-saas-patterns.md)
176
+ - [`../blueprints/page-header.md`](../blueprints/page-header.md)
177
+ - [`../component-selection-guide.md`](../component-selection-guide.md) §1
@@ -0,0 +1,165 @@
1
+ # Modern SaaS patterns — the canon Exxat DS works against
2
+
3
+ > **Audience:** humans + AI agents.
4
+ > **Companion to:** [`exxat-senior-ux/SKILL.md`](../../../.cursor/skills/exxat-senior-ux/SKILL.md),
5
+ > [`exxat-ux-principles.mdc`](../../../.cursor/rules/exxat-ux-principles.mdc),
6
+ > [`jobs/`](./jobs/).
7
+
8
+ A senior designer recognizes the work. This doc names the patterns the best
9
+ products converged on in 2024–2026, so the agent can pattern-match instead
10
+ of inventing. **Cite by name + code** when you reference one in a design brief
11
+ (e.g. `Linear issue detail (M1, M4, M7)`).
12
+
13
+ ## Canon products — cite by product, not by URL
14
+
15
+ | Product | What to learn from it |
16
+ |---------|-----------------------|
17
+ | **Linear** | Issue detail, command palette spine, density, keyboard-first, presence, "less chrome" |
18
+ | **Notion** | Inline editing, property panels, page-as-database, blocks, slash-commands |
19
+ | **Stripe Dashboard** | Record home, status chips, activity log, money-grade precision |
20
+ | **Figma** | Multiplayer, presence, side panel inspector, infinite-canvas chrome discipline |
21
+ | **Vercel** | Type-first hierarchy, dark-first, minimal chrome, log streams |
22
+ | **Height** | View tabs (table/board/timeline), custom fields, view parity |
23
+ | **Plain / Pylon** | Support detail = identity + timeline + actions; conversation-as-document |
24
+ | **Cron / Amie** | Keyboard-only flows, dense calendar, command surface |
25
+ | **Raycast** | Command palette as full app, extension surface, deep keyboard |
26
+ | **Arc Browser** | Control-stripped, content-first, sidebar as the only chrome |
27
+
28
+ ## The 12 patterns that make products feel modern
29
+
30
+ ### M1. Content-first chrome
31
+ The data is the star. Sidebars collapse to icons. Headers stay 48–56px. Page
32
+ surfaces are generous. No purple gradient on the top bar.
33
+
34
+ - **In DS:** `SiteHeader` ~h-12; `PrimaryPageTemplate` body has generous
35
+ `max-w-[1440px]`; sidebar collapsible (`SidebarTrigger`).
36
+
37
+ ### M2. Command palette as the spine
38
+ `⌘K` opens search + navigation + AI + recent. It is the primary navigation
39
+ for power users. The sidebar exists for orientation, not speed.
40
+
41
+ - **In DS:** `CommandMenu` (⌘K) + Ask Leo split (⌘⌥K).
42
+ Rule: `exxat-command-menu.mdc`.
43
+
44
+ ### M3. Side-panel detail over modal dialog
45
+ For non-destructive context-keeping actions (properties, export, invite,
46
+ preview, single-step compose), use a slide-in sheet — never a centered modal.
47
+
48
+ - **In DS:** `Sheet` (no Vaul).
49
+ Rule: `exxat-drawer-vs-dialog.mdc`. Pattern:
50
+ `apps/web/docs/drawer-vs-dialog-pattern.md`.
51
+
52
+ ### M4. Status as a first-class citizen
53
+ Colored dots, chips, counts visible everywhere data appears. Status isn't
54
+ hidden in a detail page; it appears on rows, cards, headers, navigation.
55
+
56
+ - **In DS:** `ListHubStatusBadge` + `lib/list-status-badges.ts`. Available per
57
+ row, board card, detail header, breadcrumb count slot.
58
+
59
+ ### M5. Inline editing where data is read
60
+ Click the value, edit it. No bounce to forms for single-field changes.
61
+
62
+ - **In DS:** Inline-edit primitive emerging; until then, use a `Sheet` for
63
+ multi-field, popover or contenteditable for single-field. Principle: P15.
64
+
65
+ ### M6. Optimistic UI + undo
66
+ Low-risk actions (favorite, archive, status flip) feel instant; reconcile on
67
+ error. Undo via banner or in-place affordance — **never** toast
68
+ (`exxat-no-toast.mdc`).
69
+
70
+ - **In DS:** Optimistic state in `useTableState` selection ops; undo via
71
+ `LocalBanner` with action.
72
+
73
+ ### M7. Activity timeline on every record
74
+ Who did what when. The audit log is also a navigation device.
75
+
76
+ - **In DS:** `ActivityTimeline` primitive emerging. Compose from
77
+ `lib/mock/activity` shape until then.
78
+
79
+ ### M8. Empty states with one CTA + one sentence
80
+ Illustration optional. One sentence of context. One primary action. Never a
81
+ wall of help text or a tutorial inline.
82
+
83
+ - **In DS:** `EmptyState` primitive; voice from `docs/voice-and-tone.md`.
84
+
85
+ ### M9. Skeleton, not spinner
86
+ Loading shapes content. Spinners only for indeterminate < 200ms.
87
+
88
+ - **In DS:** `Skeleton` primitive; suspense boundaries shape the body.
89
+
90
+ ### M10. Type-first hierarchy
91
+ Weight, size, color do the heavy lifting before borders, boxes, or tints.
92
+ Ornament is rare and intentional.
93
+
94
+ - **In DS:** Token scale; body in `Inter`; display in `Ivy Presto` (only on
95
+ `PageHeader` H1 by default).
96
+
97
+ ### M11. Real-time presence + collaboration as default
98
+ If the product is shared, show who else is here — face rails, cursors,
99
+ status. Inviting others is one click from the page.
100
+
101
+ - **In DS:** `PageHeader variant="collaboration"` face rail +
102
+ `InviteCollaboratorsDrawer`.
103
+ Rule: `exxat-collaboration-access.mdc`.
104
+
105
+ ### M12. AI as opt-in sidecar
106
+ Ask Leo style — never the primary path, never auto-runs on a record, always
107
+ discoverable, always cancellable. Deterministic path still exists.
108
+
109
+ - **In DS:** `AskLeoSidebar` + `⌘⌥K` toggle; never a "magic" button on a
110
+ destructive surface.
111
+
112
+ ---
113
+
114
+ ## The anti-modern signals (what NOT to do)
115
+
116
+ | Signal | Why it's dated |
117
+ |--------|----------------|
118
+ | Toasts everywhere | Modern products use inline + banner + undo. Toasts get missed. |
119
+ | Full-width tab bars stretched edge-to-edge | 2010s pattern. Modern uses `w-fit` segmented controls. |
120
+ | Centered modals for everything | Sheets and routes carry more without breaking flow. |
121
+ | Color-coded sidebar sections | Modern is monochrome chrome + status color on data. |
122
+ | Modal wizards with progress bar | If it has > 3 steps, give it a route. |
123
+ | Spinners on initial load | Skeleton the content shape instead. |
124
+ | Hover-only affordances | Touch + keyboard need parity. |
125
+ | Icons without text labels in primary nav | Recognition fails for new users. |
126
+ | "Beautiful" gradients on chrome | Data should be beautiful; chrome should disappear. |
127
+ | Aggressive empty-state illustrations | One sentence + one action beats illustration + 5 tips. |
128
+ | Edit-takes-you-to-a-form for single fields | Inline edit (M5). |
129
+ | "Back to <parent>" button alongside breadcrumb | P1 — choose one. |
130
+ | Auto-running AI on record open | M12 — opt-in only. |
131
+
132
+ ---
133
+
134
+ ## Density layers (Linear / Vercel model)
135
+
136
+ | Layer | When |
137
+ |-------|------|
138
+ | **Cozy** (default) | Most surfaces; balances scan + breathing room |
139
+ | **Compact** | Power users on daily workflow; tables, command results |
140
+ | **Comfortable** | Accessibility (low-vision, motor); marketing-adjacent |
141
+
142
+ Provide a user-level setting where audiences differ; default to **cozy**.
143
+ Don't fork stacks per density.
144
+
145
+ ---
146
+
147
+ ## How to use this doc in a design brief
148
+
149
+ ```
150
+ Reference (modern): Linear issue detail (M1, M4, M7),
151
+ Stripe customer record (M4, M11)
152
+ ```
153
+
154
+ Cite the **patterns (Mx)** the agent applied, not just the product. Forces
155
+ clear thinking and lets reviewers verify intent.
156
+
157
+ ## See also
158
+
159
+ - [`../../../.cursor/skills/exxat-senior-ux/SKILL.md`](../../../.cursor/skills/exxat-senior-ux/SKILL.md) — persona
160
+ - [`../../../.cursor/rules/exxat-ux-discovery-protocol.mdc`](../../../.cursor/rules/exxat-ux-discovery-protocol.mdc) — brief gate
161
+ - [`../../../.cursor/rules/exxat-ux-principles.mdc`](../../../.cursor/rules/exxat-ux-principles.mdc) — principles + breaks
162
+ - [`./jobs/`](./jobs/) — canonical reference per job type
163
+ - [`./component-selection-guide.md`](./component-selection-guide.md) — picking the composition
164
+ - [`./blueprints/`](./blueprints/) — framework-agnostic surface specs
165
+ - [`./voice-and-tone.md`](./voice-and-tone.md) — copy rules
@@ -1443,15 +1443,6 @@ function useTableState(data, columns, defaultSort, paginationOverride, syncedSea
1443
1443
  });
1444
1444
  return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, groupRows]) => ({ groupKey: key, groupLabel: key, rows: groupRows }));
1445
1445
  }, [rows, groupBy]);
1446
- const LOCKED_KEYS = React9.useMemo(() => new Set(Object.keys(lockedPins)), [lockedPins]);
1447
- const effectivePins = React9.useMemo(() => {
1448
- if (isReflowViewport || !isOverflowing) return {};
1449
- const result = {};
1450
- for (const [key, pin] of Object.entries(colPins)) {
1451
- result[key] = pin;
1452
- }
1453
- return result;
1454
- }, [colPins, isOverflowing, isReflowViewport]);
1455
1446
  const displayCols = React9.useMemo(() => {
1456
1447
  const leftPinned = [];
1457
1448
  const free = [];
@@ -1471,6 +1462,19 @@ function useTableState(data, columns, defaultSort, paginationOverride, syncedSea
1471
1462
  }
1472
1463
  return out;
1473
1464
  }, [colOrder, colPins, hiddenCols, columnsByKey]);
1465
+ const totalWidth = React9.useMemo(
1466
+ () => displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0),
1467
+ [displayCols, colWidths]
1468
+ );
1469
+ const LOCKED_KEYS = React9.useMemo(() => new Set(Object.keys(lockedPins)), [lockedPins]);
1470
+ const effectivePins = React9.useMemo(() => {
1471
+ if (isReflowViewport || !isOverflowing) return {};
1472
+ const result = {};
1473
+ for (const [key, pin] of Object.entries(colPins)) {
1474
+ result[key] = pin;
1475
+ }
1476
+ return result;
1477
+ }, [colPins, isOverflowing, isReflowViewport]);
1474
1478
  function startResize(key, e) {
1475
1479
  e.preventDefault();
1476
1480
  e.stopPropagation();
@@ -1531,18 +1535,21 @@ function useTableState(data, columns, defaultSort, paginationOverride, syncedSea
1531
1535
  function toggleWrap(key) {
1532
1536
  setColWrap((p) => ({ ...p, [key]: !p[key] }));
1533
1537
  }
1534
- function checkOverflow() {
1538
+ const checkOverflow = React9.useCallback(() => {
1535
1539
  const el = scrollRef.current;
1536
1540
  if (!el) return;
1537
- setIsOverflowing(el.scrollWidth > el.clientWidth + 1);
1538
- }
1541
+ setIsOverflowing(totalWidth > el.clientWidth + 1);
1542
+ }, [totalWidth]);
1539
1543
  function handleScroll() {
1540
1544
  const el = scrollRef.current;
1541
1545
  if (!el) return;
1542
1546
  setScrolled(el.scrollLeft > 1);
1543
1547
  setScrollEnd(el.scrollLeft >= el.scrollWidth - el.clientWidth - 1);
1544
- setIsOverflowing(el.scrollWidth > el.clientWidth + 1);
1548
+ setIsOverflowing(totalWidth > el.clientWidth + 1);
1545
1549
  }
1550
+ React9.useLayoutEffect(() => {
1551
+ checkOverflow();
1552
+ }, [checkOverflow]);
1546
1553
  function getRowId(row, index, getIdFn) {
1547
1554
  return getIdFn ? getIdFn(row, index) : row.id ?? index;
1548
1555
  }
@@ -1595,10 +1602,6 @@ function useTableState(data, columns, defaultSort, paginationOverride, syncedSea
1595
1602
  },
1596
1603
  [effectivePins, isReflowViewport, stickyOffsets]
1597
1604
  );
1598
- const totalWidth = React9.useMemo(
1599
- () => displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0),
1600
- [displayCols, colWidths]
1601
- );
1602
1605
  return {
1603
1606
  // Sort
1604
1607
  sortRules,
@@ -2338,7 +2341,7 @@ function DataTableInner({
2338
2341
  setSheetOpen,
2339
2342
  setSheetInitialPanel
2340
2343
  } = state;
2341
- React9.useEffect(() => {
2344
+ React9.useLayoutEffect(() => {
2342
2345
  const syncScrollport = () => {
2343
2346
  const el2 = scrollRef.current;
2344
2347
  if (el2) {
@@ -2351,8 +2354,10 @@ function DataTableInner({
2351
2354
  if (!el) return;
2352
2355
  const ro = new ResizeObserver(syncScrollport);
2353
2356
  ro.observe(el);
2357
+ const table = el.querySelector("table");
2358
+ if (table) ro.observe(table);
2354
2359
  return () => ro.disconnect();
2355
- }, []);
2360
+ }, [totalWidth, displayCols.length, checkOverflow, scrollRef]);
2356
2361
  const columnMenuPendingActionRef = React9.useRef(null);
2357
2362
  const pinnedScrollHintDoneRef = React9.useRef(false);
2358
2363
  React9.useEffect(() => {
@@ -2589,11 +2594,12 @@ function DataTableInner({
2589
2594
  children: /* @__PURE__ */ jsxs(
2590
2595
  "table",
2591
2596
  {
2592
- className: "w-full text-sm border-separate border-spacing-0",
2597
+ className: "text-sm border-separate border-spacing-0",
2593
2598
  style: {
2594
2599
  tableLayout: "fixed",
2595
- minWidth: totalWidth,
2596
- width: headerIsStuck ? floatingHeaderTableWidth : void 0
2600
+ // Explicit column-sum width — `w-full` made the grid stretch to the scrollport
2601
+ // so scrollWidth === clientWidth and the overflow-gated pin rule never fired.
2602
+ width: totalWidth
2597
2603
  },
2598
2604
  children: [
2599
2605
  /* @__PURE__ */ jsx("colgroup", { children: displayCols.map((col) => /* @__PURE__ */ jsx("col", { style: { width: colWidths[col.key] ?? col.width ?? 100 } }, col.key)) }),