From 9fdfa922cfbae92bbb8658c6f7ae1d24b5e6a857 Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 00:58:14 +0400 Subject: [PATCH 1/2] docs: design for web polish pass 3 (Change E) --- ...026-09-07-bitshaper-web-polish-3-design.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-bitshaper-web-polish-3-design.md 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. From e2e7f80cf84b12dd9ca0dfc3d34b689800da4ec7 Mon Sep 17 00:00:00 2001 From: Aleksandr Sukiasov Date: Mon, 7 Sep 2026 01:01:33 +0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(web):=20two-column=20preview,=20?= =?UTF-8?q?=E2=86=92=20Poster=20button,=20clearer=20primitive=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .preview-section splits into a sticky preview column and a settings column (Composition + Morph + Pattern) on screens ≥720px; stacks below. - New "→ Poster" action button by the shape ID loads the current shape into the Poster template and switches to the Poster tab. The poster tab's own Randomize button is removed (it only shows a sent shape now); buildPosterTab exposes setShapeId(). - Primitive toggles get three distinct states — off (dashed, dimmed, struck through), allowed (plain outline), used-in-shape (accent fill + ×N) — plus a one-line legend. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 ++ web/src/composition-panel.ts | 5 ++++ web/src/main.ts | 47 +++++++++++++++++++++++------- web/src/poster.ts | 19 ++++-------- web/src/style.css | 44 ++++++++++++++++++++++++++-- web/test/composition-panel.test.ts | 7 +++++ web/test/main-layout.test.ts | 27 +++++++++++++++-- web/test/poster.test.ts | 14 +++++++++ 8 files changed, 138 insertions(+), 28 deletions(-) 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/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"]');