12<html lang="en">3<head>4 <meta charset="utf-8">5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">6 <meta name="theme-color" content="#ece6da">7 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23eee8dc'/%3E%3Ccircle cx='0' cy='0' r='32' fill='%23c83b2e'/%3E%3C/svg%3E">8 <meta name="description" content="A seeded Bauhaus pattern generator for reproducible tiled geometric compositions.">9 <title>Bauhaus Pattern Generator</title>10 <style>11 :root {12 color-scheme: light;13 --button: rgba(245, 241, 232, 0.92);14 --button-ink: #171717;15 --focus: #fff;16 }1718 * { box-sizing: border-box; }1920 html,21 body {22 width: 100%;23 height: 100%;24 margin: 0;25 overflow: hidden;26 background: #ece6da;27 }2829 body {30 overscroll-behavior: none;31 touch-action: manipulation;32 }3334 canvas {35 display: block;36 width: 100vw;37 height: 100dvh;38 }3940 .actions {41 position: fixed;42 z-index: 2;43 right: max(14px, env(safe-area-inset-right));44 bottom: max(14px, env(safe-area-inset-bottom));45 display: flex;46 gap: 8px;47 }4849 button {50 width: 44px;51 height: 44px;52 display: grid;53 place-items: center;54 padding: 0;55 border: 1px solid rgba(23, 23, 23, 0.13);56 border-radius: 50%;57 color: var(--button-ink);58 background: var(--button);59 box-shadow: 0 2px 10px rgba(23, 23, 23, 0.12);60 -webkit-backdrop-filter: blur(10px);61 backdrop-filter: blur(10px);62 cursor: pointer;63 -webkit-tap-highlight-color: transparent;64 }6566 button:hover { background: #fff; }67 button:active { transform: translateY(1px); }68 button:focus-visible {69 outline: 3px solid var(--focus);70 outline-offset: 2px;71 }7273 button svg {74 width: 18px;75 height: 18px;76 fill: none;77 stroke: currentColor;78 stroke-width: 1.8;79 stroke-linecap: round;80 stroke-linejoin: round;81 }8283 .toast {84 position: fixed;85 z-index: 3;86 right: max(14px, env(safe-area-inset-right));87 bottom: calc(max(14px, env(safe-area-inset-bottom)) + 54px);88 max-width: min(260px, calc(100vw - 28px));89 padding: 8px 11px;90 border-radius: 999px;91 color: #171717;92 background: rgba(245, 241, 232, 0.94);93 box-shadow: 0 2px 10px rgba(23, 23, 23, 0.12);94 font: 600 11px/1.2 ui-sans-serif, system-ui, sans-serif;95 letter-spacing: 0.04em;96 text-transform: uppercase;97 opacity: 0;98 transform: translateY(5px);99 transition: opacity 160ms ease, transform 160ms ease;100 pointer-events: none;101 }102103 .toast.visible {104 opacity: 1;105 transform: translateY(0);106 }107108 .sr-only {109 position: absolute;110 width: 1px;111 height: 1px;112 padding: 0;113 margin: -1px;114 overflow: hidden;115 clip: rect(0, 0, 0, 0);116 white-space: nowrap;117 border: 0;118 }119120 @media (prefers-reduced-transparency: reduce) {121 button, .toast {122 -webkit-backdrop-filter: none;123 backdrop-filter: none;124 background: #f5f1e8;125 }126 }127 </style>128</head>129<body>130 <canvas id="art" aria-label="A seeded tiled geometric composition"></canvas>131132 <div class="actions" aria-label="Artwork actions">133 <button id="link" type="button" aria-label="Copy a permanent link to this generation" title="Copy link">134 <svg viewBox="0 0 24 24" aria-hidden="true">135 <path d="M10.2 13.8a4 4 0 0 0 5.7.1l2.8-2.8a4 4 0 0 0-5.6-5.6l-1.6 1.6"/>136 <path d="M13.8 10.2a4 4 0 0 0-5.7-.1l-2.8 2.8a4 4 0 0 0 5.6 5.6l1.6-1.6"/>137 </svg>138 </button>139 <button id="download" type="button" aria-label="Download this generation as a PNG" title="Download PNG">140 <svg viewBox="0 0 24 24" aria-hidden="true">141 <path d="M12 3v12"/>142 <path d="m7.5 10.5 4.5 4.5 4.5-4.5"/>143 <path d="M5 20h14"/>144 </svg>145 </button>146 </div>147148 <div id="toast" class="toast" role="status" aria-live="polite"></div>149 <p id="identity" class="sr-only"></p>150151 <script>152 (() => {153 'use strict';154155 const canvas = document.querySelector('#art');156 const ctx = canvas.getContext('2d', { alpha: false });157 const linkButton = document.querySelector('#link');158 const downloadButton = document.querySelector('#download');159 const toast = document.querySelector('#toast');160 const identity = document.querySelector('#identity');161 const TAU = Math.PI * 2;162163 const paletteThemes = [164 {165 name: 'Bauhaus primary',166 weight: 1.5,167 variants: [168 ['#eee8dc', '#171717', '#c83b2e', '#1e5484', '#dfb631'],169 ['#e8e3d8', '#202020', '#b8342a', '#245b8c', '#e1ad25'],170 ['#f1eadf', '#141414', '#cf432f', '#22518b', '#d9b13a'],171 ['#e9e5dc', '#181818', '#c94332', '#315f8e', '#ddb934']172 ]173 },174 {175 name: 'Walnut and sage',176 weight: 2,177 lanes: [178 { name: 'walnut', colors: ['#582f0e', '#7f4f24', '#936639', '#a68a64', '#b6ad90'] },179 { name: 'sage', colors: ['#b6ad90', '#c2c5aa', '#a4ac86', '#656d4a', '#414833', '#333d29'] }180 ]181 },182 {183 name: 'Oxidized coast',184 weight: 1.5,185 lanes: [186 { name: 'verdigris', colors: ['#001219', '#005f73', '#0a9396', '#94d2bd', '#e9d8a6'] },187 { name: 'oxide', colors: ['#e9d8a6', '#ee9b00', '#ca6702', '#bb3e03', '#ae2012', '#9b2226'] }188 ]189 },190 {191 name: 'Harvest spectrum',192 weight: 1,193 lanes: [194 { name: 'warm', colors: ['#f94144', '#f3722c', '#f8961e', '#f9844a', '#f9c74f'] },195 { name: 'field', colors: ['#90be6d', '#43aa8b', '#4d908e', '#577590', '#277da1'] }196 ]197 },198 {199 name: 'Electric bloom',200 weight: 1,201 lanes: [202 { name: 'bloom', colors: ['#f72585', '#b5179e', '#7209b7', '#560bad', '#480ca8'] },203 { name: 'electric', colors: ['#3a0ca3', '#3f37c9', '#4361ee', '#4895ef', '#6daaf2'] }204 ]205 },206 {207 name: 'Desert grove',208 weight: 2,209 lanes: [210 { name: 'grove', colors: ['#797d62', '#9b9b7a', '#baa587', '#d9ae94', '#f1dca7'] },211 { name: 'clay', colors: ['#f1dca7', '#ffcb69', '#e8ac65', '#d08c60', '#b58463', '#997b66'] }212 ]213 },214 {215 name: 'Ember kiln',216 weight: 1.5,217 colors: ['#862906', '#a04005', '#b84f00', '#c46219', '#cb5925', '#dc5713', '#ed862b', '#875702', '#5a380b', '#411308']218 },219 {220 name: 'Sunlit coast',221 weight: 1.2,222 lanes: [223 { name: 'warm', colors: ['#cc4400', '#d66915', '#e08e29', '#f0c761', '#ffff99'] },224 { name: 'cool', colors: ['#c2fcff', '#7cc6de', '#3890bc', '#1c489a', '#000077'] }225 ]226 }227 ];228229 const motifNames = ['quarter', 'half', 'bar', 'diagonal', 'ring', 'inset', 'field', 'void'];230 const grammars = ['weave', 'cadence', 'blocks'];231 let seed = readSeedFromHash() || freshSeed();232 let toastTimer = 0;233 let resizeTimer = 0;234 let renderInfo = null;235236 function normalizeSeed(value) {237 const cleaned = String(value || '').trim().replace(/^#/, '');238 return /^[a-zA-Z0-9_-]{1,64}$/.test(cleaned) ? cleaned : null;239 }240241 function readSeedFromHash() {242 return normalizeSeed(decodeURIComponent(location.hash.slice(1)));243 }244245 function freshSeed() {246 const words = new Uint32Array(2);247 crypto.getRandomValues(words);248 return Array.from(words, value => value.toString(16).padStart(8, '0')).join('');249 }250251 function hash32(text) {252 let hash = 2166136261;253 for (let index = 0; index < text.length; index += 1) {254 hash ^= text.charCodeAt(index);255 hash = Math.imul(hash, 16777619);256 }257 hash += hash << 13;258 hash ^= hash >>> 7;259 hash += hash << 3;260 hash ^= hash >>> 17;261 hash += hash << 5;262 return hash >>> 0;263 }264265 function makeRng(path) {266 let a = hash32(`${seed}|${path}|a`);267 let b = hash32(`${seed}|${path}|b`);268 let c = hash32(`${seed}|${path}|c`);269 let d = hash32(`${seed}|${path}|d`);270271 const float = () => {272 a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;273 const result = (a + b + d) >>> 0;274 d = (d + 1) >>> 0;275 a = b ^ (b >>> 9);276 b = (c + (c << 3)) >>> 0;277 c = ((c << 21) | (c >>> 11)) >>> 0;278 c = (c + result) >>> 0;279 return result / 4294967296;280 };281282 return {283 float,284 int(min, max) { return Math.floor(float() * (max - min + 1)) + min; },285 bool(chance = 0.5) { return float() < chance; },286 pick(items) { return items[Math.floor(float() * items.length)]; },287 weighted(entries) {288 const total = entries.reduce((sum, entry) => sum + entry[1], 0);289 let cursor = float() * total;290 for (const [value, weight] of entries) {291 cursor -= weight;292 if (cursor <= 0) return value;293 }294 return entries[entries.length - 1][0];295 }296 };297 }298299 function relativeLuminance(hex) {300 const channels = [1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16) / 255);301 const linear = channels.map(value => value <= 0.04045302 ? value / 12.92303 : ((value + 0.055) / 1.055) ** 2.4304 );305 return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;306 }307308 function contrastRatio(first, second) {309 const light = Math.max(relativeLuminance(first), relativeLuminance(second));310 const dark = Math.min(relativeLuminance(first), relativeLuminance(second));311 return (light + 0.05) / (dark + 0.05);312 }313314 function buildPalette(rng) {315 const theme = rng.weighted(paletteThemes.map(item => [item, item.weight]));316 if (theme.variants) {317 return { themeName: theme.name, palette: rng.pick(theme.variants) };318 }319320 const lane = theme.lanes ? rng.pick(theme.lanes) : null;321 const sourceColors = lane ? lane.colors : theme.colors;322 const themeName = lane ? `${theme.name}, ${lane.name}` : theme.name;323324 const byLightness = sourceColors325 .map(color => ({ color, lightness: relativeLuminance(color) }))326 .sort((first, second) => first.lightness - second.lightness);327 const ground = rng.pick(byLightness.slice(-3)).color;328 let ink = rng.pick(byLightness.slice(0, 2)).color;329330 // Low-contrast families still need a structural dark to preserve the331 // figure/ground tension that holds the composition together.332 if (contrastRatio(ground, ink) < 3) {333 const nativeDarkest = byLightness[0].color;334 ink = contrastRatio(ground, nativeDarkest) >= 2.75 ? nativeDarkest : '#292823';335 }336337 const pool = sourceColors.filter(color => color !== ground && color !== ink);338 const accents = [];339 while (accents.length < 3 && pool.length) {340 accents.push(pool.splice(rng.int(0, pool.length - 1), 1)[0]);341 }342343 return { themeName, palette: [ground, ink, ...accents] };344 }345346 function buildSystem() {347 const paletteRng = makeRng('palette');348 const structureRng = makeRng('structure');349 const rhythmRng = makeRng('rhythm');350 const nestingRng = makeRng('nesting');351 const { themeName, palette } = buildPalette(paletteRng);352 const grammar = structureRng.pick(grammars);353 const shortCount = structureRng.int(6, 11);354 const period = structureRng.int(3, 6);355 const blockSize = structureRng.pick([2, 2, 3]);356 const mutationRate = structureRng.pick([0.035, 0.05, 0.07, 0.09]);357 const motifSequence = Array.from({ length: period }, () => rhythmRng.pick(motifNames.slice(0, 7)));358 const colorSequence = Array.from({ length: period + 1 }, () =>359 rhythmRng.weighted([[0, 6], [1, 4], [2, 2], [3, 2], [4, 1.5]])360 );361 const direction = rhythmRng.pick([1, 1, -1]);362 const nesting = {363 enabled: nestingRng.bool(0.55),364 maxDepth: nestingRng.weighted([[1, 3], [2, 1]]),365 rootRate: nestingRng.pick([0.14, 0.18, 0.22, 0.26]),366 branchRate: nestingRng.pick([0.12, 0.18, 0.24])367 };368369 return {370 palette, themeName, grammar, shortCount, period, blockSize, mutationRate,371 motifSequence, colorSequence, direction, nesting372 };373 }374375 function setupCanvas() {376 const cssWidth = Math.max(1, window.innerWidth);377 const cssHeight = Math.max(1, window.innerHeight);378 const dpr = Math.min(window.devicePixelRatio || 1, 4);379 const pixelWidth = Math.max(1, Math.round(cssWidth * dpr));380 const pixelHeight = Math.max(1, Math.round(cssHeight * dpr));381382 if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {383 canvas.width = pixelWidth;384 canvas.height = pixelHeight;385 }386 canvas.style.width = `${cssWidth}px`;387 canvas.style.height = `${cssHeight}px`;388 // Render directly in physical pixels. Keeping every shared edge on an389 // integer backing-store coordinate prevents antialiased tile seams.390 ctx.setTransform(1, 0, 0, 1, 0, 0);391 ctx.imageSmoothingEnabled = true;392 return { cssWidth, cssHeight, dpr, pixelWidth, pixelHeight };393 }394395 function render() {396 const viewport = setupCanvas();397 const system = buildSystem();398 const { pixelWidth: width, pixelHeight: height } = viewport;399 const subdivisionUnit = 2 ** system.nesting.maxDepth;400 const rawCell = Math.min(width, height) / system.shortCount;401 const cell = Math.ceil(rawCell / subdivisionUnit) * subdivisionUnit;402 const cols = Math.ceil(width / cell) + 2;403 const rows = Math.ceil(height / cell) + 2;404 const offsetX = Math.floor((width - cols * cell) / 2);405 const offsetY = Math.floor((height - rows * cell) / 2);406 system.minimumBranchSize = 42 * viewport.dpr;407408 ctx.fillStyle = system.palette[0];409 ctx.fillRect(0, 0, width, height);410411 for (let row = 0; row < rows; row += 1) {412 for (let col = 0; col < cols; col += 1) {413 const x = offsetX + col * cell;414 const y = offsetY + row * cell;415 drawCell(ctx, system, col, row, x, y, cell);416 }417 }418419 renderInfo = { ...viewport, ...system, cols, rows, cell };420 const topology = system.nesting.enabled421 ? `nested square topology, depth ${system.nesting.maxDepth}`422 : 'uniform square topology';423 identity.textContent = `Seed ${seed}. ${system.themeName} colors. ${system.grammar} rhythm. ${topology}. ${system.shortCount} tiles on the short axis.`;424 document.documentElement.style.setProperty('--focus', system.palette[4]);425 document.querySelector('meta[name="theme-color"]').setAttribute('content', system.palette[0]);426 }427428 function drawCell(context, system, col, row, x, y, size) {429 const cadence = cadenceIndex(system, col, row);430 drawSquareNode(context, system, col, row, x, y, size, `cell/${col}/${row}`, 0, cadence);431 }432433 function drawSquareNode(context, system, col, row, x, y, size, path, depth, cadence) {434 const nestingRng = makeRng(`nesting/${path}`);435 const branchRate = depth === 0 ? system.nesting.rootRate : system.nesting.branchRate;436 const canBranch = system.nesting.enabled437 && depth < system.nesting.maxDepth438 && size >= system.minimumBranchSize439 && nestingRng.bool(branchRate);440441 if (canBranch) {442 const childSize = size / 2;443 for (let childRow = 0; childRow < 2; childRow += 1) {444 for (let childCol = 0; childCol < 2; childCol += 1) {445 const childIndex = childCol + childRow * 2;446 const childCadence = (447 cadence448 + childCol449 + childRow * system.direction450 + depth451 + 1452 + system.period * 4453 ) % system.period;454 drawSquareNode(455 context,456 system,457 col,458 row,459 x + childCol * childSize,460 y + childRow * childSize,461 childSize,462 `${path}/branch-${depth}/${childIndex}`,463 depth + 1,464 childCadence465 );466 }467 }468 return;469 }470471 drawSquareLeaf(context, system, col, row, x, y, size, path, depth, cadence);472 }473474 function drawSquareLeaf(context, system, col, row, x, y, size, path, depth, cadence) {475 const cellRng = makeRng(`${path}/leaf`);476 let motif = system.motifSequence[cadence % system.motifSequence.length];477 let rotation = ((cadence + (system.direction < 0 ? row : col) + depth) % 4 + 4) % 4;478479 if (cellRng.bool(system.mutationRate)) motif = cellRng.pick(motifNames);480 if (cellRng.bool(system.mutationRate * 1.4)) rotation = cellRng.int(0, 3);481482 const backgroundIndex = system.colorSequence[cadence % system.colorSequence.length];483 const foregroundIndex = chooseForeground(system, backgroundIndex, cadence, cellRng);484 const background = system.palette[backgroundIndex];485 const foreground = system.palette[foregroundIndex];486487 context.save();488 context.beginPath();489 context.rect(x, y, size, size);490 context.clip();491 context.fillStyle = background;492 context.fillRect(x, y, size, size);493 context.translate(x + size / 2, y + size / 2);494 context.rotate(rotation * Math.PI / 2);495 context.translate(-size / 2, -size / 2);496 context.fillStyle = foreground;497 drawMotif(context, motif, size, cellRng);498 context.restore();499 }500501 function cadenceIndex(system, col, row) {502 if (system.grammar === 'weave') {503 return (row + (col % 2) * 2) % system.period;504 }505 if (system.grammar === 'blocks') {506 return (Math.floor(col / system.blockSize) + 2 * Math.floor(row / system.blockSize)) % system.period;507 }508 return (col + system.direction * row * 2 + system.period * 8) % system.period;509 }510511 function chooseForeground(system, backgroundIndex, cadence, rng) {512 const preferred = system.colorSequence[(cadence + 1) % system.colorSequence.length];513 if (preferred !== backgroundIndex && rng.bool(0.72)) return preferred;514 const candidates = [0, 1, 2, 3, 4].filter(index => index !== backgroundIndex);515 if (backgroundIndex >= 2 && backgroundIndex <= 4) {516 return rng.weighted([[0, 3], [1, 5]]);517 }518 return rng.pick(candidates);519 }520521 function drawMotif(context, motif, size, rng) {522 const half = size / 2;523 switch (motif) {524 case 'quarter':525 context.beginPath();526 context.arc(0, 0, size, 0, TAU);527 context.fill();528 break;529 case 'half':530 context.beginPath();531 context.arc(half, size, half, Math.PI, TAU);532 context.fill();533 break;534 case 'bar': {535 const width = size * rng.pick([0.25, 0.333333, 0.5]);536 context.fillRect((size - width) / 2, 0, width, size);537 break;538 }539 case 'diagonal':540 context.beginPath();541 context.moveTo(0, 0);542 context.lineTo(size, 0);543 context.lineTo(size, size);544 context.closePath();545 context.fill();546 break;547 case 'ring': {548 const ring = size * rng.pick([0.14, 0.18, 0.22]);549 context.beginPath();550 context.arc(half, half, size * 0.38, 0, TAU);551 context.arc(half, half, Math.max(1, size * 0.38 - ring), 0, TAU, true);552 context.fill('evenodd');553 break;554 }555 case 'inset': {556 const inset = size * rng.pick([0.18, 0.24, 0.3]);557 context.fillRect(inset, inset, size - inset * 2, size - inset * 2);558 break;559 }560 case 'field':561 context.fillRect(0, 0, size, size);562 break;563 case 'void':564 default:565 break;566 }567 }568569 function showToast(message) {570 window.clearTimeout(toastTimer);571 toast.textContent = message;572 toast.classList.add('visible');573 toastTimer = window.setTimeout(() => toast.classList.remove('visible'), 1800);574 }575576 async function copyPermanentLink() {577 const url = new URL(location.href);578 url.hash = encodeURIComponent(seed);579 history.replaceState(null, '', url);580581 try {582 await navigator.clipboard.writeText(url.href);583 } catch (_) {584 const input = document.createElement('textarea');585 input.value = url.href;586 input.setAttribute('readonly', '');587 input.style.position = 'fixed';588 input.style.opacity = '0';589 document.body.appendChild(input);590 input.select();591 document.execCommand('copy');592 input.remove();593 }594 showToast('Link copied · generation pinned');595 }596597 function downloadPng() {598 if (!renderInfo) return;599 canvas.toBlob(blob => {600 if (!blob) {601 showToast('Download unavailable');602 return;603 }604 const url = URL.createObjectURL(blob);605 const anchor = document.createElement('a');606 anchor.href = url;607 anchor.download = `bauhaus-pattern-${seed}-${renderInfo.pixelWidth}x${renderInfo.pixelHeight}.png`;608 document.body.appendChild(anchor);609 anchor.click();610 anchor.remove();611 window.setTimeout(() => URL.revokeObjectURL(url), 1000);612 showToast(`${renderInfo.pixelWidth} × ${renderInfo.pixelHeight} PNG`);613 }, 'image/png');614 }615616 function scheduleRender() {617 window.clearTimeout(resizeTimer);618 resizeTimer = window.setTimeout(render, 90);619 }620621 linkButton.addEventListener('click', copyPermanentLink);622 downloadButton.addEventListener('click', downloadPng);623 window.addEventListener('resize', scheduleRender, { passive: true });624 window.addEventListener('orientationchange', scheduleRender, { passive: true });625 window.addEventListener('hashchange', () => {626 const nextSeed = readSeedFromHash();627 if (nextSeed && nextSeed !== seed) {628 seed = nextSeed;629 render();630 }631 });632633 render();634 })();635 </script>636</body>637</html>