Premier jet : gabarit SVG et rendu PNG

This commit is contained in:
Yasser Boulayoune 2026-05-12 10:12:00 +02:00
commit 81fd9dd717
5 changed files with 761 additions and 0 deletions

16
README.md Normal file
View file

@ -0,0 +1,16 @@
# card-generator
Petite app web auto-hébergée qui produit les visuels (featured images) du portfolio : SVG rendu en PNG avec librsvg.
## Stack
- Python 3 (serveur HTTP de la bibliothèque standard)
- JavaScript vanilla pour l'aperçu en direct
- librsvg (`rsvg-convert`) pour le rendu PNG
- Polices Fraunces, Syne et DM Sans
## Installation
```
python3 server.py # puis http://localhost:6789
```

197
static/app.js Normal file
View file

@ -0,0 +1,197 @@
/* Card Generator — frontend */
const $ = (id) => document.getElementById(id);
const preview = $("preview-frame");
const status = $("status");
let state = {
eyebrow: "RÉALISATION · SYMFONY",
title: "pokedwwmm",
title_2: "",
tagline: "151 Pokémon · Twig Components · JSON",
accent: "#ef4444",
logo_color:"#ef4444",
logo_kind: "preset", // preset | upload | text | none
logo_value:"symfony",
logo_size: 260,
};
let renderTimer = null;
let logoLibrary = [];
/* ── Wire up form ──────────────────────────────────────────────── */
const inputBindings = {
eyebrow: "eyebrow",
title: "title",
title_2: "title_2",
tagline: "tagline",
accent: "accent",
logo_color: "logo_color",
};
for (const [stateKey, inputId] of Object.entries(inputBindings)) {
$(inputId).addEventListener("input", (e) => {
state[stateKey] = e.target.value;
schedule();
});
}
$("logo_size").addEventListener("input", (e) => {
state.logo_size = +e.target.value;
$("logo_size_val").value = `${state.logo_size}px`;
schedule();
});
/* Swatches */
document.querySelectorAll(".swatch").forEach(btn => {
const c = btn.dataset.color;
btn.style.background = c;
btn.addEventListener("click", () => {
state.accent = c;
state.logo_color = c;
$("accent").value = c;
$("logo_color").value = c;
schedule();
});
});
/* Segmented control: logo source */
document.querySelectorAll(".seg-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".seg-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.logo_kind = btn.dataset.kind;
document.querySelectorAll(".logo-source").forEach(el => { el.hidden = true; });
if (state.logo_kind === "preset") document.querySelector(".preset-source").hidden = false;
if (state.logo_kind === "upload") document.querySelector(".upload-source").hidden = false;
if (state.logo_kind === "text") document.querySelector(".text-source").hidden = false;
schedule();
});
});
/* Text logo input */
$("logo_text").addEventListener("input", (e) => {
if (state.logo_kind === "text") state.logo_value = e.target.value;
schedule();
});
/* Upload */
$("logo_upload").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const dataUrl = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(file);
});
state.logo_kind = "upload";
state.logo_value = dataUrl;
const preview = $("upload_preview");
preview.hidden = false;
preview.innerHTML = `<img src="${dataUrl}" alt="">`;
schedule();
});
/* ── Logo library ──────────────────────────────────────────────── */
async function loadLogoLibrary() {
const res = await fetch("/api/logos");
logoLibrary = await res.json();
const grid = $("logo_grid");
grid.innerHTML = "";
for (const slug of logoLibrary) {
const tile = document.createElement("div");
tile.className = "logo-tile";
tile.title = slug;
if (slug === state.logo_value) tile.classList.add("selected");
tile.dataset.slug = slug;
const svgRes = await fetch(`/logos/${slug}.svg`);
tile.innerHTML = await svgRes.text();
tile.addEventListener("click", () => {
grid.querySelectorAll(".logo-tile").forEach(t => t.classList.remove("selected"));
tile.classList.add("selected");
state.logo_kind = "preset";
state.logo_value = slug;
// Also update segmented control
document.querySelectorAll(".seg-btn").forEach(b =>
b.classList.toggle("active", b.dataset.kind === "preset"));
document.querySelectorAll(".logo-source").forEach(el => { el.hidden = true; });
document.querySelector(".preset-source").hidden = false;
schedule();
});
grid.appendChild(tile);
}
}
/* ── Render pipeline ───────────────────────────────────────────── */
function schedule() {
if (renderTimer) clearTimeout(renderTimer);
status.textContent = "rendu…"; status.className = "status working";
renderTimer = setTimeout(render, 120);
}
async function render() {
try {
const res = await fetch("/api/svg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const svgText = await res.text();
preview.innerHTML = svgText;
status.textContent = "à jour"; status.className = "status ok";
} catch (e) {
console.error(e);
status.textContent = "erreur"; status.className = "status err";
}
}
/* ── Download buttons ──────────────────────────────────────────── */
$("btn-svg").addEventListener("click", async () => {
status.textContent = "génération SVG…"; status.className = "status working";
const res = await fetch("/api/svg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state),
});
const blob = await res.blob();
downloadBlob(blob, `${$("filename").value || "card"}.svg`);
status.textContent = "SVG téléchargé"; status.className = "status ok";
});
$("btn-png").addEventListener("click", async () => {
status.textContent = "rendu PNG (rsvg-convert)…"; status.className = "status working";
const width = +$("export_width").value;
const height = Math.round(width / 1.6);
try {
const res = await fetch("/api/png", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...state, export_width: width, export_height: height,
filename: $("filename").value || "card" }),
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
downloadBlob(blob, `${$("filename").value || "card"}.png`);
status.textContent = `PNG ${width}×${height} téléchargé`; status.className = "status ok";
} catch (e) {
console.error(e);
status.textContent = "erreur PNG"; status.className = "status err";
}
});
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
/* ── Init ──────────────────────────────────────────────────────── */
loadLogoLibrary().then(render);

