Implement M4 persistent collection, exports and accessibility hardening

This commit is contained in:
Christian Johannesen 2026-10-28 11:07:14 +01:00
parent daa461827f
commit d0c37429b0
48 changed files with 2182 additions and 604 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary

View file

@ -1,10 +1,10 @@
# Plant Laboratory / Botanica # Plant Laboratory / Botanica
M0–M2 provide a running React laboratory, deterministic genetics, illustrated SVG renderer, Fastify API, PostgreSQL schema and repeatable bootstrap. Persistent plant creation and breeding follow in M3. A working local botanical sandbox: edit paired alleles, preview illustrated velvetleaf plants, save founders, breed traceable offspring and explore a persistent collection. M0–M4 are implemented. The game uses simplified fictional genetics.
## Run ## Run
Keep `.env` local. Copy `.env.example` if missing; preserve `HOST_PORT=8155` and `PUBLIC_DOMAIN=http://localhost`. Keep `.env` local and preserve `HOST_PORT=8155`, `PUBLIC_DOMAIN=http://localhost`. Copy `.env.example` if the file is missing.
```sh ```sh
npm ci npm ci
@ -14,37 +14,48 @@ docker compose ps
curl -fsS http://localhost:8155/health 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. Open http://localhost:8155. Only the web entrypoint is published, bound to loopback. API and PostgreSQL stay on the Compose network. The one-shot bootstrap service applies transaction-locked, tracked migrations, then seeds the sandbox owner/catalog and 100 deterministic demo plants. Repeat runs do not duplicate or overwrite plants. A named volume retains your collection across `docker compose down` / `up`. Do not use `down -v` unless intentionally deleting all saved data.
## Development and checks ## Explore
- `/lab`: complete diploid allele editor, phenotype and rarity explanations, stage/cosmetic seed controls, manual mutation preview, image exports and save designed founder.
- `/breed`: saved parent pickers, compatibility, server-authoritative breeding, saved child, lineage and journal. Failed requests retry with the same idempotency key.
- `/gallery`: persisted collection with search, trait filters, sorting and 24-item pagination. The keyboard-accessible specimen inspector includes genotype, ancestry, birth records and JSON/SVG/PNG exports.
- `/plants/:id`: directly linkable plant detail and ancestry navigation.
- `/studies`: M2 systematic 12-variation atlas and 100-seed renderer proof.
- `/about`: fictional genetics, the local prototype and future scope.
Manual golden-vein previews affect image exports only. Saving from the editor creates a new founder without those preview mutations. Bred mutations and rarity outcomes are recorded once on the server. Collection frequency is shown separately from the trait-derived rarity tier.
## Checks and development
```sh ```sh
npm run typecheck npm run typecheck
npm run test npm run test
npm run build npm run build
npm run dev npm run test:integration
docker compose -f compose.test.yaml down
``` ```
`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. The regular test command builds pure package dependencies, runs domain/API tests and checks original SVG source synchronization. Integration cases are skipped unless `TEST_DATABASE_URL` is provided. `test:integration` builds a separate `botanica-checks` stack with a fresh tmpfs PostgreSQL database, no published ports and no access to the application's saved data. It tests clean migrations, idempotent 100-plant seed, persistence, concurrency, rollback, ownership, filter/search/sort and pagination. Cleanup removes only the temporary test stack.
Root `npm run db:migrate` and `npm run db:seed` require `DATABASE_URL`. Against Compose: `npm run dev` starts Vite (5173) and API (3000); provide a reachable PostgreSQL `DATABASE_URL` in the host environment. Compose intentionally does not publish its database. Prefer Compose for the complete runtime.
Root `npm run db:migrate` / `npm run db:seed` use TypeScript tooling with an explicit `DATABASE_URL`. Against Compose:
```sh ```sh
docker compose run --rm bootstrap node dist/migrate.js docker compose run --rm bootstrap node dist/migrate.js
docker compose run --rm bootstrap node dist/seed.js docker compose run --rm bootstrap node dist/seed.js
``` ```
Migrations are checked-in SQL tracked in `schema_migrations`; player seeds preserve existing records; the catalog seed synchronizes the current catalog definition without changing plants or ancestry. `.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. Production images run as non-root and contain compiled code. nginx handles SPA routes and proxies `/api/` and `/health`; relative browser URLs keep private container hostnames out of clients. `PUBLIC_BASE_URL` derives localhost plus the exposed port, or just the HTTPS origin behind a production reverse proxy. `.env` is ignored by both Git and Docker build contexts.
Routes: `/` redirects to `/lab`, which contains curated rendering studies, four stages, cosmetic seed and manual golden-vein preview, derived trait inspection, and SVG/PNG export with warm or transparent background. `/gallery` is a proof atlas of 12 systematic variants plus a 100-seed rendering demo. `/breed` remains a clearly labeled M3 empty state; `/about` explains the fictional genetics model. These studies are not saved plants. No authentication: this sandbox is intended for local use. ## Browser acceptance and artwork
Read [the implementation plan](docs/IMPLEMENTATION_PLAN.md) and [progress log](docs/PROGRESS.md) for acceptance gates. `scripts/verify-m3.cjs` and `scripts/verify-m4.cjs` exercise the real application with Playwright/Chrome and write screenshots, exports and measurements to `artifacts/`. Provide a Playwright package through `PLAYWRIGHT_MODULE` and optionally a browser binary through `BROWSER_EXECUTABLE`. Run the M3 script before M4 so the latter can follow its persisted child and parents. Browser checks intentionally retain their labeled founder/offspring as evidence of persistence. M0/M2 artifacts document earlier gates; their historical scripts target their respective earlier UI.
The pure renderer composes 15 original handcrafted SVG primitives, six palettes and deterministic layout. `npm run assets:write` regenerates primitive sources from typed path definitions; tests reject source drift. [Artwork provenance](assets/README.md) documents CC0 assets. No runtime image services, remote fonts, trackers or paid APIs are used.
## Domain and artwork See [API examples](docs/API_EXAMPLES.md), [deployment](docs/DEPLOYMENT.md), [progress](docs/PROGRESS.md) and [future roadmap](docs/ROADMAP.md).
Genetics v1 uses fixed-locus diploid genomes, explicit dominance, seeded Mulberry32 inheritance, mutation logs and derived rarity. The catalog and phenotype preview APIs are available under `/api/v1`; see `docs/API_AND_DATA.md` for request examples. Pure packages use built JS exports; root build/typecheck/test/dev scripts first build package dependencies in order. This is a local single-owner demo without authentication. It is ready for further iteration, not public internet launch. Add real authentication, ownership/access controls, throttling and deployment hardening first.
Velvetleaf is composed from 15 original handcrafted SVG primitives and six curated palettes. `npm run assets:write` regenerates standalone primitive sources from the typed paths; tests reject source drift. `assets/README.md` documents CC0 artwork provenance. Geometry is deterministic and all exported assets are self-contained.
For optional browser acceptance QA, install/provide Playwright and Chrome, then run `node scripts/verify-m2.cjs` against the running Compose app. `PLAYWRIGHT_MODULE` may point to an available Playwright package and `BROWSER_EXECUTABLE` to a browser binary. The script checks desktop/mobile overflow, 12/100 rendering studies, namespace collisions, seed invariance, real downloads, PNG alpha/background and pixel equivalence to the live preview. Screenshots and measurements are written to `artifacts/m2`.

View file

@ -0,0 +1,172 @@
import type { Pool } from "pg";
import { z } from "zod";
import { velvetleaf, type Locus } from "@botanica/genetics";
import type { PlantListQuery, CollectionStats } from "@botanica/shared";
import type { PlantRow } from "./plants.js";
import { ApiError } from "./errors.js";
const cursorSchema = z.strictObject({
id: z.uuid(),
sort: z.enum(["newest", "oldest", "name", "rarity"]),
value: z.union([z.string().max(100), z.number().int().min(0).max(100)]),
filterKey: z.string().max(500),
});
export async function listCollection(
pool: Pool,
ownerId: string,
query: PlantListQuery,
) {
const values: unknown[] = [ownerId],
bind = (value: unknown) => {
values.push(value);
return "$" + values.length;
};
const trait = (locus: Locus) => {
const definition = velvetleaf.loci[locus];
return (
"CASE " +
definition.dominance
.map(
(allele) =>
"WHEN (genome -> " +
bind(locus) +
"::text) ? " +
bind(allele) +
"::text" +
" THEN " +
bind((definition.labels as Record<string, string>)[allele]),
)
.join(" ") +
" END"
);
};
const cte =
"WITH collection AS (SELECT p.*, " +
trait("pigment") +
" AS resolved_pigment, " +
trait("leafPattern") +
" AS resolved_pattern FROM plants p WHERE owner_id=$1) ";
const conditions: string[] = [];
for (const [column, value] of [
["species_id", query.species],
["rarity_tier", query.rarity],
["resolved_pigment", query.pigment],
["resolved_pattern", query.pattern],
] as const)
if (value) conditions.push(column + " = " + bind(value));
if (query.q)
conditions.push(
"(name || ' ' || id::text || ' ' || resolved_pigment || ' ' || resolved_pattern) ILIKE " +
bind("%" + query.q.replace(/[\\%_]/g, "\\$&") + "%"),
);
const where = conditions.length ? " WHERE " + conditions.join(" AND ") : "";
const count = await pool.query(
cte + "SELECT count(*) AS count FROM collection" + where,
values,
);
const filterKey = JSON.stringify([
query.species ?? "",
query.rarity ?? "",
query.pigment ?? "",
query.pattern ?? "",
query.q ?? "",
]);
const order = {
newest: { column: "created_at", direction: "DESC", cast: "timestamptz" },
oldest: { column: "created_at", direction: "ASC", cast: "timestamptz" },
name: { column: "lower(name)", direction: "ASC", cast: "text" },
rarity: { column: "rarity_score", direction: "DESC", cast: "integer" },
}[query.sort];
let after = "";
if (query.cursor) {
let cursor: z.infer<typeof cursorSchema>;
try {
cursor = cursorSchema.parse(
JSON.parse(Buffer.from(query.cursor, "base64url").toString("utf8")),
);
} catch {
throw new ApiError(400, "INVALID_CURSOR", "The page cursor is invalid.");
}
if (cursor.sort !== query.sort || cursor.filterKey !== filterKey)
throw new ApiError(
400,
"INVALID_CURSOR",
"Reset pagination after changing filters or sort order.",
);
if (
order.cast === "timestamptz" &&
!z.iso.datetime({ offset: true }).safeParse(cursor.value).success
)
throw new ApiError(400, "INVALID_CURSOR", "Invalid timestamp cursor.");
if (order.cast === "integer" && typeof cursor.value !== "number")
throw new ApiError(400, "INVALID_CURSOR", "Invalid rarity cursor.");
after =
(where ? " AND " : " WHERE ") +
"(" +
order.column +
",id) " +
(order.direction === "DESC" ? "<" : ">") +
" (" +
bind(cursor.value) +
"::" +
order.cast +
"," +
bind(cursor.id) +
"::uuid)";
}
const rows = (
await pool.query<PlantRow>(
cte +
`SELECT *, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_time, lower(name) AS sort_name FROM collection` +
where +
after +
" ORDER BY " +
order.column +
" " +
order.direction +
",id " +
order.direction +
" LIMIT " +
bind(query.limit + 1),
values,
)
).rows;
const items = rows.slice(0, query.limit),
last = items.at(-1);
const nextCursor =
rows.length > query.limit && last
? Buffer.from(
JSON.stringify({
id: last.id,
sort: query.sort,
filterKey,
value:
query.sort === "rarity"
? last.rarity_score
: query.sort === "name"
? last.sort_name
: last.cursor_time,
}),
).toString("base64url")
: null;
return { rows: items, total: Number(count.rows[0].count), nextCursor };
}
export async function collectionStats(
pool: Pool,
ownerId: string,
): Promise<CollectionStats> {
const rows = (
await pool.query<{
rarity_tier: NonNullable<keyof CollectionStats["byRarity"]>;
count: string;
}>(
"SELECT rarity_tier,count(*) FROM plants WHERE owner_id=$1 GROUP BY rarity_tier",
[ownerId],
)
).rows;
return {
total: rows.reduce((sum, row) => sum + Number(row.count), 0),
byRarity: Object.fromEntries(
rows.map((row) => [row.rarity_tier, Number(row.count)]),
),
};
}

