JJK.engineer — Project Specification
For Claude Code: Read this entire file at the start of every session. Before making any architectural decision — adding a dependency, creating a new directory, choosing a pattern — verify it aligns with this spec. If a question isn’t answered here, ask the developer. Do not invent architecture to fill gaps. Do not add features, abstractions, or “nice-to-haves” not described in this document. When in doubt, choose the simpler option.
Document Purpose: This is the target specification for jjk.engineer. It captures decisions, not implementation. Any development work (including by Claude Code) should reference this document to ensure alignment. If a proposed change contradicts this spec, stop and discuss before proceeding.
Companion docs: docs-site/architecture/decisions/ holds the Architecture Decision Records. This spec describes what the architecture is; the ADRs record why each significant decision was made and when. Read both.
Last Updated: April 8, 2026
1. What Is This Project?
jjk.engineer is a personal engineering blog and portfolio site authored by Justin (a licensed Professional Engineer and engineering superintendent in capital projects at a chemical facility). The site’s editorial voice is satirical — it critiques corporate dysfunction, performance theater, and consultant culture through deadpan humor. The “Ernest Sludge” persona stands as a style guardian.
The site must serve two audiences simultaneously:
- Casual readers who find the satire entertaining
- Working engineers who recognize the dysfunction and find solidarity
The blog content is the primary product. Everything else — tools, portfolio, interactive features — is secondary.
2. Project Identity
Voice & Tone
- Satirical, deadpan, irreverent
- Looks like a serious corporate publication at first glance
- The absurdity reveals itself the longer you look
- Never mean-spirited — punches up at systems, not at people
Visual Identity
- Not a generic tech blog. The design IS the joke.
- Professional layout and clean typography that subtly subverts itself
- Background layers with faded SVG absurdities: fake flowcharts, nonsense P&IDs, Gantt charts extending to 2047, engineering stamps reading “APPROVED FOR DYSFUNCTION”, anything you might find in corporate documentation/emails…
- Micro-interactions that reinforce the satire (loading states say “Awaiting Approval…”, 404 is a Management of Change form, etc.)
- Icons: Font Awesome Free via kit (
<script src="https://kit.fontawesome.com/9f0422756e.js">). Use<i class="fa-solid fa-icon-name" aria-hidden="true">elements. Available families:fa-solid,fa-regular,fa-brands. No inline SVGs for icons, no other icon libraries. - Custom CSS design system — no CSS framework (Tailwind, Bootstrap, etc.)
- PrimeNG used only where interactive widget behavior is needed (tools pages), never for blog content rendering. Why: see Key Architectural Decisions.
- All UI components wrapped in
jjk-*selectors regardless of whether PrimeNG is used underneath. Why: decouples templates from any third-party library. If PrimeNG is ever swapped out, only the wrapper internals change — no template updates across the app.
Design Musts (Day One)
- Dark mode / light mode based on system preference
- Fully mobile responsive — not an afterthought
- CSS custom properties as the design token system
- Optimal reading width for blog content (~42rem)
3. Architecture
Workspace Structure
- Angular monorepo (single workspace, single application — not a multi-lib setup)
- Frontend and backend in the same repo with shared code
- npm workspaces for shared node_modules across front/back. Why: single dependency tree prevents version mismatches between what the build pipeline uses and what the frontend bundles.
- Path aliases (
@jjk/shared,@jjk/models,@jjk/utils,@jjk/constants) configured from day one in tsconfig.base.json. Why: retrofitting path aliases is painful. They also make imports readable and decouple consumers from physical directory structure. - No publishable libraries — shared code lives in a
shared/directory, not an Angular library project. Why: the only consumer is this project. Library packaging overhead has zero audience. - If a piece of code earns extraction into a lib later, it can be extracted then. Not before.
High-Level Directory Layout
jjk-workspace/
├── package.json # root workspace config
├── tsconfig.base.json # path aliases live here
├── angular.json
├── frontend/ # Angular application
├── functions/ # Firebase Cloud Functions
├── tools/build/ # content pipeline (Markdown → JSON)
├── shared/ # models, constants, utils, services (no platform deps)
├── content/ # markdown source files (the blog)
└── tools/ # dev scripts, scaffolding, config
Key Architectural Decisions
- No over-engineered content engine. Start simple. Build custom tooling only when a real authoring limitation is hit — not before.
- Build-time content processing. Markdown → HTML happens at build time, not runtime. The Angular app receives pre-compiled JSON and renders it. Zero runtime markdown parsing. Why: keeps the frontend fast and dumb — it just fetches JSON. All complexity lives in the build step where it’s testable and debuggable outside the browser.
- ViewEncapsulation.None for rendered content. Any component that renders pre-compiled HTML via
[innerHTML]must useViewEncapsulation.None. Angular’s default emulated encapsulation adds_ngcontentattributes to scoped CSS selectors — but dynamically injected HTML doesn’t carry those attributes, so styles silently fail. Scope these styles with the.jjk-proseclass prefix to prevent leakage into the rest of the app. - Blog module uses zero PrimeNG. Why: bundle size AND aesthetic independence. Blog readers should never download a UI component library. And blog styling must be fully custom to achieve the satirical visual identity — PrimeNG’s opinions would fight that. PrimeNG is lazy-loaded only in tool pages that genuinely need interactive data components (tables, dialogs, form widgets).
- Backend-driven security model. Public-facing writes go through callable Cloud Functions (
onCallwithenforceAppCheck: true), not direct Firestore writes from the client. Functions enforce input validation, sanitization, rate limiting, and ID generation server-side. Firestore rules act as a secondary gate —allow create: if falseon collections that have a corresponding function. Client-side validation is UX only; it is never the sole enforcement. Admin mutations on sensitive collections should haveonDocumentUpdatedtriggers writing audit trails to subcollections. Why: the client is a display layer. Anything that needs to be trusted runs on the server. This was established during the enterprise hardening pass and applies to all new features going forward. - Performance monitoring via Firebase Performance, not console. Never use
console.time,console.timeEnd, orconsole.logfor performance measurement. UsePerformanceService(@app/shared/services/performance.service) which wraps Firebase Performance custom traces. Traces appear in Firebase Console under Performance > Custom traces with p50–p99 data. The service returns no-op traces during SSR and emulator mode, so callers never need null checks. Why: console timing is invisible in production, can’t be dashboarded, and pollutes the console. Firebase Performance gives real user percentile data with zero console noise.
Technology Stack
| Technology | Target | Notes |
|---|---|---|
| Angular | 21 | Use standalone components, new control flow (@if, @for), signals. Do NOT install 19 or 20. |
| Test runner | Vitest | Not Jasmine, not Jest, not Karma. Vitest only. |
| Node.js | LTS (20+) | Build pipeline and Firebase Functions |
| TypeScript | Strict mode | strict: true in all tsconfig files |
| PrimeNG | Latest stable | Lazy-loaded, tools pages only |
| Firebase | Current SDK | Functions, Auth, Firestore, Hosting, App Check, Remote Config, Secret Manager, Performance |
| CSS | Custom properties | No preprocessor required — vanilla CSS with custom properties. SCSS only if a clear need emerges |
| Markdown | markdown-it or remark | Decide during implementation — either is fine, pick one and stay consistent |
| Syntax highlighting | Shiki | Build-time highlighting, not runtime |
| Diagrams | Mermaid | Runtime rendering via mermaid library (lazy-loaded). Re-renders on theme change. See ADR 0004 amendment. |
| Package manager | npm | npm workspaces for monorepo. No yarn, no pnpm unless a blocking issue forces it |
Naming Conventions
| Thing | Convention | Example |
|---|---|---|
| Files (components, services, etc.) | kebab-case | post-card.component.ts, tag-index.service.ts |
| Directories | kebab-case | blog-engine/, shared/models/ |
| Component selectors | jjk- prefix, kebab-case |
jjk-post-card, jjk-shell, jjk-nav |
| Services | PascalCase, no prefix | ContentService, TagIndexService |
| Interfaces/Models | PascalCase, no I prefix |
PostMeta, TagIndex, SeriesConfig |
| CSS custom properties | --jjk- prefix |
--jjk-color-ink, --jjk-font-body |
| Content files (posts) | NN-slug-title.md |
01-agile-is-a-trauma-response.md |
| Path aliases | @jjk/ prefix |
@jjk/models, @jjk/utils |
| Git branches | kebab-case with type prefix | feat/blog-engine, fix/dark-mode-toggle |
| Git commits | conventional commits | feat: add tag index page, fix: dark mode flicker |
| Firestore collections | snake_case | inbox_messages, timeclock_entries, rate_limits |
4. Content System
File-Based Routing
- The file path determines the URL. No manual route registration to publish a post.
- Numeric prefixes in filenames are for ordering only and are stripped from URLs.
- Files prefixed with underscore (
_) are never converted to pages. They serve as fragments, partials, series metadata, or reference material. - Draft and future-dated post visibility is environment-dependent (see Build Pipeline section for full rules).
Content Directory Structure
content/
├── posts/
│ ├── 2025/
│ │ ├── 01-post-title.md
│ │ └── 02-another-post.md
│ ├── series-name/ # series = folder inside posts
│ │ ├── _series.yaml # series metadata, not a page
│ │ ├── 01-first-entry.md
│ │ └── 02-second-entry.md
│ └── _disclaimer.md # fragment, not a page
├── pages/ # standalone pages (about, contact, etc.)
└── fragments/ # reusable content blocks
Series Handling
- A series is just a folder inside
posts/that contains a_series.yamlfile. - Posts inside a series folder automatically receive a
series:<slug>tag. Authors do not need to declare series membership in frontmatter. - Series are not architecturally different from standalone posts. They’re posts with an auto-applied tag and ordering from filename prefixes.
- Tag pages for series tags sort by series order (numeric prefix). Regular tags sort by date.
Frontmatter Schema
Every markdown content file begins with YAML frontmatter. The build pipeline validates against this schema at build time. Missing required fields = build failure.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
title |
string | yes | — | Post title, used in <h1>, <title>, and meta tags |
date |
date (YYYY-MM-DD) | yes | — | Publish date. Controls scheduled publishing in prod builds |
tags |
string[] | yes | — | Array of tag slugs. Used for categorization and tag pages |
subtitle |
string | no | — | Displayed below title, used in meta description if no excerpt |
updated |
date (YYYY-MM-DD) | no | — | If present, shows “Updated” badge on the post |
draft |
boolean | no | false |
If true, excluded from production builds entirely |
cover |
string (path) | no | — | Path to cover image (e.g. /assets/img/posts/foo.png). Renders as post hero + card thumbnail and drives og:image. File must exist; source must be < 2MB (< 500KB recommended; optimized target < 100KB). |
coverAlt |
string | when cover set |
— | Accessibility + SEO alt text for the cover image. Build fails if cover is set without this. |
excerpt |
string | no | auto-generated | Short summary for post cards and meta description |
toc |
boolean | no | true |
Whether to render table of contents |
layout |
"post" | "page" | "tool" |
no | "post" |
Determines which layout template wraps the content |
readingTime |
number | "auto" |
no | "auto" |
Minutes. Auto-calculated from word count if not specified |
corporateBsLevel |
number (0-10) | no | — | Satirical metadata. May drive visual indicators on post cards |
topArticle |
number (1-5) | no | — | Position in “Top 5 Articles” ranking. Omit if not a top post |
member |
boolean | no | false |
Marks post as paid-member-only content. Gating not yet implemented |
Custom Markdown Directives (Start Simple)
:::callout{type="..."}— styled content blocks (warnings, asides, corporate-bs callouts):::include{src="..."}— inline content from underscore-prefixed fragment files- More directive types (interactive component hydration, expressions) can be added later IF a real need emerges. Do not build these speculatively.
5. Infrastructure & Hosting
Vendor Consolidation: Firebase
- Hosting: Firebase Hosting
- Backend: Firebase Functions
- Auth: Firebase Auth (custom claims for admin roles)
- Database: Firestore
- App Check: reCAPTCHA Enterprise for abuse protection
- Remote Config: Feature flags and dynamic values (e.g.,
admin_idle_timeout_ms) - Secret Manager: API keys and sensitive config (accessed via
defineSecret()in functions) - Goal: Single
firebase deployhits everything. Single vendor, single billing, single CLI.
What Migrates from Azure
- Inventory Azure resources and migrate anything stateful
- Most Azure endpoints are expected to port to Firebase Functions mechanically
- This is a migration task, not an architecture task
Deployment
npm run buildproduces a static frontend + compiled functions- Firebase preview channels for draft/staging content
- No SSR required — static HTML with Angular hydration is sufficient for a blog
- Cloud Build scheduled trigger handles scheduled publishing (see Build Pipeline > Post Visibility Rules)
6. Build Pipeline
Core Responsibilities
- Discover markdown files in
content/(respecting underscore exclusion) - Parse frontmatter and body
- Validate frontmatter against schema
- Apply environment-aware filtering (see Post Visibility Rules below)
- Enrich metadata (auto-tag series, calculate reading time)
- Transform markdown to HTML (syntax highlighting, custom directives, heading anchors)
- Generate outputs: route manifest, tag index, series manifest, RSS, sitemap
- Output pre-compiled JSON files for the frontend to consume as static assets
Post Visibility Rules
The build pipeline treats posts differently based on environment:
Development (npm run dev):
- All posts are built and visible — drafts, future-dated, everything
- Draft posts (
draft: true) render with a visible “DRAFT” banner - Future-dated posts (
dateis after today,draft: false) render with a visible “SCHEDULED” banner - This allows the author to preview and proof everything locally
Production (npm run build):
- Posts with
draft: trueare excluded entirely — not built, not in route manifest, not in RSS - Posts with a
datein the future anddraft: falseare excluded entirely — they don’t exist in the build output until their date arrives - Only posts with
draft: false(or no draft field) AND adateon or before the build date are included
Scheduled Publishing:
- A Cloud Build scheduled trigger (via Cloud Scheduler) runs Monday and Thursday at 07:00 UTC
- The trigger invokes the
deploy-blogCloud Build pipeline - Posts whose
datehas now arrived are automatically included in the new build — no manual intervention required - This enables batching: write multiple posts, set future dates, and let the schedule handle publishing
Developer Experience
npm run devwatches content files and rebuilds on change, with Angular dev server running concurrentlynpm run new:postscaffolds a new post with frontmatter template- Build warnings for common mistakes (typos in tag names, missing images, etc.)
- Draft and scheduled banners in dev mode are visually distinct and impossible to miss
7. Migration Priority
In order of importance. Each item includes a “done when” checkpoint — if you can do the described action, that phase is complete.
- Blog engine — markdown pipeline, post rendering, tag pages, navigation, RSS
- Done when: A new
.mdfile incontent/posts/with valid frontmatter produces a routable, styled page afternpm run build. Tags link to tag pages. RSS feed includes the post. Draft/scheduled filtering works in both dev and prod modes.
- Done when: A new
- Visual identity — the design system, background SVGs, satirical chrome, dark/light mode
- Done when: The site is visually indistinguishable from a “real” corporate engineering publication at first glance, with satirical details revealing themselves on closer inspection. Dark/light mode works via system preference. All pages are fully responsive.
- Core pages — about,
portfolio/resume, contact, investorsDone when:/about,/portfolio, and/contactexist, render from markdown or templates, and are linked from the site navigation.- Portfolio/resume deprecated — replaced by
/investors(satirical fundraise page with real Stripe payment links). The about page covers professional background sufficiently. - Done when:
/about,/contact, and/investorsexist, are styled on-brand, and are linked from the site navigation. Contact form writes to Firestore. Investor tiers link to real Stripe payment URLs.
- Admin & authentication — login, contact inbox, ratings dashboard, security hardening
/login— Firebase Auth email/password sign-in (single admin user). Observable-basedauthGuardwaits for auth state to resolve before granting or redirecting. Guard verifiesadmincustom claim viagetIdTokenResult()— not just authentication./adminredirects to/admin/inbox. SharedAdminNavComponentprovides tab navigation (Inbox, Ratings) and sign-out./admin/inbox— Real-time Firestore inbox of contact form submissions. Supports read/unread toggle, soft delete, and mailto reply. Protected byauthGuard./admin/ratings— Real-time ratings dashboard showing per-post aggregates, distribution bars, confidence levels, and sort options. Protected byauthGuard.- Post rating system — Snarky 0–5 star widget on every post. Anonymous auth (lazy, on first interaction). Votes stored in
ratings/{postSlug}/votes/{uid}. Cloud Function (onRatingWritten) maintains aggregate docs via transaction. Display threshold (RATING_DISPLAY_THRESHOLD) controls when stars appear in post metadata. - Contact form submission — Goes through
submitInboxMessagecallable Cloud Function (not direct Firestore write). Function enforces App Check, input validation, sanitization, rate limiting (5/email/24h), and server-side ticket ID generation.onInboxMessageCreatedtrigger sends email notification via Resend.onInboxMessageUpdatedtrigger writes audit trail toinbox_messages/{id}/audit/subcollection. - Admin session security — Idle timeout for admin users (configurable via Remote Config
admin_idle_timeout_ms, default 30min). Auto sign-out on inactivity. Activity tracking runs outside Angular zone. - Error reporting — Sentry captures uncaught errors and manual
captureExceptioncalls in production. Lazy-loaded viacore/sentry.tsto stay out of the initial bundle —main.tscallsinitSentry(dsn)whenenvironment.sentryDsnis set, which dynamically imports@sentry/angular. Components usecaptureException()from@app/core/sentry. - Auth service exposes
user$observable (shared viashareReplay(1)) anduser/isAuthenticatedsignals for template use. - Not linked from site navigation — admin routes are accessed directly.
- Done when: An authenticated admin can manage contacts at
/admin/inboxand view rating analytics at/admin/ratings. Readers can rate posts anonymously. Unauthenticated or non-admin users are redirected to/login. Contact submissions are server-validated. Admin sessions auto-expire on idle.
- Firebase consolidation — move hosting from Azure, unify deployment
- Done when:
firebase deploydeploys the full site (hosting + functions + firestore rules). Azure resources are decommissioned. DNS points to Firebase Hosting.
- Done when:
- Migrate select tools — only tools that get traffic or are still actively used
- Done when: Selected tools are accessible as lazy-loaded routes under
/tools/*. They use PrimeNG where needed, wrapped injjk-*components. Blog bundle size is unaffected.
- Done when: Selected tools are accessible as lazy-loaded routes under
- Everything else — sunset or archive. If nobody misses it, it’s gone.
- Done when: Old repo is archived. No dangling Azure resources. Redirects in place for any URLs that had external links.
8. What This Project Is NOT
- Not a static site generator framework. We’re building a blog, not a product for other people to use.
- Not a multi-library Angular workspace. No
@jjk/core,@jjk/markdown,@jjk/themepackages. Shared code lives in a directory. - Not a Docusaurus clone. We’re borrowing the authoring experience (file-based, markdown-first) but not reimplementing the framework.
- Not a playground for speculative features. Every custom build tool must solve a problem the author has actually hit. If an off-the-shelf solution works, use it.
- Not a tool-first site. The blog is the product. Tools are optional extras.
- Not an excuse for sloppy code. “It’s just a blog” is not a reason to skip validation, hardening, or proper architecture. The feature set is blog-scale. The code quality is not.
9. Guiding Principles
- Enterprise-grade code, blog-scale product. The product is a satirical blog. The engineering is not casual. Code quality, security posture, and operational discipline meet production SaaS standards: strict types, server-side enforcement, proper error handling, audit trails, clean frontend/backend boundaries. The satire is in the content, never in the codebase.
- Content over infrastructure. If you’re spending more time on the engine than writing posts, something is wrong.
- Earn features, not quality. Start with the simplest feature set that works — but implement it to the highest standard. “Earn complexity” means don’t build speculative features. It does not mean skip the hardening pass, cut corners on validation, or leave security for later.
- The visual design is the differentiator. Invest time in the satirical aesthetic, background details, and micro-interactions. That’s what makes this site memorable.
- Dark mode and mobile are not features — they’re fundamentals. Never build a component without both.
- One vendor, one deploy. Firebase for everything. No multi-cloud complexity for a blog.
- Wrap everything in
jjk-*. Every UI component gets a project-specific selector. No raw PrimeNG selectors in templates. No framework classes in markup. - The blog that satirizes over-engineering must not itself be over-engineered. Over-engineering means speculative features, premature abstractions, and infrastructure nobody asked for. It does not mean strict types, input validation, server-side enforcement, or audit trails. Those are just engineering. Know the difference.
Member Content (Planned)
member: truefrontmatter field added to schema. Marks posts as paid-member-only content. Gating implementation pending — auth strategy (Firebase custom claims + Stripe webhook) to be designed separately. Do not implement until instructed.
Note
This document is the source of truth for architectural and product decisions. Implementation details, code patterns, and technical how-tos belong elsewhere.
For Claude Code — decision protocol when this spec is silent on a topic:
- Can it be solved with what’s already in the spec? → Do that.
- Is there a simple, standard Angular convention? → Follow that.
- Is it a cosmetic/minor choice? → Pick the simpler option and move on.
- Is it an architectural choice that could be hard to undo? → Stop and ask the developer.