Implement M0 bootable Plant Laboratory infrastructure

This commit is contained in:
Christian Johannesen 2026-10-28 09:59:06 +01:00
commit ea9adb85e6
56 changed files with 5684 additions and 0 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
node_modules
**/node_modules
**/dist
.git
.env
.env.*
coverage
*.log

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
HOST_PORT=8155
PUBLIC_DOMAIN=http://localhost
POSTGRES_DB=botanica
POSTGRES_USER=botanica
POSTGRES_PASSWORD=change-me-before-exposing
NODE_ENV=development

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
node_modules/
dist/
coverage/
.vite/
*.log
.env.local
.env.production
.env.*.local
!.env.example
# Local .env supplied in handoff but should not be committed to public VCS
.env

51
AGENTS.md Normal file
View file

@ -0,0 +1,51 @@
# Codex repository instructions — Botanica / Plant Laboratory
## Mission
Build **Plant Laboratory**, the first working module of a future online plant-breeding idle game. Illustrated 2D, procedural SVG assembled from art-directed botanical vector primitives, deterministic genetics, and an elegant responsive interface. This repository contains the authoritative implementation handoff. **Read `docs/IMPLEMENTATION_PLAN.md` and `docs/PRODUCT_SPEC.md` first.**
## Non-negotiable operational settings
- Docker Compose is the standard runtime. The public HTTP UI is exposed on `${HOST_PORT}` (default **8155**) at `${PUBLIC_DOMAIN}` (default **http://localhost**), thus `http://localhost:8155`.
- The user requested literal `.env` values `HOST_PORT=8155` and `PUBLIC_DOMAIN=http://localhost`. Do not silently change these values. `PUBLIC_DOMAIN` is an **origin without port**, not a full public base URL. Build `PUBLIC_BASE_URL` from origin + exposed port for local development; do not append the port in production when running behind a reverse proxy. See `docs/DEPLOYMENT.md`.
- Keep PostgreSQL and API private on Compose networks; publish only the web entrypoint.
- No paid AI APIs, external image generation at runtime, cloud-only dependencies, trackers, auth integrations, marketplaces, multiplayer or microservices in the MVP.
- Use the root `package.json` workspaces with npm and committed lockfile after installing.
## Source of truth and reading order
1. `docs/IMPLEMENTATION_PLAN.md` — phases and acceptance gates.
2. `docs/PRODUCT_SPEC.md` — player workflows, UI, quality bar.
3. `docs/GENETICS_SPEC.md` — genotype/phenotype, inheritance and randomness.
4. `docs/GRAPHICS_SPEC.md` — art pipeline, asset contract, deterministic SVG.
5. `docs/API_AND_DATA.md` — schemas, persistence, routes and invariants.
6. `docs/DEPLOYMENT.md` — Compose, ports, secrets, runbook.
7. `docs/DECISIONS.md` — fixed architectural decisions.
8. `docs/CODEX_START_PROMPT.md` — initial mission.
## Implementation habits
- Work through milestones **M0 → M1 → M2 → M3 → M4**, one at a time. Never substitute a large unverified implementation for milestone gates.
- Implement the current milestone end to end; run its specified tests; update the checklist in `docs/PROGRESS.md`. Stop at a gate and report results, blockers and next step. If explicitly asked to continue autonomously, proceed sequentially.
- Favor simple readable TypeScript; no speculative abstraction, no giant dependencies, no unnecessary test scaffolding.
- Implement actual code and assets, not mockups, placeholders, TODO-based behavior or screenshots-as-assets. Make the UI genuinely usable.
- `packages/genetics`, `packages/renderer`, `packages/rules` must not import React, DOM UI state, database or server. Rendering may consume DOM/SVG APIs only in a thin web adapter; pure layout remains platform independent.
- Server owns authoritative breeding decisions, economy and persisted plant creation. Client never dictates child genotype, mutation outcome, rarity or inventory balances.
- Seeded random generation must be reproducible with explicit algorithm and version; **never use `Math.random()` inside genetics/renderer domain functions**. Use cryptographically secure server RNG to generate initial seeds; store them and use a deterministic PRNG for replay.
- Preserve provenance in `parents`, `generation`, `genomeVersion`, `rendererVersion`, `visualSeed`; no deleting ancestry on migration.
- No arbitrary cross-species breeding. Require compatibility groups, with species-specific constraints.
- Avoid using rarity as a naive random roll independent from genotype. Derive it from a versioned rarity policy as outlined in the specs.
- UI: English copy in the initial codebase; architecture ready for future i18n. Accessible labels, keyboard navigation, reduced motion support.
- Use SVG vector shapes created for this project; no scraped commercial plant assets or image-hotlinking.
## Technical defaults
React + Vite + TypeScript, Tailwind CSS, shadcn/ui where useful, Motion for transitions, Zustand for local view state, TanStack Query for API state; Node.js + Fastify + Zod, PostgreSQL + Drizzle; Docker Compose. Use current compatible stable dependency versions and pin them through lockfile; verify APIs against documentation if needed.
## Required commands / checks
- `docker compose config`
- `docker compose up --build -d`
- `docker compose ps`
- `curl -fsS http://localhost:8155/health`
- `npm run typecheck`
- `npm run test`
- `npm run build`
Use commands as applicable during scaffolding; fix any errors rather than suppressing them. The health endpoint must be served by web reverse proxy to API.
## Finish and reporting
After each milestone report: files changed, URL, tests actually run/results, screenshots or concise UI description, known issues, and what M milestone is next. Do not claim tests were run if they were not. Never mutate `.env` secretly, commit credentials, or delete the user's data.

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# Plant Laboratory / Botanica
M0 provides a running React shell, Fastify API, PostgreSQL schema and repeatable bootstrap. Genetics and game workflows follow in M1–M4.
## Run
Keep `.env` local. Copy `.env.example` if missing; preserve `HOST_PORT=8155` and `PUBLIC_DOMAIN=http://localhost`.
```sh
npm ci
docker compose config --quiet
docker compose up --build -d
docker compose ps
curl -fsS http://localhost:8155/health
```
Open http://localhost:8155. Only the web entrypoint is published, bound to loopback. API and PostgreSQL use the Compose network. The one-shot `bootstrap` service runs a transaction-locked migration followed by an idempotent demo-player/catalog seed before API startup. A named volume retains data across `docker compose down` and `up`; do not use `down -v` unless intentionally deleting all data.
## Development and checks
```sh
npm run typecheck
npm run test
npm run build
npm run dev
```
`npm run dev` serves Vite on port 5173 and API on 3000. Set `DATABASE_URL` to a reachable PostgreSQL database for host development; Compose intentionally does not publish its database. Prefer Compose for the complete runtime.
Root `npm run db:migrate` and `npm run db:seed` require `DATABASE_URL`. Against Compose:
```sh
docker compose run --rm bootstrap node dist/migrate.js
docker compose run --rm bootstrap node dist/seed.js
```
Migrations are checked-in SQL tracked in `schema_migrations`; seeds never overwrite existing records. `.env` is ignored by Git and Docker build contexts. Production containers run as non-root. The API returns safe uniform errors and health responds 503 if PostgreSQL is unavailable. Relative browser API URLs avoid container hostname leakage. `PUBLIC_BASE_URL` is validated and derived from origin plus port for localhost, or just origin behind a production reverse proxy.
Routes: `/` redirects to `/lab`; `/lab`, `/breed`, `/gallery`, `/about` currently show clearly labeled milestone empty states. Original shell illustration is decorative; the deterministic species renderer is M2. No authentication: this sandbox is intended for local use.
Read [the implementation plan](docs/IMPLEMENTATION_PLAN.md) and [progress log](docs/PROGRESS.md) for acceptance gates.

20
apps/server/Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/ packages/
RUN npm ci
COPY tsconfig.base.json ./
COPY apps/server/ apps/server/
RUN npm run build -w @botanica/server && npm prune --omit=dev
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/apps/server/package.json ./package.json
COPY --from=build --chown=node:node /app/apps/server/dist ./dist
COPY --from=build --chown=node:node /app/apps/server/migrations ./migrations
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

View file

@ -0,0 +1,7 @@
CREATE TABLE species (id text PRIMARY KEY, name text NOT NULL, compatibility_group text NOT NULL, catalog_version integer NOT NULL, config_jsonb jsonb NOT NULL);
CREATE TABLE players (id uuid PRIMARY KEY, display_name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE plants (id uuid PRIMARY KEY, owner_id uuid NOT NULL REFERENCES players(id), species_id text NOT NULL REFERENCES species(id), genome_version integer NOT NULL, genome jsonb NOT NULL, mutations jsonb NOT NULL, visual_seed integer NOT NULL, renderer_version integer NOT NULL, rarity_policy_version integer NOT NULL, rarity_score integer NOT NULL CHECK (rarity_score BETWEEN 0 AND 100), rarity_tier text NOT NULL CHECK (rarity_tier IN ('Common','Uncommon','Rare','Epic','Legendary','Mythic')), generation integer NOT NULL CHECK (generation >= 0), parent_a_id uuid REFERENCES plants(id), parent_b_id uuid REFERENCES plants(id), created_at timestamptz NOT NULL DEFAULT now(), CHECK ((parent_a_id IS NULL) = (parent_b_id IS NULL)));
CREATE INDEX plants_owner_created ON plants(owner_id, created_at DESC);
CREATE INDEX plants_species_rarity ON plants(species_id, rarity_tier);
CREATE TABLE breeding_events (id uuid PRIMARY KEY, owner_id uuid NOT NULL REFERENCES players(id), parent_a_id uuid NOT NULL REFERENCES plants(id), parent_b_id uuid NOT NULL REFERENCES plants(id), child_id uuid NOT NULL UNIQUE REFERENCES plants(id), rng_seed integer NOT NULL, engine_version integer NOT NULL, mutation_events jsonb NOT NULL, idempotency_key text NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now());
CREATE INDEX breeding_owner_created ON breeding_events(owner_id, created_at DESC);

23
apps/server/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "@botanica/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"db:migrate": "tsx src/migrate.ts",
"db:seed": "tsx src/seed.ts"
},
"dependencies": {
"fastify": "^5.12.7",
"zod": "^4.0.0",
"drizzle-orm": "^0.45.4",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/pg": "^8.15.5",
"tsx": "^4.20.0"
}
}

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { createApp } from "./app.js";
import { readConfig } from "./config.js";
describe("M0 infrastructure contracts", () => {
it("checks database before reporting healthy", async () => {
let queries = 0;
const app = createApp(async () => {
queries++;
});
const response = await app.inject("/health");
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ status: "ok", database: "connected" });
expect(queries).toBe(1);
await app.close();
});
it("returns safe database and route errors", async () => {
const app = createApp(async () => {
throw new Error("secret password");
});
const response = await app.inject("/health");
expect(response.statusCode).toBe(503);
expect(response.body).not.toContain("secret");
expect((await app.inject("/missing")).json().error.code).toBe("NOT_FOUND");
await app.close();
});
it("derives local URL and preserves reverse proxy origin", () => {
expect(readConfig({ DATABASE_URL: "postgres://db" }).PUBLIC_BASE_URL).toBe(
"http://localhost:8155",
);
expect(
readConfig({
DATABASE_URL: "postgres://db",
PUBLIC_DOMAIN: "https://plants.example",
}).PUBLIC_BASE_URL,
).toBe("https://plants.example");
expect(() =>
readConfig({ DATABASE_URL: "x", PUBLIC_DOMAIN: "http://localhost:8155" }),
).toThrow();
});
});

