Trois templates HTML autonomes pour générer les assets visuels sans quitter le repo : - `docs/logo-export.html` — outil interactif d'export du wordmark Rubis.pro en PNG (Bricolage Grotesque). 3 layouts (horizontal, vertical, texte seul), 5 fonds, 5 couleurs, taille 120 → 4096 px, format carré. Preset 120 px pour le logo de consent screen Google OAuth. Le canvas attend `document.fonts.ready` avant rendu pour éviter le fallback sans-serif. - `docs/og-default-source.html` — recréation HTML pixel-perfect du `og-default.png` (1200×630) avec le nouveau wordmark `Rubis.pro` et `DSO*`. Export via Chrome DevTools "Capture node screenshot" pour remplacer `apps/landing/public/og-default.png`. - `docs/linkedin-launch-source.html` — image carrée 1200×1200 pour un post de lancement LinkedIn. Format optimal pour le feed mobile (LinkedIn n'crop pas les carrés). Headline + stat-héros + brand. Tous les gems sont des SVG inline (4 facettes + contour) — identité visuelle 1:1 avec `<Gem/>` du SPA et `favicon.svg`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
586 lines
20 KiB
HTML
586 lines
20 KiB
HTML
<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Logo export — Rubis.pro</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||
<style>
|
||
:root {
|
||
--rubis: #9F1239;
|
||
--rubis-deep: #771328;
|
||
--rubis-light: #C9415C;
|
||
--rubis-glow: #FBE4EA;
|
||
--cream: #FAF7F2;
|
||
--ink: #1A1410;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; padding: 0; }
|
||
body {
|
||
font-family: "Inter", system-ui, sans-serif;
|
||
background: var(--cream);
|
||
color: var(--ink);
|
||
min-height: 100vh;
|
||
padding: 32px;
|
||
}
|
||
.app {
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
gap: 32px;
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
h1 {
|
||
font-family: "Bricolage Grotesque", sans-serif;
|
||
font-weight: 700;
|
||
font-size: 28px;
|
||
margin: 0 0 4px;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.sub {
|
||
font-size: 13px;
|
||
color: #6b5f57;
|
||
margin-bottom: 24px;
|
||
}
|
||
.panel {
|
||
background: white;
|
||
border: 1px solid #e8e0d6;
|
||
border-radius: 16px;
|
||
padding: 20px;
|
||
}
|
||
.controls h2 {
|
||
font-family: "Bricolage Grotesque", sans-serif;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: #6b5f57;
|
||
margin: 0 0 8px;
|
||
font-weight: 600;
|
||
}
|
||
.group { margin-bottom: 20px; }
|
||
.group:last-child { margin-bottom: 0; }
|
||
.seg {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px;
|
||
}
|
||
.seg.three { grid-template-columns: repeat(3, 1fr); }
|
||
.seg button {
|
||
font-family: "Inter", sans-serif;
|
||
font-size: 13px;
|
||
padding: 8px 10px;
|
||
border: 1px solid #e8e0d6;
|
||
background: white;
|
||
color: var(--ink);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.12s;
|
||
}
|
||
.seg button:hover { border-color: var(--rubis-light); }
|
||
.seg button.active {
|
||
background: var(--rubis);
|
||
color: white;
|
||
border-color: var(--rubis);
|
||
}
|
||
.swatches {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 6px;
|
||
}
|
||
.swatches button {
|
||
aspect-ratio: 1;
|
||
border: 2px solid #e8e0d6;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.12s;
|
||
position: relative;
|
||
}
|
||
.swatches button.active {
|
||
border-color: var(--rubis);
|
||
box-shadow: 0 0 0 2px var(--rubis-glow);
|
||
}
|
||
.swatches button[data-bg="transparent"] {
|
||
background-image:
|
||
linear-gradient(45deg, #ddd 25%, transparent 25%),
|
||
linear-gradient(-45deg, #ddd 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #ddd 75%),
|
||
linear-gradient(-45deg, transparent 75%, #ddd 75%);
|
||
background-size: 8px 8px;
|
||
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
|
||
}
|
||
label {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 13px;
|
||
margin-bottom: 6px;
|
||
color: #6b5f57;
|
||
}
|
||
input[type="range"] {
|
||
width: 100%;
|
||
accent-color: var(--rubis);
|
||
}
|
||
input[type="text"] {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border: 1px solid #e8e0d6;
|
||
border-radius: 8px;
|
||
font-family: "Inter", sans-serif;
|
||
font-size: 13px;
|
||
}
|
||
input[type="text"]:focus {
|
||
outline: none;
|
||
border-color: var(--rubis);
|
||
box-shadow: 0 0 0 3px var(--rubis-glow);
|
||
}
|
||
.toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 10px;
|
||
background: var(--cream);
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.toggle input { margin: 0; accent-color: var(--rubis); }
|
||
.export {
|
||
width: 100%;
|
||
padding: 14px;
|
||
background: var(--rubis);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 12px;
|
||
font-family: "Bricolage Grotesque", sans-serif;
|
||
font-weight: 600;
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
margin-top: 8px;
|
||
}
|
||
.export:hover { background: var(--rubis-deep); }
|
||
|
||
.preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.preview-stage {
|
||
flex: 1;
|
||
min-height: 480px;
|
||
border: 1px dashed #d4cabe;
|
||
border-radius: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px;
|
||
background-image:
|
||
linear-gradient(45deg, #f0e9de 25%, transparent 25%),
|
||
linear-gradient(-45deg, #f0e9de 25%, transparent 25%),
|
||
linear-gradient(45deg, transparent 75%, #f0e9de 75%),
|
||
linear-gradient(-45deg, transparent 75%, #f0e9de 75%);
|
||
background-size: 16px 16px;
|
||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||
}
|
||
#stage canvas {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
box-shadow: 0 8px 30px rgba(26, 20, 16, 0.12);
|
||
border-radius: 4px;
|
||
}
|
||
.info {
|
||
font-size: 12px;
|
||
color: #6b5f57;
|
||
text-align: center;
|
||
}
|
||
.font-status {
|
||
font-size: 12px;
|
||
color: #6b5f57;
|
||
margin-top: 8px;
|
||
}
|
||
.font-status.loaded { color: #2d8a4e; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<aside class="controls">
|
||
<h1>Logo export</h1>
|
||
<p class="sub">Wordmark Rubis.pro · Bricolage Grotesque · PNG haute résolution</p>
|
||
|
||
<div class="panel">
|
||
<div class="group">
|
||
<h2>Texte</h2>
|
||
<input type="text" id="text" value="Rubis.pro" />
|
||
</div>
|
||
|
||
<div class="group">
|
||
<h2>Layout</h2>
|
||
<div class="seg three" id="layout">
|
||
<button data-layout="horizontal" class="active">Horizontal</button>
|
||
<button data-layout="vertical">Vertical</button>
|
||
<button data-layout="wordmark">Texte seul</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<h2>Couleur du logo</h2>
|
||
<div class="seg three" id="fg">
|
||
<button data-fg="rubis" class="active">Rubis</button>
|
||
<button data-fg="ink">Encre</button>
|
||
<button data-fg="cream">Crème</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<h2>Fond</h2>
|
||
<div class="swatches" id="bg">
|
||
<button data-bg="cream" style="background:#FAF7F2" class="active" title="Crème"></button>
|
||
<button data-bg="white" style="background:#ffffff" title="Blanc"></button>
|
||
<button data-bg="ink" style="background:#1A1410" title="Encre"></button>
|
||
<button data-bg="rubis" style="background:#9F1239" title="Rubis"></button>
|
||
<button data-bg="transparent" title="Transparent"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<h2>Graisse</h2>
|
||
<div class="seg three" id="weight">
|
||
<button data-weight="500">500</button>
|
||
<button data-weight="700" class="active">700</button>
|
||
<button data-weight="800">800</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<h2>Taille export</h2>
|
||
<div class="seg" id="size-presets" style="grid-template-columns: repeat(5, 1fr); margin-bottom: 8px;">
|
||
<button data-size="120" title="Google OAuth consent (120×120)">120</button>
|
||
<button data-size="512">512</button>
|
||
<button data-size="1024">1024</button>
|
||
<button data-size="2048" class="active">2048</button>
|
||
<button data-size="4096">4096</button>
|
||
</div>
|
||
<label><span>Côté</span><span id="size-val">2048 × 2048 px</span></label>
|
||
<input type="range" id="size" min="120" max="4096" step="8" value="2048" />
|
||
</div>
|
||
|
||
<div class="group">
|
||
<label class="toggle">
|
||
<span>Padding interne</span>
|
||
<input type="checkbox" id="padding" checked />
|
||
</label>
|
||
</div>
|
||
|
||
<button class="export" id="export-btn">Télécharger en PNG</button>
|
||
<p class="font-status" id="font-status">Chargement de la police…</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="preview">
|
||
<div class="preview-stage">
|
||
<div id="stage"></div>
|
||
</div>
|
||
<p class="info" id="info"></p>
|
||
</main>
|
||
</div>
|
||
|
||
<script>
|
||
// --- State ---
|
||
const state = {
|
||
text: "Rubis.pro",
|
||
layout: "horizontal",
|
||
fg: "rubis",
|
||
bg: "cream",
|
||
weight: 700,
|
||
size: 2048,
|
||
padding: true,
|
||
};
|
||
|
||
const COLORS = {
|
||
rubis: "#9F1239",
|
||
"rubis-deep": "#771328",
|
||
"rubis-light": "#C9415C",
|
||
ink: "#1A1410",
|
||
cream: "#FAF7F2",
|
||
white: "#ffffff",
|
||
};
|
||
|
||
// --- Drawing ---
|
||
/**
|
||
* Dessine le gem facetté (4 facettes + contour) à la position (x, y) avec
|
||
* la taille `size` et la couleur `color`. Reproduit exactement <Gem/> du
|
||
* SPA / favicon.svg, mais en canvas — garantit l'identité visuelle 1:1.
|
||
*/
|
||
function drawGem(ctx, x, y, size, color) {
|
||
const s = size / 88; // viewBox interne 88x88 (de 6 à 94)
|
||
const cx = x + size / 2;
|
||
const cy = y + size / 2;
|
||
const tx = (px) => cx + (px - 50) * s;
|
||
const ty = (py) => cy + (py - 50) * s;
|
||
|
||
const facets = [
|
||
{ pts: [[50, 6], [6, 50], [50, 50]], alpha: 1.0 },
|
||
{ pts: [[50, 6], [94, 50], [50, 50]], alpha: 0.8 },
|
||
{ pts: [[6, 50], [50, 50], [50, 94]], alpha: 0.65 },
|
||
{ pts: [[50, 50], [94, 50], [50, 94]], alpha: 0.48 },
|
||
];
|
||
for (const { pts, alpha } of facets) {
|
||
ctx.globalAlpha = alpha;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx(pts[0][0]), ty(pts[0][1]));
|
||
ctx.lineTo(tx(pts[1][0]), ty(pts[1][1]));
|
||
ctx.lineTo(tx(pts[2][0]), ty(pts[2][1]));
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = Math.max(1.6 * s, 1);
|
||
ctx.lineJoin = "round";
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx(50), ty(6));
|
||
ctx.lineTo(tx(94), ty(50));
|
||
ctx.lineTo(tx(50), ty(94));
|
||
ctx.lineTo(tx(6), ty(50));
|
||
ctx.closePath();
|
||
ctx.stroke();
|
||
}
|
||
|
||
/**
|
||
* Construit un canvas avec le logo selon le state courant.
|
||
* Renvoie { canvas, width, height } — le canvas est dimensionné pour matcher
|
||
* le contenu (avec padding optionnel), pas forcé carré.
|
||
*/
|
||
function renderLogo(opts = {}) {
|
||
const exportSize = opts.size ?? state.size;
|
||
const dpr = opts.dpr ?? 1;
|
||
const fgColor = COLORS[state.fg];
|
||
const bgColor = state.bg === "transparent" ? null : COLORS[state.bg];
|
||
|
||
// Le wordmark est dimensionné en fonction de la largeur cible.
|
||
// Bricolage Grotesque a une cap-height ~0.72 et un descender ~0.22.
|
||
// Pour un rendu propre, on calcule d'abord la métrique du texte puis
|
||
// on positionne tout autour.
|
||
|
||
// Off-screen mesure
|
||
const measure = document.createElement("canvas").getContext("2d");
|
||
|
||
// En layout "wordmark" pur, le texte fait ~80% de la largeur.
|
||
// En "horizontal", gem prend ~0.18 de la largeur, gap ~0.05, texte le reste.
|
||
// En "vertical", gem au-dessus, texte en-dessous.
|
||
|
||
const padding = state.padding ? exportSize * 0.08 : 0;
|
||
|
||
let textHeightRatio; // hauteur du texte / hauteur totale du contenu
|
||
let gemSize, gap, contentW, contentH;
|
||
let fontSize;
|
||
|
||
if (state.layout === "wordmark") {
|
||
// Texte seul : on calibre la fontSize pour que le texte rendu fasse
|
||
// ~exportSize - 2*padding de large.
|
||
const targetW = exportSize - 2 * padding;
|
||
fontSize = 100;
|
||
measure.font = `${state.weight} ${fontSize}px "Bricolage Grotesque", sans-serif`;
|
||
const w0 = measure.measureText(state.text).width;
|
||
fontSize = (targetW / w0) * fontSize;
|
||
measure.font = `${state.weight} ${fontSize}px "Bricolage Grotesque", sans-serif`;
|
||
const m = measure.measureText(state.text);
|
||
const ascent = m.actualBoundingBoxAscent || fontSize * 0.75;
|
||
const descent = m.actualBoundingBoxDescent || fontSize * 0.22;
|
||
contentW = m.width;
|
||
contentH = ascent + descent;
|
||
gemSize = 0;
|
||
gap = 0;
|
||
} else if (state.layout === "horizontal") {
|
||
// Gem + texte côte à côte. Hauteur du gem = hauteur du texte (cap-height-ish).
|
||
// On vise une largeur totale = exportSize - 2*padding.
|
||
const targetW = exportSize - 2 * padding;
|
||
// Premier passage : on suppose gem = hauteur du texte ≈ 0.78 * fontSize.
|
||
// gap = 0.18 * gemSize, contentW = gemSize + gap + textW.
|
||
// On calibre fontSize pour que tout ça fasse targetW.
|
||
fontSize = 100;
|
||
measure.font = `${state.weight} ${fontSize}px "Bricolage Grotesque", sans-serif`;
|
||
const w0 = measure.measureText(state.text).width;
|
||
// gem ≈ 0.78 * fontSize (proche de la cap-height)
|
||
// gap ≈ 0.22 * gem
|
||
const gemRatio = 0.85;
|
||
const gapRatio = 0.18;
|
||
const totalAtFs100 = gemRatio * fontSize + gapRatio * gemRatio * fontSize + w0;
|
||
fontSize = (targetW / totalAtFs100) * fontSize;
|
||
measure.font = `${state.weight} ${fontSize}px "Bricolage Grotesque", sans-serif`;
|
||
const m = measure.measureText(state.text);
|
||
const ascent = m.actualBoundingBoxAscent || fontSize * 0.75;
|
||
const descent = m.actualBoundingBoxDescent || fontSize * 0.22;
|
||
gemSize = gemRatio * fontSize;
|
||
gap = gapRatio * gemSize;
|
||
contentW = gemSize + gap + m.width;
|
||
contentH = Math.max(gemSize, ascent + descent);
|
||
} else {
|
||
// vertical : gem au-dessus, texte en-dessous, centrés horizontalement
|
||
const targetW = exportSize - 2 * padding;
|
||
fontSize = 100;
|
||
measure.font = `${state.weight} ${fontSize}px "Bricolage Grotesque", sans-serif`;
|
||
const w0 = measure.measureText(state.text).width;
|
||
fontSize = (targetW / w0) * fontSize;
|
||
measure.font = `${state.weight} ${fontSize}px "Bricolage Grotesque", sans-serif`;
|
||
const m = measure.measureText(state.text);
|
||
const ascent = m.actualBoundingBoxAscent || fontSize * 0.75;
|
||
const descent = m.actualBoundingBoxDescent || fontSize * 0.22;
|
||
gemSize = m.width * 0.42; // gem proportionné au texte
|
||
gap = gemSize * 0.25;
|
||
contentW = m.width;
|
||
contentH = gemSize + gap + ascent + descent;
|
||
}
|
||
|
||
// Canvas final : carré exportSize × exportSize, contenu centré.
|
||
const totalW = exportSize;
|
||
const totalH = exportSize;
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.round(totalW * dpr);
|
||
canvas.height = Math.round(totalH * dpr);
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.scale(dpr, dpr);
|
||
|
||
// Fond
|
||
if (bgColor) {
|
||
ctx.fillStyle = bgColor;
|
||
ctx.fillRect(0, 0, totalW, totalH);
|
||
}
|
||
|
||
// Dessin selon layout
|
||
ctx.fillStyle = fgColor;
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.font = `${state.weight} ${fontSize}px "Bricolage Grotesque", sans-serif`;
|
||
|
||
if (state.layout === "wordmark") {
|
||
const m = ctx.measureText(state.text);
|
||
const ascent = m.actualBoundingBoxAscent || fontSize * 0.75;
|
||
const descent = m.actualBoundingBoxDescent || fontSize * 0.22;
|
||
const x = (totalW - contentW) / 2;
|
||
const y = totalH / 2 - (ascent + descent) / 2 + ascent;
|
||
ctx.fillText(state.text, x, y);
|
||
} else if (state.layout === "horizontal") {
|
||
const m = ctx.measureText(state.text);
|
||
const ascent = m.actualBoundingBoxAscent || fontSize * 0.75;
|
||
const descent = m.actualBoundingBoxDescent || fontSize * 0.22;
|
||
const textH = ascent + descent;
|
||
const startX = (totalW - contentW) / 2;
|
||
const centerY = totalH / 2;
|
||
// Gem centré verticalement
|
||
const gemY = centerY - gemSize / 2;
|
||
drawGem(ctx, startX, gemY, gemSize, fgColor);
|
||
// Texte aligné sur la baseline → on positionne pour que le centre vertical du texte = centerY
|
||
const textY = centerY - (ascent + descent) / 2 + ascent;
|
||
ctx.fillStyle = fgColor;
|
||
ctx.fillText(state.text, startX + gemSize + gap, textY);
|
||
} else {
|
||
// vertical
|
||
const m = ctx.measureText(state.text);
|
||
const ascent = m.actualBoundingBoxAscent || fontSize * 0.75;
|
||
const gemX = (totalW - gemSize) / 2;
|
||
const gemY = (totalH - contentH) / 2;
|
||
drawGem(ctx, gemX, gemY, gemSize, fgColor);
|
||
ctx.fillStyle = fgColor;
|
||
const textX = (totalW - m.width) / 2;
|
||
const textY = gemY + gemSize + gap + ascent;
|
||
ctx.fillText(state.text, textX, textY);
|
||
}
|
||
|
||
return { canvas, width: totalW, height: totalH };
|
||
}
|
||
|
||
// --- Preview ---
|
||
function updatePreview() {
|
||
const stage = document.getElementById("stage");
|
||
stage.innerHTML = "";
|
||
// Preview = mêmes proportions, taille bornée pour l'affichage
|
||
const previewSize = Math.min(state.size, 1024);
|
||
const { canvas, width, height } = renderLogo({ size: previewSize, dpr: 2 });
|
||
// Cadre transparent visible si bg = transparent
|
||
stage.appendChild(canvas);
|
||
document.getElementById("info").textContent =
|
||
`Export : ${state.size} × ${Math.round((height / width) * state.size)} px`;
|
||
}
|
||
|
||
// --- Export ---
|
||
function exportPng() {
|
||
const { canvas } = renderLogo({ size: state.size, dpr: 1 });
|
||
const variant = [state.layout, state.fg, state.bg, `${state.size}px`].join("-");
|
||
const filename = `rubis-logo-${variant}.png`;
|
||
canvas.toBlob((blob) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}, "image/png");
|
||
}
|
||
|
||
// --- Wiring ---
|
||
function wireSegmented(containerId, key, type = "string") {
|
||
const container = document.getElementById(containerId);
|
||
container.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("button");
|
||
if (!btn) return;
|
||
container.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
const val = btn.dataset[key];
|
||
state[key] = type === "number" ? Number(val) : val;
|
||
updatePreview();
|
||
});
|
||
}
|
||
|
||
document.getElementById("text").addEventListener("input", (e) => {
|
||
state.text = e.target.value || "Rubis.pro";
|
||
updatePreview();
|
||
});
|
||
wireSegmented("layout", "layout");
|
||
wireSegmented("fg", "fg");
|
||
wireSegmented("bg", "bg");
|
||
wireSegmented("weight", "weight", "number");
|
||
|
||
function setSize(value) {
|
||
state.size = Number(value);
|
||
document.getElementById("size").value = state.size;
|
||
document.getElementById("size-val").textContent = `${state.size} × ${state.size} px`;
|
||
const presets = document.getElementById("size-presets");
|
||
presets.querySelectorAll("button").forEach((b) => {
|
||
b.classList.toggle("active", Number(b.dataset.size) === state.size);
|
||
});
|
||
updatePreview();
|
||
}
|
||
document.getElementById("size").addEventListener("input", (e) => setSize(e.target.value));
|
||
document.getElementById("size-presets").addEventListener("click", (e) => {
|
||
const btn = e.target.closest("button");
|
||
if (btn) setSize(btn.dataset.size);
|
||
});
|
||
document.getElementById("padding").addEventListener("change", (e) => {
|
||
state.padding = e.target.checked;
|
||
updatePreview();
|
||
});
|
||
document.getElementById("export-btn").addEventListener("click", exportPng);
|
||
|
||
// --- Font loading ---
|
||
// On attend que Bricolage Grotesque soit chargée avant de rendre le canvas,
|
||
// sinon le navigateur fallback en sans-serif et le rendu est faux.
|
||
const status = document.getElementById("font-status");
|
||
Promise.all([
|
||
document.fonts.load('700 100px "Bricolage Grotesque"'),
|
||
document.fonts.load('800 100px "Bricolage Grotesque"'),
|
||
document.fonts.load('500 100px "Bricolage Grotesque"'),
|
||
]).then(() => {
|
||
status.textContent = "Police chargée — prêt à exporter.";
|
||
status.classList.add("loaded");
|
||
updatePreview();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|