From 4a32004563bfab7c1e58f91d0b042dc13637f1ec Mon Sep 17 00:00:00 2001 From: nguyenngothuong Date: Sat, 8 Aug 2026 23:13:26 +0700 Subject: [PATCH 1/2] The peek is the record page, at a size the reader picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a row gave a panel that listed the model's fields, drew the stages as tags and offered its own Edit. The record page drew the same record from the same model with a different header, a highlight strip, a stage path, four tabs and a different Edit. Two renderings of one thing drift, and this pair had already started: the peek showed the *row* the list had fetched, so a column the list does not select was missing from it with nothing saying so. `RecordDetail` is that rendering, once. `RecordScreen` is now `` around it, and the list's peek is a `Drawer` around it. A field added to the layout appears in both because there is only one place to add it to. WHAT THE READER GETS. Three sizes — the 452px strip it always was, a 920px column, and the whole window — on two buttons in the drawer header, and the choice is remembered per browser. `wide` is the size that makes a peek an answer rather than a preview: four sections of two-column fields do not fit in a strip, and in one they wrap to a line each and the reader scrolls a page that would have fitted on a screen. Escape steps down through the sizes before it closes, because a reader who went full screen and pressed it meant "give me the list back", and closing instead is not undoable — the peek does not remember which record it held. At the narrowest size the highlight strip and the stage path are dropped. Both are horizontal by nature and neither survives a 452px column: three highlights become four wrapped lines, which reads as damage rather than density. DRAWERS NOW PORTAL TO THE DOCUMENT. That became necessary rather than tidy: the peek draws `RecordDetail`, which has an Edit that opens a drawer of its own, and rendered in place the edit panel was laid out inside the peek and clipped by it. `.shell` is the only positioned ancestor and it fills the window, so `fixed` covers the rectangle `absolute` did — no pixel moves for the nine drawers that were already fine. Tests: `drawerSize` is a state machine and a stored preference, so the ends and the failure are what is pinned — widen and narrow saturate rather than wrap, an unknown stored value falls back, and a `localStorage` that throws does not take the record page with it. Safari in private mode throws on `setItem`, and a peek is not worth a blank screen. Verified against the running sample with Playwright: 452 → 920 → 1440 on a 1440 viewport, Widen disabled at the end, three Escapes to close, the size surviving a reload, and the nested edit drawer landing at the window edge rather than inside the panel. `npm test` is 140 passed with the one failure that is pre-existing on this commit's parent — `liveRecords.test.ts` asserts `28 Aug 2026` for a `T23:18:06Z` timestamp, which is 29 August anywhere east of UTC (#30 covers the application-side half of that). Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: nguyenngothuong --- .../src/design/primitives/Drawer.module.css | 36 +++++- .../crm-web/src/design/primitives/Drawer.tsx | 102 ++++++++++++++--- .../primitives/__tests__/drawerSize.test.ts | 83 ++++++++++++++ .../src/design/primitives/drawerSize.ts | 62 ++++++++++ .../crm-web/src/features/sales/ListScreen.tsx | 106 +++++++----------- .../src/features/sales/RecordScreen.tsx | 84 +++++++++++--- 6 files changed, 373 insertions(+), 100 deletions(-) create mode 100644 samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts create mode 100644 samples/crm-web/src/design/primitives/drawerSize.ts diff --git a/samples/crm-web/src/design/primitives/Drawer.module.css b/samples/crm-web/src/design/primitives/Drawer.module.css index 0d238f2f..01fb8415 100644 --- a/samples/crm-web/src/design/primitives/Drawer.module.css +++ b/samples/crm-web/src/design/primitives/Drawer.module.css @@ -1,5 +1,5 @@ .scrim { - position: absolute; + position: fixed; inset: 0; z-index: 30; display: flex; @@ -7,6 +7,11 @@ background: rgba(0, 0, 0, 0.18); } +/* Nothing behind a full-width panel is visible, so there is nothing for the scrim to dim. */ +.scrim:has(.full) { + background: transparent; +} + .panel { position: relative; width: var(--drawer-width, 452px); @@ -102,3 +107,32 @@ line-height: 1.2; white-space: nowrap; } + +/* + * The three sizes. + * + * Widths in `min()` against the viewport so a narrow laptop gets the whole screen rather than a + * panel wider than the window with its close button off the edge. `full` drops the scrim's + * darkening and the panel's shadow: at full width there is nothing behind to dim, and a shadow + * cast by an edge that is the screen edge reads as a rendering fault. + */ +.peek { + width: var(--drawer-width, 452px); +} + +.wide { + width: min(920px, 100%); +} + +.full { + width: 100%; + border-left: 0; + box-shadow: none; +} + +.headControls { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} diff --git a/samples/crm-web/src/design/primitives/Drawer.tsx b/samples/crm-web/src/design/primitives/Drawer.tsx index cb84e3a1..f8e8697f 100644 --- a/samples/crm-web/src/design/primitives/Drawer.tsx +++ b/samples/crm-web/src/design/primitives/Drawer.tsx @@ -1,6 +1,9 @@ import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import type { ReactNode } from 'react' import { Button } from './Button' +import { narrow, widen } from './drawerSize' +import type { DrawerSize } from './drawerSize' import styles from './Drawer.module.css' export interface DrawerProps { @@ -14,6 +17,13 @@ export interface DrawerProps { actions?: ReactNode onClose: () => void width?: number + /** + * How much of the screen it takes. Omit it and the drawer is the strip it always was — the + * forms that open one are the size of their fields and have nothing to widen into. + */ + size?: DrawerSize + /** Supplied with {@link size} to offer the widen and narrow controls. */ + onSizeChange?: (size: DrawerSize) => void children: ReactNode } @@ -24,7 +34,17 @@ export interface DrawerProps { * keyboard user opens and then cannot leave; both are a handful of lines and both are the first * things reported when this pattern ships without them. */ -export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, children }: DrawerProps) { +export function Drawer({ + title, + eyebrow, + subtitle, + actions, + onClose, + width, + size, + onSizeChange, + children, +}: DrawerProps) { const panel = useRef(null) const opener = useRef(null) @@ -33,7 +53,17 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil panel.current?.focus() function onKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') onClose() + if (event.key !== 'Escape') return + + // Escape steps back down through the sizes before it closes. A reader who went full screen + // and pressed Escape meant "give me the page back", not "throw away what I was reading" — + // and the second is not undoable, because the peek does not remember which record it held. + if (size && onSizeChange && size !== 'peek') { + onSizeChange(narrow(size)) + return + } + + onClose() } document.addEventListener('keydown', onKeyDown) @@ -46,7 +76,15 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil } }, [onClose]) - return ( + /* + * PORTALLED TO THE DOCUMENT, so a drawer opened from inside a drawer covers the window rather + * than the panel it was opened from. That became reachable when the list's peek started drawing + * `RecordDetail`, which has an Edit of its own: rendered in place, the edit panel was laid out + * inside the peek and clipped by it, which reads as a broken control rather than a nested one. + * `.shell` is the only positioned ancestor and it fills the window, so `fixed` covers the same + * rectangle `absolute` did — this moves no pixels for the nine drawers that were already fine. + */ + return createPortal(
{ @@ -59,21 +97,54 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil aria-modal="true" aria-label={typeof title === 'string' ? title : undefined} tabIndex={-1} - className={styles.panel} - style={width ? ({ '--drawer-width': `${width}px` } as React.CSSProperties) : undefined} + className={`${styles.panel} ${size ? (styles[size] ?? '') : ''}`} + style={ + width && (size ?? 'peek') === 'peek' + ? ({ '--drawer-width': `${width}px` } as React.CSSProperties) + : undefined + } >
{eyebrow ? {eyebrow} : null} - +
+ {size && onSizeChange ? ( + <> + {/* + Two buttons rather than one toggle. A single "expand" that cycles peek → wide → + full → peek is one click away from the size you wanted and three from the one + you left, and a reader cannot tell which way it will go before pressing it. + */} + + + + ) : null} + +
{title}
{subtitle ?
{subtitle}
: null} @@ -81,7 +152,8 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil
{children}
- + , + document.body, ) } diff --git a/samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts b/samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts new file mode 100644 index 00000000..79ecce4b --- /dev/null +++ b/samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts @@ -0,0 +1,83 @@ +/** + * The peek's size, as a state machine and as a stored preference. + * + * The interesting cases are the two ends and the browser that refuses storage. A wrap at either + * end would take a reader from full screen back to a strip without them asking, and a throw from + * `localStorage` would take the whole record page with it — Safari in private mode throws on + * `setItem`, and a peek is not worth a blank screen. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DEFAULT_DRAWER_SIZE, + isDrawerSize, + narrow, + rememberDrawerSize, + storedDrawerSize, + widen, +} from '../drawerSize' + +afterEach(() => { + localStorage.clear() + vi.restoreAllMocks() +}) + +describe('widen and narrow', () => { + it('steps up through the three sizes', () => { + expect(widen('peek')).toBe('wide') + expect(widen('wide')).toBe('full') + }) + + it('steps back down', () => { + expect(narrow('full')).toBe('wide') + expect(narrow('wide')).toBe('peek') + }) + + it('saturates rather than wrapping', () => { + expect(widen('full')).toBe('full') + expect(narrow('peek')).toBe('peek') + }) +}) + +describe('the stored preference', () => { + it('round-trips', () => { + rememberDrawerSize('wide') + + expect(storedDrawerSize()).toBe('wide') + }) + + it('falls back to the default when nothing is stored', () => { + expect(storedDrawerSize()).toBe(DEFAULT_DRAWER_SIZE) + }) + + it('falls back when the stored value is not a size', () => { + // A key this application wrote in an earlier shape, or one somebody edited by hand. + localStorage.setItem('crm.drawerSize', 'enormous') + + expect(storedDrawerSize()).toBe(DEFAULT_DRAWER_SIZE) + }) + + it('does not throw when storage is denied on read', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('The operation is insecure.') + }) + + expect(storedDrawerSize()).toBe(DEFAULT_DRAWER_SIZE) + }) + + it('does not throw when storage is denied on write', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('The quota has been exceeded.') + }) + + expect(() => rememberDrawerSize('full')).not.toThrow() + }) +}) + +describe('isDrawerSize', () => { + it('accepts the three and refuses everything else', () => { + expect(isDrawerSize('peek')).toBe(true) + expect(isDrawerSize('full')).toBe(true) + expect(isDrawerSize('wider')).toBe(false) + expect(isDrawerSize(null)).toBe(false) + }) +}) diff --git a/samples/crm-web/src/design/primitives/drawerSize.ts b/samples/crm-web/src/design/primitives/drawerSize.ts new file mode 100644 index 00000000..ca18227c --- /dev/null +++ b/samples/crm-web/src/design/primitives/drawerSize.ts @@ -0,0 +1,62 @@ +/** + * How wide the peek is, and why it is remembered. + * + * THREE SIZES, NOT TWO. A right-hand panel is the right shape for checking one value and the + * wrong shape for reading a record with four sections in it — the fields wrap to one per line and + * the reader scrolls a page that would have fitted on a screen. `wide` is the size that makes the + * peek an answer rather than a preview, and `full` is for the case where the peek has stopped + * being a peek and the reader wants the page. + * + * IT IS REMEMBERED PER BROWSER, because the choice is a working habit rather than a property of a + * record: somebody who reads records wide reads every record wide, and having to say so on each + * one is the kind of friction that ends with the peek being ignored in favour of the full page. + * Same `localStorage` shape as the locale, and the same refusal to throw when storage is denied. + */ +export const DRAWER_SIZES = ['peek', 'wide', 'full'] as const + +export type DrawerSize = (typeof DRAWER_SIZES)[number] + +export const DEFAULT_DRAWER_SIZE: DrawerSize = 'peek' + +const STORAGE_KEY = 'crm.drawerSize' + +export function isDrawerSize(value: string | null): value is DrawerSize { + return value !== null && (DRAWER_SIZES as readonly string[]).includes(value) +} + +/** The size this browser last used, or the default. Never throws: private mode has no storage. */ +export function storedDrawerSize(): DrawerSize { + try { + const saved = localStorage.getItem(STORAGE_KEY) + + return isDrawerSize(saved) ? saved : DEFAULT_DRAWER_SIZE + } catch { + return DEFAULT_DRAWER_SIZE + } +} + +export function rememberDrawerSize(size: DrawerSize): void { + try { + localStorage.setItem(STORAGE_KEY, size) + } catch { + // A browser that refuses storage still gets the size for this session. + } +} + +/** + * The next size up, and the next size down, saturating at each end. + * + * Saturating rather than wrapping: a reader holding the widen key expects to arrive at the widest + * and stop, and a wrap would take them from full screen back to a strip without them asking. + */ +export function widen(size: DrawerSize): DrawerSize { + const next = DRAWER_SIZES[DRAWER_SIZES.indexOf(size) + 1] + + return next ?? size +} + +export function narrow(size: DrawerSize): DrawerSize { + const index = DRAWER_SIZES.indexOf(size) + + return index <= 0 ? size : (DRAWER_SIZES[index - 1] as DrawerSize) +} diff --git a/samples/crm-web/src/features/sales/ListScreen.tsx b/samples/crm-web/src/features/sales/ListScreen.tsx index 60a76247..99ad1122 100644 --- a/samples/crm-web/src/features/sales/ListScreen.tsx +++ b/samples/crm-web/src/features/sales/ListScreen.tsx @@ -6,11 +6,8 @@ import { DataTable, ErrorState, Drawer, - DrawerSection, - FieldRow, Page, PageHeader, - Tag, TextField, } from '@/design/primitives' import type { Column } from '@/design/primitives' @@ -18,14 +15,14 @@ import { useEntityPage, useProcess, useSchema } from '@/api/queries/hooks' import { modelFor } from '@/fixtures/objects' import type { RecordRow } from '@/fixtures/objects' import { isNumeric, renderCell } from './RecordCell' +import { RecordDetail } from './RecordScreen' import { entityOf, toRows } from './liveRecords' -import { EditFieldsDrawer } from './EditFieldsDrawer' +import { rememberDrawerSize, storedDrawerSize } from '@/design/primitives/drawerSize' +import type { DrawerSize } from '@/design/primitives/drawerSize' import { NewLeadDrawer } from './NewLeadDrawer' import { NewTaskDrawer } from './NewTaskDrawer' import styles from './ListScreen.module.css' -/** The kinds a custom field can be declared on, which is what the edit drawer writes. */ -const EDITABLE_KINDS: readonly string[] = ['Lead', 'Account', 'Contact', 'Opportunity'] /** * Why the other objects have no New button. @@ -61,7 +58,15 @@ export function ListScreen({ objectKey }: { objectKey: string }) { const [search, setSearch] = useState('') const [stage, setStage] = useState('all') const [peek, setPeek] = useState(null) - const [editing, setEditing] = useState(null) + + // The size outlives the peek: closing one record and opening the next keeps the width the + // reader chose, which is the whole reason it is remembered rather than reset per record. + const [size, setSizeState] = useState(storedDrawerSize) + + const setSize = (next: DrawerSize) => { + setSizeState(next) + rememberDrawerSize(next) + } const [hidden, setHidden] = useState([]) const [showColumns, setShowColumns] = useState(false) const [creating, setCreating] = useState(false) @@ -319,74 +324,41 @@ export function ListScreen({ objectKey }: { objectKey: string }) { {peek ? ( setPeek(null)} actions={ - <> - - {/* - The same drawer the record page opens, and only for the four kinds a custom field - can be declared on. It did nothing at all before, which is the one outcome a - reader cannot tell from a slow one. - */} - - + } > - - {model.fields.map((field) => ( - - {renderCell(model, peek, field.name)} - - ))} - {model.stageField ? ( - <> - -
- {stages.map((option) => ( - - {option} - - ))} -
- - ) : null} + {/* + THE PEEK IS THE RECORD PAGE, at a density. It used to be a second rendering — the + fields off the model in one flat list, a stage strip made of tags, and its own Edit + button — which is two places to add a field to and one of them gets forgotten. It also + showed the *row* the list had already fetched rather than the record, so a column the + list does not select was simply missing from the peek with nothing saying so. + */} +
) : null} - {editing !== null && entity !== null ? ( - setEditing(null)} - /> - ) : null}
) } diff --git a/samples/crm-web/src/features/sales/RecordScreen.tsx b/samples/crm-web/src/features/sales/RecordScreen.tsx index b7719575..ce709e45 100644 --- a/samples/crm-web/src/features/sales/RecordScreen.tsx +++ b/samples/crm-web/src/features/sales/RecordScreen.tsx @@ -41,7 +41,51 @@ type RecordTab = 'details' | 'related' | 'activity' | 'files' /** The kinds a custom field can be declared on, which is what the edit drawer writes. */ const EDITABLE: readonly string[] = ['Lead', 'Account', 'Contact', 'Opportunity'] +/** + * The record page. + * + * Everything it draws is {@link RecordDetail}, which the list's peek draws too. That is the whole + * point of the split: the page and the peek were about to be two renderings of one record, and + * two renderings drift — the peek grows a field the page does not have, or stops showing one it + * does, and nobody notices because nobody opens both at once. + */ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string }) { + return ( + + + + ) +} + +/** + * How much room the record has. + * + * `full` is the page. `peek` is the drawer at its narrowest, where the highlight strip and the + * stage path are the first things to go — both are horizontal by nature, and a horizontal strip in + * a 452px column is three items wrapping onto four lines, which reads as damage rather than + * density. What stays is the identity, the actions and the sections, because those are what + * somebody opened the record to see. + */ +export type RecordDensity = 'peek' | 'full' + +export function RecordDetail({ + objectKey, + id, + density = 'full', + identity = true, +}: { + objectKey: string + id: string + density?: RecordDensity + /** + * Whether to draw the record's own name and id. + * + * The drawer states them in its header, so a peek that drew them again showed the account name + * twice, eleven pixels apart. The actions stay either way — they are the record's, not the + * chrome's. + */ + identity?: boolean +}) { const navigate = useNavigate() const model = modelFor(objectKey) const { tenantId } = useSession() @@ -95,15 +139,15 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string if (entity !== null && live.isPending) { return ( - + <> - + ) } if (!record) { return ( - + <> } /> - + ) } @@ -137,18 +181,22 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string const related = relatedLinksOf(model.key) return ( - -
+ <> +
- -
-
- {model.label} · {record.id} -
-

{String(record[titleField] ?? record.id)}

-
+ {identity ? ( + <> + +
+
+ {model.label} · {record.id} +
+

{String(record[titleField] ?? record.id)}

+
+ + ) : null}
{/* Only the four kinds a custom field can be declared on, and only for a live record. @@ -215,6 +263,7 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string
+ {density === 'full' ? (
{model.listCols.slice(1, 5).map((name) => (
@@ -225,6 +274,7 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string
))}
+ ) : null} {/* THE PATH IS A DISPLAY, AND IT USED TO BE MADE OF BUTTONS. Nine `