Initial commit: SparkVault Idea Tracker, 64-card Creative Whack Pack & Nano Banana 2.1 Mockup Studio

This commit is contained in:
airent106 committed 2026-10-29 10:41:37 -06:00
commit 49e3134758
82 files changed
+6312

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules
data/ideas.db*
test
.git
+7
View File
@@ -0,0 +1,7 @@
node_modules/
data/ideas.db
data/ideas.db-shm
data/ideas.db-wal
data/mockups/nb-*
data/mockups/mockup-*
.env
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=80
ENV DATA_DIR=/app/store
COPY package*.json ./
RUN npm install --omit=dev
COPY public/ ./public/
COPY server/ ./server/
COPY data/mockups/ ./data/mockups/
RUN mkdir -p /app/store/mockups
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1
CMD ["node", "server/index.js"]
+16
View File
@@ -0,0 +1,16 @@
# SparkVault — Idea Tracker, Creative Whack Pack & Nano Banana 2.1 Mockup Studio
**SparkVault** is a self-hosted full-stack idea pipeline, creative catalyst deck, and visual mockup studio built with Node.js 22 (`node:sqlite`) and Google's **Nano Banana 2.1 (`gemini-nano-banana-2.1`)**.
## Core Features
- **⚡ Instant Quick Capture (`Ctrl+K`)**: Capture fleeting sparks with inline `#tags`, `@category` (`@tshirt`, `@creative`, `@project`, `@research`, `@spark`), and `!high` excitement boost.
- **📋 5-Stage Pipeline Board & Explorer Grid**: Drag-and-drop ideas across **Spark → Exploring → Building → Shipped → Archived** with composite priority scoring (`0–100`), checklists, Markdown notes, and pros/cons.
- **🃏 64-Card Creative Whack Pack Studio**:
- Full 64-card Roger von Oech deck across **🧭 Explorer (1–16)**, **🎨 Artist (17–32)**, **⚖️ Judge (33–48)**, and **⚔️ Warrior (49–64)**.
- **Idea Collider**: Collide a random Whack Pack card with an active spark to log breakthroughs or visualize twists.
- **4-Role Creative Spread** & searchable 64-card browser.
- **🍌 Nano Banana 2.1 Visual Mockup Studio (`gemini-nano-banana-2.1`)**:
- Generate photorealistic **T-Shirt Full-Front**, **T-Shirt 5-inch Left-Chest**, **Die-Cut Sticker**, **Poster**, or **Isolated Screenprint Graphic** mockups.
- **Multi-Turn Iterative Image Editing**: Select any existing mockup and pass a natural-language edit instruction to evolve the concept step-by-step.
- **Interactive HTML5 Canvas T-Shirt & Product Compositor**: Instant garment color swatches, Left-Chest vs. Full-Front placement, vector emblems, custom image overlay, and screenprint typography.
- **⚔️ Resurfacing & Head-to-Head Arena**: Pairwise idea comparisons and a stale-spark triage deck.
Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

