From c645be47eb9528d55d4e4b914b5d40d716439141 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 23:42:16 +0100 Subject: [PATCH 1/5] Preview neutral gallery templates without PrimeReact Landing, ModuleWorkspace, FeatureSection and SliceSection no longer require component packages, ConfirmDialog is pinned as neutral, and gallery previews choose the full profile only when every declared package is loaded, keeping the partial-bundle profile otherwise. --- ...ving_the_component_names_they_reference.ts | 11 ++++++ ...en_choosing_the_profile_for_a_registry.tsx | 36 +++++++++++++++++++ ...hoosing_the_default_or_the_widget_mode.tsx | 10 +++++- .../gallery/GalleryDialogPreview.tsx | 6 ++-- .../gallery/GalleryScreenPreview.tsx | 6 ++-- .../blueprint.default/gallery/nesting.ts | 8 ++--- .../gallery/previewProfile.ts | 21 +++++++++-- .../gallery/statusTemplates.ts | 4 +-- .../blueprint.default/templateProvenance.ts | 9 +++++ 9 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx diff --git a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts index 2646bad8..be0e4fde 100644 --- a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts +++ b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts @@ -9,6 +9,7 @@ import { distinctComponentNames, galleryComponentCatalog, galleryDialogTemplates, + galleryPreviewProfile, galleryProfile, galleryScreens, } from '../gallery'; @@ -61,4 +62,14 @@ describe('when resolving the component names they reference', () => { .filter(name => !referenced.has(name)) .should.be.empty; }); + + it('should let PrimeReact provide the shared names in the full gallery profile', () => { + ['text', 'button', 'card'].map(name => resolveComponentName(name, galleryProfile, galleryComponentCatalog)!.package) + .should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact']); + }); + + it('should let core provide the shared names in the partial-bundle preview profile', () => { + ['text', 'button', 'card'].map(name => resolveComponentName(name, galleryPreviewProfile, galleryComponentCatalog)!.package) + .should.deep.equal(['core', 'core', 'core']); + }); }); diff --git a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx new file mode 100644 index 00000000..39c89283 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { cleanup, render } from '@testing-library/react'; +import { ComponentRegistry, corePackage } from '@cratis/scene.react'; +import { defaultBlueprintComponents } from '../defaultBlueprintComponents'; +import { defaultBlueprintName } from '../packageName'; +import { GalleryScreenPreview, TemplatePreviewState, galleryComponentCatalog, galleryPreviewProfile, galleryProfile, previewProfileFor } from '../gallery'; +import { resolveComponentName } from '@cratis/scene.engine'; +import { provideBrowserApis } from '../for_templatePreviewStates/given'; + +const stand = () => null; +const partial: ComponentRegistry = { ...corePackage.components, ...defaultBlueprintComponents }; +const full: ComponentRegistry = { ...partial, 'PrimeReact:text': stand, 'Cratis.Components:dataPage': stand }; +const sharedNames = ['text', 'button', 'card']; +const providers = (registry: ComponentRegistry) => sharedNames.map(name => resolveComponentName(name, previewProfileFor(registry), galleryComponentCatalog)!.package); + +describe('when choosing the profile for a registry', () => { + beforeEach(provideBrowserApis); + afterEach(cleanup); + + it('should use the full gallery profile when every package it declares is implemented', () => previewProfileFor(full).should.equal(galleryProfile)); + it('should let PrimeReact provide the shared names with the full registry', () => providers(full).should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact'])); + it('should keep the partial profile with core and the blueprint only', () => previewProfileFor(partial).should.equal(galleryPreviewProfile)); + it('should let core provide the shared names with a partial registry', () => providers(partial).should.deep.equal(['core', 'core', 'core'])); + it('should keep the partial profile when a listed package implements nothing', () => previewProfileFor({ ...partial, 'PrimeReact:text': stand }).should.equal(galleryPreviewProfile)); + + for (const name of ['Landing', 'ModuleWorkspace', 'FeatureSection', 'SliceSection']) { + it(`should render ${name} with a partial registry and no placeholders`, () => { + const { container } = render(); + (container.querySelector('[data-scene-unresolved-component]') === null).should.equal(true); + }); + } + + it('should name the blueprint as a package of the full profile', () => galleryProfile.packages.should.include(defaultBlueprintName)); +}); diff --git a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx index 840793b0..42cb61ba 100644 --- a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx +++ b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx @@ -9,7 +9,7 @@ import { ScenePackageBundle, corePackage, resolvePackageHost } from '@cratis/sce import { primeReactPackage } from '@cratis/scene.primereact'; import { defaultBlueprint } from '../defaultBlueprint'; import { defaultBlueprintName } from '../packageName'; -import { galleryComponentCatalog, galleryScreenTemplates, GalleryScreenPreview, TemplatePreviewState } from '../gallery'; +import { galleryComponentCatalog, galleryDialogTemplates, galleryScreenTemplates, GalleryScreenPreview, TemplatePreviewState } from '../gallery'; import { provideBrowserApis } from './given'; const empty = (name: string, kind: PackageKind): ScenePackageBundle => ({ @@ -37,6 +37,14 @@ describe('when choosing the default or the widget mode', () => { it('should not activate PrimeReact', () => host.bundles.some(bundle => bundle.manifest.name === 'PrimeReact').should.equal(false)); it('should offer every foundation template as compatible', () => host.templates.filter(entry => foundation.some(template => template.name === entry.name) && !entry.compatible).map(entry => entry.name).should.deep.equal([])); + it('should offer exactly the five neutral templates without PrimeReact among the former gallery templates', () => { + const neutral = ['Landing', 'ModuleWorkspace', 'FeatureSection', 'SliceSection']; + neutral.every(name => foundation.some(template => template.name === name)).should.equal(true); + borrowing.map(template => template.name).should.not.include.members(neutral); + const confirm = host.templates.find(entry => entry.name === 'ConfirmDialog' && entry.package === defaultBlueprintName); + (confirm?.compatible === true).should.equal(true); + (galleryDialogTemplates.find(template => template.name === 'ConfirmDialog')!.metadata?.compatibility?.packages ?? []).should.be.empty; + }); it('should offer every widget-borrowing template as unavailable with an actionable message', () => { const entries = host.templates.filter(entry => borrowing.some(template => template.name === entry.name)); entries.length.should.equal(borrowing.length); diff --git a/Source/JavaScript/blueprint.default/gallery/GalleryDialogPreview.tsx b/Source/JavaScript/blueprint.default/gallery/GalleryDialogPreview.tsx index 1ba6d4a4..8b43f8e5 100644 --- a/Source/JavaScript/blueprint.default/gallery/GalleryDialogPreview.tsx +++ b/Source/JavaScript/blueprint.default/gallery/GalleryDialogPreview.tsx @@ -10,7 +10,7 @@ import { LayoutThemeProvider, defaultBlueprintThemes } from '../themes'; import { externalComponent } from './elements'; import { ForcedComponentState, forceComponentState, galleryPreviewRegistry, queryResultsOf } from './GalleryScreenPreview'; import { galleryDialogTemplates } from './dialogTemplates'; -import { galleryComponentCatalog, galleryPreviewProfile } from './previewProfile'; +import { galleryComponentCatalog, previewProfileFor } from './previewProfile'; import { resolveElementComponentNames } from './resolveElementNames'; import { TemplatePreviewState } from './TemplatePreviewState'; @@ -51,9 +51,9 @@ export function GalleryDialogPreview({ dialogName, initialConfig, themes = defau const element = useMemo(() => { const composed = externalComponent(`dialog-${template.name}`, previewTemplateComponent, {}, template.content ?? {}); - const resolved = resolveElementComponentNames(composed, galleryPreviewProfile, galleryComponentCatalog); + const resolved = resolveElementComponentNames(composed, previewProfileFor(registry), galleryComponentCatalog); return forceState ? forceComponentState(resolved, forceState) : resolved; - }, [template, forceState]); + }, [template, forceState, registry]); const full = useMemo(() => ({ ...registry, ...previewComponents }), [registry]); return ( diff --git a/Source/JavaScript/blueprint.default/gallery/GalleryScreenPreview.tsx b/Source/JavaScript/blueprint.default/gallery/GalleryScreenPreview.tsx index 511b7582..cd6f16ac 100644 --- a/Source/JavaScript/blueprint.default/gallery/GalleryScreenPreview.tsx +++ b/Source/JavaScript/blueprint.default/gallery/GalleryScreenPreview.tsx @@ -11,7 +11,7 @@ import { ComponentState, foundationSamples } from '../foundation'; import { defaultBlueprintName } from '../packageName'; import { LayoutThemeProvider, defaultBlueprintThemes } from '../themes'; import { composeScreenElement } from './composeScreen'; -import { galleryComponentCatalog, galleryPreviewProfile } from './previewProfile'; +import { galleryComponentCatalog, previewProfileFor } from './previewProfile'; import { resolveElementComponentNames } from './resolveElementNames'; import { galleryScreen } from './screens'; import { TemplatePreviewState } from './TemplatePreviewState'; @@ -118,9 +118,9 @@ export function GalleryScreenPreview({ screenName, initialConfig, themes = defau throw new Error(`The gallery has no screen named '${screenName}'.`); } - const resolved = applyTemplatePreviewState(resolveElementComponentNames(composeScreenElement(screen), galleryPreviewProfile, galleryComponentCatalog), state); + const resolved = applyTemplatePreviewState(resolveElementComponentNames(composeScreenElement(screen), previewProfileFor(registry), galleryComponentCatalog), state); return forceState ? forceComponentState(resolved, forceState) : resolved; - }, [screenName, state, forceState]); + }, [screenName, state, forceState, registry]); return ( diff --git a/Source/JavaScript/blueprint.default/gallery/nesting.ts b/Source/JavaScript/blueprint.default/gallery/nesting.ts index 60a40f26..886b6789 100644 --- a/Source/JavaScript/blueprint.default/gallery/nesting.ts +++ b/Source/JavaScript/blueprint.default/gallery/nesting.ts @@ -7,7 +7,7 @@ import { SlotName, column, slotLeaf } from '../layouts'; import { TemplateSlotName } from './TemplateSlotName'; import { button, externalComponent } from './elements'; import { pageHeader } from './widgets'; -import { defaultBlueprintTemplateProvenance } from '../templateProvenance'; +import { neutralGalleryTemplateProvenance } from '../templateProvenance'; /** * A worked three-level chain, from the application layout down to a slice. @@ -37,7 +37,7 @@ export const moduleWorkspaceTemplate: ScreenTemplate = { }, displayName: 'Module workspace', description: 'Module level: fits the application layout content slot and offers a body for one feature.', - metadata: { ...defaultBlueprintTemplateProvenance, category: 'Foundation / Workspace' }, + metadata: { ...neutralGalleryTemplateProvenance, category: 'Foundation / Workspace' }, }; /** @@ -58,7 +58,7 @@ export const featureSectionTemplate: ScreenTemplate = { }, displayName: 'Feature section', description: 'Feature level: fits a module workspace body slot and brings the action toolbar.', - metadata: { ...defaultBlueprintTemplateProvenance, category: 'Foundation / Section' }, + metadata: { ...neutralGalleryTemplateProvenance, category: 'Foundation / Section' }, }; /** @@ -79,7 +79,7 @@ export const sliceSectionTemplate: ScreenTemplate = { }, displayName: 'Slice section', description: 'Slice level: fits a feature section body slot and hosts one behavior.', - metadata: { ...defaultBlueprintTemplateProvenance, category: 'Foundation / Section' }, + metadata: { ...neutralGalleryTemplateProvenance, category: 'Foundation / Section' }, }; /** The chain, outermost first - what a spec walks to prove `fitsSlot` resolves at every level. */ diff --git a/Source/JavaScript/blueprint.default/gallery/previewProfile.ts b/Source/JavaScript/blueprint.default/gallery/previewProfile.ts index 8c27d649..a809dfbc 100644 --- a/Source/JavaScript/blueprint.default/gallery/previewProfile.ts +++ b/Source/JavaScript/blueprint.default/gallery/previewProfile.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { UiProfile } from '@cratis/scene.model'; +import { ComponentRegistry, componentRegistryKey } from '@cratis/scene.react'; import { PackageCatalog } from '@cratis/scene.engine'; import { ComponentName } from '../ComponentName'; import { defaultBlueprintName } from '../packageName'; @@ -23,7 +24,7 @@ export const galleryProfile: UiProfile = { }; /** - * The profile a *preview* resolves against, when only some of those packages are actually loaded. + * The profile a *preview* resolves against, when only some of those packages are actually loaded (see {@link previewProfileFor}). * * Same four packages, different order, and the difference is deliberate. This blueprint takes no npm * dependency on the optional widget bundles - each borrowing template declares its own requirements - so a preview run from this package has `core` and this blueprint @@ -33,8 +34,8 @@ export const galleryProfile: UiProfile = { * Names only the libraries declare - `dataTable`, `timeline`, `tag` - still resolve to them, find no * implementation, and render as `UnresolvedComponent`'s dashed box naming what is missing. That is the * right outcome rather than a papered-over one: previewing a blueprint against half a profile *should* - * look visibly incomplete. A host with all four bundles loaded uses {@link galleryProfile} and sees the - * real widgets. + * look visibly incomplete. A preview whose registry implements all four packages uses {@link galleryProfile} + * instead and sees the real widgets. */ export const galleryPreviewProfile: UiProfile = { name: 'Gallery preview', @@ -42,6 +43,20 @@ export const galleryPreviewProfile: UiProfile = { packages: ['PrimeReact', 'Cratis.Components', 'core', defaultBlueprintName], }; +/** + * The profile a preview resolves against for the registry it renders with. + * + * {@link galleryProfile} when every package it declares has at least one implemented component in the registry, so + * PrimeReact's `text`, `button` and `card` win over core's; otherwise {@link galleryPreviewProfile}, which keeps + * partial bundles resolving to what exists. A package counts only when the registry holds one of its components, + * because a package that is listed but implements nothing would only turn names into placeholders. + */ +export const previewProfileFor = (registry: ComponentRegistry): UiProfile => { + const keys = Object.keys(registry); + const loaded = (name: string) => keys.some(key => key.startsWith(componentRegistryKey(name, ''))); + return galleryProfile.packages.every(loaded) ? galleryProfile : galleryPreviewProfile; +}; + /** * The component catalog the gallery resolves against. * diff --git a/Source/JavaScript/blueprint.default/gallery/statusTemplates.ts b/Source/JavaScript/blueprint.default/gallery/statusTemplates.ts index e1fbbf31..44415c03 100644 --- a/Source/JavaScript/blueprint.default/gallery/statusTemplates.ts +++ b/Source/JavaScript/blueprint.default/gallery/statusTemplates.ts @@ -7,7 +7,7 @@ import { SlotName } from '../layouts'; import { TemplateSlotName } from './TemplateSlotName'; import { button, card, externalComponent, panel, text } from './elements'; import { widget } from './widgets'; -import { defaultBlueprintTemplateProvenance } from '../templateProvenance'; +import { defaultBlueprintTemplateProvenance, neutralGalleryTemplateProvenance } from '../templateProvenance'; /** * The four screens that are not part of anyone's plan: a server error, a refusal, a wrong address, and the @@ -116,7 +116,7 @@ export const landingTemplate: ScreenTemplate = { }, displayName: 'Landing', description: 'The marketing front door, with its own navigation because it has no application chrome.', - metadata: { ...defaultBlueprintTemplateProvenance, category: 'Foundation / Landing' }, + metadata: { ...neutralGalleryTemplateProvenance, category: 'Foundation / Landing' }, }; /** The four status and marketing templates. */ diff --git a/Source/JavaScript/blueprint.default/templateProvenance.ts b/Source/JavaScript/blueprint.default/templateProvenance.ts index c49f1c0f..d26f2174 100644 --- a/Source/JavaScript/blueprint.default/templateProvenance.ts +++ b/Source/JavaScript/blueprint.default/templateProvenance.ts @@ -21,6 +21,15 @@ export const defaultBlueprintTemplateProvenance: Pick = { + ...defaultBlueprintTemplateProvenance, + compatibility: { scene: defaultBlueprintTemplateProvenance.compatibility!.scene }, +}; + /** * The provenance of the shell and the dashboard, whose arrangement follows PrimeTek's free Sakai template. * The arrangement is followed; no Sakai source or asset is part of this package. From cbf7f0685145ff580c0db38aad3ccaef83909c8f Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 23:43:42 +0100 Subject: [PATCH 2/5] Assert catalog-derived component priority in gallery profile specs PrimeReact's gallery subset declares text and card but not button, so button resolves to core under both profiles. --- .../when_resolving_the_component_names_they_reference.ts | 5 +++-- .../when_choosing_the_profile_for_a_registry.tsx | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts index be0e4fde..08b6240f 100644 --- a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts +++ b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts @@ -63,9 +63,10 @@ describe('when resolving the component names they reference', () => { .should.be.empty; }); - it('should let PrimeReact provide the shared names in the full gallery profile', () => { + it('should let PrimeReact provide the shared names it declares in the full gallery profile', () => { + // PrimeReact declares text and card but not button, so button stays with core. ['text', 'button', 'card'].map(name => resolveComponentName(name, galleryProfile, galleryComponentCatalog)!.package) - .should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact']); + .should.deep.equal(['PrimeReact', 'core', 'PrimeReact']); }); it('should let core provide the shared names in the partial-bundle preview profile', () => { diff --git a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx index 39c89283..5a005e0c 100644 --- a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx +++ b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx @@ -6,6 +6,7 @@ import { ComponentRegistry, corePackage } from '@cratis/scene.react'; import { defaultBlueprintComponents } from '../defaultBlueprintComponents'; import { defaultBlueprintName } from '../packageName'; import { GalleryScreenPreview, TemplatePreviewState, galleryComponentCatalog, galleryPreviewProfile, galleryProfile, previewProfileFor } from '../gallery'; +import { primeReactComponentNames } from '../gallery/dependencyComponentNames'; import { resolveComponentName } from '@cratis/scene.engine'; import { provideBrowserApis } from '../for_templatePreviewStates/given'; @@ -20,7 +21,8 @@ describe('when choosing the profile for a registry', () => { afterEach(cleanup); it('should use the full gallery profile when every package it declares is implemented', () => previewProfileFor(full).should.equal(galleryProfile)); - it('should let PrimeReact provide the shared names with the full registry', () => providers(full).should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact'])); + it('should let PrimeReact provide the shared names it declares with the full registry', () => providers(full).should.deep.equal(['PrimeReact', 'core', 'PrimeReact'])); + it('should resolve differently from the partial registry for every shared name PrimeReact declares', () => primeReactComponentNames.filter(name => sharedNames.includes(name)).forEach(name => resolveComponentName(name, previewProfileFor(full), galleryComponentCatalog)!.package.should.not.equal(resolveComponentName(name, previewProfileFor(partial), galleryComponentCatalog)!.package))); it('should keep the partial profile with core and the blueprint only', () => previewProfileFor(partial).should.equal(galleryPreviewProfile)); it('should let core provide the shared names with a partial registry', () => providers(partial).should.deep.equal(['core', 'core', 'core'])); it('should keep the partial profile when a listed package implements nothing', () => previewProfileFor({ ...partial, 'PrimeReact:text': stand }).should.equal(galleryPreviewProfile)); From 008c49a3b67034e4d87461c5694ca71349b4b794 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 23:45:46 +0100 Subject: [PATCH 3/5] Require complete PrimeReact registration before PrimeReact preview Declare PrimeReact button in the gallery catalog subset, select the full gallery profile only when every borrowed PrimeReact name is registered, and describe the conditional selection in the README. --- Source/JavaScript/blueprint.default/README.md | 5 +++-- ...resolving_the_component_names_they_reference.ts | 3 +-- .../when_choosing_the_profile_for_a_registry.tsx | 14 +++++++++++--- .../gallery/dependencyComponentNames.ts | 1 + .../blueprint.default/gallery/previewProfile.ts | 12 +++++++----- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Source/JavaScript/blueprint.default/README.md b/Source/JavaScript/blueprint.default/README.md index 07bf95ef..d2e67eb8 100644 --- a/Source/JavaScript/blueprint.default/README.md +++ b/Source/JavaScript/blueprint.default/README.md @@ -139,8 +139,9 @@ shell versions win the bare names while PrimeReact's stay reachable as `PrimeRea `GalleryScreenPreview` boots one gallery screen through the real path - real `Screen`, real `resolveComponentName`, real `SceneElementView`, real registry. Nothing in it is a preview-only code path. -It resolves against `galleryPreviewProfile` rather than `galleryProfile`. Both describe the gallery's optional -widget vocabulary; only a host that explicitly lists `PrimeReact` loads those widgets. A profile naming +It resolves against `galleryProfile` when its registry implements every package that profile declares (PrimeReact by +every component the gallery borrows from it), and against `galleryPreviewProfile` otherwise, so a partial bundle +renders what exists instead of placeholders. Only a host that explicitly lists `PrimeReact` loads those widgets. A profile naming only `Cratis.Blueprint.Default` resolves the shell and Cratis Components, renders the foundation templates, and leaves templates requiring the optional package incompatible in the template catalog. diff --git a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts index 08b6240f..a4725a3a 100644 --- a/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts +++ b/Source/JavaScript/blueprint.default/for_galleryScreens/when_resolving_the_component_names_they_reference.ts @@ -64,9 +64,8 @@ describe('when resolving the component names they reference', () => { }); it('should let PrimeReact provide the shared names it declares in the full gallery profile', () => { - // PrimeReact declares text and card but not button, so button stays with core. ['text', 'button', 'card'].map(name => resolveComponentName(name, galleryProfile, galleryComponentCatalog)!.package) - .should.deep.equal(['PrimeReact', 'core', 'PrimeReact']); + .should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact']); }); it('should let core provide the shared names in the partial-bundle preview profile', () => { diff --git a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx index 5a005e0c..f32fd834 100644 --- a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx +++ b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx @@ -12,7 +12,8 @@ import { provideBrowserApis } from '../for_templatePreviewStates/given'; const stand = () => null; const partial: ComponentRegistry = { ...corePackage.components, ...defaultBlueprintComponents }; -const full: ComponentRegistry = { ...partial, 'PrimeReact:text': stand, 'Cratis.Components:dataPage': stand }; +const primeReact = Object.fromEntries(primeReactComponentNames.map(name => [`PrimeReact:${name}`, stand])); +const full: ComponentRegistry = { ...partial, ...primeReact, 'Cratis.Components:dataPage': stand }; const sharedNames = ['text', 'button', 'card']; const providers = (registry: ComponentRegistry) => sharedNames.map(name => resolveComponentName(name, previewProfileFor(registry), galleryComponentCatalog)!.package); @@ -21,11 +22,11 @@ describe('when choosing the profile for a registry', () => { afterEach(cleanup); it('should use the full gallery profile when every package it declares is implemented', () => previewProfileFor(full).should.equal(galleryProfile)); - it('should let PrimeReact provide the shared names it declares with the full registry', () => providers(full).should.deep.equal(['PrimeReact', 'core', 'PrimeReact'])); + it('should let PrimeReact provide the shared names it declares with the full registry', () => providers(full).should.deep.equal(['PrimeReact', 'PrimeReact', 'PrimeReact'])); it('should resolve differently from the partial registry for every shared name PrimeReact declares', () => primeReactComponentNames.filter(name => sharedNames.includes(name)).forEach(name => resolveComponentName(name, previewProfileFor(full), galleryComponentCatalog)!.package.should.not.equal(resolveComponentName(name, previewProfileFor(partial), galleryComponentCatalog)!.package))); it('should keep the partial profile with core and the blueprint only', () => previewProfileFor(partial).should.equal(galleryPreviewProfile)); it('should let core provide the shared names with a partial registry', () => providers(partial).should.deep.equal(['core', 'core', 'core'])); - it('should keep the partial profile when a listed package implements nothing', () => previewProfileFor({ ...partial, 'PrimeReact:text': stand }).should.equal(galleryPreviewProfile)); + it('should keep the partial profile when a listed package implements nothing', () => previewProfileFor({ ...partial, 'PrimeReact:text': stand, 'Cratis.Components:dataPage': stand }).should.equal(galleryPreviewProfile)); for (const name of ['Landing', 'ModuleWorkspace', 'FeatureSection', 'SliceSection']) { it(`should render ${name} with a partial registry and no placeholders`, () => { @@ -34,5 +35,12 @@ describe('when choosing the profile for a registry', () => { }); } + it('should render Landing without placeholders when PrimeReact has text but not card', () => { + const partlyLoaded = { ...partial, 'PrimeReact:text': stand, 'Cratis.Components:dataPage': stand }; + previewProfileFor(partlyLoaded).should.equal(galleryPreviewProfile); + const { container } = render(); + (container.querySelector('[data-scene-unresolved-component]') === null).should.equal(true); + }); + it('should name the blueprint as a package of the full profile', () => galleryProfile.packages.should.include(defaultBlueprintName)); }); diff --git a/Source/JavaScript/blueprint.default/gallery/dependencyComponentNames.ts b/Source/JavaScript/blueprint.default/gallery/dependencyComponentNames.ts index db7051b8..9ef3c733 100644 --- a/Source/JavaScript/blueprint.default/gallery/dependencyComponentNames.ts +++ b/Source/JavaScript/blueprint.default/gallery/dependencyComponentNames.ts @@ -30,6 +30,7 @@ export const primeReactComponentNames: string[] = [ 'avatar', 'breadcrumb', + 'button', 'calendar', 'card', 'checkbox', diff --git a/Source/JavaScript/blueprint.default/gallery/previewProfile.ts b/Source/JavaScript/blueprint.default/gallery/previewProfile.ts index a809dfbc..4d29ae52 100644 --- a/Source/JavaScript/blueprint.default/gallery/previewProfile.ts +++ b/Source/JavaScript/blueprint.default/gallery/previewProfile.ts @@ -46,15 +46,17 @@ export const galleryPreviewProfile: UiProfile = { /** * The profile a preview resolves against for the registry it renders with. * - * {@link galleryProfile} when every package it declares has at least one implemented component in the registry, so - * PrimeReact's `text`, `button` and `card` win over core's; otherwise {@link galleryPreviewProfile}, which keeps - * partial bundles resolving to what exists. A package counts only when the registry holds one of its components, - * because a package that is listed but implements nothing would only turn names into placeholders. + * {@link galleryProfile} when every package it declares is implemented in the registry: this blueprint, core and + * Cratis Components by at least one component, and PrimeReact by *every* component name the gallery borrows from it + * (the catalog's PrimeReact subset), so PrimeReact's `text` and `card` win over core's. Otherwise + * {@link galleryPreviewProfile}, which keeps partial bundles resolving to what exists. A package that is only partly + * registered must not win a name it cannot render, or the preview would show placeholders. */ export const previewProfileFor = (registry: ComponentRegistry): UiProfile => { const keys = Object.keys(registry); const loaded = (name: string) => keys.some(key => key.startsWith(componentRegistryKey(name, ''))); - return galleryProfile.packages.every(loaded) ? galleryProfile : galleryPreviewProfile; + const primeReactComplete = primeReactComponentNames.every(name => componentRegistryKey('PrimeReact', name) in registry); + return galleryProfile.packages.every(loaded) && primeReactComplete ? galleryProfile : galleryPreviewProfile; }; /** From fb95acd3c65f8105129817f376288e8ed6a80ae6 Mon Sep 17 00:00:00 2001 From: woksin Date: Mon, 26 Oct 2026 23:47:29 +0100 Subject: [PATCH 4/5] Assert the empty compatibility list without an unused expression --- .../when_choosing_the_default_or_the_widget_mode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx index 42cb61ba..31a3a1d5 100644 --- a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx +++ b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx @@ -43,7 +43,7 @@ describe('when choosing the default or the widget mode', () => { borrowing.map(template => template.name).should.not.include.members(neutral); const confirm = host.templates.find(entry => entry.name === 'ConfirmDialog' && entry.package === defaultBlueprintName); (confirm?.compatible === true).should.equal(true); - (galleryDialogTemplates.find(template => template.name === 'ConfirmDialog')!.metadata?.compatibility?.packages ?? []).should.be.empty; + (galleryDialogTemplates.find(template => template.name === 'ConfirmDialog')!.metadata?.compatibility?.packages ?? []).length.should.equal(0); }); it('should offer every widget-borrowing template as unavailable with an actionable message', () => { const entries = host.templates.filter(entry => borrowing.some(template => template.name === entry.name)); From 7da54ba0ef46bb61ad5da2156b42cc395e145cfe Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 27 Oct 2026 00:07:52 +0100 Subject: [PATCH 5/5] Preserve neutral gallery actions and dialog interactions --- Source/JavaScript/blueprint.default/README.md | 9 ++- .../when_rendering_an_action_icon.tsx | 21 ++++++ ...en_choosing_the_profile_for_a_registry.tsx | 11 ++- ...endering_the_neutral_gallery_templates.tsx | 68 +++++++++++++++++++ ...hoosing_the_default_or_the_widget_mode.tsx | 2 +- .../when_previewing_the_dialog_templates.tsx | 24 ++++++- .../foundation/ActionButtonControl.tsx | 2 + .../foundation/foundationDescriptors.ts | 1 + .../blueprint.default/gallery/nesting.ts | 9 ++- .../gallery/statusTemplates.ts | 29 ++++---- .../primereact/button/PrimeButton.tsx | 3 +- 11 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 Source/JavaScript/blueprint.default/for_FoundationInteractions/when_rendering_an_action_icon.tsx create mode 100644 Source/JavaScript/blueprint.default/for_previewProfile/when_rendering_the_neutral_gallery_templates.tsx diff --git a/Source/JavaScript/blueprint.default/README.md b/Source/JavaScript/blueprint.default/README.md index d2e67eb8..ac30ac6f 100644 --- a/Source/JavaScript/blueprint.default/README.md +++ b/Source/JavaScript/blueprint.default/README.md @@ -154,9 +154,16 @@ which the Cratis Breadcrumbs destination callback does not expose. Shell icons a installed icon font. Layout modes, breakpoints, persistence, navigation policies and theme switching are unchanged. -The shell and foundation need no `PrimeReactProvider`, widget preset or icon font. Storybook applies none +The shell and foundation need no `PrimeReactProvider` or widget preset. The optional `actionButton.icon` +property renders decorative PrimeIcons classes; hosts using it supply the unchanged PrimeIcons font. +The button's label remains its accessible name. Storybook applies no widget provider, preset or font globally. Stories that preview optional widgets own their provider, preset and font imports instead. +Landing, ModuleWorkspace, FeatureSection and SliceSection use blueprint-owned headers and controls rather +than library-selected cards, text and buttons. Their primary and secondary actions keep their emphasis; +Landing's sign-in and start actions retain their target-screen navigation. FeatureSection keeps its +PrimeIcons. Headings are rendered once, including with the complete widget registry. + Older business templates still borrow widgets from `scene.primereact`. Their `templateProvenance` and the gallery dependency vocabulary declare that requirement locally; a host opts into the package in its UI profile when it chooses such a template. The shell layouts declare only their own component requirement. diff --git a/Source/JavaScript/blueprint.default/for_FoundationInteractions/when_rendering_an_action_icon.tsx b/Source/JavaScript/blueprint.default/for_FoundationInteractions/when_rendering_an_action_icon.tsx new file mode 100644 index 00000000..0730bff5 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_FoundationInteractions/when_rendering_an_action_icon.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { cleanup, screen } from '@testing-library/react'; +import { renderFoundation } from '../for_foundation/given'; + +describe('when rendering an action icon', () => { + afterEach(cleanup); + + it('should render the optional icon decoratively without changing the accessible name', () => { + renderFoundation('actionButton', { label: 'New', icon: 'pi pi-plus', emphasis: 'primary' }); + const button = screen.getByRole('button', { name: 'New' }); + expect(button.querySelector('i')?.className).toBe('pi pi-plus'); + expect(button.querySelector('i')?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('should keep actions without an icon unchanged', () => { + renderFoundation('actionButton', { label: 'Cancel', emphasis: 'secondary' }); + expect(screen.getByRole('button', { name: 'Cancel' }).querySelector('i')).toBeNull(); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx index f32fd834..f60ca423 100644 --- a/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx +++ b/Source/JavaScript/blueprint.default/for_previewProfile/when_choosing_the_profile_for_a_registry.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { cleanup, render } from '@testing-library/react'; +import { cleanup, render, screen } from '@testing-library/react'; import { ComponentRegistry, corePackage } from '@cratis/scene.react'; import { defaultBlueprintComponents } from '../defaultBlueprintComponents'; import { defaultBlueprintName } from '../packageName'; @@ -42,5 +42,14 @@ describe('when choosing the profile for a registry', () => { (container.querySelector('[data-scene-unresolved-component]') === null).should.equal(true); }); + it('should keep Landing headings singular and its actions independent of the complete widget registry', () => { + render(); + for (const title of ['Every mode people expect', 'Themeable to the token', 'The pages you were going to build anyway']) { + expect(screen.getAllByText(title)).toHaveLength(1); + } + expect(screen.getByRole('button', { name: 'Sign in' }).getAttribute('data-emphasis')).toBe('secondary'); + expect(screen.getAllByRole('button', { name: 'Start free' }).every(button => button.getAttribute('data-emphasis') === 'primary')).toBe(true); + }); + it('should name the blueprint as a package of the full profile', () => galleryProfile.packages.should.include(defaultBlueprintName)); }); diff --git a/Source/JavaScript/blueprint.default/for_previewProfile/when_rendering_the_neutral_gallery_templates.tsx b/Source/JavaScript/blueprint.default/for_previewProfile/when_rendering_the_neutral_gallery_templates.tsx new file mode 100644 index 00000000..d22802b7 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_previewProfile/when_rendering_the_neutral_gallery_templates.tsx @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { SceneElementView } from '@cratis/scene.react'; +import { galleryComponentCatalog, galleryPreviewRegistry, galleryScreenTemplates, resolveElementComponentNames } from '../gallery'; +import { defaultBlueprintName } from '../packageName'; + +const profile = { name: 'Neutral', targetPlatform: 'web', packages: ['core', defaultBlueprintName] }; + +function renderTemplate(name: string) { + const template = galleryScreenTemplates.find(candidate => candidate.name === name)!; + const elements = Object.values(template.content ?? {}).flat().map(element => resolveElementComponentNames(element, profile, galleryComponentCatalog)); + return render(<>{elements.map(element => undefined} />)}); +} + +describe('when rendering the neutral gallery templates', () => { + afterEach(() => { + cleanup(); + window.location.hash = ''; + }); + + it('should keep Landing actions named, distinguished and navigable without a widget library', () => { + const { container } = renderTemplate('Landing'); + expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull(); + const signIn = screen.getByRole('button', { name: 'Sign in' }); + expect(signIn.getAttribute('data-emphasis')).toBe('secondary'); + fireEvent.click(signIn); + expect(window.location.hash).toBe('#/Login'); + for (const start of screen.getAllByRole('button', { name: 'Start free' })) { + expect(start.getAttribute('data-emphasis')).toBe('primary'); + window.location.hash = ''; + fireEvent.click(start); + expect(window.location.hash).toBe('#/Register'); + } + expect(screen.getByRole('button', { name: 'Talk to us' }).getAttribute('data-emphasis')).toBe('secondary'); + for (const title of ['Ship the shell on day one', 'Every mode people expect', 'Themeable to the token', 'The pages you were going to build anyway']) { + expect(screen.getAllByText(title)).toHaveLength(1); + } + }); + + it('should keep ModuleWorkspace headings and content without inventing actions', () => { + const { container } = renderTemplate('ModuleWorkspace'); + screen.getByRole('heading', { name: 'Operations' }); + screen.getByText('Everything the operations module owns'); + expect(screen.queryAllByRole('button')).toHaveLength(0); + expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull(); + }); + + it('should keep FeatureSection primary and secondary actions and their decorative icons', () => { + const { container } = renderTemplate('FeatureSection'); + const primary = screen.getByRole('button', { name: 'New' }); + const secondary = screen.getByRole('button', { name: 'Export' }); + expect(primary.getAttribute('data-emphasis')).toBe('primary'); + expect(secondary.getAttribute('data-emphasis')).toBe('secondary'); + expect(primary.querySelector('i.pi-plus')?.getAttribute('aria-hidden')).toBe('true'); + expect(secondary.querySelector('i.pi-download')?.getAttribute('aria-hidden')).toBe('true'); + expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull(); + }); + + it('should keep SliceSection headings and content without inventing actions', () => { + const { container } = renderTemplate('SliceSection'); + screen.getByRole('heading', { name: 'Adjustment' }); + screen.getByText('One behavior, one surface'); + expect(screen.queryAllByRole('button')).toHaveLength(0); + expect(container.querySelector('[data-scene-unresolved-component]')).toBeNull(); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx index 31a3a1d5..448757aa 100644 --- a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx +++ b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_choosing_the_default_or_the_widget_mode.tsx @@ -37,7 +37,7 @@ describe('when choosing the default or the widget mode', () => { it('should not activate PrimeReact', () => host.bundles.some(bundle => bundle.manifest.name === 'PrimeReact').should.equal(false)); it('should offer every foundation template as compatible', () => host.templates.filter(entry => foundation.some(template => template.name === entry.name) && !entry.compatible).map(entry => entry.name).should.deep.equal([])); - it('should offer exactly the five neutral templates without PrimeReact among the former gallery templates', () => { + it('should offer the neutral gallery screens and confirmation dialog without PrimeReact', () => { const neutral = ['Landing', 'ModuleWorkspace', 'FeatureSection', 'SliceSection']; neutral.every(name => foundation.some(template => template.name === name)).should.equal(true); borrowing.map(template => template.name).should.not.include.members(neutral); diff --git a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx index e572e540..001643f2 100644 --- a/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx +++ b/Source/JavaScript/blueprint.default/for_templatePreviewStates/when_previewing_the_dialog_templates.tsx @@ -1,10 +1,10 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { PrimeReactProvider } from '@primereact/core'; -import { SceneElementView } from '@cratis/scene.react'; -import { galleryComponentCatalog, galleryDialogTemplates, galleryPreviewProfile, queryResultsOf, resolveElementComponentNames } from '../gallery'; +import { InteractionScope, SceneElementView, createBrowserDispatcher } from '@cratis/scene.react'; +import { galleryComponentCatalog, galleryDialogTemplates, galleryPreviewProfile, galleryProfile, previewProfileFor, queryResultsOf, resolveElementComponentNames } from '../gallery'; import { foundationPack } from '../for_foundationTemplates/given'; import { packageBackedRegistry, provideBrowserApis } from './given'; @@ -12,6 +12,24 @@ describe('when previewing the dialog templates', () => { beforeEach(provideBrowserApis); afterEach(cleanup); + it('should dispatch CloseDialog when Cancel is clicked with the complete PrimeReact registry', async () => { + const registry = { ...packageBackedRegistry, 'Cratis.Components:dataPage': () => undefined }; + const profile = previewProfileFor(registry); + expect(profile).toBe(galleryProfile); + const closeDialog = vi.fn(); + const template = galleryDialogTemplates.find(candidate => candidate.name === 'FormDialog')!; + const elements = Object.values(template.content ?? {}).flat() + .map(element => resolveElementComponentNames(element, profile, galleryComponentCatalog)); + render( + + {elements.map(element => undefined} />)} + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(closeDialog).toHaveBeenCalledTimes(1)); + }); + for (const template of galleryDialogTemplates) { it(`should render ${template.name} through the real packages, with a named field and an action for every choice`, () => { const elements = Object.values(template.content ?? {}).flat() diff --git a/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx b/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx index 340e0859..594d8ec9 100644 --- a/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx +++ b/Source/JavaScript/blueprint.default/foundation/ActionButtonControl.tsx @@ -22,6 +22,7 @@ export function ActionButtonControl({ element, interactions }: RegisteredCompone const requested = readOptionalString(element, 'emphasis'); const emphasis = (emphases as readonly string[]).includes(requested ?? '') ? (requested as typeof emphases[number]) : 'primary'; const target = readOptionalString(element, 'targetScreen'); + const icon = readOptionalString(element, 'icon'); return ( @@ -30,6 +31,7 @@ export function ActionButtonControl({ element, interactions }: RegisteredCompone if (interactions?.onClick) interactions.onClick(event); else if (target && typeof window !== 'undefined') window.location.hash = `#/${target}`; }}> + {icon &&