View file

@ -0,0 +1,32 @@
import { genomeSchema, createRng, type Genome } from "@botanica/genetics";
export function demoSpecimen(index: number) {
const rng = createRng(5100 + index),
pick = <T>(values: readonly T[]) =>
values[Math.floor(rng() * values.length)];
const pigment = (["G", "P", "R", "Y"] as const)[index % 4],
shape = (["O", "L", "S"] as const)[Math.floor(index / 4) % 3],
pattern = (["S", "V", "M"] as const)[Math.floor(index / 12) % 3],
habit = (["C", "B", "T"] as const)[Math.floor(index / 36) % 3],
flower = pick(["W", "P", "B", "R"] as const),
density = pick(["L", "M", "H"] as const);
const genome: Genome = genomeSchema.parse({
pigment: [pigment, pigment],
leafShape: [shape, shape],
leafPattern: [pattern, pattern],
growthHabit: [habit, habit],
flowerColor: [flower, flower],
flowerDensity: [density, density],
});
return {
id: "20000000-0000-4000-8000-" + String(index + 1).padStart(12, "0"),
name:
["Fern", "Plum", "Copper", "Citron"][index % 4] +
" · specimen " +
String(index + 3).padStart(3, "0"),
speciesId: "velvetleaf",
genome,
visualSeed: 5100 + index * 7919,
mutations: index % 17 === 0 ? (["golden_veins"] as ["golden_veins"]) : [],
creationSource: "demo" as const,
};
}

View file

