From 9824e8689ab36ff8e7299b9afcb7cd6321ce48ca Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 15:52:15 -0500 Subject: [PATCH 01/55] chore: initialize Bun + Hono project --- .gitignore | 1 + bunfig.toml | 2 ++ package.json | 23 +++++++++++++++++++++++ tests/setup.ts | 1 + tsconfig.json | 25 +++++++++++++++++++++++++ 5 files changed, 52 insertions(+) create mode 100644 bunfig.toml create mode 100644 package.json create mode 100644 tests/setup.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 06f80fc..cb3af15 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .direnv/ .DS_Store *.log +bun.lock diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..8370a01 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./tests/setup.ts"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..98d149a --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "flexion-labs-website", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run build/entry.ts --watch", + "build": "bun run build/entry.ts", + "test": "bun test", + "refresh:catalog": "bun run catalog/refresh.ts" + }, + "dependencies": { + "hono": "^4.6.0", + "marked": "^14.0.0", + "yaml": "^2.6.0" + }, + "devDependencies": { + "@types/bun": "latest", + "happy-dom": "^15.0.0", + "axe-core": "^4.10.0", + "typescript": "^5.6.0" + } +} diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..22894cb --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1 @@ +// Placeholder. Component tests that need DOM globals import happy-dom locally. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cbc2811 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["bun-types"], + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + }, + "skipLibCheck": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"] +} From 3890d409cbf1fb82ceccc5f47c66c67ec99b19d7 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:03:11 -0500 Subject: [PATCH 02/55] docs(plan): correct @hono/ssg to hono/ssg subpath export --- notes/plans/2026-04-27-flexion-labs-website.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/notes/plans/2026-04-27-flexion-labs-website.md b/notes/plans/2026-04-27-flexion-labs-website.md index d7e1f77..af065ec 100644 --- a/notes/plans/2026-04-27-flexion-labs-website.md +++ b/notes/plans/2026-04-27-flexion-labs-website.md @@ -6,7 +6,7 @@ **Architecture:** Bun + Hono SSG renders every route to static HTML at build time. A committed JSON snapshot (refreshed daily from the GitHub API) plus a hand-authored YAML overrides file plus per-repo markdown overlays feed a single merged catalog. Hand-rolled CSS with cascade layers, container queries, and design tokens sourced from Flexion's brand palette. HTML Web Components wrap rendered HTML to add filter/sort/copy behaviors as progressive enhancement. GitHub Pages hosts production at the domain root and branch previews under `/preview//`, surfaced via GitHub Deployments. -**Tech Stack:** Bun (runtime + package manager + test runner), TypeScript, Hono (with `@hono/ssg`), `hono/jsx` for components, `yaml` for overrides, `marked` for markdown, `happy-dom` for component tests, `@axe-core/playwright` or `axe-core` with `happy-dom` for a11y, GitHub Actions for CI. +**Tech Stack:** Bun (runtime + package manager + test runner), TypeScript, Hono (the SSG helper is a subpath export — `import { toSSG } from 'hono/ssg'` — no extra dependency needed), `hono/jsx` for components, `yaml` for overrides, `marked` for markdown, `happy-dom` for component tests, `axe-core` with `happy-dom` for a11y, GitHub Actions for CI. **Plan location convention:** Per user preferences, this plan is saved to `notes/plans/` rather than `docs/superpowers/plans/`. Ephemeral planning lives in `notes/`; durable behavioral docs live in `docs/`. @@ -151,7 +151,6 @@ Top-level domain layout (Screaming Architecture). Subdirectories reveal intent b }, "dependencies": { "hono": "^4.6.0", - "@hono/ssg": "^0.2.0", "marked": "^14.0.0", "yaml": "^2.6.0" }, From 9fb366f0c5fe01d431ffb6a810a7cb2543135552 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:04:04 -0500 Subject: [PATCH 03/55] docs: rewrite README with Flexion Labs orientation --- README.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b4c82d6..9a5801d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ -# flexion.github.io -flexion organization github pages +# Flexion Labs + +Source for [labs.flexion.us](https://labs.flexion.us/) — a public-facing site that showcases Flexion's open source portfolio, indexes our public repositories, and publishes our open source commitment. + +## Layout + +- `catalog/` — the inventory of our open source work (a generated snapshot plus hand-authored overrides). +- `content/` — the words we publish, as markdown. +- `standards/` — the stewardship rules the health report evaluates. +- `views/` — the pages visitors see, rendered at build time. +- `styles/` — hand-rolled CSS with cascade layers and design tokens. +- `enhancements/` — HTML Web Components that decorate rendered HTML. +- `build/` — the Bun + Hono build driver. +- `docs/` — durable behavioral documentation for contributors and agents. +- `notes/` — ephemeral planning and specs. + +## Getting started + +```bash +bun install +bun run build # writes static site to dist/ +bun test # runs the full test suite +``` + +See `docs/README.md` for the project orientation. From 7890b4bceaa56ef896a28abc65bc25c65867e930 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:10:00 -0500 Subject: [PATCH 04/55] feat(catalog): define catalog entry types --- catalog/types.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 catalog/types.ts diff --git a/catalog/types.ts b/catalog/types.ts new file mode 100644 index 0000000..33bddd0 --- /dev/null +++ b/catalog/types.ts @@ -0,0 +1,48 @@ +export type Tier = 'active' | 'as-is' | 'archived' | 'unreviewed' + +export type Category = + | 'product' + | 'tool' + | 'workshop' + | 'prototype' + | 'fork' + | 'uncategorized' + +export type GithubSnapshotEntry = { + name: string + description: string | null + url: string + homepage: string | null + language: string | null + license: string | null + pushedAt: string // ISO 8601 + archived: boolean + fork: boolean + stars: number + hasReadme: boolean + hasLicense: boolean + hasContributing: boolean +} + +export type OverrideEntry = { + tier?: Tier + category?: Category + featured?: boolean + hidden?: boolean +} + +export type Overlay = { + title?: string + summary?: string + body?: string +} + +export type CatalogEntry = GithubSnapshotEntry & { + tier: Tier + category: Category + featured: boolean + hidden: boolean + overlay: Overlay | null +} + +export type Catalog = ReadonlyArray From 7d9e70627cf6d708ee4411575f90e968d7db5883 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:12:25 -0500 Subject: [PATCH 05/55] feat(catalog): apply tier and category defaults --- catalog/defaults.ts | 45 +++++++++++++++++++++++++ tests/catalog/defaults.test.ts | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 catalog/defaults.ts create mode 100644 tests/catalog/defaults.test.ts diff --git a/catalog/defaults.ts b/catalog/defaults.ts new file mode 100644 index 0000000..33bab15 --- /dev/null +++ b/catalog/defaults.ts @@ -0,0 +1,45 @@ +import type { + Category, + GithubSnapshotEntry, + OverrideEntry, + Tier, +} from './types' + +type Resolved = { + tier: Tier + category: Category + featured: boolean + hidden: boolean +} + +export function applyDefaults( + snapshot: GithubSnapshotEntry, + override: OverrideEntry, +): Resolved { + let tier: Tier | undefined = override.tier + let category: Category | undefined = override.category + + // Apply category defaults first + if (snapshot.fork) { + category ??= 'fork' + } + category ??= 'uncategorized' + + // Apply tier defaults with archived taking precedence + if (!tier) { + if (snapshot.archived) { + tier = 'archived' + } else if (snapshot.fork) { + tier = 'as-is' + } else { + tier = 'unreviewed' + } + } + + return { + tier, + category, + featured: override.featured ?? false, + hidden: override.hidden ?? false, + } +} diff --git a/tests/catalog/defaults.test.ts b/tests/catalog/defaults.test.ts new file mode 100644 index 0000000..beb4dd6 --- /dev/null +++ b/tests/catalog/defaults.test.ts @@ -0,0 +1,61 @@ +import { describe, test, expect } from 'bun:test' +import { applyDefaults } from '../../catalog/defaults' +import type { GithubSnapshotEntry } from '../../catalog/types' + +const base: GithubSnapshotEntry = { + name: 'example', + description: null, + url: 'https://github.com/flexion/example', + homepage: null, + language: null, + license: null, + pushedAt: '2026-01-01T00:00:00Z', + archived: false, + fork: false, + stars: 0, + hasReadme: false, + hasLicense: false, + hasContributing: false, +} + +describe('applyDefaults', () => { + test('forks default to category=fork, tier=as-is', () => { + const result = applyDefaults({ ...base, fork: true }, {}) + expect(result.category).toBe('fork') + expect(result.tier).toBe('as-is') + }) + + test('archived repos default to tier=archived', () => { + const result = applyDefaults({ ...base, archived: true }, {}) + expect(result.tier).toBe('archived') + expect(result.category).toBe('uncategorized') + }) + + test('archived forks keep category=fork and tier=archived', () => { + const result = applyDefaults({ ...base, fork: true, archived: true }, {}) + expect(result.category).toBe('fork') + expect(result.tier).toBe('archived') + }) + + test('plain repos default to unreviewed + uncategorized', () => { + const result = applyDefaults(base, {}) + expect(result.tier).toBe('unreviewed') + expect(result.category).toBe('uncategorized') + }) + + test('override fields take precedence over defaults', () => { + const result = applyDefaults( + { ...base, fork: true, archived: true }, + { tier: 'active', category: 'product', featured: true }, + ) + expect(result.tier).toBe('active') + expect(result.category).toBe('product') + expect(result.featured).toBe(true) + }) + + test('featured and hidden default to false', () => { + const result = applyDefaults(base, {}) + expect(result.featured).toBe(false) + expect(result.hidden).toBe(false) + }) +}) From e694ce27f3842c6d3b70cb65ea74e6c563086907 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:14:51 -0500 Subject: [PATCH 06/55] docs(spec): clarify archived-over-fork precedence for tier defaults --- notes/specs/2026-04-27-flexion-labs-website-design.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/notes/specs/2026-04-27-flexion-labs-website-design.md b/notes/specs/2026-04-27-flexion-labs-website-design.md index bdce5f4..1a46bc0 100644 --- a/notes/specs/2026-04-27-flexion-labs-website-design.md +++ b/notes/specs/2026-04-27-flexion-labs-website-design.md @@ -150,11 +150,12 @@ type CatalogEntry = { ### 3.3 Defaults -When `overrides.yml` has no entry for a repo, defaults are applied per field. Rules fire in order; a field set by an earlier rule is not overwritten: +When `overrides.yml` has no entry for a repo, defaults are derived per field with `archived` taking precedence over `fork` for tier: -1. `fork: true` → `category: 'fork'`, `tier: 'as-is'` -2. `archived: true` (on GitHub) → `tier: 'archived'` (category may already be set from rule 1; if not, it stays `'uncategorized'`) -3. No field set yet → `tier: 'unreviewed'`, `category: 'uncategorized'` +- **Category**: `fork: true` → `'fork'`; otherwise `'uncategorized'`. +- **Tier**: `archived: true` → `'archived'`; else `fork: true` → `'as-is'`; else `'unreviewed'`. + +Overrides always win over either rule. The "unreviewed" tier is publicly visible and honest — it says "a human has not yet classified this repo." From 3b266a8eaba655e0adf6806277d5bfb93836b865 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:17:38 -0500 Subject: [PATCH 07/55] feat(catalog): load markdown overlays with front-matter --- catalog/overlays.ts | 27 +++++++++++++++++++++++++++ tests/catalog/overlays.test.ts | 17 +++++++++++++++++ tests/fixtures/overlays/messaging.md | 6 ++++++ 3 files changed, 50 insertions(+) create mode 100644 catalog/overlays.ts create mode 100644 tests/catalog/overlays.test.ts create mode 100644 tests/fixtures/overlays/messaging.md diff --git a/catalog/overlays.ts b/catalog/overlays.ts new file mode 100644 index 0000000..49299f2 --- /dev/null +++ b/catalog/overlays.ts @@ -0,0 +1,27 @@ +import { parse as parseYaml } from 'yaml' +import type { Overlay } from './types' + +const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/ + +export async function loadOverlay(path: string): Promise { + const file = Bun.file(path) + if (!(await file.exists())) return null + const raw = await file.text() + + const match = raw.match(FRONTMATTER_RE) + if (!match) { + return { body: raw.trim() || undefined } + } + const frontMatter = (parseYaml(match[1]) ?? {}) as Record + const body = match[2].trim() + + return { + title: stringOrUndefined(frontMatter.title), + summary: stringOrUndefined(frontMatter.summary), + body: body || undefined, + } +} + +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} diff --git a/tests/catalog/overlays.test.ts b/tests/catalog/overlays.test.ts new file mode 100644 index 0000000..231386a --- /dev/null +++ b/tests/catalog/overlays.test.ts @@ -0,0 +1,17 @@ +import { describe, test, expect } from 'bun:test' +import { loadOverlay } from '../../catalog/overlays' + +describe('loadOverlay', () => { + test('parses front-matter and body from a markdown file', async () => { + const overlay = await loadOverlay('tests/fixtures/overlays/messaging.md') + expect(overlay).not.toBeNull() + expect(overlay!.title).toBe('Messaging') + expect(overlay!.summary).toBe('Text-based communication for critical updates.') + expect(overlay!.body).toContain('Messaging is a platform') + }) + + test('returns null when file does not exist', async () => { + const overlay = await loadOverlay('tests/fixtures/overlays/does-not-exist.md') + expect(overlay).toBeNull() + }) +}) diff --git a/tests/fixtures/overlays/messaging.md b/tests/fixtures/overlays/messaging.md new file mode 100644 index 0000000..d3d23a0 --- /dev/null +++ b/tests/fixtures/overlays/messaging.md @@ -0,0 +1,6 @@ +--- +title: Messaging +summary: Text-based communication for critical updates. +--- + +Messaging is a platform for sending notifications to the public. From 52f6b08c5779531aeaa7fc935a72cf6a44f9fe87 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:18:41 -0500 Subject: [PATCH 08/55] feat(catalog): merge snapshot with overrides and overlays --- catalog/merge.ts | 23 +++++++++++++++ tests/catalog/merge.test.ts | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 catalog/merge.ts create mode 100644 tests/catalog/merge.test.ts diff --git a/catalog/merge.ts b/catalog/merge.ts new file mode 100644 index 0000000..659b956 --- /dev/null +++ b/catalog/merge.ts @@ -0,0 +1,23 @@ +import { applyDefaults } from './defaults' +import type { + CatalogEntry, + GithubSnapshotEntry, + OverrideEntry, + Overlay, +} from './types' + +export function mergeCatalog( + snapshot: ReadonlyArray, + overrides: Record, + overlays: ReadonlyMap, +): CatalogEntry[] { + return snapshot.map((entry) => { + const override = overrides[entry.name] ?? {} + const resolved = applyDefaults(entry, override) + return { + ...entry, + ...resolved, + overlay: overlays.get(entry.name) ?? null, + } + }) +} diff --git a/tests/catalog/merge.test.ts b/tests/catalog/merge.test.ts new file mode 100644 index 0000000..ec88f78 --- /dev/null +++ b/tests/catalog/merge.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from 'bun:test' +import { mergeCatalog } from '../../catalog/merge' +import type { GithubSnapshotEntry, OverrideEntry, Overlay } from '../../catalog/types' + +const snapshot: GithubSnapshotEntry = { + name: 'messaging', + description: 'GOV.UK Notify-style messaging.', + url: 'https://github.com/flexion/messaging', + homepage: null, + language: 'TypeScript', + license: 'Apache-2.0', + pushedAt: '2026-04-20T00:00:00Z', + archived: false, + fork: false, + stars: 3, + hasReadme: true, + hasLicense: true, + hasContributing: true, +} + +describe('mergeCatalog', () => { + test('applies defaults when no override exists', () => { + const catalog = mergeCatalog([snapshot], {}, new Map()) + expect(catalog[0].tier).toBe('unreviewed') + expect(catalog[0].category).toBe('uncategorized') + }) + + test('applies overrides by repo name', () => { + const overrides: Record = { + messaging: { tier: 'active', category: 'product', featured: true }, + } + const catalog = mergeCatalog([snapshot], overrides, new Map()) + expect(catalog[0].tier).toBe('active') + expect(catalog[0].featured).toBe(true) + }) + + test('attaches overlay keyed by repo name', () => { + const overlays = new Map([ + ['messaging', { title: 'Messaging', body: '…' }], + ]) + const catalog = mergeCatalog([snapshot], {}, overlays) + expect(catalog[0].overlay).not.toBeNull() + expect(catalog[0].overlay!.title).toBe('Messaging') + }) + + test('entries without overlays get overlay=null', () => { + const catalog = mergeCatalog([snapshot], {}, new Map()) + expect(catalog[0].overlay).toBeNull() + }) + + test('preserves order of the snapshot input', () => { + const a = { ...snapshot, name: 'a' } + const b = { ...snapshot, name: 'b' } + const c = { ...snapshot, name: 'c' } + const catalog = mergeCatalog([c, a, b], {}, new Map()) + expect(catalog.map((e) => e.name)).toEqual(['c', 'a', 'b']) + }) +}) From c270c08dc103891c46059f603a648b6b7125bda5 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:21:50 -0500 Subject: [PATCH 09/55] feat(catalog): load snapshot, overrides, and overlays from disk --- catalog/load.ts | 44 ++++++++++++++++++++++++++++++++ catalog/overrides.yml | 2 ++ catalog/repos.json | 1 + tests/catalog/load.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 catalog/load.ts create mode 100644 catalog/overrides.yml create mode 100644 catalog/repos.json create mode 100644 tests/catalog/load.test.ts diff --git a/catalog/load.ts b/catalog/load.ts new file mode 100644 index 0000000..49ccf28 --- /dev/null +++ b/catalog/load.ts @@ -0,0 +1,44 @@ +import { parse as parseYaml } from 'yaml' +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import { mergeCatalog } from './merge' +import { loadOverlay } from './overlays' +import type { Catalog, GithubSnapshotEntry, OverrideEntry, Overlay } from './types' + +export async function loadCatalog(rootDir: string): Promise { + const snapshot = await readSnapshot(join(rootDir, 'catalog', 'repos.json')) + const overrides = await readOverrides(join(rootDir, 'catalog', 'overrides.yml')) + const overlays = await readOverlays(join(rootDir, 'content', 'work')) + return mergeCatalog(snapshot, overrides, overlays) +} + +async function readSnapshot(path: string): Promise { + const file = Bun.file(path) + if (!(await file.exists())) return [] + return (await file.json()) as GithubSnapshotEntry[] +} + +async function readOverrides( + path: string, +): Promise> { + const file = Bun.file(path) + if (!(await file.exists())) return {} + const parsed = parseYaml(await file.text()) + return (parsed ?? {}) as Record +} + +async function readOverlays(dir: string): Promise> { + const overlays = new Map() + let files: string[] = [] + try { + files = readdirSync(dir).filter((f) => f.endsWith('.md')) + } catch { + return overlays + } + for (const file of files) { + const slug = file.replace(/\.md$/, '') + const overlay = await loadOverlay(join(dir, file)) + if (overlay) overlays.set(slug, overlay) + } + return overlays +} diff --git a/catalog/overrides.yml b/catalog/overrides.yml new file mode 100644 index 0000000..72d7250 --- /dev/null +++ b/catalog/overrides.yml @@ -0,0 +1,2 @@ +# Hand-authored overrides keyed by repo name. +# See docs/catalog.md for supported fields. diff --git a/catalog/repos.json b/catalog/repos.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/catalog/repos.json @@ -0,0 +1 @@ +[] diff --git a/tests/catalog/load.test.ts b/tests/catalog/load.test.ts new file mode 100644 index 0000000..e1dacad --- /dev/null +++ b/tests/catalog/load.test.ts @@ -0,0 +1,52 @@ +import { describe, test, expect } from 'bun:test' +import { loadCatalog } from '../../catalog/load' +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +function seed() { + const dir = mkdtempSync(join(tmpdir(), 'flexion-labs-')) + mkdirSync(join(dir, 'catalog'), { recursive: true }) + mkdirSync(join(dir, 'content', 'work'), { recursive: true }) + writeFileSync( + join(dir, 'catalog', 'repos.json'), + JSON.stringify([ + { + name: 'messaging', + description: null, + url: 'https://github.com/flexion/messaging', + homepage: null, + language: 'TypeScript', + license: 'Apache-2.0', + pushedAt: '2026-04-20T00:00:00Z', + archived: false, + fork: false, + stars: 0, + hasReadme: true, + hasLicense: true, + hasContributing: false, + }, + ]), + ) + writeFileSync( + join(dir, 'catalog', 'overrides.yml'), + 'messaging:\n tier: active\n category: product\n featured: true\n', + ) + writeFileSync( + join(dir, 'content', 'work', 'messaging.md'), + '---\ntitle: Messaging\n---\n\nBody copy.\n', + ) + return dir +} + +describe('loadCatalog', () => { + test('combines repos.json + overrides.yml + content/work overlays', async () => { + const root = seed() + const catalog = await loadCatalog(root) + expect(catalog.length).toBe(1) + expect(catalog[0].name).toBe('messaging') + expect(catalog[0].tier).toBe('active') + expect(catalog[0].featured).toBe(true) + expect(catalog[0].overlay?.title).toBe('Messaging') + }) +}) From d4d0df6b069fd33b0e8a9fbe27d60bf90682ab00 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:23:07 -0500 Subject: [PATCH 10/55] docs(catalog): explain what the catalog is and how it's refreshed --- catalog/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 catalog/README.md diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 0000000..18eaefb --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,16 @@ +# Catalog + +The catalog is the inventory of Flexion's open source work. It drives every view on `labs.flexion.us`. + +## Files + +- `repos.json` — a machine-generated snapshot from the GitHub API. Rewritten daily by the `refresh-catalog` workflow. +- `overrides.yml` — hand-authored metadata keyed by repo name. Each entry may set `tier`, `category`, `featured`, and `hidden`. + +## Fields + +See `types.ts` for the canonical type. The fields that humans set are described in `docs/catalog.md`. + +## Refresh cadence + +The `refresh-catalog` workflow runs daily at 09:00 UTC. If the resulting snapshot differs from the committed one, it opens a PR. If CI is green, the PR auto-merges. From 30bc29de051d3381ef449a3b61a30ace0147f3a3 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:24:37 -0500 Subject: [PATCH 11/55] feat(standards): evaluate repos against stewardship standards --- standards/README.md | 15 +++++++ standards/maintenance-tiers.md | 6 +++ standards/repo-checks.ts | 42 +++++++++++++++++ tests/standards/repo-checks.test.ts | 70 +++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 standards/README.md create mode 100644 standards/maintenance-tiers.md create mode 100644 standards/repo-checks.ts create mode 100644 tests/standards/repo-checks.test.ts diff --git a/standards/README.md b/standards/README.md new file mode 100644 index 0000000..ef64c69 --- /dev/null +++ b/standards/README.md @@ -0,0 +1,15 @@ +# Standards + +This directory encodes Flexion's stewardship standards. Every public repo is evaluated against the checks defined here; the results drive `/work/health/`. + +## Checks + +- **README** — repo has a README.md at the root. +- **License** — repo has a detectable license (GitHub's license field OR a LICENSE file). +- **Contributing** — repo has a CONTRIBUTING.md at the root. +- **Activity** — most recent push is within 6 months (pass), 6–18 months (warn), or older (fail). Archived repos pass by policy — they're not expected to receive updates. +- **Tier assigned** — a human has classified the repo with an explicit tier in `catalog/overrides.yml`. Unclassified repos fail this check. + +## Hiding per-repo failures + +`SHOW_PER_REPO_FAILURES` in `repo-checks.ts` controls whether `/work/health/` shows specific repo names next to failures. Set it to `false` before launch if leadership prefers aggregate reporting. diff --git a/standards/maintenance-tiers.md b/standards/maintenance-tiers.md new file mode 100644 index 0000000..36dae26 --- /dev/null +++ b/standards/maintenance-tiers.md @@ -0,0 +1,6 @@ +# Maintenance tiers + +- **active** — Flexion commits to security patch management and defined response times. Bug reports are triaged. Pull requests are reviewed on a predictable cadence. +- **as-is** — Available without active maintenance. The code works (or worked at one point); future updates are not promised. Forks and prototypes live here by default. +- **archived** — No longer maintained. GitHub's archive flag is set. The repo is read-only. Listed for transparency. +- **unreviewed** — A human has not yet classified this repo. Defaults to this state; visible on the site so gaps are honest. diff --git a/standards/repo-checks.ts b/standards/repo-checks.ts new file mode 100644 index 0000000..85e0f69 --- /dev/null +++ b/standards/repo-checks.ts @@ -0,0 +1,42 @@ +import type { CatalogEntry } from '../catalog/types' + +export type CheckResult = 'pass' | 'warn' | 'fail' + +export type RepoEvaluation = { + readme: CheckResult + license: CheckResult + contributing: CheckResult + activity: CheckResult + tierAssigned: CheckResult + overallPass: boolean +} + +const SIX_MONTHS_MS = 1000 * 60 * 60 * 24 * 30 * 6 +const EIGHTEEN_MONTHS_MS = SIX_MONTHS_MS * 3 + +export const SHOW_PER_REPO_FAILURES = true + +export function evaluateRepo(entry: CatalogEntry, now: Date): RepoEvaluation { + const readme = entry.hasReadme ? 'pass' : 'fail' + const license = entry.hasLicense ? 'pass' : 'fail' + const contributing = entry.hasContributing ? 'pass' : 'fail' + const tierAssigned = entry.tier === 'unreviewed' ? 'fail' : 'pass' + const activity = evaluateActivity(entry, now) + + const overallPass = + readme === 'pass' && + license === 'pass' && + contributing === 'pass' && + tierAssigned === 'pass' && + activity !== 'fail' + + return { readme, license, contributing, activity, tierAssigned, overallPass } +} + +function evaluateActivity(entry: CatalogEntry, now: Date): CheckResult { + if (entry.tier === 'archived') return 'pass' + const age = now.getTime() - new Date(entry.pushedAt).getTime() + if (age < SIX_MONTHS_MS) return 'pass' + if (age < EIGHTEEN_MONTHS_MS) return 'warn' + return 'fail' +} diff --git a/tests/standards/repo-checks.test.ts b/tests/standards/repo-checks.test.ts new file mode 100644 index 0000000..9e8ed7d --- /dev/null +++ b/tests/standards/repo-checks.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect } from 'bun:test' +import { evaluateRepo } from '../../standards/repo-checks' +import type { CatalogEntry } from '../../catalog/types' + +const NOW = new Date('2026-04-27T00:00:00Z') + +const base: CatalogEntry = { + name: 'example', + description: null, + url: 'https://github.com/flexion/example', + homepage: null, + language: null, + license: 'Apache-2.0', + pushedAt: '2026-04-01T00:00:00Z', + archived: false, + fork: false, + stars: 0, + hasReadme: true, + hasLicense: true, + hasContributing: true, + tier: 'active', + category: 'product', + featured: false, + hidden: false, + overlay: null, +} + +describe('evaluateRepo', () => { + test('a fully compliant repo passes every check', () => { + const result = evaluateRepo(base, NOW) + expect(result.readme).toBe('pass') + expect(result.license).toBe('pass') + expect(result.contributing).toBe('pass') + expect(result.activity).toBe('pass') + expect(result.tierAssigned).toBe('pass') + expect(result.overallPass).toBe(true) + }) + + test('missing README fails', () => { + const result = evaluateRepo({ ...base, hasReadme: false }, NOW) + expect(result.readme).toBe('fail') + expect(result.overallPass).toBe(false) + }) + + test('activity between 6 and 18 months ago warns', () => { + const pushed = new Date('2025-07-01T00:00:00Z').toISOString() // ~10 months ago + const result = evaluateRepo({ ...base, pushedAt: pushed }, NOW) + expect(result.activity).toBe('warn') + }) + + test('activity older than 18 months fails', () => { + const pushed = new Date('2024-08-01T00:00:00Z').toISOString() // ~20 months ago + const result = evaluateRepo({ ...base, pushedAt: pushed }, NOW) + expect(result.activity).toBe('fail') + }) + + test('unreviewed tier counts as tier-not-assigned', () => { + const result = evaluateRepo({ ...base, tier: 'unreviewed' }, NOW) + expect(result.tierAssigned).toBe('fail') + }) + + test('archived repos skip the activity check (pass by policy)', () => { + const pushed = new Date('2020-01-01T00:00:00Z').toISOString() + const result = evaluateRepo( + { ...base, tier: 'archived', archived: true, pushedAt: pushed }, + NOW, + ) + expect(result.activity).toBe('pass') + }) +}) From 7123bf16fbbd11479c7e848bc2475d89337d807e Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:26:30 -0500 Subject: [PATCH 12/55] test: shared fixture catalog for view tests --- tests/fixtures/catalog.ts | 89 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/fixtures/catalog.ts diff --git a/tests/fixtures/catalog.ts b/tests/fixtures/catalog.ts new file mode 100644 index 0000000..ac2c8ca --- /dev/null +++ b/tests/fixtures/catalog.ts @@ -0,0 +1,89 @@ +import type { Catalog, CatalogEntry } from '../../catalog/types' + +function entry(overrides: Partial): CatalogEntry { + return { + name: overrides.name ?? 'example', + description: overrides.description ?? null, + url: overrides.url ?? 'https://github.com/flexion/example', + homepage: overrides.homepage ?? null, + language: overrides.language ?? null, + license: overrides.license ?? null, + pushedAt: overrides.pushedAt ?? '2026-04-20T00:00:00Z', + archived: overrides.archived ?? false, + fork: overrides.fork ?? false, + stars: overrides.stars ?? 0, + hasReadme: overrides.hasReadme ?? true, + hasLicense: overrides.hasLicense ?? true, + hasContributing: overrides.hasContributing ?? true, + tier: overrides.tier ?? 'unreviewed', + category: overrides.category ?? 'uncategorized', + featured: overrides.featured ?? false, + hidden: overrides.hidden ?? false, + overlay: overrides.overlay ?? null, + } +} + +export const fixtureCatalog: Catalog = [ + entry({ + name: 'messaging', + description: 'Messaging — text-based notifications for critical updates.', + language: 'TypeScript', + license: 'Apache-2.0', + tier: 'active', + category: 'product', + featured: true, + overlay: { + title: 'Messaging', + summary: 'Text-based communication for critical updates.', + body: '

