Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ coverage/

# local scratch renders
scratch-tiling/

# git worktrees created by agents
.claude/worktrees/
82 changes: 82 additions & 0 deletions docs/superpowers/specs/2026-09-07-bitshaper-web-polish-3-design.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions web/src/composition-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
47 changes: 37 additions & 10 deletions web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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);

Expand Down Expand Up @@ -264,6 +280,7 @@ export function buildLayout(root: HTMLElement): {
rampPanelContainer,
exportSvgButton,
exportPngButton,
sendToPosterButton,
shapeIdInput,
copyIdButton,
tileRepeatInput,
Expand All @@ -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). */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 5 additions & 14 deletions web/src/poster.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TextPos, string> = {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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`);
});
Expand All @@ -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 };
}
44 changes: 42 additions & 2 deletions web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions web/test/composition-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('.primitive-toggle[data-type="1"]');
Expand Down
Loading
Loading