4
static/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0a0a0e"/>
<path fill="#ef4444" d="M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368-38.1 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z" transform="translate(0, -10)"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

163
static/index.html Normal file
View file

@ -0,0 +1,163 @@
<!doctype html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="googlebot" content="noindex, nofollow">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<title>Card Generator · Yasder</title>
<link rel="stylesheet" href="/static/style.css">
<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=Fraunces:opsz,wght@9..144,400;9..144,700&family=Syne:wght@400;600&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<header class="app-header">
<div class="brand">
<svg class="brand-logo" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368 -38.1 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"/>
</svg>
<span class="brand-mark">YB<span class="dot">.</span></span>
<span class="brand-name">Card Generator</span>
</div>
<div class="brand-tagline">Mêmes cartes que sur yasser.boulayoune.com · Fraunces / Syne / DM Sans</div>
</header>
<main class="app">
<!-- LEFT: form -->
<aside class="panel form-panel">
<h2>Contenu</h2>
<div class="field">
<label for="eyebrow">Eyebrow <span class="hint">(petit titre en haut, en majuscules)</span></label>
<input id="eyebrow" type="text" value="RÉALISATION · SYMFONY" maxlength="60">
</div>
<div class="field">
<label for="title">Titre (ligne 1)</label>
<input id="title" type="text" value="pokedwwmm" maxlength="40">
</div>
<div class="field">
<label for="title_2">Titre ligne 2 <span class="hint">(optionnel · pour titre sur 2 lignes)</span></label>
<input id="title_2" type="text" value="" maxlength="40">
</div>
<div class="field">
<label for="tagline">Tagline <span class="hint">(stack, mots-clés)</span></label>
<input id="tagline" type="text" value="151 Pokémon · Twig Components · JSON" maxlength="80">
</div>
<h2>Couleurs</h2>
<div class="field row">
<div class="row-col">
<label for="accent">Couleur d'accent</label>
<input id="accent" type="color" value="#ef4444">
</div>
<div class="row-col">
<label for="logo_color">Couleur du logo <span class="hint">(SVG icônes uniquement)</span></label>
<input id="logo_color" type="color" value="#ef4444">
</div>
</div>
<div class="field">
<label>Palettes rapides</label>
<div class="swatches">
<button type="button" class="swatch" data-color="#ef4444" title="Rouge Symfony"></button>
<button type="button" class="swatch" data-color="#5865f2" title="Discord violet"></button>
<button type="button" class="swatch" data-color="#21759b" title="WordPress bleu"></button>
<button type="button" class="swatch" data-color="#a855f7" title="Violet"></button>
<button type="button" class="swatch" data-color="#22c55e" title="Vert Node"></button>
<button type="button" class="swatch" data-color="#fb923c" title="Orange Forgejo"></button>
<button type="button" class="swatch" data-color="#3b82f6" title="Bleu"></button>
<button type="button" class="swatch" data-color="#06b6d4" title="Cyan"></button>
<button type="button" class="swatch" data-color="#f5f5f7" title="Blanc"></button>
<button type="button" class="swatch" data-color="#a8e063" title="Lime"></button>
</div>
</div>
<h2>Logo</h2>
<div class="field">
<label>Source</label>
<div class="seg">
<button type="button" class="seg-btn active" data-kind="preset">Bibliothèque</button>
<button type="button" class="seg-btn" data-kind="upload">Upload PNG</button>
<button type="button" class="seg-btn" data-kind="text">Texte</button>
<button type="button" class="seg-btn" data-kind="none">Aucun</button>
</div>
</div>
<div class="field logo-source preset-source">
<label for="logo_preset">Icône (simpleicons + FontAwesome)</label>
<div class="logo-grid" id="logo_grid"></div>
</div>
<div class="field logo-source upload-source" hidden>
<label for="logo_upload">Upload une image (PNG transparent recommandé, ou SVG)</label>
<input id="logo_upload" type="file" accept="image/png,image/svg+xml,image/webp,image/jpeg">
<div id="upload_preview" class="upload-preview" hidden></div>
</div>
<div class="field logo-source text-source" hidden>
<label for="logo_text">Texte du logo <span class="hint">(monogramme · ex : YB.)</span></label>
<input id="logo_text" type="text" value="YB." maxlength="6">
</div>
<div class="field">
<label for="logo_size">Taille du logo <output id="logo_size_val">260px</output></label>
<input id="logo_size" type="range" min="120" max="380" step="10" value="260">
</div>
<h2>Export</h2>
<div class="field">
<label for="filename">Nom du fichier</label>
<input id="filename" type="text" value="card" maxlength="60">
</div>
<div class="field row">
<div class="row-col">
<label for="export_width">Largeur PNG</label>
<select id="export_width">
<option value="800">800 × 500 (1×)</option>
<option value="1600" selected>1600 × 1000 (2×, retina)</option>
<option value="2400">2400 × 1500 (3×)</option>
</select>
</div>
</div>
<div class="actions">
<button id="btn-png" type="button" class="btn btn-primary">⬇ Télécharger PNG</button>
<button id="btn-svg" type="button" class="btn">⬇ Télécharger SVG</button>
</div>
</aside>
<!-- RIGHT: preview -->
<section class="panel preview-panel">
<header class="preview-header">
<h2>Aperçu</h2>
<div class="zoom-info">800 × 500 · preview live</div>
</header>
<div class="preview-wrap">
<div id="preview-frame">
<!-- SVG is injected here -->
</div>
</div>
<footer class="preview-footer">
<span id="status" class="status">prêt</span>
</footer>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