@ -37,7 +37,7 @@ describe.skipIf(!databaseUrl)(
).toBe(1); ).toBe(1);
expect( expect(
Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count), Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count),
).toBe(2); ).toBe(100);
}); });
it("persists a server-validated designed founder with secure default seed and rejects immutable claims", async () => { it("persists a server-validated designed founder with secure default seed and rejects immutable claims", async () => {
const payload = { const payload = {
@ -90,7 +90,7 @@ describe.skipIf(!databaseUrl)(
seen.add(page.items[0].id); seen.add(page.items[0].id);
cursor = page.nextCursor; cursor = page.nextCursor;
} while (cursor); } while (cursor);
expect(seen.size).toBe(3); expect(seen.size).toBe(101);
expect( expect(
(await app.inject("/api/v1/plants?cursor=invalid")).statusCode, (await app.inject("/api/v1/plants?cursor=invalid")).statusCode,
).toBe(400); ).toBe(400);
@ -142,7 +142,7 @@ describe.skipIf(!databaseUrl)(
expect(result.event.mutationEvents).toEqual(expected.mutationEvents); expect(result.event.mutationEvents).toEqual(expected.mutationEvents);
expect( expect(
Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count), Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count),
).toBe(4); ).toBe(102);
expect( expect(
Number( Number(
(await pool.query("SELECT count(*) FROM breeding_events")).rows[0] (await pool.query("SELECT count(*) FROM breeding_events")).rows[0]
@ -222,6 +222,90 @@ describe.skipIf(!databaseUrl)(
expect( expect(
Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count), Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count),
).toBe(before); ).toBe(before);
await pool.query("DELETE FROM plants WHERE id=ANY($1::uuid[])", [
[alien, foreign],
]);
await pool.query("DELETE FROM species WHERE id='alien'");
await pool.query("DELETE FROM players WHERE id=$1", [owner]);
});
it("filters, searches and sorts the persisted collection with stable bounded pages", async () => {
for (const sort of ["newest", "oldest", "name", "rarity"]) {
const seen = new Set<string>();
let cursor = null;
let previous = null;
do {
const page = (
await app.inject(
"/api/v1/plants?limit=17&sort=" +
sort +
(cursor ? "&cursor=" + encodeURIComponent(cursor) : ""),
)
).json();
expect(page.items.length).toBeLessThanOrEqual(17);
for (const plant of page.items) {
expect(seen.has(plant.id)).toBe(false);
seen.add(plant.id);
if (previous && sort === "rarity")
expect(previous.rarity.score).toBeGreaterThanOrEqual(
plant.rarity.score,
);
if (previous && sort === "name")
expect(
previous.name.toLowerCase() <= plant.name.toLowerCase(),
).toBe(true);
previous = plant;
}
cursor = page.nextCursor;
} while (cursor);
expect(seen.size).toBe(103);
}
for (const filter of [
"pigment=purple",
"pattern=marbled",
"rarity=Legendary",
"species=velvetleaf",
]) {
const response = await app.inject("/api/v1/plants?limit=100&" + filter);
expect(response.statusCode).toBe(200);
const page = response.json();
expect(page.items.length).toBeGreaterThan(0);
for (const p of page.items) {
if (filter === "pigment=purple")
expect(p.phenotype.pigment).toBe("purple");
if (filter === "pattern=marbled")
expect(p.phenotype.leafPattern).toBe("marbled");
if (filter === "rarity=Legendary")
expect(p.rarity.tier).toBe("Legendary");
}
}
const matching = (
await app.inject("/api/v1/plants?q=Acceptance%20founder")
).json();
expect(matching.total).toBe(1);
expect(
(await app.inject("/api/v1/plants?q=zzzz-no-match")).json().total,
).toBe(0);
expect((await app.inject("/api/v1/plants?q=%25")).json().total).toBe(0);
expect(
(await app.inject("/api/v1/plants?sort=arbitrarySQL")).statusCode,
).toBe(400);
const first = (await app.inject("/api/v1/plants?limit=1")).json();
expect(
(
await app.inject(
"/api/v1/plants?sort=name&cursor=" +
encodeURIComponent(first.nextCursor),
)
).statusCode,
).toBe(400);
const stats = (await app.inject("/api/v1/collection/stats")).json();
expect(stats.total).toBe(103);
expect(
Object.values(stats.byRarity).reduce(
(a, b) => Number(a) + Number(b),
0,
),
).toBe(103);
}); });
it("rolls back the child when event insertion fails and returns a safe error", async () => { it("rolls back the child when event insertion fails and returns a safe error", async () => {
await pool.query( await pool.query(

View file

@ -5,12 +5,17 @@ import {
createPlantSchema, createPlantSchema,
breedingRequestSchema, breedingRequestSchema,
pageQuerySchema, pageQuerySchema,
plantListQuerySchema,
} from "@botanica/shared"; } from "@botanica/shared";
import { PlantRepository } from "./plants.js"; import { PlantRepository } from "./plants.js";
export function registerPlantRoutes(app: FastifyInstance, pool: Pool) { export function registerPlantRoutes(app: FastifyInstance, pool: Pool) {
const repository = new PlantRepository(pool); const repository = new PlantRepository(pool);
app.get("/api/v1/plants", (request) => app.get("/api/v1/plants", (request) =>
repository.listPlants(pageQuerySchema.parse(request.query)), repository.listPlants(plantListQuerySchema.parse(request.query)),
);
app.get("/api/v1/collection/stats", () => repository.stats());
app.get("/api/v1/plants/:id/birth", (request) =>
repository.birthEvent(z.object({ id: z.uuid() }).parse(request.params).id),
); );
app.get("/api/v1/plants/:id", (request) => app.get("/api/v1/plants/:id", (request) =>
repository.getPlant(z.object({ id: z.uuid() }).parse(request.params).id), repository.getPlant(z.object({ id: z.uuid() }).parse(request.params).id),

View file

@ -1,3 +1,5 @@
import { listCollection, collectionStats } from "./collection.js";
import type { PlantListQuery } from "@botanica/shared";
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { Pool, PoolClient, QueryResultRow } from "pg"; import type { Pool, PoolClient, QueryResultRow } from "pg";
import { z } from "zod"; import { z } from "zod";
@ -21,7 +23,7 @@ import type {
import { DEMO_PLAYER_ID } from "./constants.js"; import { DEMO_PLAYER_ID } from "./constants.js";
import { ApiError } from "./errors.js"; import { ApiError } from "./errors.js";
export const generateSeed = () => randomBytes(4).readInt32BE(); export const generateSeed = () => randomBytes(4).readInt32BE();
interface PlantRow extends QueryResultRow { export interface PlantRow extends QueryResultRow {
id: string; id: string;
name: string; name: string;
owner_id: string; owner_id: string;
@ -36,6 +38,7 @@ interface PlantRow extends QueryResultRow {
parent_b_id: string | null; parent_b_id: string | null;
created_at: Date; created_at: Date;
cursor_time?: string; cursor_time?: string;
sort_name?: string;
creation_source: PlantDto["creationSource"]; creation_source: PlantDto["creationSource"];
rarity_policy_version: 1; rarity_policy_version: 1;
rarity_score: number; rarity_score: number;
@ -201,36 +204,27 @@ export class PlantRepository {
client.release(); client.release();
} }
} }
async listPlants(query: { async listPlants(query: PlantListQuery): Promise<PageDto<PlantDto>> {
limit: number; const result = await listCollection(this.pool, this.ownerId, query);
cursor?: string;
}): Promise<PageDto<PlantDto>> {
const cursor = decodeCursor(query.cursor);
const rows = await this.pool.query<PlantRow>(
`SELECT *, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_time FROM plants WHERE owner_id=$1 AND ($2::timestamptz IS NULL OR (created_at,id)<($2::timestamptz,$3::uuid)) ORDER BY created_at DESC,id DESC LIMIT $4`,
[
this.ownerId,
cursor?.createdAt ?? null,
cursor?.id ?? null,
query.limit + 1,
],
);
const total = Number(
(
await this.pool.query(
"SELECT count(*) AS count FROM plants WHERE owner_id=$1",
[this.ownerId],
)
).rows[0].count,
);
const hasNext = rows.rows.length > query.limit,
items = rows.rows.slice(0, query.limit);
return { return {
items: items.map(plantDto), items: result.rows.map(plantDto),
nextCursor: hasNext ? cursorFor(items.at(-1)!) : null, total: result.total,
total, nextCursor: result.nextCursor,
}; };
} }
async stats() {
return collectionStats(this.pool, this.ownerId);
}
async birthEvent(id: string) {
await this.getPlant(id);
const row = (
await this.pool.query<EventRow>(
"SELECT * FROM breeding_events WHERE child_id=$1 AND owner_id=$2",
[id, this.ownerId],
)
).rows[0];
return { event: row ? eventDto(row) : null };
}
async listEvents(query: { async listEvents(query: {
limit: number; limit: number;
cursor?: string; cursor?: string;

View file

@ -1,3 +1,4 @@
import { demoSpecimen } from "./demo-plants.js";
import { velvetleaf, exampleGenome } from "@botanica/genetics"; import { velvetleaf, exampleGenome } from "@botanica/genetics";
import type { Pool } from "pg"; import type { Pool } from "pg";
import { insertPlant } from "./plants.js"; import { insertPlant } from "./plants.js";
@ -40,6 +41,8 @@ export async function seedDatabase(pool: Pool) {
visualSeed: 1440, visualSeed: 1440,
creationSource: "demo", creationSource: "demo",
}); });
for (let index = 0; index < 98; index++)
await insertPlant(client, demoSpecimen(index));
await client.query("COMMIT"); await client.query("COMMIT");
} catch (error) { } catch (error) {
await client.query("ROLLBACK"); await client.query("ROLLBACK");

View file

@ -0,0 +1,27 @@
import React from "react";
export class AppErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
return this.state.failed ? (
<main>
<div className="query-message" role="alert">
<h1>The laboratory needs a moment.</h1>
<p>
Reload the view to try again. Saved plants are kept in the database.
</p>
<button onClick={() => window.location.reload()}>
Reload laboratory
</button>
</div>
</main>
) : (
this.props.children
);
}
}

View file

@ -0,0 +1,73 @@
export 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>
);
}

View file

@ -1,23 +1,34 @@
import { useState } from "react"; import { useState } from "react";
import type { Phenotype } from "@botanica/genetics"; import type { Phenotype } from "@botanica/genetics";
import type { GrowthStage, Background } from "@botanica/renderer"; import type { GrowthStage, Background } from "@botanica/renderer";
import { exportPlant } from "../lib/exportPlant"; import type { PlantDto } from "@botanica/shared";
import { exportPlant, download } from "../lib/exportPlant";
export function ExportActions({ export function ExportActions({
phenotype, phenotype,
visualSeed, visualSeed,
stage, stage,
background = "transparent", background = "transparent",
plant,
}: { }: {
phenotype: Phenotype; phenotype: Phenotype;
visualSeed: number; visualSeed: number;
stage: GrowthStage; stage: GrowthStage;
background?: Background; background?: Background;
plant?: PlantDto;
}) { }) {
const [busy, setBusy] = useState(false), const [busy, setBusy] = useState(false),
[message, setMessage] = useState(""); [message, setMessage] = useState("");
async function save(format: "svg" | "png") { async function save(format: "svg" | "png" | "json") {
setBusy(true); setBusy(true);
try { try {
if (format === "json" && plant)
download(
new Blob([JSON.stringify(plant, null, 2) + "\n"], {
type: "application/json",
}),
"velvetleaf-" + plant.id + ".json",
);
else if (format !== "json")
await exportPlant(format, phenotype, visualSeed, stage, background); await exportPlant(format, phenotype, visualSeed, stage, background);
setMessage(format.toUpperCase() + " exported."); setMessage(format.toUpperCase() + " exported.");
} catch (error) { } catch (error) {
@ -35,6 +46,11 @@ export function ExportActions({
<button disabled={busy} onClick={() => void save("png")}> <button disabled={busy} onClick={() => void save("png")}>
Export PNG Export PNG
</button> </button>
{plant && (
<button disabled={busy} onClick={() => void save("json")}>
Export JSON
</button>
)}
</div> </div>
<p className="export-status" role="status"> <p className="export-status" role="status">
{message} {message}

View file

@ -0,0 +1,93 @@
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { PlantRecord } from "./PlantRecord";
import { QueryMessage } from "./QueryMessage";
import { plantQuery } from "../lib/api";
export function PlantInspector({
id,
onClose,
onSelect,
}: {
id: string;
onClose: () => void;
onSelect: (id: string) => void;
}) {
const dialog = useRef<HTMLDialogElement>(null),
title = useRef<HTMLHeadingElement>(null),
plant = useQuery(plantQuery(id));
useEffect(() => {
const previous = document.activeElement;
dialog.current?.showModal();
return () => {
dialog.current?.close();
if (previous instanceof HTMLElement && previous.isConnected)
previous.focus();
};
}, []);
useEffect(() => {
title.current?.focus();
}, [id]);
return (
<dialog
ref={dialog}
className="plant-inspector"
aria-labelledby="inspector-title"
onKeyDown={(e) => {
if (e.key !== "Tab" || !dialog.current) return;
const focusable = Array.from(
dialog.current.querySelectorAll<HTMLElement>(
'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),summary,[tabindex]:not([tabindex="-1"])',
),
).filter((element) => element.getClientRects().length > 0);
const first = focusable[0],
last = focusable.at(-1);
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
} else if (
e.shiftKey &&
(document.activeElement === first ||
document.activeElement === title.current)
) {
e.preventDefault();
last?.focus();
}
}}
onCancel={(e) => {
e.preventDefault();
onClose();
}}
onClick={(e) => {
if (e.target === dialog.current) onClose();
}}
>
<div className="inspector-inner">
<div className="inspector-header">
<div>
<p className="eyebrow">Specimen inspector</p>
<h2 id="inspector-title" ref={title} tabIndex={-1}>
{plant.data?.name ?? "Loading plant…"}
</h2>
</div>
<button
className="secondary close-inspector"
aria-label="Close inspector"
onClick={onClose}
>
×
</button>
</div>
{plant.isPending ? (
<QueryMessage loading />
) : plant.error ? (
<QueryMessage
error={plant.error}
retry={() => void plant.refetch()}
/>
) : (
<PlantRecord plant={plant.data} onParent={onSelect} />
)}
</div>
</dialog>
);
}

View file

@ -0,0 +1,212 @@
import { useInspectorOptions } from "../lib/inspectorStore";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router";
import { loci } from "@botanica/genetics";
import { stages, type GrowthStage, type Background } from "@botanica/renderer";
import type { PlantDto } from "@botanica/shared";
import { PlantPreview } from "./PlantPreview";
import { ExportActions } from "./ExportActions";
import { QueryMessage } from "./QueryMessage";
import { locusLabels } from "./GenomeEditor";
import { api } from "../lib/api";
export function PlantRecord({
plant: p,
onParent,
}: {
plant: PlantDto;
onParent?: (id: string) => void;
}) {
const { stage, background, set } = useInspectorOptions();
const setStage = (stage: GrowthStage) => set({ stage }),
setBackground = (background: Background) => set({ background });
const stats = useQuery({
queryKey: ["collection-stats"],
queryFn: api.stats,
}),
birth = useQuery({
queryKey: ["birth", p.id],
queryFn: () => api.birth(p.id),
});
useEffect(() => {
setStage("flowering");
}, [p.id]);
return (
<section className="render-workbench plant-details">
<div className="specimen">
<PlantPreview
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage={stage}
background={background}
/>
<div className="specimen-bottom">
<span>
{p.phenotype.pigment} · {p.phenotype.leafShape} ·{" "}
{p.phenotype.leafPattern}
</span>
<PlantPreview
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage={stage}
size={64}
/>
</div>
</div>
<aside className="study-panel">
<div className="detail-badges">
<span
className={"rarity-badge rarity-" + p.rarity.tier.toLowerCase()}
>
{p.rarity.tier} · {p.rarity.score}/100
</span>
<span className="badge">Generation {p.generation}</span>
</div>
<h2>Genotype & phenotype</h2>
<dl className="genome-record">
{loci.map((locus) => (
<div key={locus}>
<dt>{locusLabels[locus]}</dt>
<dd>
<code>{p.genome[locus].join(" / ")}</code>
<span>{p.phenotype[locus]}</span>
</dd>
</div>
))}
</dl>
<p className="panel-note">
{p.rarity.reasons.map((r) => r.label + " +" + r.points).join("; ") ||
"No unusual traits."}{" "}
· Rarity policy v{p.rarity.rarityPolicyVersion}
</p>
{stats.data && (
<p className="frequency-note">
Collection frequency: {stats.data.byRarity[p.rarity.tier] ?? 0} of{" "}
{stats.data.total} saved plants share this tier. This count is
separate from the trait score.
</p>
)}
<h3>Lineage</h3>
{p.parents.length ? (
<ul className="lineage-links">
{p.parents.map((parent, i) => (
<li key={parent}>
{onParent ? (
<button
className="link-button"
onClick={() => onParent(parent)}
>
Parent {i === 0 ? "A" : "B"} · {parent.slice(0, 8)} →
</button>
) : (
<Link to={"/plants/" + parent}>
Parent {i === 0 ? "A" : "B"} · {parent.slice(0, 8)} →
</Link>
)}
</li>
))}
</ul>
) : (
<p className="panel-note">Founder plant. No recorded parents.</p>
)}
<p className="panel-note">
{p.mutations.length
? "Recorded modifier: " + p.mutations.join(", ")
: "No named mutations."}
{p.creationSource === "demo"
? " Demonstration traits are curated examples, not breeding outcomes."
: ""}
</p>
{birth.error && (
<QueryMessage
error={birth.error}
retry={() => void birth.refetch()}
/>
)}{" "}
{birth.data?.event && (
<details>
<summary>Breeding record & mutations</summary>
<p>
Engine v{birth.data.event.engineVersion} · Seed{" "}
{birth.data.event.rngSeed}
</p>
{birth.data.event.mutationEvents.length ? (
<ul>
{birth.data.event.mutationEvents.map((event, i) => (
<li key={i}>
{event.type === "named"
? event.to
: `${event.locus}, parent ${event.parent}: ${event.from} → ${event.to}`}
</li>
))}
</ul>
) : (
<p>No mutations recorded in this cross.</p>
)}
</details>
)}
<details>
<summary>Provenance</summary>
<dl className="provenance">
<dt>Plant ID</dt>
<dd>{p.id}</dd>
<dt>Created</dt>
<dd>{new Date(p.createdAt).toLocaleString("en-GB")}</dd>
<dt>Origin</dt>
<dd>
{p.creationSource === "bred"
? "Bred offspring"
: p.creationSource === "demo"
? "Demonstration plant"
: "Designed founder"}
</dd>
<dt>Versions</dt>
<dd>
Genome {p.genomeVersion} · Renderer {p.rendererVersion}
</dd>
<dt>Visual seed</dt>
<dd>{p.visualSeed}</dd>
</dl>
</details>
<div className="control-pair">
<label>
Growth stage
<select
value={stage}
onChange={(e) => setStage(e.target.value as GrowthStage)}
>
{stages.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</label>
<label>
Export background
<select
value={background}
onChange={(e) => setBackground(e.target.value as Background)}
>
<option value="warm">Warm ivory</option>
<option value="transparent">Transparent</option>
</select>
</label>
</div>
<ExportActions
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage={stage}
background={background}
plant={p}
/>
<div className="detail-links">
<Link className="text-link" to={"/lab?plant=" + p.id}>
Study genome →
</Link>
<Link className="text-link" to={"/breed?parent=" + p.id}>
Use as parent →
</Link>
</div>
</aside>
</section>
);
}

View file

@ -1,3 +1,4 @@
import type { CollectionStats } from "@botanica/shared";
import type { import type {
CatalogResponse, CatalogResponse,
PlantDto, PlantDto,
@ -43,6 +44,11 @@ export async function request<T>(
return body as T; return body as T;
} }
export const api = { export const api = {
stats: () => request<CollectionStats>("/collection/stats"),
birth: (id: string) =>
request<{ event: BreedingEventDto | null }>(
"/plants/" + encodeURIComponent(id) + "/birth",
),
catalog: () => request<CatalogResponse>("/catalog/species"), catalog: () => request<CatalogResponse>("/catalog/species"),
plant: (id: string) => request<PlantDto>("/plants/" + encodeURIComponent(id)), plant: (id: string) => request<PlantDto>("/plants/" + encodeURIComponent(id)),
plants: (query = "") => plants: (query = "") =>

View file

@ -5,7 +5,7 @@ import {
type GrowthStage, type GrowthStage,
} from "@botanica/renderer"; } from "@botanica/renderer";
import type { Phenotype } from "@botanica/genetics"; import type { Phenotype } from "@botanica/genetics";
function download(blob: Blob, filename: string) { export function download(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob), const url = URL.createObjectURL(blob),
anchor = document.createElement("a"); anchor = document.createElement("a");
anchor.href = url; anchor.href = url;

View file

@ -0,0 +1,9 @@
import { create } from "zustand";
import type { GrowthStage, Background } from "@botanica/renderer";
export const useInspectorOptions = create<{
stage: GrowthStage;
background: Background;
set: (
options: Partial<{ stage: GrowthStage; background: Background }>,
) => void;
}>((set) => ({ stage: "flowering", background: "warm", set }));

View file

@ -1,18 +0,0 @@
import { create } from "zustand";
import type { Background, GrowthStage } from "@botanica/renderer";
interface StudyState {
studyId: string;
stage: GrowthStage;
visualSeed: number;
goldenVeins: boolean;
background: Background;
set: (value: Partial<Omit<StudyState, "set">>) => void;
}
export const useStudy = create<StudyState>((set) => ({
studyId: "LGS",
stage: "flowering",
visualSeed: 8155,
goldenVeins: false,
background: "warm",
set: (value) => set(value),
}));

View file

@ -1,145 +1,31 @@
import { Lab } from "./pages/Lab"; import React, { useEffect } from "react";
import { BreedingLab } from "./pages/BreedingLab";
import { PlantDetails } from "./pages/PlantDetails";
import { RenderLab } from "./pages/RenderLab";
import { RenderGallery } from "./pages/RenderGallery";
import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router"; import {
BrowserRouter,
NavLink,
Navigate,
Route,
Routes,
Link,
useLocation,
} from "react-router";
import { import {
QueryClient, QueryClient,
QueryClientProvider, QueryClientProvider,
useQuery, useQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import { Leaf } from "lucide-react";
Leaf, import { Lab } from "./pages/Lab";
ArrowUpRight, import { BreedingLab } from "./pages/BreedingLab";
FlaskConical, import { Collection } from "./pages/Collection";
Sprout, import { PlantDetails } from "./pages/PlantDetails";
Images, import { RenderGallery } from "./pages/RenderGallery";
BookOpen, import { BotanicalPlate } from "./components/BotanicalPlate";
} from "lucide-react"; import { AppErrorBoundary } from "./components/AppErrorBoundary";
import "./styles.css"; import "./styles.css";
const queryClient = new QueryClient(); const queryClient = new QueryClient({
const pages = { defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
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() { function Health() {
const result = useQuery({ const result = useQuery({
queryKey: ["health"], queryKey: ["health"],
@ -162,53 +48,66 @@ function Health() {
</span> </span>
); );
} }
function Page({ page }: { page: keyof typeof pages }) {
const p = pages[page]; function About() {
const Icon = p.icon;
return ( return (
<> <>
<section className="hero"> <section className="hero">
<div className="intro"> <div className="intro">
<p className="eyebrow">{p.eyebrow}</p> <p className="eyebrow">Field notes / Plant Laboratory</p>
<h1>{p.title}</h1> <h1>Botany meets a little imagination.</h1>
<p className="description">{p.description}</p> <p className="description">
<div className="edition"> An illustrated breeding sandbox inspired by Mendelian genetics. The
<span>PLANT LABORATORY</span> plants are fictional; the joy of discovering a new combination is
<span>Local sandbox · No. 001</span> yours.
</div> </p>
<Link className="text-link" to="/lab">
Begin a botanical study →
</Link>
</div> </div>
<figure> <figure>
<BotanicalPlate /> <BotanicalPlate />
<figcaption>BOTANICAL STUDY / ILLUSTRATED SHELL ART</figcaption> <figcaption>AN ORIGINAL BOTANICAL STUDY</figcaption>
</figure> </figure>
</section> </section>
<section className="workbench" aria-labelledby="workspace-title"> <section className="about-notes">
<div className="section-heading"> <article>
<div> <h2>Paired traits</h2>
<p className="eyebrow">{p.label}</p> <p>
<h2 id="workspace-title"> Each of six loci carries two alleles. Dominance resolves their
{page === "lab" ? "The workbench" : p.label} expression. A child inherits one allele from each saved parent, with
</h2> small, recorded chances of mutation. This simplified game model is
</div> not a scientifically accurate botany simulator.
<span className="badge">M2 · Foundations</span> </p>
</div> </article>
<div className="empty"> <article>
<span className="icon"> <h2>A traceable collection</h2>
<Icon size={25} strokeWidth={1.4} /> <p>
</span> Saved plants retain their genomes, versions, cosmetic seeds and
<h3>{p.empty}</h3> ancestry. Rarity is a score derived from traits, separate from how
<p>{p.detail}</p> often a trait occurs in your collection. Demonstration specimens are
{page === "lab" && ( curated examples, clearly labeled.
<NavLink to="/about" className="text-link"> </p>
Read the field notes <ArrowUpRight size={16} /> </article>
</NavLink> <article>
)} <h2>A first chapter</h2>
</div> <p>
Plant Laboratory is Botanica's first working module. Greenhouse
progression, resources, research and accounts are future work. This
local sandbox has no authentication and is not ready for public
internet use.
</p>
</article>
</section> </section>
</> </>
); );
} }
function App() { function App() {
const { pathname } = useLocation();
useEffect(() => {
document.getElementById("main")?.focus({ preventScroll: true });
window.scrollTo({ top: 0 });
}, [pathname]);
return ( return (
<> <>
<a href="#main" className="skip"> <a href="#main" className="skip">
@ -220,45 +119,39 @@ function App() {
<Leaf size={25} /> <Leaf size={25} />
</span> </span>
<span> <span>
Plant Laboratory<small>BOTANICA / EXPERIMENT NO. 001</small> Plant Laboratory<small>BOTANICA / PLANT LABORATORY</small>
</span> </span>
</NavLink> </NavLink>
<nav aria-label="Main navigation"> <nav aria-label="Main navigation">
{Object.entries(pages).map(([key, p]) => ( {[
<NavLink key={key} to={`/${key}`}> ["lab", "Laboratory"],
{p.label} ["breed", "Breeding"],
["gallery", "Collection"],
["about", "Field notes"],
].map(([key, label]) => (
<NavLink key={key} to={"/" + key}>
{label}
</NavLink> </NavLink>
))} ))}
</nav> </nav>
</header> </header>
<main id="main"> <main id="main" tabIndex={-1}>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/lab" replace />} /> <Route path="/" element={<Navigate to="/lab" replace />} />
{Object.keys(pages).map((key) => ( <Route path="/lab" element={<Lab />} />
<Route <Route path="/breed" element={<BreedingLab />} />
key={key} <Route path="/gallery" element={<Collection />} />
path={`/${key}`}
element={
key === "lab" ? (
<Lab />
) : key === "breed" ? (
<BreedingLab />
) : key === "gallery" ? (
<RenderGallery />
) : (
<Page page={key as keyof typeof pages} />
)
}
/>
))}
<Route path="/plants/:id" element={<PlantDetails />} /> <Route path="/plants/:id" element={<PlantDetails />} />
<Route path="/studies" element={<RenderGallery />} /> <Route path="/studies" element={<RenderGallery />} />
<Route path="/about" element={<About />} />
<Route <Route
path="*" path="*"
element={ element={
<section className="empty"> <section className="empty">
<h1>Page not found</h1> <h1>Page not found</h1>
<NavLink to="/lab">Return to the laboratory</NavLink> <Link className="text-link" to="/lab">
Return to the laboratory →
</Link>
</section> </section>
} }
/> />
@ -275,7 +168,9 @@ createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<AppErrorBoundary>
<App /> <App />
</AppErrorBoundary>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,

View file

@ -1,6 +1,6 @@
import { useState, useRef } from "react"; import { useState, useRef, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router"; import { Link, useSearchParams } from "react-router";
import { canBreed } from "@botanica/genetics"; import { canBreed } from "@botanica/genetics";
import { PlantPreview } from "../components/PlantPreview"; import { PlantPreview } from "../components/PlantPreview";
import { QueryMessage } from "../components/QueryMessage"; import { QueryMessage } from "../components/QueryMessage";
@ -58,8 +58,9 @@ function Parent({
); );
} }
export function BreedingLab() { export function BreedingLab() {
const [search] = useSearchParams();
const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }), const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }),
[a, setA] = useState(""), [a, setA] = useState(search.get("parent") ?? ""),
[b, setB] = useState(""), [b, setB] = useState(""),
[cursor, setCursor] = useState<string | undefined>(), [cursor, setCursor] = useState<string | undefined>(),
history = useQuery({ history = useQuery({
@ -73,9 +74,14 @@ export function BreedingLab() {
onSuccess: () => { onSuccess: () => {
void client.invalidateQueries({ queryKey: ["parents"] }); void client.invalidateQueries({ queryKey: ["parents"] });
void client.invalidateQueries({ queryKey: ["plants"] }); void client.invalidateQueries({ queryKey: ["plants"] });
void client.invalidateQueries({ queryKey: ["collection-stats"] });
void client.invalidateQueries({ queryKey: ["breeding"] }); void client.invalidateQueries({ queryKey: ["breeding"] });
}, },
}); });
const resultHeading = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (cross.data) resultHeading.current?.focus();
}, [cross.data]);
const list = parents.data ?? [], const list = parents.data ?? [],
left = list.find((p) => p.id === a), left = list.find((p) => p.id === a),
right = list.find((p) => p.id === b); right = list.find((p) => p.id === b);
@ -200,7 +206,12 @@ export function BreedingLab() {
/> />
<div> <div>
<p className="eyebrow">A new discovery</p> <p className="eyebrow">A new discovery</p>
<h2 id="offspring-title">{cross.data.plant.name}</h2> <p role="status" className="saved-notice">
Cross saved to your collection.
</p>
<h2 id="offspring-title" ref={resultHeading} tabIndex={-1}>
{cross.data.plant.name}
</h2>
<p> <p>
{cross.data.plant.phenotype.pigment} ·{" "} {cross.data.plant.phenotype.pigment} ·{" "}
{cross.data.plant.phenotype.leafPattern} ·{" "} {cross.data.plant.phenotype.leafPattern} ·{" "}

View file

@ -0,0 +1,237 @@
import { useDeferredValue } from "react";
import {
useInfiniteQuery,
useQuery,
keepPreviousData,
} from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router";
import { PlantPreview } from "../components/PlantPreview";
import { PlantInspector } from "../components/PlantInspector";
import { QueryMessage } from "../components/QueryMessage";
import { api } from "../lib/api";
const tiers = ["Common", "Uncommon", "Rare", "Epic", "Legendary", "Mythic"];
export function Collection() {
const [search, setSearch] = useSearchParams();
const q = useDeferredValue(search.get("q") ?? ""),
species = search.get("species") ?? "",
pigment = search.get("pigment") ?? "",
pattern = search.get("pattern") ?? "",
rarity = search.get("rarity") ?? "",
sort = search.get("sort") ?? "newest",
selected = search.get("plant");
const filters = { q, species, pigment, pattern, rarity, sort };
const plants = useInfiniteQuery({
queryKey: ["plants", filters],
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
api.plants(
new URLSearchParams(
Object.fromEntries(
Object.entries({
...filters,
limit: "24",
cursor: pageParam ?? "",
}).filter(([, v]) => v !== ""),
),
).toString(),
),
getNextPageParam: (page) => page.nextCursor,
placeholderData: keepPreviousData,
});
const stats = useQuery({
queryKey: ["collection-stats"],
queryFn: api.stats,
});
const items = plants.data?.pages.flatMap((p) => p.items) ?? [],
total = plants.data?.pages[0]?.total ?? 0;
function update(key: string, value: string) {
const next = new URLSearchParams(search);
if (value) next.set(key, value);
else next.delete(key);
setSearch(next, { replace: key === "q" });
}
function clear() {
const next = new URLSearchParams();
if (selected) next.set("plant", selected);
setSearch(next);
}
return (
<>
<section className="lab-heading">
<div>
<p className="eyebrow">Your living archive</p>
<h1>A collection worth cultivating.</h1>
<p>
{stats.data
? stats.data.total +
" saved plants, each with its own genome and traceable story."
: "Your saved plants, their inherited traits and the stories behind them."}
</p>
</div>
<Link className="button-link secondary" to="/lab">
Create a plant →
</Link>
</section>
<section className="collection-filters" aria-label="Collection filters">
<label className="search-filter">
Search plants
<input
type="search"
placeholder="Name, ID, pigment or pattern"
value={search.get("q") ?? ""}
maxLength={80}
onChange={(e) => update("q", e.target.value)}
/>
</label>
<label>
Species
<select
value={species}
onChange={(e) => update("species", e.target.value)}
>
<option value="">All species</option>
<option value="velvetleaf">Velvetleaf</option>
</select>
</label>
<label>
Pigment
<select
value={pigment}
onChange={(e) => update("pigment", e.target.value)}
>
<option value="">All pigments</option>
{["green", "purple", "red", "yellow"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</label>
<label>
Pattern
<select
value={pattern}
onChange={(e) => update("pattern", e.target.value)}
>
<option value="">All patterns</option>
{["solid", "variegated", "marbled"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
</label>
<label>
Rarity
<select
value={rarity}
onChange={(e) => update("rarity", e.target.value)}
>
<option value="">All tiers</option>
{tiers.map((v) => (
<option key={v}>{v}</option>
))}
</select>
</label>
<label>
Sort plants
<select value={sort} onChange={(e) => update("sort", e.target.value)}>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name A–Z</option>
<option value="rarity">Rarity score</option>
</select>
</label>
</section>
<section className="collection-toolbar">
<p role="status">
{plants.isFetching && !plants.isFetchingNextPage
? "Updating collection…"
: `Showing ${items.length} of ${total} plants`}
</p>
<button className="link-button" onClick={clear}>
Clear filters
</button>
<Link className="text-link" to="/studies">
Explore render studies →
</Link>
</section>
{plants.isPending ? (
<QueryMessage loading />
) : plants.error ? (
<QueryMessage
error={plants.error}
retry={() => void plants.refetch()}
/>
) : items.length ? (
<div className="collection-grid" aria-busy={plants.isPlaceholderData}>
{items.map((p) => (
<article className="collection-card" key={p.id}>
<button
className="open-specimen"
onClick={() => update("plant", p.id)}
aria-label={"Inspect " + p.name}
>
<div className="card-top">
<span
className={
"rarity-badge rarity-" + p.rarity.tier.toLowerCase()
}
>
{p.rarity.tier}
</span>
<span>Gen {p.generation}</span>
</div>
<PlantPreview
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage="flowering"
size={512}
label={p.name}
/>
<h2>{p.name}</h2>
<p>
{p.phenotype.pigment} · {p.phenotype.leafShape}
</p>
<div className="collection-card-footer">
<span>{p.phenotype.leafPattern}</span>
<span>
{p.creationSource === "demo"
? "Demo study"
: p.creationSource === "bred"
? "Bred offspring"
: "Designed founder"}
</span>
</div>
</button>
</article>
))}
</div>
) : (
<div className="empty">
<h2>No plants match these filters</h2>
<p>
Try a different trait or clear your search to see the whole
collection.
</p>
<button className="secondary" onClick={clear}>
Show all plants
</button>
</div>
)}
{plants.hasNextPage && (
<div className="load-more">
<button
onClick={() => void plants.fetchNextPage()}
disabled={plants.isFetchingNextPage || plants.isPlaceholderData}
>
{plants.isFetchingNextPage ? "Loading…" : "Load more plants"}
</button>
</div>
)}
{selected && (
<PlantInspector
id={selected}
onClose={() => update("plant", "")}
onSelect={(id) => update("plant", id)}
/>
)}
</>
);
}

View file

@ -31,6 +31,7 @@ export function Lab() {
mutationFn: api.create, mutationFn: api.create,
onSuccess: () => { onSuccess: () => {
void client.invalidateQueries({ queryKey: ["plants"] }); void client.invalidateQueries({ queryKey: ["plants"] });
void client.invalidateQueries({ queryKey: ["collection-stats"] });
void client.invalidateQueries({ queryKey: ["parents"] }); void client.invalidateQueries({ queryKey: ["parents"] });
}, },
}); });

View file

@ -1,11 +1,8 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { PlantPreview } from "../components/PlantPreview";
import { ExportActions } from "../components/ExportActions";
import { QueryMessage } from "../components/QueryMessage"; import { QueryMessage } from "../components/QueryMessage";
import { PlantRecord } from "../components/PlantRecord";
import { plantQuery } from "../lib/api"; import { plantQuery } from "../lib/api";
import { loci } from "@botanica/genetics";
import { locusLabels } from "../components/GenomeEditor";
export function PlantDetails() { export function PlantDetails() {
const { id = "" } = useParams(), const { id = "" } = useParams(),
plant = useQuery(plantQuery(id)); plant = useQuery(plantQuery(id));
@ -14,112 +11,19 @@ export function PlantDetails() {
return ( return (
<QueryMessage error={plant.error} retry={() => void plant.refetch()} /> <QueryMessage error={plant.error} retry={() => void plant.refetch()} />
); );
const p = plant.data;
return ( return (
<> <>
<section className="lab-heading"> <section className="lab-heading">
<div> <div>
<p className="eyebrow">A living record</p> <p className="eyebrow">A living record</p>
<h1>{p.name}</h1> <h1>{plant.data.name}</h1>
<p> <p>Every plant has a story. Follow its traits back to their roots.</p>
Generation {p.generation} ·{" "}
{p.creationSource === "bred"
? "Bred offspring"
: p.creationSource === "demo"
? "Demonstration plant"
: "Designed founder"}
</p>
</div> </div>
<Link className="button-link secondary" to="/breed"> <Link className="button-link secondary" to="/gallery">
Back to breeding → Back to collection →
</Link> </Link>
</section> </section>
<section className="render-workbench plant-details"> <PlantRecord plant={plant.data} />
<div className="specimen">
<PlantPreview
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage="flowering"
/>
<div className="specimen-bottom">
<span>
{p.phenotype.pigment} · {p.phenotype.leafShape} ·{" "}
{p.phenotype.leafPattern}
</span>
<PlantPreview
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage="flowering"
size={64}
/>
</div>
</div>
<aside className="study-panel">
<h2>Genotype & phenotype</h2>
<dl className="genome-record">
{loci.map((locus) => (
<div key={locus}>
<dt>{locusLabels[locus]}</dt>
<dd>
<code>{p.genome[locus].join(" / ")}</code>
<span>{p.phenotype[locus]}</span>
</dd>
</div>
))}
</dl>
<h3>
{p.rarity.tier} · {p.rarity.score}/100
</h3>
<p className="panel-note">
{p.rarity.reasons
.map((r) => r.label + " +" + r.points)
.join("; ") || "No unusual traits."}{" "}
· Rarity policy v{p.rarity.rarityPolicyVersion}
</p>
<h3>Lineage</h3>
{p.parents.length ? (
<ul className="lineage-links">
{p.parents.map((parent, i) => (
<li key={parent}>
<Link to={"/plants/" + parent}>
Parent {i === 0 ? "A" : "B"} · {parent.slice(0, 8)} →
</Link>
</li>
))}
</ul>
) : (
<p className="panel-note">Founder plant. No recorded parents.</p>
)}
<p className="panel-note">
{p.mutations.length
? "Recorded modifier: " + p.mutations.join(", ")
: "No named mutations."}
</p>
<details>
<summary>Provenance</summary>
<dl className="provenance">
<dt>Plant ID</dt>
<dd>{p.id}</dd>
<dt>Created</dt>
<dd>{new Date(p.createdAt).toLocaleString("en-GB")}</dd>
<dt>Versions</dt>
<dd>
Genome {p.genomeVersion} · Renderer {p.rendererVersion}
</dd>
<dt>Visual seed</dt>
<dd>{p.visualSeed}</dd>
</dl>
</details>
<ExportActions
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage="flowering"
/>
<Link className="text-link" to={"/lab?plant=" + p.id}>
Study this genome in the lab →
</Link>
</aside>
</section>
</> </>
); );
} }

View file

@ -1,235 +0,0 @@
import { useMemo, useState } from "react";
import { Download, Leaf } from "lucide-react";
import { classifyRarity, resolvePhenotype } from "@botanica/genetics";
import { stages, type GrowthStage, type Background } from "@botanica/renderer";
import { PlantPreview } from "../components/PlantPreview";
import { studies } from "../lib/studies";
import { useStudy } from "../lib/studyStore";
import { exportPlant } from "../lib/exportPlant";
export function RenderLab() {
const state = useStudy(),
study = studies.find((s) => s.id === state.studyId) ?? studies[0];
const phenotype = useMemo(
() =>
resolvePhenotype(
"velvetleaf",
study.genome,
state.goldenVeins ? ["golden_veins"] : [],
),
[study, state.goldenVeins],
);
const rarity = classifyRarity(phenotype);
const [message, setMessage] = useState(""),
[busy, setBusy] = useState(false);
async function save(format: "svg" | "png") {
setBusy(true);
setMessage("");
try {
await exportPlant(
format,
phenotype,
state.visualSeed,
state.stage,
state.background,
);
setMessage(format.toUpperCase() + " exported.");
} catch (error) {
setMessage(
error instanceof Error
? error.message
: "Export failed. Please try again.",
);
} finally {
setBusy(false);
}
}
return (
<>
<section className="lab-heading">
<div>
<p className="eyebrow">The illustrated laboratory</p>
<h1>A study in possibility.</h1>
<p>
One species, many expressions. Explore the illustrated character of
velvetleaf.
</p>
</div>
<span className="badge">M2 · Vector studies</span>
</section>
<section
className="render-workbench"
aria-label="Plant rendering workbench"
>
<div className="specimen">
<div className="specimen-top">
<span>VELVETLEAF</span>
<span>Plate 001 / Renderer v1</span>
</div>
<PlantPreview
phenotype={phenotype}
visualSeed={state.visualSeed}
stage={state.stage}
background={state.background}
/>
<div className="specimen-bottom">
<div>
<h2>Velvetleaf</h2>
<p>
{phenotype.leafShape} foliage · {phenotype.growthHabit} habit
</p>
</div>
<PlantPreview
phenotype={phenotype}
visualSeed={state.visualSeed}
stage={state.stage}
size={64}
label="64 pixel icon preview"
/>
</div>
</div>
<aside className="study-panel" aria-labelledby="study-heading">
<div className="panel-title">
<Leaf size={20} />
<h2 id="study-heading">Specimen study</h2>
</div>
<p className="panel-note">
Curated render presets. Diploid editing and saved plants arrive in
M3.
</p>
<label>
Botanical study
<select
value={state.studyId}
onChange={(e) => state.set({ studyId: e.target.value })}
>
{studies.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<div className="control-pair">
<label>
Growth stage
<select
value={state.stage}
onChange={(e) =>
state.set({ stage: e.target.value as GrowthStage })
}
>
{stages.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</label>
<label>
Visual seed
<input
type="number"
min="-2147483648"
max="2147483647"
step="1"
value={state.visualSeed}
onChange={(e) => {
const value = Number(e.target.value);
if (
Number.isInteger(value) &&
value >= -2147483648 &&
value <= 2147483647
)
state.set({ visualSeed: value });
}}
/>
</label>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={state.goldenVeins}
onChange={(e) => state.set({ goldenVeins: e.target.checked })}
/>
Golden veins · manual preview
</label>
<label>
Preview & export background
<select
value={state.background}
onChange={(e) =>
state.set({ background: e.target.value as Background })
}
>
<option value="warm">Warm ivory</option>
<option value="transparent">Transparent</option>
</select>
</label>
<div className="trait-summary">
<span className="eyebrow">Resolved phenotype</span>
<dl>
<div>
<dt>Pigment</dt>
<dd>{phenotype.pigment}</dd>
</div>
<div>
<dt>Pattern</dt>
<dd>{phenotype.leafPattern}</dd>
</div>
<div>
<dt>Flowers</dt>
<dd>
{phenotype.flowerColor} · {phenotype.flowerDensity}
</dd>
</div>
<div>
<dt>Derived rarity</dt>
<dd>
{rarity.tier} · {rarity.score}/100
</dd>
</div>
</dl>
<p className="rarity-note">
{rarity.reasons
.map((r) => r.label + " +" + r.points)
.join("; ") || "No unusual traits in this preset."}
</p>
</div>
<div className="export-actions">
<button onClick={() => void save("svg")} disabled={busy}>
<Download size={15} />
Export SVG
</button>
<button onClick={() => void save("png")} disabled={busy}>
<Download size={15} />
Export PNG
</button>
</div>
<p role="status" className="export-status">
{message}
</p>
</aside>
</section>
<section className="stage-strip" aria-labelledby="stages-heading">
<div>
<p className="eyebrow">From first leaf to flower</p>
<h2 id="stages-heading">Four chapters of growth</h2>
<p>
Growth changes the illustration. Hereditary traits stay the same.
</p>
</div>
<div className="stage-previews">
{stages.map((stage) => (
<div key={stage}>
<PlantPreview
phenotype={phenotype}
visualSeed={state.visualSeed}
stage={stage}
size={128}
/>
<span>{stage}</span>
</div>
))}
</div>
</section>
</>
);
}

View file

@ -1065,3 +1065,346 @@ summary {
gap: 12px; gap: 12px;
} }
} }
/* Collection cabinet and accessible specimen sheet */
.collection-filters {
display: grid;
grid-template-columns: 2fr repeat(5, minmax(0, 1fr));
gap: 12px;
padding: 22px;
background: #fffcf4;
border: 1px solid #d8ded2;
border-radius: 12px;
}
.collection-filters label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 10px;
color: #4c654f;
}
.collection-filters select {
font-size: 11px;
}
.collection-toolbar {
display: flex;
align-items: center;
gap: 20px;
padding: 22px 0;
flex-wrap: wrap;
}
.collection-toolbar p {
font-size: 12px;
color: #637565;
margin: 0 auto 0 0;
}
.collection-toolbar .text-link {
margin: 0;
}
.link-button {
background: transparent;
border: 0;
color: #32684a;
padding: 5px 0;
min-height: 32px;
border-radius: 2px;
font-size: 12px;
text-align: left;
text-decoration: underline;
text-underline-offset: 3px;
}
.link-button:hover {
background: transparent;
color: #20392d;
}
.collection-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.collection-card {
min-width: 0;
border: 1px solid #d8ded2;
background: #fffcf4;
border-radius: 11px;
overflow: hidden;
transition: border-color 0.15s;
}
.collection-card:hover {
border-color: #8da184;
}
.open-specimen {
display: block;
min-width: 0;
width: 100%;
height: 100%;
padding: 18px;
color: #20392d;
text-align: left;
background: transparent;
border: 0;
border-radius: 10px;
}
.open-specimen:hover {
background: #f7f6eb;
}
.open-specimen:focus-visible {
outline-offset: -4px;
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 9px;
color: #637565;
}
.rarity-badge {
display: inline-block;
border: 1px solid currentColor;
border-radius: 20px;
padding: 5px 9px;
font-size: 9px;
font-family: Arial, sans-serif;
background: #f6f7ee;
}
.rarity-common {
color: #4c7050;
}
.rarity-uncommon {
color: #286e63;
}
.rarity-rare {
color: #37658c;
}
.rarity-epic {
color: #725090;
}
.rarity-legendary {
color: #84651d;
}
.rarity-mythic {
color: #a34764;
}
.collection-card .plant-preview {
margin: 10px auto 3px;
}
.collection-card h2 {
font:
21px/1.25 Georgia,
serif;
overflow-wrap: anywhere;
margin: 10px 0;
}
.collection-card p {
font-size: 11px;
color: #637565;
margin: 0 0 15px;
text-transform: capitalize;
}
.collection-card-footer {
border-top: 1px solid #e0e4d8;
display: flex;
justify-content: space-between;
gap: 10px;
padding-top: 12px;
font-size: 9px;
color: #637565;
text-transform: capitalize;
}
.load-more {
display: flex;
justify-content: center;
padding: 32px;
}
.plant-inspector {
border: 0;
padding: 0;
position: fixed;
inset: 0 0 0 auto;
margin: 0 0 0 auto;
width: min(650px, 100vw);
max-width: none;
height: 100dvh;
max-height: none;
background: #fffcf4;
color: #20392d;
overflow: auto;
box-shadow: -12px 0 40px #20392d26;
}
.plant-inspector::backdrop {
background: #20392d66;
}
.inspector-inner {
padding: 25px;
}
.inspector-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 24px;
}
.inspector-header .eyebrow {
margin: 0 0 10px;
font-size: 9px;
}
.inspector-header h2 {
font:
30px/1.2 Georgia,
serif;
overflow-wrap: anywhere;
}
.close-inspector {
padding: 0;
width: 40px;
min-width: 40px;
height: 40px;
font-size: 28px;
}
.plant-inspector .plant-details {
display: block;
border: 0;
border-radius: 0;
}
.plant-inspector .specimen {
border: 1px solid #d8ded2;
border-radius: 10px;
}
.plant-inspector .specimen > .plant-preview {
max-width: 350px !important;
}
.plant-inspector .study-panel {
padding: 25px 0 0;
border: 0;
}
.plant-inspector .specimen-bottom {
font-size: 11px;
}
.detail-badges {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 23px;
}
.frequency-note {
font-size: 11px;
color: #637565;
background: #edf0e4;
padding: 12px;
border-radius: 6px;
line-height: 1.8;
}
.detail-links {
display: flex;
justify-content: space-between;
gap: 14px;
}
.plant-details .export-actions {
flex-wrap: wrap;
}
.plant-details .export-actions button {
min-width: 100px;
}
.about-notes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 22px;
margin: 25px 0;
}
.about-notes article {
border-top: 1px solid #cbd3c3;
padding: 22px 0;
}
.about-notes h2 {
font-size: 24px;
}
.about-notes p {
font-size: 13px;
line-height: 1.9;
color: #637565;
}
main:focus {
outline: none;
}
.saved-notice {
background: #edf0e4;
border-left: 3px solid #32684a;
border-radius: 4px;
padding: 12px;
}
.query-message button {
margin-top: 10px;
}
@media (max-width: 1100px) {
.collection-filters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.collection-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.collection-filters {
padding: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 10px;
}
.collection-filters .search-filter {
grid-column: 1/-1;
}
.collection-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.open-specimen {
padding: 12px;
}
.collection-card h2 {
font-size: 17px;
}
.card-top {
font-size: 8px;
gap: 4px;
}
.rarity-badge {
font-size: 8px;
padding: 4px 7px;
}
.collection-card-footer {
flex-direction: column;
font-size: 9px;
gap: 6px;
}
.collection-toolbar {
gap: 12px;
}
.collection-toolbar p {
width: 100%;
}
.inspector-inner {
padding: 20px;
}
.inspector-header h2 {
font-size: 26px;
}
.plant-inspector .specimen {
padding: 12px;
}
.plant-inspector .specimen-bottom {
font-size: 10px;
}
.detail-links {
flex-direction: column;
gap: 0;
}
.about-notes {
grid-template-columns: 1fr;
}
.about-notes article {
padding: 17px 0;
}
.detail-badges {
gap: 9px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,67 @@
{
"checks": [
"no overflow 1440 /gallery",
"Pigment filter/sort purple",
"Pattern filter/sort marbled",
"Rarity filter/sort Legendary",
"Sort plants filter/sort name",
"100+ persisted plants paginated",
"modal focus trap, Escape and restoration",
"inspector child to saved parent navigation",
"recoverable API error and retry",
"no overflow 360 /lab",
"no overflow 360 /breed",
"no overflow 360 /gallery",
"no overflow 360 /plants/8c6d8b2b-836f-42e4-9ce7-0d42a773a287",
"no overflow 360 /about",
"deep-linked inspector reload and reduced motion"
],
"errors": [],
"exports": [
{
"background": "warm",
"width": 512,
"height": 512,
"corner": [
246,
243,
232,
255
],
"svgDifference": 0,
"previewDifference": 0
},
{
"background": "transparent",
"width": 512,
"height": 512,
"corner": [
0,
0,
0,
0
],
"svgDifference": 0,
"previewDifference": 0
}
],
"pagination": [
{
"cards": 48,
"elapsedMs": 102
},
{
"cards": 72,
"elapsedMs": 100
},
{
"cards": 96,
"elapsedMs": 122
},
{
"cards": 102,
"elapsedMs": 69
}
],
"filterMs": 70
}

View file

@ -0,0 +1,69 @@
{
"id": "8c6d8b2b-836f-42e4-9ce7-0d42a773a287",
"name": "Velvetleaf · generation 1",
"ownerId": "00000000-0000-4000-8000-000000000001",
"speciesId": "velvetleaf",
"genome": {
"pigment": [
"G",
"P"
],
"leafShape": [
"L",
"O"
],
"flowerColor": [
"W",
"W"
],
"growthHabit": [
"C",
"C"
],
"leafPattern": [
"S",
"S"
],
"flowerDensity": [
"H",
"M"
]
},
"mutations": [],
"phenotype": {
"speciesId": "velvetleaf",
"catalogVersion": 1,
"pigment": "purple",
"leafShape": "lobed",
"leafPattern": "solid",
"growthHabit": "compact",
"flowerColor": "white",
"flowerDensity": "high",
"mutations": []
},
"visualSeed": 1550622050,
"genomeVersion": 1,
"rendererVersion": 1,
"generation": 1,
"parents": [
"df2f5481-7e5d-47ba-ae41-985aaf0561ee",
"10000000-0000-4000-8000-000000000002"
],
"createdAt": "2026-10-28T09:46:36.573Z",
"creationSource": "bred",
"rarity": {
"score": 40,
"tier": "Rare",
"rarityPolicyVersion": 1,
"reasons": [
{
"label": "Unusual recessive growthHabit",
"points": 20
},
{
"label": "Unusual recessive flowerColor",
"points": 20
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,69 @@
{
"id": "8c6d8b2b-836f-42e4-9ce7-0d42a773a287",
"name": "Velvetleaf · generation 1",
"ownerId": "00000000-0000-4000-8000-000000000001",
"speciesId": "velvetleaf",
"genome": {
"pigment": [
"G",
"P"
],
"leafShape": [
"L",
"O"
],
"flowerColor": [
"W",
"W"
],
"growthHabit": [
"C",
"C"
],
"leafPattern": [
"S",
"S"
],
"flowerDensity": [
"H",
"M"
]
},
"mutations": [],
"phenotype": {
"speciesId": "velvetleaf",
"catalogVersion": 1,
"pigment": "purple",
"leafShape": "lobed",
"leafPattern": "solid",
"growthHabit": "compact",
"flowerColor": "white",
"flowerDensity": "high",
"mutations": []
},
"visualSeed": 1550622050,
"genomeVersion": 1,
"rendererVersion": 1,
"generation": 1,
"parents": [
"df2f5481-7e5d-47ba-ae41-985aaf0561ee",
"10000000-0000-4000-8000-000000000002"
],
"createdAt": "2026-10-28T09:46:36.573Z",
"creationSource": "bred",
"rarity": {
"score": 40,
"tier": "Rare",
"rarityPolicyVersion": 1,
"reasons": [
{
"label": "Unusual recessive growthHabit",
"points": 20
},
{
"label": "Unusual recessive flowerColor",
"points": 20
}
]
}
}

BIN
artifacts/m4/child-warm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
artifacts/m4/lab-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View file

@ -57,3 +57,11 @@ Indices: plants(owner_id,created_at DESC), plants(species_id,rarity_tier), breed
`GET /api/v1/catalog/species` returns `{catalogVersion:1,species:[...]}` with dominance, phenotype names, compatibility and mutation/rarity policy data. `POST /api/v1/phenotype/resolve` accepts `{speciesId:"velvetleaf",genome:<the six diploid locus pairs>,mutations:[],catalogVersion:1}` and returns `{phenotype,rarity}`. Unknown client fields (including rarity claims) and invalid alleles return a uniform 400 error. Shared request schemas/DTOs are in `packages/shared`; no breeding endpoint exists before M3. `GET /api/v1/catalog/species` returns `{catalogVersion:1,species:[...]}` with dominance, phenotype names, compatibility and mutation/rarity policy data. `POST /api/v1/phenotype/resolve` accepts `{speciesId:"velvetleaf",genome:<the six diploid locus pairs>,mutations:[],catalogVersion:1}` and returns `{phenotype,rarity}`. Unknown client fields (including rarity claims) and invalid alleles return a uniform 400 error. Shared request schemas/DTOs are in `packages/shared`; no breeding endpoint exists before M3.
`POST /api/v1/preview` accepts the resolve fields plus signed-int32 `visualSeed`, `growthStage` (`seedling`, `juvenile`, `mature`, `flowering`), `rendererVersion:1` and `background` (`transparent`, `warm`). It validates genomes server-side and returns `{rendererVersion:1,svg:"<svg ...>...</svg>"}` using the same pure renderer as the browser. It does not persist a plant or accept arbitrary SVG/scene/palette input. `POST /api/v1/preview` accepts the resolve fields plus signed-int32 `visualSeed`, `growthStage` (`seedling`, `juvenile`, `mature`, `flowering`), `rendererVersion:1` and `background` (`transparent`, `warm`). It validates genomes server-side and returns `{rendererVersion:1,svg:"<svg ...>...</svg>"}` using the same pure renderer as the browser. It does not persist a plant or accept arbitrary SVG/scene/palette input.
## Implemented M3–M4 contracts
See `API_EXAMPLES.md` for copyable requests and all collection query parameters. The persisted plant model additionally stores `name`, `creation_source` (`designed`, `bred`, `demo`) and `rarity_reasons`; these are additive migration fields. Plant reads resolve the immutable versioned genome but return stored birth rarity and mutation outcomes. `/plants/:id/birth` returns the recorded breeding event for lineage/mutation inspection; `/collection/stats` returns actual saved collection counts by tier.
Collection filters are evaluated server-side against expressed traits using the catalog's dominance order and parameterized SQL. Sort columns/directions are chosen from a fixed allowlist. Cursor tuples use a UUID tie-breaker and preserve full timestamp precision; filter/sort context is part of the opaque cursor. Search treats user wildcard characters literally. Responses are bounded to at most 100 plants (UI pages contain 24).
Breeding uses cryptographically secure event seeds, sandbox-owner-scoped parent reads, and a per-owner/idempotency-key transaction lock. The child and event are inserted atomically. Repeated identical requests return the same committed child/event; reusing a key for another pair returns 409. Clients cannot set immutable fields or save manual mutation previews as breeding outcomes.

52
docs/API_EXAMPLES.md Normal file
View file

@ -0,0 +1,52 @@
# Plant Laboratory API
All endpoints use relative URLs under `/api/v1`. This is a single-owner local sandbox without authentication. The server fixes the sandbox owner; clients cannot assign ownership, ancestry, mutation outcomes, rarity or breeding seeds. Errors use `{error:{code,message,details?}}`.
## Design and save
```sh
curl -fsS http://localhost:8155/api/v1/catalog/species
curl -fsS -X POST http://localhost:8155/api/v1/plants \
-H 'Content-Type: application/json' \
-d '{"speciesId":"velvetleaf","name":"My study","genome":{"pigment":["P","G"],"leafShape":["L","O"],"leafPattern":["V","S"],"growthHabit":["B","C"],"flowerColor":["R","W"],"flowerDensity":["H","M"]}}'
```
A designed plant is a generation 0 founder. An optional signed-int32 `visualSeed` controls cosmetics; otherwise the server generates it securely. Named mutation flags are accepted only by non-persistent preview endpoints. The UI makes manual preview flags explicit and never saves them as bred mutations. Plant names contain 1–60 characters. Extra or immutable client fields are rejected.
## Collection and details
```sh
curl -fsS 'http://localhost:8155/api/v1/plants?limit=24&pigment=purple&pattern=marbled&sort=rarity'
curl -fsS 'http://localhost:8155/api/v1/plants?q=Fern&sort=name'
curl -fsS http://localhost:8155/api/v1/plants/10000000-0000-4000-8000-000000000001
curl -fsS http://localhost:8155/api/v1/collection/stats
```
Lists return `{items,nextCursor,total}`. Pass an opaque `nextCursor` back as URL-encoded `cursor` with the same filters and sort. Limits are 1–100, default 24. Filters: `species`, `pigment`, `pattern`, `rarity`; `q` searches names, IDs, expressed pigment and pattern (up to 80 characters; wildcard characters are literal). Sorts: `newest`, `oldest`, `name`, `rarity`. UUID ties make every order stable; timestamp cursors retain PostgreSQL microseconds. Changing a cursor's filter/sort context returns 400. Counts describe the actual saved collection, independently of versioned rarity scores.
Each plant DTO includes canonical genome, resolved phenotype, named modifiers, parents, generation, owner, name, origin, creation time, genome/renderer versions, visual seed and persisted birth rarity score/tier/reasons/policy version. `/plants/:id/birth` returns `{event}` for bred children or `{event:null}` for founders. No read operation rerolls inheritance, mutations or rarity.
## Breed saved parents
```sh
curl -fsS -X POST http://localhost:8155/api/v1/breeding \
-H 'Content-Type: application/json' \
-d '{"parentAId":"10000000-0000-4000-8000-000000000001","parentBId":"10000000-0000-4000-8000-000000000002","idempotencyKey":"90000000-0000-4000-8000-000000000001"}'
curl -fsS 'http://localhost:8155/api/v1/breeding?limit=12'
```
The POST returns `{plant,event}`. Use a fresh UUID key for each intended new cross; reuse the same key for retries. Concurrent identical keys return the original child/event. Reusing a key for another parent pair returns 409. Two distinct owned parents and an approved compatibility rule are required. A single transaction records the child, its parents, secure event seed, engine version and mutation log. Errors or transaction failure leave no orphan child. Generation is `max(parent generations)+1`.
## Preview and exports
`POST /phenotype/resolve` accepts a genome with `speciesId`, optional `mutations` and `catalogVersion:1`; it returns phenotype and rarity preview. `POST /preview` additionally accepts `visualSeed`, `growthStage`, `rendererVersion:1` and `background`; it returns a self-contained SVG string. The browser uses the identical pure renderer for live preview and SVG/PNG downloads. JSON export contains the saved canonical plant DTO and lineage. Image stage/background choices are cosmetic and do not change stored plants.
## Responses and limits
- 400: malformed UUID, unknown allele/version/species, extra fields, invalid query/cursor.
- 404: missing or non-owned plant.
- 409: incompatible species, same parent twice, idempotency key conflict.
- 500: generic unexpected failure, without SQL details or secrets.
- `/health` is outside `/api/v1`: 200 when the DB query succeeds, 503 otherwise.
The demo seed creates exactly 100 fixed-ID specimens, including two named parents. Re-running it fills missing examples and never overwrites saved plant genomes or ancestry. Demonstration mutations are curated illustrations, not claims about breeding frequency.

View file

@ -42,3 +42,13 @@ Later replace `PUBLIC_DOMAIN` with a true HTTPS origin and configure TLS at the
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. Player seeds use conflict-safe inserts and preserve existing records. The M1 catalog seed synchronizes the current versioned definition; it does not rewrite plant genomes or ancestry. 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. Player seeds use conflict-safe inserts and preserve existing records. The M1 catalog seed synchronizes the current versioned definition; it does not rewrite plant genomes or ancestry.
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`. 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`.
## M3–M4 persistence and collection
The additive `0002_plant_details` migration adds plant names, creation source and persisted birth rarity reasons. Migrations are applied in filename order inside a single advisory-locked transaction and tracked by name; repeat bootstrap does not rewrite existing plants, their parents, generations, genomes or seeds. The fixed demo owner is only sandbox scoping and must not be treated as real authentication.
The seed now creates exactly 100 fixed-ID demonstration specimens, including the original two named parents. Conflicting plant IDs are left untouched. A fresh installation therefore has 100 demo plants; manual designs and bred offspring increase the total. Curated demo modifiers are labeled as demonstrations rather than bred mutation outcomes.
Run the isolated acceptance database with `npm run test:integration`, then `docker compose -f compose.test.yaml down`. That Compose project uses a tmpfs database, no persistent volume and no published ports. It neither resets nor connects to the application's named volume. The deliberate rollback test logs an expected PostgreSQL exception while checking that the API returns only a safe generic response.
API examples and cursor/idempotency contracts are documented in `docs/API_EXAMPLES.md`. Before any public deployment add real identity, owner authorization, rate limits, TLS/access controls and operational backup/restore checks. The current published address remains loopback-only. Future game scope is documented in `docs/ROADMAP.md` and is not implemented.

View file

@ -4,7 +4,7 @@
- [x] M1 — genetics contracts, deterministic PRNG, pure engine + tests. - [x] M1 — genetics contracts, deterministic PRNG, pure engine + tests.
- [x] M2 — procedural illustrated SVG renderer, exports, visual QA. - [x] M2 — procedural illustrated SVG renderer, exports, visual QA.
- [x] M3 — lab editor, server persistence, breeding flow. - [x] M3 — lab editor, server persistence, breeding flow.
- [ ] M4 — gallery, provenance, UX polish, full acceptance. - [x] M4 — gallery, provenance, UX polish, full acceptance.
## M0 — 2026-10-28 ## M0 — 2026-10-28
@ -82,3 +82,24 @@ Checks run: `npm run typecheck`, `npm run test` (16 unit/domain/API tests passed
`node scripts/verify-m3.cjs` passed live browser acceptance: editing a pigment allele changes visible phenotype/SVG; save excludes the manual mutation; founder and child survive reload; offspring has two persisted parents and generation 1; lineage navigates to the saved founder; laboratory, breeding and detail pages fit 360px. Desktop/mobile screenshots and result IDs are in artifacts/m3. The first browser attempt used a label-text locator that included select-option text; the actual accessible combobox names were verified and the test corrected. No app defect was suppressed. Visually reviewed laboratory and breeding screens. `node scripts/verify-m3.cjs` passed live browser acceptance: editing a pigment allele changes visible phenotype/SVG; save excludes the manual mutation; founder and child survive reload; offspring has two persisted parents and generation 1; lineage navigates to the saved founder; laboratory, breeding and detail pages fit 360px. Desktop/mobile screenshots and result IDs are in artifacts/m3. The first browser attempt used a label-text locator that included select-option text; the actual accessible combobox names were verified and the test corrected. No app defect was suppressed. Visually reviewed laboratory and breeding screens.
URL: http://localhost:8155. Known limit: collection filtering/demo population belongs to M4; no auth or public release. The UI acceptance founder and its offspring are retained as evidence of persistence. Continuing to M4 under user authorization. URL: http://localhost:8155. Known limit: collection filtering/demo population belongs to M4; no auth or public release. The UI acceptance founder and its offspring are retained as evidence of persistence. Continuing to M4 under user authorization.
## M4 — 2026-10-28
Gate complete. Implemented the persisted collection cabinet with server-side species/pigment/pattern/rarity filters, literal search, four stable sort modes and bounded cursor pagination. SQL trait expressions derive their dominance order from the versioned catalog; no client input becomes raw SQL. Added actual collection statistics separately from rarity scoring, saved-plant birth-event inspection, a native modal inspector with explicit Tab wrapping, Escape/close and focus restoration, deep links, parent navigation, full provenance and JSON/SVG/PNG exports. Added shared inspector preferences, loading/empty/error/retry states, save/cross confirmations, route focus handling, reduced-motion support and a render-error fallback. Removed superseded M2 lab/store code; kept the render atlas at /studies.
The idempotent seed now contains exactly 100 fixed-ID deterministic demonstration plants, including the two M3 parents. Existing plants are not overwritten. The live collection has 102 records: 100 demonstrations plus the M3 UI acceptance founder and its child. Demo traits/modifiers are explicitly described as curated examples, not observed breeding frequencies. Added API_EXAMPLES.md, ROADMAP.md, deployment notes and final README.
### Final acceptance results
- `npm run typecheck`: passed all workspaces on final application source.
- `npm run test`: 16 unit/domain/API tests passed; eight PostgreSQL cases are skipped in this command without TEST_DATABASE_URL and are executed separately below. All 16 primitive source/manifest files remain synchronized.
- `npm run build`: passed pure packages, server and production web assets.
- `docker compose -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from test`: all eight real-PostgreSQL integration cases passed on a fresh isolated tmpfs database. Repeated seed produced exactly 100 demo plants. M3 concurrency/rollback/replay/ownership checks still pass; M4 traverses the entire collection under newest/oldest/name/rarity sorts without gaps or duplicates, verifies filters, literal wildcard search, empty search, invalid sort/cursor rejection and real tier counts. Temporary test containers/network were removed afterward, leaving the application data untouched.
- `docker compose config --quiet`, `docker compose up --build -d`, `docker compose ps` and `curl.exe -fsS http://localhost:8155/health`: passed. API and DB are healthy; only web publishes `127.0.0.1:8155`. Proxied health returns 200 connected JSON.
- Repeated live `docker compose run --rm bootstrap node dist/seed.js`: still exactly 100 demo records, one designed acceptance founder and one bred child. Both schema migrations recorded; no lineage reset.
- `node scripts/verify-m4.cjs`: passed real browser search/filter/sort, 24-item initial page, successive pages through all 102 records, empty results, recoverable simulated API error/retry, modal keyboard trap/Escape/focus restoration, child-to-parent inspector navigation, deep-linked inspector reload, reduced motion and 360px/1440px overflow checks. No JavaScript page errors.
- Browser filter update measured about 70 ms; successive page interactions/rendering measured 102/100/122/69 ms for 48/72/96/102 cards in this local headless Chrome run. These are observed desktop measurements rather than cross-device guarantees.
- Actual saved-child JSON, SVG and PNG downloads succeeded for warm and transparent backgrounds. JSON preserves six-locus genome, both parents, generation, seeds and versions. Images decode at 512×512; SVG-to-PNG and preview-to-PNG mean pixel difference both 0. Warm RGBA corner is 246,243,232,255; transparent corner is 0,0,0,0. Exports contain no external artwork references or scripts.
- Visually reviewed the complete desktop collection, desktop inspector, mobile collection/inspector and error state. Evidence and downloads are in artifacts/m4. Initial browser pagination assertions ran before React finished restoring the list after clearing empty search; the test now waits for the actual list. Keyboard QA found Chrome's native modal tab cycle can stop outside the document; explicit endpoint wrapping fixed it and the full suite passed afterward.
M0–M4 are complete at http://localhost:8155. No known milestone blockers. The app is ready for further local iteration, not public launch: authentication, real owner authorization and throttling remain future work. No M5 economy, accounts or multiplayer were added. The requested .env origin/port are unchanged and excluded from Git.

14
docs/ROADMAP.md Normal file
View file

@ -0,0 +1,14 @@
# Future development after Plant Laboratory
M0–M4 establish a local illustrated plant laboratory with deterministic genetics, original vector rendering, persistent breeding and a traceable collection.
Future milestones should be designed and gated separately:
1. Authentication, ownership enforcement for real accounts, access control, request throttling and deployment hardening before public exposure. The current fixed sandbox owner is not authentication.
2. Greenhouse progression: pots, seeds, resources and an explicit economy, with server-authoritative transactions.
3. Offline progression based on persisted event timestamps; test clock changes, retries and race conditions before adding automation.
4. Research, upgrades and achievements, with versioned balance rules and migration policies.
5. Additional species with authored botanical grammars, family-scoped allele catalogs and explicitly approved hybrid rules.
6. Social trading or multiplayer only after account security, provenance and economic invariants have been reviewed.
Do not add these systems implicitly to the laboratory. Preserve stored genomes, seeds, versions and ancestry as the model evolves. Future rarity policy changes require an explicit, reviewed recomputation migration rather than silently changing birth classifications.

View file

@ -76,3 +76,19 @@ export interface BreedingResponse {
} }
export type CreatePlantRequest = z.input<typeof createPlantSchema>; export type CreatePlantRequest = z.input<typeof createPlantSchema>;
export type BreedingRequest = z.infer<typeof breedingRequestSchema>; export type BreedingRequest = z.infer<typeof breedingRequestSchema>;
export const plantListQuerySchema = pageQuerySchema.extend({
species: z.literal("velvetleaf").optional(),
rarity: z
.enum(["Common", "Uncommon", "Rare", "Epic", "Legendary", "Mythic"])
.optional(),
pigment: z.enum(["green", "purple", "red", "yellow"]).optional(),
pattern: z.enum(["solid", "variegated", "marbled"]).optional(),
q: z.string().trim().max(80).optional(),
sort: z.enum(["newest", "oldest", "name", "rarity"]).default("newest"),
});
export type PlantListQuery = z.infer<typeof plantListQuerySchema>;
export interface CollectionStats {
total: number;
byRarity: Partial<Record<PlantDto["rarity"]["tier"], number>>;
}

373
scripts/verify-m4.cjs Normal file
View file

@ -0,0 +1,373 @@
const fs = require("node:fs");
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright");
const directory = "artifacts/m4";
(async () => {
fs.mkdirSync(directory, { recursive: true });
const browser = await chromium.launch({
headless: true,
...(process.env.BROWSER_EXECUTABLE
? { executablePath: process.env.BROWSER_EXECUTABLE }
: {}),
});
const page = await browser.newPage({
viewport: { width: 1440, height: 1100 },
reducedMotion: "reduce",
acceptDownloads: true,
});
const report = { checks: [], errors: [], exports: [], pagination: [] };
page.on("pageerror", (e) => report.errors.push(e.message));
const base = "http://localhost:8155";
async function ready() {
await page.evaluate(
() =>
new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(r)),
),
);
await page.waitForFunction(() => {
const status = document.querySelector(
".collection-toolbar [role=status]",
);
return status && status.textContent.startsWith("Showing");
});
}
async function width(route, size) {
await page.setViewportSize({ width: size, height: 1100 });
await page.goto(base + route);
await page
.getByRole("status")
.filter({ hasText: "Laboratory online" })
.waitFor();
if (
await page.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
)
)
throw Error("Overflow: " + route + " at " + size);
report.checks.push("no overflow " + size + " " + route);
}
await width("/gallery", 1440);
await ready();
if ((await page.locator(".collection-card").count()) !== 24)
throw Error("Collection page size");
await page.screenshot({
path: directory + "/collection-desktop.png",
fullPage: true,
});
async function select(label, value, key) {
const response = page.waitForResponse(
(r) =>
r.url().includes("/api/v1/plants?") &&
new URL(r.url()).searchParams.get(key) === value,
);
const start = Date.now();
await page
.getByRole("combobox", { name: label, exact: true })
.selectOption(value);
await response;
await ready();
report.checks.push(label + " filter/sort " + value);
return Date.now() - start;
}
report.filterMs = await select("Pigment", "purple", "pigment");
if (
!(await page.locator(".collection-card p").allTextContents()).every((t) =>
t.toLowerCase().includes("purple"),
)
)
throw Error("Wrong pigment results");
await select("Pattern", "marbled", "pattern");
if (
!(await page.locator(".collection-card-footer").allTextContents()).every(
(t) => t.toLowerCase().includes("marbled"),
)
)
throw Error("Wrong pattern results");
await page.screenshot({
path: directory + "/filtered-desktop.png",
fullPage: true,
});
await page
.getByRole("button", { name: "Clear filters", exact: true })
.click();
await ready();
await select("Rarity", "Legendary", "rarity");
if (
!(
await page.locator(".collection-card .rarity-badge").allTextContents()
).every((t) => t === "Legendary")
)
throw Error("Wrong rarity results");
await page
.getByRole("button", { name: "Clear filters", exact: true })
.click();
await ready();
await select("Sort plants", "name", "sort");
const names = await page.locator(".collection-card h2").allTextContents();
if (
names.some(
(name, i) => i > 0 && names[i - 1].toLowerCase() > name.toLowerCase(),
)
)
throw Error("Names not sorted");
await page
.getByRole("searchbox", { name: "Search plants", exact: true })
.fill("zzzz-no-match");
await page
.getByRole("heading", {
name: "No plants match these filters",
exact: true,
})
.waitFor();
await page.screenshot({ path: directory + "/empty-state.png" });
await page
.getByRole("button", { name: "Show all plants", exact: true })
.click();
await page.locator(".collection-card").first().waitFor();
await ready();
while (
await page
.getByRole("button", { name: "Load more plants", exact: true })
.isVisible()
) {
const count = await page.locator(".collection-card").count(),
start = Date.now();
await page
.getByRole("button", { name: "Load more plants", exact: true })
.click();
await page.waitForFunction(
(previous) =>
document.querySelectorAll(".collection-card").length > previous,
count,
);
report.pagination.push({
cards: await page.locator(".collection-card").count(),
elapsedMs: Date.now() - start,
});
}
if ((await page.locator(".collection-card").count()) < 100)
throw Error("Expected 100+ persisted plants");
report.checks.push("100+ persisted plants paginated");
const metadata = JSON.parse(
fs.readFileSync("artifacts/m3/browser-checks.json", "utf8"),
);
const childResponse = await fetch(
base + "/api/v1/plants/" + metadata.childId,
);
if (!childResponse.ok) throw Error("M3 acceptance child missing");
const child = await childResponse.json();
await page
.getByRole("searchbox", { name: "Search plants", exact: true })
.fill(child.id);
await ready();
await page.waitForFunction(
() => document.querySelectorAll(".collection-card").length === 1,
);
const trigger = page.getByRole("button", {
name: "Inspect " + child.name,
exact: true,
});
await trigger.click();
const dialog = page.getByRole("dialog");
await dialog
.getByRole("heading", { name: "Genotype & phenotype", exact: true })
.waitFor();
for (let i = 0; i < 20; i++) {
await page.keyboard.press("Tab");
if (
!(await page.evaluate(() => !!document.activeElement.closest("dialog")))
)
throw Error("Focus escaped modal");
}
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "detached" });
if (!(await trigger.evaluate((e) => document.activeElement === e)))
throw Error("Focus did not return to specimen");
report.checks.push("modal focus trap, Escape and restoration");
await trigger.click();
await dialog
.getByRole("heading", { name: "Genotype & phenotype", exact: true })
.waitFor();
await page.screenshot({ path: directory + "/inspector-child-desktop.png" });
for (const background of ["warm", "transparent"]) {
await dialog
.getByRole("combobox", { name: "Export background", exact: true })
.selectOption(background);
for (const format of ["svg", "png", "json"]) {
const [download] = await Promise.all([
page.waitForEvent("download"),
dialog
.getByRole("button", {
name: "Export " + format.toUpperCase(),
exact: true,
})
.click(),
]);
if (await download.failure()) throw Error("Download failed");
await download.saveAs(directory + "/child-" + background + "." + format);
}
const saved = JSON.parse(
fs.readFileSync(directory + "/child-" + background + ".json"),
);
if (
saved.id !== child.id ||
saved.parents.length !== 2 ||
saved.generation !== 1 ||
Object.keys(saved.genome).length !== 6 ||
saved.genomeVersion !== 1 ||
saved.rendererVersion !== 1
)
throw Error("JSON lineage/version export invalid");
const svg = fs.readFileSync(
directory + "/child-" + background + ".svg",
"utf8",
),
png = fs
.readFileSync(directory + "/child-" + background + ".png")
.toString("base64");
if (/<script|foreignObject|<image|href=/.test(svg))
throw Error("Unsafe SVG");
const result = await page.evaluate(
async ({ svg, png }) => {
async function raster(src) {
const image = new Image();
image.src = src;
await image.decode();
const c = document.createElement("canvas");
c.width = 512;
c.height = 512;
const ctx = c.getContext("2d");
ctx.drawImage(image, 0, 0, 512, 512);
return {
data: ctx.getImageData(0, 0, 512, 512).data,
width: image.naturalWidth,
height: image.naturalHeight,
};
}
const a = await raster("data:image/png;base64," + png),
b = await raster(
"data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg),
),
c = await raster(
"data:image/svg+xml;charset=utf-8," +
encodeURIComponent(
document.querySelector("dialog .specimen>.plant-preview svg")
.outerHTML,
),
);
let difference = 0,
preview = 0;
for (let i = 0; i < a.data.length; i++) {
difference += Math.abs(a.data[i] - b.data[i]);
preview += Math.abs(a.data[i] - c.data[i]);
}
return {
width: a.width,
height: a.height,
corner: Array.from(a.data.slice(0, 4)),
svgDifference: difference / a.data.length,
previewDifference: preview / a.data.length,
};
},
{ svg, png },
);
if (
result.width !== 512 ||
result.height !== 512 ||
result.svgDifference > 0.1 ||
result.previewDifference > 0.1
)
throw Error("Image export mismatch");
if (
background === "transparent"
? result.corner[3] !== 0
: result.corner.join(",") !== "246,243,232,255"
)
throw Error("Incorrect export background");
report.exports.push({ background, ...result });
}
const parent = await (
await fetch(base + "/api/v1/plants/" + child.parents[0])
).json();
await dialog
.getByRole("button", {
name: "Parent A · " + parent.id.slice(0, 8) + " →",
exact: true,
})
.click();
await dialog
.getByRole("heading", { name: parent.name, exact: true })
.waitFor();
report.checks.push("inspector child to saved parent navigation");
await page.keyboard.press("Escape");
await page.route("**/api/v1/plants?**", (route) =>
route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({
error: {
code: "ACCEPTANCE_OUTAGE",
message: "Collection is temporarily unavailable.",
},
}),
}),
);
await page.goto(base + "/gallery");
await page
.getByRole("alert")
.filter({ hasText: "Collection is temporarily unavailable." })
.waitFor();
await page.screenshot({ path: directory + "/error-state.png" });
await page.unroute("**/api/v1/plants?**");
await page.getByRole("button", { name: "Try again", exact: true }).click();
await ready();
report.checks.push("recoverable API error and retry");
for (const route of [
"/lab",
"/breed",
"/gallery",
"/plants/" + child.id,
"/about",
]) {
await width(route, 360);
if (route === "/lab" || route === "/breed" || route === "/gallery")
await page.screenshot({
path: directory + "/" + route.slice(1) + "-mobile.png",
fullPage: true,
});
}
await page.goto(base + "/gallery?q=" + child.id);
await ready();
await page
.getByRole("button", { name: "Inspect " + child.name, exact: true })
.click();
await dialog
.getByRole("heading", { name: "Genotype & phenotype", exact: true })
.waitFor();
await page.reload();
await dialog
.getByRole("heading", { name: child.name, exact: true })
.waitFor();
if (await dialog.evaluate((e) => e.scrollWidth > e.clientWidth))
throw Error("Mobile inspector overflow");
await page.screenshot({ path: directory + "/inspector-mobile.png" });
await page.keyboard.press("Escape");
if (
(await page
.locator(".collection-card")
.first()
.evaluate((e) => getComputedStyle(e).transitionDuration)) !== "0s"
)
throw Error("Reduced-motion override missing");
report.checks.push("deep-linked inspector reload and reduced motion");
if (report.errors.length) throw Error(report.errors.join("\n"));
fs.writeFileSync(
directory + "/browser-checks.json",
JSON.stringify(report, null, 2) + "\n",
);
console.log(JSON.stringify(report, null, 2));
await browser.close();
})().catch((error) => {
console.error(error);
process.exit(1);
});