diff --git a/.env.example b/.env.example index 08cdf24..9c3ed74 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # Production or staging environment template. # Set these in your hosting provider's environment-variable UI instead of committing a real .env file. +# Prefer provider-managed secrets or mounted files. Any private key below can also be supplied as `${NAME}_FILE=/run/secrets/${NAME}`. + +KB_ENVIRONMENT="production" # Optional: Mapbox access token for event location/address geocoding (server). # Get a token at https://account.mapbox.com/access-tokens/ @@ -27,6 +30,7 @@ SMTP_PASS="" SMTP_FROM="\"Knowledge Basket\" " # Sentry (errors + performance) +# Use separate DSNs or projects for staging so non-production traffic stays isolated. PUBLIC_SENTRY_DSN="https://public-key@o0.ingest.us.sentry.io/0" SENTRY_DSN="https://public-key@o0.ingest.us.sentry.io/0" PUBLIC_SENTRY_TRACES_SAMPLE_RATE="0.1" @@ -39,16 +43,17 @@ PUBLIC_SENTRY_FEEDBACK_AUTO_INJECT="false" PUBLIC_SENTRY_ENABLE_DEV="false" PUBLIC_SENTRY_ENVIRONMENT="production" PUBLIC_SENTRY_RELEASE="" +SENTRY_ENVIRONMENT="production" +SENTRY_RELEASE="" SENTRY_ENABLE_LOGS="true" # Optional: enable source map uploads during build. # SENTRY_AUTH_TOKEN="" # SENTRY_ORG="" # SENTRY_PROJECT="" -# SENTRY_RELEASE="" -# SENTRY_ENVIRONMENT="production" # PostHog (product analytics + session replay; explicit events only, after analytics consent) +# Use a separate staging project or source so staging traffic does not pollute production funnels. PUBLIC_POSTHOG_KEY="phc_your_project_api_key" PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" diff --git a/.env.local.example b/.env.local.example index e897d43..02bb773 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,5 +1,8 @@ # Local development template. # Copy this file to `.env` for everyday local development. +# Mounted secret files also work locally. Example: `BETTER_AUTH_SECRET_FILE=.secrets/better-auth-secret`. + +KB_ENVIRONMENT="development" # Optional: Mapbox access token for event location/address geocoding (server). # Get a token at https://account.mapbox.com/access-tokens/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a7619ae --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @haydenkoch diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e19f00d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..28510ea --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,37 @@ +## Summary + +- + +## Linear Issue + +- Closes: + +## Risk Level + +- [ ] Low +- [ ] Medium +- [ ] High + +## Migration Impact + +- [ ] No schema or data migration +- [ ] Requires committed migration files + +## Rollback Plan + +- + +## Testing + +- [ ] `pnpm lint` +- [ ] `pnpm check` +- [ ] Other: + +## Staging Verification + +- [ ] Not required +- [ ] `GET /api/health` verified +- [ ] Auth smoke-tested +- [ ] Search smoke-tested +- [ ] Uploads/assets smoke-tested +- [ ] Admin/source-ops smoke-tested diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d506751..40b379d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: SMTP_HOST: 127.0.0.1 SMTP_PORT: 1025 SMTP_FROM: '"Knowledge Basket" ' + PUBLIC_ASSET_BASE_URL: http://127.0.0.1:4273/assets MEILISEARCH_HOST: http://127.0.0.1:7700 MEILISEARCH_API_KEY: kbdev REINDEX_SECRET: ci-reindex-secret-1234567890123456 @@ -58,15 +59,13 @@ jobs: run: pnpm build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: fetch-depth: 0 - - uses: pnpm/action-setup@v4 - with: - version: 10 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 cache: pnpm @@ -74,12 +73,16 @@ jobs: - name: Install run: pnpm install --frozen-lockfile + - name: Verify committed migrations + if: matrix.task == 'typecheck' + run: node scripts/check-migrations-ci.mjs + - name: Run ${{ matrix.task }} run: ${{ matrix.run }} - name: Upload build artifact if: matrix.task == 'build' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: app-build path: build @@ -107,13 +110,11 @@ jobs: shard: [1, 2, 3] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: pnpm/action-setup@v4 - with: - version: 10 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 cache: pnpm @@ -164,13 +165,11 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: pnpm/action-setup@v4 - with: - version: 10 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 cache: pnpm @@ -179,7 +178,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Download build artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: app-build path: build @@ -208,8 +207,8 @@ jobs: throw new Error('Timed out waiting for Postgres'); EOF - - name: Apply database schema - run: pnpm db:deploy + - name: Bootstrap ephemeral database schema + run: pnpm db:push --force - name: Seed database run: pnpm db:seed:ci @@ -264,13 +263,11 @@ jobs: --health-retries 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: pnpm/action-setup@v4 - with: - version: 10 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 cache: pnpm @@ -279,7 +276,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Download build artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: app-build path: build @@ -320,8 +317,8 @@ jobs: } EOF - - name: Apply database schema - run: pnpm db:deploy + - name: Bootstrap ephemeral database schema + run: pnpm db:push --force - name: Seed database run: pnpm db:seed:ci @@ -362,13 +359,11 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: pnpm/action-setup@v4 - with: - version: 10 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 cache: pnpm @@ -377,7 +372,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Download build artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: app-build path: build @@ -406,8 +401,8 @@ jobs: throw new Error('Timed out waiting for Postgres'); EOF - - name: Apply database schema - run: pnpm db:deploy + - name: Bootstrap ephemeral database schema + run: pnpm db:push --force - name: Seed database run: pnpm db:seed:ci @@ -455,13 +450,11 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: pnpm/action-setup@v4 - with: - version: 10 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 24 cache: pnpm @@ -470,13 +463,13 @@ jobs: run: pnpm install --frozen-lockfile - name: Download build artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: app-build path: build - name: Restore Playwright browser cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} @@ -508,8 +501,8 @@ jobs: throw new Error('Timed out waiting for Postgres'); EOF - - name: Apply database schema - run: pnpm db:deploy + - name: Bootstrap ephemeral database schema + run: pnpm db:push --force - name: Seed database run: pnpm db:seed:ci @@ -519,7 +512,7 @@ jobs: - name: Upload Playwright artifacts if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: playwright-artifacts-${{ matrix.project }} path: | diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..7ac5295 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,33 @@ +name: PR Title + +on: + pull_request_target: + types: + - opened + - edited + - reopened + - synchronize + +permissions: {} + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + steps: + - name: Validate Conventional Commit PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + node <<'EOF' + const title = process.env.PR_TITLE ?? ''; + const pattern = + /^(build|chore|ci|docs|feat|fix|perf|refactor|release|revert|security|style|test)(\([^)]+\))?!?: .+/; + + if (!pattern.test(title)) { + console.error(`Invalid PR title: "${title}"`); + console.error( + 'Use Conventional Commit style, for example: fix(search): handle empty queries' + ); + process.exit(1); + } + EOF diff --git a/.prettierignore b/.prettierignore index 4926fc5..276db21 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,7 @@ bun.lockb /drizzle/ /.claude/ /test-results/ +/AGENTS.md +/CLAUDE.md +/docs/plans/** +/docs/reviews/** diff --git a/AGENTS.md b/AGENTS.md index 886bcd7..05ec95c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Generates a Svelte Playground link with the provided code. After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. + # GitNexus — Code Intelligence This project is indexed by GitNexus as **site** (3747 symbols, 9661 relationships, 269 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. @@ -67,35 +68,36 @@ This project is indexed by GitNexus as **site** (3747 symbols, 9661 relationship ## Tools Quick Reference -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Tool | When to use | Command | +| ---------------- | ----------------------------- | ----------------------------------------------------------------------- | +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | ## Impact Risk Levels -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +| Depth | Meaning | Action | +| ----- | ------------------------------------- | --------------------- | +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | ## Resources -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/site/context` | Codebase overview, check index freshness | -| `gitnexus://repo/site/clusters` | All functional areas | -| `gitnexus://repo/site/processes` | All execution flows | -| `gitnexus://repo/site/process/{name}` | Step-by-step execution trace | +| Resource | Use for | +| ------------------------------------- | ---------------------------------------- | +| `gitnexus://repo/site/context` | Codebase overview, check index freshness | +| `gitnexus://repo/site/clusters` | All functional areas | +| `gitnexus://repo/site/processes` | All execution flows | +| `gitnexus://repo/site/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing Before completing any code modification task, verify: + 1. `gitnexus_impact` was run for all modified symbols 2. No HIGH/CRITICAL risk warnings were ignored 3. `gitnexus_detect_changes()` confirms changes match expected scope @@ -121,13 +123,13 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. ## CLI -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Read this skill file | +| -------------------------------------------- | ----------------------------------------------------------- | +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | diff --git a/CLAUDE.md b/CLAUDE.md index 57b1ac2..5c2c515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ No project-specific Claude instructions are required here. + # GitNexus — Code Intelligence This project is indexed by GitNexus as **site** (3747 symbols, 9661 relationships, 269 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. @@ -39,35 +40,36 @@ This project is indexed by GitNexus as **site** (3747 symbols, 9661 relationship ## Tools Quick Reference -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| Tool | When to use | Command | +| ---------------- | ----------------------------- | ----------------------------------------------------------------------- | +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | ## Impact Risk Levels -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +| Depth | Meaning | Action | +| ----- | ------------------------------------- | --------------------- | +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | ## Resources -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/site/context` | Codebase overview, check index freshness | -| `gitnexus://repo/site/clusters` | All functional areas | -| `gitnexus://repo/site/processes` | All execution flows | -| `gitnexus://repo/site/process/{name}` | Step-by-step execution trace | +| Resource | Use for | +| ------------------------------------- | ---------------------------------------- | +| `gitnexus://repo/site/context` | Codebase overview, check index freshness | +| `gitnexus://repo/site/clusters` | All functional areas | +| `gitnexus://repo/site/processes` | All execution flows | +| `gitnexus://repo/site/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing Before completing any code modification task, verify: + 1. `gitnexus_impact` was run for all modified symbols 2. No HIGH/CRITICAL risk warnings were ignored 3. `gitnexus_detect_changes()` confirms changes match expected scope @@ -93,13 +95,13 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats. ## CLI -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Read this skill file | +| -------------------------------------------- | ----------------------------------------------------------- | +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e191a67 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +## Local setup + +```sh +nvm use +pnpm install --frozen-lockfile +cp .env.local.example .env +pnpm dev +``` + +Use Node `24.14.0` and pnpm `10.32.1`. + +## Quality gates + +Before opening a pull request, run: + +```sh +pnpm check +pnpm lint +pnpm build +``` + +Run targeted test commands when you touch the corresponding area, and prefer the full CI matrix before merging release-sensitive changes. + +## Database changes + +- Use `pnpm db:push` only for local iteration. +- Commit generated migrations for any schema-affecting change. +- Production and staging deploys must use committed migrations via `pnpm db:deploy`. + +## Branch and PR workflow + +- Open changes through pull requests. +- Expect protected branches to require review and passing CI. +- Keep PRs scoped so required checks stay readable and actionable. + +## Repository conventions + +- `.vscode/*`, `.cursor/mcp.json`, and similar workspace files are intentionally tracked to document the supported editor and MCP setup. +- Avoid committing real secrets or environment files. +- Do not force-push protected branches. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1d6274c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hayden Koch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2655050..5d45abd 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Use [docs/README.md](/Users/hayden/Desktop/kb/site/docs/README.md) as the map of From the `site/` directory: ```sh +nvm use pnpm install cp .env.local.example .env pnpm dev @@ -71,6 +72,55 @@ curl http://localhost:7700/health Use `.env.local.example` as the local development template and copy it to `site/.env`. Use `.env.example` as the production or staging checklist for your hosting environment variables. +Use `.env.staging.example` for a staging deploy baseline and `.env.ci.example` for CI/app-test boot. + +## Secrets Management + +The repo now treats environment management as one versioned contract: + +- Keep the templates in git: + - `.env.local.example` + - `.env.staging.example` + - `.env.example` + - `.env.ci.example` +- Keep real values in your provider secret store, never in git. +- Prefer `*_FILE` for mounted secrets when your platform supports it. + - Example: `BETTER_AUTH_SECRET_FILE=/run/secrets/better_auth_secret` +- Set `KB_ENVIRONMENT` explicitly in staging, production, and CI. + - `development` + - `ci` + - `staging` + - `production` +- Validate before deploy or before running CI: + +```sh +pnpm secrets:check +pnpm secrets:check:staging +pnpm secrets:check:production +pnpm secrets:check:ci +``` + +Recommended environment split: + +- Local development: + - Use local Postgres/MinIO/Meilisearch values from `.env.local.example` + - Keep OAuth, Sentry, and SMTP optional unless you are actively testing them +- Staging: + - Use the same variable names as production + - Point them at staging-only DB, bucket, search host, SMTP sender, and OAuth callback origin + - Generate separate `BETTER_AUTH_SECRET`, `REINDEX_SECRET`, and `SOURCE_OPS_SECRET` +- Production: + - Same contract as staging, with production URLs and production-only secrets +- CI: + - Only the boot-critical app values are required by default + - Add search/object-storage vars only if the CI job actually exercises those services + +The easiest way to keep environments in sync is to treat template changes like schema changes: + +1. Update the example files in git whenever the app contract changes. +2. Run `pnpm secrets:check:` locally or in CI. +3. Copy only the changed keys into Railway/GitHub Actions/your secret store. +4. Keep staging and production on the same key set, differing only by values. Public placeholder/branding assets are now expected to already exist in MinIO, with `PUBLIC_ASSET_BASE_URL` pointing at a public bucket root or CDN origin. `pnpm images:sync` @@ -95,10 +145,13 @@ pnpm auth:schema Notes: +- `pnpm db:push` is for local development only. Do not use it in staging or production deploys. - `pnpm db:seed` now runs the launch-data seed: events, non-event coils, and source registry seeds when the shared source seed file is available. - `pnpm db:seed:events` runs the legacy events CSV seed only. - `pnpm db:seed:coils` seeds Funding, Red Pages, Jobs, and Toolbox sample content. - `pnpm db:seed:sources` seeds the source registry from shared `seed-sources.json` data when that file is present in the linked data repo. +- The committed migration chain is the required path for persistent environments, but fresh ephemeral databases in CI still bootstrap with `pnpm db:push --force` until the repo has a full historical baseline migration for all content tables. +- CI seeds only the curated in-repo coil fixtures with `pnpm db:seed:coils`; the broader `pnpm db:seed` launcher is for local launch-data restores and optional shared source seeds. - Search indexing uses Meilisearch when configured. - Health status for DB, search, object storage, and source ops is exposed at `GET /api/health`. - After applying DB migrations, restart the dev server so cached schema-health warnings clear. @@ -106,7 +159,7 @@ Notes: - `POST /api/reindex` is protected in production. Use an admin/moderator session or send `x-reindex-secret` matching `REINDEX_SECRET`. - The admin UI also exposes search reindexing at `/admin/settings/search`. - `sitemap.xml`, `robots.txt`, and `manifest.webmanifest` are generated as part of the app surface. -- `pnpm start` now runs `pnpm db:migrate` before booting the Node server so deploys apply schema changes as part of startup on single-instance environments. +- `pnpm start` now runs `pnpm db:migrate` before booting the Node server so deploys apply committed migrations as part of startup on single-instance environments. ## Production Environment Contract @@ -122,11 +175,13 @@ Launch environments should set, at minimum: Recommended observability settings: - `SENTRY_DSN` and/or `PUBLIC_SENTRY_DSN` +- `SENTRY_ENVIRONMENT`, `PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`, `PUBLIC_SENTRY_RELEASE` - `PUBLIC_POSTHOG_KEY` and optional `PUBLIC_POSTHOG_HOST` - `LOG_LEVEL` - `ERROR_WEBHOOK_URL` In production, startup now validates this contract and fails fast on missing or clearly invalid required settings. +Staging uses the same contract and should set `KB_ENVIRONMENT=staging`. ## Railway Deployment @@ -138,25 +193,39 @@ The app is already configured for Railway's Node deployment path: Recommended Railway setup: -1. Create an app service from this `site/` directory. -2. Add a Postgres service and point `DATABASE_URL` at it. -3. Generate a Railway public domain. -4. Set `ORIGIN` to your app URL. If you are using only the generated Railway domain, the app can fall back to `RAILWAY_PUBLIC_DOMAIN`, but explicit `ORIGIN` is still the safer default. -5. Add the rest of the required production variables from the contract above. -6. Redeploy and verify `GET /api/health`, auth flows, and `/sitemap.xml`. +1. Create separate Railway `staging` and `production` environments for this `site/` service. +2. Add isolated services per environment for Postgres, Meilisearch, and any optional Redis/object-storage bindings. Do not share writable staging and production services. +3. Generate a Railway public domain for each environment. +4. Set `ORIGIN` to the correct environment URL. If you are using only the generated Railway domain, the app can fall back to `RAILWAY_PUBLIC_DOMAIN`, but explicit `ORIGIN` is still the safer default. +5. Add the rest of the required variables from the contract above, including environment-specific Sentry/PostHog keys and release metadata. +6. Keep staging protected/internal and use sanitized snapshot data there rather than a writable production copy. +7. Verify `GET /api/health`, auth flows, and `/sitemap.xml` before promoting `staging` to `main`. Operational notes: - Railway injects `PORT`; the Node adapter will bind to it automatically. +- Railway pre-deploys should run committed migrations only. Keep `pnpm db:push` for local iteration and use `pnpm db:deploy` / `pnpm db:migrate` for deploy environments. - The generated `RAILWAY_PUBLIC_DOMAIN` is good enough for a first deploy, but switch `ORIGIN` to your custom domain before finalizing Google OAuth or canonical URLs. - If you later split this repo into multiple Railway services, move the start command into each service's dashboard settings so the shared `railway.toml` does not force the same process everywhere. +## Safe Delivery Flow + +Knowledge Basket now treats `main` as production-only and `staging` as the protected integration branch. + +- Normal work flows through `feature/* -> staging -> main`. +- `main` and `staging` should both be protected from direct pushes. +- Release PRs from `staging` into `main` should use a Conventional Commit title such as `release: promote staging to production (2026.04.08.1)`. +- Emergency fixes branch from `main` as `hotfix/*`, merge into `main`, and then back-merge into `staging`. +- Staging should stay internal, use sanitized production-derived data, and send telemetry to staging-specific Sentry/PostHog destinations. + +Use [docs/RELEASE_PROCESS.md](/Users/hayden/.claude-squad/worktrees/hayden/kb-safety-rollout/docs/RELEASE_PROCESS.md) and [docs/PRODUCTION_RUNBOOK.md](/Users/hayden/.claude-squad/worktrees/hayden/kb-safety-rollout/docs/PRODUCTION_RUNBOOK.md) as the operational source of truth for releases. + ## Quality Status As of the current takeover baseline: - `pnpm check` should pass. -- `pnpm lint` should pass. +- `pnpm lint` should pass for the tracked app, config, script, and test files enforced by this repo. - `pnpm test` runs the smoke and handler tests. - `pnpm test:search:indexed` validates the Meilisearch-backed contract. - `pnpm test:search:degraded` validates compatibility-mode search when Meilisearch is unavailable. @@ -172,10 +241,20 @@ As of the current takeover baseline: - `docs/DESIGN_SYSTEM.md` - `docs/PERFORMANCE.md` - `docs/ANALYTICS_AND_MARKETING.md` +- `docs/RELEASE_PROCESS.md` - `docs/PRODUCTION_RUNBOOK.md` - `docs/ops-content-workflows.md` - `docs/SOURCE_OPS_HANDOFF.md` +## Toolchain + +Use the pinned local toolchain when contributing: + +- Node `24.14.0` via `.nvmrc` +- pnpm `10.32.1` via `packageManager` + +`pnpm install --frozen-lockfile`, `pnpm check`, `pnpm lint`, and `pnpm build` are the baseline local gates before opening a PR. + ## Short-Term Direction The current implementation direction is: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d18b1ad --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Reporting a vulnerability + +Please do not open public issues for suspected security vulnerabilities. + +Use one of these paths instead: + +- Open a private GitHub security advisory if you have repository access. +- Contact the maintainer directly through GitHub if you do not. + +Include: + +- a short description of the issue +- affected paths or features +- reproduction steps or a proof of concept +- impact assessment if known + +We will triage reports privately and coordinate a fix before public disclosure when possible. diff --git a/docs/PRODUCTION_RUNBOOK.md b/docs/PRODUCTION_RUNBOOK.md index 2c51a00..fdec119 100644 --- a/docs/PRODUCTION_RUNBOOK.md +++ b/docs/PRODUCTION_RUNBOOK.md @@ -14,6 +14,34 @@ This runbook is the current operational baseline for launching and maintaining K - `ERROR_WEBHOOK_URL` for forwarding structured server errors - `LOG_LEVEL` to tune structured stdout logging (`debug`, `info`, `warn`, `error`) +## Secrets strategy + +- Keep env templates in git: + - `.env.local.example` + - `.env.staging.example` + - `.env.example` + - `.env.ci.example` +- Keep real values in provider secret managers or deploy-platform secret UIs. +- Prefer file-backed secrets where possible: + - `DATABASE_URL_FILE` + - `BETTER_AUTH_SECRET_FILE` + - `MEILISEARCH_API_KEY_FILE` + - `MINIO_SECRET_KEY_FILE` + - `SMTP_PASS_FILE` + - `REINDEX_SECRET_FILE` + - `SOURCE_OPS_SECRET_FILE` +- Set `KB_ENVIRONMENT` explicitly per environment: + - local dev: `development` + - CI: `ci` + - staging: `staging` + - production: `production` +- Validate the contract before deploy: + - `pnpm secrets:check:staging` + - `pnpm secrets:check:production` + - `pnpm secrets:check:ci` + +Staging should mirror production keys exactly. The values should differ, but the key set should not. + ## Railway baseline - `railway.toml` is the deployment source of truth for the web service: @@ -23,8 +51,36 @@ This runbook is the current operational baseline for launching and maintaining K - healthcheck: `GET /api/health` - Generate a Railway public domain before first launch. - Set `ORIGIN` explicitly once the final public URL is known. +- Set `KB_ENVIRONMENT` to `staging` or `production` in each Railway environment. - If `ORIGIN` is temporarily omitted, the app can fall back to `RAILWAY_PUBLIC_DOMAIN`, but treat that as a bootstrap convenience rather than the steady-state production setting. +## Delivery baseline + +- Branch roles: + - `staging` is the protected integration branch and Railway staging source + - `main` is production-only +- Normal work: + - branch from `staging` as `feature/*` + - merge feature PRs into `staging` + - promote `staging` to `main` with a dedicated release PR +- Hotfixes: + - branch from `main` as `hotfix/*` + - merge the hotfix into `main` + - immediately back-merge the hotfix into `staging` +- Release PRs should use a Conventional Commit title with a CalVer payload such as `release: promote staging to production (2026.04.08.1)`. +- `main` and `staging` should block direct pushes, force pushes, and branch deletion. + +## Staging isolation + +- Staging must be operationally separate from production: + - separate Postgres + - separate Meilisearch + - separate object-storage bucket or prefix + - separate optional Redis +- Staging should stay protected/internal, not publicly indexed. +- Staging data should come from sanitized snapshots or fixtures only. Do not point staging at a writable production database. +- Staging should use separate telemetry destinations or strict environment partitioning so staging traffic does not pollute production Sentry triage or PostHog reporting. + ## Health checks - App/system health: @@ -57,6 +113,9 @@ After seeding published content: ## Migration parity - Apply the latest Drizzle migrations before launch and after every deployment that adds schema changes. +- Persistent environments such as staging and production must use committed migrations, not schema pushes. +- CI currently uses a throwaway database bootstrap via `pnpm db:push --force` because the historical migration set does not yet recreate every legacy content table from empty state. +- CI seeds only curated in-repo coil fixtures with `pnpm db:seed:coils`; launch-data and shared source seeds stay out of the GitHub workflow. - Verify `GET /api/health` reports schema checks as healthy for: - source ops - privacy request storage @@ -105,6 +164,16 @@ Recommended launch habit: - Keep one dashboard or saved query per area so on-call review is not dependent on admin UI memory alone. - Before GA, verify each alert path reaches a human inbox or paging destination. +## Release checklist + +Before promoting `staging` into `main`: + +1. Confirm `checks` is green on the release PR. +2. Verify `GET /api/health` is healthy in staging. +3. Smoke-test auth, search, uploads/assets, and the admin/source-ops path in staging. +4. Verify the latest committed migrations have been applied cleanly. +5. Record a rollback note in the PR before merge. + ## Backups and recovery - Postgres: @@ -129,6 +198,13 @@ Run one full restore drill in staging before launch and record the exact timesta 6. Spot-check `/search`, `/events`, `/account`, and a representative uploaded asset. 7. Trigger `POST /api/source-ops/run-due` once and confirm the scheduler path still works after restore. +## Environment drift prevention + +1. When a new env var is introduced, update the example templates in the same PR. +2. Run the matching `pnpm secrets:check:` command before merging. +3. Apply the new key to staging first, then production. +4. Do not create staging-only variable names when a staging-specific value under the production key name will do. + ## Incident checks - Search looks empty: diff --git a/docs/README.md b/docs/README.md index bee6014..95be3d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,8 @@ For implementation truth, use: - performance notes and follow-up work - `docs/ANALYTICS_AND_MARKETING.md` - PostHog capture contract, event inventory, and marketing-email template usage +- `docs/RELEASE_PROCESS.md` + - protected-branch workflow, staging promotion checklist, and hotfix path - `docs/SOURCE_OPS_HANDOFF.md` - source-ops and operational handoff context - `docs/PRODUCTION_RUNBOOK.md` diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md new file mode 100644 index 0000000..bcf749e --- /dev/null +++ b/docs/RELEASE_PROCESS.md @@ -0,0 +1,82 @@ +# Release Process + +This document is the day-to-day operating guide for safe delivery after the unsafe-push cutoff at `77162ec`. + +## Branch model + +- `main` + - production only + - no direct pushes + - receives release PRs from `staging` and emergency hotfix PRs +- `staging` + - protected integration branch + - deploy source for Railway staging + - target for normal feature work +- `feature/*` + - branch from `staging` + - merge back into `staging` through a PR +- `hotfix/*` + - branch from `main` + - merge into `main` first, then back-merge into `staging` + +## Pull request standards + +- PR titles must follow Conventional Commit style. +- Recommended types: + - `feat` + - `fix` + - `chore` + - `docs` + - `refactor` + - `test` + - `perf` + - `build` + - `ci` + - `release` + - `security` +- Every protected-branch PR should include: + - linked Linear issue + - migration impact (`yes` or `no`) + - risk level + - rollback plan + - test evidence + +## Release flow + +1. Merge normal work into `staging`. +2. Let Railway deploy staging automatically. +3. Verify the staging checklist. +4. Open a PR from `staging` to `main`. +5. Use a title like `release: promote staging to production (2026.04.08.1)`. +6. Merge only after all required checks pass. +7. Verify production health after deploy. +8. Create a GitHub Release using the same CalVer. + +## Staging checklist + +- `checks` is green. +- `GET /api/health` is healthy. +- Auth flow is smoke-tested. +- Search is smoke-tested. +- Uploads and asset serving are smoke-tested. +- Admin/source-ops flows are smoke-tested. +- Migration state is verified. +- Rollback note is present in the PR. + +## Hotfix flow + +1. Branch from `main` as `hotfix/*`. +2. Open a PR into `main`. +3. Merge after checks pass. +4. Back-merge the hotfix into `staging` immediately. + +## Data and telemetry rules + +- Local uses seed/synthetic data. +- Staging uses sanitized production-derived snapshots or fixtures. +- Production is canonical. +- Staging must never share a writable database, search index, or uploads bucket with production. +- Staging telemetry must be isolated from production: + - separate Sentry environment or project + - separate PostHog project or source + - environment-specific release naming diff --git a/docs/plans/glittery-forging-rainbow.md b/docs/plans/glittery-forging-rainbow.md index 8c5b167..1f60541 100644 --- a/docs/plans/glittery-forging-rainbow.md +++ b/docs/plans/glittery-forging-rainbow.md @@ -39,29 +39,30 @@ Replace the CSS-percentage + aspect-ratio approach with explicit pixel dimension - On `loadDocument()` reset: clear `paperWidth = 0; paperHeight = 0;` alongside the existing `zoomMultiplier = 1;`. **Markup change** (line 628): + ```svelte -
- +
+
``` + Drop `--pdf-zoom` and the inline `aspect-ratio`; they become redundant. **CSS change** (`.kb-pdf__paper`, lines 840–852): + ```css .kb-pdf__paper { - display: block; - max-width: 100%; /* safety clamp at zoom = 1 */ - align-self: center; - background: #fff; - border: 1px solid var(--border); - box-shadow: - 0 1px 2px rgba(15, 23, 42, 0.04), - 0 8px 24px -8px rgba(15, 23, 42, 0.12); + display: block; + max-width: 100%; /* safety clamp at zoom = 1 */ + align-self: center; + background: #fff; + border: 1px solid var(--border); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 8px 24px -8px rgba(15, 23, 42, 0.12); } ``` + Remove `width: calc(...)`, `min-width: 100%`, `max-width: none`, and `aspect-ratio`. Keep `.kb-pdf__canvas { width: 100% !important; height: 100% !important; }` — canvas continues to fill the explicitly-sized paper. Why this fixes the overflow: at zoom = 1 the paper is exactly `fitWidth` (stage content width minus padding), so there is no rounding error or race that can push it past the scroll container. At zoom > 1, the scroll container (`.kb-pdf__stage-scroll`) legitimately scrolls horizontally — which is what the user expects when they've zoomed in. @@ -74,71 +75,73 @@ Lock the rail's layout so page count and PDF aspect ratio no longer influence th ```css @media (min-width: 980px) { - .kb-pdf__layout.has-thumbs { - grid-template-columns: minmax(0, 1fr) 180px; - max-height: min(80vh, 900px); - } - .kb-pdf__stage-scroll { - min-height: 0; /* allow grid row to shrink */ - } + .kb-pdf__layout.has-thumbs { + grid-template-columns: minmax(0, 1fr) 180px; + max-height: min(80vh, 900px); + } + .kb-pdf__stage-scroll { + min-height: 0; /* allow grid row to shrink */ + } } ``` + (Widening the sidebar from 168px → 180px gives the thumbnails breathing room for consistent padding; adjust down if it feels too wide in review.) **Thumb panel / list** (lines 864–906): explicitly set alignment and lock item sizing. ```css .kb-pdf__thumb-panel { - border-top: 1px solid var(--border); - background: var(--card); - min-width: 0; - min-height: 0; + border-top: 1px solid var(--border); + background: var(--card); + min-width: 0; + min-height: 0; } @media (min-width: 980px) { - .kb-pdf__thumb-panel { - border-top: 0; - border-left: 1px solid var(--border); - display: flex; - flex-direction: column; - min-height: 0; - } + .kb-pdf__thumb-panel { + border-top: 0; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; + } } /* Mobile: horizontal strip, always starts flush left, consistent item size. */ .kb-pdf__thumb-list { - display: grid; - grid-auto-flow: column; - grid-auto-columns: 104px; - justify-content: start; - align-content: start; - gap: 0.75rem; - padding: 0.75rem; - overflow-x: auto; - overflow-y: hidden; - max-width: 100%; - min-width: 0; - scrollbar-width: thin; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 104px; + justify-content: start; + align-content: start; + gap: 0.75rem; + padding: 0.75rem; + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + min-width: 0; + scrollbar-width: thin; } @media (min-width: 980px) { - .kb-pdf__thumb-list { - grid-auto-flow: row; - grid-auto-columns: initial; - grid-template-columns: 1fr; - justify-content: stretch; - align-content: start; /* never distribute vertical space across rows */ - gap: 0.75rem; - overflow-x: visible; - overflow-y: auto; - flex: 1 1 0; - min-height: 0; - max-width: none; - } + .kb-pdf__thumb-list { + grid-auto-flow: row; + grid-auto-columns: initial; + grid-template-columns: 1fr; + justify-content: stretch; + align-content: start; /* never distribute vertical space across rows */ + gap: 0.75rem; + overflow-x: visible; + overflow-y: auto; + flex: 1 1 0; + min-height: 0; + max-width: none; + } } ``` Key properties added: + - `align-content: start` on both orientations — rows never stretch to fill extra space, so 2 pages and 200 pages render with identical row metrics. - `justify-content: start` (mobile) / `stretch` (desktop) — explicit rather than relying on grid's `normal` default. - Consistent `gap: 0.75rem` in both orientations (was `0.625rem`). diff --git a/docs/plans/groovy-gliding-shamir.md b/docs/plans/groovy-gliding-shamir.md index 1acfc8f..24a2982 100644 --- a/docs/plans/groovy-gliding-shamir.md +++ b/docs/plans/groovy-gliding-shamir.md @@ -24,29 +24,31 @@ Goal: add a prominent interactive Mapbox map at the top of each list page, above ### 1. New organism: `src/lib/components/organisms/ListLocationMap.svelte` Props: + ```ts type PreviewKind = 'organization' | 'venue'; type MapPreview = { - id: string; - slug: string; - name: string; - lat: number; - lng: number; - logoUrl?: string | null; - badge?: string | null; // orgType or venueType - verified?: boolean; - location?: string | null; // "City, ST" - description?: string | null; // already stripped/trimmed server-side + id: string; + slug: string; + name: string; + lat: number; + lng: number; + logoUrl?: string | null; + badge?: string | null; // orgType or venueType + verified?: boolean; + location?: string | null; // "City, ST" + description?: string | null; // already stripped/trimmed server-side }; type Props = { - token: string | null; - kind: PreviewKind; - points: MapPreview[]; - height?: number; // default 480 + token: string | null; + kind: PreviewKind; + points: MapPreview[]; + height?: number; // default 480 }; ``` Behavior: + - Dynamic `import('mapbox-gl')` + CSS, same pattern as `LocationMap.svelte:250-254`. - On mount: initialize map, add a clustered GeoJSON source from `points`, add three layers (cluster circles, cluster counts, unclustered points). - Fit bounds to all points on initial load; if ≤1 point, center + zoom 4. @@ -58,20 +60,23 @@ Behavior: - Graceful fallback: if `token` is null or `points.length === 0`, render a skeleton/empty state (reuse the placeholder grid pattern from `LocationMap.svelte:464-476`). Popup HTML structure (built imperatively to avoid Svelte lifecycle inside Mapbox popups): + ```html ``` + Styled via a scoped `