sync current state
This commit is contained in:
248
app.js
Normal file
248
app.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
function gcd(a, b) {
|
||||||
|
let x = Math.abs(a);
|
||||||
|
let y = Math.abs(b);
|
||||||
|
while (y !== 0) {
|
||||||
|
const t = y;
|
||||||
|
y = x % y;
|
||||||
|
x = t;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modPow(base, exp, mod) {
|
||||||
|
let result = 1;
|
||||||
|
let b = base % mod;
|
||||||
|
let e = exp;
|
||||||
|
while (e > 0) {
|
||||||
|
if (e % 2 === 1) {
|
||||||
|
result = (result * b) % mod;
|
||||||
|
}
|
||||||
|
b = (b * b) % mod;
|
||||||
|
e = Math.floor(e / 2);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modInverse(e, phi) {
|
||||||
|
let oldR = e;
|
||||||
|
let r = phi;
|
||||||
|
let oldS = 1;
|
||||||
|
let s = 0;
|
||||||
|
|
||||||
|
while (r !== 0) {
|
||||||
|
const q = Math.floor(oldR / r);
|
||||||
|
[oldR, r] = [r, oldR - q * r];
|
||||||
|
[oldS, s] = [s, oldS - q * s];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldR !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((oldS % phi) + phi) % phi;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrime(n) {
|
||||||
|
if (n < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 2; i * i <= n; i += 1) {
|
||||||
|
if (n % i === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
en: {
|
||||||
|
heroTag: "Cryptography Trainer",
|
||||||
|
heroTitle: "RSA Study Coach",
|
||||||
|
heroCopy: "Step through RSA from intuition to full key generation, then test yourself.",
|
||||||
|
languageLabel: "Language",
|
||||||
|
roadmapTitle: "Learning Path",
|
||||||
|
nextStep: "Mark step as understood",
|
||||||
|
labTitle: "RSA Lab",
|
||||||
|
runLab: "Run RSA walk-through",
|
||||||
|
randomExample: "Random small example",
|
||||||
|
quizTitle: "Quick Check",
|
||||||
|
quizIntro: "What must hold for a valid public exponent e?"
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
heroTag: "Kryptografie-Trainer",
|
||||||
|
heroTitle: "RSA-Lerncoach",
|
||||||
|
heroCopy: "Gehe RSA Schritt für Schritt durch und überprüfe danach dein Verständnis.",
|
||||||
|
languageLabel: "Sprache",
|
||||||
|
roadmapTitle: "Lernpfad",
|
||||||
|
nextStep: "Schritt als verstanden markieren",
|
||||||
|
labTitle: "RSA-Labor",
|
||||||
|
runLab: "RSA-Ablauf starten",
|
||||||
|
randomExample: "Zufallsbeispiel",
|
||||||
|
quizTitle: "Kurztest",
|
||||||
|
quizIntro: "Was muss für einen gültigen öffentlichen Exponenten e gelten?"
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
heroTag: "Entrenador de criptografía",
|
||||||
|
heroTitle: "Tutor de RSA",
|
||||||
|
heroCopy: "Avanza por RSA paso a paso y comprueba tu comprensión.",
|
||||||
|
languageLabel: "Idioma",
|
||||||
|
roadmapTitle: "Ruta de aprendizaje",
|
||||||
|
nextStep: "Marcar paso como entendido",
|
||||||
|
labTitle: "Laboratorio RSA",
|
||||||
|
runLab: "Ejecutar recorrido RSA",
|
||||||
|
randomExample: "Ejemplo aleatorio",
|
||||||
|
quizTitle: "Comprobación rápida",
|
||||||
|
quizIntro: "¿Qué debe cumplirse para un exponente público e válido?"
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
heroTag: "Entraîneur de cryptographie",
|
||||||
|
heroTitle: "Coach RSA",
|
||||||
|
heroCopy: "Parcours RSA étape par étape puis teste ta compréhension.",
|
||||||
|
languageLabel: "Langue",
|
||||||
|
roadmapTitle: "Parcours d'apprentissage",
|
||||||
|
nextStep: "Marquer l'étape comme comprise",
|
||||||
|
labTitle: "Laboratoire RSA",
|
||||||
|
runLab: "Lancer le parcours RSA",
|
||||||
|
randomExample: "Exemple aléatoire",
|
||||||
|
quizTitle: "Vérification rapide",
|
||||||
|
quizIntro: "Que faut-il pour qu'un exposant public e soit valide ?"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
"Why modular arithmetic allows reversible operations",
|
||||||
|
"How two primes p and q produce n = p * q",
|
||||||
|
"Why phi(n) = (p - 1)(q - 1) matters",
|
||||||
|
"How to choose e with gcd(e, phi(n)) = 1",
|
||||||
|
"How to compute d so e * d ≡ 1 (mod phi(n))",
|
||||||
|
"Why c = m^e mod n and m = c^d mod n work"
|
||||||
|
];
|
||||||
|
|
||||||
|
let activeStep = 0;
|
||||||
|
|
||||||
|
const roadmapEl = document.getElementById("roadmap");
|
||||||
|
const nextStepBtn = document.getElementById("nextStep");
|
||||||
|
const runLabBtn = document.getElementById("runLab");
|
||||||
|
const randomBtn = document.getElementById("randomSmall");
|
||||||
|
const outputEl = document.getElementById("labOutput");
|
||||||
|
const langEl = document.getElementById("lang");
|
||||||
|
const quizFeedbackEl = document.getElementById("quizFeedback");
|
||||||
|
|
||||||
|
function renderRoadmap() {
|
||||||
|
roadmapEl.innerHTML = "";
|
||||||
|
steps.forEach((step, index) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = step;
|
||||||
|
if (index < activeStep) {
|
||||||
|
li.classList.add("done");
|
||||||
|
}
|
||||||
|
roadmapEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLab() {
|
||||||
|
const p = Number(document.getElementById("p").value);
|
||||||
|
const q = Number(document.getElementById("q").value);
|
||||||
|
const e = Number(document.getElementById("e").value);
|
||||||
|
const m = Number(document.getElementById("m").value);
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
if (!isPrime(p) || !isPrime(q)) {
|
||||||
|
lines.push("Error: p and q must both be prime.");
|
||||||
|
outputEl.textContent = lines.join("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = p * q;
|
||||||
|
const phi = (p - 1) * (q - 1);
|
||||||
|
const g = gcd(e, phi);
|
||||||
|
lines.push(`1) Choose primes p=${p}, q=${q}`);
|
||||||
|
lines.push(`2) Compute n = p*q = ${n}`);
|
||||||
|
lines.push(`3) Compute phi(n) = (p-1)(q-1) = ${phi}`);
|
||||||
|
lines.push(`4) Check gcd(e, phi(n)): gcd(${e}, ${phi}) = ${g}`);
|
||||||
|
|
||||||
|
if (g !== 1) {
|
||||||
|
lines.push("Invalid e: it must be coprime to phi(n). Try another e.");
|
||||||
|
outputEl.textContent = lines.join("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = modInverse(e, phi);
|
||||||
|
if (d === null) {
|
||||||
|
lines.push("Could not compute d (mod inverse does not exist).");
|
||||||
|
outputEl.textContent = lines.join("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`5) Compute private exponent d so e*d ≡ 1 mod phi(n): d = ${d}`);
|
||||||
|
if (m < 0 || m >= n) {
|
||||||
|
lines.push(`Message m must satisfy 0 <= m < n (${n}).`);
|
||||||
|
outputEl.textContent = lines.join("\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = modPow(m, e, n);
|
||||||
|
const recovered = modPow(c, d, n);
|
||||||
|
lines.push(`6) Encrypt c = m^e mod n = ${m}^${e} mod ${n} = ${c}`);
|
||||||
|
lines.push(`7) Decrypt m = c^d mod n = ${c}^${d} mod ${n} = ${recovered}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Public key: (n=${n}, e=${e})`);
|
||||||
|
lines.push(`Private key: (n=${n}, d=${d})`);
|
||||||
|
lines.push(recovered === m ? "Result: success, decrypted message matches original." : "Result: mismatch.");
|
||||||
|
|
||||||
|
outputEl.textContent = lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomSmallExample() {
|
||||||
|
const primePairs = [
|
||||||
|
[11, 13],
|
||||||
|
[17, 19],
|
||||||
|
[23, 29],
|
||||||
|
[31, 37]
|
||||||
|
];
|
||||||
|
const es = [3, 5, 7, 11, 17];
|
||||||
|
const pair = primePairs[Math.floor(Math.random() * primePairs.length)];
|
||||||
|
document.getElementById("p").value = pair[0];
|
||||||
|
document.getElementById("q").value = pair[1];
|
||||||
|
|
||||||
|
const phi = (pair[0] - 1) * (pair[1] - 1);
|
||||||
|
const validE = es.find((candidate) => candidate < phi && gcd(candidate, phi) === 1) || 3;
|
||||||
|
document.getElementById("e").value = validE;
|
||||||
|
|
||||||
|
const n = pair[0] * pair[1];
|
||||||
|
document.getElementById("m").value = Math.floor(Math.random() * (n - 1)) + 1;
|
||||||
|
runLab();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLanguage(lang) {
|
||||||
|
const map = translations[lang] || translations.en;
|
||||||
|
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||||
|
const key = el.dataset.i18n;
|
||||||
|
if (map[key]) {
|
||||||
|
el.textContent = map[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextStepBtn.addEventListener("click", () => {
|
||||||
|
if (activeStep < steps.length) {
|
||||||
|
activeStep += 1;
|
||||||
|
renderRoadmap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runLabBtn.addEventListener("click", runLab);
|
||||||
|
randomBtn.addEventListener("click", randomSmallExample);
|
||||||
|
langEl.addEventListener("change", (event) => applyLanguage(event.target.value));
|
||||||
|
|
||||||
|
document.querySelectorAll(".quiz-option").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const isCorrect = button.dataset.correct === "true";
|
||||||
|
quizFeedbackEl.textContent = isCorrect ? "Correct: e must be coprime to phi(n)." : "Not quite. Focus on coprimality with phi(n).";
|
||||||
|
quizFeedbackEl.className = `feedback ${isCorrect ? "good" : "bad"}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderRoadmap();
|
||||||
|
applyLanguage("en");
|
||||||
|
runLab();
|
||||||
BIN
crypto01.pdf
Normal file
BIN
crypto01.pdf
Normal file
Binary file not shown.
BIN
crypto02.pdf
Normal file
BIN
crypto02.pdf
Normal file
Binary file not shown.
BIN
crypto03.pdf
Normal file
BIN
crypto03.pdf
Normal file
Binary file not shown.
BIN
crypto04.pdf
Normal file
BIN
crypto04.pdf
Normal file
Binary file not shown.
BIN
crypto05.pdf
Normal file
BIN
crypto05.pdf
Normal file
Binary file not shown.
BIN
crypto06.pdf
Normal file
BIN
crypto06.pdf
Normal file
Binary file not shown.
BIN
crypto07.pdf
Normal file
BIN
crypto07.pdf
Normal file
Binary file not shown.
BIN
crypto08.pdf
Normal file
BIN
crypto08.pdf
Normal file
Binary file not shown.
BIN
crypto09.pdf
Normal file
BIN
crypto09.pdf
Normal file
Binary file not shown.
BIN
crypto10.pdf
Normal file
BIN
crypto10.pdf
Normal file
Binary file not shown.
BIN
crypto11.pdf
Normal file
BIN
crypto11.pdf
Normal file
Binary file not shown.
BIN
crypto12.pdf
Normal file
BIN
crypto12.pdf
Normal file
Binary file not shown.
BIN
crypto13.pdf
Normal file
BIN
crypto13.pdf
Normal file
Binary file not shown.
BIN
crypto14.pdf
Normal file
BIN
crypto14.pdf
Normal file
Binary file not shown.
BIN
crypto15.pdf
Normal file
BIN
crypto15.pdf
Normal file
Binary file not shown.
BIN
exercise1.pdf
Normal file
BIN
exercise1.pdf
Normal file
Binary file not shown.
BIN
exercise10.pdf
Normal file
BIN
exercise10.pdf
Normal file
Binary file not shown.
BIN
exercise11.pdf
Normal file
BIN
exercise11.pdf
Normal file
Binary file not shown.
BIN
exercise2.pdf
Normal file
BIN
exercise2.pdf
Normal file
Binary file not shown.
BIN
exercise3.pdf
Normal file
BIN
exercise3.pdf
Normal file
Binary file not shown.
BIN
exercise4.pdf
Normal file
BIN
exercise4.pdf
Normal file
Binary file not shown.
BIN
exercise5.pdf
Normal file
BIN
exercise5.pdf
Normal file
Binary file not shown.
BIN
exercise6.pdf
Normal file
BIN
exercise6.pdf
Normal file
Binary file not shown.
BIN
exercise7.pdf
Normal file
BIN
exercise7.pdf
Normal file
Binary file not shown.
BIN
exercise8.pdf
Normal file
BIN
exercise8.pdf
Normal file
Binary file not shown.
BIN
exercise8_2_solution.pdf
Normal file
BIN
exercise8_2_solution.pdf
Normal file
Binary file not shown.
BIN
exercise9.pdf
Normal file
BIN
exercise9.pdf
Normal file
Binary file not shown.
77
index.html
Normal file
77
index.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>RSA Study Coach</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-inner">
|
||||||
|
<p class="eyebrow" data-i18n="heroTag">Cryptography Trainer</p>
|
||||||
|
<h1 data-i18n="heroTitle">RSA Study Coach</h1>
|
||||||
|
<p class="hero-copy" data-i18n="heroCopy">
|
||||||
|
Step through RSA from intuition to full key generation, then test yourself.
|
||||||
|
</p>
|
||||||
|
<div class="toolbar">
|
||||||
|
<label for="lang" data-i18n="languageLabel">Language</label>
|
||||||
|
<select id="lang" aria-label="Language selector">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="card" aria-labelledby="roadmap-title">
|
||||||
|
<h2 id="roadmap-title" data-i18n="roadmapTitle">Learning Path</h2>
|
||||||
|
<ol id="roadmap" class="roadmap"></ol>
|
||||||
|
<button id="nextStep" class="primary" data-i18n="nextStep">Mark step as understood</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby="lab-title">
|
||||||
|
<h2 id="lab-title" data-i18n="labTitle">RSA Lab</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="p">p (prime)</label>
|
||||||
|
<input id="p" type="number" value="61" min="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="q">q (prime)</label>
|
||||||
|
<input id="q" type="number" value="53" min="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="e">e (public exponent)</label>
|
||||||
|
<input id="e" type="number" value="17" min="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="m">m (message as number)</label>
|
||||||
|
<input id="m" type="number" value="65" min="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="runLab" class="primary" data-i18n="runLab">Run RSA walk-through</button>
|
||||||
|
<button id="randomSmall" data-i18n="randomExample">Random small example</button>
|
||||||
|
</div>
|
||||||
|
<pre id="labOutput" class="output" aria-live="polite"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby="quiz-title">
|
||||||
|
<h2 id="quiz-title" data-i18n="quizTitle">Quick Check</h2>
|
||||||
|
<p data-i18n="quizIntro">What must hold for a valid public exponent e?</p>
|
||||||
|
<div class="quiz-options" role="radiogroup" aria-label="quiz options">
|
||||||
|
<button class="quiz-option" data-correct="false">e must be prime and larger than n</button>
|
||||||
|
<button class="quiz-option" data-correct="true">1 < e < φ(n) and gcd(e, φ(n)) = 1</button>
|
||||||
|
<button class="quiz-option" data-correct="false">e must divide φ(n)</button>
|
||||||
|
</div>
|
||||||
|
<p id="quizFeedback" class="feedback" aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
rsa-core.mjs
Normal file
55
rsa-core.mjs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export function gcd(a, b) {
|
||||||
|
let x = Math.abs(a);
|
||||||
|
let y = Math.abs(b);
|
||||||
|
while (y !== 0) {
|
||||||
|
const t = y;
|
||||||
|
y = x % y;
|
||||||
|
x = t;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modPow(base, exp, mod) {
|
||||||
|
let result = 1;
|
||||||
|
let b = base % mod;
|
||||||
|
let e = exp;
|
||||||
|
while (e > 0) {
|
||||||
|
if (e % 2 === 1) {
|
||||||
|
result = (result * b) % mod;
|
||||||
|
}
|
||||||
|
b = (b * b) % mod;
|
||||||
|
e = Math.floor(e / 2);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modInverse(e, phi) {
|
||||||
|
let oldR = e;
|
||||||
|
let r = phi;
|
||||||
|
let oldS = 1;
|
||||||
|
let s = 0;
|
||||||
|
|
||||||
|
while (r !== 0) {
|
||||||
|
const q = Math.floor(oldR / r);
|
||||||
|
[oldR, r] = [r, oldR - q * r];
|
||||||
|
[oldS, s] = [s, oldS - q * s];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldR !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((oldS % phi) + phi) % phi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrime(n) {
|
||||||
|
if (n < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 2; i * i <= n; i += 1) {
|
||||||
|
if (n % i === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
33
rsa-core.test.mjs
Normal file
33
rsa-core.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { gcd, isPrime, modInverse, modPow } from "./rsa-core.mjs";
|
||||||
|
|
||||||
|
test("gcd computes expected values", () => {
|
||||||
|
assert.equal(gcd(54, 24), 6);
|
||||||
|
assert.equal(gcd(17, 3120), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modInverse finds RSA example inverse", () => {
|
||||||
|
assert.equal(modInverse(17, 3120), 2753);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modPow encrypt/decrypt roundtrip for classic RSA sample", () => {
|
||||||
|
const p = 61;
|
||||||
|
const q = 53;
|
||||||
|
const n = p * q;
|
||||||
|
const phi = (p - 1) * (q - 1);
|
||||||
|
const e = 17;
|
||||||
|
const d = modInverse(e, phi);
|
||||||
|
const m = 65;
|
||||||
|
assert.notEqual(d, null);
|
||||||
|
const c = modPow(m, e, n);
|
||||||
|
const recovered = modPow(c, d, n);
|
||||||
|
assert.equal(c, 2790);
|
||||||
|
assert.equal(recovered, m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isPrime identifies primes and composites", () => {
|
||||||
|
assert.equal(isPrime(61), true);
|
||||||
|
assert.equal(isPrime(51), false);
|
||||||
|
assert.equal(isPrime(1), false);
|
||||||
|
});
|
||||||
189
styles.css
Normal file
189
styles.css
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1d1d1f;
|
||||||
|
--muted: #4d4d52;
|
||||||
|
--primary: #0a84ff;
|
||||||
|
--primary-strong: #0066cc;
|
||||||
|
--ok: #2e7d32;
|
||||||
|
--bad: #b00020;
|
||||||
|
--border: #d7dbe4;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "SF Pro Text", "Avenir Next", system-ui, -apple-system, sans-serif;
|
||||||
|
background: radial-gradient(circle at 20% -10%, #deecff 0%, #f5f7fb 55%);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 2rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-inner {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: clamp(1.8rem, 3vw, 2.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 65ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem 2rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 10px 30px rgba(45, 65, 99, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmap {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmap li {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmap li.done {
|
||||||
|
color: var(--ok);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover {
|
||||||
|
background: var(--primary-strong);
|
||||||
|
border-color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f2f6ff;
|
||||||
|
min-height: 220px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.good {
|
||||||
|
color: var(--ok);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.bad {
|
||||||
|
color: var(--bad);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user