diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..05dae97 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,38 @@ +name: Deploy GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Package site + run: | + mkdir -p _site + cp index.html 404.html styles.css app.js favicon.svg .nojekyll _site/ + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/404.html b/404.html new file mode 100644 index 0000000..0dea96a --- /dev/null +++ b/404.html @@ -0,0 +1,12 @@ + + + + + + Page introuvable + + + +

Retour au guide

+ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1a85cd --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Est-ce vibecodé ? + +Petit guide statique pour reconnaître le code écrit à l’instinct (souvent avec une IA) : **huit signes**, des **exemples concrets**, un **détecteur noté sur 100**. + +Aucun build. Ouvre `index.html` en local, ou publie le dépôt sur GitHub Pages. + +## Lancer en local + +Ouvre le fichier, ou sers le dossier : + +```bash +python3 -m http.server 8080 +``` + +Puis va sur `http://localhost:8080`. + +## Publier sur GitHub Pages + +### Option A — depuis la branche (la plus simple) + +1. Fusionne ce travail dans `main`. +2. Dépôt → **Settings** → **Pages**. +3. **Source** : *Deploy from a branch*. +4. Branch `main`, dossier `/ (root)`, **Save**. +5. Le site apparaît sur `https://.github.io/guide_vibecode/` après quelques minutes. + +### Option B — GitHub Actions + +1. Settings → **Pages** → **Source** : *GitHub Actions*. +2. Le workflow `.github/workflows/pages.yml` publie la racine à chaque push sur `main`. + +Le fichier `.nojekyll` empêche GitHub de traiter le site avec Jekyll. + +## Contenu + +| Fichier | Rôle | +| --- | --- | +| `index.html` | Guide + détecteur | +| `styles.css` | Mise en page | +| `app.js` | Score et verdict | +| `404.html` | Retour à l’accueil | + +Licence MIT. diff --git a/app.js b/app.js new file mode 100644 index 0000000..1484437 --- /dev/null +++ b/app.js @@ -0,0 +1,188 @@ +const SIGNS = [ + { + id: "comments", + title: "Le tutoriel dans le code", + hint: "Chaque ligne a son commentaire d’évidence.", + }, + { + id: "names", + title: "Les noms jetables", + hint: "data, item, result, obj, temp, value…", + }, + { + id: "catchall", + title: "L’erreur de décoration", + hint: "try/catch qui loggue, avale, ou dit « handle error ».", + }, + { + id: "overkill", + title: "L’usine à gaz", + hint: "Cinq couches pour un if, un hook, et trois helpers vides.", + }, + { + id: "docs", + title: "La doc qui répète le code", + hint: "JSDoc d’un add(a, b) plus long que la fonction.", + }, + { + id: "assistant", + title: "Les traces de l’assistant", + hint: "« Sure! », TODO: implement, api.example.com, emojis de célébration.", + }, + { + id: "patchwork", + title: "Le patchwork de styles", + hint: "camelCase + snake_case, tabs + espaces, 3 façons de faire un fetch.", + }, + { + id: "leftovers", + title: "Les restes de chantier", + hint: "console.log, imports morts, fichiers Untitled, README de rêve.", + }, +]; + +const LEVELS = [ + { value: 0, label: "Absent" }, + { value: 1, label: "Léger" }, + { value: 2, label: "Présent" }, + { value: 3, label: "Criant" }, +]; + +const VERDICTS = [ + { + min: 0, + stamp: "Artisan", + title: "Code avec une âme", + text: "Peu de tics d’assistant. Soit quelqu’un a écrit ça, soit quelqu’un a eu le courage de relire.", + }, + { + min: 18, + stamp: "Vibe light", + title: "Un Copilot a soufflé", + text: "Quelques formules toutes faites, mais le fond tient. Une passe de nettoyage et ça redevient du code.", + }, + { + min: 38, + stamp: "Vibecodé", + title: "On entend encore le « Sure! »", + text: "Les signes s’accumulent : commentaires-tutoriel, noms génériques, politesse inutile. Diagnostic assez net.", + }, + { + min: 62, + stamp: "Vibe max", + title: "Plus de vibe que de métier", + text: "Ça compile peut-être. Ça raconte surtout une session de génération. À reprendre plutôt qu’à patcher.", + }, + { + min: 82, + stamp: "Spécimen", + title: "Laboratoire, rayon IA", + text: "Félicitations : c’est un cas d’école. Gardez-le pour le guide. Ne le mettez pas en prod.", + }, +]; + +const state = Object.fromEntries(SIGNS.map((sign) => [sign.id, 0])); + +function maxScore() { + return SIGNS.length * 3; +} + +function rawScore() { + return Object.values(state).reduce((sum, value) => sum + value, 0); +} + +function percent() { + return Math.round((rawScore() / maxScore()) * 100); +} + +function verdictFor(score) { + return [...VERDICTS].reverse().find((item) => score >= item.min) ?? VERDICTS[0]; +} + +function renderChecks() { + const root = document.querySelector("#checks"); + root.innerHTML = SIGNS.map( + (sign) => ` +
+

