rubis/docs/logo-export.html
ordinarthur 642747d762
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 37s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m10s
docs(marketing): outils d'export logo + OG image + image LinkedIn
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>
2026-05-10 18:29:44 +02:00

586 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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