This commit is contained in:
Oli Passey
2026-02-21 17:31:10 +00:00
commit b63d094c70
7 changed files with 1218 additions and 0 deletions

60
README.md Normal file
View File

@@ -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.

727
app.js Normal file
View File

@@ -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();

121
index.html Normal file
View File

@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tile Pattern Creator</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="app-header">
<div>
<h1>Tile Pattern Creator</h1>
<p>Build a tile library, then paint patterns for your tiler.</p>
</div>
<div class="header-actions">
<button id="export-json" class="primary">Export JSON</button>
<button id="copy-json">Copy JSON</button>
<button id="export-png">Export PNG</button>
</div>
</header>
<main class="app-layout">
<section class="panel">
<h2>Tile Library</h2>
<form id="tile-form" class="tile-form">
<label>
Tile name
<input id="tile-name" type="text" placeholder="e.g., Sky Blue" required />
</label>
<label>
Tile color
<input id="tile-color" type="color" value="#4c8bf5" />
</label>
<label>
Tile width (cells)
<input id="tile-width" type="number" min="1" max="12" value="1" />
</label>
<label>
Tile height (cells)
<input id="tile-height" type="number" min="1" max="12" value="1" />
</label>
<label>
Optional image
<input id="tile-image" type="file" accept="image/*" />
</label>
<button type="submit" class="primary">Add tile</button>
</form>
<div class="active-tile">
<span>Active tile</span>
<div class="active-preview" id="active-tile-swatch"></div>
<strong id="active-tile-name">None selected</strong>
</div>
<div id="tile-list" class="tile-list"></div>
</section>
<section class="workspace">
<div class="controls">
<div class="control-group">
<label>
Rows
<input id="grid-rows" type="number" min="1" max="100" value="12" />
</label>
<label>
Columns
<input id="grid-cols" type="number" min="1" max="100" value="12" />
</label>
<label>
Tile size
<input id="grid-size" type="number" min="12" max="120" value="48" />
</label>
</div>
<div class="control-group">
<label class="checkbox">
<input id="floor-enabled" type="checkbox" />
Floor row
</label>
<label>
Floor tile
<select id="floor-tile"></select>
</label>
</div>
<div class="control-group">
<label>
Fill row #
<input id="fill-row" type="number" min="1" max="100" value="1" />
</label>
<label>
Fill with tile
<select id="fill-row-tile"></select>
</label>
<button id="fill-row-button">Fill row</button>
</div>
<div class="control-actions">
<button id="apply-grid">Apply grid</button>
<button id="clear-grid">Clear grid</button>
</div>
<div class="control-tip">
Tip: click to paint. Shift-click or right click to erase.
</div>
</div>
<div class="canvas-wrap">
<canvas id="pattern-canvas" width="576" height="576"></canvas>
</div>
<div class="json-panel">
<div class="json-actions">
<h3>Pattern JSON</h3>
<button id="import-json">Import JSON</button>
<button id="refresh-json">Refresh JSON</button>
</div>
<textarea id="pattern-json" spellcheck="false"></textarea>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>

10
package.json Normal file
View File

@@ -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"
}
}

8
scripts/serve.ps1 Normal file
View File

@@ -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

15
scripts/smoke.ps1 Normal file
View File

@@ -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

277
styles.css Normal file
View File

@@ -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;
}
}