@@ -73,6 +73,14 @@ import { t, tFormat, translateMetadataType, useMetadataLocale } from './i18n.js'
7373import { buildPackageScopeOptions } from './package-scope.js' ;
7474import { ENVIRONMENT_SCOPE_QUERY , isEnvironmentScope , useWalledCatalogReadOnly } from './catalog-scope.js' ;
7575import { useMetadataAreaHost } from './metadataAreaHost.js' ;
76+ import { customizationPackageId } from '@objectstack/spec/kernel' ;
77+ import { CustomizationMark , usePackageCustomizations } from '../studio-design/CustomizationMark.js' ;
78+ import {
79+ customizationBaseOfRow ,
80+ customizationMarkOf ,
81+ customizationTotal ,
82+ type CustomizationMarkKind ,
83+ } from '../studio-design/customizationMarks.js' ;
7684import {
7785 audienceAnchorsOf ,
7886 hasCatalogActivation ,
@@ -195,6 +203,19 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
195203 // package scope is the area's, never picked or repaired here, and a
196204 // read-only package offers no create.
197205 const host = useMetadataAreaHost ( ) ;
206+ // objectstack ADR-0146 D3 (#28568) — "On top, one surface": with a base open
207+ // in the area, its list holds the base's items and its paired
208+ // `<base>.custom` package's items together, each item of the pair marked
209+ // from the server's customization read ("customized (base)" for a served
210+ // override, "added" for the rest), filterable to those alone. A pending or
211+ // failed read draws no mark; Setup (no host) reads nothing.
212+ const customizations = usePackageCustomizations ( host ?. packageId ?? null ) ;
213+ const pairId = React . useMemo ( ( ) => {
214+ if ( ! host ?. packageId ) return null ;
215+ const pair = customizationPackageId ( host . packageId ) ;
216+ return pair . ok ? pair . value : null ;
217+ } , [ host ?. packageId ] ) ;
218+ const [ onlyCustomized , setOnlyCustomized ] = React . useState ( false ) ;
198219 // objectstack#27356 — under Setup the header names the page as the sidebar
199220 // entry that opens it does ("Positions"); the type's label otherwise.
200221 const { app } = useExpressionContext ( ) ;
@@ -548,15 +569,44 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
548569 // Only rows tagged with the active writable base match. Untagged /
549570 // `sys_metadata`-provenance legacy rows have no scope of their own
550571 // (ADR-0070 D5 — the package-less "Local / Custom" scope is removed).
551- return pkg === activePackage ;
572+ // Under the area a base's list also holds its pair's items (ADR-0146 D3).
573+ return pkg === activePackage || ( pairId !== null && pkg === pairId ) ;
552574 } ) ,
553- [ items , activePackage , config , envScope ] ,
575+ [ items , activePackage , config , envScope , pairId ] ,
576+ ) ;
577+
578+ // The customization mark of each row, from the base's served read only.
579+ const markOf = React . useCallback (
580+ ( row : ItemRow ) : CustomizationMarkKind | null => {
581+ if ( customizations . status !== 'read' ) return null ;
582+ const pk = config . primaryKey ?? 'name' ;
583+ return customizationMarkOf (
584+ {
585+ type,
586+ name : String ( row . item [ pk ] ?? '' ) ,
587+ member : bundleMemberOf ( row . item , config . bundleMemberField ) ,
588+ packageId : ( row . item as { _packageId ?: string } ) . _packageId ,
589+ } ,
590+ customizations . index ,
591+ ) ;
592+ } ,
593+ [ customizations , config . primaryKey , config . bundleMemberField , type ] ,
554594 ) ;
595+ const markedCount = React . useMemo (
596+ ( ) => scopedItems . filter ( ( row ) => markOf ( row ) !== null ) . length ,
597+ [ scopedItems , markOf ] ,
598+ ) ;
599+ // The filter is offered once the read has answered and the base has a
600+ // customization to show; a base with no pair is unchanged.
601+ const showCustomizedFilter =
602+ customizations . status === 'read' && ( markedCount > 0 || customizationTotal ( customizations . read ) > 0 ) ;
603+ const filteringCustomized = onlyCustomized && showCustomizedFilter ;
555604
556605 // User-driven filters (search query + source provenance) on top of scope.
557606 const filtered = scopedItems . filter ( ( row ) => {
558607 if ( ! matchesQuery ( row . item , query , searchableFields ) ) return false ;
559608 if ( sourceFilter !== 'all' && row . source !== sourceFilter ) return false ;
609+ if ( filteringCustomized && markOf ( row ) === null ) return false ;
560610 if ( activation && statusFilter !== 'all' ) {
561611 // Only a KNOWN state filters: while the ledger is unread or unreadable,
562612 // no item is either active or inactive to this filter.
@@ -719,7 +769,24 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
719769 </ SelectContent >
720770 </ Select >
721771 ) }
772+ { showCustomizedFilter && (
773+ < Button
774+ size = "sm"
775+ variant = { filteringCustomized ? 'default' : 'outline' }
776+ className = { inSetup ? 'h-8 rounded-full px-3 text-xs' : undefined }
777+ aria-pressed = { filteringCustomized }
778+ onClick = { ( ) => setOnlyCustomized ( ( v ) => ! v ) }
779+ data-testid = "metadata-only-customizations"
780+ >
781+ { tFormat ( 'engine.list.customizations.only' , locale , { marked : markedCount } ) }
782+ </ Button >
783+ ) }
722784 </ div >
785+ { customizations . status === 'failed' && (
786+ < div className = "text-xs text-muted-foreground" data-testid = "metadata-customizations-failed" >
787+ { tFormat ( 'engine.customizationMark.readFailed' , locale , { message : customizations . message } ) }
788+ </ div >
789+ ) }
723790
724791 { /* objectui#7611 — the environment scope says WHY a caller cannot
725792 define here, in the posture's own terms (#22621 → A): under
@@ -924,6 +991,22 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
924991 rowEditSuffix ,
925992 rowMember ? `${ BUNDLE_MEMBER_PARAM } =${ encodeURIComponent ( rowMember ) } ` : '' ,
926993 ) } `;
994+ // ADR-0146 D3 — the row's customization mark: under a base,
995+ // from its served read; in the cross-package leg, an item of a
996+ // pair names its base.
997+ const mark = markOf ( row ) ;
998+ const pairBase = host && host . packageId === null ? customizationBaseOfRow ( rowPkg ) : null ;
999+ const markNode =
1000+ mark && customizations . status === 'read' ? (
1001+ < CustomizationMark
1002+ kind = { mark }
1003+ baseName = { customizations . baseName }
1004+ pairId = { customizations . index . pairId }
1005+ locale = { locale }
1006+ />
1007+ ) : pairBase ? (
1008+ < CustomizationMark kind = "customizationOf" baseName = { pairBase } pairId = { rowPkg } locale = { locale } />
1009+ ) : null ;
9271010 return (
9281011 < tr
9291012 key = { name + i }
@@ -985,6 +1068,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
9851068 { cell }
9861069 </ Link >
9871070 ) }
1071+ { markNode }
9881072 </ span >
9891073 ) : inSetup && c . key === 'description' && typeof value === 'string' && value !== '' ? (
9901074 // objectstack#27356 — two lines at most, so a row
0 commit comments