diff --git a/README.md b/README.md index 465c160..6db97f1 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,96 @@ # Placeholderer -Placeholder asset generator for game developers (v1). +Placeholder asset generator for game developers. -Browser-first PWA + CLI with shared TypeScript core. +Browser-first PWA + CLI with a shared TypeScript core. Import a JSON or CSV +manifest, preview assets, generate placeholders (images, sprite sheets, +tilesets, UI panels, audio tones), and download a ZIP — or author UI +placeholders in the built-in UI Builder. ## Prerequisites - Node.js 20+ -- pnpm (recommended package manager) +- [pnpm](https://pnpm.io/) 9+ -## Getting Started - -### 1. Install dependencies +## Getting started ```bash pnpm install ``` -### 2. Run the Web App (Development) +### Web app (development) ```bash -pnpm --filter web dev +pnpm dev +# → http://localhost:5173 ``` -Then open http://localhost:5173 +GitHub Pages deploys the production build from `main` under +`/Placeholderer/` (see `apps/web/vite.config.ts`). -### 3. Build the Web App +### CLI ```bash -pnpm --filter web build +pnpm --filter cli build +node apps/cli/dist/index.js --help ``` -### 4. Run the CLI +Commands: + +| Command | Purpose | +|---------|---------| +| `validate ` | Validate a manifest against the JSON Schema | +| `generate --in [--out ]` | Generate a ZIP of placeholders | +| `init-template ` | Write a starter manifest | +| `list-templates` | List engines and asset types | +| `explain-schema ` | Print schema (or a sub-schema) | + +### Build / test / lint ```bash -pnpm --filter cli build -node apps/cli/dist/index.js --help +pnpm build # all packages +pnpm test # core + CLI unit tests +pnpm lint # tsc --noEmit per package +pnpm e2e # Playwright (web) ``` -## Project Structure +## Project structure ``` placeholderer/ ├── apps/ -│ ├── web/ # Vite + React PWA +│ ├── web/ # Vite + React PWA (import, overview, builder, templates) │ └── cli/ # Commander CLI ├── packages/ -│ ├── schemas/ # JSON schemas + types -│ ├── core/ # Validation, sanitization, generation -│ └── templates/ # (future) Engine templates -├── package.json -└── pnpm-workspace.yaml +│ ├── schemas/ # JSON Schema + TypeScript types +│ ├── core/ # Validation, sanitization, rendering, ZIP, CSV, engines +│ └── templates/ # Engine guide re-exports (extensible starter pack) +├── tests/e2e/ # Playwright +└── package.json ``` -## Current Status +## What works today -- Monorepo with pnpm workspaces initialized -- Full manifest + builder recipe JSON schemas -- Core validation and path sanitization -- Working web app with: - - JSON paste import - - Manifest validation - - Job Overview with collapsible requests -- CLI skeleton +- **JSON import** — paste or upload/drop a file; explicit Import button; AJV validation +- **CSV import** — type selector first, quoted fields, full schema validation +- **Job Overview** — collapsible requests, preview strips, Item Detail safe adjustments +- **Generation** — PNG/JPG/WebP/BMP/GIF images, WAV audio, ZIP with manifest/error reports +- **Rendering options** — labels, numbering styles, grid toggle, UI panel frame styles & guides, panel metadata sidecars, fill_mode checker overlay +- **UI Builder** — layers, presets, undo/redo, recipe import/export, PNG/JPG/BMP/GIF/SVG +- **Engine templates** — Godot, Unity, RPG Maker, GameMaker, Unreal (+ v1.1 engines) +- **AI-driven mode** — preference in localStorage; prompt + JSON copy on Templates +- **Theming** — light/dark via `localStorage` -See `.hermes/desktop-attachments/placeholderer-v1-spec-draft.md` for the full product spec. +## Known limitations -## Next +- **Dual builder renderers:** the web UI Builder supports pattern/image fills and SVG export. Manifest `builder_recipe` assets generated via shared `generateJob` (web download or CLI) use a **solid-fill subset** in `@placeholderer/core`. Prefer exporting images from the Builder when fill fidelity matters. +- **`custom_fill_image`:** accepted by the schema; core draws a procedural checker when `fill_mode` is `repeat` or a fill image path is set. Loading arbitrary raster URLs into core is not implemented (env-specific). +- **Fonts:** system font stack only (offline-friendly); no bundled webfonts yet. +- **Interactive builder workflows** (drag layers, live edit) are web-only; CLI does not edit recipes. -- Full generation logic (Canvas-based rendering) -- UI Builder -- File upload support -- ZIP export +Full product/technical intent lives in +[`.hermes/desktop-attachments/placeholderer-v1-spec-draft.md`](.hermes/desktop-attachments/placeholderer-v1-spec-draft.md). ## License -MIT \ No newline at end of file +MIT diff --git a/apps/cli/package.json b/apps/cli/package.json index 60190c0..dce6e89 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "lint": "tsc --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index f9529e8..f5d8055 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -45,7 +45,7 @@ program program .command('init-template') .description('Write a starter manifest for the given engine and asset type.') - .argument('', `engine name (${'see'} 'list-templates')`) + .argument('', "engine name (see 'list-templates')") .argument('', "asset type: image | sprite_sheet | ui_panel | tileset | mixed") .option('--out ', 'output path (default: ./_.json)') .option('--json', 'emit machine-readable JSON output') diff --git a/apps/web/package.json b/apps/web/package.json index 4f28d9f..54508ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "lint": "tsc --noEmit", "preview": "vite preview" }, "dependencies": { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 432de50..d235f65 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,16 @@ -import { useState } from 'react'; -import { validateManifest, generateJob, encodeBmp, encodeGif, type CanvasBackend, type Canvas2D, type GenerationReport } from '@placeholderer/core'; -import type { Manifest, Asset, SafeAdjustment } from '@placeholderer/schemas'; +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + validateManifest, + generateJob, + encodeBmp, + encodeGif, + parseCsvToManifest, + type CanvasBackend, + type Canvas2D, + type GenerationReport, + type CsvAssetKind, +} from '@placeholderer/core'; +import type { Manifest, Asset, SafeAdjustment, NumberingStyle, LabelPosition } from '@placeholderer/schemas'; import { AssetPreview } from './AssetPreview'; import { UIBuilder } from './UIBuilder'; import { Templates } from './Templates'; @@ -38,6 +48,16 @@ const webCanvasBackend: CanvasBackend = { type View = 'home' | 'overview' | 'detail' | 'builder' | 'templates'; +const AI_MODE_KEY = 'placeholderer:ai-mode'; + +function readAiMode(): boolean { + try { + return window.localStorage.getItem(AI_MODE_KEY) === '1'; + } catch { + return false; + } +} + function App() { const [view, setView] = useState('home'); const [job, setJob] = useState(null); @@ -45,25 +65,41 @@ function App() { const [error, setError] = useState(null); const [expandedRequests, setExpandedRequests] = useState>(new Set()); const [isGenerating, setIsGenerating] = useState(false); + const [genProgress, setGenProgress] = useState<{ index: number; total: number; name: string } | null>(null); const [importMode, setImportMode] = useState<'json' | 'csv'>('json'); - const [lastReport, setLastReport] = useState(null); + const [jsonText, setJsonText] = useState(''); + const [lastReport, setLastReport] = useState<{ success: boolean; errors?: string[]; cancelled?: boolean } | null>(null); const [manifestReport, setManifestReport] = useState(null); + const [aiMode, setAiMode] = useState(readAiMode); + const [dragOver, setDragOver] = useState(false); const { theme, toggle: toggleTheme } = useTheme(); + const abortRef = useRef(null); + const fileInputRef = useRef(null); + + useEffect(() => { + try { + window.localStorage.setItem(AI_MODE_KEY, aiMode ? '1' : '0'); + } catch { + // ignore + } + }, [aiMode]); + + const acceptManifest = useCallback((parsed: Manifest) => { + setManifestReport(null); + setLastReport(null); + setJob(parsed); + setView('overview'); + setError(null); + setExpandedRequests(new Set([0])); + }, []); - const handlePaste = (text: string) => { + const handleJsonImport = (text: string) => { try { const parsed = JSON.parse(text); const result = validateManifest(parsed); if (result.valid) { - // Clear any report from the previous job so the user - // doesn't see stale folders/files alongside the new - // manifest's overview. - setManifestReport(null); - setJob(parsed as Manifest); - setView('overview'); - setError(null); - setExpandedRequests(new Set()); + acceptManifest(parsed as Manifest); } else { setError(JSON.stringify(result.errors, null, 2)); } @@ -72,10 +108,61 @@ function App() { } }; - const handleCSVImport = (data: any) => { - setManifestReport(null); - setJob(data); - setView('overview'); + const handleCSVImport = (data: Manifest) => { + setError(null); + acceptManifest(data); + }; + + const importFileText = async (file: File) => { + const text = await file.text(); + const name = file.name.toLowerCase(); + if (name.endsWith('.csv') || importMode === 'csv') { + setImportMode('csv'); + // Default kind for dropped CSV is image; CSVImport will re-validate + // when the user clicks Import, but auto-import drop with kind=image. + const parsed = parseCsvToManifest(text, 'image' as CsvAssetKind); + if (!parsed.ok) { + setError(parsed.error + ' — open CSV mode, pick the correct type, and paste the file contents.'); + setJsonText(''); + return; + } + const result = validateManifest(parsed.manifest); + if (!result.valid) { + setError( + 'CSV failed validation (kind may be wrong). Switch to CSV, choose the asset type, paste the file, and Import.\n' + + JSON.stringify(result.errors, null, 2) + ); + return; + } + acceptManifest(parsed.manifest); + return; + } + setImportMode('json'); + setJsonText(text); + handleJsonImport(text); + }; + + const onFileInput = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + await importFileText(file); + } catch (err: any) { + setError(err?.message ?? String(err)); + } + e.target.value = ''; + }; + + const onDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (!file) return; + try { + await importFileText(file); + } catch (err: any) { + setError(err?.message ?? String(err)); + } }; const toggleRequest = (index: number) => { @@ -116,12 +203,19 @@ function App() { const handleGenerate = async () => { if (!job) return; setIsGenerating(true); + setGenProgress(null); + setError(null); // Drop any report from a previous successful generation so the // user can't see stale folders/files when the new generation // fails or produces a different job. setManifestReport(null); + const controller = new AbortController(); + abortRef.current = controller; try { - const result = await generateJob(job, webCanvasBackend); + const result = await generateJob(job, webCanvasBackend, { + signal: controller.signal, + onProgress: (p) => setGenProgress(p), + }); setLastReport(result); if (result.success && result.zip) { @@ -148,6 +242,9 @@ function App() { } catch { // Manifest is best-effort. } + } else if (result.zip && result.cancelled) { + setManifestReport(null); + setError('Generation cancelled. Partial ZIP was not downloaded.'); } else { // Surface the new error but also wipe any stale report so // the user can't see the previous job's folders/files @@ -159,9 +256,15 @@ function App() { setError(e.message); } finally { setIsGenerating(false); + setGenProgress(null); + abortRef.current = null; } }; + const handleCancelGenerate = () => { + abortRef.current?.abort(); + }; + const isBuilderView = view === 'builder'; const contentMaxWidth = isBuilderView ? '100%' : '1200px'; const contentPadding = isBuilderView ? '1rem 2rem' : '2rem'; @@ -185,9 +288,11 @@ function App() { padding: '1rem 2rem', display: 'flex', alignItems: 'center', - justifyContent: 'space-between' + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: '0.75rem', }}> -
+

Placeholderer

@@ -197,22 +302,46 @@ function App() {
- +
+ + +
@@ -220,7 +349,7 @@ function App() { {view === 'home' && (
-
+
+ +
- {importMode === 'json' && ( - <> -

Import Manifest

-