Adds published: boolean to the bootstrap wire contract. ConfigService swaps to a new DEFAULT_BOOTSTRAP constant (all feature flags on, generic branding/theme/pages) whenever the backend reports published: false, so an unpublished marketplace renders a working demo instead of a blank or broken page. Missing published field stays backward compatible (treated as true). Documents the brand bootstrap wire shape for backend/ops use. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
21 KiB
Frontend Default Bootstrap Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: When a marketplace has no published revision, the frontend renders a built-in, all-features-on generic placeholder instead of a broken/empty page — decided from one explicit published: boolean field on the /bootstrap response, not from HTTP status.
Architecture: Add published to BootstrapConfig. Add one new frontend-only constant DEFAULT_BOOTSTRAP: BootstrapConfig, composed from existing DEFAULT_HEADER_CONFIG / DEFAULT_MARKETPLACE_FEATURES_CONFIG / DEFAULT_PLATFORM_MODULES_CONFIG plus a hardcoded generic shell for the sections with no existing default (tenant, branding, theme, company, featureFlags, apiEndpoints, localization, seo, permissions, navigation, footer, pages, staticPages). ConfigService.loadBootstrap() swaps its cached snapshot to DEFAULT_BOOTSTRAP whenever the fetched response has published === false. No provider changes.
Tech Stack: Angular 22, RxJS, Jasmine/Karma (existing .spec.ts pattern in this repo).
Global Constraints
publishedmissing/undefined on a response must be treated astrue(backward compatible — matches the existing pattern formodules/ADR-011).- Fallback is a whole-object swap — no field-level merging with the real response.
- Fallback triggers only on the explicit
published: falsesignal, never on HTTP failure (existingcatchErrorbehavior inConfigServiceis untouched). DEFAULT_BOOTSTRAP.featureFlagsand.featuresmust have every flagtrue.- Reuse
DEFAULT_HEADER_CONFIG,DEFAULT_MARKETPLACE_FEATURES_CONFIG,DEFAULT_PLATFORM_MODULES_CONFIGas-is — do not redefine their values inline.
Task 1: Add published to the BootstrapConfig contract
Files:
- Modify:
src/app/shared/models/config/bootstrap-config.model.ts - Modify:
src/assets/mock/bootstrap/bootstrap.json(add"published": trueso the existing mock keeps behaving as "already live")
Interfaces:
-
Produces:
BootstrapConfig.published: boolean— consumed by Task 3 (ConfigService). -
Step 1: Add the field to the interface
In src/app/shared/models/config/bootstrap-config.model.ts, add published right after generatedAt:
export interface BootstrapConfig {
schemaVersion: string;
generatedAt: string;
published: boolean;
tenant: TenantConfig;
branding: BrandingConfig;
theme: ThemeConfig;
company: CompanyConfig;
featureFlags: FeatureFlagsConfig;
features?: MarketplaceFeaturesConfig;
apiEndpoints: ApiEndpointsConfig;
localization: LocalizationConfig;
seo: SeoConfig;
permissions: PermissionsConfig;
header?: HeaderConfig;
catalog?: CatalogConfig;
layout?: PlatformLayoutConfig;
navigation: NavigationConfig;
footer?: FooterConfig;
productPage?: ProductPageConfig;
userExperience?: UserExperienceConfig;
pages: PageConfig[];
staticPages?: StaticPagesConfig;
widgetRegistry?: WidgetRegistryConfig;
modules?: PlatformModulesConfig;
seller?: SellerConfig;
}
- Step 2: Update the mock fixture
In src/assets/mock/bootstrap/bootstrap.json, add "published": true, as the line right after "generatedAt": "2026-07-03T00:00:00Z", (line 3).
- Step 3: Compile check
Run: npx tsc --noEmit -p tsconfig.json
Expected: no new errors referencing bootstrap-config.model.ts or bootstrap.json (the mock file isn't type-checked, but any TS consumer that builds a BootstrapConfig object literal without published will now fail — confirms the field is wired through).
- Step 4: Commit
git add src/app/shared/models/config/bootstrap-config.model.ts src/assets/mock/bootstrap/bootstrap.json
git commit -m "feat: add published field to BootstrapConfig contract"
Task 2: Add the DEFAULT_BOOTSTRAP constant
Files:
- Create:
src/app/shared/models/config/default-bootstrap.const.ts - Modify:
src/app/shared/models/config/index.ts(export the new file) - Test:
src/app/shared/models/config/default-bootstrap.const.spec.ts
Interfaces:
-
Consumes:
BootstrapConfig(Task 1),DEFAULT_HEADER_CONFIGfrom./header-config.model,DEFAULT_MARKETPLACE_FEATURES_CONFIGfrom./features-config.model,DEFAULT_PLATFORM_MODULES_CONFIGfrom./platform-modules.model. -
Produces:
DEFAULT_BOOTSTRAP: BootstrapConfig— consumed by Task 3 (ConfigService). -
Step 1: Write the failing test
Create src/app/shared/models/config/default-bootstrap.const.spec.ts:
import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const';
describe('DEFAULT_BOOTSTRAP', () => {
it('is marked unpublished', () => {
expect(DEFAULT_BOOTSTRAP.published).toBe(false);
});
it('has every feature flag turned on', () => {
Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => {
expect(value).toBe(true);
});
});
it('has every optional MarketplaceFeaturesConfig flag turned on', () => {
expect(DEFAULT_BOOTSTRAP.features).toBeDefined();
Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => {
expect(value).toBe(true);
});
});
it('has at least one page with a hero section', () => {
expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0);
const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero');
expect(heroSection).toBeDefined();
});
it('has a generic brand name, not a real tenant name', () => {
expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace');
});
});
- Step 2: Run test to verify it fails
Run: ng test --include='**/default-bootstrap.const.spec.ts' --watch=false
Expected: FAIL — Cannot find module './default-bootstrap.const'
- Step 3: Write the constant
Create src/app/shared/models/config/default-bootstrap.const.ts:
import { BootstrapConfig } from './bootstrap-config.model';
import { DEFAULT_HEADER_CONFIG } from './header-config.model';
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model';
import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model';
/**
* Whole-object fallback rendered whenever the backend reports
* `published: false` for the resolved marketplace (no published revision
* yet). Every feature flag is on so it doubles as a full-surface product
* demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md.
*/
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
schemaVersion: '1.0.0',
generatedAt: new Date(0).toISOString(),
published: false,
tenant: {
id: 'tenant-default-unpublished',
slug: 'default',
code: 'DEFAULT',
host: 'default.local',
name: 'Marketplace',
websiteBaseUrl: 'https://marketplace.local',
builderBaseUrl: 'https://builder.marketplace.local',
backofficeBaseUrl: 'https://backoffice.marketplace.local',
defaultLocale: 'en',
supportedLocales: ['en'],
defaultCurrency: 'USD',
supportedCurrencies: ['USD'],
timezone: 'UTC',
},
branding: {
brandName: 'Marketplace',
legalName: 'Marketplace',
slogan: 'Your store, coming soon',
logoUrl: '/icons/icon-192x192.png',
logoCompactUrl: '/icons/icon-192x192.png',
faviconUrl: '/favicon.ico',
appIconUrl: '/icons/icon-192x192.png',
supportEmail: 'support@marketplace.local',
},
theme: {
themeId: 'default-light',
mode: 'light',
palette: {
primary: '#497671',
secondary: '#a1b4b5',
accent: '#a7ceca',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
textPrimary: '#1e3c38',
textSecondary: '#667a77',
backgroundPrimary: '#ffffff',
backgroundSecondary: '#f5f5f5',
border: '#d3dad9',
},
typography: {
primaryFontFamily: 'DM Sans, sans-serif',
headingFontFamily: 'DM Sans, sans-serif',
baseFontSize: 16,
},
spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] },
borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' },
shadows: {
sm: '0 2px 8px rgba(0,0,0,0.1)',
md: '0 4px 12px rgba(0,0,0,0.15)',
lg: '0 12px 32px rgba(73,118,113,0.2)',
},
iconSet: 'default',
},
company: {
companyName: 'Marketplace',
address: { country: '', city: '' },
contacts: { email: 'support@marketplace.local' },
},
featureFlags: {
wishlist: true,
compare: true,
reviews: true,
questions: true,
comments: true,
recommendations: true,
blog: true,
chat: true,
analytics: true,
notifications: true,
coupons: true,
loyalty: true,
giftCards: true,
invoices: true,
},
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG,
apiEndpoints: {
bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 },
website: {},
builder: {},
backoffice: {},
},
localization: {
defaultLocale: 'en',
supportedLocales: ['en'],
currencyByLocale: { en: 'USD' },
dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }],
},
seo: {
default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
byPageKey: {
home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
},
},
permissions: { definitions: [], roles: [] },
header: DEFAULT_HEADER_CONFIG,
navigation: {
header: [
{ id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 },
{ id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 },
{ id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 },
],
footer: [
{ id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 },
{ id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 },
],
},
footer: {
paymentIcons: [],
copyrightText: { en: '© 2026 Marketplace. All rights reserved.' },
legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'],
},
staticPages: {
'about-us': {
route: '/about-us',
title: { en: 'About Us' },
html: { en: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
},
'privacy-policy': {
route: '/privacy-policy',
title: { en: 'Privacy Policy' },
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
},
'terms-of-service': {
route: '/terms-of-service',
title: { en: 'Terms of Service' },
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
},
},
pages: [
{
id: 'page-home',
key: 'home',
title: 'Home',
route: { path: '/', exact: true },
layout: { type: 'default' },
seoKey: 'home',
visible: true,
sections: [
{
id: 'section-hero',
type: 'hero',
order: 1,
layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-hero-main',
type: 'hero',
version: '1.0.0',
order: 1,
padding: '0.5rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: {
title: { en: 'Welcome to Marketplace' },
subtitle: { en: 'This storefront has not been published yet' },
ctaLabel: { en: 'Learn more' },
},
},
],
},
{
id: 'section-categories',
type: 'categories',
order: 2,
layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-categories-root',
type: 'categories',
version: '1.0.0',
order: 1,
padding: '0.25rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' },
},
],
},
],
},
],
modules: DEFAULT_PLATFORM_MODULES_CONFIG,
};
- Step 4: Export it from the barrel file
In src/app/shared/models/config/index.ts, add one line (alphabetical position, after catalog-config.model):
export * from './default-bootstrap.const';
- Step 5: Run test to verify it passes
Run: ng test --include='**/default-bootstrap.const.spec.ts' --watch=false
Expected: PASS (5 specs)
- Step 6: Commit
git add src/app/shared/models/config/default-bootstrap.const.ts src/app/shared/models/config/default-bootstrap.const.spec.ts src/app/shared/models/config/index.ts
git commit -m "feat: add DEFAULT_BOOTSTRAP placeholder config"
Task 3: Swap to DEFAULT_BOOTSTRAP in ConfigService when unpublished
Files:
- Modify:
src/app/core/config/config.service.ts - Test:
src/app/core/config/config.service.spec.ts(new file — none exists today)
Interfaces:
-
Consumes:
DEFAULT_BOOTSTRAP(Task 2),BootstrapConfig.published(Task 1), existingCONFIG_PROVIDERtoken /ConfigProvider.loadBootstrap(). -
Produces: no new public method —
loadBootstrap()andgetBootstrapSnapshot()keep their existing signatures; behavior changes only in which object ends up cached. -
Step 1: Write the failing tests
Create src/app/core/config/config.service.spec.ts:
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ConfigService } from './config.service';
import { CONFIG_PROVIDER } from './config-provider.token';
import { ConfigProvider } from './config-provider.interface';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
function makeRealBootstrap(overrides: Partial<BootstrapConfig> = {}): BootstrapConfig {
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
}
describe('ConfigService', () => {
let provider: jasmine.SpyObj<ConfigProvider>;
function setup(response: BootstrapConfig): ConfigService {
provider = jasmine.createSpyObj<ConfigProvider>('ConfigProvider', ['loadBootstrap']);
provider.loadBootstrap.and.returnValue(of(response));
TestBed.configureTestingModule({
providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }],
});
return TestBed.inject(ConfigService);
}
it('caches the real response when published is true', done => {
const real = makeRealBootstrap();
const service = setup(real);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(service.getBootstrapSnapshot()).toEqual(real);
done();
});
});
it('swaps to DEFAULT_BOOTSTRAP when published is false', done => {
const draft = makeRealBootstrap({ published: false });
const service = setup(draft);
service.loadBootstrap().subscribe(result => {
expect(result).toEqual(DEFAULT_BOOTSTRAP);
expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
it('treats a missing published field as published (backward compatible)', done => {
const legacy = makeRealBootstrap();
delete (legacy as Partial<BootstrapConfig>).published;
const service = setup(legacy);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
});
- Step 2: Run tests to verify they fail
Run: ng test --include='**/config.service.spec.ts' --watch=false
Expected: FAIL on the "swaps to DEFAULT_BOOTSTRAP" spec — result currently equals draft (unpublished, unswapped), not DEFAULT_BOOTSTRAP.
- Step 3: Implement the swap
Replace the body of src/app/core/config/config.service.ts with:
import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
import { CONFIG_PROVIDER } from './config-provider.token';
@Injectable({ providedIn: 'root' })
export class ConfigService {
private readonly provider = inject(CONFIG_PROVIDER);
private bootstrapSnapshot: BootstrapConfig | null = null;
private bootstrap$?: Observable<BootstrapConfig>;
private readonly revisionState = signal(0);
readonly bootstrapRevision = this.revisionState.asReadonly();
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
return this.bootstrap$;
}
if (!this.bootstrap$ || forceRefresh) {
this.bootstrap$ = this.provider.loadBootstrap().pipe(
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
tap(config => {
this.bootstrapSnapshot = config;
this.revisionState.update(value => value + 1);
}),
shareReplay(1),
catchError(error => {
this.bootstrap$ = undefined;
this.bootstrapSnapshot = null;
return throwError(() => error);
})
);
}
return this.bootstrap$;
}
getBootstrapSnapshot(): BootstrapConfig | null {
return this.bootstrapSnapshot;
}
applyBootstrapOverride(next: BootstrapConfig): void {
const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig;
this.bootstrapSnapshot = cloned;
this.bootstrap$ = of(cloned);
this.revisionState.update(value => value + 1);
}
}
The only change from the current file: the map operator inserted before tap, and the DEFAULT_BOOTSTRAP import. config.published === false (strict) rather than !config.published is deliberate — it makes undefined/missing explicitly fall through to "treat as published," matching the backward-compatibility constraint.
- Step 4: Run tests to verify they pass
Run: ng test --include='**/config.service.spec.ts' --watch=false
Expected: PASS (3 specs)
- Step 5: Run the full unit suite to check for regressions
Run: ng test --watch=false
Expected: PASS, no new failures (existing consumers of ConfigService only rely on loadBootstrap()/getBootstrapSnapshot(), unchanged signatures).
- Step 6: Commit
git add src/app/core/config/config.service.ts src/app/core/config/config.service.spec.ts
git commit -m "feat: fall back to DEFAULT_BOOTSTRAP when marketplace is unpublished"
Task 4: E2E smoke test for the unpublished placeholder
Files:
- Modify:
e2e/smoke.spec.ts(existing Playwright smoke suite)
Interfaces:
-
Consumes: Playwright route interception (
page.route),DEFAULT_BOOTSTRAP.branding.brandName(Task 2) as the assertion target. -
Step 1: Read the existing smoke spec to match its conventions
Run: cat e2e/smoke.spec.ts (or open the file) — confirm the existing pattern for intercepting /bootstrap if one exists, and the base URL fixture used by other specs in this file.
- Step 2: Write the failing test
Add to e2e/smoke.spec.ts:
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
await page.route('**/bootstrap', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
})
);
await page.goto('/');
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
});
- Step 3: Run it to verify it fails
Run: npx playwright test e2e/smoke.spec.ts -g "unpublished"
Expected: FAIL — either the route interception payload is rejected client-side (schema mismatch) or the text isn't found, since Task 1–3 aren't wired in yet if this task runs standalone. If Tasks 1–3 are already merged, this should already pass; if it fails for a reason other than "text not found" (e.g. a network error), fix the intercepted payload shape first, not the app code.
- Step 4: Confirm it passes against the real implementation
Run: npx playwright test e2e/smoke.spec.ts -g "unpublished"
Expected: PASS, once Tasks 1–3 are committed.
- Step 5: Commit
git add e2e/smoke.spec.ts
git commit -m "test: add e2e smoke test for unpublished-marketplace placeholder"
Self-review notes
- Spec coverage: §1 (backend
publishedfield) → Task 1. §2 (whole-object swap,DEFAULT_BOOTSTRAPcomposed from existingDEFAULT_*constants) → Task 2. §2 (ConfigServicetrigger point) → Task 3. §3 (error handling: missing field = published, HTTP failure unchanged) → covered by Task 3 Step 1 test 3 and by leavingcatchErroruntouched. §4 (testing) → Tasks 2–4 cover unit + E2E; schema-shape check is TypeScript compilation itself (Task 2 Step 3 must compile againstBootstrapConfig). - Backend-side resolution logic (how the backend decides
publishedfromMarketplaceRevision.status) is explicitly out of scope per the spec — not a frontend-repo task.