Messaging body copy.

', + }, + }), + entry({ + name: 'forms', + description: 'Accessible form experiences for public agencies.', + language: 'TypeScript', + license: 'Apache-2.0', + tier: 'active', + category: 'product', + featured: true, + }), + entry({ + name: 'document-extractor', + description: 'Extract structured data from PDFs and images.', + language: 'Python', + license: 'Apache-2.0', + tier: 'active', + category: 'product', + featured: true, + }), + entry({ + name: 'old-prototype', + description: 'An old experiment.', + pushedAt: '2022-01-01T00:00:00Z', + tier: 'as-is', + category: 'prototype', + }), + entry({ + name: 'fork-of-thing', + description: 'A fork we picked up.', + fork: true, + tier: 'as-is', + category: 'fork', + }), + entry({ + name: 'archived-thing', + description: 'An archived repo.', + archived: true, + hasLicense: false, + hasContributing: false, + tier: 'archived', + category: 'tool', + }), + entry({ + name: 'unreviewed-thing', + description: null, + hasReadme: false, + }), +] + +export const fixtureNow = new Date('2026-04-27T00:00:00Z') From 06cbf00ae85590b7bf62a001c2f2639ed324675e Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:26:59 -0500 Subject: [PATCH 13/55] feat(build): add renderToHtml helper --- build/render.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 build/render.ts diff --git a/build/render.ts b/build/render.ts new file mode 100644 index 0000000..8dd1106 --- /dev/null +++ b/build/render.ts @@ -0,0 +1,8 @@ +import type { HtmlEscapedString } from 'hono/utils/html' + +export async function renderToHtml( + element: HtmlEscapedString | Promise, +): Promise { + const resolved = await Promise.resolve(element) + return '\n' + resolved.toString() +} From 37837fd18f1529ae0e8152529dbb77a622281456 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:28:27 -0500 Subject: [PATCH 14/55] feat(build): resolve base path and build-time URL helpers --- build/config.ts | 22 ++++++++++++++++++++++ tests/build/config.test.ts | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 build/config.ts create mode 100644 tests/build/config.test.ts diff --git a/build/config.ts b/build/config.ts new file mode 100644 index 0000000..04ef140 --- /dev/null +++ b/build/config.ts @@ -0,0 +1,22 @@ +export function getBasePath(raw: string | undefined): string { + if (!raw || raw === '' || raw === '/') return '/' + const trimmed = raw.replace(/^\/+/, '').replace(/\/+$/, '') + return `/${trimmed}/` +} + +export function url(path: string, basePath: string): string { + const normalised = path.startsWith('/') ? path.slice(1) : path + return basePath + normalised +} + +export type SiteConfig = { + basePath: string + buildTime: string +} + +export function createConfig(env: NodeJS.ProcessEnv = process.env): SiteConfig { + return { + basePath: getBasePath(env.SITE_BASE_URL), + buildTime: new Date().toISOString(), + } +} diff --git a/tests/build/config.test.ts b/tests/build/config.test.ts new file mode 100644 index 0000000..6a58cf4 --- /dev/null +++ b/tests/build/config.test.ts @@ -0,0 +1,19 @@ +import { describe, test, expect } from 'bun:test' +import { getBasePath, url } from '../../build/config' + +describe('build config', () => { + test('base path defaults to /', () => { + expect(getBasePath(undefined)).toBe('/') + }) + + test('base path respects SITE_BASE_URL with leading and trailing slashes', () => { + expect(getBasePath('/preview/feat-x/')).toBe('/preview/feat-x/') + expect(getBasePath('preview/feat-x')).toBe('/preview/feat-x/') + }) + + test('url() prefixes the base path and preserves leading slashes', () => { + expect(url('/work/', '/preview/x/')).toBe('/preview/x/work/') + expect(url('/', '/preview/x/')).toBe('/preview/x/') + expect(url('/work/messaging/', '/')).toBe('/work/messaging/') + }) +}) From dd95376fead660158141b1ff002134c8f17e90ea Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Mon, 27 Apr 2026 16:31:55 -0500 Subject: [PATCH 15/55] feat(views): layout shell with header, footer, and skip link --- tests/views/layout.test.tsx | 49 +++++++++++++++++++++++++++++++++++++ views/components/footer.tsx | 23 +++++++++++++++++ views/components/header.tsx | 30 +++++++++++++++++++++++ views/layout.tsx | 35 ++++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 tests/views/layout.test.tsx create mode 100644 views/components/footer.tsx create mode 100644 views/components/header.tsx create mode 100644 views/layout.tsx diff --git a/tests/views/layout.test.tsx b/tests/views/layout.test.tsx new file mode 100644 index 0000000..4367779 --- /dev/null +++ b/tests/views/layout.test.tsx @@ -0,0 +1,49 @@ +import { describe, test, expect } from 'bun:test' +import { Layout } from '../../views/layout' +import { renderToHtml } from '../../build/render' + +const config = { basePath: '/', buildTime: '2026-04-27T12:00:00Z' } + +describe('Layout', () => { + test('renders a full HTML document with landmarks', async () => { + const html = await renderToHtml( + +