381
static/style.css Normal file
View file

@ -0,0 +1,381 @@
/* ─── Reset + tokens ────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
:root {
--bg: #0a0a0e;
--surface: #14141a;
--surface-2: #1a1a22;
--line: #26262f;
--t1: #f5f5f7;
--t2: #c8c8d0;
--t3: #8a8a92;
--accent: #ef4444;
--accent-soft: #ef444433;
--radius: 12px;
--radius-sm: 6px;
--serif: 'Fraunces', Georgia, serif;
--sans: 'Syne', system-ui, sans-serif;
--body: 'DM Sans', system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
}
body {
font-family: var(--body);
background: radial-gradient(circle at 10% -10%, #1a1a22, var(--bg) 60%);
color: var(--t1);
min-height: 100vh;
font-size: 15px;
line-height: 1.5;
}
/* ─── Header ────────────────────────────────────────────────────── */
.app-header {
display: flex; align-items: baseline; justify-content: space-between;
gap: 24px;
padding: 24px 32px;
border-bottom: 1px solid var(--line);
background: rgba(10, 10, 14, 0.6);
backdrop-filter: blur(8px);
}
.brand {
display: flex; align-items: center; gap: 14px;
}
.brand-logo {
width: 36px; height: 36px;
fill: var(--accent);
filter: drop-shadow(0 4px 12px rgba(239, 68, 68, 0.35));
}
.brand-mark {
font-family: var(--serif); font-weight: 700; font-size: 28px;
letter-spacing: -0.02em;
line-height: 1;
}
.brand-mark .dot { color: var(--accent); }
.brand-name {
font-family: var(--sans); font-weight: 600; font-size: 14px;
letter-spacing: 0.18em; text-transform: uppercase; color: var(--t2);
}
.brand-tagline {
font-size: 13px; color: var(--t3); font-family: var(--mono);
}
/* ─── Layout ────────────────────────────────────────────────────── */
.app {
display: grid;
grid-template-columns: 420px 1fr;
gap: 24px;
padding: 24px 32px 48px;
align-items: start;
max-width: 1600px;
margin: 0 auto;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 24px;
}
.form-panel {
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
.form-panel h2 {
font-family: var(--sans);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--t3);
margin: 24px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.form-panel h2:first-child { margin-top: 0; }
/* ─── Form fields ───────────────────────────────────────────────── */
.field { margin-bottom: 16px; }
.field label {
display: block; margin-bottom: 6px;
font-size: 13px; font-weight: 500; color: var(--t2);
}
.field .hint {
font-size: 11px; color: var(--t3); font-weight: 400;
margin-left: 4px;
}
.field.row {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
}
.field.row .row-col { margin: 0; }
input[type="text"], input[type="file"], select {
width: 100%;
background: var(--bg);
color: var(--t1);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 9px 12px;
font-family: var(--body);
font-size: 14px;
transition: border-color .15s;
}
input[type="text"]:focus, select:focus, input[type="file"]:focus {
outline: none;
border-color: var(--accent);
}
input[type="color"] {
width: 100%; height: 42px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
cursor: pointer;
padding: 4px;
}
input[type="range"] {
width: 100%; accent-color: var(--accent);
}
output {
float: right; font-family: var(--mono); font-size: 12px; color: var(--t3);
}
/* Swatches */
.swatches {
display: flex; flex-wrap: wrap; gap: 8px;
}
.swatch {
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid var(--line);
background: var(--color);
cursor: pointer;
transition: transform .12s, border-color .12s;
}
.swatch:hover { transform: scale(1.1); border-color: var(--t2); }
/* Segmented control */
.seg {
display: grid;
grid-template-columns: repeat(4, 1fr);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 3px;
gap: 2px;
}
.seg-btn {
background: transparent;
color: var(--t3);
border: 0;
padding: 8px 6px;
border-radius: 4px;
font-family: var(--body);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background .15s, color .15s;
}
.seg-btn:hover { color: var(--t2); }
.seg-btn.active {
background: var(--accent);
color: white;
}
/* Logo grid */
.logo-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
max-height: 200px;
overflow-y: auto;
padding: 6px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.logo-tile {
aspect-ratio: 1;
background: var(--surface-2);
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
padding: 6px;
transition: border-color .12s, background .12s;
}
.logo-tile:hover { background: var(--surface); border-color: var(--t3); }
.logo-tile.selected { border-color: var(--accent); background: var(--surface); }
.logo-tile svg { width: 100%; height: 100%; fill: var(--t1); }
/* Upload preview */
.upload-preview {
margin-top: 8px;
padding: 12px;
background: var(--bg);
border: 1px dashed var(--line);
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
min-height: 80px;
}
.upload-preview img { max-width: 100%; max-height: 80px; }
/* Actions */
.actions {
display: flex; gap: 10px; margin-top: 20px;
}
.btn {
flex: 1;
background: var(--surface-2);
color: var(--t1);
border: 1px solid var(--line);
padding: 12px 16px;
border-radius: var(--radius-sm);
font-family: var(--body);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background .15s, border-color .15s, transform .08s;
}
.btn:hover { background: var(--line); border-color: var(--t3); }
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.btn-primary:hover {
background: #f87171;
border-color: #f87171;
}
/* ─── Preview ───────────────────────────────────────────────────── */
.preview-panel { padding: 16px; }
.preview-header {
display: flex; align-items: baseline; justify-content: space-between;
padding: 8px 8px 16px;
}
.preview-header h2 {
font-family: var(--sans); font-weight: 600;
font-size: 11px; text-transform: uppercase; letter-spacing: 0.22em;
color: var(--t3); margin: 0;
}
.zoom-info {
font-family: var(--mono); font-size: 11px; color: var(--t3);
}
.preview-wrap {
background: repeating-conic-gradient(#0c0c10 0% 25%, #16161e 0% 50%) 50% / 24px 24px;
border-radius: var(--radius-sm);
padding: 24px;
display: flex; align-items: center; justify-content: center;
min-height: 540px;
}
#preview-frame {
width: 800px; max-width: 100%;
aspect-ratio: 8/5;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 30px 80px rgba(0,0,0,0.5);
}
#preview-frame svg {
width: 100%; height: 100%;
display: block;
}
.preview-footer {
padding: 16px 8px 4px;
display: flex; justify-content: flex-end;
}
.status {
font-family: var(--mono); font-size: 11px;
color: var(--t3);
}
.status.working { color: #f59e0b; }
.status.ok { color: #22c55e; }
.status.err { color: var(--accent); }
/* ─── Responsive ────────────────────────────────────────────────── */
/* Tablette large : on empile form + preview, le form reste large */
@media (max-width: 1080px) {
.app {
grid-template-columns: 1fr;
padding: 20px 24px 40px;
gap: 20px;
}
.form-panel {
position: static;
max-height: none;
overflow-y: visible;
}
.preview-wrap { padding: 16px; min-height: 0; }
#preview-frame { width: 100%; max-width: 800px; margin: 0 auto; }
}
/* Tablette : header passe sur 2 lignes, paddings réduits */
@media (max-width: 720px) {
.app-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 18px 20px;
}
.brand { gap: 10px; }
.brand-logo { width: 28px; height: 28px; }
.brand-mark { font-size: 22px; }
.brand-name { font-size: 12px; letter-spacing: 0.14em; }
.brand-tagline { font-size: 12px; }
.app {
padding: 16px 16px 32px;
gap: 16px;
}
.panel { padding: 18px; }
.preview-panel { padding: 12px; }
.preview-wrap { padding: 10px; }
/* Grille des logos plus aérée */
.logo-grid { grid-template-columns: repeat(5, 1fr); max-height: 240px; }
/* Le segmented control reste sur une ligne avec un texte plus court */
.seg-btn { padding: 8px 4px; font-size: 11px; }
}
/* Mobile : on passe en monocolonne pour la ligne couleurs, output sur sa propre ligne */
@media (max-width: 520px) {
.app-header { padding: 14px 16px; }
.brand-tagline { display: none; }
.app {
padding: 12px 12px 28px;
gap: 12px;
}
.panel { padding: 14px; border-radius: 10px; }
.preview-panel { padding: 10px; }
.preview-wrap {
padding: 8px;
background-size: 16px 16px;
}
.field.row { grid-template-columns: 1fr; gap: 14px; }
output { float: none; display: block; margin-top: 4px; }
/* Grille logos : 4 col pour des tuiles plus tappables */
.logo-grid { grid-template-columns: repeat(4, 1fr); gap: 8px; }
.logo-tile { padding: 10px; }
/* Boutons d'export empilés pour qu'ils restent lisibles */
.actions { flex-direction: column; }
.btn { width: 100%; padding: 14px 16px; }
/* Swatches plus gros, plus faciles au doigt */
.swatch { width: 32px; height: 32px; }
.preview-header { flex-direction: column; align-items: flex-start; gap: 4px; }
}
/* Très petits écrans : on tasse encore un peu */
@media (max-width: 360px) {
.seg { grid-template-columns: repeat(2, 1fr); }
.logo-grid { grid-template-columns: repeat(3, 1fr); }
}