${sign.title}

+

${sign.hint}

+
+ ${LEVELS.map( + (level) => ` + + ` + ).join("")} +
+
+ ` + ).join(""); + + root.addEventListener("change", (event) => { + const input = event.target; + if (!(input instanceof HTMLInputElement)) return; + state[input.name] = Number(input.value); + renderMeter(); + }); +} + +function renderMeter() { + const score = percent(); + const verdict = verdictFor(score); + const meter = document.querySelector(".meter-ring"); + meter.style.setProperty("--score", String(score)); + document.querySelector("#score-value").textContent = String(score); + document.querySelector("#stamp").textContent = verdict.stamp; + document.querySelector("#verdict-title").textContent = verdict.title; + document.querySelector("#verdict-text").textContent = verdict.text; +} + +function copyResult() { + const score = percent(); + const verdict = verdictFor(score); + const active = SIGNS.filter((sign) => state[sign.id] >= 2) + .map((sign) => sign.title) + .join(", "); + const text = [ + `Est-ce vibecodé ? ${score}/100 — ${verdict.stamp}`, + verdict.title, + active ? `Signes nets : ${active}` : "Aucun signe criant coché.", + window.location.href.split("#")[0], + ].join("\n"); + + const button = document.querySelector("#copy"); + const done = () => { + button.textContent = "Copié"; + window.setTimeout(() => { + button.textContent = "Copier le verdict"; + }, 1600); + }; + + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(done).catch(() => { + window.prompt("Copier le verdict :", text); + }); + return; + } + window.prompt("Copier le verdict :", text); +} + +function resetScore() { + SIGNS.forEach((sign) => { + state[sign.id] = 0; + const input = document.querySelector(`input[name="${sign.id}"][value="0"]`); + if (input) input.checked = true; + }); + renderMeter(); +} + +document.addEventListener("DOMContentLoaded", () => { + renderChecks(); + renderMeter(); + document.querySelector("#copy").addEventListener("click", copyResult); + document.querySelector("#reset").addEventListener("click", resetScore); +}); diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..71314a2 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,5 @@ + + + + V + diff --git a/index.html b/index.html new file mode 100644 index 0000000..2b3ce58 --- /dev/null +++ b/index.html @@ -0,0 +1,543 @@ + + + + + + Est-ce vibecodé ? — Guide de terrain + + + + + + + + + + + + + + + +
+
+
+

Guide de terrain · v1.0

+

Est-ce vibecodé ?

+

+ Un petit guide pour lire un fichier et décider, sans cérémonie, s’il + a été écrit — ou surtout généré — à l’instinct. Huit signes, des + exemples concrets, une note sur 100. +

+ +
+
8catégories observables
+
0–100échelle de vibe
+
2 minpour un premier verdict
+
+
+
+ +
+
+

À quoi ça sert

+

+ Vibecoder, c’est coller la sortie d’un assistant, tweaker jusqu’à ce + que « ça marche », et passer à la suite. L’outil n’est pas le + problème. Le problème, c’est le code que plus personne ne peut + expliquer. Ce guide ne chasse pas l’IA : il chasse les traces + laissées quand on n’a pas relu. +

+
+
+ 01 + Ouvre le fichier + Une fonction, un composant, un script. Pas tout le repo d’un coup. +
+
+ 02 + Cherche les 8 signes + Chaque catégorie a un contre-exemple. Compare, ne compte pas les + lignes. +
+
+ 03 + Coche le détecteur + Absent, léger, présent, criant. Le score se calcule tout seul. +
+
+ 04 + Lis le verdict + La note est un jeu. La liste des signes, elle, est actionnable. +
+
+
+
+ +
+
+

Les 8 signes

+

+ Un seul signe ne prouve rien : un humain fatigué commente trop, une + IA bien briefée peut être sobre. C’est le cocktail qui parle. +

+ +
+
01
+
+

Le tutoriel dans le code

+

+ Le fichier raconte ce qu’il fait comme s’il s’adressait à un + débutant. Les commentaires paraphrasent chaque ligne au lieu + d’expliquer une intention, un piège, ou un choix. +

+
+
+
Vibecodé
+
// This function adds two numbers together
+// It takes a and b, then returns the sum
+function add(a, b) {
+  // Store the result of adding a and b
+  const result = a + b;
+  // Return the result to the caller
+  return result;
+}
+
+
+
Plutôt ça
+
function add(a, b) {
+  return a + b;
+}
+
+// Arrondi banquier : les totaux caisse
+// doivent matcher le journal N-1.
+function roundMoney(amount) {
+  return Math.round(amount * 20) / 20;
+}
+
+
+

+ Règle : un commentaire dit pourquoi, pas ce que fait + déjà le code. +

+
+
+ +
+
02
+
+

Les noms jetables

+

+ Vocabulaire IKEA : data, + item, + result, + obj, + processData. Ça compile. Ça ne + décrit aucun métier. +

+
+
+
Vibecodé
+
function processData(data) {
+  const result = [];
+  for (const item of data) {
+    const obj = { ...item, value: item.value };
+    result.push(obj);
+  }
+  return result;
+}
+
+
+
Plutôt ça
+
function invoicesWithTax(invoices) {
+  return invoices.map((invoice) => ({
+    ...invoice,
+    total: invoice.amount * 1.2,
+  }));
+}
+
+
+

+ Si tu ne peux pas remplacer data + par un mot du produit (facture, trajet, vote), le nom n’a pas + encore été choisi. +

+
+
+ +
+
03
+
+

L’erreur de décoration

+

+ Un try/catch partout, un + console.log dans le catch, un + commentaire // handle error, et + on continue comme si de rien n’était. +

+
+
+
Vibecodé
+
try {
+  const data = JSON.parse(raw);
+  return data;
+} catch (error) {
+  console.log("An error occurred:", error);
+  // Handle error gracefully
+}
+
+
+
Plutôt ça
+
function parseConfig(raw) {
+  try {
+    return JSON.parse(raw);
+  } catch {
+    throw new Error("Config JSON invalide");
+  }
+}
+
+
+

+ Attraper une erreur, c’est décider : retry, message utilisateur, + ou arrêt. Logger et avaler, ce n’est pas décider. +

+
+
+ +
+
04
+
+

L’usine à gaz

+

+ Factory, helper, wrapper, context, et un + useMemo pour un booléen. Le + problème tenait en six lignes. La solution en tient soixante. +

+
+
+
Vibecodé
+
const createToggleFactory = () => {
+  return {
+    createInitialState: () => ({ isEnabled: false }),
+    toggle: (state) => ({ isEnabled: !state.isEnabled }),
+  };
+};
+const factory = createToggleFactory();
+const state = factory.createInitialState();
+
+
+
Plutôt ça
+
const [enabled, setEnabled] = useState(false);
+
+<button onClick={() => setEnabled((v) => !v)}>
+  {enabled ? "On" : "Off"}
+</button>
+
+
+

+ Si tu dois dessiner un schéma pour expliquer un interrupteur, tu + as généré de l’architecture, pas une solution. +

+
+
+ +
+
05
+
+

La doc qui répète le code

+

+ Un JSDoc encyclopédique sur une fonction triviale, des + @param qui recopyent les noms, + zéro info sur les cas limites. +

+
+
+
Vibecodé
+
/**
+ * Adds two numbers together.
+ * @param {number} a - The first number to add.
+ * @param {number} b - The second number to add.
+ * @returns {number} The sum of a and b.
+ */
+function add(a, b) {
+  return a + b;
+}
+
+
+
Plutôt ça
+
/** Prix TTC à partir d’un HT déjà en centimes. */
+function ttcFromCents(htCents, vatRate = 0.2) {
+  return Math.round(htCents * (1 + vatRate));
+}
+
+
+

+ Documente le contrat bizarre (unités, arrondis, auth). Laisse + add tranquille. +

+
+
+ +
+
06
+
+

Les traces de l’assistant

+

+ Phrases de chat restées dans le fichier : « Sure! », « Here’s a + comprehensive solution », TODO: implement, emojis 🚀✅, URL + api.example.com. +

+
+
+
Vibecodé
+
// Sure! Here's a robust user service 🚀
+// Let's fetch the users from the API
+async function fetchUsers() {
+  // TODO: add your API endpoint here
+  const res = await fetch("https://api.example.com/users");
+  const data = await res.json();
+  return data; // return the data
+}
+
+
+
Plutôt ça
+
async function fetchUsers() {
+  const res = await fetch("/api/users");
+  if (!res.ok) {
+    throw new Error(`Users HTTP ${res.status}`);
+  }
+  return res.json();
+}
+
+
+

+ Si une phrase pourrait être collée dans un chat, elle n’a rien à + faire dans le dépôt. +

+
+
+ +
+
07
+
+

Le patchwork de styles

+

+ camelCase et snake_case dans le même objet, quotes mixtes, un + fetch ici, un + axios là, des imports d’un + package qui n’existe pas. +

+
+
+
Vibecodé
+
const user_name = user.name;
+const userId = user["id"];
+await axios.get("/v1/profile");
+await fetch("/api/profile");
+import { prettyPrint } from "lodash-helpers";
+
+
+
Plutôt ça
+
const userName = user.name;
+const userId = user.id;
+const profile = await api.get("/v1/profile");
+
+
+

+ Un fichier, une convention. Les APIs inventées et les imports + morts sont le bonus « halluciné ». +

+
+
+ +
+
08
+
+

Les restes de chantier

+

+ console.log("here"), variables + inutilisées, README impeccable qui promet un dashboard alors que + le bouton ne fait rien. +

+
+
+
Vibecodé
+
export function saveDraft(payload) {
+  console.log("payload", payload);
+  console.log("saving...");
+  // TODO: implement persistence
+  return { success: true, data: payload };
+}
+
+
+
Plutôt ça
+
export async function saveDraft(payload) {
+  const row = await db.drafts.upsert(payload);
+  return row;
+}
+
+
+

+ Le README n’est pas un alibi. S’il décrit une app qui n’existe + pas encore, c’est de la vibe, pas de la doc. +

+
+
+
+
+ +
+
+

L’échelle

+

+ Le détecteur additionne 8 signes notés de 0 à 3, puis ramène le + total sur 100. C’est un jeu — utile pour comparer deux fichiers, pas + pour un procès. +

+
+
+ 0–17 + Artisan — quelqu’un a choisi les noms et coupé le gras. + Artisan +
+
+ 18–37 + Vibe light — un assistant a aidé, une relecture a suivi. + Vibe light +
+
+ 38–61 + Vibecodé — les tics s’empilent, le métier s’efface. + Vibecodé +
+
+ 62–81 + Vibe max — trop de code, trop peu de décisions. + Vibe max +
+
+ 82–100 + Spécimen — à classer au musée, pas en production. + Spécimen +
+
+
+
+ +
+
+

Détecteur

+

+ Coche ce que tu vois dans le fichier. Le vibe-o-mètre fait le reste. +

+
+
+ +
+
+
+ +
+
+

Publier sur GitHub Pages

+

+ Ce dépôt est un site statique : HTML, CSS, JS. Aucun build. Une fois + fusionné dans main, active Pages + ainsi : +

+
+
+
    +
  1. + Ouvre le dépôt → Settings → + Pages. +
  2. +
  3. + Sous Build and deployment, choisis + Source : Deploy from a branch. +
  4. +
  5. + Branch main, dossier + / (root), puis + Save. +
  6. +
  7. + Le site sera servi sur + https://<user>.github.io/guide_vibecode/ + (quelques minutes). +
  8. +
+
+
+

+ Variante Actions : dans Pages, source + GitHub Actions. Le workflow + .github/workflows/pages.yml + publie la racine du dépôt à chaque push sur + main. +

+

+ Fichier .nojekyll : GitHub ne + passe pas le site dans Jekyll, donc les chemins et assets restent + tels quels. +

+
+
+
+
+
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f740671 --- /dev/null +++ b/styles.css @@ -0,0 +1,638 @@ +:root { + --bg: #11110f; + --bg-2: #181714; + --paper: #1f1d19; + --ink: #f3efe4; + --muted: #9a9486; + --faint: #5c574e; + --line: #2c2a25; + --vibe: #e7ff3d; + --vibe-dim: #c6d94a; + --alert: #ff5a36; + --ok: #8dffc1; + --bad: #ff8a74; + --stamp: #ff5a36; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.35); + --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --sans: "Bricolage Grotesque", "Segoe UI", sans-serif; + --serif: "Source Serif 4", Georgia, serif; + --radius: 18px; + --max: 1120px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + color-scheme: dark; +} + +body { + margin: 0; + color: var(--ink); + background: + radial-gradient(1200px 500px at 10% -10%, rgba(231, 255, 61, 0.08), transparent 50%), + radial-gradient(900px 400px at 100% 0%, rgba(255, 90, 54, 0.08), transparent 45%), + var(--bg); + font-family: var(--serif); + font-size: 1.0625rem; + line-height: 1.65; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.04; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E"); + z-index: 0; +} + +img { + max-width: 100%; +} + +a { + color: var(--vibe); + text-underline-offset: 0.18em; +} + +a:hover { + color: var(--ink); +} + +:focus-visible { + outline: 2px solid var(--vibe); + outline-offset: 3px; +} + +.skip { + position: absolute; + left: 1rem; + top: -4rem; + background: var(--vibe); + color: #111; + padding: 0.6rem 0.9rem; + font-family: var(--sans); + font-weight: 700; + z-index: 20; +} + +.skip:focus { + top: 1rem; +} + +.wrap { + width: min(var(--max), calc(100% - 2rem)); + margin-inline: auto; +} + +.site-header { + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(16px); + background: color-mix(in srgb, var(--bg) 78%, transparent); + border-bottom: 1px solid var(--line); +} + +.site-header .wrap { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 4.1rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.7rem; + color: var(--ink); + text-decoration: none; + font-family: var(--sans); + font-weight: 800; + letter-spacing: -0.03em; +} + +.mark { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 7px; + background: var(--vibe); + color: #111; + font-family: var(--mono); + font-size: 0.85rem; + font-weight: 500; +} + +.nav { + display: flex; + gap: 1.1rem; + font-family: var(--sans); + font-size: 0.92rem; +} + +.nav a { + color: var(--muted); + text-decoration: none; +} + +.nav a:hover { + color: var(--ink); +} + +.hero { + position: relative; + padding: 4.5rem 0 3.5rem; +} + +.kicker { + display: inline-flex; + align-items: center; + gap: 0.55rem; + margin: 0 0 1.1rem; + color: var(--vibe-dim); + font-family: var(--mono); + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.kicker::before { + content: ""; + width: 1.6rem; + height: 2px; + background: var(--vibe); +} + +.hero h1 { + margin: 0; + max-width: 14ch; + font-family: var(--sans); + font-size: clamp(3rem, 8vw, 6.4rem); + font-weight: 800; + letter-spacing: -0.06em; + line-height: 0.92; +} + +.lede { + max-width: 42rem; + margin: 1.4rem 0 0; + color: var(--muted); + font-size: 1.2rem; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + margin-top: 2rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + border: 1px solid transparent; + border-radius: 999px; + padding: 0.8rem 1.15rem; + background: var(--vibe); + color: #14140f; + font-family: var(--sans); + font-size: 0.98rem; + font-weight: 800; + text-decoration: none; + cursor: pointer; + appearance: none; +} + +.btn:hover { + color: #14140f; + filter: brightness(1.05); +} + +.btn--ghost { + background: transparent; + border-color: var(--line); + color: var(--ink); +} + +.btn--ghost:hover { + color: var(--ink); + border-color: var(--muted); +} + +.stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 3rem; + padding-top: 1.4rem; + border-top: 1px solid var(--line); +} + +.stat { + font-family: var(--sans); +} + +.stat b { + display: block; + font-size: 1.7rem; + letter-spacing: -0.04em; +} + +.stat span { + color: var(--muted); + font-size: 0.92rem; +} + +.section { + padding: 3.4rem 0; +} + +.section h2 { + margin: 0 0 0.7rem; + font-family: var(--sans); + font-size: clamp(1.8rem, 4vw, 2.7rem); + letter-spacing: -0.04em; + line-height: 1.1; +} + +.section > .wrap > p, +.prose { + max-width: 44rem; + color: color-mix(in srgb, var(--ink) 88%, var(--muted)); +} + +.steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.9rem; + margin-top: 1.8rem; +} + +.step { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.1rem 1.15rem 1.2rem; +} + +.step strong { + display: block; + margin-bottom: 0.35rem; + font-family: var(--sans); + font-size: 1.05rem; +} + +.step span { + color: var(--vibe-dim); + font-family: var(--mono); + font-size: 0.75rem; +} + +.sign { + display: grid; + grid-template-columns: 7.5rem 1fr; + gap: 1.5rem; + margin-top: 2.4rem; + padding-top: 2.2rem; + border-top: 1px solid var(--line); +} + +.sign-index { + font-family: var(--sans); + font-size: 2.4rem; + font-weight: 800; + letter-spacing: -0.06em; + color: var(--vibe); + line-height: 1; +} + +.sign h3 { + margin: 0 0 0.45rem; + font-family: var(--sans); + font-size: 1.55rem; + letter-spacing: -0.03em; +} + +.sign .why { + margin: 0 0 1.15rem; + color: var(--muted); +} + +.compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.85rem; +} + +.panel { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + background: #141311; +} + +.panel header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.55rem 0.8rem; + border-bottom: 1px solid var(--line); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.panel--bad header { + color: var(--bad); +} + +.panel--ok header { + color: var(--ok); +} + +pre { + margin: 0; + padding: 0.95rem 0.9rem 1.05rem; + overflow: auto; + color: #ece7da; + font-family: var(--mono); + font-size: 0.78rem; + line-height: 1.55; +} + +::selection { + background: var(--vibe); + color: #111; +} + +.c { + color: #8d9a6c; +} + +.kw { + color: #f0c36a; +} + +.tip { + margin: 0.9rem 0 0; + padding: 0.7rem 0.85rem; + border-left: 3px solid var(--vibe); + background: color-mix(in srgb, var(--vibe) 8%, transparent); + color: var(--ink); + font-size: 0.95rem; +} + +.scale { + display: grid; + gap: 0.7rem; + margin-top: 1.4rem; +} + +.scale-row { + display: grid; + grid-template-columns: 6.5rem 1fr auto; + gap: 0.9rem; + align-items: center; + padding: 0.85rem 1rem; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--paper); +} + +.scale-row b { + font-family: var(--sans); +} + +.scale-row span { + color: var(--muted); + font-size: 0.95rem; +} + +.pill { + font-family: var(--mono); + font-size: 0.75rem; + color: #111; + background: var(--vibe); + border-radius: 999px; + padding: 0.2rem 0.55rem; +} + +.detector { + background: linear-gradient(180deg, color-mix(in srgb, var(--paper) 70%, transparent), transparent); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.detector-grid { + display: grid; + grid-template-columns: 1.4fr 0.9fr; + gap: 1.4rem; + align-items: start; +} + +.card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 20px; + padding: 1.1rem; + box-shadow: var(--shadow); +} + +.check { + display: grid; + gap: 0.55rem; + padding: 0.95rem 0.2rem 1rem; + border-bottom: 1px solid var(--line); +} + +.check:last-child { + border-bottom: 0; +} + +.check p { + margin: 0.15rem 0 0; + color: var(--muted); + font-size: 0.95rem; +} + +.check h4 { + margin: 0; + font-family: var(--sans); + font-size: 1.05rem; +} + +.levels { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.levels label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.28rem 0.65rem; + background: #151411; + font-family: var(--sans); + font-size: 0.85rem; + cursor: pointer; +} + +.levels input { + accent-color: var(--vibe); +} + +.meter-card { + position: sticky; + top: 5.2rem; +} + +.meter-ring { + display: grid; + place-items: center; + margin: 0.4rem auto 0.8rem; + width: 190px; + height: 190px; + border-radius: 50%; + background: + conic-gradient(var(--vibe) calc(var(--score) * 1%), #2a2823 0); +} + +.meter-inner { + display: grid; + place-items: center; + width: 152px; + height: 152px; + border-radius: 50%; + background: var(--paper); + text-align: center; +} + +.meter-inner strong { + font-family: var(--sans); + font-size: 2.6rem; + letter-spacing: -0.05em; + line-height: 1; +} + +.meter-inner span { + color: var(--muted); + font-family: var(--mono); + font-size: 0.75rem; +} + +.verdict { + margin: 0.4rem 0 1rem; +} + +.verdict h3 { + margin: 0 0 0.35rem; + font-family: var(--sans); + font-size: 1.35rem; +} + +.verdict p { + margin: 0; + color: var(--muted); +} + +.stamp { + display: inline-block; + margin-bottom: 0.55rem; + border: 3px solid var(--stamp); + color: var(--stamp); + border-radius: 8px; + padding: 0.18rem 0.5rem; + font-family: var(--sans); + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + transform: rotate(-7deg); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pages { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 1.2rem; + margin-top: 1.5rem; +} + +.pages ol { + margin: 0; + padding-left: 1.2rem; +} + +.pages li + li { + margin-top: 0.55rem; +} + +.code-inline { + font-family: var(--mono); + font-size: 0.85em; + color: var(--vibe); +} + +.site-footer { + padding: 2.2rem 0 2.8rem; + color: var(--muted); + font-size: 0.95rem; +} + +.site-footer .wrap { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +@media (max-width: 900px) { + .steps, + .compare, + .detector-grid, + .pages, + .sign, + .stats, + .scale-row { + grid-template-columns: 1fr; + } + + .nav { + display: none; + } + + .meter-card { + position: static; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + * { + animation: none !important; + transition: none !important; + } +}