From 1c8ad3219bf04ecc024d3ad74045724d4df2f1e5 Mon Sep 17 00:00:00 2001
From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com>
Date: Mon, 3 Aug 2026 22:49:38 -0700
Subject: [PATCH] feat: extend engineer profile
---
.github/workflows/ci.yml | 14 ++
.gitignore | 1 +
CONTRIBUTING.md | 7 +
README.md | 90 ++++++++++--
engineer-profile.config.json | 12 ++
package-lock.json | 4 +-
package.json | 6 +-
src/config/loader.ts | 90 +++++++++++-
src/deploy/index.ts | 22 +++
src/deploy/local.ts | 61 ++++++++
src/index.ts | 44 +++++-
src/ingest/github.ts | 2 +-
src/publish/site.ts | 156 +++++++++++++-------
src/refresh/run.ts | 4 +
src/theme/index.ts | 8 ++
src/theme/palette.ts | 266 +++++++++++++++++++++++++++++++++++
src/types.ts | 29 ++++
tests/config.test.ts | 59 ++++++++
tests/deploy.test.ts | 105 ++++++++++++++
tests/manifest.test.ts | 91 ++++++++++++
tests/refresh.test.ts | 25 ++++
tests/site.test.ts | 12 ++
tests/theme.test.ts | 95 +++++++++++++
23 files changed, 1135 insertions(+), 68 deletions(-)
create mode 100644 src/deploy/index.ts
create mode 100644 src/deploy/local.ts
create mode 100644 src/theme/index.ts
create mode 100644 src/theme/palette.ts
create mode 100644 tests/deploy.test.ts
create mode 100644 tests/manifest.test.ts
create mode 100644 tests/theme.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eae27d9..534904c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,6 +44,20 @@ jobs:
- name: Run fixture demo
run: node dist/index.js demo
+ - name: Verify theme catalog
+ run: node dist/index.js themes
+
+ - name: Verify site manifest
+ run: |
+ node -e "
+ const m = require('./output/site-manifest.json');
+ if (m.formatVersion !== 1) process.exit(1);
+ if (m.projectCount !== 2) process.exit(1);
+ if (!m.files.includes('index.html')) process.exit(1);
+ if (!m.files.includes('site-manifest.json')) process.exit(1);
+ console.log('Manifest ok: ' + m.projectCount + ' projects, theme ' + m.theme);
+ "
+
- name: Store demo output
if: success()
uses: actions/upload-artifact@v4
diff --git a/.gitignore b/.gitignore
index 6d63646..c229850 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ dist/
coverage/
data/
output/
+/deploy/
*.db
*.db-journal
*.db-shm
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7802f1c..feef8b1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,6 +15,13 @@ npm test
Use fixtures for changes that need repeatable data.
Do not add credentials, private repository data, or generated output.
+## Feature areas
+
+The theme catalog lives in `src/theme/`.
+Add a palette, then register its description.
+The deploy adapters live in `src/deploy/`.
+Keep the publish, capture, and deploy stages separated.
+
## Pull requests
Explain the user value and the data path.
diff --git a/README.md b/README.md
index f1a2a81..4e53623 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
EngineerProfile builds a local engineering portfolio from public repository data.
It stores repository metadata, commits, releases, privacy settings, and preview
paths in SQLite. It publishes a static site from these records.
+Themes and deploy targets control the presentation.
## Value
@@ -10,6 +11,8 @@ paths in SQLite. It publishes a static site from these records.
- Refresh project cards from public GitHub repositories.
- Build release notes from releases or conventional commits.
- Capture repeatable project previews with Playwright.
+- Choose a built-in theme or tune the accent color.
+- Copy the published site to local deploy targets.
- Hide projects and redact author emails before publication.
- Run one configured refresh from a scheduled workflow.
@@ -30,20 +33,28 @@ flowchart LR
L --> W[Publisher]
P --> W
S --> W
+ T[Theme] --> W
W --> O[Static output]
+ W --> M[Site manifest]
+ O --> A[Deploy]
+ C --> T
+ C --> A
+ A --> Y[Local targets]
```
| Area | Responsibility |
| --- | --- |
-| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, and privacy settings. |
+| `engineer-profile.config.json` | Store owner, presentation, refresh, paths, theme, deploy, and privacy settings. |
| `src/config/` | Validate checked-in JSON and merge safe defaults. |
-| `src/refresh/` | Coordinate ingest, best-effort capture, and static publishing. |
+| `src/refresh/` | Coordinate ingest, best-effort capture, static publishing, and deploy. |
| `src/ingest/` | Fetch public GitHub data and map it to records. |
| `src/db/` | Store projects, commits, changelogs, and audit events. |
| `src/changelog/` | Prefer release notes and fall back to commit groups. |
| `src/privacy/` | Hide projects and block sensitive commit messages. |
| `src/preview/` | Capture fixed viewport screenshots with Playwright. |
-| `src/publish/` | Render HTML, changelog files, and preview assets. |
+| `src/theme/` | Resolve built-in themes and emit CSS variables. |
+| `src/publish/` | Render HTML, changelog files, preview assets, and the site manifest. |
+| `src/deploy/` | Copy the published snapshot to configured local targets. |
| `fixtures/` | Provide deterministic demo data and local preview pages. |
The refresh command runs each stage in a fixed order.
@@ -68,7 +79,8 @@ Both directories are ignored by Git.
## Configuration
`engineer-profile.config.json` is the checked-in source for scheduled refreshes.
-It sets the GitHub owner, site presentation, repository limit, paths, and privacy controls.
+It sets the GitHub owner, presentation, repository limit, paths, theme, deploy
+targets, and privacy controls.
The loader accepts repository limits from 1 through 100.
It rejects malformed values before network access.
@@ -81,7 +93,7 @@ npm run refresh
```
The refresh command reads public repositories, captures previews, publishes HTML,
-and reports skipped captures.
+copies the site to deploy targets, and reports skipped captures.
GitHub ingestion uses the public API.
Set `GITHUB_TOKEN` for a higher rate limit.
@@ -94,6 +106,50 @@ npm run refresh
Do not put a token in repository files.
Use `.env.example` as a variable reference.
+## Themes
+
+Choose a theme with the `theme.name` field.
+Built-in themes are `deep-space`, `paper`, and `terminal`.
+The `deep-space` theme is the default.
+Override the accent color, corner radius, or font for any theme.
+
+```json
+{
+ "theme": {
+ "name": "terminal",
+ "accent": "#39d353"
+ }
+}
+```
+
+Run `npm run themes` to list the catalog.
+
+## Deploy targets
+
+Deploy targets copy the published snapshot to local folders.
+Use the `deploy.targets` list in the configuration.
+Each target needs a name, a type, and a target path.
+The `local` type copies the output directory.
+A target must stay outside the output directory.
+The publisher rejects unsafe target paths.
+
+```json
+{
+ "deploy": {
+ "targets": [
+ {
+ "name": "public",
+ "type": "local",
+ "target": "deploy/site"
+ }
+ ]
+ }
+}
+```
+
+Run `npm run deploy` to publish and copy the site.
+Deploy runs automatically at the end of a refresh.
+
## Sample output
The fixture set contains `signal-router` and `metrics-kit`.
@@ -106,11 +162,13 @@ Captured demo-engineer-signal-router.
Captured demo-engineer-metrics-kit.
Published 2 projects to output/index.html.
Copied 2 available preview screenshots.
+Deployed public: 6 files to deploy/site.
Open output/index.html in a browser.
```
The site shows project facts, source links, changelog previews, and screenshots.
The totals come from fixture fields and stored commit records.
+The manifest lists the published files and the active theme.
## Commands
@@ -119,11 +177,13 @@ Build before direct CLI commands.
| Command | Result |
| --- | --- |
| `npm run demo` | Run the complete fixture pipeline. |
+| `npm run themes` | List built-in presentation themes. |
| `npm run ingest -- octocat --limit 3` | Load public repository evidence. |
| `npm run ingest -- --fixture` | Load fixture records only. |
| `npm run capture -- --fixture` | Capture local fixture pages. |
| `npm run publish` | Rebuild the site from SQLite. |
-| `npm run refresh` | Run configured ingest, capture, and publish stages. |
+| `npm run deploy` | Publish the site and copy it to targets. |
+| `npm run refresh` | Run configured ingest, capture, publish, and deploy stages. |
| `node dist/index.js status` | Show visibility and recent operations. |
| `npm test` | Run deterministic unit and integration tests. |
| `npm run typecheck` | Validate TypeScript types. |
@@ -144,6 +204,13 @@ Hidden projects stay out of public HTML and copied assets.
Author emails are redacted by default.
Sensitive commit messages are skipped before storage.
+## Site manifest
+
+`publish` writes `site-manifest.json` to the output directory.
+The manifest records the theme, the project count, and every published file.
+It lists each project with its changelog file.
+Scripts can use the manifest to verify a build.
+
## Audit model
Each project stores a repository URL and its last pushed timestamp.
@@ -153,11 +220,12 @@ Each release keeps its tag, notes, date, and source URL.
The site displays visible projects only.
It links project cards to repositories.
It links release notes to their release pages.
-It records local operations in an audit table.
+It records ingest, capture, publish, and deploy operations in an audit table.
## CI and test status
The regular CI workflow runs typecheck, build, tests, the fixture demo, and artifact upload.
+It verifies the theme catalog and the site manifest.
The scheduled refresh workflow runs each Monday and supports manual dispatch.
It uploads the generated site as a workflow artifact.
@@ -168,7 +236,10 @@ The test suite covers these core behaviors:
- Release-first changelog generation.
- SQLite upserts and changelog replacement.
- Privacy filtering and email redaction.
+- Theme resolution and CSS variable output.
+- Local deploy adapter safety checks.
- Fixture ingestion and static publishing.
+- Site manifest accuracy.
- Configured refresh orchestration.
- Release source links.
- Deterministic HTML output.
@@ -196,6 +267,7 @@ The fixture pipeline provides deterministic data for repeatable checks.
- Changelog quality depends on releases or conventional commits.
- External pages can fail during capture.
- Capture failures are reported and do not stop publishing.
+- Deploy adapters copy local folders. They do not upload to remote hosts.
- Publishing creates local files. It does not deploy them.
- Scheduled runs upload artifacts. They do not commit generated output.
@@ -205,8 +277,8 @@ The fixture pipeline provides deterministic data for repeatable checks.
| --- | --- | --- |
| v0.1 | Complete | Fixture demo, GitHub ingest, changelog, capture, publish, and privacy controls. |
| v0.2 | Complete | Checked-in configuration, coordinated refresh command, and scheduled artifact workflow. |
-| v0.3 | Next | Custom themes and deployment adapters. |
-| v0.4 | Later | Commit-diff summaries and an RSS feed. |
+| v0.3 | Complete | Built-in themes, local deploy adapters, and the site manifest. |
+| v0.4 | Next | Commit-diff summaries and an RSS feed. |
## License
diff --git a/engineer-profile.config.json b/engineer-profile.config.json
index 81008fb..b08ee84 100644
--- a/engineer-profile.config.json
+++ b/engineer-profile.config.json
@@ -5,6 +5,18 @@
"repositoryLimit": 5,
"dataDir": "data",
"outputDir": "output",
+ "theme": {
+ "name": "deep-space"
+ },
+ "deploy": {
+ "targets": [
+ {
+ "name": "public",
+ "type": "local",
+ "target": "deploy/site"
+ }
+ ]
+ },
"privacy": {
"hiddenProjects": [],
"redactEmails": true,
diff --git a/package-lock.json b/package-lock.json
index 87f5d51..23ffbf1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "engineer-profile",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "engineer-profile",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.8.1",
diff --git a/package.json b/package.json
index d36edbe..637c970 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "engineer-profile",
- "version": "0.2.0",
+ "version": "0.3.0",
"description": "Self-maintaining engineering portfolio from repository activity",
"type": "module",
"main": "dist/index.js",
@@ -18,7 +18,9 @@
"ingest": "npm run build && node dist/index.js ingest",
"publish": "npm run build && node dist/index.js publish",
"capture": "npm run build && node dist/index.js capture",
- "refresh": "npm run build && node dist/index.js refresh"
+ "refresh": "npm run build && node dist/index.js refresh",
+ "deploy": "npm run build && node dist/index.js deploy",
+ "themes": "npm run build && node dist/index.js themes"
},
"engines": {
"node": ">=20"
diff --git a/src/config/loader.ts b/src/config/loader.ts
index 406896b..d0ad852 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -1,7 +1,14 @@
import { readFileSync } from "node:fs";
-import type { PortfolioConfig, PrivacyConfig } from "../types.js";
-import { DEFAULT_CONFIG, DEFAULT_PRIVACY } from "../types.js";
+import type {
+ DeployConfig,
+ DeployTarget,
+ PortfolioConfig,
+ PrivacyConfig,
+ ThemeConfig,
+} from "../types.js";
+import { DEFAULT_CONFIG, DEFAULT_DEPLOY, DEFAULT_PRIVACY, DEFAULT_THEME } from "../types.js";
import { mergePrivacy } from "../privacy/controls.js";
+import { isBuiltinTheme, isValidHexColor, listBuiltinThemes } from "../theme/palette.js";
export const DEFAULT_CONFIG_PATH = "engineer-profile.config.json";
@@ -56,6 +63,83 @@ function readPrivacy(source: ConfigValue): PrivacyConfig {
});
}
+function readTheme(source: ConfigValue): ThemeConfig {
+ if (!("theme" in source)) return DEFAULT_THEME;
+ if (!isConfigValue(source.theme)) {
+ throw new Error('Configuration field "theme" must be an object.');
+ }
+
+ const name = source.theme.name === undefined
+ ? DEFAULT_THEME.name
+ : readString(source.theme, "name", DEFAULT_THEME.name);
+ if (!isBuiltinTheme(name)) {
+ const names = listBuiltinThemes().map((theme) => theme.name).join(", ");
+ throw new Error(`Configuration field "theme.name" must be one of: ${names}.`);
+ }
+ const theme: ThemeConfig = { name };
+
+ if ("accent" in source.theme) {
+ const accent = source.theme.accent;
+ if (typeof accent !== "string" || !isValidHexColor(accent)) {
+ throw new Error('Configuration field "theme.accent" must be a hex color like "#67b7ff".');
+ }
+ theme.accent = accent.trim();
+ }
+ if ("radius" in source.theme) {
+ const radius = source.theme.radius;
+ if (typeof radius !== "string" || radius.trim() === "") {
+ throw new Error('Configuration field "theme.radius" must be a non-empty CSS length.');
+ }
+ theme.radius = radius.trim();
+ }
+ if ("font" in source.theme) {
+ const font = source.theme.font;
+ if (typeof font !== "string" || font.trim() === "") {
+ throw new Error('Configuration field "theme.font" must be a non-empty font stack.');
+ }
+ theme.font = font.trim();
+ }
+ return theme;
+}
+
+function readDeploy(source: ConfigValue): DeployConfig {
+ if (!("deploy" in source)) return DEFAULT_DEPLOY;
+ if (!isConfigValue(source.deploy)) {
+ throw new Error('Configuration field "deploy" must be an object.');
+ }
+ if (!("targets" in source.deploy)) return DEFAULT_DEPLOY;
+
+ const targets = source.deploy.targets;
+ if (!Array.isArray(targets)) {
+ throw new Error('Configuration field "deploy.targets" must be a list.');
+ }
+
+ const parsedTargets: DeployTarget[] = targets.map((target, index) => {
+ if (!isConfigValue(target)) {
+ throw new Error(`Configuration field "deploy.targets[${index}]" must be an object.`);
+ }
+ const { name, type, target: targetPath } = target;
+ if (typeof name !== "string" || name.trim() === "") {
+ throw new Error(
+ `Configuration field "deploy.targets[${index}].name" must be a non-empty string.`
+ );
+ }
+ if (type !== "local") {
+ throw new Error(
+ `Configuration field "deploy.targets[${index}].type" must be "local".`
+ );
+ }
+ if (typeof targetPath !== "string" || targetPath.trim() === "") {
+ throw new Error(
+ `Configuration field "deploy.targets[${index}].target" must be a non-empty path.`
+ );
+ }
+ return { name: name.trim(), type: "local", target: targetPath.trim() };
+ });
+
+ return { targets: parsedTargets };
+}
+
export function loadPortfolioConfig(
path: string = DEFAULT_CONFIG_PATH,
clock: () => string = DEFAULT_CONFIG.clock
@@ -78,6 +162,8 @@ export function loadPortfolioConfig(
repositoryLimit: readLimit(parsed, "repositoryLimit", DEFAULT_CONFIG.repositoryLimit),
dataDir: readString(parsed, "dataDir", DEFAULT_CONFIG.dataDir),
outputDir: readString(parsed, "outputDir", DEFAULT_CONFIG.outputDir),
+ theme: readTheme(parsed),
+ deploy: readDeploy(parsed),
privacy: readPrivacy(parsed),
clock,
};
diff --git a/src/deploy/index.ts b/src/deploy/index.ts
new file mode 100644
index 0000000..ed53b6f
--- /dev/null
+++ b/src/deploy/index.ts
@@ -0,0 +1,22 @@
+import type { DeployTarget, PortfolioConfig } from "../types.js";
+import { deployLocal, type DeployResult } from "./local.js";
+
+export type { DeployResult } from "./local.js";
+
+export function deployToTarget(
+ config: PortfolioConfig,
+ target: DeployTarget
+): DeployResult {
+ switch (target.type) {
+ case "local":
+ return deployLocal(config, target);
+ default: {
+ const exhaustive: never = target.type;
+ throw new Error(`Unknown deploy adapter "${exhaustive}".`);
+ }
+ }
+}
+
+export function deployAll(config: PortfolioConfig): DeployResult[] {
+ return config.deploy.targets.map((target) => deployToTarget(config, target));
+}
diff --git a/src/deploy/local.ts b/src/deploy/local.ts
new file mode 100644
index 0000000..2c38dea
--- /dev/null
+++ b/src/deploy/local.ts
@@ -0,0 +1,61 @@
+import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
+import { isAbsolute, join, relative, resolve } from "node:path";
+import { openDatabase } from "../db/client.js";
+import type { DeployTarget, PortfolioConfig } from "../types.js";
+
+export interface DeployResult {
+ targetName: string;
+ targetPath: string;
+ files: number;
+}
+
+export function isPathInside(parent: string, child: string): boolean {
+ const rel = relative(parent, child);
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
+}
+
+function countFiles(targetPath: string): number {
+ let total = 0;
+ const walk = (current: string): void => {
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
+ if (entry.isDirectory()) {
+ walk(join(current, entry.name));
+ } else {
+ total++;
+ }
+ }
+ };
+ walk(targetPath);
+ return total;
+}
+
+export function deployLocal(
+ config: PortfolioConfig,
+ target: DeployTarget
+): DeployResult {
+ const indexPath = join(config.outputDir, "index.html");
+ if (!existsSync(indexPath)) {
+ throw new Error(`No published site found in "${config.outputDir}". Run publish first.`);
+ }
+
+ const outputRoot = resolve(config.outputDir);
+ const targetRoot = resolve(target.target);
+ if (isPathInside(outputRoot, targetRoot)) {
+ throw new Error(
+ `Deploy target "${target.name}" must be outside the output directory "${config.outputDir}".`
+ );
+ }
+
+ mkdirSync(target.target, { recursive: true });
+ cpSync(config.outputDir, target.target, { recursive: true });
+ const files = countFiles(target.target);
+
+ const db = openDatabase(config.dataDir, config.clock);
+ try {
+ db.logIngest("deploy", `${target.name} -> ${target.target}`);
+ } finally {
+ db.close();
+ }
+
+ return { targetName: target.name, targetPath: target.target, files };
+}
diff --git a/src/index.ts b/src/index.ts
index aea5c27..71fe942 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,13 +10,15 @@ import { openDatabase } from "./db/client.js";
import { DEFAULT_CONFIG, type PortfolioConfig } from "./types.js";
import { DEFAULT_CONFIG_PATH, loadPortfolioConfig } from "./config/loader.js";
import { refreshPortfolio } from "./refresh/run.js";
+import { listBuiltinThemes } from "./theme/palette.js";
+import { deployAll } from "./deploy/index.js";
const program = new Command();
program
.name("engineer-profile")
.description("Build a local engineering portfolio from public repository evidence")
- .version("0.2.0");
+ .version("0.3.0");
function resolveConfig(options: { config?: string; data?: string; output?: string }): PortfolioConfig {
const base = options.config
@@ -67,6 +69,9 @@ addConfigOption(program
const copied = copyScreenshotsToOutput(config);
console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`);
console.log(`Copied ${copied} available preview screenshots.`);
+ for (const deployed of deployAll(config)) {
+ console.log(`Deployed ${deployed.targetName}: ${deployed.files} files to ${deployed.targetPath}.`);
+ }
console.log("Open output/index.html in a browser.");
}));
@@ -135,7 +140,7 @@ addConfigOption(program
addConfigOption(program
.command("refresh")
- .description("Ingest, capture, and publish from the checked-in configuration")
+ .description("Ingest, capture, publish, and deploy from the checked-in configuration")
.option("-d, --data
", "Data directory")
.option("-o, --output ", "Output directory")
.action(async (options) => {
@@ -149,6 +154,41 @@ addConfigOption(program
for (const error of result.captureErrors) {
console.warn(`Skipped ${error.slug}: ${error.message}`);
}
+ for (const deployed of result.deployed) {
+ console.log(`Deployed ${deployed.targetName}: ${deployed.files} files to ${deployed.targetPath}.`);
+ }
+ }));
+
+program
+ .command("themes")
+ .description("List built-in presentation themes")
+ .action(() => {
+ const themes = listBuiltinThemes();
+ console.log(`Built-in themes: ${themes.length}`);
+ for (const theme of themes) {
+ console.log(` ${theme.name}: ${theme.description}`);
+ }
+ });
+
+addConfigOption(program
+ .command("deploy")
+ .description("Publish the snapshot and copy it to configured deploy targets")
+ .option("-d, --data ", "Data directory")
+ .option("-o, --output ", "Output directory")
+ .action((options) => {
+ const config = resolveConfig(options);
+ mkdirSync(config.dataDir, { recursive: true });
+ const result = publishSite(config);
+ const copied = copyScreenshotsToOutput(config);
+ console.log(`Published ${result.projectCount} projects to ${result.indexPath}.`);
+ console.log(`Copied ${copied} available preview screenshots.`);
+ const deployed = deployAll(config);
+ if (deployed.length === 0) {
+ console.log('No deploy targets configured. Add a "deploy.targets" entry to the configuration.');
+ }
+ for (const item of deployed) {
+ console.log(`Deployed ${item.targetName}: ${item.files} files to ${item.targetPath}.`);
+ }
}));
addConfigOption(program
diff --git a/src/ingest/github.ts b/src/ingest/github.ts
index 6ed0fa7..4b1ee2d 100644
--- a/src/ingest/github.ts
+++ b/src/ingest/github.ts
@@ -15,7 +15,7 @@ export class GitHubClient {
private headers(): Record {
const headers: Record = {
Accept: "application/vnd.github+json",
- "User-Agent": "engineer-profile/0.1.0",
+ "User-Agent": "engineer-profile/0.3.0",
};
if (this.token) headers.Authorization = `Bearer ${this.token}`;
return headers;
diff --git a/src/publish/site.ts b/src/publish/site.ts
index d382163..d7db10e 100644
--- a/src/publish/site.ts
+++ b/src/publish/site.ts
@@ -1,7 +1,8 @@
-import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { formatChangelogMarkdown } from "../changelog/generator.js";
-import { openDatabase } from "../db/client.js";
+import { openDatabase, type PortfolioDatabase } from "../db/client.js";
+import { resolveTheme, themeVariables, type ThemeTokens } from "../theme/palette.js";
import type { ChangelogEntry, PortfolioConfig, ProjectRecord } from "../types.js";
function escapeHtml(text: string): string {
@@ -124,33 +125,18 @@ function projectCard(
`;
}
-function siteCss(): string {
+function siteCss(theme: ThemeTokens): string {
return `
-:root {
- color-scheme: dark;
- --ink: #08111f;
- --ink-soft: #0d1a2d;
- --panel: #112139;
- --panel-strong: #172a46;
- --line: rgba(169, 195, 222, 0.18);
- --text: #f3f7fb;
- --muted: #9db0c7;
- --blue: #67b7ff;
- --blue-soft: #b8dcff;
- --mint: #a7f3d0;
- --orange: #ffb86b;
- --shadow: 0 24px 60px rgba(0, 0, 0, 0.24);
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-}
+${themeVariables(theme)}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
-body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; }
+body { margin: 0; min-width: 320px; background: var(--ink); color: var(--text); line-height: 1.5; font-family: var(--font); }
a { color: inherit; }
-.site-shell { min-height: 100vh; background: radial-gradient(circle at 82% -10%, rgba(70, 148, 232, 0.2), transparent 34rem), var(--ink); }
+.site-shell { min-height: 100vh; background: var(--glow), var(--ink-soft); }
.container { width: min(1180px, calc(100% - 48px)); margin: 0 auto; }
.site-nav { display: flex; justify-content: space-between; align-items: center; padding: 28px 0; border-bottom: 1px solid var(--line); }
.brand { display: inline-flex; align-items: center; gap: 12px; font: 700 0.9rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; }
-.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--blue); color: var(--blue); border-radius: 8px; font-size: 0.72rem; }
+.brand-mark { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--blue); color: var(--blue); border-radius: var(--radius); font-size: 0.72rem; }
.nav-link { color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-decoration: none; text-transform: uppercase; }
.nav-link:hover, .text-link:hover, h2 a:hover { color: var(--blue); }
.hero { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 80px; align-items: end; padding: 86px 0 70px; }
@@ -158,7 +144,7 @@ a { color: inherit; }
.kicker { color: var(--mint); margin: 0 0 22px; }
h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-weight: 650; letter-spacing: -0.08em; line-height: 0.94; }
.hero-copy { max-width: 530px; margin: 28px 0 0; color: var(--blue-soft); font-size: 1.12rem; }
-.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72)); box-shadow: var(--shadow); }
+.hero-aside { padding: 22px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--aside-gradient); box-shadow: var(--shadow); }
.aside-index { display: flex; justify-content: space-between; color: var(--orange); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.12em; text-transform: uppercase; }
.hero-aside p { margin: 24px 0 4px; color: var(--text); font-size: 1rem; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
@@ -166,19 +152,19 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we
.stat:last-child { border-right: 0; }
.stat strong { display: block; color: var(--text); font-size: 1.8rem; font-weight: 600; letter-spacing: -0.04em; }
.stat span { color: var(--muted); font: 0.7rem/1.3 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; }
-.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 10px; background: rgba(17, 33, 57, 0.66); }
+.audit-panel { display: grid; grid-template-columns: 1fr auto; gap: 20px; align-items: center; margin: 26px 0 80px; padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--audit-bg); }
.audit-copy { color: var(--muted); font-size: 0.88rem; }
.audit-copy strong { color: var(--text); font-weight: 500; }
.audit-time { color: var(--blue); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; text-align: right; }
.index-header { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 24px; }
.index-header h2 { margin: 0; font-size: 2rem; font-weight: 550; letter-spacing: -0.05em; }
.index-header p { max-width: 350px; margin: 0; color: var(--muted); font-size: 0.88rem; text-align: right; }
-.project-card { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); overflow: hidden; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 16px; background: linear-gradient(135deg, rgba(23, 42, 70, 0.98), rgba(13, 26, 45, 0.96)); box-shadow: var(--shadow); }
-.project-visual { position: relative; min-height: 310px; background: #0a1525; }
+.project-card { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); overflow: hidden; margin-bottom: 24px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel-gradient); box-shadow: var(--shadow); }
+.project-visual { position: relative; min-height: 310px; background: var(--visual); }
.screenshot { display: block; width: 100%; height: 100%; min-height: 310px; object-fit: cover; opacity: 0.9; }
-.placeholder { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--blue); background: repeating-linear-gradient(135deg, rgba(103, 183, 255, 0.05), rgba(103, 183, 255, 0.05) 1px, transparent 1px, transparent 14px); }
+.placeholder { display: flex; min-height: 310px; align-items: center; justify-content: center; flex-direction: column; gap: 7px; color: var(--blue); background: repeating-linear-gradient(135deg, var(--stripe), var(--stripe) 1px, transparent 1px, transparent 14px); }
.placeholder small { color: var(--muted); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; text-transform: uppercase; }
-.visual-label { position: absolute; right: 16px; bottom: 16px; padding: 7px 9px; border: 1px solid rgba(255,255,255,0.18); border-radius: 5px; background: rgba(8, 17, 31, 0.72); color: var(--blue-soft); }
+.visual-label { position: absolute; right: 16px; bottom: 16px; padding: 7px 9px; border: 1px solid var(--label-border); border-radius: calc(var(--radius) / 2); background: var(--label-bg); color: var(--blue-soft); }
.project-body { padding: 30px 34px 32px; }
.card-topline { display: flex; justify-content: space-between; gap: 12px; color: var(--blue); }
.project-body h2 { margin: 18px 0 8px; font-size: 2.1rem; font-weight: 560; letter-spacing: -0.06em; }
@@ -187,8 +173,8 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we
.facts { display: flex; flex-wrap: wrap; gap: 22px; margin: 24px 0 16px; color: var(--muted); font: 0.76rem/1 "SFMono-Regular", Consolas, monospace; }
.facts strong { color: var(--text); font-size: 1rem; font-weight: 600; }
.tags { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 26px; }
-.tag { padding: 5px 9px; border: 1px solid rgba(167, 243, 208, 0.26); border-radius: 999px; color: var(--mint); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; }
-.change-log { padding: 18px 0 20px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
+.tag { padding: 5px 9px; border: 1px solid var(--tag-border); border-radius: 999px; color: var(--mint); background: var(--tag-bg); font: 0.68rem/1 "SFMono-Regular", Consolas, monospace; }
+.change-log { padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel-strong); }
.section-heading { display: flex; justify-content: space-between; color: var(--orange); }
.source-badge { color: var(--muted); }
.change-log h3 { margin: 12px 0 2px; font-size: 1.08rem; font-weight: 550; }
@@ -197,20 +183,20 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we
.change-preview h3, .change-preview h4 { margin: 12px 0 4px; color: var(--text); font-size: 0.78rem; font-weight: 600; }
.change-preview p { margin: 3px 0; }
.change-preview li { margin: 3px 0 3px 18px; }
-.change-preview code { padding: 2px 4px; border-radius: 3px; color: var(--mint); background: rgba(167, 243, 208, 0.08); font: 0.76rem "SFMono-Regular", Consolas, monospace; }
+.change-preview code { padding: 2px 4px; border-radius: 3px; color: var(--mint); background: var(--code-bg); font: 0.76rem "SFMono-Regular", Consolas, monospace; }
.text-link { display: inline-block; margin-top: 14px; color: var(--blue); font: 0.73rem/1 "SFMono-Regular", Consolas, monospace; text-decoration: none; text-transform: uppercase; }
.card-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; }
-.button { display: inline-block; padding: 10px 14px; border-radius: 7px; font: 0.72rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.04em; text-decoration: none; text-transform: uppercase; }
-.button.primary { background: var(--blue); color: var(--ink); }
+.button { display: inline-block; padding: 10px 14px; border-radius: var(--radius); font: 0.72rem/1 "SFMono-Regular", Consolas, monospace; letter-spacing: 0.04em; text-decoration: none; text-transform: uppercase; }
+.button.primary { background: var(--blue); color: var(--button-text); }
.button.primary:hover { background: var(--blue-soft); }
.button.secondary { border: 1px solid var(--line); color: var(--text); }
.button.secondary:hover { border-color: var(--blue); color: var(--blue); }
.muted { color: var(--muted); }
-.source-trail { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 36px; margin: 80px 0; padding: 26px 0; border-top: 1px solid var(--line); }
+.source-trail { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 36px; margin: 80px 0; padding: 26px 28px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.source-trail h2 { margin: 0 0 8px; font-size: 1.2rem; font-weight: 550; }
.source-trail p { margin: 0; color: var(--muted); font-size: 0.86rem; }
.audit-list { margin: 0; padding: 0; list-style: none; }
-.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid rgba(169, 195, 222, 0.1); color: var(--muted); font: 0.72rem/1.4 "SFMono-Regular", Consolas, monospace; }
+.audit-list li { display: grid; grid-template-columns: 150px 72px 1fr; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line-soft); color: var(--muted); font: 0.72rem/1.4 "SFMono-Regular", Consolas, monospace; }
.audit-list time { color: var(--blue); }
.audit-list strong { color: var(--orange); font-weight: 500; text-transform: uppercase; }
.site-footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 40px; border-top: 1px solid var(--line); color: var(--muted); font: 0.7rem/1.4 "SFMono-Regular", Consolas, monospace; }
@@ -239,10 +225,60 @@ h1 { max-width: 760px; margin: 0; font-size: clamp(3.2rem, 8vw, 6.4rem); font-we
`;
}
+export interface SiteManifestProject {
+ slug: string;
+ name: string;
+ url: string;
+ changelogFile: string | null;
+}
+
+export interface SiteManifest {
+ formatVersion: 1;
+ generatedAt: string;
+ title: string;
+ owner: string;
+ theme: string;
+ projectCount: number;
+ projects: SiteManifestProject[];
+ files: string[];
+ screenshots: string[];
+}
+
export interface PublishResult {
indexPath: string;
+ manifestPath: string;
projectCount: number;
generatedAt: string;
+ theme: string;
+}
+
+function relativePosixPaths(outputDir: string): string[] {
+ const paths: string[] = [];
+ const walk = (current: string, prefix: string): void => {
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ walk(join(current, entry.name), relative);
+ } else {
+ paths.push(relative);
+ }
+ }
+ };
+ walk(outputDir, "");
+ return paths.sort();
+}
+
+function copyScreenshotAssets(config: PortfolioConfig, db: PortfolioDatabase): number {
+ const assetsDir = join(config.outputDir, "assets", "screenshots");
+ mkdirSync(assetsDir, { recursive: true });
+
+ let available = 0;
+ for (const project of db.listProjects(true)) {
+ if (!project.screenshot_path || !existsSync(project.screenshot_path)) continue;
+ cpSync(project.screenshot_path, join(assetsDir, `${project.slug}.png`));
+ available++;
+ }
+ return available;
}
export function publishSite(config: PortfolioConfig): PublishResult {
@@ -250,6 +286,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {
try {
const projects = db.listProjects(true);
const generatedAt = config.clock();
+ const theme = resolveTheme(config.theme);
const views = projects.map((project) => ({
project,
changelog: db.getChangelog(project.id),
@@ -279,7 +316,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {
${escapeHtml(config.title)} / Portfolio
-
+
@@ -295,7 +332,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {
${escapeHtml(config.tagline)}. Each project stays connected to its repository, commits, releases, and preview.
@@ -315,7 +352,7 @@ export function publishSite(config: PortfolioConfig): PublishResult {
${cards}
- Audit / 03
Refresh trail
EngineerProfile records each ingest, capture, and publish operation locally.
+ Audit / 03
Refresh trail
EngineerProfile records each ingest, capture, publish, and deploy operation locally.
${auditItems}
@@ -344,27 +381,46 @@ export function publishSite(config: PortfolioConfig): PublishResult {
writeFileSync(join(config.outputDir, `${view.project.slug}-changelog.md`), markdown, "utf-8");
}
+ copyScreenshotAssets(config, db);
+
+ const publishedFiles = relativePosixPaths(config.outputDir);
+ const manifest: SiteManifest = {
+ formatVersion: 1,
+ generatedAt,
+ title: config.title,
+ owner: config.owner,
+ theme: theme.name,
+ projectCount: projects.length,
+ projects: views.map((view) => ({
+ slug: view.project.slug,
+ name: view.project.name,
+ url: view.project.url,
+ changelogFile: view.changelog.length > 0 ? `${view.project.slug}-changelog.md` : null,
+ })),
+ files: [...new Set([...publishedFiles, "site-manifest.json"])].sort(),
+ screenshots: publishedFiles.filter((file) => file.startsWith("assets/screenshots/")),
+ };
+ const manifestPath = join(config.outputDir, "site-manifest.json");
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
+
db.logIngest("publish", `${projects.length} projects -> ${indexPath}`);
- return { indexPath, projectCount: projects.length, generatedAt };
+ return {
+ indexPath,
+ manifestPath,
+ projectCount: projects.length,
+ generatedAt,
+ theme: theme.name,
+ };
} finally {
db.close();
}
}
export function copyScreenshotsToOutput(config: PortfolioConfig): number {
- const assetsDir = join(config.outputDir, "assets", "screenshots");
- mkdirSync(assetsDir, { recursive: true });
-
const db = openDatabase(config.dataDir, config.clock);
- let copied = 0;
try {
- for (const project of db.listProjects(true)) {
- if (!project.screenshot_path || !existsSync(project.screenshot_path)) continue;
- cpSync(project.screenshot_path, join(assetsDir, `${project.slug}.png`));
- copied++;
- }
+ return copyScreenshotAssets(config, db);
} finally {
db.close();
}
- return copied;
-}
\ No newline at end of file
+}
diff --git a/src/refresh/run.ts b/src/refresh/run.ts
index acc9e53..86274ee 100644
--- a/src/refresh/run.ts
+++ b/src/refresh/run.ts
@@ -1,6 +1,7 @@
import { ingestOwnerRepos } from "../ingest/orchestrator.js";
import { captureAllProjects } from "../preview/capture.js";
import { copyScreenshotsToOutput, publishSite, type PublishResult } from "../publish/site.js";
+import { deployAll, type DeployResult } from "../deploy/index.js";
import type { FixtureData } from "../ingest/orchestrator.js";
import { DEFAULT_CONFIG, type PortfolioConfig } from "../types.js";
@@ -15,6 +16,7 @@ export interface RefreshResult {
copiedScreenshots: number;
captureErrors: Array<{ slug: string; message: string }>;
published: PublishResult;
+ deployed: DeployResult[];
}
export async function refreshPortfolio(
@@ -36,6 +38,7 @@ export async function refreshPortfolio(
(slug, error) => captureErrors.push({ slug, message: error.message })
);
const published = publishSite(config);
+ const deployed = deployAll(config);
return {
ingested: ingested.length,
@@ -43,5 +46,6 @@ export async function refreshPortfolio(
copiedScreenshots: copyScreenshotsToOutput(config),
captureErrors,
published,
+ deployed,
};
}
diff --git a/src/theme/index.ts b/src/theme/index.ts
new file mode 100644
index 0000000..b2fbf33
--- /dev/null
+++ b/src/theme/index.ts
@@ -0,0 +1,8 @@
+export {
+ isBuiltinTheme,
+ isValidHexColor,
+ listBuiltinThemes,
+ resolveTheme,
+ themeVariables,
+} from "./palette.js";
+export type { ThemeCatalogEntry, ThemeMode, ThemeTokens } from "./palette.js";
diff --git a/src/theme/palette.ts b/src/theme/palette.ts
new file mode 100644
index 0000000..e18349e
--- /dev/null
+++ b/src/theme/palette.ts
@@ -0,0 +1,266 @@
+import { DEFAULT_THEME, type ThemeConfig } from "../types.js";
+
+export type ThemeMode = "dark" | "light";
+
+export interface ThemeTokens {
+ name: string;
+ mode: ThemeMode;
+ ink: string;
+ inkSoft: string;
+ panel: string;
+ panelStrong: string;
+ line: string;
+ lineSoft: string;
+ text: string;
+ muted: string;
+ blue: string;
+ blueSoft: string;
+ buttonText: string;
+ mint: string;
+ orange: string;
+ shadow: string;
+ visual: string;
+ glow: string;
+ panelGradient: string;
+ asideGradient: string;
+ labelBg: string;
+ labelBorder: string;
+ auditBg: string;
+ codeBg: string;
+ tagBg: string;
+ tagBorder: string;
+ stripe: string;
+ radius: string;
+ font: string;
+}
+
+interface BuiltinPalette extends Omit {
+ mode: ThemeMode;
+}
+
+export interface ThemeCatalogEntry {
+ name: string;
+ description: string;
+}
+
+const SANS_FONT =
+ 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
+const MONO_FONT = 'ui-monospace, "Cascadia Mono", Consolas, "SFMono-Regular", Menlo, monospace';
+
+const BUILTIN_PALETTES: Record = {
+ "deep-space": {
+ mode: "dark",
+ ink: "#08111f",
+ inkSoft: "#0d1a2d",
+ panel: "#112139",
+ panelStrong: "#172a46",
+ line: "rgba(169, 195, 222, 0.18)",
+ lineSoft: "rgba(169, 195, 222, 0.1)",
+ text: "#f3f7fb",
+ muted: "#9db0c7",
+ blue: "#67b7ff",
+ blueSoft: "#b8dcff",
+ buttonText: "#08111f",
+ mint: "#a7f3d0",
+ orange: "#ffb86b",
+ shadow: "0 24px 60px rgba(0, 0, 0, 0.24)",
+ visual: "#0a1525",
+ glow: "radial-gradient(circle at 82% -10%, rgba(70, 148, 232, 0.2), transparent 34rem)",
+ panelGradient: "linear-gradient(135deg, rgba(23, 42, 70, 0.98), rgba(13, 26, 45, 0.96))",
+ asideGradient: "linear-gradient(145deg, rgba(23, 42, 70, 0.92), rgba(13, 26, 45, 0.72))",
+ labelBg: "rgba(8, 17, 31, 0.72)",
+ labelBorder: "rgba(255, 255, 255, 0.18)",
+ auditBg: "rgba(17, 33, 57, 0.66)",
+ codeBg: "rgba(167, 243, 208, 0.08)",
+ tagBg: "rgba(167, 243, 208, 0.06)",
+ tagBorder: "rgba(167, 243, 208, 0.26)",
+ stripe: "rgba(103, 183, 255, 0.05)",
+ radius: "16px",
+ font: SANS_FONT,
+ },
+ paper: {
+ mode: "light",
+ ink: "#f6f8fb",
+ inkSoft: "#eef1f6",
+ panel: "#ffffff",
+ panelStrong: "#e9eef5",
+ line: "rgba(15, 23, 42, 0.14)",
+ lineSoft: "rgba(15, 23, 42, 0.08)",
+ text: "#0f172a",
+ muted: "#5b6b82",
+ blue: "#0f6bbd",
+ blueSoft: "#1d4ed8",
+ buttonText: "#ffffff",
+ mint: "#0f766e",
+ orange: "#b45309",
+ shadow: "0 24px 60px rgba(15, 23, 42, 0.14)",
+ visual: "#e3e9f2",
+ glow: "radial-gradient(circle at 82% -10%, rgba(15, 107, 189, 0.14), transparent 34rem)",
+ panelGradient: "linear-gradient(135deg, #ffffff, #eef2f8)",
+ asideGradient: "linear-gradient(145deg, #ffffff, #edf1f6)",
+ labelBg: "rgba(255, 255, 255, 0.8)",
+ labelBorder: "rgba(15, 23, 42, 0.12)",
+ auditBg: "rgba(255, 255, 255, 0.72)",
+ codeBg: "rgba(15, 118, 110, 0.08)",
+ tagBg: "rgba(15, 118, 110, 0.06)",
+ tagBorder: "rgba(15, 118, 110, 0.24)",
+ stripe: "rgba(15, 107, 189, 0.08)",
+ radius: "16px",
+ font: SANS_FONT,
+ },
+ terminal: {
+ mode: "dark",
+ ink: "#070b09",
+ inkSoft: "#0b120d",
+ panel: "#0f1610",
+ panelStrong: "#141e16",
+ line: "rgba(154, 230, 170, 0.16)",
+ lineSoft: "rgba(154, 230, 170, 0.08)",
+ text: "#d8ffe0",
+ muted: "#8aa395",
+ blue: "#3fd95e",
+ blueSoft: "#9cf0ae",
+ buttonText: "#04100a",
+ mint: "#a7f3b0",
+ orange: "#ffb86b",
+ shadow: "0 24px 60px rgba(0, 0, 0, 0.34)",
+ visual: "#050a07",
+ glow: "radial-gradient(circle at 82% -10%, rgba(63, 217, 94, 0.16), transparent 34rem)",
+ panelGradient: "linear-gradient(135deg, rgba(20, 30, 22, 0.98), rgba(11, 18, 13, 0.96))",
+ asideGradient: "linear-gradient(145deg, rgba(20, 30, 22, 0.92), rgba(11, 18, 13, 0.76))",
+ labelBg: "rgba(7, 11, 9, 0.78)",
+ labelBorder: "rgba(216, 255, 224, 0.2)",
+ auditBg: "rgba(15, 22, 16, 0.7)",
+ codeBg: "rgba(63, 217, 94, 0.1)",
+ tagBg: "rgba(63, 217, 94, 0.08)",
+ tagBorder: "rgba(154, 230, 170, 0.3)",
+ stripe: "rgba(63, 217, 94, 0.06)",
+ radius: "4px",
+ font: MONO_FONT,
+ },
+};
+
+const BUILTIN_DESCRIPTIONS: Record = {
+ "deep-space": "Dark palette with blue accents. Default.",
+ paper: "Light palette with dark text and high contrast.",
+ terminal: "Dark palette with green phosphor text.",
+};
+
+export function listBuiltinThemes(): ThemeCatalogEntry[] {
+ return Object.keys(BUILTIN_PALETTES).map((name) => ({
+ name,
+ description: BUILTIN_DESCRIPTIONS[name] ?? "Built-in theme.",
+ }));
+}
+
+export function isBuiltinTheme(name: string): boolean {
+ return name in BUILTIN_PALETTES;
+}
+
+const HEX_COLOR_RE = /^#?[0-9a-f]{3}([0-9a-f]{3})?$/i;
+
+export function isValidHexColor(value: string): boolean {
+ return HEX_COLOR_RE.test(value.trim());
+}
+
+function normalizeHexColor(value: string): string {
+ let hex = value.trim().replace(/^#/, "").toLowerCase();
+ if (hex.length === 3) {
+ hex = hex
+ .split("")
+ .map((channel) => channel + channel)
+ .join("");
+ }
+ return `#${hex}`;
+}
+
+function hexToRgb(hex: string): [number, number, number] | null {
+ const match = /^[0-9a-f]{6}$/i.exec(hex.replace(/^#/, ""));
+ if (!match) return null;
+ const value = Number.parseInt(match[0], 16);
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
+}
+
+function rgbToHex(rgb: [number, number, number]): string {
+ return `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+}
+
+function mixHex(source: string, target: string, amount: number): string {
+ const from = hexToRgb(source) ?? [0, 0, 0];
+ const to = hexToRgb(target) ?? [255, 255, 255];
+ const mixed = from.map((channel, index) =>
+ Math.round(channel + (to[index] - channel) * amount)
+ ) as [number, number, number];
+ return rgbToHex(mixed);
+}
+
+function rgbaFromHex(hex: string, alpha: number): string {
+ const [red, green, blue] = hexToRgb(hex) ?? [0, 0, 0];
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
+}
+
+function glowFromAccent(accent: string, mode: ThemeMode): string {
+ const alpha = mode === "dark" ? 0.2 : 0.14;
+ return `radial-gradient(circle at 82% -10%, ${rgbaFromHex(accent, alpha)}, transparent 34rem)`;
+}
+
+function stripeFromAccent(accent: string, mode: ThemeMode): string {
+ const alpha = mode === "dark" ? 0.05 : 0.08;
+ return rgbaFromHex(accent, alpha);
+}
+
+export function resolveTheme(config?: ThemeConfig): ThemeTokens {
+ const requestedName = config?.name ?? DEFAULT_THEME.name;
+ const baseName = isBuiltinTheme(requestedName) ? requestedName : DEFAULT_THEME.name;
+ const base = BUILTIN_PALETTES[baseName];
+
+ const tokens: ThemeTokens = {
+ ...base,
+ name: baseName,
+ };
+
+ if (config?.accent) {
+ const accent = normalizeHexColor(config.accent);
+ tokens.blue = accent;
+ tokens.blueSoft = mixHex(accent, base.mode === "dark" ? "#ffffff" : "#000000", 0.5);
+ tokens.glow = glowFromAccent(accent, base.mode);
+ tokens.stripe = stripeFromAccent(accent, base.mode);
+ }
+ if (config?.radius) tokens.radius = config.radius;
+ if (config?.font) tokens.font = config.font;
+
+ return tokens;
+}
+
+export function themeVariables(tokens: ThemeTokens): string {
+ return `:root {
+ color-scheme: ${tokens.mode};
+ --ink: ${tokens.ink};
+ --ink-soft: ${tokens.inkSoft};
+ --panel: ${tokens.panel};
+ --panel-strong: ${tokens.panelStrong};
+ --line: ${tokens.line};
+ --line-soft: ${tokens.lineSoft};
+ --text: ${tokens.text};
+ --muted: ${tokens.muted};
+ --blue: ${tokens.blue};
+ --blue-soft: ${tokens.blueSoft};
+ --button-text: ${tokens.buttonText};
+ --mint: ${tokens.mint};
+ --orange: ${tokens.orange};
+ --shadow: ${tokens.shadow};
+ --visual: ${tokens.visual};
+ --glow: ${tokens.glow};
+ --panel-gradient: ${tokens.panelGradient};
+ --aside-gradient: ${tokens.asideGradient};
+ --label-bg: ${tokens.labelBg};
+ --label-border: ${tokens.labelBorder};
+ --audit-bg: ${tokens.auditBg};
+ --code-bg: ${tokens.codeBg};
+ --tag-bg: ${tokens.tagBg};
+ --tag-border: ${tokens.tagBorder};
+ --stripe: ${tokens.stripe};
+ --radius: ${tokens.radius};
+ --font: ${tokens.font};
+}`;
+}
diff --git a/src/types.ts b/src/types.ts
index 92c30da..d329913 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -78,6 +78,23 @@ export interface PrivacyConfig {
maxCommitsPerProject: number;
}
+export interface ThemeConfig {
+ name: string;
+ accent?: string;
+ radius?: string;
+ font?: string;
+}
+
+export interface DeployTarget {
+ name: string;
+ type: "local";
+ target: string;
+}
+
+export interface DeployConfig {
+ targets: DeployTarget[];
+}
+
export interface PortfolioConfig {
owner: string;
title: string;
@@ -86,6 +103,8 @@ export interface PortfolioConfig {
dataDir: string;
outputDir: string;
privacy: PrivacyConfig;
+ theme: ThemeConfig;
+ deploy: DeployConfig;
clock: () => string;
}
@@ -95,6 +114,14 @@ export const DEFAULT_PRIVACY: PrivacyConfig = {
maxCommitsPerProject: 50,
};
+export const DEFAULT_THEME: ThemeConfig = {
+ name: "deep-space",
+};
+
+export const DEFAULT_DEPLOY: DeployConfig = {
+ targets: [],
+};
+
export const DEFAULT_CONFIG = {
owner: "demo-engineer",
title: "EngineerProfile",
@@ -103,5 +130,7 @@ export const DEFAULT_CONFIG = {
dataDir: "data",
outputDir: "output",
privacy: DEFAULT_PRIVACY,
+ theme: DEFAULT_THEME,
+ deploy: DEFAULT_DEPLOY,
clock: () => new Date().toISOString(),
} satisfies PortfolioConfig;
diff --git a/tests/config.test.ts b/tests/config.test.ts
index 44e2720..7d034ad 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -37,4 +37,63 @@ describe("portfolio configuration", () => {
'Configuration field "repositoryLimit" must be an integer from 1 to 100.'
);
});
+
+ it("loads theme and deploy settings", () => {
+ mkdirSync(TEST_DIR, { recursive: true });
+ writeFileSync(TEST_FILE, JSON.stringify({
+ theme: { name: "paper", accent: "#0f6bbd" },
+ deploy: {
+ targets: [
+ { name: "public", type: "local", target: "deploy/site" },
+ ],
+ },
+ }));
+
+ const config = loadPortfolioConfig(TEST_FILE);
+
+ expect(config.theme.name).toBe("paper");
+ expect(config.theme.accent).toBe("#0f6bbd");
+ expect(config.deploy.targets).toHaveLength(1);
+ expect(config.deploy.targets[0].name).toBe("public");
+ expect(config.deploy.targets[0].target).toBe("deploy/site");
+ });
+
+ it("applies the default theme and deploy settings when omitted", () => {
+ mkdirSync(TEST_DIR, { recursive: true });
+ writeFileSync(TEST_FILE, JSON.stringify({ owner: "owner" }));
+
+ const config = loadPortfolioConfig(TEST_FILE);
+
+ expect(config.theme.name).toBe("deep-space");
+ expect(config.deploy.targets).toEqual([]);
+ });
+
+ it("rejects unknown theme names", () => {
+ mkdirSync(TEST_DIR, { recursive: true });
+ writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "vaporwave" } }));
+
+ expect(() => loadPortfolioConfig(TEST_FILE)).toThrow(
+ /Configuration field "theme.name" must be one of:/
+ );
+ });
+
+ it("rejects invalid accent colors", () => {
+ mkdirSync(TEST_DIR, { recursive: true });
+ writeFileSync(TEST_FILE, JSON.stringify({ theme: { name: "paper", accent: "not-a-color" } }));
+
+ expect(() => loadPortfolioConfig(TEST_FILE)).toThrow(
+ /Configuration field "theme.accent" must be a hex color/
+ );
+ });
+
+ it("rejects deploy targets with an unknown adapter", () => {
+ mkdirSync(TEST_DIR, { recursive: true });
+ writeFileSync(TEST_FILE, JSON.stringify({
+ deploy: { targets: [{ name: "netlify", type: "ftp", target: "x" }] },
+ }));
+
+ expect(() => loadPortfolioConfig(TEST_FILE)).toThrow(
+ /deploy.targets\[0\]\.type" must be "local"/
+ );
+ });
});
diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts
new file mode 100644
index 0000000..151c408
--- /dev/null
+++ b/tests/deploy.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { deployAll, deployToTarget } from "../src/deploy/index.js";
+import { isPathInside } from "../src/deploy/local.js";
+import { openDatabase } from "../src/db/client.js";
+import { DEFAULT_CONFIG } from "../src/types.js";
+
+const TEST_DATA = join("data", "test-deploy");
+const TEST_OUTPUT = join("output", "test-deploy");
+const TEST_TARGET = join("deploy", "test-deploy-target");
+
+function publishedConfig(target = TEST_TARGET) {
+ return {
+ ...DEFAULT_CONFIG,
+ dataDir: TEST_DATA,
+ outputDir: TEST_OUTPUT,
+ deploy: {
+ targets: [{ name: "public", type: "local" as const, target }],
+ },
+ clock: () => "2026-07-31T00:00:00.000Z",
+ };
+}
+
+function writePublishedSite() {
+ mkdirSync(join(TEST_OUTPUT, "assets", "screenshots"), { recursive: true });
+ writeFileSync(join(TEST_OUTPUT, "index.html"), "portfolio", "utf-8");
+ writeFileSync(join(TEST_OUTPUT, "site-manifest.json"), "{}", "utf-8");
+}
+
+describe("deploy adapters", () => {
+ beforeEach(() => {
+ rmSync(TEST_DATA, { recursive: true, force: true });
+ rmSync(TEST_OUTPUT, { recursive: true, force: true });
+ rmSync(TEST_TARGET, { recursive: true, force: true });
+ });
+
+ afterEach(() => {
+ rmSync(TEST_DATA, { recursive: true, force: true });
+ rmSync(TEST_OUTPUT, { recursive: true, force: true });
+ rmSync(TEST_TARGET, { recursive: true, force: true });
+ });
+
+ it("copies the published site into a local target", () => {
+ writePublishedSite();
+ const result = deployAll(publishedConfig());
+
+ expect(result).toHaveLength(1);
+ expect(result[0].targetName).toBe("public");
+ expect(result[0].targetPath).toBe(TEST_TARGET);
+ expect(result[0].files).toBeGreaterThanOrEqual(2);
+ expect(existsSync(join(TEST_TARGET, "index.html"))).toBe(true);
+ expect(existsSync(join(TEST_TARGET, "site-manifest.json"))).toBe(true);
+ });
+
+ it("reports a publish-first error when output is missing", () => {
+ const config = publishedConfig();
+ expect(() => deployToTarget(config, config.deploy.targets[0])).toThrow(
+ /No published site found/
+ );
+ });
+
+ it("rejects a target inside the output directory", () => {
+ writePublishedSite();
+ const config = publishedConfig(join(TEST_OUTPUT, "nested"));
+ expect(() => deployAll(config)).toThrow(/must be outside the output directory/);
+ });
+
+ it("returns an empty result list without targets", () => {
+ writePublishedSite();
+ const config = { ...publishedConfig(), deploy: { targets: [] } };
+ expect(deployAll(config)).toEqual([]);
+ });
+
+ it("records a deploy audit entry", () => {
+ writePublishedSite();
+ deployAll(publishedConfig());
+
+ const db = openDatabase(TEST_DATA, publishedConfig().clock);
+ const log = db.getIngestLog(5);
+ db.close();
+ expect(log[0].action).toBe("deploy");
+ expect(log[0].detail).toContain("public");
+ });
+
+ it("copies subdirectories inside the output directory", () => {
+ writePublishedSite();
+ writeFileSync(
+ join(TEST_OUTPUT, "assets", "screenshots", "demo-engineer-signal-router.png"),
+ "png",
+ "utf-8"
+ );
+ deployAll(publishedConfig());
+
+ const targetFiles = readdirSync(join(TEST_TARGET, "assets", "screenshots"));
+ expect(targetFiles).toContain("demo-engineer-signal-router.png");
+ });
+
+ it("reports paths that fall inside a parent directory", () => {
+ expect(isPathInside("output", "output/nested")).toBe(true);
+ expect(isPathInside("output", "output")).toBe(true);
+ expect(isPathInside("output", "other")).toBe(false);
+ expect(isPathInside("output", "output-extra")).toBe(false);
+ });
+});
diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts
new file mode 100644
index 0000000..7c5745c
--- /dev/null
+++ b/tests/manifest.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { readFileSync, rmSync, existsSync } from "node:fs";
+import { join } from "node:path";
+import { ingestOwnerRepos } from "../src/ingest/orchestrator.js";
+import { captureLocalHtml, closeBrowser } from "../src/preview/capture.js";
+import { publishSite, type SiteManifest } from "../src/publish/site.js";
+import { loadAllFixtures } from "../src/fixtures/loader.js";
+import { DEFAULT_CONFIG } from "../src/types.js";
+
+const TEST_DATA = join("data", "test-manifest");
+const TEST_OUTPUT = join("output", "test-manifest");
+
+function readManifest(): SiteManifest {
+ return JSON.parse(readFileSync(join(TEST_OUTPUT, "site-manifest.json"), "utf-8")) as SiteManifest;
+}
+
+describe("site manifest", () => {
+ beforeEach(() => {
+ rmSync(TEST_DATA, { recursive: true, force: true });
+ rmSync(TEST_OUTPUT, { recursive: true, force: true });
+ });
+
+ afterEach(async () => {
+ rmSync(TEST_DATA, { recursive: true, force: true });
+ rmSync(TEST_OUTPUT, { recursive: true, force: true });
+ await closeBrowser();
+ });
+
+ it("records format, theme, and project counts", async () => {
+ const config = {
+ ...DEFAULT_CONFIG,
+ dataDir: TEST_DATA,
+ outputDir: TEST_OUTPUT,
+ clock: () => "2026-07-31T00:00:00.000Z",
+ };
+ await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures());
+ const result = publishSite(config);
+
+ expect(result.theme).toBe("deep-space");
+ expect(result.manifestPath).toBe(join(TEST_OUTPUT, "site-manifest.json"));
+ expect(existsSync(result.manifestPath)).toBe(true);
+
+ const manifest = readManifest();
+ expect(manifest.formatVersion).toBe(1);
+ expect(manifest.theme).toBe("deep-space");
+ expect(manifest.projectCount).toBe(2);
+ expect(manifest.owner).toBe("demo-engineer");
+ expect(manifest.generatedAt).toBe("2026-07-31T00:00:00.000Z");
+ expect(manifest.files).toContain("index.html");
+ expect(manifest.files).toContain("site-manifest.json");
+ expect(manifest.files).toContain("demo-engineer-signal-router-changelog.md");
+ expect(manifest.projects).toHaveLength(2);
+ });
+
+ it("lists screenshots after capture", async () => {
+ const config = {
+ ...DEFAULT_CONFIG,
+ dataDir: TEST_DATA,
+ outputDir: TEST_OUTPUT,
+ clock: () => "2026-07-31T00:00:00.000Z",
+ };
+ await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures());
+ for (const fixture of loadAllFixtures()) {
+ const slug = fixture.repo.full_name.replace(/\//g, "-").toLowerCase();
+ await captureLocalHtml(config, slug, join("fixtures", "preview-pages", `${fixture.repo.name}.html`));
+ }
+ publishSite(config);
+
+ const manifest = readManifest();
+ expect(manifest.screenshots).toContain("assets/screenshots/demo-engineer-signal-router.png");
+ expect(manifest.screenshots).toContain("assets/screenshots/demo-engineer-metrics-kit.png");
+ });
+
+ it("publishes with a custom theme", async () => {
+ const config = {
+ ...DEFAULT_CONFIG,
+ dataDir: TEST_DATA,
+ outputDir: TEST_OUTPUT,
+ theme: { name: "paper" },
+ clock: () => "2026-07-31T00:00:00.000Z",
+ };
+ await ingestOwnerRepos(config, config.owner, 2, loadAllFixtures());
+ const result = publishSite(config);
+
+ expect(result.theme).toBe("paper");
+ expect(readManifest().theme).toBe("paper");
+ const html = readFileSync(result.indexPath, "utf-8");
+ expect(html).toContain("Theme / paper");
+ expect(html).toContain("color-scheme: light");
+ });
+});
diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts
index d119d9b..5eb9405 100644
--- a/tests/refresh.test.ts
+++ b/tests/refresh.test.ts
@@ -7,10 +7,12 @@ import { DEFAULT_CONFIG } from "../src/types.js";
const TEST_DATA = join("data", "test-refresh");
const TEST_OUTPUT = join("output", "test-refresh");
+const TEST_TARGET = join("deploy", "test-refresh");
afterEach(() => {
rmSync(TEST_DATA, { recursive: true, force: true });
rmSync(TEST_OUTPUT, { recursive: true, force: true });
+ rmSync(TEST_TARGET, { recursive: true, force: true });
});
describe("configured refresh", () => {
@@ -33,6 +35,29 @@ describe("configured refresh", () => {
expect(result.captureErrors).toEqual([]);
expect(result.published.projectCount).toBe(2);
expect(result.copiedScreenshots).toBe(0);
+ expect(result.deployed).toEqual([]);
expect(existsSync(join(TEST_OUTPUT, "index.html"))).toBe(true);
});
+
+ it("deploys to configured local targets", async () => {
+ const config = {
+ ...DEFAULT_CONFIG,
+ dataDir: TEST_DATA,
+ outputDir: TEST_OUTPUT,
+ repositoryLimit: 2,
+ deploy: {
+ targets: [{ name: "public", type: "local" as const, target: "deploy/test-refresh" }],
+ },
+ clock: () => "2026-07-31T00:00:00.000Z",
+ };
+
+ const result = await refreshPortfolio(config, {
+ fixtureRepos: loadAllFixtures(),
+ capture: false,
+ });
+
+ expect(result.deployed).toHaveLength(1);
+ expect(result.deployed[0].targetName).toBe("public");
+ expect(existsSync(join("deploy", "test-refresh", "index.html"))).toBe(true);
+ });
});
diff --git a/tests/site.test.ts b/tests/site.test.ts
index 03061cf..27465e1 100644
--- a/tests/site.test.ts
+++ b/tests/site.test.ts
@@ -78,13 +78,25 @@ describe("database and publish pipeline", () => {
const result = publishSite(config);
expect(result.projectCount).toBe(1);
+ expect(result.theme).toBe("deep-space");
expect(existsSync(result.indexPath)).toBe(true);
+ expect(existsSync(result.manifestPath)).toBe(true);
const html = readFileSync(result.indexPath, "utf-8");
expect(html).toContain("signal-router");
expect(html).toContain("auditable SQLite data");
expect(html).toContain("42 stars");
expect(html).toContain("total stars");
+ expect(html).toContain("Theme / deep-space");
+
+ const manifest = JSON.parse(readFileSync(result.manifestPath, "utf-8")) as {
+ formatVersion: number;
+ theme: string;
+ files: string[];
+ };
+ expect(manifest.formatVersion).toBe(1);
+ expect(manifest.theme).toBe("deep-space");
+ expect(manifest.files).toContain("index.html");
});
it("respects visibility when publishing", async () => {
diff --git a/tests/theme.test.ts b/tests/theme.test.ts
new file mode 100644
index 0000000..78c1cdc
--- /dev/null
+++ b/tests/theme.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from "vitest";
+import {
+ listBuiltinThemes,
+ isBuiltinTheme,
+ isValidHexColor,
+ resolveTheme,
+ themeVariables,
+} from "../src/theme/palette.js";
+import { DEFAULT_THEME } from "../src/types.js";
+
+describe("theme catalog", () => {
+ it("exposes deterministic built-in themes", () => {
+ const names = listBuiltinThemes().map((theme) => theme.name);
+ expect(names).toEqual(["deep-space", "paper", "terminal"]);
+ for (const theme of listBuiltinThemes()) {
+ expect(theme.description.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("recognizes built-in theme names", () => {
+ expect(isBuiltinTheme("deep-space")).toBe(true);
+ expect(isBuiltinTheme("paper")).toBe(true);
+ expect(isBuiltinTheme("terminal")).toBe(true);
+ expect(isBuiltinTheme("vaporwave")).toBe(false);
+ });
+});
+
+describe("hex color validation", () => {
+ it("accepts short and long hex forms", () => {
+ expect(isValidHexColor("#fff")).toBe(true);
+ expect(isValidHexColor("#67b7ff")).toBe(true);
+ expect(isValidHexColor("67b7ff")).toBe(true);
+ });
+
+ it("rejects non-hex values", () => {
+ expect(isValidHexColor("red")).toBe(false);
+ expect(isValidHexColor("#12")).toBe(false);
+ expect(isValidHexColor("#ggg")).toBe(false);
+ });
+});
+
+describe("resolveTheme", () => {
+ it("defaults to the configured default theme", () => {
+ const tokens = resolveTheme();
+ expect(tokens.name).toBe(DEFAULT_THEME.name);
+ expect(tokens.mode).toBe("dark");
+ });
+
+ it("resolves a requested built-in theme", () => {
+ const tokens = resolveTheme({ name: "paper" });
+ expect(tokens.name).toBe("paper");
+ expect(tokens.mode).toBe("light");
+ });
+
+ it("falls back to the default theme for unknown names", () => {
+ const tokens = resolveTheme({ name: "not-a-theme" });
+ expect(tokens.name).toBe(DEFAULT_THEME.name);
+ });
+
+ it("applies an accent override to derived tokens", () => {
+ const base = resolveTheme({ name: "deep-space" });
+ const tokens = resolveTheme({ name: "deep-space", accent: "#ff0000" });
+ expect(tokens.blue).toBe("#ff0000");
+ expect(tokens.blue).not.toBe(base.blue);
+ expect(tokens.glow).toContain("rgba(255, 0, 0,");
+ expect(tokens.stripe).toContain("rgba(255, 0, 0,");
+ });
+
+ it("applies radius and font overrides", () => {
+ const tokens = resolveTheme({ name: "paper", radius: "20px", font: "Georgia, serif" });
+ expect(tokens.radius).toBe("20px");
+ expect(tokens.font).toBe("Georgia, serif");
+ });
+});
+
+describe("themeVariables", () => {
+ it("emits a :root block with color-scheme and core tokens", () => {
+ const css = themeVariables(resolveTheme({ name: "deep-space" }));
+ expect(css).toContain(":root {");
+ expect(css).toContain("color-scheme: dark");
+ expect(css).toContain("--blue: #67b7ff");
+ expect(css).toContain("--font:");
+ });
+
+ it("emits a light color-scheme for the paper theme", () => {
+ const css = themeVariables(resolveTheme({ name: "paper" }));
+ expect(css).toContain("color-scheme: light");
+ });
+
+ it("emits deterministic output for the same theme", () => {
+ const first = themeVariables(resolveTheme({ name: "paper" }));
+ const second = themeVariables(resolveTheme({ name: "paper" }));
+ expect(first).toBe(second);
+ });
+});