48
apps/server/src/app.ts Normal file
View file

@ -0,0 +1,48 @@
import Fastify from "fastify";
export function createApp(
checkDatabase: () => Promise<unknown>,
logger = false,
) {
const app = Fastify({ logger, bodyLimit: 65536 });
app.get("/health", async (_request, reply) => {
try {
await checkDatabase();
return { status: "ok", database: "connected" };
} catch {
return reply
.code(503)
.send({
error: {
code: "DATABASE_UNAVAILABLE",
message: "Database is unavailable.",
},
});
}
});
app.setNotFoundHandler((_request, reply) =>
reply
.code(404)
.send({ error: { code: "NOT_FOUND", message: "Route not found." } }),
);
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
const code =
typeof error === "object" && error !== null && "statusCode" in error
? error.statusCode
: undefined;
const status =
typeof code === "number" && code >= 400 && code < 500 ? code : 500;
reply
.code(status)
.send({
error: {
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
message:
status === 500
? "An unexpected error occurred."
: "Request could not be processed.",
},
});
});
return app;
}

29
apps/server/src/config.ts Normal file
View file

@ -0,0 +1,29 @@
import { z } from "zod";
export function readConfig(env: NodeJS.ProcessEnv) {
const c = z
.object({
DATABASE_URL: z.string().min(1),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
HOST_PORT: z.coerce.number().int().min(1).max(65535).default(8155),
PUBLIC_DOMAIN: z.string().url().default("http://localhost"),
})
.parse(env);
const origin = new URL(c.PUBLIC_DOMAIN);
if (
origin.port ||
origin.pathname !== "/" ||
origin.search ||
origin.hash ||
origin.username ||
origin.password
)
throw new Error("PUBLIC_DOMAIN must be an origin without port");
return {
...c,
PUBLIC_BASE_URL: ["localhost", "127.0.0.1", "[::1]"].includes(
origin.hostname,
)
? origin.origin + ":" + c.HOST_PORT
: origin.origin,
};
}

11
apps/server/src/db.ts Normal file
View file

@ -0,0 +1,11 @@
import pg from "pg";
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema.js";
export function connectDatabase(url: string) {
const pool = new pg.Pool({
connectionString: url,
max: 10,
connectionTimeoutMillis: 5000,
});
return { pool, db: drizzle(pool, { schema }) };
}

14
apps/server/src/index.ts Normal file
View file

@ -0,0 +1,14 @@
import { readConfig } from "./config.js";
import { connectDatabase } from "./db.js";
import { createApp } from "./app.js";
const config = readConfig(process.env);
const { pool } = connectDatabase(config.DATABASE_URL);
const app = createApp(() => pool.query("SELECT 1"), true);
app.addHook("onClose", async () => {
await pool.end();
});
for (const signal of ["SIGINT", "SIGTERM"])
process.on(signal, () => {
void app.close();
});
await app.listen({ port: config.PORT, host: "0.0.0.0" });

View file

@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import { connectDatabase } from "./db.js";
const { pool } = connectDatabase(process.env.DATABASE_URL ?? "");
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock(8155000)");
await client.query(
"CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())",
);
const name = "0001_initial";
const exists = await client.query(
"SELECT name FROM schema_migrations WHERE name = $1",
[name],
);
if (!exists.rowCount) {
await client.query(
await readFile(
new URL("../migrations/0001_initial.sql", import.meta.url),
"utf8",
),
);
await client.query("INSERT INTO schema_migrations(name) VALUES ($1)", [
name,
]);
}
await client.query("COMMIT");
console.log("Migrations current");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
await pool.end();
}

85
apps/server/src/schema.ts Normal file
View file

