commit b63d094c70ad495adc40afbb03fe01b14aeca61d Author: Oli Passey Date: Sat Feb 21 17:31:10 2026 +0000 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..009a34d --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Tile Pattern Creator + +A lightweight, browser-based tool to build a tile library and paint reusable patterns for tiling teams. + +## Features +- Add tiles by color or optional image +- Paint patterns on a configurable grid +- Support rectangular tiles (multi-cell) +- Pin a floor tile row at the bottom +- Fill any row with a chosen tile +- Export JSON for handing off to a tiler +- Export PNG snapshots +- Import JSON to continue a saved pattern + +## Quick start +Open `index.html` directly in your browser, or use the optional local server script below. + +### Optional local server (PowerShell) +```powershell +./scripts/serve.ps1 +``` + +## Smoke check +```powershell +./scripts/smoke.ps1 +``` + +## Pattern JSON format +```json +{ + "version": 2, + "config": { + "rows": 12, + "cols": 12, + "tileSize": 48, + "floorEnabled": true, + "floorTileId": "tile-abc" + }, + "tiles": [ + { + "id": "tile-abc", + "name": "Sky Blue", + "color": "#4c8bf5", + "width": 2, + "height": 1, + "imageData": null + } + ], + "placements": [ + { "id": "place-1", "tileId": "tile-abc", "row": 2, "col": 3, "width": 2, "height": 1 } + ], + "grid": [["tile-abc", null]] +} +``` + +## Tips +- Shift-click or right click to erase a tile. +- Enable “Floor row” to lock the bottom row to a chosen tile. +- Use “Fill row” to quickly paint a full row with a tile. +- If you use image tiles, keep images square for best results. diff --git a/app.js b/app.js new file mode 100644 index 0000000..b0670ec --- /dev/null +++ b/app.js @@ -0,0 +1,727 @@ +const tileForm = document.getElementById("tile-form"); +const tileNameInput = document.getElementById("tile-name"); +const tileColorInput = document.getElementById("tile-color"); +const tileWidthInput = document.getElementById("tile-width"); +const tileHeightInput = document.getElementById("tile-height"); +const tileImageInput = document.getElementById("tile-image"); +const tileList = document.getElementById("tile-list"); +const activeTileName = document.getElementById("active-tile-name"); +const activeTileSwatch = document.getElementById("active-tile-swatch"); + +const gridRowsInput = document.getElementById("grid-rows"); +const gridColsInput = document.getElementById("grid-cols"); +const gridSizeInput = document.getElementById("grid-size"); +const floorEnabledInput = document.getElementById("floor-enabled"); +const floorTileSelect = document.getElementById("floor-tile"); +const fillRowInput = document.getElementById("fill-row"); +const fillRowTileSelect = document.getElementById("fill-row-tile"); +const fillRowButton = document.getElementById("fill-row-button"); +const applyGridButton = document.getElementById("apply-grid"); +const clearGridButton = document.getElementById("clear-grid"); + +const patternCanvas = document.getElementById("pattern-canvas"); +const patternContext = patternCanvas.getContext("2d"); + +const exportJsonButton = document.getElementById("export-json"); +const copyJsonButton = document.getElementById("copy-json"); +const exportPngButton = document.getElementById("export-png"); +const importJsonButton = document.getElementById("import-json"); +const refreshJsonButton = document.getElementById("refresh-json"); +const patternJson = document.getElementById("pattern-json"); + +let tiles = []; +let grid = []; +let activeTileId = null; +let floorEnabled = false; +let floorTileId = null; +const placements = new Map(); +const imageCache = new Map(); + +const config = { + rows: Number(gridRowsInput.value), + cols: Number(gridColsInput.value), + tileSize: Number(gridSizeInput.value), +}; + +const gridLineColor = "#e4e8f2"; +const gridBackground = "#ffffff"; + +const createId = () => + (crypto && crypto.randomUUID) + ? crypto.randomUUID() + : `tile-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max); + +const getTileById = (tileId) => tiles.find((item) => item.id === tileId); +const createPlacementId = () => + (crypto && crypto.randomUUID) + ? crypto.randomUUID() + : `placement-${Date.now()}-${Math.random().toString(16).slice(2)}`; + +const applyFloorRow = () => { + if (!floorEnabled || !floorTileId) { + return; + } + const lastRow = config.rows - 1; + const tile = getTileById(floorTileId); + if (!tile) { + return; + } + for (let col = 0; col < config.cols; col += tile.width) { + const width = clampNumber(tile.width, 1, config.cols - col); + const x = col * config.tileSize; + const y = lastRow * config.tileSize; + const tileWidth = width * config.tileSize; + if (tile.imageData) { + const image = buildTileImage(tile); + if (image.complete) { + patternContext.drawImage(image, x, y, tileWidth, config.tileSize); + } else { + image.onload = () => { + renderCanvas(); + }; + } + } else { + patternContext.fillStyle = tile.color; + patternContext.fillRect(x, y, tileWidth, config.tileSize); + } + } +}; + +const initGrid = () => { + grid = Array.from({ length: config.rows }, () => + Array.from({ length: config.cols }, () => null) + ); + placements.clear(); +}; + +const buildTileImage = (tile) => { + if (!tile.imageData) { + return null; + } + if (imageCache.has(tile.id)) { + return imageCache.get(tile.id); + } + const image = new Image(); + image.src = tile.imageData; + imageCache.set(tile.id, image); + return image; +}; + +const setActiveTile = (tileId) => { + activeTileId = tileId; + const tile = getTileById(tileId); + if (!tile) { + activeTileName.textContent = "None selected"; + activeTileSwatch.style.background = "repeating-linear-gradient(45deg, #f1f2f7, #f1f2f7 10px, #e8eaf2 10px, #e8eaf2 20px)"; + activeTileSwatch.style.backgroundImage = ""; + fillRowTileSelect.value = ""; + return; + } + activeTileName.textContent = tile.name; + fillRowTileSelect.value = tile.id; + if (tile.imageData) { + activeTileSwatch.style.backgroundImage = `url(${tile.imageData})`; + activeTileSwatch.style.backgroundSize = "cover"; + activeTileSwatch.style.backgroundColor = "transparent"; + } else { + activeTileSwatch.style.backgroundImage = ""; + activeTileSwatch.style.backgroundColor = tile.color; + } +}; + +const renderFloorOptions = () => { + floorTileSelect.innerHTML = ""; + fillRowTileSelect.innerHTML = ""; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select tile"; + floorTileSelect.append(placeholder); + fillRowTileSelect.append(placeholder.cloneNode(true)); + + tiles.forEach((tile) => { + const option = document.createElement("option"); + option.value = tile.id; + option.textContent = tile.name; + floorTileSelect.append(option); + fillRowTileSelect.append(option.cloneNode(true)); + }); + + if (floorTileId && !tiles.some((tile) => tile.id === floorTileId)) { + floorTileId = null; + } + floorTileSelect.value = floorTileId ?? ""; + fillRowTileSelect.value = activeTileId ?? ""; +}; + +const renderTiles = () => { + tileList.innerHTML = ""; + tiles.forEach((tile) => { + const card = document.createElement("div"); + card.className = "tile-card"; + + const swatch = document.createElement("div"); + swatch.className = "tile-swatch"; + if (tile.imageData) { + swatch.style.backgroundImage = `url(${tile.imageData})`; + } else { + swatch.style.backgroundColor = tile.color; + } + + const meta = document.createElement("div"); + meta.className = "tile-meta"; + const title = document.createElement("strong"); + title.textContent = tile.name; + const subtitle = document.createElement("span"); + const sizeLabel = `${tile.width}x${tile.height}`; + subtitle.textContent = tile.imageData + ? `Image tile · ${sizeLabel}` + : `${tile.color.toUpperCase()} · ${sizeLabel}`; + meta.append(title, subtitle); + + const actions = document.createElement("div"); + actions.className = "tile-actions"; + + const selectButton = document.createElement("button"); + selectButton.textContent = "Use"; + selectButton.addEventListener("click", () => { + setActiveTile(tile.id); + }); + + const deleteButton = document.createElement("button"); + deleteButton.textContent = "Delete"; + deleteButton.addEventListener("click", () => { + tiles = tiles.filter((item) => item.id !== tile.id); + imageCache.delete(tile.id); + grid = grid.map((row) => row.map((cell) => (cell === tile.id ? null : cell))); + if (floorTileId === tile.id) { + floorTileId = null; + } + if (activeTileId === tile.id) { + setActiveTile(tiles[0]?.id ?? null); + } + renderTiles(); + renderCanvas(); + syncPatternPreview(); + }); + + actions.append(selectButton, deleteButton); + card.append(swatch, meta, actions); + tileList.append(card); + }); + renderFloorOptions(); +}; + +const resizeCanvas = () => { + patternCanvas.width = config.cols * config.tileSize; + patternCanvas.height = config.rows * config.tileSize; +}; + +const drawGridBackground = () => { + patternContext.fillStyle = gridBackground; + patternContext.fillRect(0, 0, patternCanvas.width, patternCanvas.height); +}; + +const drawTiles = () => { + placements.forEach((placement) => { + const tile = getTileById(placement.tileId); + if (!tile) { + return; + } + const x = placement.col * config.tileSize; + const y = placement.row * config.tileSize; + const width = placement.width * config.tileSize; + const height = placement.height * config.tileSize; + if (tile.imageData) { + const image = buildTileImage(tile); + if (image.complete) { + patternContext.drawImage(image, x, y, width, height); + } else { + image.onload = () => { + renderCanvas(); + }; + } + } else { + patternContext.fillStyle = tile.color; + patternContext.fillRect(x, y, width, height); + } + }); +}; + +const drawGridLines = () => { + patternContext.strokeStyle = gridLineColor; + patternContext.lineWidth = 1; + + for (let row = 0; row <= config.rows; row += 1) { + patternContext.beginPath(); + patternContext.moveTo(0, row * config.tileSize + 0.5); + patternContext.lineTo(patternCanvas.width, row * config.tileSize + 0.5); + patternContext.stroke(); + } + + for (let col = 0; col <= config.cols; col += 1) { + patternContext.beginPath(); + patternContext.moveTo(col * config.tileSize + 0.5, 0); + patternContext.lineTo(col * config.tileSize + 0.5, patternCanvas.height); + patternContext.stroke(); + } +}; + +const renderCanvas = () => { + resizeCanvas(); + drawGridBackground(); + drawTiles(); + applyFloorRow(); + drawGridLines(); +}; + +const getCellFromPointer = (event) => { + const rect = patternCanvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const col = Math.floor(x / config.tileSize); + const row = Math.floor(y / config.tileSize); + if (row < 0 || row >= config.rows || col < 0 || col >= config.cols) { + return null; + } + return { row, col }; +}; + +const clearPlacement = (placementId) => { + const placement = placements.get(placementId); + if (!placement) { + return; + } + for (let r = placement.row; r < placement.row + placement.height; r += 1) { + for (let c = placement.col; c < placement.col + placement.width; c += 1) { + if (grid[r]?.[c] === placementId) { + grid[r][c] = null; + } + } + } + placements.delete(placementId); +}; + +const removePlacementsInArea = (row, col, width, height) => { + const affected = new Set(); + for (let r = row; r < row + height; r += 1) { + for (let c = col; c < col + width; c += 1) { + const placementId = grid[r]?.[c]; + if (placementId) { + affected.add(placementId); + } + } + } + affected.forEach((placementId) => clearPlacement(placementId)); +}; + +const addPlacement = (row, col, tileId, width, height) => { + const placementId = createPlacementId(); + const placement = { + id: placementId, + tileId, + row, + col, + width, + height, + }; + placements.set(placementId, placement); + for (let r = row; r < row + height; r += 1) { + for (let c = col; c < col + width; c += 1) { + grid[r][c] = placementId; + } + } +}; + +const fillRowWithTile = (rowIndex, tileId) => { + const tile = getTileById(tileId); + if (!tile) { + return; + } + if (floorEnabled && rowIndex === config.rows - 1) { + return; + } + const toRemove = new Set(); + for (let col = 0; col < config.cols; col += 1) { + const placementId = grid[rowIndex][col]; + if (placementId) { + toRemove.add(placementId); + } + } + toRemove.forEach((placementId) => clearPlacement(placementId)); + + const width = clampNumber(tile.width, 1, config.cols); + for (let col = 0; col < config.cols; col += width) { + const remaining = config.cols - col; + addPlacement(rowIndex, col, tile.id, clampNumber(width, 1, remaining), 1); + } + renderCanvas(); + syncPatternPreview(); +}; + +const paintArea = (row, col, tileId, width = 1, height = 1) => { + const maxRow = floorEnabled ? config.rows - 2 : config.rows - 1; + if (row > maxRow || col >= config.cols) { + return; + } + const clampedWidth = clampNumber(width, 1, config.cols - col); + const clampedHeight = clampNumber(height, 1, maxRow - row + 1); + removePlacementsInArea(row, col, clampedWidth, clampedHeight); + if (tileId) { + addPlacement(row, col, tileId, clampedWidth, clampedHeight); + } + renderCanvas(); + syncPatternPreview(); +}; + +let isPainting = false; +let paintMode = "paint"; + +patternCanvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); +}); + +patternCanvas.addEventListener("pointerdown", (event) => { + isPainting = true; + paintMode = event.shiftKey || event.button === 2 ? "erase" : "paint"; + const cell = getCellFromPointer(event); + if (!cell) { + return; + } + if (paintMode === "erase") { + if (!(floorEnabled && cell.row === config.rows - 1)) { + const placementId = grid[cell.row][cell.col]; + if (placementId) { + clearPlacement(placementId); + renderCanvas(); + syncPatternPreview(); + } + } + } else if (activeTileId) { + const tile = getTileById(activeTileId); + if (tile) { + paintArea(cell.row, cell.col, activeTileId, tile.width, tile.height); + } + } +}); + +patternCanvas.addEventListener("pointermove", (event) => { + if (!isPainting) { + return; + } + const cell = getCellFromPointer(event); + if (!cell) { + return; + } + if (paintMode === "erase") { + if (!(floorEnabled && cell.row === config.rows - 1)) { + const placementId = grid[cell.row][cell.col]; + if (placementId) { + clearPlacement(placementId); + renderCanvas(); + syncPatternPreview(); + } + } + } else if (activeTileId) { + const tile = getTileById(activeTileId); + if (tile) { + paintArea(cell.row, cell.col, activeTileId, tile.width, tile.height); + } + } +}); + +patternCanvas.addEventListener("pointerup", () => { + isPainting = false; +}); + +patternCanvas.addEventListener("pointerleave", () => { + isPainting = false; +}); + +applyGridButton.addEventListener("click", () => { + config.rows = clampNumber(Number(gridRowsInput.value), 1, 100); + config.cols = clampNumber(Number(gridColsInput.value), 1, 100); + config.tileSize = clampNumber(Number(gridSizeInput.value), 12, 120); + gridRowsInput.value = config.rows; + gridColsInput.value = config.cols; + gridSizeInput.value = config.tileSize; + initGrid(); + renderCanvas(); + syncPatternPreview(); +}); + +clearGridButton.addEventListener("click", () => { + initGrid(); + renderCanvas(); + syncPatternPreview(); +}); + +floorEnabledInput.addEventListener("change", () => { + floorEnabled = floorEnabledInput.checked; + applyFloorRow(); + renderCanvas(); + syncPatternPreview(); +}); + +floorTileSelect.addEventListener("change", () => { + floorTileId = floorTileSelect.value || null; + applyFloorRow(); + renderCanvas(); + syncPatternPreview(); +}); + +fillRowButton.addEventListener("click", () => { + const rowIndex = clampNumber(Number(fillRowInput.value) - 1, 0, config.rows - 1); + const tileId = fillRowTileSelect.value || activeTileId; + if (!tileId) { + return; + } + fillRowWithTile(rowIndex, tileId); +}); + +const readFileAsDataUrl = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error("Could not read file")); + reader.readAsDataURL(file); + }); + +tileForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const name = tileNameInput.value.trim(); + if (!name) { + return; + } + const color = tileColorInput.value; + const width = clampNumber(Number(tileWidthInput.value), 1, 12); + const height = clampNumber(Number(tileHeightInput.value), 1, 12); + const file = tileImageInput.files?.[0] ?? null; + const tile = { + id: createId(), + name, + color, + width, + height, + imageData: null, + }; + + if (file) { + tile.imageData = await readFileAsDataUrl(file); + } + + tiles.push(tile); + tileNameInput.value = ""; + tileImageInput.value = ""; + tileWidthInput.value = "1"; + tileHeightInput.value = "1"; + if (!activeTileId) { + setActiveTile(tile.id); + } + renderTiles(); + renderCanvas(); + syncPatternPreview(); +}); + +const buildPattern = () => ({ + version: 1, + config: { + rows: config.rows, + cols: config.cols, + tileSize: config.tileSize, + floorEnabled, + floorTileId, + }, + tiles: tiles.map((tile) => ({ + id: tile.id, + name: tile.name, + color: tile.color, + width: tile.width, + height: tile.height, + imageData: tile.imageData, + })), + placements: Array.from(placements.values()), + grid: grid.map((row) => row.map((cell) => { + if (!cell) { + return null; + } + const placement = placements.get(cell); + return placement ? placement.tileId : null; + })), +}); + +const syncPatternPreview = () => { + const payload = buildPattern(); + patternJson.value = JSON.stringify(payload, null, 2); +}; + +const downloadFile = (content, filename, type) => { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +}; + +exportJsonButton.addEventListener("click", () => { + applyFloorRow(); + const payload = buildPattern(); + downloadFile(JSON.stringify(payload, null, 2), "tile-pattern.json", "application/json"); +}); + +copyJsonButton.addEventListener("click", async () => { + const payload = buildPattern(); + try { + await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + } catch (error) { + patternJson.focus(); + patternJson.select(); + } +}); + +exportPngButton.addEventListener("click", () => { + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = config.cols * config.tileSize; + exportCanvas.height = config.rows * config.tileSize; + const exportContext = exportCanvas.getContext("2d"); + exportContext.fillStyle = gridBackground; + exportContext.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + + placements.forEach((placement) => { + const tile = getTileById(placement.tileId); + if (!tile) { + return; + } + const x = placement.col * config.tileSize; + const y = placement.row * config.tileSize; + const width = placement.width * config.tileSize; + const height = placement.height * config.tileSize; + if (tile.imageData) { + const image = buildTileImage(tile); + if (image.complete) { + exportContext.drawImage(image, x, y, width, height); + } else { + image.onload = () => { + exportPngButton.click(); + }; + } + } else { + exportContext.fillStyle = tile.color; + exportContext.fillRect(x, y, width, height); + } + }); + + if (floorEnabled && floorTileId) { + const tile = getTileById(floorTileId); + if (tile) { + const lastRow = config.rows - 1; + for (let col = 0; col < config.cols; col += tile.width) { + const width = clampNumber(tile.width, 1, config.cols - col); + const x = col * config.tileSize; + const y = lastRow * config.tileSize; + const tileWidth = width * config.tileSize; + if (tile.imageData) { + const image = buildTileImage(tile); + if (image.complete) { + exportContext.drawImage(image, x, y, tileWidth, config.tileSize); + } else { + image.onload = () => { + exportPngButton.click(); + }; + } + } else { + exportContext.fillStyle = tile.color; + exportContext.fillRect(x, y, tileWidth, config.tileSize); + } + } + } + } + + exportCanvas.toBlob((blob) => { + if (!blob) { + return; + } + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "tile-pattern.png"; + link.click(); + URL.revokeObjectURL(url); + }); +}); + +refreshJsonButton.addEventListener("click", () => { + syncPatternPreview(); +}); + +importJsonButton.addEventListener("click", () => { + try { + const payload = JSON.parse(patternJson.value); + if (!payload.config || !payload.grid || !payload.tiles) { + throw new Error("Invalid pattern data"); + } + config.rows = clampNumber(Number(payload.config.rows), 1, 100); + config.cols = clampNumber(Number(payload.config.cols), 1, 100); + config.tileSize = clampNumber(Number(payload.config.tileSize), 12, 120); + floorEnabled = Boolean(payload.config.floorEnabled); + floorTileId = payload.config.floorTileId ?? null; + gridRowsInput.value = config.rows; + gridColsInput.value = config.cols; + gridSizeInput.value = config.tileSize; + floorEnabledInput.checked = floorEnabled; + fillRowInput.value = "1"; + + tiles = payload.tiles.map((tile) => ({ + id: tile.id || createId(), + name: tile.name || "Untitled", + color: tile.color || "#999999", + width: clampNumber(Number(tile.width ?? 1), 1, 12), + height: clampNumber(Number(tile.height ?? 1), 1, 12), + imageData: tile.imageData || null, + })); + + imageCache.clear(); + tiles.forEach((tile) => buildTileImage(tile)); + initGrid(); + if (Array.isArray(payload.placements)) { + payload.placements.forEach((placement) => { + const tile = getTileById(placement.tileId); + if (!tile) { + return; + } + const row = clampNumber(Number(placement.row), 0, config.rows - 1); + const col = clampNumber(Number(placement.col), 0, config.cols - 1); + const width = clampNumber(Number(placement.width ?? tile.width), 1, config.cols - col); + const height = clampNumber(Number(placement.height ?? tile.height), 1, config.rows - row); + addPlacement(row, col, tile.id, width, height); + }); + } else if (Array.isArray(payload.grid)) { + payload.grid.forEach((row, rowIndex) => { + row.forEach((cell, colIndex) => { + if (cell) { + const tile = getTileById(cell); + if (tile) { + addPlacement(rowIndex, colIndex, tile.id, 1, 1); + } + } + }); + }); + } + + setActiveTile(tiles[0]?.id ?? null); + renderTiles(); + renderFloorOptions(); + renderCanvas(); + syncPatternPreview(); + } catch (error) { + alert("Could not import JSON. Please check the format."); + } +}); + +initGrid(); +renderTiles(); +renderCanvas(); +syncPatternPreview(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..b7bd5e6 --- /dev/null +++ b/index.html @@ -0,0 +1,121 @@ + + + + + + Tile Pattern Creator + + + +
+
+

