From 5f8b2980380ad30467b4038f6d8c21e7950f1a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:26:24 +0000 Subject: [PATCH 1/4] feat(plugin-list, app-shell): one "Personalized" marker beside the list title holds the one reset to the view ListView reports which parts of the list on screen differ from its view through a new optional host prop, onPersonalizationChange; with it the Display control draws no reset of its own. The Console draws the marker in the page header's badge slot and beside the phone title, and its reset clears the reader's stored entry and cached search, strips the whole uf_* family from the URL and remounts the list. The header overflow entry "Reset view" goes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQki8itS3sC2eZCj7J --- .../src/views/ListPersonalizedMarker.tsx | 104 ++++++++++++++++++ .../app-shell/src/views/ObjectView.tsx | 22 +++- .../src/views/objectViewHeaderChrome.tsx | 14 +-- .../src/views/objectViewListRenderer.tsx | 16 ++- .../src/views/objectViewListUrlState.ts | 91 +++++++++++++-- .../src/views/objectViewPageHeader.tsx | 55 ++++----- ui/packages/i18n/src/locales/ar.ts | 19 +++- ui/packages/i18n/src/locales/de.ts | 19 +++- ui/packages/i18n/src/locales/en.ts | 19 +++- ui/packages/i18n/src/locales/es.ts | 19 +++- ui/packages/i18n/src/locales/fr.ts | 19 +++- ui/packages/i18n/src/locales/ja.ts | 19 +++- ui/packages/i18n/src/locales/ko.ts | 19 +++- ui/packages/i18n/src/locales/pt.ts | 19 +++- ui/packages/i18n/src/locales/ru.ts | 19 +++- ui/packages/i18n/src/locales/zh.ts | 19 +++- ui/packages/plugin-list/src/ListView.tsx | 93 +++++++++++++++- 17 files changed, 497 insertions(+), 88 deletions(-) create mode 100644 ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx diff --git a/ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx b/ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx new file mode 100644 index 00000000000..133b37741e0 --- /dev/null +++ b/ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx @@ -0,0 +1,104 @@ +/** + * objectstack#28950 — the 「Personalized」 marker of a Console list: drawn beside + * the list title while the reader's list differs from the view, and the ONE way + * back to the view. Opening it names what differs and offers a single reset, + * whose sentence says everything it puts back and what it leaves alone. + * + * The parts come from the list itself (`ListView`'s `onPersonalizationChange`) + * plus the host's stored column layout; the reset is the host's + * (`useObjectViewListUrlState`'s `handleResetView`). Nothing here is + * configurable: no metadata key or option reaches it. + * + * Below `sm` the trigger is an icon alone (named by its `aria-label`), so on a + * narrow row it never pushes the view name out. + */ + +import { useState } from 'react'; +import { Button, Popover, PopoverContent, PopoverTrigger, cn } from '@object-ui/components'; +import { useObjectTranslation } from '@object-ui/i18n'; +import type { ListViewProps } from '@object-ui/plugin-list'; +import { RotateCcw, UserPen } from 'lucide-react'; + +/** One part of a list that can differ from its view (`ListView`'s vocabulary). */ +export type ListPersonalizationPart = Parameters>[0][number]; + +/** The order the marker names the parts in: the toolbar's, then the Display panel's. */ +export const LIST_PERSONALIZATION_PART_ORDER: readonly ListPersonalizationPart[] = [ + 'filters', + 'search', + 'sort', + 'grouping', + 'columns', + 'hiddenFields', + 'density', + 'layout', + 'rowColor', + 'rowNumbers', + 'inlineEdit', +]; + +export function ListPersonalizedMarker({ + parts, + onReset, + className, +}: { + parts: ReadonlyArray; + onReset: () => void; + className?: string; +}) { + const { t } = useObjectTranslation(); + const [open, setOpen] = useState(false); + if (parts.length === 0) return null; + const ordered = LIST_PERSONALIZATION_PART_ORDER.filter((part) => parts.includes(part)); + const label = t('console.objectView.personalized'); + return ( + + + + + +

{t('console.objectView.personalizedHeading')}

+
    + {ordered.map((part) => ( +
  • + {t(`console.objectView.personalizedParts.${part}`)} +
  • + ))} +
+

{t('console.objectView.resetViewTitle')}

+ +
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index a2d6c676faf..fe7fa3cfee1 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -74,6 +74,7 @@ import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import { ObjectViewPageHeader } from './objectViewPageHeader.js'; import { ObjectViewRecordOverlay, RecordPaneListCount } from './objectViewRecordOverlay.js'; import { useObjectViewListRenderer } from './objectViewListRenderer.js'; +import { ListPersonalizedMarker } from './ListPersonalizedMarker.js'; import { useObjectViewViewCrud } from './objectViewViewCrud.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling @@ -643,7 +644,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co listSeedRef, ownListParamsRef, writeListUrlState, - activeViewHasPreference, + handleListPersonalizationChange, + listPersonalizedParts, handleResetView, } = useObjectViewListUrlState({ objectName, @@ -655,6 +657,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co searchParams, setSearchParams, }); + // objectstack#28950 — the 「Personalized」 marker beside the list title (the + // page header's badge slot, and the phone title): drawn only while the + // reader's list differs from the view, and the one way back to it. + const personalizedMarker = !isEnvironmentList && listPersonalizedParts.length > 0 ? ( + + ) : null; // objectstack#25992 slice 11b: the view-config save and create handlers, view switching // and the view-tab CRUD callbacks moved to `./objectViewViewCrud.tsx` (the mobile view @@ -810,6 +818,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co setSearchParams, listIdentityKey, ownListParamsRef, + listSeedRef, }); // objectstack#27594: the list title is the view switcher, which replaced the @@ -870,8 +879,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co recordOpen, openAsPage, phoneTitle: !pageHeaderShown && titleSwitcher ? ( - {titleSwitcher} + + {titleSwitcher} + {personalizedMarker} + ) : null, + handleListPersonalizationChange, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, @@ -920,8 +933,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co importDisabled, setShowImport, listFavorite, - activeViewHasPreference, - handleResetView, isEnvironmentList, toolbarBar, viewAccess, @@ -1001,8 +1012,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co environmentEntitlements={environmentEntitlements} toolbarBar={toolbarBar} listFavorite={listFavorite} - activeViewHasPreference={activeViewHasPreference} - handleResetView={handleResetView} + personalizedMarker={personalizedMarker} foldedHeaderActions={foldedHeaderActions} /> diff --git a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx index cfbdf76aa66..a3898274d7a 100644 --- a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx +++ b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx @@ -18,7 +18,6 @@ import { declaredPrimaryToolbarAction, MOBILE_FAB_LIST_CLEARANCE } from './objec import { ListHeaderActionBar } from './objectViewHeaderLaunchGate.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import type { useObjectViewIdentity } from './objectViewIdentity.js'; -import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; export function useObjectViewHeaderChrome({ @@ -35,8 +34,6 @@ export function useObjectViewHeaderChrome({ importDisabled, setShowImport, listFavorite, - activeViewHasPreference, - handleResetView, isEnvironmentList, toolbarBar, viewAccess, @@ -54,8 +51,6 @@ export function useObjectViewHeaderChrome({ importDisabled: ReturnType['importDisabled']; setShowImport: Dispatch>; listFavorite: ReturnType; - activeViewHasPreference: ReturnType['activeViewHasPreference']; - handleResetView: ReturnType['handleResetView']; isEnvironmentList: ReturnType['isEnvironmentList']; toolbarBar: ReturnType['toolbarBar']; viewAccess: ReturnType; @@ -86,7 +81,7 @@ export function useObjectViewHeaderChrome({ // objectui#12705 — the header's actions folded, for a header too narrow // for them and the description: the primary action stays labelled and // every other action goes into one `⋯` menu, above the page's own entries - // (favorites, Reset view). The header decides when; this decides what. + // (favorites). The header decides when; this decides what. // // The primary is DECLARED, never read off the row's order: the page's own // New when it draws one, else the toolbar action authored @@ -132,13 +127,6 @@ export function useObjectViewHeaderChrome({ type: 'script' as const, onClick: listFavorite.toggle, }] : []), - ...(activeViewHasPreference ? [{ - name: 'object_view_reset_view', - label: t('console.objectView.resetView'), - icon: 'rotate-ccw', - type: 'script' as const, - onClick: handleResetView, - }] : []), ], variant: 'ghost', size: 'icon', diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx index 89d930f9833..988592c00cc 100644 --- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -72,6 +72,7 @@ export function useObjectViewListRenderer({ recordOpen, openAsPage, phoneTitle, + handleListPersonalizationChange, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, @@ -113,6 +114,8 @@ export function useObjectViewListRenderer({ * a chart; `null` elsewhere, where the page header draws it. */ phoneTitle: ReactNode; + /** objectstack#28950 — what of the list differs from the view, keyed by the list identity. */ + handleListPersonalizationChange: ReturnType['handleListPersonalizationChange']; urlFilters: ReturnType['urlFilters']; initialUfSelections: ReturnType['initialUfSelections']; handleUserFilterSelectionsChange: ReturnType['handleUserFilterSelectionsChange']; @@ -401,7 +404,7 @@ export function useObjectViewListRenderer({ * popover's) names the parts it put back, and they are CLEARED from * this user's stored state rather than stored as the view's values, so * a later change to the view reaches them. A view left with nothing is - * removed (`patchViewPreference`), and with it the ⋯ "Reset view". + * removed (`patchViewPreference`). * The sort leaves the URL too, which would otherwise seed it again. * A deliberate `sort: []` or `hiddenFields: []` still arrives through * `persistListSort` / `persistHiddenFields` and stays stored. @@ -873,9 +876,16 @@ export function useObjectViewListRenderer({ // objectui#12949 — the view's own values under the user's // preference, for the Display control's reset and count, and // (objectstack#28948) the sort popover's reset. - viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight, sort: viewSort }} + // objectstack#28950 — and the view's own grouping, under a + // grouping the URL carries (the `grouping` rung above), so the + // list can tell the reader's from the view's. + viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight, sort: viewSort, grouping: listSchema.grouping }} // objectstack#28948 — those resets clear the user's state. onRestoreViewDefaults={restoreViewDefaults} + // objectstack#28950 — what of the list differs from the view, + // for the 「Personalized」 marker, which then owns the one reset + // (the Display control draws none of its own). + onPersonalizationChange={(parts) => handleListPersonalizationChange(identityKey, parts)} className={className} onEdit={editHandler} // objectui#13089 — the view's Add button is the header New: @@ -963,7 +973,7 @@ export function useObjectViewListRenderer({ dataSource={ds} /> ); - }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, createRecord, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, handleListTotalChange, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange, phoneTitle]); + }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, createRecord, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, handleListTotalChange, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange, phoneTitle, handleListPersonalizationChange]); return { renderListView }; } diff --git a/ui/packages/app-shell/src/views/objectViewListUrlState.ts b/ui/packages/app-shell/src/views/objectViewListUrlState.ts index cd7c1c66c7a..5a2435c807a 100644 --- a/ui/packages/app-shell/src/views/objectViewListUrlState.ts +++ b/ui/packages/app-shell/src/views/objectViewListUrlState.ts @@ -22,7 +22,7 @@ import { type ListUrlState, type ListSortRule, } from './userFilterUrlState.js'; -import { buildListFilterKey, readListFilterState } from './listFilterStorage.js'; +import { buildListFilterKey, readListFilterState, clearListFilterState } from './listFilterStorage.js'; import type { useObjectListPreferences, ListFilterGroupPreference, @@ -32,10 +32,26 @@ import { parseUrlEqualityFilterTriples } from './drillUrlFilters.js'; import { selectFilterParams } from './objectViewHelpers.js'; import type { buildViewTabs } from './objectViewTabs.js'; import type { ConsoleObjectViewProps } from './ObjectView.js'; +import type { ListPersonalizationPart } from './ListPersonalizedMarker.js'; const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); +/** + * objectstack#28950 — the params with the reader's whole `uf_*` list state + * removed: the Filter panel, search, sort and grouping, and every user-filter + * chip. Through the owning module's own writers, so the family has one + * definition. Every other param (`filter[...]` drill scope, `recordId`, ...) + * is kept. + */ +export function withoutListStateParams(prev: URLSearchParams): URLSearchParams { + const chips = parseUserFilterParams(prev); + const cleared = chips + ? applyUserFilterParams(prev, Object.fromEntries(Object.keys(chips).map(field => [field, []]))) + : new URLSearchParams(prev); + return applyListStateParams(cleared, { filters: null, search: null, sort: null, grouping: null }); +} + /** * objectstack#28949 — the stored sort through the seed's field gate: a level * naming a refused field is dropped and the levels around it keep their order. @@ -149,8 +165,12 @@ export function useObjectViewListUrlState({ const listIdentityKey = activeView?.id ? `${objectName}-${activeView.id}${listResetGeneration ? `~${listResetGeneration}` : ''}` : undefined; - const listSeedRef = useRef<{ key: string; source: 'url' | 'storage'; state: ListUrlState } | undefined>(undefined); + const listSeedRef = useRef<{ key: string; source: 'url' | 'storage'; state: ListUrlState; reset?: boolean } | undefined>(undefined); const ownListParamsRef = useRef(undefined); + // objectstack#28950 — set by "Reset to view" and taken by the next seed: + // that list opens from the (just cleared) stored state, never from the + // `uf_*` params, whoever wrote them, and its write-back strips them all. + const resetPendingRef = useRef(false); if (listIdentityKey && listPrefs.ready && listSeedRef.current?.key !== listIdentityKey) { // A field the object does not declare (a stale link, or a stored state // older than the field's deletion) or one this user cannot read is @@ -160,7 +180,9 @@ export function useObjectViewListUrlState({ !!objectDef.fields?.[field] && (!perms.isLoaded || perms.checkField(objectDef.name, field, 'read')); const carried = userFilterParamsKey(searchParams); - if (carried && carried !== ownListParamsRef.current) { + const reset = resetPendingRef.current; + resetPendingRef.current = false; + if (carried && carried !== ownListParamsRef.current && !reset) { listSeedRef.current = { key: listIdentityKey, source: 'url', @@ -177,13 +199,14 @@ export function useObjectViewListUrlState({ search: stored?.search || undefined, sort: acceptStoredSort(viewPref?.sort, acceptField), }, + reset, }; } } useEffect(() => { const seed = listSeedRef.current; if (!seed || seed.key !== listIdentityKey) return; - const next = applyListStateParams(searchParams, { + const next = applyListStateParams(seed.reset ? withoutListStateParams(searchParams) : searchParams, { filters: seed.state.filters ?? null, search: seed.state.search ?? null, sort: seed.state.sort ?? null, @@ -212,22 +235,63 @@ export function useObjectViewListUrlState({ ); /** - * objectui#12418 — "Reset view": drop this user's sort, filters and column - * layout for the active view, and open the list again as the view declares - * it. Offered only while they have stored something for it. + * objectstack#28950 — what of the reader's list differs from the view, as + * the list reports it (`ListView`'s `onPersonalizationChange`), held with + * the list identity it came from so a report never outlives its list. The + * column layout is the one part only this page knows: a stored column + * state is the reader's own. + */ + const [reportedPersonalization, setReportedPersonalization] = useState<{ + key: string; + parts: ReadonlyArray; + } | undefined>(undefined); + const handleListPersonalizationChange = useCallback( + (key: string, parts: ReadonlyArray) => { + setReportedPersonalization(prev => + prev && prev.key === key && prev.parts.join(',') === parts.join(',') ? prev : { key, parts }); + }, + [], + ); + const listPersonalizedParts: ListPersonalizationPart[] = [ + ...(reportedPersonalization && reportedPersonalization.key === listIdentityKey + ? reportedPersonalization.parts + : []), + ]; + if (activeView?.id && listPrefs.preference.perView?.[activeView.id]?.columnState + && !listPersonalizedParts.includes('columns')) { + listPersonalizedParts.push('columns'); + } + + /** + * objectstack#28950 — "Reset to view", the marker's one way back: the + * reader's whole list for the active view returns to the view's own. Their + * stored entry goes (sort, filters, column layout, density, hidden fields, + * objectui#12418), the cached search term with it, and the list opens + * again from that cleared state (the identity moves, so it remounts and + * re-seeds; a reset is the one case that WANTS the list's own state thrown + * away). The new seed ignores the `uf_*` params and its write-back strips + * them, chips included, so neither a reload nor Back brings the old list + * back. The remount puts the grouping, layout, row color, row numbers, + * inline edit and the "In a queue" toggle back to the view's. + * + * Not this view's list state, so untouched: the record-open preference, + * the tab order (pinned views), favorites and a `filter[...]` scope a + * "View all" link opened the list with. */ - const activeViewHasPreference = !!(activeView?.id && listPrefs.preference.perView?.[activeView.id]); const handleResetView = useCallback(() => { if (!activeView?.id) return; listPrefs.resetView(activeView.id); + clearListFilterState(buildListFilterKey(user?.id, objectName, activeView.id)); + resetPendingRef.current = true; setListResetGeneration(g => g + 1); - }, [activeView?.id, listPrefs.resetView]); + }, [activeView?.id, listPrefs.resetView, user?.id, objectName]); return { listIdentityKey, listSeedRef, ownListParamsRef, writeListUrlState, - activeViewHasPreference, + handleListPersonalizationChange, + listPersonalizedParts, handleResetView, }; } @@ -237,11 +301,13 @@ export function useObjectViewUrlFilters({ setSearchParams, listIdentityKey, ownListParamsRef, + listSeedRef, }: { searchParams: URLSearchParams; setSearchParams: SetURLSearchParams; listIdentityKey: ReturnType['listIdentityKey']; ownListParamsRef: ReturnType['ownListParamsRef']; + listSeedRef?: ReturnType['listSeedRef']; }) { /** * URL-derived equality filters in the form `?filter[]=`. @@ -300,7 +366,10 @@ export function useObjectViewUrlFilters({ */ const ufSelectionsRef = useRef<{ key: string; selections: Record | undefined } | undefined>(undefined); if (listIdentityKey && ufSelectionsRef.current?.key !== listIdentityKey) { - ufSelectionsRef.current = { key: listIdentityKey, selections: parseUserFilterParams(searchParams) }; + // objectstack#28950 — the list "Reset to view" opened starts with no + // chip selected: its seed strips their params from the address bar. + const reset = listSeedRef?.current?.key === listIdentityKey && !!listSeedRef.current.reset; + ufSelectionsRef.current = { key: listIdentityKey, selections: reset ? undefined : parseUserFilterParams(searchParams) }; } const initialUfSelections = listIdentityKey ? ufSelectionsRef.current?.selections : undefined; const handleUserFilterSelectionsChange = useCallback( diff --git a/ui/packages/app-shell/src/views/objectViewPageHeader.tsx b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx index 540eb8ba888..f26f8ea4f69 100644 --- a/ui/packages/app-shell/src/views/objectViewPageHeader.tsx +++ b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx @@ -3,7 +3,8 @@ * title (the object's plural ahead of the view switcher, or the plural alone), * managed-by badge, description and icon, with the * header's actions (the create button, Import, the schema-driven toolbar and - * the overflow menu with favorites and "Reset view") and their folded face, + * the overflow menu with favorites) and their folded face, the 「Personalized」 + * marker beside the title (objectstack#28950), * moved out of `ObjectViewInner`'s JSX verbatim (objectstack#25992, slice 10). * Every value is passed in as a prop. */ @@ -15,10 +16,9 @@ import { Button, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, } from '@object-ui/components'; -import { Upload, MoreHorizontal, RotateCcw } from 'lucide-react'; +import { Upload, MoreHorizontal } from 'lucide-react'; import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { FavoriteMenuItem } from '../layout/SidebarItemMenu.js'; import type { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; @@ -29,7 +29,6 @@ import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js import { ListHeaderActionBar } from './objectViewHeaderLaunchGate.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; -import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; import type { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import type { ConsoleObjectViewProps } from './ObjectView.js'; @@ -88,8 +87,7 @@ export function ObjectViewPageHeader({ environmentEntitlements, toolbarBar, listFavorite, - activeViewHasPreference, - handleResetView, + personalizedMarker, foldedHeaderActions, }: { objectDef: ConsoleObjectViewProps['objects'][number]; @@ -112,8 +110,12 @@ export function ObjectViewPageHeader({ environmentEntitlements: ReturnType['environmentEntitlements']; toolbarBar: ReturnType['toolbarBar']; listFavorite: ReturnType; - activeViewHasPreference: ReturnType['activeViewHasPreference']; - handleResetView: ReturnType['handleResetView']; + /** + * objectstack#28950 — the 「Personalized」 marker (`./ListPersonalizedMarker.tsx`), + * or `null` while the reader's list is the view's own. Drawn in the + * header's badge slot, beside the title, in both of the header's faces. + */ + personalizedMarker: ReactNode; foldedHeaderActions: ReturnType['foldedHeaderActions']; }) { const pluralLabel = objectPluralLabel(objectDef); @@ -137,7 +139,9 @@ export function ObjectViewPageHeader({ title={title} // The kind chip, in the header's own slot after the title // (objectui#12606). - badge={} + // objectstack#28950 — and, before it, the 「Personalized」 + // marker while the reader's list differs from the view. + badge={<>{personalizedMarker}} subtitle={objectDef.description ? objectDesc(objectDef) : undefined} // `createElement`, not a JSX tag: the looked-up icon is a value, // which react-hooks/static-components refuses in tag position @@ -191,10 +195,10 @@ export function ObjectViewPageHeader({ row: the favorites entry that replaced the header star. Favorites is a once-and-forget act, so it lives behind the ⋯ rather than in the header's most prominent slot. - "Reset view" (objectui#12418) is the same kind of act and - lives beside it, so the one-row header keeps its shape - as the user sorts. The ⋯ is drawn when either applies. */} - {(listFavorite || activeViewHasPreference) && ( + The list's way back to its view is the 「Personalized」 + marker beside the title (objectstack#28950), not an + entry here. */} + {listFavorite && (