From bb4c4e026229154c68f6e1d2c38b2262fd870824 Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:13:20 +0400 Subject: [PATCH 1/9] docs: design for Poster tab (Change D) --- ...6-09-07-bitshaper-web-poster-tab-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-bitshaper-web-poster-tab-design.md diff --git a/docs/superpowers/specs/2026-09-07-bitshaper-web-poster-tab-design.md b/docs/superpowers/specs/2026-09-07-bitshaper-web-poster-tab-design.md new file mode 100644 index 0000000..1b1a3b1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-bitshaper-web-poster-tab-design.md @@ -0,0 +1,215 @@ +# BitShaper web — Poster tab (Change D) + +**Date:** 2026-09-07 +**Status:** proposed + +Fourth slice of the web work. Adds the `Poster` tab that Change B deferred, so +the working-area tabs become **`Create` / `Trace an image` / `Poster`**. A +poster is a new composition layer — background, title text, and a grid of one +repeated BitShaper mark — that lives entirely in `web/src/`, never in +`src/core/`. + +Prereqs: Changes A, B (merged). Sibling: Change C (new primitives, `src/core/` + +`src/library/` only — no overlap). + +## Decisions (from brainstorming) + +- **Self-contained tab.** The Poster tab has its own shape, not the + always-visible preview shape. A *"Use preview shape"* button bridges the two. +- **One template to start: "Grid of marks."** Title block + an M×N grid of a + single picked shape, each cell given a rotation from a repeating cycle, with a + two-colour treatment. Additional templates (stripe field, hero mark) are a + later change. +- **Editable:** title / subtitle / caption text; text-block position (3 + presets); grid columns × rows; palette (pick one of a preset set); colour + pattern (checkerboard or per-row). +- **Poster state lives in the URL** — shareable/restorable like a shape ID. +- **Export:** 2:3 portrait, both SVG (vector) and PNG (2000×3000). + +## Modules (all new, under `web/src/`) + +### `poster-templates.ts` — pure, the renderer + +```ts +export interface PosterState { + readonly shapeId: string; + readonly title: string; + readonly subtitle: string; + readonly caption: string; + readonly textPos: "top-left" | "top-split" | "bottom-left"; + readonly cols: number; // 1..8 + readonly rows: number; // 1..12 + readonly palette: PaletteKey; + readonly pattern: "checker" | "rows"; + readonly seed: string; // drives the per-cell rotation cycle offset +} + +export const POSTER_VIEWBOX = { w: 1000, h: 1500 } as const; // 2:3 +export const PALETTES: Record; +export const DEFAULT_POSTER_STATE: PosterState; + +/** Builds the full poster as one standalone string. Never throws: + * an undecodable shapeId renders the poster with empty cells + a note. */ +export function renderPosterSvg(state: PosterState): string; +``` + +`PaletteKey` set (fg / bg / accent), 2-colour each (accent is the second ink): + +| key | look | +|-----------|-------------------------------| +| `mono` | near-black on warm off-white | +| `ink` | black on cream | +| `violet` | black + violet on white | +| `forest` | deep green on tan | +| `blueprint` | off-white on navy | + +**Layout inside `renderPosterSvg`:** + +- `` full-bleed background = `bg`. +- **Grid area:** a rectangle inset from the poster edges with a wider margin on + whichever side the text block occupies (top for `top-*`, bottom for + `bottom-left`). Cell size = `min(areaW / cols, areaH / rows)`; the grid is + centred in the area. +- **Per cell:** decode `shapeId` once; for each of the `cols * rows` cells emit + `renderShape(shapeId, { fill })` and splice its `` into a + ``. `renderShape` + output is parsed with `DOMParser`; the `` is taken (its coord space is + the shape's default `size` box → `scale = cell / size`). + - `r` = `[0, 90, 180, 270][(col + row + seedOffset) % 4]`. + - `fill` per `pattern`: `checker` → `accent` when `(col + row)` is odd, else + `fg`; `rows` → `accent` on odd rows, else `fg`. + - `seedOffset` = a small int hashed from `seed` (0 when `seed` is empty). +- **Text:** `` elements in a system sans stack, `fg`. + - `top-left`: title (large, bold) then subtitle (small) stacked at the top-left + inset; caption pinned to the bottom-left footer. + - `top-split`: title top-left, subtitle top-right (right-aligned), caption + bottom-left footer. + - `bottom-left`: title + subtitle stacked above the bottom-left footer; + caption directly under them. + - A thin `` rule under the title block (matches the samples). + - Text is emitted with XML-escaping; long titles are not auto-wrapped (kept + simple — the field is short by convention). + +No DOM, no `window`; safe to unit-test in isolation. + +### `poster-state.ts` — pure, URL read/write + +Mirrors `shape-state.ts`. Poster params are namespaced so they never collide +with `?id=`: + +| param | field | notes | +|-------|-------|-------| +| `pid` | `shapeId` | falls back to `DEFAULT_POSTER_STATE.shapeId` | +| `pt` | `title` | | +| `psub` | `subtitle` | | +| `pcap` | `caption` | | +| `ppos` | `textPos` | one of the 3 keys, else default | +| `pc` | `cols` | int, clamped 1..8 | +| `pr` | `rows` | int, clamped 1..12 | +| `ppal` | `palette` | valid `PaletteKey`, else `mono` | +| `ppat` | `pattern` | `checker` \| `rows` | +| `pseed` | `seed` | | + +```ts +export function readPosterFromUrl(): PosterState; // always returns a full state +export function updatePosterUrl(state: PosterState, opts?: { push?: boolean }): void; +``` + +`updatePosterUrl` writes only params that differ from `DEFAULT_POSTER_STATE` +(keeps the URL short) and leaves `?id=` and the `#poster` hash untouched. + +### `poster.ts` — the tab UI (vanilla DOM, matches the other `web/src/*` modules) + +```ts +export interface PosterTabOptions { + /** Current always-visible preview shape ID, or null. Backs "Use preview shape". */ + readonly getCurrentShapeId: () => string | null; +} +export interface PosterTabHandle { readonly element: HTMLElement; } +export function buildPosterTab(container: HTMLElement, opts: PosterTabOptions): PosterTabHandle; +``` + +DOM, top to bottom: + +- **`.poster-shape-row`** — shape-ID `
'; + window.history.replaceState({}, "", "/#poster"); + initApp(); + expect(document.querySelector(".composition-panel")).not.toBeNull(); + expect(document.querySelector(".poster-section")?.hidden).toBe(false); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npx vitest run test/main-layout.test.ts` +Expected: FAIL — no `poster` tab / `.poster-section`. + +- [ ] **Step 3: Edit `web/src/main.ts`** + +1. Add the import near the other tab-module imports: + +```ts +import { buildPosterTab } from "./poster.js"; +``` + +2. Extend `TABS`: + +```ts +const TABS = [ + { id: "create", label: "Create" }, + { id: "trace", label: "Trace an image" }, + { id: "poster", label: "Poster" }, +] as const; +``` + +3. In `buildLayout`, after the `catalogSection` block and before `const panels`, + add: + +```ts + const posterSection = document.createElement("section"); + posterSection.className = "poster-section tab-panel"; + const posterHint = document.createElement("p"); + posterHint.className = "section-hint"; + posterHint.textContent = + "Compose one shape into a modernist poster — pick a shape, set the title and grid, then export."; + posterSection.appendChild(posterHint); + const posterSectionBody = document.createElement("div"); + posterSection.appendChild(posterSectionBody); +``` + +4. Extend `panels`: + +```ts + const panels: Record = { + create: catalogSection, + trace: traceSection, + poster: posterSection, + }; +``` + +5. Add `posterSection: posterSectionBody` to the return object, and + `readonly posterSection: HTMLElement;` to `buildLayout`'s return type. + +6. In `initApp`, destructure `posterSection` from `buildLayout(root)` and, after + the `buildTraceSection(...)` call, add: + +```ts + buildPosterTab(posterSection, { getCurrentShapeId: () => currentShapeId }); +``` + +7. In `showShape`, after `setActionsEnabled();`, dispatch the event the Poster + tab listens for so its "Use preview shape" button enables as soon as a shape + exists: + +```ts + document.dispatchEvent(new CustomEvent("bitshaper:shape-changed")); +``` + +- [ ] **Step 4: Run the full web suite + typecheck** + +Run: `cd web && npx vitest run && npx tsc --noEmit` +Expected: all green, no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/main.ts web/test/main-layout.test.ts +git commit -m "feat(web): wire Poster as the third working-area tab" +``` + +--- + +### Task 6: Styles + full verification + +**Files:** +- Modify: `web/src/style.css` +- Test: none new (visual); run every gate. + +- [ ] **Step 1: Append the Poster styles to `web/src/style.css`** + +```css +/* --- Poster tab --- */ +.poster-tab { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.poster-shape-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.poster-shape-input { + flex: 1 1 16rem; + min-width: 0; + padding: 0.4rem 0.5rem; + background: var(--bs-surface); + border: 1px solid var(--bs-border); + border-radius: 4px; + color: var(--bs-text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.poster-shape-input.is-invalid { + border-color: var(--bs-error); +} + +.poster-shape-note { + flex-basis: 100%; + color: var(--bs-error); + font-size: 0.85rem; + min-height: 1rem; +} + +.poster-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 0.75rem; +} + +.poster-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: var(--bs-text-muted); +} + +.poster-field input, +.poster-field select { + padding: 0.4rem 0.5rem; + background: var(--bs-surface); + border: 1px solid var(--bs-border); + border-radius: 4px; + color: var(--bs-text); +} + +.poster-preview { + display: flex; + justify-content: center; + padding: 1rem; + border: 1px solid var(--bs-border); + border-radius: 6px; + background: + repeating-conic-gradient(#20242c 0% 25%, #171a20 0% 50%) 50% / 24px 24px; +} + +.poster-preview svg { + width: auto; + max-width: 100%; + max-height: 70vh; + height: auto; + box-shadow: 0 4px 24px rgb(0 0 0 / 40%); +} + +.poster-export { + display: flex; + gap: 0.5rem; +} +``` + +- [ ] **Step 2: Build the web app** + +Run: `cd web && npm run build` +Expected: build completes, no errors. + +- [ ] **Step 3: Full verification (all gates)** + +Run each; every one must pass: + +```bash +cd web && npx vitest run # all web tests green +cd web && npx tsc --noEmit # no type errors +cd web && npm run build # ok +cd .. && npm test # root suite green +npx biome check . # clean (run from repo root) +``` + +If `biome check` reports formatting, run `npx biome check --write .` and +re-review the diff before committing. + +- [ ] **Step 4: Commit** + +```bash +git add web/src/style.css +git commit -m "feat(web): Poster tab styles" +``` + +- [ ] **Step 5: Manual smoke (optional but recommended)** + +`cd web && npm run dev`, open the app, click the **Poster** tab: a poster +renders; editing Title updates it live; Columns/Rows/Palette/Pattern/Position +all change the poster; **Use preview shape** is enabled after a Randomize in the +Composition panel; **Export SVG** / **Export PNG** download files named +`poster-.svg` / `.png`; reloading the page keeps the poster (URL params). + +--- + +### Task 7: Open the PR + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin web-poster-tab +``` + +- [ ] **Step 2: Open the PR (do NOT merge)** + +```bash +gh pr create --title "Change D: Poster tab" --body "$(cat <<'EOF' +## Summary +- Adds a third working-area tab, **Poster**, composing one BitShaper shape into a modernist "grid of marks" poster. +- Editable: shape (ID field / use-preview / randomize), title / subtitle / caption, text position (3 presets), columns × rows, palette (5 presets), colour pattern (checkerboard / per row), variation seed. +- Poster state is namespaced into the URL (`p*` params) — shareable and restored on load. +- Export: 2:3 portrait SVG + PNG (2000×3000). `exportPng` gained optional `width`/`height`. + +## New modules (web/ only) +- `web/src/poster-templates.ts` — pure `renderPosterSvg(state)` + palettes + defaults +- `web/src/poster-state.ts` — pure URL read/write +- `web/src/poster.ts` — vanilla-DOM tab UI + +## Not touched +- `src/core/`, `src/library/` (Change C owns those), Composition/Morph/Trace/catalog behaviour. + +## Verification +- `cd web && npx vitest run` — green +- `npm test` (root) — green +- `npx biome check .` — clean +- `cd web && npm run build` — ok + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Third tab `Create / Trace an image / Poster` → Task 5. ✓ +- Self-contained shape + "Use preview shape" bridge → Task 4 (`getCurrentShapeId`, `usePreviewBtn`), Task 5 step 3.7 (event). ✓ +- One "grid of marks" template, per-cell rotation cycle, checker/rows 2-colour → Task 2. ✓ +- Editable text / position (3 presets) / count / palette → Task 4 fields. ✓ +- URL state → Task 3. ✓ +- 2:3, SVG + PNG 2000×3000 → Task 1 (PNG sizing), Task 2 (`POSTER_VIEWBOX`), Task 4 (export wiring). ✓ +- Styles in `style.css` with `--bs-*` → Task 6. ✓ +- Tests mirroring `web/src/`, `#poster` hash + keep `#create`/`#trace` → Tasks 2–5. ✓ +- No deps, vanilla DOM, `.js` imports → all tasks follow. ✓ + +**Placeholder scan:** No "TBD"/"handle edge cases"/bare "write tests". The Task 4 +note about `syncUsePreview` is resolved concretely in Task 5 step 3.7 (dispatch +`bitshaper:shape-changed`). + +**Type consistency:** `PosterState` shape identical across Tasks 2/3/4; +`renderPosterSvg`, `readPosterFromUrl`, `updatePosterUrl`, `buildPosterTab` +signatures match between their defining task and their callers; +`exportPng` option object matches Task 1 definition and Task 4 call site; +`buildLayout` return gains `posterSection` (Task 5) consumed only in `initApp`. From d79fbbbd1e09135212104d034709aa101818682d Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:20:35 +0400 Subject: [PATCH 3/9] feat(web): non-square canvas support in exportPng --- web/src/export-png.ts | 22 ++++++++----- web/test/export-png.test.ts | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 web/test/export-png.test.ts diff --git a/web/src/export-png.ts b/web/src/export-png.ts index 49c096f..ee300f6 100644 --- a/web/src/export-png.ts +++ b/web/src/export-png.ts @@ -3,13 +3,17 @@ import { triggerDownload } from "./download.js"; /** Default filename used for a downloaded PNG export. */ const DEFAULT_FILENAME = "bitshaper-mark.png"; -/** Default rasterized PNG width/height, in pixels, when `opts.size` is omitted. */ +/** Default rasterized PNG width/height, in pixels, when no size is given. */ const DEFAULT_SIZE = 512; /** Options accepted by {@link exportPng}. */ export interface ExportPngOptions { - /** Rasterized PNG width/height, in pixels. Defaults to 512. */ + /** Square width/height in pixels. Defaults to 512. Ignored when `width`/`height` are set. */ readonly size?: number; + /** Explicit canvas width in pixels. Overrides `size`. */ + readonly width?: number; + /** Explicit canvas height in pixels. Overrides `size`. */ + readonly height?: number; /** Filename used for the downloaded file. Defaults to `bitshaper-mark.png`. */ readonly filename?: string; } @@ -20,14 +24,16 @@ export interface ExportPngOptions { * is involved, matching the app's static-first requirement. */ export async function exportPng(svgMarkup: string, opts?: ExportPngOptions): Promise { - const size = opts?.size ?? DEFAULT_SIZE; + const square = opts?.size ?? DEFAULT_SIZE; + const width = opts?.width ?? square; + const height = opts?.height ?? square; const filename = opts?.filename ?? DEFAULT_FILENAME; - const pngBlob = await rasterizeSvgToPng(svgMarkup, size); + const pngBlob = await rasterizeSvgToPng(svgMarkup, width, height); triggerDownload(pngBlob, filename); } /** Draws `svgMarkup` into an offscreen canvas and reads it back as a PNG blob. */ -function rasterizeSvgToPng(svgMarkup: string, size: number): Promise { +function rasterizeSvgToPng(svgMarkup: string, width: number, height: number): Promise { return new Promise((resolve, reject) => { const svgUrl = URL.createObjectURL(new Blob([svgMarkup], { type: "image/svg+xml" })); const image = new Image(); @@ -36,15 +42,15 @@ function rasterizeSvgToPng(svgMarkup: string, size: number): Promise { URL.revokeObjectURL(svgUrl); const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; + canvas.width = width; + canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) { reject(new Error("Canvas 2D context is not available in this browser.")); return; } - ctx.drawImage(image, 0, 0, size, size); + ctx.drawImage(image, 0, 0, width, height); canvas.toBlob((pngBlob) => { if (!pngBlob) { reject(new Error("Failed to rasterize the preview to PNG.")); diff --git a/web/test/export-png.test.ts b/web/test/export-png.test.ts new file mode 100644 index 0000000..4af496a --- /dev/null +++ b/web/test/export-png.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { triggerDownload } from "../src/download.js"; +import { exportPng } from "../src/export-png.js"; + +vi.mock("../src/download.js", () => ({ triggerDownload: vi.fn() })); + +describe("exportPng canvas sizing", () => { + let created: HTMLCanvasElement[]; + + beforeEach(() => { + created = []; + vi.mocked(triggerDownload).mockClear(); + const realCreate = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = realCreate(tag) as HTMLElement; + if (tag === "canvas") { + const canvas = el as HTMLCanvasElement; + canvas.getContext = () => ({ drawImage: () => {} }) as unknown as CanvasRenderingContext2D; + canvas.toBlob = (cb: BlobCallback) => cb(new Blob(["png"], { type: "image/png" })); + created.push(canvas); + } + return el; + }); + // Make the offscreen Image resolve synchronously. + Object.defineProperty(globalThis, "Image", { + configurable: true, + value: class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_v: string) { + queueMicrotask(() => this.onload?.()); + } + }, + }); + vi.stubGlobal( + "URL", + Object.assign(Object.create(URL), { + createObjectURL: vi.fn(() => "blob:mock"), + revokeObjectURL: vi.fn(), + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("sizes the canvas to width/height when given", async () => { + await exportPng("", { width: 2000, height: 3000, filename: "poster.png" }); + expect(created[0].width).toBe(2000); + expect(created[0].height).toBe(3000); + expect(triggerDownload).toHaveBeenCalledWith(expect.any(Blob), "poster.png"); + }); + + it("still supports the square size shorthand", async () => { + await exportPng("", { size: 512 }); + expect(created[0].width).toBe(512); + expect(created[0].height).toBe(512); + }); +}); From 039202fd7fb0d640e90143fd459590727ce03d95 Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:21:26 +0400 Subject: [PATCH 4/9] feat(web): poster SVG renderer (grid-of-marks template) --- web/src/poster-templates.ts | 175 ++++++++++++++++++++++++++++++ web/test/poster-templates.test.ts | 50 +++++++++ 2 files changed, 225 insertions(+) create mode 100644 web/src/poster-templates.ts create mode 100644 web/test/poster-templates.test.ts diff --git a/web/src/poster-templates.ts b/web/src/poster-templates.ts new file mode 100644 index 0000000..b318284 --- /dev/null +++ b/web/src/poster-templates.ts @@ -0,0 +1,175 @@ +import { decodeShapeId, renderShape } from "bitshaper"; +import { listCatalog } from "bitshaper/library"; + +export type PaletteKey = "mono" | "ink" | "violet" | "forest" | "blueprint"; +export type TextPos = "top-left" | "top-split" | "bottom-left"; +export type Pattern = "checker" | "rows"; + +export interface PosterState { + readonly shapeId: string; + readonly title: string; + readonly subtitle: string; + readonly caption: string; + readonly textPos: TextPos; + readonly cols: number; + readonly rows: number; + readonly palette: PaletteKey; + readonly pattern: Pattern; + /** Drives the per-cell rotation-cycle offset; "" = no offset. */ + readonly seed: string; +} + +export const POSTER_VIEWBOX = { w: 1000, h: 1500 } as const; + +export const PALETTES: Record< + PaletteKey, + { readonly fg: string; readonly bg: string; readonly accent: string } +> = { + mono: { bg: "#f2efe9", fg: "#141414", accent: "#8a8a8a" }, + ink: { bg: "#efe7d6", fg: "#0c0c0c", accent: "#3a3a3a" }, + violet: { bg: "#ffffff", fg: "#111111", accent: "#7c5cff" }, + forest: { bg: "#d8cbb0", fg: "#1f3d2b", accent: "#3f6b4f" }, + blueprint: { bg: "#12233b", fg: "#eef2f7", accent: "#8fb3d9" }, +}; + +/** First curated catalog shape — always valid + renderable. */ +const DEFAULT_SHAPE_ID = listCatalog()[0]?.id ?? "BS-2X2-8888W"; + +export const DEFAULT_POSTER_STATE: PosterState = { + shapeId: DEFAULT_SHAPE_ID, + title: "GRAPHIQUE", + subtitle: "modern series 01", + caption: "BitShaper poster study", + textPos: "top-left", + cols: 4, + rows: 5, + palette: "mono", + pattern: "checker", + seed: "", +}; + +const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; +const MARGIN = 70; +const TEXT_BAND = 210; +const SHAPE_BOX = 256; // renderShape default coord box + +const XML: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; +function esc(s: string): string { + return s.replace(/[&<>"']/g, (c) => XML[c] as string); +} +function fmt(n: number): string { + return String(Math.round(n * 1000) / 1000); +} +function clampInt(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.round(n))); +} +function hashSeed(seed: string): number { + let h = 0; + for (let i = 0; i < seed.length; i += 1) h = (h * 31 + seed.charCodeAt(i)) | 0; + return Math.abs(h); +} +/** Pulls the `d` of the first in renderShape() output ("" if none). */ +function extractPathD(markup: string): string { + const m = markup.match(/]*\sd="([^"]*)"/); + return m ? (m[1] as string) : ""; +} + +const ROTATIONS = [0, 90, 180, 270]; + +export function renderPosterSvg(state: PosterState): string { + const { w, h } = POSTER_VIEWBOX; + const pal = PALETTES[state.palette] ?? PALETTES.mono; + const cols = clampInt(state.cols, 1, 8); + const rows = clampInt(state.rows, 1, 12); + const hasCaption = state.caption.trim().length > 0; + const textAtTop = state.textPos !== "bottom-left"; + + // Vertical bands: text occupies TEXT_BAND on its side; a caption footer only + // exists for the top-* layouts (bottom-left stacks the caption in-band). + const footerH = textAtTop && hasCaption ? 80 : 0; + const gridTop = textAtTop ? MARGIN + TEXT_BAND : MARGIN; + const gridBottom = textAtTop ? h - MARGIN - footerH : h - MARGIN - TEXT_BAND; + + const areaW = w - MARGIN * 2; + const areaH = Math.max(1, gridBottom - gridTop); + const cell = Math.min(areaW / cols, areaH / rows); + const gridX = MARGIN + (areaW - cell * cols) / 2; + const gridY = gridTop + (areaH - cell * rows) / 2; + + let cellD = ""; + try { + decodeShapeId(state.shapeId); + cellD = extractPathD(renderShape(state.shapeId, { size: SHAPE_BOX })); + } catch { + cellD = ""; + } + + const seedOffset = state.seed ? hashSeed(state.seed) % 4 : 0; + const scale = cell / SHAPE_BOX; + const parts: string[] = [ + ``, + ``, + ]; + + for (let r = 0; r < rows; r += 1) { + for (let c = 0; c < cols; c += 1) { + const accent = state.pattern === "checker" ? (r + c) % 2 === 1 : r % 2 === 1; + const fill = accent ? pal.accent : pal.fg; + const x = gridX + c * cell; + const y = gridY + r * cell; + const deg = ROTATIONS[(c + r + seedOffset) % 4]; + const inner = cellD + ? `` + : ``; + parts.push( + `${inner}`, + ); + } + } + + const text = ( + x: number, + y: number, + size: number, + weight: number, + content: string, + anchor: "start" | "end", + ): string => + `${esc(content)}`; + + const rule = (y: number): string => + ``; + + if (state.textPos === "top-left") { + parts.push(text(MARGIN, MARGIN + 66, 66, 700, state.title, "start")); + if (state.subtitle) parts.push(text(MARGIN, MARGIN + 104, 22, 400, state.subtitle, "start")); + parts.push(rule(MARGIN + TEXT_BAND - 34)); + if (hasCaption) parts.push(text(MARGIN, h - MARGIN, 20, 400, state.caption, "start")); + } else if (state.textPos === "top-split") { + parts.push(text(MARGIN, MARGIN + 66, 66, 700, state.title, "start")); + if (state.subtitle) parts.push(text(w - MARGIN, MARGIN + 60, 26, 700, state.subtitle, "end")); + parts.push(rule(MARGIN + TEXT_BAND - 34)); + if (hasCaption) parts.push(text(MARGIN, h - MARGIN, 20, 400, state.caption, "start")); + } else { + const bandTop = h - MARGIN - TEXT_BAND; + parts.push(rule(bandTop)); + parts.push(text(MARGIN, bandTop + 62, 66, 700, state.title, "start")); + if (state.subtitle) parts.push(text(MARGIN, bandTop + 100, 22, 400, state.subtitle, "start")); + if (hasCaption) parts.push(text(MARGIN, bandTop + 146, 20, 400, state.caption, "start")); + } + + if (!cellD) { + parts.push( + `Enter a valid shape ID`, + ); + } + + parts.push(""); + return parts.join(""); +} diff --git a/web/test/poster-templates.test.ts b/web/test/poster-templates.test.ts new file mode 100644 index 0000000..00b3e2d --- /dev/null +++ b/web/test/poster-templates.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_POSTER_STATE, + PALETTES, + type PosterState, + renderPosterSvg, +} from "../src/poster-templates.js"; + +const base: PosterState = { ...DEFAULT_POSTER_STATE, title: "HELLO", cols: 3, rows: 4 }; + +describe("renderPosterSvg", () => { + it("returns one 2:3 svg with the escaped title", () => { + const svg = renderPosterSvg({ ...base, title: "A & B " }); + expect(svg.startsWith(" group per grid cell", () => { + const svg = renderPosterSvg(base); + expect((svg.match(/ { + const svg = renderPosterSvg({ ...base, palette: "violet" }); + expect(svg).toContain(`fill="${PALETTES.violet.bg}"`); + }); + + it("checker and rows patterns place the accent colour differently", () => { + const checker = renderPosterSvg({ ...base, pattern: "checker", palette: "violet" }); + const rows = renderPosterSvg({ ...base, pattern: "rows", palette: "violet" }); + expect(checker).not.toEqual(rows); + expect(checker).toContain(`fill="${PALETTES.violet.accent}"`); + }); + + it("does not throw on an undecodable shape id and shows a note", () => { + const svg = renderPosterSvg({ ...base, shapeId: "not-a-real-id" }); + expect(svg.startsWith(" { + const withCap = renderPosterSvg({ ...base, textPos: "top-left", caption: " finis " }); + const noCap = renderPosterSvg({ ...base, textPos: "top-left", caption: "" }); + expect(withCap).toContain("finis"); + expect((noCap.match(/ Date: Mon, 7 Sep 2026 00:21:56 +0400 Subject: [PATCH 5/9] feat(web): poster state URL read/write --- web/src/poster-state.ts | 72 +++++++++++++++++++++++++++++++++++ web/test/poster-state.test.ts | 54 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 web/src/poster-state.ts create mode 100644 web/test/poster-state.test.ts diff --git a/web/src/poster-state.ts b/web/src/poster-state.ts new file mode 100644 index 0000000..8fdf129 --- /dev/null +++ b/web/src/poster-state.ts @@ -0,0 +1,72 @@ +import { + DEFAULT_POSTER_STATE, + PALETTES, + type PaletteKey, + type Pattern, + type PosterState, + type TextPos, +} from "./poster-templates.js"; + +/** URL param names, namespaced with `p` so they never collide with `?id=`. */ +const P = { + id: "pid", + title: "pt", + sub: "psub", + cap: "pcap", + pos: "ppos", + cols: "pc", + rows: "pr", + pal: "ppal", + pat: "ppat", + seed: "pseed", +} as const; + +const TEXT_POS: readonly TextPos[] = ["top-left", "top-split", "bottom-left"]; +const PATTERNS: readonly Pattern[] = ["checker", "rows"]; + +function clampInt(raw: string | null, min: number, max: number, fallback: number): number { + const n = Math.round(Number(raw)); + return raw !== null && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback; +} + +export function readPosterFromUrl(): PosterState { + const q = new URLSearchParams(window.location.search); + const d = DEFAULT_POSTER_STATE; + const pos = q.get(P.pos); + const pal = q.get(P.pal); + const pat = q.get(P.pat); + return { + shapeId: q.get(P.id) || d.shapeId, + title: q.get(P.title) ?? d.title, + subtitle: q.get(P.sub) ?? d.subtitle, + caption: q.get(P.cap) ?? d.caption, + textPos: pos && TEXT_POS.includes(pos as TextPos) ? (pos as TextPos) : d.textPos, + cols: clampInt(q.get(P.cols), 1, 8, d.cols), + rows: clampInt(q.get(P.rows), 1, 12, d.rows), + palette: pal && pal in PALETTES ? (pal as PaletteKey) : d.palette, + pattern: pat && PATTERNS.includes(pat as Pattern) ? (pat as Pattern) : d.pattern, + seed: q.get(P.seed) ?? d.seed, + }; +} + +export function updatePosterUrl(state: PosterState, opts?: { readonly push?: boolean }): void { + const url = new URL(window.location.href); + const q = url.searchParams; + const d = DEFAULT_POSTER_STATE; + const set = (key: string, value: string, def: string): void => { + if (value === def) q.delete(key); + else q.set(key, value); + }; + set(P.id, state.shapeId, d.shapeId); + set(P.title, state.title, d.title); + set(P.sub, state.subtitle, d.subtitle); + set(P.cap, state.caption, d.caption); + set(P.pos, state.textPos, d.textPos); + set(P.cols, String(state.cols), String(d.cols)); + set(P.rows, String(state.rows), String(d.rows)); + set(P.pal, state.palette, d.palette); + set(P.pat, state.pattern, d.pattern); + set(P.seed, state.seed, d.seed); + if (opts?.push) window.history.pushState(window.history.state, "", url); + else window.history.replaceState(window.history.state, "", url); +} diff --git a/web/test/poster-state.test.ts b/web/test/poster-state.test.ts new file mode 100644 index 0000000..91e3790 --- /dev/null +++ b/web/test/poster-state.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_POSTER_STATE, type PosterState } from "../src/poster-templates.js"; +import { readPosterFromUrl, updatePosterUrl } from "../src/poster-state.js"; + +beforeEach(() => { + window.history.replaceState({}, "", "/"); +}); + +describe("poster-state", () => { + it("returns the default state when no params are present", () => { + expect(readPosterFromUrl()).toEqual(DEFAULT_POSTER_STATE); + }); + + it("round-trips a non-default state through the URL", () => { + const state: PosterState = { + ...DEFAULT_POSTER_STATE, + shapeId: "BS-2X2-8888W", + title: "TRANSITION", + subtitle: "009", + caption: "vector / minimal", + textPos: "top-split", + cols: 6, + rows: 8, + palette: "violet", + pattern: "rows", + seed: "abc", + }; + updatePosterUrl(state); + expect(readPosterFromUrl()).toEqual(state); + }); + + it("clamps out-of-range columns and rows", () => { + window.history.replaceState({}, "", "/?pc=99&pr=0"); + const s = readPosterFromUrl(); + expect(s.cols).toBe(8); + expect(s.rows).toBe(1); + }); + + it("falls back on unknown palette / position", () => { + window.history.replaceState({}, "", "/?ppal=chartreuse&ppos=sideways"); + const s = readPosterFromUrl(); + expect(s.palette).toBe(DEFAULT_POSTER_STATE.palette); + expect(s.textPos).toBe(DEFAULT_POSTER_STATE.textPos); + }); + + it("leaves an existing ?id= untouched and omits default params", () => { + window.history.replaceState({}, "", "/?id=BS-2X2-8888W#poster"); + updatePosterUrl({ ...DEFAULT_POSTER_STATE, title: "X" }); + expect(window.location.search).toContain("id=BS-2X2-8888W"); + expect(window.location.search).toContain("pt=X"); + expect(window.location.search).not.toContain("ppal="); + expect(window.location.hash).toBe("#poster"); + }); +}); From 060faff577ae4a24e27161cc65c009eded179407 Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:23:12 +0400 Subject: [PATCH 6/9] feat(web): Poster tab UI --- web/src/poster.ts | 252 ++++++++++++++++++++++++++++++++++++ web/test/export-png.test.ts | 6 +- web/test/poster.test.ts | 70 ++++++++++ 3 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 web/src/poster.ts create mode 100644 web/test/poster.test.ts diff --git a/web/src/poster.ts b/web/src/poster.ts new file mode 100644 index 0000000..557335d --- /dev/null +++ b/web/src/poster.ts @@ -0,0 +1,252 @@ +import { listPrimitives } from "bitshaper"; +import { exportPng } from "./export-png.js"; +import { exportSvg } from "./export-svg.js"; +import { generateFilteredShapeId, randomSeed, tryDecodeShapeId } from "./generate.js"; +import { readPosterFromUrl, updatePosterUrl } from "./poster-state.js"; +import { + type PaletteKey, + type Pattern, + type PosterState, + type TextPos, + renderPosterSvg, +} from "./poster-templates.js"; + +export interface PosterTabOptions { + /** Current always-visible preview shape ID, or null. Backs "Use preview shape". */ + readonly getCurrentShapeId: () => string | null; +} + +export interface PosterTabHandle { + readonly element: HTMLElement; +} + +const POSITION_LABELS: Record = { + "top-left": "Top left", + "top-split": "Top split", + "bottom-left": "Bottom left", +}; +const PATTERN_LABELS: Record = { + checker: "Checkerboard", + rows: "Per row", +}; +const PALETTE_LABELS: Record = { + mono: "Mono", + ink: "Ink on cream", + violet: "Violet", + forest: "Forest", + blueprint: "Blueprint", +}; + +function labelled(text: string, control: HTMLElement): HTMLLabelElement { + const label = document.createElement("label"); + label.className = "poster-field"; + label.append(text); + label.appendChild(control); + return label; +} + +function textInput(name: string, value: string): HTMLInputElement { + const input = document.createElement("input"); + input.type = "text"; + input.name = name; + input.value = value; + return input; +} + +function numberInput(name: string, value: number, min: number, max: number): HTMLInputElement { + const input = document.createElement("input"); + input.type = "number"; + input.name = name; + input.min = String(min); + input.max = String(max); + input.value = String(value); + return input; +} + +function select( + name: string, + options: readonly T[], + labels: Record, + value: T, +): HTMLSelectElement { + const el = document.createElement("select"); + el.name = name; + for (const opt of options) { + const o = document.createElement("option"); + o.value = opt; + o.textContent = labels[opt]; + el.appendChild(o); + } + el.value = value; + return el; +} + +function clampField(input: HTMLInputElement, min: number, max: number): number { + const n = Math.round(Number(input.value)); + return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min; +} + +export function buildPosterTab(container: HTMLElement, opts: PosterTabOptions): PosterTabHandle { + let state: PosterState = readPosterFromUrl(); + + const root = document.createElement("div"); + root.className = "poster-tab"; + + // --- Shape row --- + const shapeRow = document.createElement("div"); + shapeRow.className = "poster-shape-row"; + const shapeInput = document.createElement("input"); + shapeInput.type = "text"; + shapeInput.className = "poster-shape-input"; + shapeInput.value = state.shapeId; + shapeInput.setAttribute("aria-label", "Poster shape ID"); + const shapeNote = document.createElement("span"); + shapeNote.className = "poster-shape-note"; + const usePreviewBtn = document.createElement("button"); + usePreviewBtn.type = "button"; + usePreviewBtn.className = "poster-use-preview"; + usePreviewBtn.textContent = "Use preview shape"; + const randomizeBtn = document.createElement("button"); + randomizeBtn.type = "button"; + randomizeBtn.className = "poster-randomize"; + randomizeBtn.textContent = "Randomize"; + shapeRow.append(shapeInput, usePreviewBtn, randomizeBtn, shapeNote); + + // --- Fields --- + const fields = document.createElement("div"); + fields.className = "poster-fields"; + const titleI = textInput("title", state.title); + const subI = textInput("subtitle", state.subtitle); + const capI = textInput("caption", state.caption); + const posS = select( + "textPos", + ["top-left", "top-split", "bottom-left"], + POSITION_LABELS, + state.textPos, + ); + const colsI = numberInput("cols", state.cols, 1, 8); + const rowsI = numberInput("rows", state.rows, 1, 12); + const palS = select( + "palette", + ["mono", "ink", "violet", "forest", "blueprint"], + PALETTE_LABELS, + state.palette, + ); + const patS = select("pattern", ["checker", "rows"], PATTERN_LABELS, state.pattern); + const seedI = textInput("seed", state.seed); + seedI.placeholder = "optional"; + fields.append( + labelled("Title", titleI), + labelled("Subtitle", subI), + labelled("Caption", capI), + labelled("Position", posS), + labelled("Columns", colsI), + labelled("Rows", rowsI), + labelled("Palette", palS), + labelled("Pattern", patS), + labelled("Variation seed", seedI), + ); + + // --- Preview + export --- + const preview = document.createElement("div"); + preview.className = "poster-preview"; + const exportRow = document.createElement("div"); + exportRow.className = "poster-export"; + const exportSvgBtn = document.createElement("button"); + exportSvgBtn.type = "button"; + exportSvgBtn.className = "poster-export-svg"; + exportSvgBtn.textContent = "Export SVG"; + const exportPngBtn = document.createElement("button"); + exportPngBtn.type = "button"; + exportPngBtn.className = "poster-export-png"; + exportPngBtn.textContent = "Export PNG"; + exportRow.append(exportSvgBtn, exportPngBtn); + + root.append(shapeRow, fields, preview, exportRow); + container.appendChild(root); + + function currentSvg(): string { + return renderPosterSvg(state); + } + + function paint(): void { + preview.innerHTML = currentSvg(); + } + + function syncUsePreview(): void { + usePreviewBtn.disabled = opts.getCurrentShapeId() === null; + } + + function commit(next: PosterState): void { + state = next; + paint(); + updatePosterUrl(state); + } + + function readFields(): PosterState { + return { + ...state, + title: titleI.value, + subtitle: subI.value, + caption: capI.value, + textPos: posS.value as TextPos, + cols: clampField(colsI, 1, 8), + rows: clampField(rowsI, 1, 12), + palette: palS.value as PaletteKey, + pattern: patS.value as Pattern, + seed: seedI.value.trim(), + }; + } + + for (const el of [titleI, subI, capI, seedI]) { + el.addEventListener("input", () => commit(readFields())); + } + for (const el of [posS, palS, patS, colsI, rowsI]) { + el.addEventListener("change", () => commit(readFields())); + } + + function applyShapeId(raw: string): void { + const trimmed = raw.trim(); + shapeInput.value = trimmed; + if (tryDecodeShapeId(trimmed)) { + shapeInput.classList.remove("is-invalid"); + shapeNote.textContent = ""; + commit({ ...state, shapeId: trimmed }); + } else { + shapeInput.classList.add("is-invalid"); + shapeNote.textContent = "Not a valid BitShaper ID — keeping the last one."; + } + } + + shapeInput.addEventListener("change", () => applyShapeId(shapeInput.value)); + + usePreviewBtn.addEventListener("click", () => { + const id = opts.getCurrentShapeId(); + if (id) applyShapeId(id); + }); + + randomizeBtn.addEventListener("click", () => { + const allTypes = listPrimitives().map((_, i) => i); + const id = generateFilteredShapeId(randomSeed(), { cols: 3, rows: 3 }, allTypes); + applyShapeId(id); + }); + + exportSvgBtn.addEventListener("click", () => { + exportSvg(currentSvg(), `poster-${state.shapeId}.svg`); + }); + exportPngBtn.addEventListener("click", () => { + void exportPng(currentSvg(), { + width: 2000, + height: 3000, + filename: `poster-${state.shapeId}.png`, + }); + }); + + syncUsePreview(); + // Keep the "Use preview shape" button in step with the shared preview shape. + window.addEventListener("hashchange", syncUsePreview); + document.addEventListener("bitshaper:shape-changed", syncUsePreview); + + paint(); + return { element: root }; +} diff --git a/web/test/export-png.test.ts b/web/test/export-png.test.ts index 4af496a..2c28d28 100644 --- a/web/test/export-png.test.ts +++ b/web/test/export-png.test.ts @@ -15,8 +15,10 @@ describe("exportPng canvas sizing", () => { const el = realCreate(tag) as HTMLElement; if (tag === "canvas") { const canvas = el as HTMLCanvasElement; - canvas.getContext = () => ({ drawImage: () => {} }) as unknown as CanvasRenderingContext2D; - canvas.toBlob = (cb: BlobCallback) => cb(new Blob(["png"], { type: "image/png" })); + Object.assign(canvas, { + getContext: () => ({ drawImage: () => {} }), + toBlob: (cb: BlobCallback) => cb(new Blob(["png"], { type: "image/png" })), + }); created.push(canvas); } return el; diff --git a/web/test/poster.test.ts b/web/test/poster.test.ts new file mode 100644 index 0000000..72bb386 --- /dev/null +++ b/web/test/poster.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { triggerDownload } from "../src/download.js"; +import { buildPosterTab } from "../src/poster.js"; + +vi.mock("../src/download.js", () => ({ triggerDownload: vi.fn() })); + +function build(getCurrentShapeId: () => string | null = () => null) { + const container = document.createElement("div"); + document.body.appendChild(container); + return { container, handle: buildPosterTab(container, { getCurrentShapeId }) }; +} + +beforeEach(() => { + window.history.replaceState({}, "", "/"); + document.body.innerHTML = ""; + vi.mocked(triggerDownload).mockClear(); +}); + +describe("buildPosterTab", () => { + it("renders a poster svg into .poster-preview on build", () => { + const { container } = build(); + expect(container.querySelector(".poster-preview svg")).not.toBeNull(); + }); + + it("disables 'Use preview shape' when there is no current shape", () => { + const { container } = build(() => null); + const btn = container.querySelector(".poster-use-preview"); + expect(btn?.disabled).toBe(true); + }); + + it("fills the shape field from the preview shape and re-renders", () => { + const { container } = build(() => "BS-2X2-8888W"); + const btn = container.querySelector(".poster-use-preview"); + expect(btn?.disabled).toBe(false); + btn?.click(); + const input = container.querySelector(".poster-shape-input"); + expect(input?.value).toBe("BS-2X2-8888W"); + }); + + it("editing the title re-renders the preview and writes the URL", () => { + const { container } = build(); + const title = container.querySelector('input[name="title"]'); + if (title) { + title.value = "NEWTITLE"; + title.dispatchEvent(new Event("input")); + } + expect(container.querySelector(".poster-preview svg")?.innerHTML).toContain("NEWTITLE"); + expect(window.location.search).toContain("pt=NEWTITLE"); + }); + + it("marks an invalid shape id without wiping the preview", () => { + const { container } = build(); + const input = container.querySelector(".poster-shape-input"); + if (input) { + input.value = "garbage"; + input.dispatchEvent(new Event("change")); + } + expect(input?.classList.contains("is-invalid")).toBe(true); + expect(container.querySelector(".poster-preview svg")).not.toBeNull(); + }); + + it("exports the poster SVG with a poster- filename", () => { + const { container } = build(); + container.querySelector(".poster-export-svg")?.click(); + expect(triggerDownload).toHaveBeenCalledWith( + expect.any(Blob), + expect.stringMatching(/^poster-.*\.svg$/), + ); + }); +}); From e7c1205700c0d5564900eab0d5257fa00b4c90e7 Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:24:12 +0400 Subject: [PATCH 7/9] feat(web): wire Poster as the third working-area tab --- web/src/main.ts | 19 +++++++++++++++++++ web/test/main-layout.test.ts | 29 +++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 0f2fa16..2548e62 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -6,6 +6,7 @@ import { type CompositionPanelHandle, buildCompositionPanel } from "./compositio import { exportPng } from "./export-png.js"; import { exportSvg } from "./export-svg.js"; import { generateFilteredShapeId, tryDecodeShapeId } from "./generate.js"; +import { buildPosterTab } from "./poster.js"; import { renderPreview, showPreviewError } from "./preview.js"; import { buildRampPanel } from "./ramp-panel.js"; import { @@ -60,6 +61,7 @@ function getAppRoot(): HTMLElement { const TABS = [ { id: "create", label: "Create" }, { id: "trace", label: "Trace an image" }, + { id: "poster", label: "Poster" }, ] as const; type TabId = (typeof TABS)[number]["id"]; @@ -73,6 +75,7 @@ function readTabFromHash(): TabId { export function buildLayout(root: HTMLElement): { readonly catalogSection: HTMLElement; readonly traceSection: HTMLElement; + readonly posterSection: HTMLElement; readonly previewSection: HTMLElement; readonly previewContainer: HTMLElement; readonly compositionPanelContainer: HTMLElement; @@ -207,9 +210,20 @@ export function buildLayout(root: HTMLElement): { const catalogList = document.createElement("div"); catalogSection.appendChild(catalogList); + const posterSection = document.createElement("section"); + posterSection.className = "poster-section tab-panel"; + const posterHint = document.createElement("p"); + posterHint.className = "section-hint"; + posterHint.textContent = + "Compose one shape into a modernist poster — pick a shape, set the title and grid, then export."; + posterSection.appendChild(posterHint); + const posterSectionBody = document.createElement("div"); + posterSection.appendChild(posterSectionBody); + const panels: Record = { create: catalogSection, trace: traceSection, + poster: posterSection, }; const tabButtons = new Map(); @@ -243,6 +257,7 @@ export function buildLayout(root: HTMLElement): { return { catalogSection: catalogList, traceSection: traceSectionBody, + posterSection: posterSectionBody, previewSection, previewContainer, compositionPanelContainer, @@ -263,6 +278,7 @@ export function initApp(): void { const { catalogSection, traceSection, + posterSection, previewSection, previewContainer, compositionPanelContainer, @@ -386,6 +402,7 @@ export function initApp(): void { updateUrlForShape(shapeId, opts); shapeIdInput.value = shapeId; setActionsEnabled(); + document.dispatchEvent(new CustomEvent("bitshaper:shape-changed")); if (!applyingRamp) { syncRampPanel(shapeId); } @@ -402,6 +419,8 @@ export function initApp(): void { }, }); + buildPosterTab(posterSection, { getCurrentShapeId: () => currentShapeId }); + /** Filename base for exports: the shape ID, so a download is self-identifying. */ function exportBasename(): string { return currentShapeId ?? "bitshaper-shape"; diff --git a/web/test/main-layout.test.ts b/web/test/main-layout.test.ts index e7ade80..b1c2b95 100644 --- a/web/test/main-layout.test.ts +++ b/web/test/main-layout.test.ts @@ -68,6 +68,14 @@ describe("buildLayout — Composition panel is always visible", () => { expect(document.querySelector(".trace-section")?.hidden).toBe(false); }); + it("keeps the Composition panel visible under the Poster tab", () => { + document.body.innerHTML = '
'; + window.history.replaceState({}, "", "/#poster"); + initApp(); + expect(document.querySelector(".composition-panel")).not.toBeNull(); + expect(document.querySelector(".poster-section")?.hidden).toBe(false); + }); + it("has no separate Generate tab", () => { const root = document.createElement("div"); buildLayout(root); @@ -101,12 +109,25 @@ describe("buildLayout — working-area tabs", () => { expect(window.location.hash).toBe("#create"); }); - it("labels the first tab Create", () => { + it("labels the three working-area tabs", () => { + const root = document.createElement("div"); + buildLayout(root); + const label = (id: string) => + root.querySelector(`.tab-button[data-tab="${id}"]`)?.textContent; + expect(label("create")).toBe("Create"); + expect(label("trace")).toBe("Trace an image"); + expect(label("poster")).toBe("Poster"); + }); + + it("shows the Poster panel for the #poster hash and hides the others", () => { + window.history.replaceState({}, "", "/#poster"); const root = document.createElement("div"); buildLayout(root); - expect( - root.querySelector('.tab-button[data-tab="create"]')?.textContent, - ).toBe("Create"); + const panel = (cls: string) => root.querySelector(`.${cls}`); + expect(panel("poster-section")?.hidden).toBe(false); + expect(panel("catalog-section")?.hidden).toBe(true); + expect(panel("trace-section")?.hidden).toBe(true); + expect(panel("poster-section")?.closest(".tab-panel")).not.toBeNull(); }); it("restores the active tab from the URL hash", () => { From c9e4e9fc4b2b42bf3744dd58765396e39eeef3ef Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:24:58 +0400 Subject: [PATCH 8/9] feat(web): Poster tab styles --- web/src/style.css | 81 +++++++++++++++++++++++++++++++++++ web/test/poster-state.test.ts | 2 +- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/web/src/style.css b/web/src/style.css index cae25cf..59107f3 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -691,3 +691,84 @@ button:hover { font-size: 0.85rem; color: var(--bs-text-muted); } + +/* --- Poster tab --- */ +.poster-tab { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.poster-shape-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.poster-shape-input { + flex: 1 1 16rem; + min-width: 0; + padding: 0.4rem 0.5rem; + background: var(--bs-surface); + border: 1px solid var(--bs-border); + border-radius: 4px; + color: var(--bs-text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.poster-shape-input.is-invalid { + border-color: var(--bs-error); +} + +.poster-shape-note { + flex-basis: 100%; + color: var(--bs-error); + font-size: 0.85rem; + min-height: 1rem; +} + +.poster-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 0.75rem; +} + +.poster-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: var(--bs-text-muted); +} + +.poster-field input, +.poster-field select { + padding: 0.4rem 0.5rem; + background: var(--bs-surface); + border: 1px solid var(--bs-border); + border-radius: 4px; + color: var(--bs-text); +} + +.poster-preview { + display: flex; + justify-content: center; + padding: 1rem; + border: 1px solid var(--bs-border); + border-radius: 6px; + background: repeating-conic-gradient(#20242c 0% 25%, #171a20 0% 50%) 50% / 24px 24px; +} + +.poster-preview svg { + width: auto; + max-width: 100%; + max-height: 70vh; + height: auto; + box-shadow: 0 4px 24px rgb(0 0 0 / 40%); +} + +.poster-export { + display: flex; + gap: 0.5rem; +} diff --git a/web/test/poster-state.test.ts b/web/test/poster-state.test.ts index 91e3790..42e9997 100644 --- a/web/test/poster-state.test.ts +++ b/web/test/poster-state.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_POSTER_STATE, type PosterState } from "../src/poster-templates.js"; import { readPosterFromUrl, updatePosterUrl } from "../src/poster-state.js"; +import { DEFAULT_POSTER_STATE, type PosterState } from "../src/poster-templates.js"; beforeEach(() => { window.history.replaceState({}, "", "/"); From 30664aa30a5abb1edb6b52b19f3e8a0a452dd4cf Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:25:22 +0400 Subject: [PATCH 9/9] test(web): guard default poster renders a real path --- web/test/poster-templates.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/test/poster-templates.test.ts b/web/test/poster-templates.test.ts index 00b3e2d..82c3af9 100644 --- a/web/test/poster-templates.test.ts +++ b/web/test/poster-templates.test.ts @@ -21,6 +21,12 @@ describe("renderPosterSvg", () => { expect((svg.match(/ (no fallback outline)", () => { + const svg = renderPosterSvg(DEFAULT_POSTER_STATE); + expect(svg).toContain(" { const svg = renderPosterSvg({ ...base, palette: "violet" }); expect(svg).toContain(`fill="${PALETTES.violet.bg}"`);