Tile Pattern Creator

+

Build a tile library, then paint patterns for your tiler.

+
+
+ + + +
+
+ +
+
+

Tile Library

+
+ + + + + + +
+ +
+ Active tile +
+ None selected +
+ +
+
+ +
+
+
+ + + +
+
+ + +
+
+ + + +
+
+ + +
+
+ Tip: click to paint. Shift-click or right click to erase. +
+
+ +
+ +
+ +
+
+

Pattern JSON

+ + +
+ +
+
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..d9bbafa --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "tile-pattern-creator", + "version": "1.0.0", + "private": true, + "description": "Tile library + pattern creator for tilers.", + "scripts": { + "serve": "powershell -ExecutionPolicy Bypass -File ./scripts/serve.ps1", + "smoke": "powershell -ExecutionPolicy Bypass -File ./scripts/smoke.ps1" + } +} diff --git a/scripts/serve.ps1 b/scripts/serve.ps1 new file mode 100644 index 0000000..9928e1b --- /dev/null +++ b/scripts/serve.ps1 @@ -0,0 +1,8 @@ +$python = Get-Command python -ErrorAction SilentlyContinue +if (-not $python) { + Write-Host "Python not found. Open index.html directly in a browser." -ForegroundColor Yellow + exit 1 +} + +Write-Host "Starting local server at http://localhost:8000" -ForegroundColor Green +python -m http.server 8000 diff --git a/scripts/smoke.ps1 b/scripts/smoke.ps1 new file mode 100644 index 0000000..820e987 --- /dev/null +++ b/scripts/smoke.ps1 @@ -0,0 +1,15 @@ +$files = @("index.html", "styles.css", "app.js", "README.md") +$missing = @() + +foreach ($file in $files) { + if (-not (Test-Path $file)) { + $missing += $file + } +} + +if ($missing.Count -gt 0) { + Write-Host "Missing files: $($missing -join ', ')" -ForegroundColor Red + exit 1 +} + +Write-Host "Smoke check passed." -ForegroundColor Green diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c6067c7 --- /dev/null +++ b/styles.css @@ -0,0 +1,277 @@ +:root { + color-scheme: light; + --bg: #f6f7fb; + --panel: #ffffff; + --text: #1c1f2a; + --muted: #6a7182; + --primary: #2d6cdf; + --border: #e1e5ef; + --shadow: 0 18px 40px rgba(20, 26, 40, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + color: var(--text); + background: var(--bg); +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 40px; + border-bottom: 1px solid var(--border); + background: var(--panel); + gap: 24px; +} + +.app-header h1 { + margin: 0 0 4px; + font-size: 24px; +} + +.app-header p { + margin: 0; + color: var(--muted); +} + +.header-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +button { + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + padding: 10px 16px; + border-radius: 10px; + cursor: pointer; + font-weight: 600; +} + +button.primary { + background: var(--primary); + border-color: transparent; + color: white; +} + +button:hover { + filter: brightness(0.98); +} + +.app-layout { + display: grid; + grid-template-columns: 320px 1fr; + gap: 24px; + padding: 24px 40px 40px; +} + +.panel, +.workspace { + background: var(--panel); + border-radius: 18px; + box-shadow: var(--shadow); + padding: 20px; + border: 1px solid var(--border); +} + +.panel h2 { + margin-top: 0; +} + +.tile-form { + display: grid; + gap: 12px; +} + +.tile-form label { + display: grid; + gap: 6px; + font-size: 13px; + color: var(--muted); +} + +.tile-form input[type="text"], +.tile-form input[type="number"], +.tile-form input[type="color"], +.tile-form input[type="file"], +.tile-form select { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + font-size: 14px; +} + +.active-tile { + margin: 20px 0 12px; + padding: 12px; + border: 1px dashed var(--border); + border-radius: 12px; + display: grid; + gap: 6px; +} + +.active-preview { + width: 48px; + height: 48px; + border-radius: 10px; + border: 1px solid var(--border); + background: repeating-linear-gradient(45deg, #f1f2f7, #f1f2f7 10px, #e8eaf2 10px, #e8eaf2 20px); +} + +.tile-list { + display: grid; + gap: 12px; + max-height: 420px; + overflow: auto; + padding-right: 6px; +} + +.tile-card { + display: grid; + grid-template-columns: 48px 1fr auto; + gap: 12px; + align-items: center; + padding: 10px; + border-radius: 12px; + border: 1px solid var(--border); +} + +.tile-swatch { + width: 48px; + height: 48px; + border-radius: 12px; + border: 1px solid var(--border); + background-size: cover; + background-position: center; +} + +.tile-meta { + display: grid; + gap: 4px; +} + +.tile-meta span { + font-size: 12px; + color: var(--muted); +} + +.tile-actions { + display: grid; + gap: 6px; +} + +.workspace { + display: grid; + gap: 20px; +} + +.controls { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; +} + +.control-group { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: flex-end; +} + +.control-group label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.control-group input { + width: 120px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); +} + +.control-group select { + width: 180px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: white; +} + +.checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); +} + +.checkbox input { + width: 16px; + height: 16px; +} + +.control-actions { + display: flex; + gap: 8px; +} + +.control-tip { + font-size: 12px; + color: var(--muted); +} + +.canvas-wrap { + background: #fefefe; + border-radius: 16px; + border: 1px solid var(--border); + padding: 16px; + display: grid; + justify-content: start; +} + +#pattern-canvas { + border-radius: 12px; + background: #ffffff; + border: 1px solid var(--border); +} + +.json-panel { + display: grid; + gap: 10px; +} + +.json-actions { + display: flex; + align-items: center; + justify-content: space-between; +} + +#pattern-json { + width: 100%; + min-height: 160px; + border-radius: 12px; + padding: 12px; + border: 1px solid var(--border); + font-family: "Consolas", "Courier New", monospace; + font-size: 12px; + background: #f9fafc; +} + +@media (max-width: 1100px) { + .app-layout { + grid-template-columns: 1fr; + } +}