From 361d3360eaa1e9106989910c4870c931182a230a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:25:41 +0000 Subject: [PATCH 1/4] fix(plugin-form): keep a 1rem viewport gutter on lg/xl ModalForm dialogs from sm up An lg (672px) or xl (1024px) dialog took the primitive's w-full, so on any window between sm and its max width it was as wide as the window, at x = 0. Both bounded wide sizes now also read one shared class, sm:w-[calc(100%-2rem)], under their own max-w. sm, default and full, and the phone sheet below sm, are unchanged. Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- packages/plugin-form/src/ModalForm.tsx | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 05d4eec38f..dac49d6206 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -252,18 +252,45 @@ export interface ModalFormProps { className?: string; } +/** + * The viewport gutter the bounded wide sizes keep from `sm` up + * (objectui#12593): a width of the window less 1rem on each side, under the + * size's own `max-w`. ONE value, read by both `lg` and `xl`. + * + * Without it, the dialog primitive's `w-full` makes an `lg` (672px) or `xl` + * (1024px) dialog exactly as wide as any window narrower than its max width, + * at x = 0, edge to edge. With it, the dialog is the smaller of its max width + * and the window less 2rem, and the primitive's `left-[50%]` plus + * `translate-x-[-50%]` centres it with an equal 1rem either side. Above the + * max width plus 2rem the size is exactly as wide as before. + * + * `100%`, not `100vw`: the dialog is `fixed`, so `100%` is its containing + * block, the viewport less any classic scrollbar, the same box `left-[50%]` + * centres in. `100vw` counts the scrollbar, so each gutter would lose half + * its width. + * + * `sm:` only: below `sm` the dialog is the full-screen phone sheet, unchanged. + * `sm` (384px) and `default` (512px) never reach the window edge at or above + * 640px, and `full` keeps its own `95vw`, so none of the three reads it. The + * class string is literal so Tailwind's source scan emits it. + */ +const BOUNDED_SIZE_VIEWPORT_GUTTER = 'sm:w-[calc(100%-2rem)]'; + /** * Size class map for the dialog content. * * Uses `sm:` prefix so that `tailwind-merge` correctly resolves the conflict * with MobileDialogContent's base `sm:max-w-lg` class. On mobile (< sm) the * dialog is already full-screen, so max-width only matters at sm+ breakpoints. + * + * The bounded wide sizes, `lg` and `xl`, also read + * {@link BOUNDED_SIZE_VIEWPORT_GUTTER} (objectui#12593). */ const modalSizeClasses: Record = { sm: 'sm:max-w-sm', default: 'sm:max-w-lg', - lg: 'sm:max-w-2xl', - xl: 'sm:max-w-5xl', + lg: `sm:max-w-2xl ${BOUNDED_SIZE_VIEWPORT_GUTTER}`, + xl: `sm:max-w-5xl ${BOUNDED_SIZE_VIEWPORT_GUTTER}`, full: 'sm:max-w-[95vw] sm:w-full', }; From 7e5c00943f0b3197def60e779f33bfd2ac48ccfc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:26:59 +0000 Subject: [PATCH 2/4] test(plugin-form): pin the lg/xl viewport gutter and the unchanged sizes (objectui#12593) Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .../modalViewportGutter-12593.test.tsx | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx diff --git a/packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx b/packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx new file mode 100644 index 0000000000..382e158d15 --- /dev/null +++ b/packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx @@ -0,0 +1,164 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The bounded wide dialog sizes keep a viewport gutter from `sm` up + * (objectui#12593). + * + * `lg` (`sm:max-w-2xl`, 672px) and `xl` (`sm:max-w-5xl`, 1024px) took the + * dialog primitive's `w-full`, so on a window between `sm` (640px) and the max + * width the dialog was as wide as the window, at x = 0. Both now read ONE + * shared class, a width of the window less 1rem on each side, under their own + * `max-w`. + * + * ## What it reads + * + * The real `ModalForm`, mounted as `AppContent` mounts it, and the class list + * on the dialog element after `tailwind-merge` has resolved it against + * `MobileDialogContent`'s base classes. jsdom computes no layout, so the pins + * read classes. The rendered widths are a Chromium reading, recorded on the + * pull request, not here. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ModalForm, type ModalFormSchema } from '../ModalForm'; + +registerAllFields(); + +afterEach(() => cleanup()); + +/** The shared gutter: the window less 1rem on each side, from `sm` up. */ +const GUTTER = 'sm:w-[calc(100%-2rem)]'; +/** Any width class written for the `sm` tier and up. */ +const isSmWidth = (t: string) => t.startsWith('sm:w-'); + +const TWO = { + name: 'thing', + fields: { + name: { type: 'text', label: 'Name' }, + code: { type: 'text', label: 'Code' }, + }, +}; + +const opt = (v: string) => [{ label: v, value: v }]; + +/** Twelve plain fields plus one textarea: the default size answers `xl`. */ +const TWELVE = { + name: 'thing', + fields: { + name: { type: 'text', label: 'Name' }, + code: { type: 'text', label: 'Code' }, + email: { type: 'email', label: 'Email' }, + phone: { type: 'text', label: 'Phone' }, + website: { type: 'url', label: 'Website' }, + status: { type: 'select', label: 'Status', options: opt('open') }, + stage: { type: 'select', label: 'Stage', options: opt('new') }, + source: { type: 'select', label: 'Source', options: opt('web') }, + amount: { type: 'currency', label: 'Amount' }, + quantity: { type: 'number', label: 'Quantity' }, + due: { type: 'date', label: 'Due' }, + notes: { type: 'textarea', label: 'Notes' }, + }, +}; + +const ds = (def: unknown) => ({ + getObjectSchema: vi.fn().mockResolvedValue(def), + findOne: vi.fn().mockResolvedValue({}), + find: vi.fn().mockResolvedValue({ data: [] }), + create: vi.fn(), + update: vi.fn(), +}); + +/** Mount the dialog, wait for its fields, and return its class tokens. */ +async function dialogTokens( + def: { fields: Record }, + modalSize?: ModalFormSchema['modalSize'], +): Promise { + const first = Object.keys(def.fields)[0]; + render( + , + ); + await waitFor(() => { + if (!document.body.querySelector(`[name="${first}"]`)) throw new Error('form not drawn yet'); + }); + const dialog = document.body.querySelector('[role="dialog"]') as HTMLElement; + const tokens = dialog.className.split(/\s+/).filter(Boolean); + cleanup(); + return tokens; +} + +const minus = (a: string[], b: string[]) => a.filter((t) => !b.includes(t)).sort(); + +describe('the bounded wide sizes keep a viewport gutter from sm up (objectui#12593)', () => { + it('lg and xl both carry the one shared gutter, under their own max width', async () => { + const lg = await dialogTokens(TWO, 'lg'); + const xl = await dialogTokens(TWO, 'xl'); + + expect(lg).toContain('sm:max-w-2xl'); + expect(xl).toContain('sm:max-w-5xl'); + // One value, read by both: the same single sm-tier width on each. + expect(lg.filter(isSmWidth)).toEqual([GUTTER]); + expect(xl.filter(isSmWidth)).toEqual([GUTTER]); + // tailwind-merge kept the size's max width over the primitive's base one. + expect(lg).not.toContain('sm:max-w-lg'); + expect(xl).not.toContain('sm:max-w-lg'); + }); + + it('a field-heavy dialog with no authored size, the console\'s New and Edit, carries it', async () => { + const heavy = await dialogTokens(TWELVE); + expect(heavy).toContain('sm:max-w-5xl'); + expect(heavy.filter(isSmWidth)).toEqual([GUTTER]); + }); + + it('below sm nothing changes: against default, lg and xl differ only in sm-tier classes', async () => { + const def = await dialogTokens(TWO, 'default'); + const lg = await dialogTokens(TWO, 'lg'); + const xl = await dialogTokens(TWO, 'xl'); + + expect(minus(lg, def)).toEqual([GUTTER, 'sm:max-w-2xl'].sort()); + expect(minus(xl, def)).toEqual([GUTTER, 'sm:max-w-5xl'].sort()); + expect(minus(def, lg)).toEqual(['sm:max-w-lg']); + expect(minus(def, xl)).toEqual(['sm:max-w-lg']); + for (const added of [...minus(lg, def), ...minus(xl, def)]) { + expect(added.startsWith('sm:')).toBe(true); + } + // The phone sheet and the height rules are still on both. + for (const tokens of [lg, xl]) { + expect(tokens).toEqual( + expect.arrayContaining(['fixed', 'inset-0', 'w-full', 'h-[100dvh]', 'sm:h-auto', 'sm:max-h-[90vh]']), + ); + } + }); + + it('CONTROL: sm, default and full are unchanged and read no gutter', async () => { + const sm = await dialogTokens(TWO, 'sm'); + const def = await dialogTokens(TWO, 'default'); + const full = await dialogTokens(TWO, 'full'); + + expect(sm).toContain('sm:max-w-sm'); + expect(sm.filter(isSmWidth)).toEqual([]); + expect(def).toContain('sm:max-w-lg'); + expect(def.filter(isSmWidth)).toEqual([]); + expect(full).toContain('sm:max-w-[95vw]'); + expect(full.filter(isSmWidth)).toEqual(['sm:w-full']); + }); +}); From 1f09c288bef2726f57c401ae575ebca4a31596bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:29:27 +0000 Subject: [PATCH 3/4] chore(plugin-form): changeset for the lg/xl dialog viewport gutter (objectui#12593) Also words the 100vw note exactly: each gutter would lose half the scrollbar's width, as measured in Chromium with classic scrollbars. Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .changeset/12593-modal-viewport-gutter.md | 9 +++++++++ packages/plugin-form/src/ModalForm.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .changeset/12593-modal-viewport-gutter.md diff --git a/.changeset/12593-modal-viewport-gutter.md b/.changeset/12593-modal-viewport-gutter.md new file mode 100644 index 0000000000..db84cbbcd3 --- /dev/null +++ b/.changeset/12593-modal-viewport-gutter.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-form': patch +--- + +An `lg` or `xl` `ModalForm` dialog keeps a 1rem gutter on each side of the window from `sm` (640px) up (objectui#12593). + +The two sizes are bounded at 672px (`lg`) and 1024px (`xl`), but the dialog took the full width of any window narrower than that, at x = 0, with no margin. Since every field-heavy create or edit in the console opens `lg` or `xl`, a laptop or tablet window between 640px and 1056px drew those dialogs edge to edge. Both sizes now read one shared width, the window less 2rem, under their own max width, so the dialog is centred with an equal gutter. On a window wider than the max width plus 2rem each size is exactly as wide as before. + +Unchanged: the `sm`, `default` and `full` sizes, the full-screen sheet below `sm`, and the dialog's height rules. Nothing is added to the package entry: no export, prop, type member or `modalSize` value. diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index dac49d6206..86e75a7758 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -267,7 +267,7 @@ export interface ModalFormProps { * `100%`, not `100vw`: the dialog is `fixed`, so `100%` is its containing * block, the viewport less any classic scrollbar, the same box `left-[50%]` * centres in. `100vw` counts the scrollbar, so each gutter would lose half - * its width. + * the scrollbar's width. * * `sm:` only: below `sm` the dialog is the full-screen phone sheet, unchanged. * `sm` (384px) and `default` (512px) never reach the window edge at or above From c57843d95d85d0c3dad89fbf4f4be6252528621a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:40:23 +0000 Subject: [PATCH 4/4] test(plugin-form): type the 12593 pin's data-source mock as DataSource, not any Claude-Session: https://claude.ai/code/session_01JZnppgHRp4sREcouhkQYDB Co-authored-by: Claude --- .../src/__tests__/modalViewportGutter-12593.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx b/packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx index 382e158d15..8a730ff63c 100644 --- a/packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx +++ b/packages/plugin-form/src/__tests__/modalViewportGutter-12593.test.tsx @@ -29,6 +29,7 @@ import React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, waitFor, cleanup } from '@testing-library/react'; import { registerAllFields } from '@object-ui/fields'; +import type { DataSource } from '@object-ui/types'; import { ModalForm, type ModalFormSchema } from '../ModalForm'; registerAllFields(); @@ -75,7 +76,7 @@ const ds = (def: unknown) => ({ find: vi.fn().mockResolvedValue({ data: [] }), create: vi.fn(), update: vi.fn(), -}); +}) as unknown as DataSource; /** Mount the dialog, wait for its fields, and return its class tokens. */ async function dialogTokens( @@ -94,7 +95,7 @@ async function dialogTokens( fields: Object.keys(def.fields), ...(modalSize ? { modalSize } : {}), }} - dataSource={ds(def) as any} + dataSource={ds(def)} />, ); await waitFor(() => {