1515 * code and installed packages are listed last: they open for browsing, and each
1616 * card points at the routes that customize one (objectui#11808). Writability is
1717 * the server's verdict read by packages-io (`PkgEntry.writable`, ADR-0070 D2),
18- * and the server's write gate stays the authority.
18+ * and the server's write gate stays the authority. Each read-only base's card
19+ * says what this deployment changed on it, "n customized, m switched off"
20+ * (ADR-0146 D3), from the server's per-package customization read.
1921 */
2022
2123import * as React from 'react' ;
@@ -28,6 +30,8 @@ import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js';
2830import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js' ;
2931import { isMarketplaceEnabled } from '../../runtime-config.js' ;
3032import { fetchPackages , duplicatePackage , isSpecPackageId , type PkgEntry } from './packages-io.js' ;
33+ import { fetchPackageCustomizationCounts , type PackageCustomizationCounts } from './packageCustomizations-io.js' ;
34+ import { customizationPackageId , isCustomizationPackage } from '@objectstack/spec/kernel' ;
3135import { PackageIdInput } from './PackageIdInput.js' ;
3236import { studioOrgScopePath } from './studioScope.js' ;
3337import { STUDIO_NEW_PARAM , STUDIO_NEW_PACKAGE_VALUE } from './studioNewPackage.js' ;
@@ -64,6 +68,18 @@ export const packagedAutomationPath = () =>
6468/** objectui#11863 — how many recent packages the landing lists: one row of cards. */
6569const RECENT_PACKAGES_SHOWN = 3 ;
6670
71+ /**
72+ * ADR-0146 D3 — one read-only base's customization line, by the state of its
73+ * read. No entry means the read has not answered: the line shows no number.
74+ */
75+ type CustomizationLine =
76+ | { kind : 'read' ; counts : PackageCustomizationCounts }
77+ | { kind : 'failed' ; message : string } ;
78+
79+ /** A package's Studio address on one pillar, the way `open` below spells it. */
80+ const studioPillarPath = ( packageId : string , pillar : 'data' | 'automations' ) =>
81+ `/studio/${ encodeURIComponent ( packageId ) } /${ pillar } ` ;
82+
6783export function BuilderLanding ( ) : React . ReactElement {
6884 const navigate = useNavigate ( ) ;
6985 const locale = useMetadataLocale ( ) ;
@@ -123,6 +139,100 @@ export function BuilderLanding(): React.ReactElement {
123139 const writable = pkgs ?. filter ( ( p ) => p . writable ) ?? [ ] ;
124140 const readonly = pkgs ?. filter ( ( p ) => ! p . writable ) ?? [ ] ;
125141
142+ // ADR-0146 D3 — each read-only BASE's card carries "n customized, m switched
143+ // off", the server's counts (`GET /packages/:id/customizations`), one read
144+ // per card drawn. A pair (`<base>.custom`) draws no line and is never sent:
145+ // the route refuses its id. The page does not wait for these reads. The
146+ // effect is keyed on the joined ids, the primitive it reads (ui/AGENTS.md #10).
147+ const customizationTargetKey = readonly
148+ . filter ( ( p ) => ! isCustomizationPackage ( p . id ) )
149+ . map ( ( p ) => p . id )
150+ . join ( '\n' ) ;
151+ const [ customizationLines , setCustomizationLines ] = React . useState < Record < string , CustomizationLine > > ( { } ) ;
152+ React . useEffect ( ( ) => {
153+ if ( ! customizationTargetKey ) return ;
154+ let cancelled = false ;
155+ for ( const id of customizationTargetKey . split ( '\n' ) ) {
156+ fetchPackageCustomizationCounts ( id ) . then (
157+ ( counts ) => {
158+ if ( ! cancelled ) setCustomizationLines ( ( prev ) => ( { ...prev , [ id ] : { kind : 'read' , counts } } ) ) ;
159+ } ,
160+ ( e : unknown ) => {
161+ const message = e instanceof Error ? e . message : String ( e ) ;
162+ if ( ! cancelled ) setCustomizationLines ( ( prev ) => ( { ...prev , [ id ] : { kind : 'failed' , message } } ) ) ;
163+ } ,
164+ ) ;
165+ }
166+ return ( ) => {
167+ cancelled = true ;
168+ } ;
169+ } , [ customizationTargetKey ] ) ;
170+ // The pair is linked only when the package list holds it: a base with no
171+ // pair has nothing to open, and the read never creates one.
172+ const listedIds = new Set ( pkgs ?. map ( ( p ) => p . id ) ?? [ ] ) ;
173+ const listedPairOf = ( baseId : string ) : string | null => {
174+ const pair = customizationPackageId ( baseId ) ;
175+ return pair . ok && listedIds . has ( pair . value ) ? pair . value : null ;
176+ } ;
177+
178+ const renderCustomizationLine = ( baseId : string ) : React . ReactNode => {
179+ if ( isCustomizationPackage ( baseId ) ) return null ;
180+ const line = customizationLines [ baseId ] ;
181+ const lineClass = 'text-[10px] leading-4 text-muted-foreground' ;
182+ if ( ! line ) {
183+ return (
184+ < p data-testid = "studio-landing-customizations" data-state = "loading" className = { lineClass } >
185+ { t ( 'engine.studio.landing.customizations.loading' , locale ) }
186+ </ p >
187+ ) ;
188+ }
189+ if ( line . kind === 'failed' ) {
190+ // Never a "0": the read did not answer. The server's sentence is the title.
191+ return (
192+ < p
193+ data-testid = "studio-landing-customizations"
194+ data-state = "failed"
195+ title = { line . message }
196+ className = { `${ lineClass } italic opacity-80` }
197+ >
198+ { t ( 'engine.studio.landing.customizations.failed' , locale ) }
199+ </ p >
200+ ) ;
201+ }
202+ const pair = listedPairOf ( baseId ) ;
203+ const customized = tFormat ( 'engine.studio.landing.customizations.customized' , locale , {
204+ customized : line . counts . customized ,
205+ } ) ;
206+ const linkClass = 'hover:text-foreground hover:underline' ;
207+ return (
208+ < p data-testid = "studio-landing-customizations" data-state = "read" className = { lineClass } >
209+ { pair ? (
210+ < Link
211+ to = { studioPillarPath ( pair , 'data' ) }
212+ title = { tFormat ( 'engine.studio.landing.customizations.pairTitle' , locale , { pair } ) }
213+ data-testid = "studio-landing-customizations-pair"
214+ className = { linkClass }
215+ >
216+ { customized }
217+ </ Link >
218+ ) : (
219+ < span > { customized } </ span >
220+ ) }
221+ { t ( 'engine.studio.landing.customizations.separator' , locale ) }
222+ < Link
223+ to = { studioPillarPath ( baseId , 'automations' ) }
224+ title = { t ( 'engine.studio.landing.customizations.switchedOffTitle' , locale ) }
225+ data-testid = "studio-landing-customizations-switched-off"
226+ className = { linkClass }
227+ >
228+ { tFormat ( 'engine.studio.landing.customizations.switchedOff' , locale , {
229+ switchedOff : line . counts . switchedOff ,
230+ } ) }
231+ </ Link >
232+ </ p >
233+ ) ;
234+ } ;
235+
126236 // Duplicate (ADR-0070 D4) clones a writable BASE into a new writable package,
127237 // the "duplicate base" gesture, and drops the user straight into the copy's
128238 // builder. It is offered on writable bases only. A read-only code or
@@ -384,12 +494,13 @@ export function BuilderLanding(): React.ReactElement {
384494 < Lock className = "h-2.5 w-2.5" /> { t ( 'engine.studio.pkg.readonly' , locale ) }
385495 </ span >
386496 </ button >
387- < div className = "border-t px-3 py-1.5" >
497+ < div className = "flex flex-col gap-1 border-t px-3 py-1.5" >
498+ { renderCustomizationLine ( p . id ) }
388499 < Link
389500 to = { overlayPath ( p . id ) }
390501 title = { t ( 'engine.studio.landing.overlayTitle' , locale ) }
391502 data-testid = "studio-landing-overlay"
392- className = "inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground hover:underline"
503+ className = "inline-flex self-start items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground hover:underline"
393504 >
394505 { /* objectui#12513 — the link leaves Studio for Setup: its
395506 name says where it goes, the arrow that it goes out. */ }
0 commit comments