+29
View File
@@ -0,0 +1,29 @@
services:
sparkvault:
build:
context: .
dockerfile: Dockerfile
container_name: sparkvault
restart: unless-stopped
volumes:
- /home/chrisf/docker/appdata/sparkvault/data:/app/store
networks:
- t3_proxy
ports:
- "127.0.0.1:8480:80"
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
labels:
- "traefik.enable=true"
- "traefik.http.routers.sparkvault.rule=Host(`sparkvault.vloser.us`)"
- "traefik.http.routers.sparkvault.entrypoints=websecure"
- "traefik.http.routers.sparkvault.tls.certresolver=letsencrypt"
- "traefik.http.services.sparkvault.loadbalancer.server.port=80"
networks:
t3_proxy:
external: true
+479
View File
@@ -0,0 +1,479 @@
{
"name": "sparkvault-idea-tracker",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sparkvault-idea-tracker",
"version": "1.0.0",
"dependencies": {
"@google/genai": "^2.29.1"
}
},
"node_modules/@google/genai": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.32.0.tgz",
"integrity": "sha512-60c79uXpR0a47bmGqDxbXEBuQUalNmwo5mx5kqb91jIW4FoqpsLSt86DeVhRhtH5+s/16Wqxt3sXzFQn2U1OHQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"p-retry": "^4.6.2",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
"license": "BSD-3-Clause"
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/gaxios": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz",
"integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-auth-library": {
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz",
"integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/protobufjs": {
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "sparkvault-idea-tracker",
"version": "1.0.0",
"description": "Full-stack Idea Tracker with 64-card Creative Whack Pack & Nano Banana 2.1 Visual Mockup Studio",
"type": "module",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"test": "node --test test/api.test.js"
},
"dependencies": {
"@google/genai": "^2.29.1"
}
}
+1925
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SparkVault">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1E2536"/>
<stop offset="100%" stop-color="#0E121B"/>
</linearGradient>
<linearGradient id="spark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FDE047"/>
<stop offset="50%" stop-color="#F59E0B"/>
<stop offset="100%" stop-color="#EA580C"/>
</linearGradient>
</defs>
<!-- Rounded Studio Badge -->
<rect width="64" height="64" rx="16" fill="url(#bg)"/>
<rect x="1" y="1" width="62" height="62" rx="15" fill="none" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="1"/>
<!-- 4 Whack Pack Suit Accent Dots (Explorer, Artist, Judge, Warrior) -->
<circle cx="14" cy="14" r="3" fill="#2B7EC1"/>
<circle cx="50" cy="14" r="3" fill="#D95F3B"/>
<circle cx="14" cy="50" r="3" fill="#2A9158"/>
<circle cx="50" cy="50" r="3" fill="#B8335A"/>
<!-- Tilted Creative Whack Card Silhouette behind the Spark -->
<g transform="rotate(-10 32 32)">
<rect x="17" y="12" width="28" height="38" rx="4" fill="#FBF8F1" stroke="#D8CCB8" stroke-width="1.5"/>
<line x1="21" y1="19" x2="39" y2="19" stroke="#2B7EC1" stroke-width="2.2" stroke-linecap="round"/>
<line x1="21" y1="43" x2="35" y2="43" stroke="#D95F3B" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- Central Glowing Idea Spark / Lightning Bulb -->
<polygon points="36,11 21,34 31,34 27,53 45,28 34,28" fill="url(#spark)" stroke="#FFFBEB" stroke-width="1.2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+694
View File
@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SparkVault — Idea Tracker, Creative Whack Pack & Nano Banana Mockup Studio</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!-- TOP HEADER & QUICK CAPTURE BAR -->
<header class="topbar">
<div class="topbar-brand">
<div class="brand-logo" aria-hidden="true">⚡</div>
<div>
<div class="brand-title-row">
<h1 class="brand-title">SparkVault</h1>
<span class="brand-badge">Whack Pack + 🍌 Nano Banana 2.1</span>
</div>
<p class="brand-subtitle">Capture sparks, break blocks with 64 Creative Whack cards, and mock up visuals</p>
</div>
</div>
<!-- Instant Quick Capture Command Bar -->
<form id="quick-capture-form" class="quick-capture-bar" autocomplete="off">
<span class="qc-icon" title="Quick Capture (Ctrl+K)">✨</span>
<input
id="quick-capture-input"
type="text"
placeholder="Quick capture an idea... e.g. Retro Ski Club Tee @creative #tshirt !high (Ctrl+K)"
aria-label="Quick capture idea"
/>
<select id="quick-capture-category" aria-label="Idea category">
<option value="spark">⚡ Spark</option>
<option value="creative">🎨 Creative / Merch</option>
<option value="project">🛠️ Project / App</option>
<option value="research">🔬 Research</option>
</select>
<button type="submit" class="btn btn-primary btn-sm">Capture</button>
<button
type="button"
id="btn-quick-whack-prompt"
class="btn btn-ghost btn-sm"
title="Draw a random Creative Whack Pack card to spark a new idea"
>
🃏 Whack Prompt
</button>
</form>
<div class="topbar-actions">
<button type="button" id="btn-open-settings" class="btn btn-ghost btn-sm" title="Configure Nano Banana 2.1 API Key & Model">
🍌 <span id="api-key-status-dot" class="status-dot"></span> Nano Banana
</button>
<div class="dropdown">
<button type="button" id="btn-export-menu" class="btn btn-ghost btn-sm">⬇️ Export</button>
<div id="export-dropdown-menu" class="dropdown-menu hidden">
<a href="/api/export?format=json" download="sparkvault-ideas.json" class="dropdown-item">📦 Export JSON Vault</a>
<a href="/api/export?format=markdown" download="sparkvault-ideas.md" class="dropdown-item">📝 Export Markdown Book</a>
</div>
</div>
<button type="button" id="btn-theme-toggle" class="btn btn-ghost btn-sm" title="Switch Dark / Warm Paper Theme">
☀️ Theme
</button>
</div>
</header>
<!-- OPTIONAL QUICK WHACK SPARK BANNER -->
<section id="quick-whack-banner" class="whack-prompt-banner hidden" aria-live="polite">
<div class="whack-banner-inner">
<img id="qwb-card-img" src="" alt="Whack Card" class="qwb-thumb" />
<div class="qwb-content">
<div class="qwb-meta">
<span id="qwb-suit-badge" class="suit-pill">Explorer</span>
<strong id="qwb-card-title">Card #1</strong>
</div>
<p id="qwb-card-question" class="qwb-question"></p>
</div>
<div class="qwb-actions">
<button type="button" id="btn-qwb-use" class="btn btn-primary btn-sm">✨ Create Idea from Card</button>
<button type="button" id="btn-qwb-redraw" class="btn btn-ghost btn-sm">🔄 Draw Another</button>
<button type="button" id="btn-qwb-close" class="btn btn-ghost btn-sm">✕</button>
</div>
</div>
</section>
<!-- NAVIGATION & STATS STRIP -->
<nav class="subnav">
<div class="view-tabs" role="tablist">
<button type="button" class="view-tab active" data-view="board" role="tab">
📋 Pipeline Board
</button>
<button type="button" class="view-tab" data-view="grid" role="tab">
🗂️ Explorer & Mockup Gallery
</button>
<button type="button" class="view-tab" data-view="whack" role="tab">
🃏 Creative Whack Pack <span class="tab-pill">64 Cards</span>
</button>
<button type="button" class="view-tab" data-view="review" role="tab">
⚔️ Review & Head-to-Head Arena
</button>
</div>
<div class="filter-bar" id="global-filter-bar">
<input
type="search"
id="filter-search"
class="filter-input"
placeholder="Search ideas, notes, #tags..."
/>
<select id="filter-category" class="filter-select" aria-label="Filter by category">
<option value="all">All Categories</option>
<option value="creative">🎨 Creative / Merch</option>
<option value="project">🛠️ Project / Product</option>
<option value="spark">⚡ Quick Spark</option>
<option value="research">🔬 Research</option>
</select>
<select id="filter-tag" class="filter-select" aria-label="Filter by tag">
<option value="all">All #Tags</option>
</select>
<label class="filter-checkbox">
<input type="checkbox" id="filter-has-mockup" />
<span>🍌 Has Mockup (<span id="stat-mockups-count">0</span>)</span>
</label>
<select id="filter-sort" class="filter-select" aria-label="Sort ideas">
<option value="score">Sort: Priority Score</option>
<option value="excitement">Sort: Excitement</option>
<option value="arena">Sort: Arena Wins</option>
<option value="updated">Sort: Recently Updated</option>
<option value="newest">Sort: Newest First</option>
</select>
</div>
</nav>
<!-- MAIN WORKSPACE -->
<main class="workspace">
<!-- VIEW 1: PIPELINE KANBAN BOARD -->
<section id="view-board" class="view-section active">
<div class="kanban-board" id="kanban-board">
<div class="kanban-column" data-status="spark">
<div class="kanban-col-header">
<div>
<span class="status-dot status-spark"></span>
<h2>✨ Spark</h2>
<span class="role-hint">🧭 Explorer 1–16</span>
</div>
<span class="col-count" id="count-spark">0</span>
</div>
<div class="kanban-dropzone" data-status="spark" id="col-spark"></div>
</div>
<div class="kanban-column" data-status="exploring">
<div class="kanban-col-header">
<div>
<span class="status-dot status-exploring"></span>
<h2>🔍 Exploring</h2>
<span class="role-hint">🎨 Artist & ⚖️ Judge</span>
</div>
<span class="col-count" id="count-exploring">0</span>
</div>
<div class="kanban-dropzone" data-status="exploring" id="col-exploring"></div>
</div>
<div class="kanban-column" data-status="building">
<div class="kanban-col-header">
<div>
<span class="status-dot status-building"></span>
<h2>🛠️ Building</h2>
<span class="role-hint">⚔️ Warrior 49–64</span>
</div>
<span class="col-count" id="count-building">0</span>
</div>
<div class="kanban-dropzone" data-status="building" id="col-building"></div>
</div>
<div class="kanban-column" data-status="shipped">
<div class="kanban-col-header">
<div>
<span class="status-dot status-shipped"></span>
<h2>🚀 Shipped</h2>
<span class="role-hint">Launched</span>
</div>
<span class="col-count" id="count-shipped">0</span>
</div>
<div class="kanban-dropzone" data-status="shipped" id="col-shipped"></div>
</div>
<div class="kanban-column" data-status="archived">
<div class="kanban-col-header">
<div>
<span class="status-dot status-archived"></span>
<h2>📦 Archived</h2>
<span class="role-hint">Parked Vault</span>
</div>
<span class="col-count" id="count-archived">0</span>
</div>
<div class="kanban-dropzone" data-status="archived" id="col-archived"></div>
</div>
</div>
</section>
<!-- VIEW 2: EXPLORER GRID & MOCKUP GALLERY -->
<section id="view-grid" class="view-section hidden">
<div class="grid-toolbar">
<div class="status-pills" id="grid-status-pills">
<button type="button" class="pill-btn active" data-status="all">All Stages</button>
<button type="button" class="pill-btn" data-status="spark">✨ Spark</button>
<button type="button" class="pill-btn" data-status="exploring">🔍 Exploring</button>
<button type="button" class="pill-btn" data-status="building">🛠️ Building</button>
<button type="button" class="pill-btn" data-status="shipped">🚀 Shipped</button>
<button type="button" class="pill-btn" data-status="archived">📦 Archived</button>
</div>
<button type="button" id="btn-new-idea-modal" class="btn btn-primary btn-sm">+ New Detailed Idea</button>
</div>
<div id="explorer-grid" class="explorer-grid"></div>
</section>
<!-- VIEW 3: CREATIVE WHACK PACK STUDIO (64 CARDS) -->
<section id="view-whack" class="view-section hidden">
<div class="whack-studio-header">
<div>
<h2>🃏 Creative Whack Pack Catalyst Studio</h2>
<p>64 lateral-thinking strategies by Roger von Oech across 4 creative roles: <strong>🧭 Explorer (1–16)</strong>, <strong>🎨 Artist (17–32)</strong>, <strong>⚖️ Judge (33–48)</strong>, and <strong>⚔️ Warrior (49–64)</strong>.</p>
</div>
<div class="whack-mode-tabs">
<button type="button" class="pill-btn active" data-whack-mode="collider">⚡ Idea Collider (Card + Idea)</button>
<button type="button" class="pill-btn" data-whack-mode="spread">🎴 4-Role Creative Spread</button>
<button type="button" class="pill-btn" data-whack-mode="browser">📚 Browse All 64 Cards</button>
</div>
</div>
<!-- SUB-MODE A: IDEA COLLIDER -->
<div id="whack-submode-collider" class="whack-submode">
<div class="collider-controls">
<div class="collider-control-group">
<label for="collider-suit-select">Filter Suit:</label>
<select id="collider-suit-select" class="filter-select">
<option value="">Stage-Matched / Any Suit</option>
<option value="explorer">🧭 Explorer (1–16: Gather & Discover)</option>
<option value="artist">🎨 Artist (17–32: Play & Transform)</option>
<option value="judge">⚖️ Judge (33–48: Evaluate & Test)</option>
<option value="warrior">⚔️ Warrior (49–64: Act & Ship)</option>
</select>
</div>
<div class="collider-control-group">
<label for="collider-idea-select">Target Idea:</label>
<select id="collider-idea-select" class="filter-select"></select>
</div>
<button type="button" id="btn-deal-collider" class="btn btn-primary">
🎲 Collide Card + Idea
</button>
<button type="button" id="btn-collider-random-both" class="btn btn-ghost">
🔀 Random Card & Random Spark
</button>
</div>
<div id="collider-stage" class="collider-stage"></div>
</div>
<!-- SUB-MODE B: 4-ROLE SPREAD -->
<div id="whack-submode-spread" class="whack-submode hidden">
<div class="spread-bar">
<p>Deal one card from each role (<strong>Explorer → Artist → Judge → Warrior</strong>) to walk an idea from raw spark to shipped execution.</p>
<button type="button" id="btn-deal-spread" class="btn btn-primary">🎴 Deal Fresh 4-Role Spread</button>
</div>
<div id="spread-grid" class="spread-grid"></div>
</div>
<!-- SUB-MODE C: 64-CARD DECK BROWSER -->
<div id="whack-submode-browser" class="whack-submode hidden">
<div class="deck-filter-bar">
<div class="suit-filter-buttons" id="deck-suit-filters">
<button type="button" class="pill-btn active" data-suit="all">All 64 Cards</button>
<button type="button" class="pill-btn suit-explorer-btn" data-suit="explorer">🧭 Explorer (1–16)</button>
<button type="button" class="pill-btn suit-artist-btn" data-suit="artist">🎨 Artist (17–32)</button>
<button type="button" class="pill-btn suit-judge-btn" data-suit="judge">⚖️ Judge (33–48)</button>
<button type="button" class="pill-btn suit-warrior-btn" data-suit="warrior">⚔️ Warrior (49–64)</button>
</div>
<input type="search" id="deck-search-input" class="filter-input" placeholder="Search 64 cards by title, number, or prompt..." />
</div>
<div id="deck-cards-grid" class="deck-cards-grid"></div>
</div>
</section>
<!-- VIEW 4: REVIEW & HEAD-TO-HEAD ARENA -->
<section id="view-review" class="view-section hidden">
<div class="review-header">
<div>
<h2>⚔️ Idea Resurfacing & Prioritization Studio</h2>
<p>Prevent the idea graveyard: pit ideas head-to-head to find what excites you most, or triage older sparks one card at a time.</p>
</div>
<div class="review-mode-tabs">
<button type="button" class="pill-btn active" data-review-mode="arena">⚔️ Head-to-Head Arena</button>
<button type="button" class="pill-btn" data-review-mode="triage">🃏 Stale Spark Triage Deck</button>
</div>
</div>
<div id="review-submode-arena" class="review-submode">
<div class="arena-instructions">
<span>Which idea excites you more to work on or prototype next?</span>
<button type="button" id="btn-skip-arena" class="btn btn-ghost btn-sm">🔀 Skip / Deal New Pair</button>
</div>
<div id="arena-stage" class="arena-stage"></div>
</div>
<div id="review-submode-triage" class="review-submode hidden">
<div id="triage-stage" class="triage-stage"></div>
</div>
</section>
</main>
<!-- SLIDE-OVER RICH IDEA WORKBENCH & NANO BANANA MOCKUP STUDIO -->
<div id="drawer-backdrop" class="drawer-backdrop hidden"></div>
<aside id="idea-drawer" class="idea-drawer hidden" aria-label="Idea Workbench">
<div class="drawer-header">
<div class="drawer-title-group">
<input type="text" id="drawer-title-input" class="drawer-title-input" placeholder="Idea Title" />
<div class="drawer-meta-row">
<select id="drawer-status-select" class="drawer-select">
<option value="spark">✨ Spark</option>
<option value="exploring">🔍 Exploring</option>
<option value="building">🛠️ Building</option>
<option value="shipped">🚀 Shipped</option>
<option value="archived">📦 Archived</option>
</select>
<select id="drawer-category-select" class="drawer-select">
<option value="creative">🎨 Creative / Merch</option>
<option value="project">🛠️ Project / Product</option>
<option value="spark">⚡ Quick Spark</option>
<option value="research">🔬 Research</option>
</select>
<button type="button" id="drawer-pin-btn" class="btn btn-ghost btn-sm" title="Pin Idea">📌 Pin</button>
<span id="drawer-score-badge" class="score-badge">Score: 75</span>
</div>
</div>
<div class="drawer-header-actions">
<button type="button" id="drawer-delete-btn" class="btn btn-danger-ghost btn-sm" title="Delete Idea">🗑️</button>
<button type="button" id="drawer-close-btn" class="btn btn-ghost btn-sm" title="Close (Esc)">✕ Close</button>
</div>
</div>
<!-- DRAWER SUB-TABS -->
<div class="drawer-tabs">
<button type="button" class="drawer-tab active" data-drawer-tab="details">
📝 Workbench & Notes
</button>
<button type="button" class="drawer-tab" data-drawer-tab="mockup">
🍌 Nano Banana Mockup Studio <span id="drawer-mockup-count" class="tab-pill">0</span>
</button>
<button type="button" class="drawer-tab" data-drawer-tab="whack">
🃏 Whack This Idea <span id="drawer-whack-count" class="tab-pill">0</span>
</button>
</div>
<div class="drawer-body">
<!-- TAB 1: DETAILS, NOTES, CHECKLIST, PROS/CONS, RATINGS -->
<div id="drawer-tab-details" class="drawer-tab-panel active">
<div class="field-group">
<label for="drawer-pitch-input">Elevator Pitch (One-sentence essence)</label>
<input type="text" id="drawer-pitch-input" class="text-input" placeholder="What makes this idea compelling in one sentence?" />
</div>
<!-- Prioritization Sliders -->
<div class="ratings-card">
<div class="rating-control">
<label for="slider-excitement">🔥 Excitement: <strong id="val-excitement">3</strong>/5</label>
<input type="range" id="slider-excitement" min="1" max="5" step="1" value="3" />
</div>
<div class="rating-control">
<label for="slider-impact">🎯 Impact: <strong id="val-impact">3</strong>/5</label>
<input type="range" id="slider-impact" min="1" max="5" step="1" value="3" />
</div>
<div class="rating-control">
<label for="slider-ease">⚡ Ease / Feasibility: <strong id="val-ease">3</strong>/5</label>
<input type="range" id="slider-ease" min="1" max="5" step="1" value="3" />
</div>
</div>
<!-- Tags -->
<div class="field-group">
<label>Tags</label>
<div class="tags-editor">
<div id="drawer-tags-list" class="tags-list"></div>
<form id="drawer-add-tag-form" class="inline-add-form">
<input type="text" id="drawer-new-tag-input" class="text-input input-sm" placeholder="Add #tag..." />
<button type="submit" class="btn btn-ghost btn-sm">+ Add</button>
</form>
</div>
</div>
<!-- Markdown Notes -->
<div class="field-group">
<div class="field-header-row">
<label for="drawer-notes-input">Specs, Brainstorm & Markdown Notes</label>
<button type="button" id="btn-toggle-md-preview" class="btn btn-ghost btn-xs">👁️ Toggle Preview</button>
</div>
<textarea id="drawer-notes-input" rows="6" class="textarea-input" placeholder="Write specs, fabric/ink colors, architecture notes, or markdown lists..."></textarea>
<div id="drawer-notes-preview" class="markdown-preview hidden"></div>
</div>
<!-- Action Checklist -->
<div class="field-group">
<div class="field-header-row">
<label>Action Checklist</label>
<span id="drawer-checklist-progress" class="muted-label">0/0 completed</span>
</div>
<div id="drawer-checklist-items" class="checklist-container"></div>
<form id="drawer-add-checklist-form" class="inline-add-form">
<input type="text" id="drawer-new-checklist-input" class="text-input input-sm" placeholder="Add next concrete step..." />
<button type="submit" class="btn btn-secondary btn-sm">+ Add Step</button>
</form>
</div>
<!-- Pros & Cons Matrix -->
<div class="pros-cons-grid">
<div class="field-group">
<label>✅ Pros / Why It Works</label>
<ul id="drawer-pros-list" class="bullet-list pros-list"></ul>
<form id="drawer-add-pro-form" class="inline-add-form">
<input type="text" id="drawer-new-pro-input" class="text-input input-sm" placeholder="Add a pro..." />
<button type="submit" class="btn btn-ghost btn-sm">+</button>
</form>
</div>
<div class="field-group">
<label>⚠️ Cons / Risks & Constraints</label>
<ul id="drawer-cons-list" class="bullet-list cons-list"></ul>
<form id="drawer-add-con-form" class="inline-add-form">
<input type="text" id="drawer-new-con-input" class="text-input input-sm" placeholder="Add a risk or constraint..." />
<button type="submit" class="btn btn-ghost btn-sm">+</button>
</form>
</div>
</div>
<!-- Reference Links -->
<div class="field-group">
<label>🔗 Reference Links & Inspiration</label>
<div id="drawer-links-list" class="links-list"></div>
<form id="drawer-add-link-form" class="inline-add-form">
<input type="text" id="drawer-link-title" class="text-input input-sm" placeholder="Label (e.g. Blank Shirt Spec)" />
<input type="url" id="drawer-link-url" class="text-input input-sm" placeholder="https://..." />
<button type="submit" class="btn btn-ghost btn-sm">+ Add Link</button>
</form>
</div>
</div>
<!-- TAB 2: NANO BANANA 2.1 VISUAL MOCKUP STUDIO -->
<div id="drawer-tab-mockup" class="drawer-tab-panel hidden">
<div class="mockup-studio-layout">
<!-- Left: Generator & Interactive T-Shirt / Product Compositor -->
<div class="mockup-controls-pane">
<div class="studio-card">
<div class="studio-card-header">
<h3>🍌 Nano Banana 2.1 AI Mockup Generator</h3>
<button type="button" id="btn-studio-auto-prompt" class="btn btn-ghost btn-xs">✨ Auto-Prompt from Idea</button>
</div>
<div class="form-row-2">
<div>
<label for="nb-template-select">Product / Surface Preset</label>
<select id="nb-template-select" class="filter-select full-width">
<option value="tshirt-front">👕 T-Shirt (Full-Front Graphic)</option>
<option value="tshirt-left-chest">👕 T-Shirt (5-in Left-Chest Emblem)</option>
<option value="isolated-graphic">🎨 Isolated Screenprint Vector Artwork</option>
<option value="sticker">🏷️ Die-Cut Vinyl Sticker / Patch</option>
<option value="poster">🖼️ Framed Art Poster</option>
<option value="app-ui">💻 Product / App Interface</option>
</select>
</div>
<div>
<label for="nb-whack-twist-select">Optional Whack Pack Twist</label>
<select id="nb-whack-twist-select" class="filter-select full-width">
<option value="">None (Direct Concept)</option>
</select>
</div>
</div>
<!-- Garment / Surface Color Swatches -->
<div class="field-group">
<label>Garment / Surface Color: <strong id="nb-selected-color-label">Vintage Cream (#F5E8D3)</strong></label>
<div class="color-swatch-row" id="nb-color-swatches">
<button type="button" class="color-swatch active" data-color="#F5E8D3" data-name="Vintage Cream" style="background:#F5E8D3" title="Vintage Cream (#F5E8D3)"></button>
<button type="button" class="color-swatch" data-color="#1C1B1A" data-name="Vintage Black" style="background:#1C1B1A" title="Vintage Black (#1C1B1A)"></button>
<button type="button" class="color-swatch" data-color="#1B2A4A" data-name="Midnight Navy" style="background:#1B2A4A" title="Midnight Navy (#1B2A4A)"></button>
<button type="button" class="color-swatch" data-color="#1E3F2B" data-name="Forest Pine" style="background:#1E3F2B" title="Forest Pine (#1E3F2B)"></button>
<button type="button" class="color-swatch" data-color="#A8422B" data-name="Terracotta Rust" style="background:#A8422B" title="Terracotta Rust (#A8422B)"></button>
<button type="button" class="color-swatch" data-color="#C8C9CC" data-name="Heather Grey" style="background:#C8C9CC" title="Heather Grey (#C8C9CC)"></button>
<button type="button" class="color-swatch" data-color="#FFFFFF" data-name="Crisp White" style="background:#FFFFFF" title="Crisp White (#FFFFFF)"></button>
</div>
</div>
<div class="field-group">
<label for="nb-prompt-input">Visual Description / Artwork Prompt</label>
<textarea id="nb-prompt-input" rows="3" class="textarea-input" placeholder="Describe the graphic, emblem, ink colors, typography, or mockup scene..."></textarea>
</div>
<!-- Iterative Image-to-Image Edit Banner (Active when a mockup is selected for refinement) -->
<div id="nb-edit-mode-box" class="nb-edit-box hidden">
<div class="nb-edit-header">
<span>🍌 <strong>Iterative Edit Mode:</strong> Refining <em id="nb-edit-target-label">Selected Mockup</em></span>
<button type="button" id="btn-nb-clear-edit" class="btn btn-ghost btn-xs">Cancel Edit Mode</button>
</div>
<input
type="text"
id="nb-edit-instruction-input"
class="text-input"
placeholder="Tell Nano Banana what to change (e.g. 'Move graphic to left-chest pocket on a forest green shirt')"
/>
</div>
<div class="studio-action-row">
<button type="button" id="btn-generate-nano-banana" class="btn btn-primary">
🍌 Generate with Nano Banana 2.1
</button>
<span id="nb-generation-status" class="muted-label"></span>
</div>
</div>
<!-- Interactive T-Shirt & Product Canvas Compositor -->
<div class="studio-card">
<div class="studio-card-header">
<h3>👕 Interactive T-Shirt & Emblem Compositor</h3>
<span class="muted-label">Instant apparel layout & print proof</span>
</div>
<div class="form-row-3">
<div>
<label for="comp-surface-select">Mockup Surface</label>
<select id="comp-surface-select" class="filter-select full-width">
<option value="tshirt">👕 Crewneck T-Shirt</option>
<option value="sticker">🏷️ Die-Cut Sticker / Badge</option>
<option value="poster">🖼️ Framed Print</option>
</select>
</div>
<div>
<label for="comp-placement-select">Print Placement</label>
<select id="comp-placement-select" class="filter-select full-width">
<option value="center-chest">Full Center Chest</option>
<option value="left-chest">5-inch Left-Chest Pocket</option>
<option value="oversized">Oversized Vintage Front</option>
</select>
</div>
<div>
<label for="comp-emblem-select">Vector Emblem</label>
<select id="comp-emblem-select" class="filter-select full-width">
<option value="mountains">🏔️ Mountain & Sun</option>
<option value="espresso">☕ Espresso Cup</option>
<option value="lightbulb">💡 Lightbulb Boxer</option>
<option value="compass">🧭 Explorer Compass</option>
<option value="lightning">⚡ Spark Bolt</option>
<option value="none">No Icon (Text/Image Only)</option>
</select>
</div>
</div>
<div class="form-row-2">
<div>
<label for="comp-top-text">Top Headline / Club Name</label>
<input type="text" id="comp-top-text" class="text-input input-sm" placeholder="HIGH ALTITUDE CLUB" />
</div>
<div>
<label for="comp-bottom-text">Bottom Tagline / Est.</label>
<input type="text" id="comp-bottom-text" class="text-input input-sm" placeholder="EST. 1984 • COLORADO" />
</div>
</div>
<div class="form-row-2">
<div>
<label for="comp-ink-color">Primary Screenprint Ink</label>
<input type="color" id="comp-ink-color" value="#BC4921" class="color-picker-input" />
</div>
<div>
<label for="comp-upload-art">Overlay Custom PNG/JPG Graphic</label>
<input type="file" id="comp-upload-art" accept="image/*" class="file-input" />
</div>
</div>
<div class="canvas-preview-wrapper">
<canvas id="mockup-compositor-canvas" width="680" height="680"></canvas>
</div>
<div class="studio-action-row">
<button type="button" id="btn-save-canvas-mockup" class="btn btn-secondary">
📸 Save Canvas Mockup to Idea
</button>
<button type="button" id="btn-download-canvas-png" class="btn btn-ghost btn-sm">
⬇️ Download PNG
</button>
<button type="button" id="btn-clear-uploaded-art" class="btn btn-ghost btn-sm">
Clear Overlay Image
</button>
</div>
</div>
</div>
<!-- Right: Saved Mockup Gallery for this Idea -->
<div class="mockup-gallery-pane">
<div class="studio-card">
<div class="studio-card-header">
<h3>🖼️ Idea Mockup Gallery</h3>
<span class="muted-label">Click 'Refine with 🍌' to iterate on any mockup</span>
</div>
<div id="drawer-mockup-gallery" class="drawer-mockup-gallery"></div>
</div>
</div>
</div>
</div>
<!-- TAB 3: WHACK THIS IDEA -->
<div id="drawer-tab-whack" class="drawer-tab-panel hidden">
<div class="drawer-whack-controls">
<div>
<h3>🃏 Give This Idea a Creative Whack</h3>
<p class="muted-label">Draw a stage-matched card from your 64-card deck to challenge assumptions or spark a new visual direction.</p>
</div>
<div class="drawer-whack-actions">
<select id="drawer-whack-suit-select" class="filter-select">
<option value="">Stage-Matched Suit (Recommended)</option>
<option value="explorer">🧭 Explorer (1–16)</option>
<option value="artist">🎨 Artist (17–32)</option>
<option value="judge">⚖️ Judge (33–48)</option>
<option value="warrior">⚔️ Warrior (49–64)</option>
</select>
<button type="button" id="btn-drawer-draw-whack" class="btn btn-primary btn-sm">
🃏 Draw Whack Card
</button>
</div>
</div>
<div id="drawer-active-whack-stage" class="drawer-whack-stage"></div>
<div class="field-group">
<h4>📜 Whack Breakthrough Log for This Idea</h4>
<div id="drawer-whack-history-list" class="whack-history-list"></div>
</div>
</div>
</div>
</aside>
<!-- MODAL: NANO BANANA 2.1 SETTINGS -->
<div id="settings-modal" class="modal-backdrop hidden">
<div class="modal-card">
<div class="modal-header">
<h3>🍌 Nano Banana 2.1 (Gemini Image API) Settings</h3>
<button type="button" id="btn-close-settings" class="btn btn-ghost btn-sm">✕</button>
</div>
<form id="settings-form" class="modal-body">
<p class="muted-label">
SparkVault uses Google's <strong>Nano Banana 2.1 (`gemini-nano-banana-2.1`)</strong> via the official `@google/genai` Interactions API for text-to-image product mockups and multi-turn conversational image editing.
</p>
<div class="field-group">
<label for="settings-api-key">Gemini API Key (`GEMINI_API_KEY`)</label>
<input
type="password"
id="settings-api-key"
class="text-input"
placeholder="Paste your AI Studio / Gemini API key (AIza...)"
autocomplete="off"
/>
<small id="settings-key-status" class="muted-label"></small>
</div>
<div class="field-group">
<label for="settings-model-select">Nano Banana Model</label>
<select id="settings-model-select" class="filter-select full-width">
<option value="gemini-nano-banana-2.1">gemini-nano-banana-2.1 — Nano Banana 2.1 (Default High-Efficiency)</option>
<option value="gemini-3-pro-image">gemini-3-pro-image — Nano Banana Pro (High-Detail Studio)</option>
<option value="gemini-3.1-flash-lite-image">gemini-3.1-flash-lite-image — Nano Banana 2 Lite (Ultra-Fast)</option>
</select>
</div>
<div class="modal-footer">
<button type="button" id="btn-cancel-settings" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
</div>
</div>
<!-- MODAL: CARD / MOCKUP LIGHTBOX INSPECTOR -->
<div id="lightbox-modal" class="modal-backdrop hidden">
<div class="lightbox-card">
<div class="modal-header">
<h3 id="lightbox-title">Card Preview</h3>
<button type="button" id="btn-close-lightbox" class="btn btn-ghost btn-sm">✕</button>
</div>
<div class="lightbox-body">
<img id="lightbox-img" src="" alt="Preview" />
<div id="lightbox-meta" class="lightbox-meta"></div>
</div>
</div>
</div>
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<script type="module" src="/app.js"></script>
</body>
</html>
+1373
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+601
View File
@@ -0,0 +1,601 @@
import { DatabaseSync } from "node:sqlite";
import * as fs from "node:fs";
import * as path from "node:path";
import { getCardByNumber } from "./whack_cards.js";
export const VALID_STATUSES = ["spark", "exploring", "building", "shipped", "archived"];
export const VALID_CATEGORIES = ["project", "creative", "spark", "research"];
export function parseQuickCaptureInput(rawInput, defaultCategory = "spark") {
let text = String(rawInput || "").trim();
const tags = [];
let detectedCategory = null;
let boostExcitement = false;
// Extract #tags
text = text.replace(/(?:^|\s)#([a-zA-Z0-9_-]+)/g, (_, tag) => {
const clean = tag.toLowerCase();
if (!tags.includes(clean)) tags.push(clean);
return " ";
});
// Extract @category
text = text.replace(/(?:^|\s)@([a-zA-Z0-9_-]+)/g, (_, cat) => {
const lower = cat.toLowerCase();
if (["project", "product", "app", "saas", "startup"].includes(lower)) {
detectedCategory = "project";
} else if (["creative", "art", "tshirt", "merch", "design", "writing"].includes(lower)) {
detectedCategory = "creative";
if (["tshirt", "merch"].includes(lower) && !tags.includes(lower)) {
tags.push(lower);
}
} else if (["research", "book", "learn", "inspiration"].includes(lower)) {
detectedCategory = "research";
} else if (["spark", "thought", "quick"].includes(lower)) {
detectedCategory = "spark";
}
return " ";
});
// Extract !high or !!
if (/(?:^|\s)!(?:high|hot|!)/i.test(text)) {
boostExcitement = true;
text = text.replace(/(?:^|\s)!(?:high|hot|!)/gi, " ");
}
const cleanTitle = text.replace(/\s+/g, " ").trim() || "Untitled Spark";
const category = detectedCategory || (VALID_CATEGORIES.includes(defaultCategory) ? defaultCategory : "spark");
return {
title: cleanTitle,
category,
tags,
boostExcitement
};
}
export function computeCompositeScore(row) {
const excitement = Number(row.rating_excitement ?? 3);
const impact = Number(row.rating_impact ?? 3);
const ease = Number(row.rating_ease ?? 3);
// Weighted base 0..100
const base = ((excitement * 0.42 + impact * 0.35 + ease * 0.23) / 5) * 100;
const arenaBonus = Math.min(15, Number(row.arena_wins || 0) * 3);
const whackBonus = Math.min(6, (row.whack_history?.length || 0) * 2);
const mockupBonus = (row.mockups?.length || 0) > 0 ? 4 : 0;
return Math.min(100, Math.round(base + arenaBonus + whackBonus + mockupBonus));
}
export function createDatabase(dbFilePath) {
fs.mkdirSync(path.dirname(dbFilePath), { recursive: true });
const db = new DatabaseSync(dbFilePath);
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS ideas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
elevator_pitch TEXT DEFAULT '',
category TEXT DEFAULT 'spark',
status TEXT DEFAULT 'spark',
notes_markdown TEXT DEFAULT '',
tags_json TEXT DEFAULT '[]',
checklist_json TEXT DEFAULT '[]',
pros_json TEXT DEFAULT '[]',
cons_json TEXT DEFAULT '[]',
links_json TEXT DEFAULT '[]',
whack_history_json TEXT DEFAULT '[]',
inspired_by_card INTEGER DEFAULT NULL,
mockups_json TEXT DEFAULT '[]',
cover_mockup_url TEXT DEFAULT '',
rating_excitement INTEGER DEFAULT 3,
rating_impact INTEGER DEFAULT 3,
rating_ease INTEGER DEFAULT 3,
arena_wins INTEGER DEFAULT 0,
arena_matches INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
last_reviewed_at TEXT DEFAULT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
function safeParse(jsonStr, fallback = []) {
try {
return jsonStr ? JSON.parse(jsonStr) : fallback;
} catch {
return fallback;
}
}
function hydrateIdea(row) {
if (!row) return null;
const tags = safeParse(row.tags_json, []);
const checklist = safeParse(row.checklist_json, []);
const pros = safeParse(row.pros_json, []);
const cons = safeParse(row.cons_json, []);
const links = safeParse(row.links_json, []);
const whack_history = safeParse(row.whack_history_json, []);
const mockups = safeParse(row.mockups_json, []);
const inspiredCard = row.inspired_by_card ? getCardByNumber(row.inspired_by_card) : null;
const hydrated = {
id: row.id,
title: row.title,
elevator_pitch: row.elevator_pitch || "",
category: row.category || "spark",
status: row.status || "spark",
notes_markdown: row.notes_markdown || "",
tags,
checklist,
pros,
cons,
links,
whack_history,
inspired_by_card: row.inspired_by_card || null,
inspired_card_detail: inspiredCard,
mockups,
cover_mockup_url: row.cover_mockup_url || (mockups[0]?.imageUrl || ""),
rating_excitement: Number(row.rating_excitement ?? 3),
rating_impact: Number(row.rating_impact ?? 3),
rating_ease: Number(row.rating_ease ?? 3),
arena_wins: Number(row.arena_wins ?? 0),
arena_matches: Number(row.arena_matches ?? 0),
pinned: Boolean(row.pinned),
last_reviewed_at: row.last_reviewed_at,
created_at: row.created_at,
updated_at: row.updated_at
};
hydrated.composite_score = computeCompositeScore(hydrated);
return hydrated;
}
function listIdeas({ status, category, tag, search, hasMockup, sort = "score" } = {}) {
const rows = db.prepare(`SELECT * FROM ideas`).all();
let items = rows.map(hydrateIdea);
if (status && status !== "all") {
items = items.filter((i) => i.status === status);
}
if (category && category !== "all") {
items = items.filter((i) => i.category === category);
}
if (tag && tag !== "all") {
items = items.filter((i) => i.tags.includes(tag.toLowerCase()));
}
if (hasMockup === "true" || hasMockup === true) {
items = items.filter((i) => i.mockups.length > 0 || Boolean(i.cover_mockup_url));
}
if (search && search.trim()) {
const q = search.trim().toLowerCase();
items = items.filter(
(i) =>
i.title.toLowerCase().includes(q) ||
i.elevator_pitch.toLowerCase().includes(q) ||
i.notes_markdown.toLowerCase().includes(q) ||
i.tags.some((t) => t.toLowerCase().includes(q))
);
}
items.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (sort === "newest") return b.created_at.localeCompare(a.created_at) || b.id - a.id;
if (sort === "updated") return b.updated_at.localeCompare(a.updated_at) || b.id - a.id;
if (sort === "arena") return b.arena_wins - a.arena_wins || b.composite_score - a.composite_score;
if (sort === "excitement") return b.rating_excitement - a.rating_excitement || b.composite_score - a.composite_score;
return b.composite_score - a.composite_score || b.updated_at.localeCompare(a.updated_at);
});
return items;
}
function getIdea(id) {
const row = db.prepare(`SELECT * FROM ideas WHERE id = ?`).get(Number(id));
return hydrateIdea(row);
}
function createIdea(payload = {}) {
const now = new Date().toISOString();
const parsed = parseQuickCaptureInput(payload.title || "Untitled Spark", payload.category);
const finalTitle = payload.preserveRawTitle ? String(payload.title).trim() : parsed.title;
const finalCategory = VALID_CATEGORIES.includes(payload.category) ? payload.category : parsed.category;
const finalStatus = VALID_STATUSES.includes(payload.status) ? payload.status : "spark";
const mergedTags = Array.from(
new Set([...(Array.isArray(payload.tags) ? payload.tags.map((t) => String(t).toLowerCase()) : []), ...parsed.tags])
);
const excitement = payload.rating_excitement ?? (parsed.boostExcitement ? 5 : 3);
const impact = payload.rating_impact ?? 3;
const ease = payload.rating_ease ?? 3;
const mockups = Array.isArray(payload.mockups) ? payload.mockups : [];
const whackHistory = Array.isArray(payload.whack_history) ? payload.whack_history : [];
const coverMockupUrl = payload.cover_mockup_url || (mockups[0]?.imageUrl ?? "");
const stmt = db.prepare(`
INSERT INTO ideas (
title, elevator_pitch, category, status, notes_markdown,
tags_json, checklist_json, pros_json, cons_json, links_json,
whack_history_json, inspired_by_card, mockups_json, cover_mockup_url,
rating_excitement, rating_impact, rating_ease,
arena_wins, arena_matches, pinned, last_reviewed_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
finalTitle,
payload.elevator_pitch || "",
finalCategory,
finalStatus,
payload.notes_markdown || "",
JSON.stringify(mergedTags),
JSON.stringify(Array.isArray(payload.checklist) ? payload.checklist : []),
JSON.stringify(Array.isArray(payload.pros) ? payload.pros : []),
JSON.stringify(Array.isArray(payload.cons) ? payload.cons : []),
JSON.stringify(Array.isArray(payload.links) ? payload.links : []),
JSON.stringify(whackHistory),
payload.inspired_by_card ? Number(payload.inspired_by_card) : null,
JSON.stringify(mockups),
coverMockupUrl,
Number(excitement),
Number(impact),
Number(ease),
Number(payload.arena_wins || 0),
Number(payload.arena_matches || 0),
payload.pinned ? 1 : 0,
payload.last_reviewed_at || null,
now,
now
);
return getIdea(result.lastInsertRowid);
}
function updateIdea(id, patch = {}) {
const existing = getIdea(id);
if (!existing) return null;
const now = new Date().toISOString();
const updated = {
title: patch.title !== undefined ? String(patch.title).trim() || existing.title : existing.title,
elevator_pitch: patch.elevator_pitch !== undefined ? String(patch.elevator_pitch) : existing.elevator_pitch,
category: patch.category !== undefined && VALID_CATEGORIES.includes(patch.category) ? patch.category : existing.category,
status: patch.status !== undefined && VALID_STATUSES.includes(patch.status) ? patch.status : existing.status,
notes_markdown: patch.notes_markdown !== undefined ? String(patch.notes_markdown) : existing.notes_markdown,
tags: Array.isArray(patch.tags) ? patch.tags.map((t) => String(t).toLowerCase().trim()).filter(Boolean) : existing.tags,
checklist: Array.isArray(patch.checklist) ? patch.checklist : existing.checklist,
pros: Array.isArray(patch.pros) ? patch.pros : existing.pros,
cons: Array.isArray(patch.cons) ? patch.cons : existing.cons,
links: Array.isArray(patch.links) ? patch.links : existing.links,
whack_history: Array.isArray(patch.whack_history) ? patch.whack_history : existing.whack_history,
inspired_by_card: patch.inspired_by_card !== undefined ? (patch.inspired_by_card ? Number(patch.inspired_by_card) : null) : existing.inspired_by_card,
mockups: Array.isArray(patch.mockups) ? patch.mockups : existing.mockups,
cover_mockup_url: patch.cover_mockup_url !== undefined ? String(patch.cover_mockup_url) : existing.cover_mockup_url,
rating_excitement: patch.rating_excitement !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_excitement))) : existing.rating_excitement,
rating_impact: patch.rating_impact !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_impact))) : existing.rating_impact,
rating_ease: patch.rating_ease !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_ease))) : existing.rating_ease,
arena_wins: patch.arena_wins !== undefined ? Number(patch.arena_wins) : existing.arena_wins,
arena_matches: patch.arena_matches !== undefined ? Number(patch.arena_matches) : existing.arena_matches,
pinned: patch.pinned !== undefined ? (patch.pinned ? 1 : 0) : (existing.pinned ? 1 : 0),
last_reviewed_at: patch.last_reviewed_at !== undefined ? patch.last_reviewed_at : existing.last_reviewed_at,
updated_at: now
};
db.prepare(`
UPDATE ideas SET
title = ?, elevator_pitch = ?, category = ?, status = ?, notes_markdown = ?,
tags_json = ?, checklist_json = ?, pros_json = ?, cons_json = ?, links_json = ?,
whack_history_json = ?, inspired_by_card = ?, mockups_json = ?, cover_mockup_url = ?,
rating_excitement = ?, rating_impact = ?, rating_ease = ?,
arena_wins = ?, arena_matches = ?, pinned = ?, last_reviewed_at = ?, updated_at = ?
WHERE id = ?
`).run(
updated.title,
updated.elevator_pitch,
updated.category,
updated.status,
updated.notes_markdown,
JSON.stringify(updated.tags),
JSON.stringify(updated.checklist),
JSON.stringify(updated.pros),
JSON.stringify(updated.cons),
JSON.stringify(updated.links),
JSON.stringify(updated.whack_history),
updated.inspired_by_card,
JSON.stringify(updated.mockups),
updated.cover_mockup_url,
updated.rating_excitement,
updated.rating_impact,
updated.rating_ease,
updated.arena_wins,
updated.arena_matches,
updated.pinned,
updated.last_reviewed_at,
updated.updated_at,
Number(id)
);
return getIdea(id);
}
function deleteIdea(id) {
const info = db.prepare(`DELETE FROM ideas WHERE id = ?`).run(Number(id));
return info.changes > 0;
}
function logWhackOnIdea(id, { cardNumber, reflection, addChecklistItem = false, promoteStatus = null }) {
const idea = getIdea(id);
if (!idea) return null;
const card = getCardByNumber(cardNumber);
if (!card) throw new Error(`Invalid Whack Pack card number: ${cardNumber}`);
const entry = {
id: `whack-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
cardNumber: card.number,
cardTitle: card.title,
suit: card.suit,
suitLabel: card.suitLabel,
promptQuestion: card.promptQuestion,
reflection: String(reflection || "").trim(),
createdAt: new Date().toISOString()
};
const nextWhackHistory = [entry, ...idea.whack_history];
const nextChecklist = [...idea.checklist];
if (addChecklistItem && entry.reflection) {
nextChecklist.push({
id: `chk-${Date.now()}`,
text: `[Card #${card.number} ${card.title}] ${entry.reflection}`,
done: false
});
}
const patch = {
whack_history: nextWhackHistory,
checklist: nextChecklist,
last_reviewed_at: new Date().toISOString()
};
if (promoteStatus && VALID_STATUSES.includes(promoteStatus)) {
patch.status = promoteStatus;
}
return updateIdea(id, patch);
}
function addMockupToIdea(id, mockupEntry) {
const idea = getIdea(id);
if (!idea) return null;
const entry = {
id: mockupEntry.id || `mk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
title: mockupEntry.title || `${idea.title} Mockup`,
template: mockupEntry.template || "tshirt-front",
garmentColor: mockupEntry.garmentColor || "#F5E8D3",
garmentColorName: mockupEntry.garmentColorName || "Vintage Cream",
placement: mockupEntry.placement || "center-chest",
prompt: mockupEntry.prompt || "",
editInstruction: mockupEntry.editInstruction || "",
parentMockupId: mockupEntry.parentMockupId || null,
modelUsed: mockupEntry.modelUsed || "canvas-compositor",
imageUrl: mockupEntry.imageUrl,
topText: mockupEntry.topText || "",
bottomText: mockupEntry.bottomText || "",
createdAt: new Date().toISOString()
};
const nextMockups = [entry, ...idea.mockups];
return updateIdea(id, {
mockups: nextMockups,
cover_mockup_url: entry.imageUrl
});
}
function recordArenaMatch({ winnerId, loserId }) {
const winner = getIdea(winnerId);
const loser = getIdea(loserId);
if (!winner || !loser) return null;
const now = new Date().toISOString();
const updatedWinner = updateIdea(winner.id, {
arena_wins: winner.arena_wins + 1,
arena_matches: winner.arena_matches + 1,
last_reviewed_at: now
});
const updatedLoser = updateIdea(loser.id, {
arena_matches: loser.arena_matches + 1,
last_reviewed_at: now
});
return { winner: updatedWinner, loser: updatedLoser };
}
function getSetting(key, defaultValue = "") {
const row = db.prepare(`SELECT value FROM settings WHERE key = ?`).get(String(key));
return row ? row.value : defaultValue;
}
function setSetting(key, value) {
db.prepare(`
INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(String(key), String(value ?? ""));
}
function seedIfEmpty() {
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM ideas`).get();
if (countRow && countRow.count > 0) return;
createIdea({
title: "High Altitude Espresso Club — Vintage Screenprint Tee",
elevator_pitch: "Heavyweight cream crewneck t-shirt with a 3-color retro mountain & espresso emblem (5-inch left chest + full back print).",
category: "creative",
status: "building",
rating_excitement: 5,
rating_impact: 4,
rating_ease: 5,
pinned: true,
tags: ["tshirt", "merch", "screenprint", "apparel"],
notes_markdown: "### Garment & Ink Specs\n- **Blank**: Heavyweight 6.5oz combed ring-spun tee in **Vintage Cream (`#F5E8D3`)**.\n- **Ink Palette**: Espresso Brown (`#42200E`), Burnt Orange (`#BC4921`), Warm Gold (`#EFA51C`).\n- **Placement**: Test both **Left-Chest 5-inch emblem** and **Full-Front club crest** in the Nano Banana Mockup Studio.",
checklist: [
{ id: "c1", text: "Generate initial 3-color emblem mockup in Nano Banana Studio", done: true },
{ id: "c2", text: "Compare Left-Chest 5in placement vs Full-Front on Cream tee", done: true },
{ id: "c3", text: "Test Forest Green & Vintage Black colorway variations", done: false },
{ id: "c4", text: "Export 300 DPI print-ready PNG proof", done: false }
],
pros: [
"3 spot colors keep screen setup affordable",
"Cream fabric works as the 4th highlight tone without white underbase"
],
cons: [
"Small tagline text needs minimum 10pt stroke for clean screen mesh"
],
inspired_by_card: 14,
whack_history: [
{
id: "whack-seed-1",
cardNumber: 14,
cardTitle: "Look to the Past",
suit: "explorer",
suitLabel: "Explorer",
promptQuestion: "What vintage craft, historical design, or classic wisdom can you revive or remix here?",
reflection: "Modeled the typography and badge border after 1970s Alpine mountaineering club patches and classic Italian espresso tin labels.",
createdAt: new Date(Date.now() - 86400000 * 2).toISOString()
}
],
mockups: [
{
id: "mk-seed-espresso",
title: "Vintage Cream Tee — 3-Color Mountain Espresso Crest",
template: "tshirt-front",
garmentColor: "#F5E8D3",
garmentColorName: "Vintage Cream",
placement: "center-chest",
prompt: "Heavyweight vintage cream crewneck t-shirt featuring a 3-color retro screenprint emblem for HIGH ALTITUDE ESPRESSO CLUB - EST. 1984",
modelUsed: "gemini-nano-banana-2.1",
imageUrl: "/mockups/seed-tshirt-espresso.jpg",
createdAt: new Date(Date.now() - 3600000 * 12).toISOString()
}
],
cover_mockup_url: "/mockups/seed-tshirt-espresso.jpg",
arena_wins: 3,
arena_matches: 3
});
createIdea({
title: "Take a Whack At It — Die-Cut Studio Sticker & Desk Totem",
elevator_pitch: "Die-cut vinyl laptop & sketchbook sticker inspired by Card #49 (Warrior suit) to nudge daily shipping habits.",
category: "creative",
status: "exploring",
rating_excitement: 4,
rating_impact: 4,
rating_ease: 5,
tags: ["sticker", "merch", "whack-pack", "illustration"],
notes_markdown: "Inspired by Roger von Oech's **Warrior Card #49: Take a Whack at It**.\n\nCould be printed as a 3-inch matte vinyl sticker and included as a physical reward whenever a project in the vault moves to **Shipped**.",
checklist: [
{ id: "c21", text: "Mock up die-cut sticker contour with bold typography", done: true },
{ id: "c22", text: "Try a t-shirt pocket variation in the Mockup Studio", done: false }
],
pros: ["Instant tactile reminder to stop overthinking and build"],
cons: ["Needs high-contrast border for dark laptops"],
inspired_by_card: 49,
whack_history: [
{
id: "whack-seed-2",
cardNumber: 49,
cardTitle: "Take a Whack at It",
suit: "warrior",
suitLabel: "Warrior",
promptQuestion: "How can you take a concrete whack at your idea in the next 30 minutes?",
reflection: "Turned the lightbulb-boxer metaphor directly into a collectible studio sticker.",
createdAt: new Date(Date.now() - 86400000).toISOString()
}
],
mockups: [
{
id: "mk-seed-sticker",
title: "Die-Cut Vinyl Sticker — Lightbulb Boxer",
template: "sticker",
garmentColor: "#1C1B1A",
garmentColorName: "Studio Slate",
placement: "die-cut",
prompt: "Die-cut vinyl sticker of a glowing lightbulb wearing red boxing gloves with text TAKE A WHACK AT IT",
modelUsed: "gemini-nano-banana-2.1",
imageUrl: "/mockups/seed-sticker-whack.jpg",
createdAt: new Date(Date.now() - 3600000 * 6).toISOString()
}
],
cover_mockup_url: "/mockups/seed-sticker-whack.jpg",
arena_wins: 2,
arena_matches: 3
});
createIdea({
title: "E-Ink Kitchen Recipe & Brew Telemetry Frame",
elevator_pitch: "Low-power wall-mounted e-paper display that shows active fermentation stats by day and switches to dinner recipe cards at 5pm.",
category: "project",
status: "exploring",
rating_excitement: 5,
rating_impact: 5,
rating_ease: 3,
tags: ["hardware", "e-ink", "homelab", "brewing"],
notes_markdown: "Combine a 7.5-inch tri-color E-Ink panel with ESP32.\n- Morning/Afternoon: Ambient dashboard (brew tilt hydrometer gravity + cellar temp).\n- Evening: Pulls tonight's selected recipe card with step-by-step ingredients.",
checklist: [
{ id: "c31", text: "Design 800x480 1-bit wireframe layout", done: false },
{ id: "c32", text: "Test rotary encoder for scrolling recipe steps with floury hands", done: false }
],
pros: ["Zero screen glare in the kitchen", "Battery lasts months"],
cons: ["Slow refresh rate when switching pages"],
inspired_by_card: 25,
arena_wins: 1,
arena_matches: 2
});
createIdea({
title: "National Park Night-Sky Astrophotography Hoodie Series",
elevator_pitch: "Minimalist star-chart back print paired with elevation coordinates on the left chest for Maroon Bells & Mount Sneffels.",
category: "creative",
status: "spark",
rating_excitement: 4,
rating_impact: 4,
rating_ease: 4,
tags: ["tshirt", "apparel", "colorado", "astronomy"],
notes_markdown: "Use glow-in-the-dark or metallic silver puff ink on a deep midnight navy garment (`#1B2A4A`). Great candidate to test in the **Nano Banana Mockup Studio**!"
});
createIdea({
title: "Acoustic Vinyl Listening Log & Liner Notes Archive",
elevator_pitch: "Scan album jackets on the turntable and pair them with personal listening memories and pressing notes.",
category: "spark",
status: "spark",
rating_excitement: 3,
rating_impact: 3,
rating_ease: 4,
tags: ["music", "archive", "weekend-project"],
notes_markdown: "Quick spark—could use a Whack Pack card in the **Idea Collider** to find a unique twist."
});
}
seedIfEmpty();
return {
db,
listIdeas,
getIdea,
createIdea,
updateIdea,
deleteIdea,
logWhackOnIdea,
addMockupToIdea,
recordArenaMatch,
getSetting,
setSetting
};
}
+599
View File
@@ -0,0 +1,599 @@
import * as http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createDatabase } from "./db.js";
import {
WHACK_CARDS,
SUIT_META,
getCardByNumber,
drawCards,
drawFourRoleSpread
} from "./whack_cards.js";
import {
NANO_BANANA_MODELS,
SURFACE_PRESETS,
buildNanoBananaPrompt,
generateWithNanoBanana
} from "./nano_banana.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, "..");
const PUBLIC_DIR = path.join(ROOT_DIR, "public");
const BUNDLED_DATA_DIR = path.join(ROOT_DIR, "data");
const ACTIVE_DATA_DIR = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: BUNDLED_DATA_DIR;
const DEFAULT_DB_PATH = path.join(ACTIVE_DATA_DIR, "ideas.db");
const DEFAULT_MOCKUPS_DIR = path.join(ACTIVE_DATA_DIR, "mockups");
const MIME_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".ico": "image/x-icon"
};
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store"
});
res.end(body);
}
function readJsonBody(req, maxBytes = 25 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
req.on("data", (chunk) => {
total += chunk.length;
if (total > maxBytes) {
reject(new Error("Payload too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf-8");
resolve(raw ? JSON.parse(raw) : {});
} catch (err) {
reject(new Error("Invalid JSON body"));
}
});
req.on("error", reject);
});
}
function serveStaticFile(res, filePath) {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
sendJson(res, 404, { error: "File not found" });
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType });
fs.createReadStream(filePath).pipe(res);
}
function ensureSeedMockupsCopied(targetMockupsDir) {
fs.mkdirSync(targetMockupsDir, { recursive: true });
const bundledMockupsDir = path.join(BUNDLED_DATA_DIR, "mockups");
if (!fs.existsSync(bundledMockupsDir) || path.resolve(bundledMockupsDir) === path.resolve(targetMockupsDir)) {
return;
}
for (const file of fs.readdirSync(bundledMockupsDir)) {
const src = path.join(bundledMockupsDir, file);
const dest = path.join(targetMockupsDir, file);
if (!fs.existsSync(dest) && fs.statSync(src).isFile()) {
fs.copyFileSync(src, dest);
}
}
}
export function createAppServer({
dbPath = DEFAULT_DB_PATH,
mockupsDir = DEFAULT_MOCKUPS_DIR
} = {}) {
ensureSeedMockupsCopied(mockupsDir);
const store = createDatabase(dbPath);
const server = http.createServer(async (req, res) => {
try {
const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const pathname = decodeURIComponent(parsedUrl.pathname);
const method = req.method.toUpperCase();
// --- HEALTHCHECK ---
if (method === "GET" && pathname === "/api/health") {
sendJson(res, 200, {
status: "ok",
service: "sparkvault",
timestamp: new Date().toISOString()
});
return;
}
// --- API ROUTES ---
// 1. GET /api/ideas
if (method === "GET" && pathname === "/api/ideas") {
const status = parsedUrl.searchParams.get("status") || "all";
const category = parsedUrl.searchParams.get("category") || "all";
const tag = parsedUrl.searchParams.get("tag") || "all";
const search = parsedUrl.searchParams.get("search") || "";
const hasMockup = parsedUrl.searchParams.get("hasMockup") || "";
const sort = parsedUrl.searchParams.get("sort") || "score";
const ideas = store.listIdeas({ status, category, tag, search, hasMockup, sort });
const allIdeas = store.listIdeas({});
const tagCounts = {};
const statusCounts = { spark: 0, exploring: 0, building: 0, shipped: 0, archived: 0 };
let totalMockups = 0;
let totalWhacks = 0;
for (const item of allIdeas) {
if (statusCounts[item.status] !== undefined) statusCounts[item.status]++;
totalMockups += item.mockups.length;
totalWhacks += item.whack_history.length;
for (const t of item.tags) {
tagCounts[t] = (tagCounts[t] || 0) + 1;
}
}
sendJson(res, 200, {
ideas,
stats: {
total: allIdeas.length,
statusCounts,
totalMockups,
totalWhacks,
tags: Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => ({ name, count }))
}
});
return;
}
// 2. POST /api/ideas
if (method === "POST" && pathname === "/api/ideas") {
const body = await readJsonBody(req);
if (!body.title || !String(body.title).trim()) {
sendJson(res, 400, { error: "Idea title is required" });
return;
}
const created = store.createIdea(body);
sendJson(res, 201, { idea: created });
return;
}
// 3. GET / PATCH / DELETE /api/ideas/:id
const ideaIdMatch = pathname.match(/^\/api\/ideas\/(\d+)$/);
if (ideaIdMatch) {
const id = Number(ideaIdMatch[1]);
if (method === "GET") {
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { idea });
}
if (method === "PATCH") {
const body = await readJsonBody(req);
const updated = store.updateIdea(id, body);
if (!updated) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { idea: updated });
}
if (method === "DELETE") {
const deleted = store.deleteIdea(id);
if (!deleted) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { deleted: true, id });
}
}
// 4. GET /api/whack-cards
if (method === "GET" && pathname === "/api/whack-cards") {
const allIdeas = store.listIdeas({});
const usageByCard = {};
for (const idea of allIdeas) {
if (idea.inspired_by_card) {
usageByCard[idea.inspired_by_card] = (usageByCard[idea.inspired_by_card] || 0) + 1;
}
for (const w of idea.whack_history) {
usageByCard[w.cardNumber] = (usageByCard[w.cardNumber] || 0) + 1;
}
}
const cards = WHACK_CARDS.map((c) => ({
...c,
usageCount: usageByCard[c.number] || 0
}));
sendJson(res, 200, {
suits: SUIT_META,
cards
});
return;
}
// 5. GET /api/whack-cards/draw
if (method === "GET" && pathname === "/api/whack-cards/draw") {
const mode = parsedUrl.searchParams.get("mode") || "single";
const suit = parsedUrl.searchParams.get("suit") || "";
const status = parsedUrl.searchParams.get("status") || "";
const ideaId = parsedUrl.searchParams.get("ideaId");
if (mode === "spread") {
const spread = drawFourRoleSpread();
return sendJson(res, 200, { mode: "spread", cards: spread });
}
if (mode === "collider") {
const activeIdeas = store
.listIdeas({})
.filter((i) => i.status !== "archived" && i.status !== "shipped");
let targetIdea = null;
if (ideaId) {
targetIdea = store.getIdea(Number(ideaId));
}
if (!targetIdea && activeIdeas.length > 0) {
targetIdea = activeIdeas[Math.floor(Math.random() * activeIdeas.length)];
}
const drawn = drawCards({
suit: suit || undefined,
status: targetIdea?.status || undefined,
count: 1
})[0];
return sendJson(res, 200, {
mode: "collider",
card: drawn,
idea: targetIdea
});
}
const drawn = drawCards({
suit: suit || undefined,
status: status || undefined,
count: 1
})[0];
sendJson(res, 200, { mode: "single", card: drawn });
return;
}
// 6. POST /api/ideas/:id/whack
const whackMatch = pathname.match(/^\/api\/ideas\/(\d+)\/whack$/);
if (method === "POST" && whackMatch) {
const id = Number(whackMatch[1]);
const body = await readJsonBody(req);
if (!body.cardNumber) {
return sendJson(res, 400, { error: "cardNumber is required" });
}
const updated = store.logWhackOnIdea(id, {
cardNumber: Number(body.cardNumber),
reflection: body.reflection || "",
addChecklistItem: Boolean(body.addChecklistItem),
promoteStatus: body.promoteStatus || null
});
if (!updated) return sendJson(res, 404, { error: "Idea not found" });
return sendJson(res, 200, { idea: updated });
}
// 7. POST /api/ideas/:id/mockups/nano-banana
const nbMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/nano-banana$/);
if (method === "POST" && nbMatch) {
const id = Number(nbMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const body = await readJsonBody(req);
const template = body.template || "tshirt-front";
const garmentColor = body.garmentColor || "#F5E8D3";
const garmentColorName = body.garmentColorName || "Vintage Cream";
const placement = body.placement || (template === "tshirt-left-chest" ? "left-chest" : "center-chest");
const whackCard = body.whackCardNumber ? getCardByNumber(body.whackCardNumber) : null;
// Check if editing an existing mockup
let referenceImagePath = null;
let parentMockupId = body.parentMockupId || null;
if (parentMockupId) {
const parentMockup = idea.mockups.find((m) => m.id === parentMockupId);
if (parentMockup && parentMockup.imageUrl.startsWith("/mockups/")) {
const candidate = path.join(mockupsDir, path.basename(parentMockup.imageUrl));
if (fs.existsSync(candidate)) {
referenceImagePath = candidate;
}
}
}
const finalPrompt = buildNanoBananaPrompt({
rawPrompt: body.prompt,
ideaTitle: idea.title,
elevatorPitch: idea.elevator_pitch,
template,
garmentColorName,
garmentHex: garmentColor,
whackCard,
editInstruction: body.editInstruction
});
const storedKey = store.getSetting("gemini_api_key", "");
const selectedModel = body.model || store.getSetting("nano_banana_model", "gemini-nano-banana-2.1");
try {
const genResult = await generateWithNanoBanana({
apiKey: body.apiKey || storedKey,
model: selectedModel,
prompt: finalPrompt,
referenceImagePath,
referenceImageBase64: body.referenceImageBase64 || null,
outputDir: mockupsDir
});
const updated = store.addMockupToIdea(id, {
title: body.title || (body.editInstruction ? `Edited: ${body.editInstruction.slice(0, 40)}` : `${SURFACE_PRESETS[template]?.label || "Mockup"}`),
template,
garmentColor,
garmentColorName,
placement,
prompt: finalPrompt,
editInstruction: body.editInstruction || "",
parentMockupId,
modelUsed: genResult.modelUsed,
imageUrl: genResult.imageUrl
});
return sendJson(res, 201, {
idea: updated,
mockup: updated.mockups[0],
textNotes: genResult.textNotes
});
} catch (err) {
const status = err.code === "MISSING_API_KEY" ? 400 : 502;
return sendJson(res, status, {
error: err.message || "Nano Banana image generation failed",
code: err.code || "GENERATION_ERROR",
promptBuilt: finalPrompt
});
}
}
// 8. POST /api/ideas/:id/mockups/canvas
const canvasMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/canvas$/);
if (method === "POST" && canvasMatch) {
const id = Number(canvasMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const body = await readJsonBody(req);
if (!body.imageDataUrl || !String(body.imageDataUrl).startsWith("data:image/")) {
return sendJson(res, 400, { error: "Valid imageDataUrl (data:image/png;base64,...) is required" });
}
const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
const filename = `mockup-${id}-${Date.now()}.png`;
const filePath = path.join(mockupsDir, filename);
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
const updated = store.addMockupToIdea(id, {
title: body.title || `${idea.title} (${body.placement === "left-chest" ? "Left-Chest" : "Mockup"})`,
template: body.template || "tshirt-front",
garmentColor: body.garmentColor || "#F5E8D3",
garmentColorName: body.garmentColorName || "Vintage Cream",
placement: body.placement || "center-chest",
prompt: body.prompt || "",
topText: body.topText || "",
bottomText: body.bottomText || "",
modelUsed: body.modelUsed || "Interactive Mockup Compositor",
imageUrl: `/mockups/${filename}`
});
return sendJson(res, 201, {
idea: updated,
mockup: updated.mockups[0]
});
}
// 9. PATCH /api/ideas/:id/mockups/:mockupId/cover & DELETE /api/ideas/:id/mockups/:mockupId
const mockupItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/([^/]+)(?:\/(cover))?$/);
if (mockupItemMatch) {
const id = Number(mockupItemMatch[1]);
const mockupId = mockupItemMatch[2];
const isCoverAction = mockupItemMatch[3] === "cover";
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
if (method === "PATCH" && isCoverAction) {
const target = idea.mockups.find((m) => m.id === mockupId);
if (!target) return sendJson(res, 404, { error: "Mockup not found" });
const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl });
return sendJson(res, 200, { idea: updated });
}
if (method === "DELETE" && !isCoverAction) {
const nextMockups = idea.mockups.filter((m) => m.id !== mockupId);
const nextCover =
idea.cover_mockup_url && nextMockups.some((m) => m.imageUrl === idea.cover_mockup_url)
? idea.cover_mockup_url
: nextMockups[0]?.imageUrl || "";
const updated = store.updateIdea(id, {
mockups: nextMockups,
cover_mockup_url: nextCover
});
return sendJson(res, 200, { idea: updated });
}
}
// 10. GET /api/review/next & POST /api/review/arena
if (method === "GET" && pathname === "/api/review/next") {
const allIdeas = store.listIdeas({});
const activeIdeas = allIdeas.filter((i) => i.status !== "archived" && i.status !== "shipped");
const triageQueue = [...activeIdeas].sort((a, b) => {
if (!a.last_reviewed_at && b.last_reviewed_at) return -1;
if (a.last_reviewed_at && !b.last_reviewed_at) return 1;
if (a.last_reviewed_at && b.last_reviewed_at) {
return a.last_reviewed_at.localeCompare(b.last_reviewed_at);
}
return a.created_at.localeCompare(b.created_at);
});
let arenaPair = [];
if (activeIdeas.length >= 2) {
const shuffled = [...activeIdeas].sort(() => Math.random() - 0.5);
arenaPair = [shuffled[0], shuffled[1]];
}
return sendJson(res, 200, {
triageQueue,
arenaPair
});
}
if (method === "POST" && pathname === "/api/review/arena") {
const body = await readJsonBody(req);
if (!body.winnerId || !body.loserId) {
return sendJson(res, 400, { error: "winnerId and loserId are required" });
}
const result = store.recordArenaMatch({
winnerId: Number(body.winnerId),
loserId: Number(body.loserId)
});
if (!result) return sendJson(res, 404, { error: "Ideas not found" });
return sendJson(res, 200, result);
}
// 11. GET / PUT /api/settings
if (pathname === "/api/settings") {
if (method === "GET") {
const savedKey = store.getSetting("gemini_api_key", "");
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
const activeKey = savedKey || envKey;
const model = store.getSetting("nano_banana_model", "gemini-nano-banana-2.1");
return sendJson(res, 200, {
hasApiKey: Boolean(activeKey),
apiKeySource: savedKey ? "settings" : envKey ? "environment" : "none",
maskedKey: activeKey ? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}` : "",
nanoBananaModel: model,
availableModels: NANO_BANANA_MODELS,
surfacePresets: Object.entries(SURFACE_PRESETS).map(([id, val]) => ({
id,
label: val.label
}))
});
}
if (method === "PUT") {
const body = await readJsonBody(req);
if (body.geminiApiKey !== undefined) {
store.setSetting("gemini_api_key", String(body.geminiApiKey).trim());
}
if (body.nanoBananaModel !== undefined) {
store.setSetting("nano_banana_model", String(body.nanoBananaModel).trim());
}
const savedKey = store.getSetting("gemini_api_key", "");
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
const activeKey = savedKey || envKey;
return sendJson(res, 200, {
saved: true,
hasApiKey: Boolean(activeKey),
nanoBananaModel: store.getSetting("nano_banana_model", "gemini-nano-banana-2.1")
});
}
}
// 12. GET /api/export
if (method === "GET" && pathname === "/api/export") {
const format = parsedUrl.searchParams.get("format") || "json";
const ideas = store.listIdeas({});
if (format === "markdown") {
const mdLines = [
`# SparkVault Idea Export (${new Date().toISOString().slice(0, 10)})`,
""
];
for (const idea of ideas) {
mdLines.push(`## [${idea.status.toUpperCase()}] ${idea.title} (Score: ${idea.composite_score})`);
if (idea.elevator_pitch) mdLines.push(`> ${idea.elevator_pitch}`);
mdLines.push(`- **Category**: ${idea.category} | **Tags**: ${idea.tags.map((t) => `#${t}`).join(" ") || "none"}`);
mdLines.push(`- **Ratings**: Excitement ${idea.rating_excitement}/5, Impact ${idea.rating_impact}/5, Ease ${idea.rating_ease}/5 | **Arena Wins**: ${idea.arena_wins}`);
if (idea.notes_markdown) {
mdLines.push("", idea.notes_markdown);
}
if (idea.checklist.length > 0) {
mdLines.push("", "### Checklist");
for (const item of idea.checklist) {
mdLines.push(`- [${item.done ? "x" : " "}] ${item.text}`);
}
}
if (idea.whack_history.length > 0) {
mdLines.push("", "### Creative Whack Pack Reflections");
for (const w of idea.whack_history) {
mdLines.push(`- **Card #${w.cardNumber} (${w.cardTitle})**: ${w.reflection}`);
}
}
mdLines.push("", "---", "");
}
res.writeHead(200, {
"Content-Type": "text/markdown; charset=utf-8",
"Content-Disposition": 'attachment; filename="sparkvault-ideas.md"'
});
res.end(mdLines.join("\n"));
return;
}
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="sparkvault-ideas.json"'
});
res.end(JSON.stringify({ exportedAt: new Date().toISOString(), ideas }, null, 2));
return;
}
// --- STATIC ASSETS ---
if (pathname.startsWith("/mockups/")) {
const rel = pathname.slice("/mockups/".length);
const safePath = path.normalize(path.join(mockupsDir, rel));
if (!safePath.startsWith(mockupsDir)) {
return sendJson(res, 403, { error: "Forbidden" });
}
return serveStaticFile(res, safePath);
}
const relPath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
const staticPath = path.normalize(path.join(PUBLIC_DIR, relPath));
if (!staticPath.startsWith(PUBLIC_DIR)) {
return sendJson(res, 403, { error: "Forbidden" });
}
if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
return serveStaticFile(res, staticPath);
}
// SPA Fallback
return serveStaticFile(res, path.join(PUBLIC_DIR, "index.html"));
} catch (err) {
console.error("Server error:", err);
sendJson(res, 500, { error: err.message || "Internal server error" });
}
});
return { server, store };
}
// Start server if invoked directly
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
const port = Number(process.env.PORT || 3456);
const { server } = createAppServer();
server.listen(port, () => {
console.log(`✨ SparkVault Idea Tracker running at http://localhost:${port}`);
});
}
+174
View File
@@ -0,0 +1,174 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { GoogleGenAI } from "@google/genai";
export const NANO_BANANA_MODELS = [
{
id: "gemini-nano-banana-2.1",
label: "Nano Banana 2.1 (Default High-Efficiency)",
description: "gemini-nano-banana-2.1 — Fast, high-efficiency native image generation and conversational editing"
},
{
id: "gemini-3-pro-image",
label: "Nano Banana Pro (Studio Quality)",
description: "gemini-3-pro-image — High-detail studio mockup generation and multi-turn editing"
},
{
id: "gemini-3.1-flash-lite-image",
label: "Nano Banana 2 Lite (Ultra-Fast)",
description: "gemini-3.1-flash-lite-image — Rapid concept sketching and quick iterations"
}
];
export const SURFACE_PRESETS = {
"tshirt-front": {
label: "👕 T-Shirt (Full Front Print)",
buildPrompt: ({ prompt, garmentColorName, garmentHex }) =>
`Photorealistic studio flat-lay apparel mockup of a heavyweight ${garmentColorName || "vintage cream"} (${garmentHex || "#F5E8D3"}) crewneck t-shirt. Centered full-front screenprint graphic design: ${prompt}. Crisp fabric texture, authentic screenprint ink detail, clean studio lighting.`
},
"tshirt-left-chest": {
label: "👕 T-Shirt (5-inch Left-Chest Emblem)",
buildPrompt: ({ prompt, garmentColorName, garmentHex }) =>
`Photorealistic studio closeup & flat-lay apparel mockup of a heavyweight ${garmentColorName || "vintage cream"} (${garmentHex || "#F5E8D3"}) crewneck t-shirt featuring a compact 5-inch left-chest pocket-zone screenprinted emblem: ${prompt}. Clean vector-style spot-color ink on fabric, studio lighting.`
},
"isolated-graphic": {
label: "🎨 Isolated Screenprint Artwork (Vector Style)",
buildPrompt: ({ prompt, garmentColorName }) =>
`Isolated flat vector screenprint t-shirt graphic emblem on a clean solid ${garmentColorName || "cream"} background, no shirt folds, bold spot-color illustration and outlined vintage typography: ${prompt}`
},
"sticker": {
label: "🏷️ Die-Cut Vinyl Sticker / Patch",
buildPrompt: ({ prompt }) =>
`Photorealistic die-cut vinyl sticker with a crisp white contour border resting on a dark slate studio surface. Bold vector illustration and typography: ${prompt}`
},
"poster": {
label: "🖼️ Framed Studio Art Poster",
buildPrompt: ({ prompt }) =>
`Gallery-framed vertical screenprint poster mockup in a minimalist studio interior. Bold editorial illustration and typography: ${prompt}`
},
"app-ui": {
label: "💻 Product / App Interface Concept",
buildPrompt: ({ prompt }) =>
`High-craft modern product design and UI concept presentation mockup on a sleek dark desktop studio display: ${prompt}`
}
};
export function buildNanoBananaPrompt({
rawPrompt,
ideaTitle,
elevatorPitch,
template = "tshirt-front",
garmentColorName = "Vintage Cream",
garmentHex = "#F5E8D3",
whackCard = null,
editInstruction = null
}) {
if (editInstruction && editInstruction.trim()) {
let editText = editInstruction.trim();
if (whackCard) {
editText += ` Incorporate creative twist from Card #${whackCard.number} (${whackCard.title}): ${whackCard.promptQuestion}`;
}
return editText;
}
const baseConcept = (rawPrompt && rawPrompt.trim())
? rawPrompt.trim()
: `${ideaTitle}${elevatorPitch ? ` — ${elevatorPitch}` : ""}`;
const surface = SURFACE_PRESETS[template] || SURFACE_PRESETS["tshirt-front"];
let finalPrompt = surface.buildPrompt({
prompt: baseConcept,
garmentColorName,
garmentHex
});
if (whackCard) {
finalPrompt += ` Creative direction inspired by "${whackCard.title}": ${whackCard.promptQuestion}`;
}
return finalPrompt;
}
/**
* Generates or edits an image using Google Gemini Nano Banana (`gemini-nano-banana-2.1`)
* via the official `@google/genai` Interactions API (`ai.interactions.create`).
*/
export async function generateWithNanoBanana({
apiKey,
model = "gemini-nano-banana-2.1",
prompt,
referenceImagePath = null,
referenceImageBase64 = null,
referenceMimeType = "image/png",
outputDir
}) {
const resolvedKey = apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!resolvedKey) {
const err = new Error(
"No Gemini API key configured. Click '⚙️ Nano Banana API Key' in the Mockup Studio (or set GEMINI_API_KEY) to generate live images with Nano Banana 2.1, or use the Interactive T-Shirt Canvas Compositor."
);
err.code = "MISSING_API_KEY";
throw err;
}
const validModel = NANO_BANANA_MODELS.some((m) => m.id === model)
? model
: "gemini-nano-banana-2.1";
const ai = new GoogleGenAI({ apiKey: resolvedKey });
let inputPayload = prompt;
// Check if we are performing conversational image-to-image editing
let base64Data = referenceImageBase64;
let mimeType = referenceMimeType || "image/png";
if (!base64Data && referenceImagePath && fs.existsSync(referenceImagePath)) {
const bytes = fs.readFileSync(referenceImagePath);
base64Data = bytes.toString("base64");
if (referenceImagePath.endsWith(".jpg") || referenceImagePath.endsWith(".jpeg")) {
mimeType = "image/jpeg";
}
}
if (base64Data) {
// Strip data URL prefix if present
const cleanedBase64 = base64Data.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
inputPayload = [
{ type: "text", text: prompt },
{
type: "image",
mime_type: mimeType,
data: cleanedBase64
}
];
}
const interaction = await ai.interactions.create({
model: validModel,
input: inputPayload
});
const generatedImage = interaction.output_image;
if (!generatedImage || !generatedImage.data) {
throw new Error(
interaction.output_text ||
"Nano Banana completed the interaction without returning an image block. Try refining your visual prompt."
);
}
const ext = (generatedImage.mime_type || "").includes("jpeg") ? "jpg" : "png";
const filename = `nb-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
const fullPath = path.join(outputDir, filename);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(fullPath, Buffer.from(generatedImage.data, "base64"));
return {
filename,
imageUrl: `/mockups/${filename}`,
mimeType: generatedImage.mime_type || `image/${ext}`,
modelUsed: validModel,
interactionId: interaction.id || null,
textNotes: interaction.output_text || null
};
}
+158
View File
@@ -0,0 +1,158 @@
// Complete 64-Card Creative Whack Pack Catalog (Roger von Oech)
// Mapped to the 4 Creative Roles: Explorer (1-16), Artist (17-32), Judge (33-48), Warrior (49-64)
export const SUIT_META = {
explorer: {
id: "explorer",
label: "Explorer",
range: "1–16",
color: "#2b7ec1",
roleDescription: "For discovering new territory, gathering raw information, and looking in unexpected places.",
stageAffinity: ["spark", "exploring"]
},
artist: {
id: "artist",
label: "Artist",
range: "17–32",
color: "#d95f3b",
roleDescription: "For transforming raw material into new ideas through play, reversal, metaphor, and combination.",
stageAffinity: ["spark", "exploring"]
},
judge: {
id: "judge",
label: "Judge",
range: "33–48",
color: "#2a9158",
roleDescription: "For evaluating ideas critically, testing hidden assumptions, and deciding what is worth building.",
stageAffinity: ["exploring", "building"]
},
warrior: {
id: "warrior",
label: "Warrior",
range: "49–64",
color: "#b8335a",
roleDescription: "For overcoming excuses, slaying obstacles, and carrying your idea into shipped reality.",
stageAffinity: ["building", "shipped"]
}
};
const RAW_CARDS = [
// EXPLORER (1-16)
{ number: 1, file: "1.give_yourself_a_whack_on_the_side_of_the_head.jpg", title: "Give Yourself a Whack on the Side of the Head", promptQuestion: "How can you whack your thinking and break out of habitual routines?" },
{ number: 2, file: "2.ask_why.jpg", title: "Ask Why", promptQuestion: "Why does this problem exist, and what happens if you ask 'Why?' five times in a row?" },
{ number: 3, file: "3.get_out_of_your_box.jpg", title: "Get Out of Your Box", promptQuestion: "What other fields or disciplines outside your specialty can you draw on for this idea?" },
{ number: 4, file: "4.look_somewhere_else.jpg", title: "Look Somewhere Else", promptQuestion: "Where else could you look for inspiration that nobody in this space usually checks?" },
{ number: 5, file: "5.dig_deeper.jpg", title: "Dig Deeper", promptQuestion: "What overlooked details or root mechanics lie beneath the surface of this idea?" },
{ number: 6, file: "6.change_viewpoints.jpg", title: "Change Viewpoints", promptQuestion: "How does this idea look from the perspective of a beginner, a skeptic, or the end user?" },
{ number: 7, file: "7.see_the_big_picture.jpg", title: "See the Big Picture", promptQuestion: "If you zoom out 10x, what larger system or story is this idea really part of?" },
{ number: 8, file: "8.try_a_random_idea.jpg", title: "Try a Random Idea", promptQuestion: "Pick a random object or concept nearby—how can it force a fresh connection to your idea?" },
{ number: 9, file: "9.drop_an_assumption.jpg", title: "Drop an Assumption", promptQuestion: "What unnecessary boundary or assumption are you taking for granted—and what if you dropped it?" },
{ number: 10, file: "10.find_a_pattern.jpg", title: "Find a Pattern", promptQuestion: "What recurring theme, rhythm, or pattern connects this idea to other things that work?" },
{ number: 11, file: "11.see_hear_tast_feel_smell.jpg", title: "See, Hear, Taste, Feel, Smell", promptQuestion: "How can you engage more physical senses, textures, or tactile qualities in this concept?" },
{ number: 12, file: "12.listen_to_your_dreams.jpg", title: "Listen to Your Dreams", promptQuestion: "What subconscious hunch, daydream, or wild wish is trying to surface here?" },
{ number: 13, file: "13.look_to_nature.jpg", title: "Look to Nature", promptQuestion: "How has nature or biology already solved a problem similar to yours?" },
{ number: 14, file: "14.look_to_the_past.jpg", title: "Look to the Past", promptQuestion: "What vintage craft, historical design, or classic wisdom can you revive or remix here?" },
{ number: 15, file: "15.let_your_mind_wander.jpg", title: "Let Your Mind Wander", promptQuestion: "If you stop forcing a direct path and let your thoughts drift, what tangent excites you most?" },
{ number: 16, file: "16.see_the_obvious.jpg", title: "See the Obvious", promptQuestion: "What simple, right-in-front-of-your-nose truth are you overcomplicating?" },
// ARTIST (17-32)
{ number: 17, file: "17.think_like_a_kid.jpg", title: "Think Like a Kid", promptQuestion: "What would a six-year-old see, ask, or play with if they looked at your project?" },
{ number: 18, file: "18.ask_what_if.jpg", title: "Ask What If", promptQuestion: "What if the rules of gravity, cost, or convention didn't apply—what 'What if?' unlocks this?" },
{ number: 19, file: "19.challenge_the_rules.jpg", title: "Challenge the Rules", promptQuestion: "Which sacred rule in this category is ripe to be broken or inverted?" },
{ number: 20, file: "20.be_whacky.jpg", title: "Be Whacky", promptQuestion: "What is the most absurd, playful, or outrageous version of this idea?" },
{ number: 21, file: "21.simplify.jpg", title: "Simplify", promptQuestion: "What can you strip away so only the purest, most essential core remains?" },
{ number: 22, file: "22.exaggerate.jpg", title: "Exaggerate", promptQuestion: "What happens if you magnify one feature 100x or shrink it to miniature scale?" },
{ number: 23, file: "23.rearrange.jpg", title: "Rearrange", promptQuestion: "How can you swap the order, layout, or hierarchy of the components?" },
{ number: 24, file: "24.substitute.jpg", title: "Substitute", promptQuestion: "What material, medium, setting, or ingredient could you swap in for a surprising twist?" },
{ number: 25, file: "25.combine_ideas.jpg", title: "Combine Ideas", promptQuestion: "What two unrelated concepts or sparks can you fuse together into a hybrid?" },
{ number: 26, file: "26.make_a_metaphor.jpg", title: "Make a Metaphor", promptQuestion: "If this idea were a physical object, ritual, or creature, what metaphor captures it best?" },
{ number: 27, file: "27.reverse.jpg", title: "Reverse", promptQuestion: "What happens if you flip this idea inside-out, upside-down, or backwards?" },
{ number: 28, file: "28.imagine_how_others_woud_do_it.jpg", title: "Imagine How Others Would Do It", promptQuestion: "How would your favorite artist, designer, or historical figure tackle this idea?" },
{ number: 29, file: "29.imagine_youre_the_idea.jpg", title: "Imagine You're the Idea", promptQuestion: "If you were this idea personified, what would you want to become next?" },
{ number: 30, file: "30.change_its_name.jpg", title: "Change Its Name", promptQuestion: "What new name, title, or slogan would completely reframe how people experience this?" },
{ number: 31, file: "31.dont_force_it.jpg", title: "Don't Force It", promptQuestion: "Where are you pushing too hard, and how can you let the natural form of the idea emerge?" },
{ number: 32, file: "32.pause_for_a_bit.jpg", title: "Pause for a Bit", promptQuestion: "What part of this idea needs a brief incubation period so a better angle can click?" },
// JUDGE (33-48)
{ number: 33, file: "33.see_the_positive.jpg", title: "See the Positive", promptQuestion: "What is genuinely strong, exciting, and worth protecting about this idea right now?" },
{ number: 34, file: "34.find_out_whats_out_of_whack.jpg", title: "Find Out What's Out of Whack", promptQuestion: "What feels slightly off-balance, awkward, or friction-heavy in the current concept?" },
{ number: 35, file: "35.solve_the_right_problem.jpg", title: "Solve the Right Problem", promptQuestion: "Are you solving the real core problem, or just polishing a symptom?" },
{ number: 36, file: "36.conform.jpg", title: "Conform", promptQuestion: "Where should you actually follow familiar standards so users immediately understand it?" },
{ number: 37, file: "37.ask_a_fool.jpg", title: "Ask a Fool", promptQuestion: "What naive or blunt question would an outsider ask that exposes a blind spot?" },
{ number: 38, file: "38.focus_on_the_real_truth.jpg", title: "Focus on the Real Truth", promptQuestion: "Strip away wishful thinking—what do the real constraints and facts say?" },
{ number: 39, file: "39.slay_a_sacred_cow.jpg", title: "Slay a Sacred Cow", promptQuestion: "What darling feature or habit are you keeping out of nostalgia that should be cut?" },
{ number: 40, file: "40.loosen_up.jpg", title: "Loosen Up", promptQuestion: "Are you judging this idea too harshly before giving it room to breathe?" },
{ number: 41, file: "41.check_your_timing.jpg", title: "Check Your Timing", promptQuestion: "Why is right now (or later) the right moment to bring this idea to life?" },
{ number: 42, file: "42.beware_the_unintended.jpg", title: "Beware the Unintended", promptQuestion: "What second-order side effects or unintended consequences could this create?" },
{ number: 43, file: "43.get_out_of_the_dogma_house.jpg", title: "Get Out of the Dogma House", promptQuestion: "Where is rigid ideology getting in the way of a practical, creative solution?" },
{ number: 44, file: "44.be_dissatisfied.jpg", title: "Be Dissatisfied", promptQuestion: "What 'good enough' part of this idea deserves to be pushed from decent to remarkable?" },
{ number: 45, file: "45.dont_fall_in_love_with_ideas.jpg", title: "Don't Fall in Love with Ideas", promptQuestion: "If you had to pivot 50% of this concept today, what would you willingly let go of?" },
{ number: 46, file: "46.avoid_arrogance.jpg", title: "Avoid Arrogance", promptQuestion: "Where might your assumptions be wrong, and how can you test them cheaply?" },
{ number: 47, file: "47.listen_to_that_hunch.jpg", title: "Listen to That Hunch", promptQuestion: "What quiet gut instinct do you have about this idea that logic hasn't caught up to yet?" },
{ number: 48, file: "48.hear_the_knock_of_opportunity.jpg", title: "Hear the Knock of Opportunity", promptQuestion: "What unexpected side-benefit or spin-off opportunity is hiding inside this idea?" },
// WARRIOR (49-64)
{ number: 49, file: "49.take_a_whack_at_it.jpg", title: "Take a Whack at It", promptQuestion: "How can you take a concrete whack at your idea in the next 30 minutes?" },
{ number: 50, file: "50.believe_in_yourself.jpg", title: "Believe in Yourself", promptQuestion: "Why are you uniquely equipped to bring this specific idea into the world?" },
{ number: 51, file: "51.put_a_lion_in_your_heart.jpg", title: "Put a Lion in Your Heart", promptQuestion: "What bold move would you make on this project if you weren't worried about criticism?" },
{ number: 52, file: "52.fight_for_it.jpg", title: "Fight for It", promptQuestion: "What is the #1 obstacle blocking this idea, and how will you push through it?" },
{ number: 53, file: "53.flex_your_risk_muscle.jpg", title: "Flex Your Risk Muscle", promptQuestion: "What calculated risk or experiment can you run right now to move this forward?" },
{ number: 54, file: "54.get_support.jpg", title: "Get Support", promptQuestion: "Who could you show this mockup or prototype to for feedback or collaboration?" },
{ number: 55, file: "55.get_rid_of_excuses.jpg", title: "Get Rid of Excuses", promptQuestion: "What excuse has kept this idea sitting on the shelf—and how can you eliminate it today?" },
{ number: 56, file: "56.slay_a_dragon.jpg", title: "Slay a Dragon", promptQuestion: "What is the hardest, scariest task on this idea's checklist that you should tackle first?" },
{ number: 57, file: "57.have_something_at_stake.jpg", title: "Have Something at Stake", promptQuestion: "How can you put real skin in the game so you're committed to shipping this?" },
{ number: 58, file: "58.use_your_shield.jpg", title: "Use Your Shield", promptQuestion: "How can you protect this fledgling idea from premature naysayers while you build v1?" },
{ number: 59, file: "59.do_the_unexpected.jpg", title: "Do the Unexpected", promptQuestion: "How can you launch, present, or prototype this in a way that surprises everyone?" },
{ number: 60, file: "60.sell_sell_sell.jpg", title: "Sell, Sell, Sell", promptQuestion: "How can you pitch the core value of this idea in one irresistible sentence or visual?" },
{ number: 61, file: "61.set_a_deadline.jpg", title: "Set a Deadline", promptQuestion: "By what exact date and time will you ship a tangible prototype or mockup of this idea?" },
{ number: 62, file: "62.be_persistent.jpg", title: "Be Persistent", promptQuestion: "What small daily or weekly step will keep momentum alive until this crosses the finish line?" },
{ number: 63, file: "63.learn_from_mistakes.jpg", title: "Learn from Mistakes", promptQuestion: "What past stumble or failed attempt can you turn into fuel for this version?" },
{ number: 64, file: "64.give_yourself_a_pat_on_the_back.jpg", title: "Give Yourself a Pat on the Back", promptQuestion: "What progress have you already made on this idea that deserves recognition?" }
];
export const WHACK_CARDS = RAW_CARDS.map((card) => {
let suitKey = "explorer";
if (card.number >= 17 && card.number <= 32) suitKey = "artist";
else if (card.number >= 33 && card.number <= 48) suitKey = "judge";
else if (card.number >= 49) suitKey = "warrior";
const suit = SUIT_META[suitKey];
return {
...card,
suit: suit.id,
suitLabel: suit.label,
color: suit.color,
roleDescription: suit.roleDescription,
stageAffinity: suit.stageAffinity,
imageUrl: `/whack_pack/${card.file}`
};
});
export function getCardByNumber(num) {
return WHACK_CARDS.find((c) => c.number === Number(num)) || null;
}
export function drawCards({ suit, status, count = 1, excludeNumbers = [] } = {}) {
let pool = WHACK_CARDS.filter((c) => !excludeNumbers.includes(c.number));
if (suit && SUIT_META[suit]) {
pool = pool.filter((c) => c.suit === suit);
} else if (status) {
if (status === "spark") {
pool = pool.filter((c) => c.suit === "explorer" || c.suit === "artist");
} else if (status === "exploring") {
pool = pool.filter((c) => c.suit === "artist" || c.suit === "judge");
} else if (status === "building") {
pool = pool.filter((c) => c.suit === "judge" || c.suit === "warrior");
} else if (status === "shipped") {
pool = pool.filter((c) => c.suit === "warrior");
}
}
if (pool.length === 0) pool = [...WHACK_CARDS];
const shuffled = [...pool].sort(() => Math.random() - 0.5);
return shuffled.slice(0, Math.max(1, Math.min(count, shuffled.length)));
}
export function drawFourRoleSpread() {
return ["explorer", "artist", "judge", "warrior"].map((suit) => drawCards({ suit, count: 1 })[0]);
}
+184
View File
@@ -0,0 +1,184 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { fileURLToPath } from "node:url";
import { createAppServer } from "../server/index.js";
import { WHACK_CARDS } from "../server/whack_cards.js";
import { buildNanoBananaPrompt } from "../server/nano_banana.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PUBLIC_WHACK_DIR = path.resolve(__dirname, "..", "public", "whack_pack");
let server;
let baseUrl;
let tmpDir;
before(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sparkvault-test-"));
const dbPath = path.join(tmpDir, "test-ideas.db");
const mockupsDir = path.join(tmpDir, "mockups");
const app = createAppServer({ dbPath, mockupsDir });
server = app.server;
await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
baseUrl = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
});
after(async () => {
if (server) {
await new Promise((resolve) => server.close(resolve));
}
});
test("All 64 Creative Whack Pack cards are indexed and have local JPG scans in public/whack_pack", async () => {
assert.equal(WHACK_CARDS.length, 64, "Should have exactly 64 Creative Whack Pack cards");
const suitsCount = { explorer: 0, artist: 0, judge: 0, warrior: 0 };
for (const card of WHACK_CARDS) {
suitsCount[card.suit]++;
const diskPath = path.join(PUBLIC_WHACK_DIR, card.file);
assert.ok(fs.existsSync(diskPath), `Missing card scan on disk: ${card.file}`);
assert.ok(card.promptQuestion.length > 10, `Card #${card.number} should have a guiding promptQuestion`);
}
assert.deepEqual(suitsCount, {
explorer: 16,
artist: 16,
judge: 16,
warrior: 16
});
const res = await fetch(`${baseUrl}/api/whack-cards`);
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.cards.length, 64);
});
test("Quick Capture parses inline #tags, @category (@tshirt), and !high priority boost", async () => {
const res = await fetch(`${baseUrl}/api/ideas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Retro Red Rocks Amphitheatre Tee @tshirt #colorado #screenprint !high"
})
});
assert.equal(res.status, 201);
const { idea } = await res.json();
assert.equal(idea.title, "Retro Red Rocks Amphitheatre Tee");
assert.equal(idea.category, "creative");
assert.equal(idea.status, "spark");
assert.equal(idea.rating_excitement, 5);
assert.ok(idea.tags.includes("tshirt"));
assert.ok(idea.tags.includes("colorado"));
assert.ok(idea.tags.includes("screenprint"));
});
test("Creative Whack Pack Draw, 4-Role Spread, Idea Collider, and Logging a Whack reflection work", async () => {
// 1. 4-Role Spread
const spreadRes = await fetch(`${baseUrl}/api/whack-cards/draw?mode=spread`);
assert.equal(spreadRes.status, 200);
const spreadData = await spreadRes.json();
assert.equal(spreadData.cards.length, 4);
assert.deepEqual(
spreadData.cards.map((c) => c.suit),
["explorer", "artist", "judge", "warrior"]
);
// 2. Idea Collider
const colliderRes = await fetch(`${baseUrl}/api/whack-cards/draw?mode=collider&suit=artist`);
assert.equal(colliderRes.status, 200);
const colliderData = await colliderRes.json();
assert.equal(colliderData.card.suit, "artist");
assert.ok(colliderData.idea && colliderData.idea.id);
// 3. Log a Whack breakthrough onto the collided idea
const targetId = colliderData.idea.id;
const whackRes = await fetch(`${baseUrl}/api/ideas/${targetId}/whack`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cardNumber: 27,
reflection: "Reverse the color scheme: print cream ink onto a heavyweight forest green shirt.",
addChecklistItem: true
})
});
assert.equal(whackRes.status, 200);
const whackUpdated = await whackRes.json();
assert.equal(whackUpdated.idea.whack_history[0].cardNumber, 27);
assert.equal(whackUpdated.idea.whack_history[0].cardTitle, "Reverse");
assert.ok(
whackUpdated.idea.checklist.some((c) => c.text.includes("[Card #27 Reverse]")),
"Should append reflection to checklist when addChecklistItem is true"
);
});
test("Visual Mockup Studio saves canvas T-shirt mockups and builds Nano Banana 2.1 prompts", async () => {
// Verify Nano Banana prompt builder for Left-Chest T-Shirt + Whack Card
const prompt = buildNanoBananaPrompt({
rawPrompt: "Minimalist Alpine Ibex emblem in burnt orange and gold",
ideaTitle: "Ibex Mountain Tee",
template: "tshirt-left-chest",
garmentColorName: "Vintage Cream",
garmentHex: "#F5E8D3",
whackCard: WHACK_CARDS[21] // #22 Exaggerate
});
assert.ok(prompt.includes("5-inch left-chest"));
assert.ok(prompt.includes("Vintage Cream"));
assert.ok(prompt.includes("Exaggerate"));
// Save a 1x1 PNG data URL via /api/ideas/1/mockups/canvas
const samplePngDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const saveRes = await fetch(`${baseUrl}/api/ideas/1/mockups/canvas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Left-Chest Cream Proof",
template: "tshirt-left-chest",
garmentColor: "#F5E8D3",
garmentColorName: "Vintage Cream",
placement: "left-chest",
imageDataUrl: samplePngDataUrl
})
});
assert.equal(saveRes.status, 201);
const saveData = await saveRes.json();
assert.equal(saveData.mockup.placement, "left-chest");
assert.equal(saveData.idea.cover_mockup_url, saveData.mockup.imageUrl);
// Verify the saved mockup image is served over HTTP
const imgRes = await fetch(`${baseUrl}${saveData.mockup.imageUrl}`);
assert.equal(imgRes.status, 200);
assert.equal(imgRes.headers.get("content-type"), "image/png");
});
test("Head-to-Head Arena and Triage Review endpoints update arena_wins and composite_score", async () => {
const revRes = await fetch(`${baseUrl}/api/review/next`);
assert.equal(revRes.status, 200);
const revData = await revRes.json();
assert.ok(revData.triageQueue.length >= 2);
assert.equal(revData.arenaPair.length, 2);
const [first, second] = revData.arenaPair;
const beforeWins = first.arena_wins;
const arenaRes = await fetch(`${baseUrl}/api/review/arena`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ winnerId: first.id, loserId: second.id })
});
assert.equal(arenaRes.status, 200);
const arenaData = await arenaRes.json();
assert.equal(arenaData.winner.arena_wins, beforeWins + 1);
});