Body

+
, + ) + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain(' to " — Flexion Labs"', async () => { + const html = await renderToHtml( + +

+ , + ) + expect(html).toMatch(/About — Flexion Labs<\/title>/) + }) + + test('home page uses the bare site title', async () => { + const html = await renderToHtml( + <Layout title={null} config={config}> + <p /> + </Layout>, + ) + expect(html).toMatch(/<title>Flexion Labs<\/title>/) + }) + + test('prefixes asset URLs with basePath', async () => { + const html = await renderToHtml( + <Layout title={null} config={{ basePath: '/preview/x/', buildTime: '2026-04-27T12:00:00Z' }}> + <p /> + </Layout>, + ) + expect(html).toContain('href="/preview/x/styles/index.css"') + }) +}) diff --git a/views/components/footer.tsx b/views/components/footer.tsx new file mode 100644 index 0000000..488470b --- /dev/null +++ b/views/components/footer.tsx @@ -0,0 +1,23 @@ +import { url } from '../../build/config' +import type { SiteConfig } from '../../build/config' + +export function Footer({ config }: { config: SiteConfig }) { + return ( + <footer class="site-footer"> + <nav aria-label="Footer"> + <ul> + <li><a href={url('/work/', config.basePath)}>Work</a></li> + <li><a href={url('/commitment/', config.basePath)}>Commitment</a></li> + <li><a href={url('/about/', config.basePath)}>About</a></li> + </ul> + </nav> + <p class="site-footer__meta"> + Built {formatBuildTime(config.buildTime)}. Content licensed as noted per project. + </p> + </footer> + ) +} + +function formatBuildTime(iso: string): string { + return new Date(iso).toISOString().slice(0, 10) +} diff --git a/views/components/header.tsx b/views/components/header.tsx new file mode 100644 index 0000000..48d5d13 --- /dev/null +++ b/views/components/header.tsx @@ -0,0 +1,30 @@ +import { url } from '../../build/config' +import type { SiteConfig } from '../../build/config' + +export function Header({ config }: { config: SiteConfig }) { + return ( + <header class="site-header"> + <a href={url('/', config.basePath)} class="site-brand"> + Flexion Labs + </a> + <nav aria-label="Primary"> + <ul> + <li> + <a href={url('/work/', config.basePath)}>Work</a> + </li> + <li> + <a href={url('/commitment/', config.basePath)}>Commitment</a> + </li> + <li> + <a href={url('/about/', config.basePath)}>About</a> + </li> + <li> + <a href="https://github.com/flexion" rel="noopener external"> + GitHub + </a> + </li> + </ul> + </nav> + </header> + ) +} diff --git a/views/layout.tsx b/views/layout.tsx new file mode 100644 index 0000000..b4cc1bd --- /dev/null +++ b/views/layout.tsx @@ -0,0 +1,35 @@ +import type { Child } from 'hono/jsx' +import { Header } from './components/header' +import { Footer } from './components/footer' +import { url } from '../build/config' +import type { SiteConfig } from '../build/config' + +export function Layout({ + title, + config, + children, +}: { + title: string | null + config: SiteConfig + children: Child +}) { + const documentTitle = title ? `${title} — Flexion Labs` : 'Flexion Labs' + return ( + <html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{documentTitle} + + + + + + +

+
{children}
+