@ -0,0 +1,85 @@
import {
pgTable,
text,
integer,
uuid,
jsonb,
timestamp,
index,
unique,
type AnyPgColumn,
} from "drizzle-orm/pg-core";
export const species = pgTable("species", {
id: text().primaryKey(),
name: text().notNull(),
compatibilityGroup: text("compatibility_group").notNull(),
catalogVersion: integer("catalog_version").notNull(),
config: jsonb("config_jsonb").notNull(),
});
export const players = pgTable("players", {
id: uuid().primaryKey(),
displayName: text("display_name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const plants = pgTable(
"plants",
{
id: uuid().primaryKey(),
ownerId: uuid("owner_id")
.notNull()
.references(() => players.id),
speciesId: text("species_id")
.notNull()
.references(() => species.id),
genomeVersion: integer("genome_version").notNull(),
genome: jsonb().notNull(),
mutations: jsonb().notNull(),
visualSeed: integer("visual_seed").notNull(),
rendererVersion: integer("renderer_version").notNull(),
rarityPolicyVersion: integer("rarity_policy_version").notNull(),
rarityScore: integer("rarity_score").notNull(),
rarityTier: text("rarity_tier").notNull(),
generation: integer().notNull(),
parentAId: uuid("parent_a_id").references((): AnyPgColumn => plants.id),
parentBId: uuid("parent_b_id").references((): AnyPgColumn => plants.id),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [
index("plants_owner_created").on(t.ownerId, t.createdAt.desc()),
index("plants_species_rarity").on(t.speciesId, t.rarityTier),
],
);
export const breedingEvents = pgTable(
"breeding_events",
{
id: uuid().primaryKey(),
ownerId: uuid("owner_id")
.notNull()
.references(() => players.id),
parentAId: uuid("parent_a_id")
.notNull()
.references(() => plants.id),
parentBId: uuid("parent_b_id")
.notNull()
.references(() => plants.id),
childId: uuid("child_id")
.notNull()
.references(() => plants.id),
rngSeed: integer("rng_seed").notNull(),
engineVersion: integer("engine_version").notNull(),
mutationEvents: jsonb("mutation_events").notNull(),
idempotencyKey: text("idempotency_key").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => [
unique().on(t.childId),
unique().on(t.idempotencyKey),
index("breeding_owner_created").on(t.ownerId, t.createdAt.desc()),
],
);

37
apps/server/src/seed.ts Normal file
View file

@ -0,0 +1,37 @@
import { connectDatabase } from "./db.js";
import { players, species } from "./schema.js";
const { pool, db } = connectDatabase(process.env.DATABASE_URL ?? "");
try {
await db.transaction(async (tx) => {
await tx
.insert(players)
.values({
id: "00000000-0000-4000-8000-000000000001",
displayName: "Sandbox botanist",
})
.onConflictDoNothing();
await tx
.insert(species)
.values({
id: "velvetleaf",
name: "Velvetleaf",
compatibilityGroup: "velvetleaf",
catalogVersion: 1,
config: {
interspeciesBreeding: false,
loci: {
pigment: ["G", "P", "R", "Y"],
leafShape: ["O", "L", "S"],
leafPattern: ["S", "V", "M"],
growthHabit: ["C", "B", "T"],
flowerColor: ["W", "P", "B", "R"],
flowerDensity: ["L", "M", "H"],
},
},
})
.onConflictDoNothing();
});
console.log("Sandbox player and velvetleaf catalog ready");
} finally {
await pool.end();
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

14
apps/web/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/ packages/
RUN npm ci
COPY tsconfig.base.json ./
COPY apps/web/ apps/web/
RUN npm run build -w @botanica/web
FROM nginxinc/nginx-unprivileged:stable-alpine
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 8080

15
apps/web/components.json Normal file
View file

@ -0,0 +1,15 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "./src/components",
"utils": "./src/lib/utils"
}
}

1
apps/web/index.html Normal file
View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="theme-color" content="#20392d"/><meta name="description" content="Plant Laboratory — a botanical breeding sandbox."/><title>Plant Laboratory</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

12
apps/web/nginx.conf Normal file
View file

@ -0,0 +1,12 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy same-origin always;
location = /health { proxy_pass http://api:3000/health; }
location /api/ { proxy_pass http://api:3000; }
location / { try_files $uri $uri/ /index.html; }
}

30
apps/web/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "@botanica/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-router": "^7.0.0",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.0.0",
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.577.0"
},
"devDependencies": {
"vite": "^8.3.5",
"@vitejs/plugin-react": "^6.0.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}

View file

@ -0,0 +1,5 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

265
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,265 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router";
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
import {
Leaf,
ArrowUpRight,
FlaskConical,
Sprout,
Images,
BookOpen,
} from "lucide-react";
import "./styles.css";
const queryClient = new QueryClient();
const pages = {
lab: {
label: "Laboratory",
eyebrow: "A place for possibility",
title: "Small seeds. Endless possibilities.",
description:
"A botanical cabinet for curious minds. Explore the art of growing, the patterns of inheritance, and the delight of something unexpected.",
empty: "Your workbench is taking root",
detail:
"The laboratory is ready for its first species. Genome controls and live plant previews arrive in the next milestones.",
icon: FlaskConical,
},
breed: {
label: "Breeding",
eyebrow: "The next generation",
title: "Every cross begins a new story.",
description:
"Bring two compatible plants together and follow the traits they pass on.",
empty: "No parents to pair yet",
detail:
"Saved parent plants and authoritative breeding will arrive in milestone M3.",
icon: Sprout,
},
gallery: {
label: "Collection",
eyebrow: "Your living archive",
title: "A collection worth cultivating.",
description:
"A home for each discovery, from familiar greens to the wonderfully unusual.",
empty: "Room for your first discovery",
detail:
"Your saved plants will appear here. The collection gallery arrives in milestone M4.",
icon: Images,
},
about: {
label: "Field notes",
eyebrow: "About this experiment",
title: "Botany meets a little imagination.",
description:
"Plant Laboratory is the first chapter of Botanica, a future plant-breeding idle game.",
empty: "Fictional genetics, real curiosity",
detail:
"This educational game uses simplified, fictional Mendelian genetics. It is not a scientifically accurate botany simulator. This local sandbox has no accounts yet.",
icon: BookOpen,
},
};
function BotanicalPlate() {
return (
<svg
viewBox="0 -20 480 530"
role="img"
aria-label="Original botanical study of a leafy plant in a terracotta pot"
>
<defs>
<linearGradient id="leaf" x2="1" y2="1">
<stop stopColor="#718c58" />
<stop offset="1" stopColor="#284e3a" />
</linearGradient>
</defs>
<circle cx="246" cy="231" r="172" fill="#e6e9d7" />
<circle
cx="246"
cy="231"
r="151"
fill="none"
stroke="#b9c6a8"
strokeDasharray="2 8"
/>
<ellipse cx="242" cy="458" rx="95" ry="12" fill="#283c2b" opacity=".09" />
<path
d="M239 362 Q225 263 252 108 M238 321 L171 251 M241 275 L315 209 M244 224 L199 160 M247 178 L297 127"
fill="none"
stroke="#486345"
strokeWidth="6"
strokeLinecap="round"
/>
{[
[171, 251, -52],
[315, 209, 45],
[199, 160, -41],
[297, 127, 35],
[252, 108, 0],
].map(([x, y, r], i) => (
<g key={i} transform={`translate(${x} ${y}) rotate(${r})`}>
<path
d="M0 8 C-67-18-58-92 0-113 C53-81 56-22 0 8Z"
fill="url(#leaf)"
stroke="#486345"
strokeWidth="1.5"
/>
<path
d="M0 6 Q-7-47 0-100 M-3-30 L-32-57 M-4-49 L27-75 M-3-66 L-24-84"
fill="none"
stroke="#b9c69a"
strokeWidth="1.6"
/>
<path
d="M-12-79 Q-38-54-26-30"
fill="none"
stroke="#c7d1a6"
strokeWidth="7"
opacity=".15"
/>
</g>
))}
<path d="M181 363 L195 444 Q240 464 286 444 L301 363Z" fill="#b77659" />
<path d="M195 380 L205 438 Q216 444 224 444 L216 380" fill="#cc9171" />
<path
d="M181 363 Q241 347 301 363 L301 379 Q239 394 181 379Z"
fill="#cc9474"
/>
<ellipse cx="241" cy="362" rx="59" ry="10" fill="#765a40" />
<path d="M240 363 L239 341" stroke="#486345" strokeWidth="6" />
<text x="38" y="486" fill="#6a7c70" fontFamily="Georgia" fontSize="12">
Fig. 01 — A study in green
</text>
</svg>
);
}
function Health() {
const result = useQuery({
queryKey: ["health"],
queryFn: async () => {
const r = await fetch("/health");
if (!r.ok) throw new Error("Unavailable");
return r.json() as Promise<{ status: string }>;
},
refetchInterval: 30000,
retry: 1,
});
return (
<span className="health" role="status">
<span className={result.isSuccess ? "dot online" : "dot"} />
{result.isPending
? "Connecting to laboratory"
: result.isError
? "Laboratory connection unavailable"
: "Laboratory online"}
</span>
);
}
function Page({ page }: { page: keyof typeof pages }) {
const p = pages[page];
const Icon = p.icon;
return (
<>
<section className="hero">
<div className="intro">
<p className="eyebrow">{p.eyebrow}</p>
<h1>{p.title}</h1>
<p className="description">{p.description}</p>
<div className="edition">
<span>PLANT LABORATORY</span>
<span>Local sandbox · No. 001</span>
</div>
</div>
<figure>
<BotanicalPlate />
<figcaption>BOTANICAL STUDY / ILLUSTRATED SHELL ART</figcaption>
</figure>
</section>
<section className="workbench" aria-labelledby="workspace-title">
<div className="section-heading">
<div>
<p className="eyebrow">{p.label}</p>
<h2 id="workspace-title">
{page === "lab" ? "The workbench" : p.label}
</h2>
</div>
<span className="badge">M0 · Foundations</span>
</div>
<div className="empty">
<span className="icon">
<Icon size={25} strokeWidth={1.4} />
</span>
<h3>{p.empty}</h3>
<p>{p.detail}</p>
{page === "lab" && (
<NavLink to="/about" className="text-link">
Read the field notes <ArrowUpRight size={16} />
</NavLink>
)}
</div>
</section>
</>
);
}
function App() {
return (
<>
<a href="#main" className="skip">
Skip to content
</a>
<header>
<NavLink to="/lab" className="brand" aria-label="Plant Laboratory home">
<span className="brand-icon">
<Leaf size={25} />
</span>
<span>
Plant Laboratory<small>BOTANICA / EXPERIMENT NO. 001</small>
</span>
</NavLink>
<nav aria-label="Main navigation">
{Object.entries(pages).map(([key, p]) => (
<NavLink key={key} to={`/${key}`}>
{p.label}
</NavLink>
))}
</nav>
</header>
<main id="main">
<Routes>
<Route path="/" element={<Navigate to="/lab" replace />} />
{Object.keys(pages).map((key) => (
<Route
key={key}
path={`/${key}`}
element={<Page page={key as keyof typeof pages} />}
/>
))}
<Route
path="*"
element={
<section className="empty">
<h1>Page not found</h1>
<NavLink to="/lab">Return to the laboratory</NavLink>
</section>
}
/>
</Routes>
</main>
<footer>
<span>Rooted in curiosity. Grown with care.</span>
<Health />
</footer>
</>
);
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

299
apps/web/src/styles.css Normal file
View file

@ -0,0 +1,299 @@
@import "tailwindcss";
:root {
font-family: Arial, sans-serif;
color: #20392d;
background: #f6f3e8;
font-synthesis: none;
--background: #f6f3e8;
--foreground: #20392d;
--primary: #32684a;
--border: #d8ded2;
--radius: 0.75rem;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
a:focus-visible {
outline: 3px solid #b76f52;
outline-offset: 5px;
border-radius: 3px;
}
header,
main,
footer {
max-width: 1280px;
margin: auto;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 29px 42px;
border-bottom: 1px solid #d8ded2;
gap: 25px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font:
23px Georgia,
serif;
}
.brand-icon {
display: grid;
place-items: center;
border: 1px solid #b3c1a7;
border-radius: 50%;
width: 46px;
height: 46px;
}
.brand small {
display: block;
font:
9px Arial,
sans-serif;
letter-spacing: 2px;
margin-top: 7px;
}
nav {
display: flex;
gap: 27px;
font-size: 13px;
}
nav a {
padding: 13px 0;
color: #586c5e;
}
nav a.active {
color: #20392d;
border-bottom: 2px solid #32684a;
}
main {
padding: 0 42px 60px;
}
.hero {
display: grid;
grid-template-columns: 1.1fr 1fr;
align-items: center;
gap: 45px;
min-height: 530px;
}
.eyebrow {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 2.5px;
color: #647961;
font-weight: bold;
margin: 0 0 22px;
}
h1 {
font:
clamp(40px, 4.8vw, 67px)/1.07 Georgia,
serif;
letter-spacing: -2px;
max-width: 620px;
margin: 0 0 25px;
}
.description {
font-size: 15px;
line-height: 1.85;
color: #5d7062;
max-width: 440px;
}
.edition {
margin-top: 37px;
display: flex;
gap: 19px;
flex-wrap: wrap;
font-size: 9px;
letter-spacing: 1.5px;
color: #687665;
}
.edition span:first-child {
color: #9b6349;
}
figure {
margin: 0;
max-width: 440px;
justify-self: center;
width: 100%;
}
figure svg {
display: block;
width: 100%;
height: auto;
}
figcaption {
text-align: center;
font-size: 8px;
letter-spacing: 2px;
color: #75816f;
margin-top: -10px;
}
.workbench {
border-top: 1px solid #d8ded2;
padding-top: 28px;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.section-heading .eyebrow {
margin-bottom: 8px;
}
h2 {
font:
29px Georgia,
serif;
margin: 0;
}
.badge {
font-size: 10px;
color: #63775f;
background: #e9ecde;
border: 1px solid #d7ddc9;
padding: 9px 13px;
border-radius: 20px;
white-space: nowrap;
}
.empty {
margin-top: 23px;
padding: 36px 20px;
text-align: center;
background: #fffcf4;
border: 1px solid #d8ded2;
border-radius: 12px;
}
.icon {
display: inline-grid;
place-items: center;
width: 49px;
height: 49px;
background: #edf0e4;
border-radius: 50%;
color: #617e56;
}
h3 {
font:
23px Georgia,
serif;
margin: 17px 0 10px;
}
.empty p {
max-width: 440px;
margin: 0 auto;
color: #637565;
font-size: 13px;
line-height: 1.8;
}
.text-link {
display: inline-flex;
align-items: center;
gap: 9px;
font-size: 12px;
margin-top: 21px;
color: #32684a;
}
footer {
display: flex;
justify-content: space-between;
gap: 20px;
border-top: 1px solid #d8ded2;
padding: 24px 42px;
font-size: 11px;
color: #6a7c70;
}
.health {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
height: 6px;
width: 6px;
border-radius: 50%;
background: #b76f52;
flex-shrink: 0;
}
.online {
background: #547e4e;
}
.skip {
position: absolute;
top: -100px;
padding: 10px;
background: #fffcf4;
}
.skip:focus {
top: 5px;
left: 5px;
}
@media (max-width: 720px) {
header {
padding: 22px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
nav {
width: 100%;
justify-content: space-between;
gap: 10px;
font-size: 12px;
}
main {
padding: 0 22px 35px;
}
.hero {
grid-template-columns: 1fr;
gap: 0;
padding-top: 38px;
}
.hero figure {
max-width: 320px;
margin: 12px 0 30px;
}
.eyebrow {
margin-bottom: 17px;
}
h1 {
font-size: 46px;
}
.description {
font-size: 14px;
}
.edition {
margin-top: 25px;
}
footer {
padding: 22px;
flex-direction: column;
gap: 12px;
}
.badge {
font-size: 9px;
padding: 8px;
}
.empty {
padding: 28px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}

11
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}

12
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/health": "http://localhost:3000",
"/api": "http://localhost:3000",
},
},
});

BIN
artifacts/m0/1440.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
artifacts/m0/360.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -0,0 +1,62 @@
{
"results": [
{
"width": 360,
"route": "/",
"url": "http://localhost:8155/lab",
"viewport": 360,
"document": 360
},
{
"width": 360,
"route": "/breed",
"url": "http://localhost:8155/breed",
"viewport": 360,
"document": 360
},
{
"width": 360,
"route": "/gallery",
"url": "http://localhost:8155/gallery",
"viewport": 360,
"document": 360
},
{
"width": 360,
"route": "/about",
"url": "http://localhost:8155/about",
"viewport": 360,
"document": 360
},
{
"width": 1440,
"route": "/",
"url": "http://localhost:8155/lab",
"viewport": 1440,
"document": 1440
},
{
"width": 1440,
"route": "/breed",
"url": "http://localhost:8155/breed",
"viewport": 1440,
"document": 1440
},
{
"width": 1440,
"route": "/gallery",
"url": "http://localhost:8155/gallery",
"viewport": 1440,
"document": 1440
},
{
"width": 1440,
"route": "/about",
"url": "http://localhost:8155/about",
"viewport": 1440,
"document": 1440
}
],
"errors": [],
"keyboard": "Skip link is first focus target"
}

2
assets/README.md Normal file
View file

@ -0,0 +1,2 @@
# Artwork
The botanical shell study in apps/web/src/main.tsx is original SVG artwork created for Botanica by Codex (2026-10-28), provided under the repository terms. No external assets are used. This is decorative M0 art; species primitives and the genotype renderer belong to M2.

71
compose.yaml Normal file
View file

@ -0,0 +1,71 @@
name: botanica
services:
web:
build:
context: .
dockerfile: apps/web/Dockerfile
ports: ["127.0.0.1:${HOST_PORT:-8155}:8080"]
depends_on:
api:
condition: service_healthy
restart: unless-stopped
networks: [internal]
api:
build:
context: .
dockerfile: apps/server/Dockerfile
environment:
NODE_ENV: production
PORT: "3000"
HOST_PORT: ${HOST_PORT:-8155}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-http://localhost}
depends_on:
bootstrap:
condition: service_completed_successfully
db:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
networks: [internal]
bootstrap:
build:
context: .
dockerfile: apps/server/Dockerfile
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
command: ["sh", "-c", "node dist/migrate.js && node dist/seed.js"]
depends_on:
db:
condition: service_healthy
restart: "no"
networks: [internal]
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-botanica}
POSTGRES_USER: ${POSTGRES_USER:-botanica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
volumes: ["postgres_data:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 15
restart: unless-stopped
networks: [internal]
networks:
internal:
volumes:
postgres_data:

53
docs/API_AND_DATA.md Normal file
View file

@ -0,0 +1,53 @@
# API and persistence contracts
## API conventions
Versioned REST at `/api/v1`. JSON UTF-8. Zod validation for input. Consistent failures `{error:{code,message,details?}}`; use 400 for malformed, 404 for missing, 409 for incompatible/duplicate, 500 for unexpected failures. `GET /health` returns 200 JSON and DB health information without credentials.
## Endpoints M0–M4
- `GET /health` — proxy-through health.
- `GET /api/v1/catalog/species` — available species and allele catalogs.
- `POST /api/v1/phenotype/resolve` — validated genome → phenotype + rarity preview; no persistence.
- `POST /api/v1/preview` — validated genome + seed + stage → serialized SVG (or typed scene; choose one and document).
- `GET /api/v1/plants?cursor=&limit=&species=&rarity=&pigment=` — paginated saved demo plants.
- `POST /api/v1/plants` — create demo plant using server-side validation and secure initial visual seed if omitted; no privilege to set own immutable rarity/parent claims.
- `GET /api/v1/plants/:id` — plant, phenotype, parent IDs and status.
- `POST /api/v1/breeding` — `{parentAId,parentBId,idempotencyKey}`; server validates, generates seed, applies genetics, persists child + event in a single transaction. Repeated key returns same result (not additional child).
- `GET /api/v1/breeding?limit=&cursor=` — recent events.
## Data model (first migration)
`species(id PK, name, compatibility_group, catalog_version, config_jsonb)`.
`players(id UUID PK, display_name, created_at)` — seed single sandbox player only; no auth yet.
`plants(id UUID PK, owner_id FK, species_id FK, genome_version integer, genome jsonb, mutations jsonb, visual_seed integer, renderer_version integer, rarity_policy_version integer, rarity_score integer, rarity_tier text, generation integer, parent_a_id nullable FK self, parent_b_id nullable FK self, created_at timestamptz)`.
`breeding_events(id UUID PK, owner_id FK, parent_a_id FK, parent_b_id FK, child_id UNIQUE FK, rng_seed integer, engine_version integer, mutation_events jsonb, idempotency_key unique text, created_at timestamptz)`.
`discoveries(player_id FK, signature text, first_discovered_at timestamptz, PK(player_id,signature))` optional in M4.
Indices: plants(owner_id,created_at DESC), plants(species_id,rarity_tier), breeding_events(owner_id,created_at DESC). Use enum validation in domain layer. Schema-specific migrations must be checked into repository. Seed catalog + demo player in idempotent script; sample gallery generated from seeded fixed seeds for repeatability.
## Example valid genome
```json
{
"pigment": ["P", "G"],
"leafShape": ["L", "O"],
"leafPattern": ["V", "S"],
"growthHabit": ["B", "C"],
"flowerColor": ["R", "W"],
"flowerDensity": ["H", "M"]
}
```
## Example breeding request
```json
{
"parentAId": "uuid-parent-a",
"parentBId": "uuid-parent-b",
"idempotencyKey": "uuid-user-generated-unique-request"
}
```
## Security and transaction invariants
- Only exposed service is the web reverse proxy. Proxy `/api/` and `/health` to `api:3000` over Compose internal network, not to `localhost` from inside container.
- Demo endpoints are deliberately not publicly secure without authentication. For internet deployment, **must** add auth + ownership and rate limits first; current MVP intended for localhost only.
- Sanitize SVG input by using known primitive IDs/validated hex palette values; never interpolate arbitrary string into SVG markup.
- No raw SQL concatenation; no secrets in frontend build; limit payload size.
- Idempotent breeding ensures retries cannot create duplicated child plants.
- API response and schema types must be shared where sensible; avoid circular import chains.

View file

@ -0,0 +1,12 @@
# First message to Codex
Read `AGENTS.md` and the whole `docs/` handoff. You are implementing a real repository, not writing another plan. Begin with **milestone M0 only** from `docs/IMPLEMENTATION_PLAN.md`: scaffold the workspaces, create all Dockerfiles, web reverse proxy and a responsive Plant Laboratory shell with a working API and Postgres migrations. Keep the requested `.env` values (`HOST_PORT=8155`, `PUBLIC_DOMAIN=http://localhost`). Do not add unnecessary frameworks. Execute the M0 acceptance commands, fix failures, update `docs/PROGRESS.md`, then report results and ask to proceed to M1. All later milestones must follow the exact order and contracts specified.
# Subsequent prompts
- `Implement M1 exactly as specified, validate it, update PROGRESS.md, and report.`
- `Implement M2 exactly as specified, validate it, update PROGRESS.md, and report.`
- `Implement M3 exactly as specified, validate it, update PROGRESS.md, and report.`
- `Implement M4 exactly as specified, validate it, update PROGRESS.md, and report.`
For more independent execution: `Implement M0 through M4 sequentially, checking each acceptance gate and recording verified results. Do not skip failed gates.`

12
docs/DECISIONS.md Normal file
View file

@ -0,0 +1,12 @@
# Architectural decisions (fixed for v1)
- First deliverable is Plant Laboratory, not the entire idle economy.
- React/Vite frontend; Fastify backend; PostgreSQL; npm workspaces; Docker Compose; TypeScript across code.
- Illustrated 2D original SVG primitives; pure deterministic renderer with optional PixiJS **later** only if performance demands.
- Diploid fixed-locus genetics; explicit seed and versions; no bio-realism claims.
- One demonstrator species (`velvetleaf`), other species supported by schema but not required before M4.
- Two-parent controlled breeding, server authoritative and idempotent.
- Single sandbox demo player, no auth or public release at MVP stage.
- English UI for v1, sensible localization-ready keys later.
- Simple focused tests over test-heavy process; acceptance gates must work.
- No cloud API dependencies for game operation.

44
docs/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,44 @@
# Docker Compose deployment/runbook
## Requested configuration
`.env` includes exactly:
```
HOST_PORT=8155
PUBLIC_DOMAIN=http://localhost
```
The actual **browser URL** is `http://localhost:8155`. `PUBLIC_DOMAIN` is the domain origin requested by owner and deliberately has no port. For local URL construction derive `PUBLIC_BASE_URL=http://localhost:8155`; prefer relative API URLs (`/api/v1/...`) on the web client. Do not send client requests directly to container name `api` (not browser-resolvable).
## M0 required deliverables
- `apps/web/Dockerfile`: multi-stage Node build → lightweight nginx runtime, serving Vite output and forwarding `/api/`, `/health` to `api:3000`. Create nginx config under `apps/web/nginx.conf`. Use SPA history fallback to `index.html`; ensure `/health` and `/api/` proxy rules take precedence.
- `apps/server/Dockerfile`: multi-stage Node build, Node runtime listening on `0.0.0.0:3000` and health endpoint.
- `compose.yaml` is provided; adapt only if necessary for a **working**, secure Compose setup, preserving exposed host port, private DB/API, healthcheck and volume. If Node native dependencies require extra Docker libraries, document why.
- Use `NODE_ENV=development` only for initial local setup; deployment builds should run production assets and API with runtime environment.
- Implement Drizzle migrations on container initialization as a controlled documented command, not an uncontrolled migration loop. Provide `npm run db:migrate`, `npm run db:seed`.
- `docker compose config` should parse correctly before Docker build.
## Commands
```bash
# local bootstrap (after M0 files created)
docker compose config
docker compose up --build -d
docker compose ps
curl -fsS http://localhost:8155/health
# Stop (keep database)
docker compose down
# NEVER use -v unless intentionally deleting data
docker compose down -v
```
## Critical security note
The starter `.env` has a deliberately weak local-only placeholder password. Generate a strong password before exposing the app or sharing `.env`. `.env` is gitignored. In this MVP there is no user authentication: **do not open the app to the public internet** until auth/access control and API throttling are implemented.
## Future reverse proxy
Later replace `PUBLIC_DOMAIN` with a true HTTPS origin and configure TLS at the reverse proxy; no fixed 8155 should be appended for an external HTTPS origin. Keep Compose `HOST_PORT` configurable; enable trusted proxy headers only for known proxy. Do not overbuild this now.
## Implemented M0 bootstrap
Compose publishes web on loopback (`127.0.0.1:${HOST_PORT}:8080`), with nginx running as an unprivileged user. API and DB have no published ports. The `bootstrap` one-shot service uses the API image to run `node dist/migrate.js` then `node dist/seed.js`; API waits for successful completion. A PostgreSQL transaction advisory lock serializes migration execution. A migration tracking table prevents repeat schema application. Seeds use conflict-safe inserts and preserve existing records.
For explicit reruns use `docker compose run --rm bootstrap node dist/migrate.js` or `docker compose run --rm bootstrap node dist/seed.js`. Runtime images contain compiled JavaScript; root npm migration/seed scripts use TypeScript tooling for host development with an explicit `DATABASE_URL`. Compose production mode overrides the starter development value without modifying `.env`.

59
docs/GENETICS_SPEC.md Normal file
View file

@ -0,0 +1,59 @@
# Genetics contract v1
## Basic representation
This is an intentional **game model** inspired by Mendelian genetics. It is not rigorous botanical genetics. Genomes are diploid: each locus is `[alleleFromParentA, alleleFromParentB]` (ordering retained for parental origin if useful). The trait catalog is species-family scoped, versioned and data-driven.
Initial loci:
| key | alleles | mode | visible phenotype |
|---|---|---|---|
| pigment | `G,P,R,Y` | categorical dominance `P > R > Y > G` for v1 | leaf base palette |
| leafShape | `O,L,S` | `L > S > O` | oval/lobed/pointed silhouette |
| leafPattern | `S,V,M` | `V > M > S` | solid/variegated/marbled |
| growthHabit | `C,B,T` | `T > B > C` | compact/bushy/trailing |
| flowerColor | `W,P,B,R` | `B > R > P > W` | flower palette |
| flowerDensity | `L,M,H` | `H > M > L` | number of blooms |
Dominance order is a gameplay simplification and is **not** an assertion about real plant biology. Use stable, named IDs in catalog; do not rely on one-letter characters to be globally unique between loci.
## Determinism contract
- `resolvePhenotype(speciesId, genome, mutations, catalogVersion): Phenotype` pure, no RNG.
- `layoutPlant(speciesId, phenotype, visualSeed, growthStage, rendererVersion): PlantScene` pure; same inputs result in same scene bytes/geometry to meaningful precision.
- `breed(parentA, parentB, seed, catalogVersion): BreedingResult` pure; reject incompatible species, select one parental allele per locus with uniform seeded draw, independently across loci for v1. Use fixed PRNG and documented serialization of seed. Save source seed, engine version and result in breeding event.
- `generateSeed()` server-only with secure randomness; genetics gets explicit numerical seed. Use a testable seeded generator such as Mulberry32 or PCG32 and document cross-runtime integer semantics.
- `visualSeed` affects only cosmetic leaf placement/density jitter within the constraints of resolved phenotype. Never affects hereditary traits.
- Allele catalog/engine version changes must not silently rewrite existing stored genotypes. Keep backwards-compatible resolution or migration scripts.
## Cross compatibility
- Species have `compatibilityGroup`, plus optional allowed partner overrides.
- Default: only same-group species can cross. Unapproved combinations reject with machine code `INCOMPATIBLE_SPECIES`.
- In v1, seed offspring species is parent A if same species, otherwise use a **specified deterministic hybrid rule**; initially disallow interspecies even within group until hybrids are defined in data. Code should have a clear compatibility extension point.
## Mutation rules
- Per-locus mutation is optional and small; **baseline probability 0.005 per inherited allele** in configurable catalog; when triggered replace inherited allele with a *different valid allele* selected uniformly. Persist event log of mutations.
- Rare named morphological mutation `golden_veins` is an explicit modifier with a separate configurable triggered probability, initially **0.001 per offspring**, rolled only at breeding. It changes vein rendering only, not chromosome alleles.
- These are example balance values, not promises of rarity tiers; capture actual result via tests.
- Mutation outcome must be committed once with breeding transaction, not recomputed on fetching plant.
## Rarity policy
- Rarity is a **derived, versioned classification**, not an independent RNG reward.
- Score `0..100`: +20 for each recessive unusual phenotype (max +40), +20 for variegated/marbled patterns, +25 for named rare mutation, +15 for other uncommon combination; clamp 100. Define unusual traits per species catalog rather than hardcoding across all species.
- Tier thresholds: Common 0–9, Uncommon 10–24, Rare 25–44, Epic 45–64, Legendary 65–84, Mythic 85–100. Some tiers may be inaccessible before more traits/species are added; do not misrepresent tier frequency.
- `rarityPolicyVersion=1`, store score and tier at birth with policy version; provide a recompute command for future version upgrades, but no automatic silent rewrite.
- Display collection frequency (from actual dataset) separately from rarity tier.
## Breeding algorithm outline
1. Validate species compatibility, complete genomes and locus alleles using Zod.
2. Instantiate deterministic RNG from event seed.
3. At each locus, draw exactly one of each parent's two alleles; invoke mutation checks in **stable sorted locus order**.
4. Roll named mutations in stable sorted ID order (fixed order documented).
5. Resolve phenotype and classify rarity.
6. Derive fresh child `visualSeed` deterministically from event seed; persist child, generation `max(parents)+1`, parent IDs, breed event and costs atomically.
7. Return resolved phenotype and persisted child.
## Tests (mandatory, small and meaningful)
- `Pg × Pg` in a single dominant locus: 25% homozygous dominant, 50% heterozygous, 25% homozygous recessive after >=10,000 draws with mutation OFF, tolerances ±2 percentage points.
- Two parents with same genotype and fixed seed always reproduce same child.
- Same genome, different visualSeed: same phenotype, different cosmetics.
- Invalid alleles, missing loci and incompatible species fail.
- Mutations never create an unknown allele and are logged.
- No simultaneous duplicate breeding can spend resources twice (when economy implemented); meanwhile transaction persists exactly one child per idempotency key.

49
docs/GRAPHICS_SPEC.md Normal file
View file

@ -0,0 +1,49 @@
# Graphics and renderer architecture v1
## Principle
No pre-rendered combination explosion. Build plants from **illustrated hand-authored SVG primitives** composed into a deterministic scene. Species style is recognizable: no generic random leaves substituting for all botanical families.
## Module shape
- `packages/renderer/src/layout.ts`: pure scene generation (geometry, layer ordering, deterministic placements).
- `packages/renderer/src/renderSvg.ts`: scene → **safe deterministic SVG string** with validated colors/asset paths, fixed viewBox `0 0 512 512`, no scripts/external href.
- `apps/web/src/components/PlantPreview.tsx`: SVG display wrapper; supports 64, 128, 512 pixel responsive presentation.
- Exports: browser SVG native; PNG via canvas using locally generated serialized SVG with same-origin/Blob URLs and graceful error; JSON genome export separate.
## Render stack, back to front
1. soft ground shadow; 2. pot rear; 3. rear leaves; 4. stem branches; 5. front leaves; 6. blooms/inflorescence; 7. pigment patterns + veins; 8. pot lip and highlights.
## Primitive asset contract
SVG primitives saved under `assets/species/<slug>/` as `.svg` source and optional typed path definitions. Each primitive declares:
```
{
"assetId": "velvetleaf/leaf_lobed_v1",
"category": "leaf",
"anchor": [0.5, 0.92],
"bounds": [0, 0, 100, 140],
"paletteSlots": ["leafBase", "leafShade", "vein", "variegation"],
"rendererVersion": 1
}
```
Each path has canonical coordinates and must be paintable using declared palette slots. No bitmap/image elements, filter-heavy effects, third-party assets, arbitrary scripts or CSS foreignObject.
## v1 authored assets
- Species `velvetleaf`: 3 silhouettes (oval, lobed, pointed), each with veins and highlights.
- 3 branch templates (compact, bushy, trailing) and stem primitive.
- 4 bloom variants / petals, 2 pots, ground shadow.
- 4 growth stages: seedling, juvenile, mature, flowering. Bloom graphics hidden until flowering stage. Stage affects geometry/counts, never genotype.
- 6 curated palettes, mapping allele phenotype to leaf colors. Variegation and marbling are **masks or path overlays**, not arbitrary giant color rectangles.
- Golden veins mutation renders as restrained warm-gold vein overlay.
## Scene and repeatability
- Layout output is a data tree: layer, assetId, x, y, rotation, scale, palette map, optional clipping/masking config.
- Use seeded RNG for branch/leaf placement, but do not randomize core silhouette beyond recognition.
- Bounds must never clip major leaves or pot. Maintain consistent scale across gallery cards.
- Render to SVG with stable ID namespace per preview (no collisions when multiple plants appear on the page). IDs derived from scene instance + fixed index; do not affect visual deterministic geometry.
- Avoid React rerender of all 100 plants when changing one editor control; memoize based on canonical render inputs.
## Acceptance visual QA
- At least 12 intentional variants (3 leaf shapes × 2 pigments × 2 patterns) clearly distinguishable; manually visually inspect using rendered browser screenshot.
- Large preview remains attractive at 512px, readable at 64px; avoid jagged joins and disproportionate pots.
- 100 small previews render without unacceptable jank on a normal desktop browser.
- Exported SVG opens in a standard SVG viewer without external assets; PNG export has correct alpha/background as selected.
- All graphics are original vector assets stored in the repository; document creator and licensing in `assets/README.md`.

View file

@ -0,0 +1,89 @@
# Implementation plan — explicit Codex execution gates
## Working rules
Complete milestones sequentially. Finish one and validate it before moving on. Use `docs/PROGRESS.md` as execution log. Do not implement speculative game systems. In the event of a spec conflict, prioritize: `AGENTS.md`, then this plan, then narrower contracts. Document deviations instead of silently redefining requirements. Prefer small components, one implementation per responsibility and clear error states.
## M0 — infrastructure & bootable application
**Objective:** real running web + API + database through Docker Compose, before domain features.
Tasks:
1. Initialize npm workspace root with `apps/web`, `apps/server`, `packages/genetics`, `packages/renderer`, `packages/rules`, `packages/shared`. Configure shared TS config, ESLint if straightforward, testing via Vitest and root scripts `dev`, `build`, `typecheck`, `test`, `db:migrate`, `db:seed`.
2. Scaffold React/Vite + router + Tailwind, install shadcn/ui prerequisites only (add actual components as needed), Zustand and TanStack Query; a proper illustrated shell/header and top nav. Implement `/lab`, `/breed`, `/gallery`, `/about`; all pages may show clearly labeled empty states in M0.
3. Scaffold Fastify TypeScript API, Zod, Drizzle PostgreSQL connection, `/health` returning 200 if DB query succeeds. Implement uniform API errors.
4. Create DB schema and initial migration consistent with `docs/API_AND_DATA.md`, demo player + `velvetleaf` catalog seed; startup/bootstrap behavior must be repeatable. Seed operation idempotent.
5. Create production-quality Dockerfiles, nginx SPA/proxy config, .dockerignore. Make Compose from root work with provided .env (host 8155, public origin http://localhost).
6. Add simple README scripts, screenshots not mandatory yet. No credentials in Git.
Gate:
- `docker compose config` works.
- `docker compose up --build -d` produces healthy API/DB and reachable UI at `http://localhost:8155`.
- `curl -fsS http://localhost:8155/health` returns HTTP 200 JSON.
- Mobile page renders without horizontal overflow.
- `npm run typecheck`, `npm run test`, `npm run build` pass (root tooling installed and lockfile committed).
- Database survives `docker compose down` / `up` and cannot be reached via published Compose ports.
## M1 — genetics domain (pure package)
Tasks:
1. Create versioned catalogs/types, Zod validators, `validateGenome`, `resolvePhenotype`, `canBreed`, `breed`, `classifyRarity` as specified.
2. Implement a deterministic seeded PRNG with documented integer rules and deterministic cross-run results. Do not mix RNG into renderer.
3. Allele inheritance: 50/50 from each parent independently by locus; dominance policy per locus; named mutation logging; explicit exceptions for invalid/incompatible pairs.
4. Add proper unit tests on example cross, mutation boundaries, deterministic replay, phenotypes and rarity policy.
5. Implement `/api/v1/catalog/species` and `/api/v1/phenotype/resolve` with shared DTOs.
Gate:
- Example cross with mutations disabled yields 1:2:1 genotypes over 10k runs within ±2 percentage points.
- Same seed → exact same result, across calls.
- Cross-species breeding blocked unless compatibility is explicitly defined.
- Untrusted client alleles rejected by server.
- `npm run test`, `typecheck`, `build` pass.
## M2 — illustrated vector rendering
Tasks:
1. Create at least **original** handcrafted SVG primitive sources (see GRAPHICS_SPEC). Strong visual quality; use multiple paths, veins/shading, artistically composed plant silhouette.
2. Implement species-specific plant grammar and pure geometry layout with visualSeed, phenotype, 4 stages and stable IDs.
3. Render an isolated viewBox 512 SVG. Apply color palettes, silhouette switching, variegation/marbling and golden veins mutation. No unsafe injected XML.
4. Add export SVG and PNG with transparent or warm background selection, ensure rendered result matches preview.
5. Add gallery proof view showing at least 12 systematic variations and 100-seed smoke demo; perform browser visual QA and optimize if needed.
Gate:
- 3 leaf shapes, 2 pigments, 2 patterns visibly distinct.
- Same args produce same SVG geometry; same genotype with different visualSeed varies only cosmetically.
- 64px icon and 512px hero remain legible; no obvious clipping.
- SVG and PNG exports open and visually match.
- 100 plants render without noticeable frozen UI on typical desktop.
- Build/tests/typecheck pass.
## M3 — editor and persistent breeding
Tasks:
1. Build polished Lab: live plant preview, species selector, diploid allele editor, stage selector, visual seed control and mutation toggles (manual preview only, not falsely claimed as bred mutations), genotype/phenotype and score explanations.
2. Add server-side `POST /api/v1/plants`, `GET /api/v1/plants/:id`, pagination GET. Persist canonical genomes, seeds, lineage fields. Seed demo parents.
3. Implement server authoritative `POST /api/v1/breeding` idempotent and transactional, plus history endpoint; create real child instance with securely selected event seed and recorded parent IDs.
4. Implement Breeding Lab screen: parent selection, validation, cross, result card and lineage navigation. Show accurate example probabilities only for single-gene preview.
5. Mutation logic and rarity scores computed once at child creation and persisted. Data migration scripts tested on clean DB.
Gate:
- Editing allele changes expected visible traits instantly.
- Breed yields child with exactly two persisted parents and generation +1.
- Retry same idempotencyKey returns same child, no duplicates.
- Cross incompatible species refuses with clear message.
- Browser reload persists generated plants.
- No server error leaks secrets. Tests/build/typecheck pass.
## M4 — collection gallery and hardening
Tasks:
1. Implement `/gallery` cards with filters, sorting and pagination, details inspector, lineage ancestry links and export JSON/SVG/PNG.
2. Create **100 deterministic varied demo plants** using seed script. Make seed idempotent so running twice does not duplicate.
3. Add helpful empty/loading/error states, toast/action confirmation, responsive layout and focus handling, reduced motion.
4. Polish botanical art direction and cards, ensure readability at 64px; inspect screenshots for desktop + mobile and fix visual issues.
5. Document API via concise examples, deployment constraints, future idle-game roadmap; verify no public auth claims or security oversell.
Gate:
- 100 plant gallery searchable/filterable without excessive UI lag.
- Exported SVG, PNG and JSON valid; opens without external resources.
- Lineage from bred child to parents navigable.
- 360px and 1440px layouts verified.
- `docker compose up --build -d`, health, tests, typecheck, build pass.
- Project ready for further iteration, **not** publicly launchable until auth/ownership controls added.
## Scope of next phase (do not code yet)
M5+ greenhouse economy (pots, seeds, currencies), offline time progression via event timestamps, upgrades and automation, seed shop, collection achievements, research, player accounts, secure multiplayer/trading. Should be planned after Plant Lab proof is accepted, not smuggled into MVP.

40
docs/PRODUCT_SPEC.md Normal file
View file

@ -0,0 +1,40 @@
# Product specification — Plant Laboratory v1
## Goal
A functioning botanical breeding sandbox with beautiful **illustrated 2D** assets. This is the first slice of a larger idle plant collection/greenhouse game, not a generic admin UI. Initial interface language English.
## Personas and core flow
1. Visitor opens responsive browser app at `/` and sees a lovely large plant rendered as SVG.
2. They choose species, edit diploid alleles, watch the plant change immediately, select a growth stage and set the cosmetic visual seed.
3. They select two parent plants and request breeding through the API; resulting child genotype and traceable parent IDs are saved by the server.
4. They browse a 100-plant visual gallery with filter by species, pigment, pattern and rarity, open details, inspect ancestry and export JSON/SVG/PNG.
5. At end of MVP, single demo player has local-account-free sandbox persistence; no login. Build ownership into schema for later authenticated accounts. Avoid exposing a destructive global reset in production.
## Pages/screens
- `/lab`: hero live SVG preview (large), genome editor (editable locus/allele pairs), controls for stage, visualSeed, mutation flags, responsive inspector with phenotype and derived rarity.
- `/breed`: two parent pickers (from persisted sample plants), compatibility indicator, breeding button, outcome card, parent links, historic breed events.
- `/gallery`: responsive 2–5 column cards, virtualize or paginate when >100, species/trait/rarity filters, details sheet, download actions.
- `/about`: clear description of educational *fictional game genetics*, not a scientifically accurate botany simulator.
- `/` redirects to `/lab`.
## Visual design
Mood: calm botanical cabinet of curiosities; credible handmade illustration rather than glossy tech dashboard. Canvas warm ivory, forest green controls, restrained terra-cotta, rare colors as accents. Dark mode optional, not prerequisite. Leaves and flowers must have deliberate illustrated shapes (veins, subtle layered shading); avoid simple emoji or generic circles.
Design tokens suggested:
- Background `#F6F3E8`; surface `#FFFCF4`; ink `#20392D`; muted `#6A7C70`.
- Primary `#32684A`; secondary `#B76F52`; borders `#D8DED2`.
- Rarity accents: Common `#78977B`, Uncommon `#4F9D91`, Rare `#5489C6`, Epic `#9366C3`, Legendary `#CB9C39`, Mythic `#C76D87`.
- Use consistent typography (system serif for botanical headings, sans for controls); avoid remote font dependencies.
## Core UX acceptance
- Clear keyboard-focus indication, semantic controls, labels, WCAG AA targets where feasible.
- Mobile 360px width does not overflow. Desktop 1440px has split inspector/gallery composition.
- SVG shapes readable at 64px card and at 500px inspector.
- Select altered allele → phenotype and illustration change without page refresh, for traits with visible manifestations.
- Controls show genotype **and** phenotype separately.
- Show why crossing is blocked if species incompatible.
- Show generated probabilities only if accurate to implemented model; do not imply independent rarity dice rolls.
- Reduced-motion users do not get excessive animations. No external runtime artwork APIs.
## Explicitly out of scope for this handoff
Accounts/auth, inventory purchase/harvest economy, greenhouse automation/timers, social trading, realtime multiplayer, paywalls, AI-generated graphics, crop disease. Add later in separate plan after Laboratory MVP.

40
docs/PROGRESS.md Normal file
View file

@ -0,0 +1,40 @@
# Implementation progress
- [x] M0 — workspaces, Compose, healthcheck, migrations, responsive shell.
- [ ] M1 — genetics contracts, deterministic PRNG, pure engine + tests.
- [ ] M2 — procedural illustrated SVG renderer, exports, visual QA.
- [ ] M3 — lab editor, server persistence, breeding flow.
- [ ] M4 — gallery, provenance, UX polish, full acceptance.
## M0 — 2026-10-28
Implemented npm workspaces (`apps/web`, `apps/server`, and four pure package boundaries), shared strict TypeScript configuration, Vitest, and root development/build/typecheck/test/migration/seed commands. Installed compatible dependencies and generated `package-lock.json`.
Added React/Vite/Tailwind shell with router, TanStack Query health indicator, Zustand and shadcn prerequisites. Four routes have explicit M0 empty states, keyboard focus and skip navigation, responsive layout and reduced-motion support. Original static botanical SVG is shell decoration; no genetics or procedural renderer is claimed at M0.
Added Fastify API with uniform safe errors, DB-dependent `/health`, Zod runtime configuration, local/reverse-proxy public URL derivation, PostgreSQL pool and Drizzle schema. Checked-in initial SQL migration creates species, players, plants and breeding events with ownership/lineage references, uniqueness, indexes and basic data constraints. Transaction-locked migration tracking and idempotent catalog/player seeding run in a one-shot Compose bootstrap service before API startup.
Added non-root production Docker images, nginx history fallback and API/health reverse proxy, `.dockerignore`, local-only web publication and persistent PostgreSQL volume. Updated README and asset provenance. `.env` remains unchanged and ignored; requested values are `HOST_PORT=8155`, `PUBLIC_DOMAIN=http://localhost`.
### Verified acceptance
- `docker compose config --quiet`: passed.
- `docker compose up --build -d`: passed; API and PostgreSQL healthy; bootstrap exits successfully.
- `docker compose ps`: API/DB running healthy; web running at `127.0.0.1:8155`.
- `curl.exe -fsS http://localhost:8155/health`: HTTP 200, `{"status":"ok","database":"connected"}` through nginx.
- `npm run typecheck`: passed across all six workspaces.
- `npm run test`: 3 tests passed (DB health success, safe failure/404, local versus reverse-proxy URL configuration).
- `npm run build`: all workspaces passed; Vite generated production assets.
- Repeated `docker compose run --rm bootstrap node dist/migrate.js` and `node dist/seed.js`: passed; counts remain one player, one species, one migration.
- `docker compose down` then `docker compose up -d --wait`: passed without removing volumes. Player UUID and original `created_at` remained identical; velvetleaf catalog persisted.
- Docker inspection reports empty published port bindings for API and DB. Only web publishes a host port; it binds to loopback.
- Headless Chrome visited all four routes at 360px and 1440px, with zero JavaScript page errors and document width equal to viewport width. `/` redirects to `/lab`. Desktop and mobile screenshots visually inspected; see `artifacts/m0/1440.png`, `artifacts/m0/360.png`, and `artifacts/m0/browser-checks.json`.
- Git ignore verification confirms `.env` excluded; no live credentials in tracked source.
### Execution notes and limits
Initial Docker build started before npm finished writing the lockfile and failed; rebuilding with the completed lockfile succeeded. Initial typecheck found Fastify's unknown error type; handler now narrows it safely. Vitest's temporary-cache writes were blocked by the Windows sandbox; rerunning with approved expanded execution passed. Docker, npm network access and headless Chrome also required expanded execution in this environment.
No M1–M4 behavior is implemented. Domain packages intentionally establish empty module boundaries until their milestone. No ESLint was added; strict TypeScript and focused tests provide the M0 checks. This remains a local demo without authentication.
M0 gate complete. Stop here. Next: M1 genetics contracts, deterministic PRNG, inheritance engine and tests, after user instruction to proceed.

3894
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "botanica",
"private": true,
"version": "0.0.0",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "concurrently \"npm run dev -w @botanica/server\" \"npm run dev -w @botanica/web\"",
"build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "vitest run",
"db:migrate": "npm run db:migrate -w @botanica/server",
"db:seed": "npm run db:seed -w @botanica/server"
},
"devDependencies": {
"typescript": "~5.9.3",
"vitest": "^4.1.0",
"concurrently": "^9.2.1",
"@types/node": "^24.0.0"
}
}

View file

@ -0,0 +1,11 @@
{
"name": "@botanica/genetics",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

View file

@ -0,0 +1,11 @@
{
"name": "@botanica/renderer",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

View file

@ -0,0 +1,11 @@
{
"name": "@botanica/rules",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

View file

@ -0,0 +1,11 @@
{
"name": "@botanica/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

11
tsconfig.base.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}