The server-injected inline <style> was missing the three derived gradient
variables (--sky-gradient, --sunset-gradient, --mixed-gradient) that
components reference via var(). Without them, components fell back to
SCSS build-time defaults on the first paint, causing a visible flash
before ThemeService.apply() corrected the values.
Additionally, migrate three component SCSS files from hardcoded SCSS
color variables to CSS custom properties so the first paint uses the
server-injected runtime colors:
- login.component.scss: backgrounds, text colors, borders, focus rings
- events.component.scss: event date card background gradient
- about.component.scss: donation CTA background gradient
The SSR server injects fresh color CSS custom properties as an inline
<style> block before </head>, but _themes.scss also set hardcoded
color defaults on :root at build time. The two :root blocks compete
in the cascade — the compiled stylesheet (loaded after the inline
<style>) overwrites the server-injected colors, causing a visible
flash where wrong colors render briefly before ThemeService.apply()
corrects them.
Removing the color properties from :root in _themes.scss makes the
server-injected <style> the sole source of truth for colors. Non-color
properties (fonts, radii, shadows) remain as build-time defaults since
they don't change between deploys.
The CSR shell previously rendered with hardcoded DEFAULT_CONFIG values,
then the client-side API call replaced them 50-500ms later, causing a
visible 'flash of stale content' (wrong colors, wrong station name).
Fix: SSR server fetches live station config from the backend API before
serving the CSR shell, injecting it as:
- Inline <script> seeding window.__STATION_CONFIG (client reads sync)
- Inline <style> setting CSS custom properties (correct colors first paint)
Also adds no-cache Cache-Control headers on HTML page responses to
prevent browsers from caching stale content.
StationConfigService now reads window.__STATION_CONFIG on construction
instead of always starting with DEFAULT_CONFIG.
The isPlatformBrowser guard in App.ngOnInit() was blocking
stationConfig.load() on the server, so SSR always rendered with
DEFAULT_CONFIG (hardcoded colors, assets, content).
Two-part fix:
- main.server.ts: Add APP_INITIALIZER that blocks server rendering
until station config is fetched from the API. Only runs on the
server — is a no-op in the browser to avoid blocking the client.
- app.ts: Move stationConfig.load() before the isPlatformBrowser
guard so ngOnInit also calls it on the server as a safety net.
This ensures SSR HTML contains real DB values (colors, hero icon,
station name, etc.) instead of build-time defaults — eliminating
the stale-asset flash reported by users.
- src/main.server.ts: process.env['SSR_API_BASE_URL'] instead of
process.env.SSR_API_BASE_URL to satisfy TS4111 index signature rule.
- server.spec.ts: update corresponding test assertions to match new syntax.
Root cause: Dockerfile copied only browser/ to nginx; server/server.mjs
was built but never deployed or executed. Prerender baked DEFAULT_CONFIG
into static HTML with no API at build time, causing stale content on
every initial paint.
Changes:
- Dockerfile: serve stage uses node:22-alpine, installs nginx via apk,
copies both browser/ and server/ bundles from build stage
- docker-entrypoint.sh: starts Angular SSR Node process on port 4200,
waits for readiness, exports SSR_API_BASE_URL, then exec nginx
- nginx.conf.template: adds upstream ssr_backend block (127.0.0.1:4200),
proxies / to SSR backend, static assets served directly with 1y cache
- app.server.routes.ts: all routes switched from RenderMode.Prerender
to RenderMode.Server for zero stale content
- server.ts: removed dead prerender code (PRERENDERED_ROUTES,
isPrerenderedRoute, allowStaticRouteRender)
- main.server.ts: injects SSR-aware apiBaseUrl via APP_CONFIG
(reads SSR_API_BASE_URL env var)
- server.spec.ts: updated 5 test suites + added 5 new suites (31 tests)
covering server.ts cleanup, RenderMode.Server, entrypoint lifecycle,
Dockerfile build, nginx upstream proxy, and API URL injection
- vitest.config.ts: added server.spec.ts to include pattern
Request flow: Browser -> nginx (port 80) -> SSR server (port 4200)
-> Angular SSR -> HttpClient -> localhost:80/api/station-config
-> nginx proxies to api:8000 -> fresh data rendered in HTML
- Add <link rel="preload"> for hero background image (LCP element) in index.html
- Remove blocking APP_INITIALIZER for config.json and station config loading
- Config loads now fire asynchronously in App.ngOnInit() (browser only)
- SSR/prerender renders instantly with default config — no network wait
- Improves LCP, Time to First Byte, and initial paint for SEO
- ThemeService: remove isPlatformBrowser guard so CSS custom properties
are applied during SSR/prerender — server-rendered HTML now carries the
correct color scheme and gradients (fixes missing colors on test site).
- main.ts: add provideClientHydration() so Angular preserves the server-
rendered DOM on the client instead of destroying and rebuilding it
(fixes jarring full-page reload between static and dynamic versions).
- Tests: update theme.service.spec.ts to match new behaviour, expand
coverage with gradient tests and empty-value handling (9 tests).
Fix structured-data.component.spec.ts import paths. All 54 tests pass.
The PlatformLocation static initializer in @angular/common/router
requires @angular/compiler to be loaded first. Move the compiler
import before any Angular router/common imports so the JIT facade
is available when the module loads.
- Install @angular/ssr and @angular/platform-server (v21.2.x)
- Create server entry point (server.ts) and server bootstrap (main.server.ts)
- Add server routes config (app.server.routes.ts) with 5 prerendered routes:
/, /about, /schedule, /events, /contact
- Update angular.json with server/ssr/prerender build configuration
- Add prerender-routes.txt for build-time static route generation
- Fix SSR compatibility:
- App component: guard DOM access with isPlatformBrowser
- StructuredDataComponent: skip head manipulation on server
- ThemeService: skip CSS variable application on server
- ShowService/EventService: return empty arrays on API failure
(graceful fallback during prerender with no backend)
- Prerendered output verified: 5 static HTML routes in dist/
Add SeoService for dynamic metadata management:
- Per-route document titles (e.g., 'KMountain Flower Radio — About')
- Meta descriptions from station config per route
- Canonical URLs, Open Graph tags, Twitter Card tags
- Input sanitization (HTML stripping, URL validation, attribute escaping)
- noindex robots directives for /login and /admin routes
- Centralized router watcher (zero per-component boilerplate)
Add StructuredDataService + StructuredDataComponent for JSON-LD:
- Organization schema (NCBOrganization) site-wide with contact info
- WebSite schema with publisher info
- WebPage schema per-route with breadcrumbs
- Dynamic Event schema on /events page
- RadioSeason schema on /schedule page
Wire up SeoService.watchRouter() in app-root ngOnInit.
Wire up StructuredDataComponent in app-root template.
Wire up EventsComponent and ScheduleComponent to feed data to
StructuredDataService for dynamic schema injection.
Includes full test suite for SeoService (22 tests).
Addresses reviewer feedback: social media fields (Facebook, Twitter/X, Instagram, YouTube) were added to the backend and frontend interfaces but had no admin UI for station owners to configure them.
Added a 'Social Media' section to the station config form with four URL inputs that persist via the existing station-config API.
- Import RouterLinkWithHref in footer and events components (required for queryParams binding on <a>)
- Add social media fields to DEFAULT_CONFIG in station-config.service.ts
- Add public/robots.txt (allow all, disallow /admin and /login, reference sitemap)
- Add public/sitemap.xml (5 public routes with change frequencies and priorities)
- Fix heading hierarchy: h2 -> h1 on about, schedule, events, contact pages
- Remove dead footer href="#" links:
- Volunteer/Corporate Giving/Planned Giving -> routerLink to /contact with subject query param
- Social media links -> conditional on config().social_* fields (hidden when empty)
- Fix logo ARIA: add aria-hidden="true" to decorative logo in about page divider
- Add social_facebook, social_twitter, social_instagram, social_youtube to:
- StationConfig DB model + startup migration
- StationConfigResponse and StationConfigUpdate schemas
- Frontend StationConfig TypeScript interface
- Fix events "Learn More" dead link -> routerLink to /contact
The Angular production build was failing with TS2348 errors because
EventEmitter<void>() was used without the 'new' keyword. This is not
valid TypeScript — EventEmitter is a constructor class, not a callable
value.
Fixes:
- src/app/admin/admin-users-tab.component.ts: added 'new' to saved and
closed EventEmitter properties
Backend:
- /api/admin/users: GET list, POST create, PUT update, DELETE
- Admin users can be added with email, display name, and password
- Self-deletion and last-admin deletion are blocked
- Admin role auto-assigned on creation
Frontend:
- AdminUsersTabComponent: list + add/delete admin users
- AdminUserService: HTTP client for admin user CRUD
- AdminUser interface
- 'Admins' tab added to admin dashboard
Tests:
- 10 new tests for admin user management endpoints
- 140 total tests pass (130 original + 10 new)