diff --git a/.gitignore b/.gitignore index d72ccf5..7d76629 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ coverage/ # local scratch renders scratch-tiling/ + +# git worktrees created by agents +.claude/worktrees/ diff --git a/docs/superpowers/specs/2026-09-07-bitshaper-web-polish-3-design.md b/docs/superpowers/specs/2026-09-07-bitshaper-web-polish-3-design.md new file mode 100644 index 0000000..1fd0a40 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-bitshaper-web-polish-3-design.md @@ -0,0 +1,82 @@ +# BitShaper web — polish pass 3 (Change E) + +**Date:** 2026-09-07 +**Status:** accepted + +Follow-up feedback after Changes B–D merged. Web-only, one change. + +## 1. Two-column preview layout + +`buildLayout` wraps the contents of `.preview-section` in a `.preview-layout` +grid with two children: + +- `.preview-col-main` — the existing `.preview-sticky` (preview + shape-ID row + + action buttons). +- `.preview-col-side` — Composition panel container, Morph panel container, + Pattern control + hint, history hint (in that order). + +CSS: `.preview-layout` is a single column by default; at `min-width: 720px` it +becomes `grid-template-columns: minmax(0, 340px) minmax(0, 1fr)` with +`align-items: start`, so the settings sit to the right of the preview. The +preview stays `position: sticky`. `.preview-section` drops `align-items: center` +(the columns manage their own alignment); `.preview-col-side` left-aligns its +panels and lets them grow to the column width (composition/ramp panels lose +their hard `max-width: 340px` inside the side column, kept as a fallback when +stacked). + +No behavioural change — pure layout. + +## 2. "→ Poster" button by the preview + +- New `.id-action` button `→ Poster` appended to `.shape-id-row` after + `PNG`. Disabled until a shape is loaded (added to the existing enable/disable + set in `setActionsEnabled`). +- `buildPosterTab` returns `{ element, setShapeId(id: string): void }` + (was `{ element }`). `setShapeId` runs the existing `applyShapeId` path. +- `initApp` keeps the `buildPosterTab` handle and wires the button: + `if (currentShapeId) { poster.setShapeId(currentShapeId); selectTab("poster"); }`. + `selectTab` is re-added to the `initApp` destructure. +- The Poster tab's own **`Randomize` button is removed** — creation, the + `shapeRow.append(... randomizeBtn ...)`, and its click handler. The now-unused + imports `generateFilteredShapeId`, `randomSeed`, and `listPrimitives` are + dropped from `poster.ts`. The "Use preview shape" button stays as the manual + re-sync. `poster-templates.ts` / `poster-state.ts` are untouched (the poster + `seed` field drives pattern layout, not the shape, and stays). + +## 3. Primitive-toggle states + legend + +Composition panel toggle chips get three visually distinct states: + +| State | Style | +|---|---| +| Off (`aria-pressed="false"`) | `opacity: 0.5`, `background: transparent`, dashed border, `.primitive-toggle-name` gets `text-decoration: line-through` | +| On, unused | solid `--bs-bg` fill, solid `--bs-border` outline (current default) | +| On, used (`data-used="true"`) | `background: color-mix(in srgb, var(--bs-accent) 18%, transparent)`, `border-color: var(--bs-accent)`; badge shows `×N` | + +A `.composition-legend` line (muted, ~0.72rem) is appended to the panel after +`.composition-primitives`: +*"Dim = off · outlined = allowed · filled = used in this shape"*. + +`buildCompositionPanel` adds the legend element; `showUsage`/`clearUsage` and +the toggle click handler are unchanged (they already set +`aria-pressed` / `data-used`). + +## Tests + +- `web/test/main-layout.test.ts` — `→ Poster` button present in `.shape-id-row`, + disabled initially, enabled after a shape loads; clicking it switches the hash + to `#poster`. Assert `.preview-layout` / `.preview-col-main` / + `.preview-col-side` structure and that the Composition panel is in the side + column. +- `web/test/poster.test.ts` — no `.poster-randomize` button; `setShapeId` on the + handle updates the poster shape input + preview. +- `web/test/composition-panel.test.ts` — a `.composition-legend` exists; the + off-state toggle carries `aria-pressed="false"` (existing) — add an assertion + that the legend text mentions "off" and "used". + +Full core + web suites green; Biome clean; `vite build` ok. + +## Non-goals + +Nothing in `src/core`. No new poster templates. No change to Trace, cell editor, +Morph behaviour, or the ID codec. diff --git a/web/src/composition-panel.ts b/web/src/composition-panel.ts index 6b1b237..87fade4 100644 --- a/web/src/composition-panel.ts +++ b/web/src/composition-panel.ts @@ -144,6 +144,11 @@ export function buildCompositionPanel( toggles.set(primitive.index, { button, badge }); } + const legend = document.createElement("p"); + legend.className = "section-hint composition-legend"; + legend.textContent = "Dim = off · outlined = allowed · filled = used in this shape"; + panel.appendChild(legend); + copySeedButton.addEventListener("click", () => { const value = seedInput.value.trim(); if (value.length === 0) { diff --git a/web/src/main.ts b/web/src/main.ts index 2548e62..24d8d4f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -82,6 +82,7 @@ export function buildLayout(root: HTMLElement): { readonly rampPanelContainer: HTMLElement; readonly exportSvgButton: HTMLButtonElement; readonly exportPngButton: HTMLButtonElement; + readonly sendToPosterButton: HTMLButtonElement; readonly shapeIdInput: HTMLInputElement; readonly copyIdButton: HTMLButtonElement; readonly tileRepeatInput: HTMLInputElement; @@ -137,22 +138,37 @@ export function buildLayout(root: HTMLElement): { exportPngButton.className = "id-action"; exportPngButton.textContent = "PNG"; exportPngButton.title = "Export as PNG"; - for (const b of [copyIdButton, exportSvgButton, exportPngButton]) { + const sendToPosterButton = document.createElement("button"); + sendToPosterButton.type = "button"; + sendToPosterButton.className = "id-action"; + sendToPosterButton.textContent = "→ Poster"; + sendToPosterButton.title = "Use this shape in the Poster tab"; + for (const b of [copyIdButton, exportSvgButton, exportPngButton, sendToPosterButton]) { b.disabled = true; shapeIdRow.appendChild(b); } previewSticky.appendChild(shapeIdRow); - previewSection.appendChild(previewSticky); - // Composition (columns/rows + seed + Randomize + primitive toggles) and Morph - // sit under the preview and stay visible on every tab. + // On wide screens the preview sits left, the settings panels right; on narrow + // screens they stack (preview on top). Both live in .preview-section so they + // stay visible on every tab. + const previewLayout = document.createElement("div"); + previewLayout.className = "preview-layout"; + const previewColMain = document.createElement("div"); + previewColMain.className = "preview-col-main"; + previewColMain.appendChild(previewSticky); + const previewColSide = document.createElement("div"); + previewColSide.className = "preview-col-side"; + previewLayout.append(previewColMain, previewColSide); + previewSection.appendChild(previewLayout); + const compositionPanelContainer = document.createElement("div"); compositionPanelContainer.className = "composition-panel-container"; - previewSection.appendChild(compositionPanelContainer); + previewColSide.appendChild(compositionPanelContainer); const rampPanelContainer = document.createElement("div"); rampPanelContainer.className = "ramp-panel-container"; - previewSection.appendChild(rampPanelContainer); + previewColSide.appendChild(rampPanelContainer); const tileRepeatLabel = document.createElement("label"); tileRepeatLabel.className = "tile-repeat"; @@ -172,14 +188,14 @@ export function buildLayout(root: HTMLElement): { const tileRepeatHint = document.createElement("span"); tileRepeatHint.className = "section-hint tile-repeat-hint"; tileRepeatHint.textContent = "Repeat this shape to preview it as a tiling pattern."; - previewSection.appendChild(tileRepeatLabel); - previewSection.appendChild(tileRepeatHint); + previewColSide.appendChild(tileRepeatLabel); + previewColSide.appendChild(tileRepeatHint); const historyHint = document.createElement("p"); historyHint.className = "section-hint"; historyHint.textContent = "Randomized a few times? Use your browser's Back button to step through previous shapes."; - previewSection.appendChild(historyHint); + previewColSide.appendChild(historyHint); main.appendChild(previewSection); @@ -264,6 +280,7 @@ export function buildLayout(root: HTMLElement): { rampPanelContainer, exportSvgButton, exportPngButton, + sendToPosterButton, shapeIdInput, copyIdButton, tileRepeatInput, @@ -285,10 +302,12 @@ export function initApp(): void { rampPanelContainer, exportSvgButton, exportPngButton, + sendToPosterButton, shapeIdInput, copyIdButton, tileRepeatInput, tileSeamStatus, + selectTab, } = buildLayout(root); /** Current repeat count (1 = single shape, 2–10 = N×N pattern preview). */ @@ -392,6 +411,7 @@ export function initApp(): void { copyIdButton.disabled = !enabled; exportSvgButton.disabled = !enabled; exportPngButton.disabled = !enabled; + sendToPosterButton.disabled = !enabled; } function showShape(shapeId: string, opts?: { readonly push?: boolean }): void { @@ -419,7 +439,14 @@ export function initApp(): void { }, }); - buildPosterTab(posterSection, { getCurrentShapeId: () => currentShapeId }); + const poster = buildPosterTab(posterSection, { getCurrentShapeId: () => currentShapeId }); + + sendToPosterButton.addEventListener("click", () => { + if (currentShapeId) { + poster.setShapeId(currentShapeId); + selectTab("poster"); + } + }); /** Filename base for exports: the shape ID, so a download is self-identifying. */ function exportBasename(): string { diff --git a/web/src/poster.ts b/web/src/poster.ts index 557335d..535e4e4 100644 --- a/web/src/poster.ts +++ b/web/src/poster.ts @@ -1,7 +1,6 @@ -import { listPrimitives } from "bitshaper"; import { exportPng } from "./export-png.js"; import { exportSvg } from "./export-svg.js"; -import { generateFilteredShapeId, randomSeed, tryDecodeShapeId } from "./generate.js"; +import { tryDecodeShapeId } from "./generate.js"; import { readPosterFromUrl, updatePosterUrl } from "./poster-state.js"; import { type PaletteKey, @@ -18,6 +17,8 @@ export interface PosterTabOptions { export interface PosterTabHandle { readonly element: HTMLElement; + /** Loads `id` as the poster's shape (same path as typing it into the field). */ + setShapeId(id: string): void; } const POSITION_LABELS: Record = { @@ -106,11 +107,7 @@ export function buildPosterTab(container: HTMLElement, opts: PosterTabOptions): 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); + shapeRow.append(shapeInput, usePreviewBtn, shapeNote); // --- Fields --- const fields = document.createElement("div"); @@ -225,12 +222,6 @@ export function buildPosterTab(container: HTMLElement, opts: PosterTabOptions): 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`); }); @@ -248,5 +239,5 @@ export function buildPosterTab(container: HTMLElement, opts: PosterTabOptions): document.addEventListener("bitshaper:shape-changed", syncUsePreview); paint(); - return { element: root }; + return { element: root, setShapeId: applyShapeId }; } diff --git a/web/src/style.css b/web/src/style.css index 59107f3..9c4012f 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -87,10 +87,36 @@ section h2 { .preview-section { display: flex; flex-direction: column; - align-items: center; gap: 0.75rem; } +.preview-layout { + display: grid; + gap: 1.25rem; + justify-items: center; +} + +.preview-col-side { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + max-width: 340px; +} + +@media (min-width: 720px) { + .preview-layout { + grid-template-columns: minmax(0, 340px) minmax(0, 1fr); + align-items: start; + justify-items: stretch; + } + + .preview-col-side { + max-width: none; + align-items: flex-start; + } +} + .preview-sticky { position: sticky; top: 0; @@ -423,11 +449,20 @@ button:hover { cursor: pointer; } +/* Off — excluded from the next Randomize. */ .primitive-toggle[aria-pressed="false"] { - opacity: 0.4; + opacity: 0.5; + background: transparent; + border-style: dashed; } +.primitive-toggle[aria-pressed="false"] .primitive-toggle-name { + text-decoration: line-through; +} + +/* On and used by the shape currently in the preview. */ .primitive-toggle[data-used="true"] { + background: color-mix(in srgb, var(--bs-accent) 18%, transparent); border-color: var(--bs-accent); } @@ -436,6 +471,11 @@ button:hover { font-variant-numeric: tabular-nums; } +.composition-legend { + margin: 0; + font-size: 0.72rem; +} + .primitive-icon { display: inline-flex; width: 1.5rem; diff --git a/web/test/composition-panel.test.ts b/web/test/composition-panel.test.ts index e592749..22b7971 100644 --- a/web/test/composition-panel.test.ts +++ b/web/test/composition-panel.test.ts @@ -19,6 +19,13 @@ describe("buildCompositionPanel — primitive toggles", () => { expect(handle.allowedTypes().length).toBe(PRIMITIVE_COUNT); }); + it("shows a legend explaining the off / allowed / used states", () => { + const { container } = build(); + const legend = container.querySelector(".composition-legend")?.textContent ?? ""; + expect(legend).toMatch(/off/i); + expect(legend).toMatch(/used/i); + }); + it("drops a primitive from allowedTypes when its toggle is clicked off", () => { const { container, handle } = build(); const first = container.querySelector('.primitive-toggle[data-type="1"]'); diff --git a/web/test/main-layout.test.ts b/web/test/main-layout.test.ts index b1c2b95..1f20f2a 100644 --- a/web/test/main-layout.test.ts +++ b/web/test/main-layout.test.ts @@ -9,15 +9,29 @@ describe("buildLayout — ID action buttons", () => { window.history.replaceState({}, "", "/"); }); - it("puts Copy ID / SVG / PNG in the shape-ID row, disabled until a shape loads", () => { + it("puts Copy ID / SVG / PNG / → Poster in the shape-ID row, disabled until a shape loads", () => { const root = document.createElement("div"); buildLayout(root); const buttons = [...root.querySelectorAll(".shape-id-row .id-action")]; - expect(buttons.map((b) => b.textContent)).toEqual(["Copy ID", "SVG", "PNG"]); + expect(buttons.map((b) => b.textContent)).toEqual(["Copy ID", "SVG", "PNG", "→ Poster"]); expect(buttons.every((b) => b.disabled)).toBe(true); }); + it("→ Poster loads the shape into the poster tab and switches to it", () => { + document.body.innerHTML = '
'; + window.history.replaceState({}, "", "?id=BS-2X2-8888W"); + initApp(); + + document + .querySelector('.id-action[title="Use this shape in the Poster tab"]') + ?.click(); + expect(window.location.hash).toBe("#poster"); + expect(document.querySelector(".poster-shape-input")?.value).toBe( + "BS-2X2-8888W", + ); + }); + it("wraps the preview and ID row in a sticky element", () => { const root = document.createElement("div"); buildLayout(root); @@ -26,6 +40,15 @@ describe("buildLayout — ID action buttons", () => { expect(sticky?.querySelector(".shape-id-row")).not.toBeNull(); }); + it("splits the preview section into a main column (preview) and a side column (panels)", () => { + const root = document.createElement("div"); + buildLayout(root); + const layout = root.querySelector(".preview-section .preview-layout"); + expect(layout?.querySelector(".preview-col-main .preview-sticky")).not.toBeNull(); + expect(layout?.querySelector(".preview-col-side .composition-panel-container")).not.toBeNull(); + expect(layout?.querySelector(".preview-col-side .ramp-panel-container")).not.toBeNull(); + }); + it("enables the action buttons once a shape is loaded from the URL", () => { document.body.innerHTML = '
'; window.history.replaceState({}, "", "?id=BS-2X2-8888W"); diff --git a/web/test/poster.test.ts b/web/test/poster.test.ts index 72bb386..d76e27c 100644 --- a/web/test/poster.test.ts +++ b/web/test/poster.test.ts @@ -37,6 +37,20 @@ describe("buildPosterTab", () => { expect(input?.value).toBe("BS-2X2-8888W"); }); + it("has no poster-local Randomize button", () => { + const { container } = build(); + expect(container.querySelector(".poster-randomize")).toBeNull(); + }); + + it("setShapeId on the handle loads a shape into the field and preview", () => { + const { container, handle } = build(); + handle.setShapeId("BS-2X2-8888W"); + expect(container.querySelector(".poster-shape-input")?.value).toBe( + "BS-2X2-8888W", + ); + expect(container.querySelector(".poster-preview svg")).not.toBeNull(); + }); + it("editing the title re-renders the preview and writes the URL", () => { const { container } = build(); const title = container.querySelector('input[name="title"]');