diff --git a/docusaurus.config.ts b/docusaurus.config.ts index fd561fd..1c1b903 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -127,6 +127,7 @@ export default async function createConfig(): Promise { plugins: [ './plugins/pyrunner/index.js', + './plugins/exercise-graph/index.js', [ '@docusaurus/plugin-content-docs', { diff --git a/package-lock.json b/package-lock.json index b153ad3..3898462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "python-doesnt-byte", - "version": "0.7.13", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "python-doesnt-byte", - "version": "0.7.13", + "version": "0.8.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-python": "^6.2.1", diff --git a/package.json b/package.json index 78930d2..bcdde40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "python-doesnt-byte", - "version": "0.7.13", + "version": "0.8.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/plugins/exercise-graph/index.js b/plugins/exercise-graph/index.js new file mode 100644 index 0000000..3d36211 --- /dev/null +++ b/plugins/exercise-graph/index.js @@ -0,0 +1,217 @@ +const path = require('path'); +const fs = require('fs'); +const { + parseMarkdownFile, + DEFAULT_PARSE_FRONT_MATTER, +} = require('@docusaurus/utils'); + +const PLUGIN_NAME = 'exercise-graph'; + +// Tutti i volumi (istanze plugin-content-docs). Le lezioni-sorgente vivono nei +// primi tre; gli esercizi nel quarto (apprendista). +const VOLUMES = ['programmatore', 'artefice', 'archivista', 'apprendista']; + +const MD_EXT = /\.mdx?$/; + +// Umanizza un id-segmento come fallback per il titolo quando manca il +// frontmatter (`title`/`sidebar_label`) e l'H1: «le-stringhe» → «Le stringhe». +function humanize(segment) { + const s = segment.replace(/[-_]+/g, ' ').trim(); + return s.charAt(0).toUpperCase() + s.slice(1); +} + +// Cammina ricorsivamente una cartella-volume e ritorna la lista dei file +// markdown con il loro docId volume-local (path relativo senza estensione, +// separatori posix). Replica la convenzione di id di @docusaurus/plugin-content-docs. +function walkDocs(dir, baseDir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + // Esclude file/cartelle con prefisso `_` o `.` — stessa convenzione di + // content-docs (es. _category_.json, _esercizio-template.mdx, partial), che + // li tiene fuori dal build: il grafo deve ignorarli per non validare un + // template-segnaposto. + if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDocs(full, baseDir, out); + } else if (entry.isFile() && MD_EXT.test(entry.name)) { + const rel = path + .relative(baseDir, full) + .split(path.sep) + .join('/') + .replace(MD_EXT, ''); + out.push({ filePath: full, relId: rel }); + } + } + return out; +} + +// docId effettivo: il frontmatter `id` sovrascrive l'ultimo segmento del path +// (stessa regola di content-docs). Senza override → relId. +function resolveDocId(relId, frontMatter) { + const override = frontMatter.id; + if (typeof override !== 'string' || override.length === 0) return relId; + const slash = relId.lastIndexOf('/'); + return slash === -1 ? override : `${relId.slice(0, slash + 1)}${override}`; +} + +async function parseDoc(filePath) { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + return parseMarkdownFile({ + filePath, + fileContent, + parseFrontMatter: DEFAULT_PARSE_FRONT_MATTER, + removeContentTitle: true, + }); +} + +// Normalizza una chiave globale «/» in { volume, docId }. +// Lo split è sul PRIMO `/`: il volume è il primo segmento, il resto è il docId +// volume-local (che può contenere altri `/`). +function splitKey(raw, filePath, field) { + if (typeof raw !== 'string' || !raw.includes('/')) { + throw new Error( + `[exercise-graph] ${field} non valido in ${filePath}: ` + + `atteso «/», ricevuto ${JSON.stringify(raw)}.`, + ); + } + const slash = raw.indexOf('/'); + const volume = raw.slice(0, slash); + const docId = raw.slice(slash + 1); + if (!VOLUMES.includes(volume)) { + throw new Error( + `[exercise-graph] ${field} in ${filePath}: volume sconosciuto «${volume}». ` + + `Volumi validi: ${VOLUMES.join(', ')}.`, + ); + } + return { volume, docId, key: `${volume}/${docId}` }; +} + +module.exports = function exerciseGraphPlugin(context) { + const volumeDir = (v) => path.join(context.siteDir, 'volumes', v); + + return { + name: PLUGIN_NAME, + + async loadContent() { + // ── Indice delle lezioni (tutti i volumi) ──────────────────────────── + // lessons["/"] = { title } — usato per validare i + // riferimenti e per il display lato client. + const lessons = {}; + // exercises[""] = record completo (la chiave è il docId + // volume-local dell'esercizio; il volume è sempre `apprendista`). + const exercises = {}; + // byLesson["/"] = [ref...] — mappa inversa lezione→esercizi. + const byLesson = {}; + + // Raccolgo prima TUTTI i doc di tutti i volumi, così la validazione degli + // esercizi vede l'indice completo delle lezioni. + const docsByVolume = {}; + await Promise.all( + VOLUMES.map(async (volume) => { + const base = volumeDir(volume); + const files = walkDocs(base, base); + const parsed = await Promise.all( + files.map(async ({ filePath, relId }) => { + const { frontMatter, contentTitle } = await parseDoc(filePath); + const docId = resolveDocId(relId, frontMatter); + const title = + (typeof frontMatter.title === 'string' && frontMatter.title) || + (typeof frontMatter.sidebar_label === 'string' && + frontMatter.sidebar_label) || + contentTitle || + humanize(docId.split('/').pop()); + const key = `${volume}/${docId}`; + lessons[key] = { title }; + return { filePath, docId, key, frontMatter, title }; + }), + ); + docsByVolume[volume] = parsed; + }), + ); + + // ── Esercizi (solo `apprendista`, marcati da `assigned_in`) ─────────── + for (const doc of docsByVolume.apprendista) { + const { filePath, docId, frontMatter, title } = doc; + if (typeof frontMatter.assigned_in === 'undefined') continue; // non è un esercizio + + const assignedIn = splitKey( + frontMatter.assigned_in, + filePath, + 'assigned_in', + ); + if (!lessons[assignedIn.key]) { + throw new Error( + `[exercise-graph] ${filePath}: assigned_in «${assignedIn.key}» ` + + `non corrisponde ad alcuna lezione esistente.`, + ); + } + + const theoryRaw = Array.isArray(frontMatter.theory) + ? frontMatter.theory + : []; + const theory = theoryRaw.map((t) => { + const ref = splitKey(t, filePath, 'theory'); + if (!lessons[ref.key]) { + throw new Error( + `[exercise-graph] ${filePath}: theory «${ref.key}» ` + + `non corrisponde ad alcuna lezione esistente.`, + ); + } + return { volume: ref.volume, docId: ref.docId, title: lessons[ref.key].title }; + }); + + const record = { + docId, + title, + kind: + typeof frontMatter.exercise_kind === 'string' + ? frontMatter.exercise_kind + : 'rapidi', + n: typeof frontMatter.n === 'string' ? frontMatter.n : undefined, + lessonId: + typeof frontMatter.lesson_id === 'string' + ? frontMatter.lesson_id + : undefined, + difficulty: + typeof frontMatter.difficulty === 'string' + ? frontMatter.difficulty + : undefined, + time: typeof frontMatter.time === 'string' ? frontMatter.time : undefined, + assignedIn: { + volume: assignedIn.volume, + docId: assignedIn.docId, + title: lessons[assignedIn.key].title, + }, + theory, + }; + exercises[docId] = record; + + // Backlink inverso: l'esercizio è «assegnato in» assignedIn → lo + // elenchiamo su quella lezione. La teoria NON genera backlink (non è + // assegnazione, solo supporto). + (byLesson[assignedIn.key] ??= []).push({ + docId, + title, + kind: record.kind, + difficulty: record.difficulty, + time: record.time, + }); + } + + // `lessons` serve solo qui per validare i riferimenti: non finisce nel + // global data (i client usano solo `exercises` e `byLesson`). + return { exercises, byLesson }; + }, + + async contentLoaded({ content, actions }) { + actions.setGlobalData(content); + }, + + getPathsToWatch() { + return VOLUMES.map((v) => path.join(volumeDir(v), '**/*.{md,mdx}')); + }, + }; +}; + +module.exports.PLUGIN_NAME = PLUGIN_NAME; diff --git a/sidebars/apprendista.ts b/sidebars/apprendista.ts index a6830f3..0146468 100644 --- a/sidebars/apprendista.ts +++ b/sidebars/apprendista.ts @@ -1,11 +1,76 @@ -import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; +import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; // Biblioteca dell'Apprendista (Volume 4 — esercizi e laboratori). -// Sidebar per percorso, identica struttura agli altri volumi. +// +// Gli esercizi sono raggruppati per volume di provenienza: +// volume-sorgente > capitolo > lezione +// +// Modello IBRIDO al livello "lezione": +// - lezione con SOLO esercizi rapidi → è una PAGINA singola (un doc) +// - lezione con anche esercizi dedicati / laboratori → è una CATEGORIA, con +// sotto una pagina per tipo (Esercizi rapidi / Esercizio: … / Laboratorio: …) +// Gli esercizietti di una pagina-batch sono sezioni

(vanno nel TOC della +// pagina), non voci di sidebar. +// +// Regola di collapse (per evitare la "lista infinita"): +// - categorie volume-sorgente: collapsed: false → aperte, mostrano i capitoli +// - categorie capitolo / lezione: collapsed: true → chiuse di default +// Così a colpo d'occhio si vedono i volumi-sorgente e l'elenco dei capitoli; +// il resto resta nascosto finché non si espande. +// +// NB: `themeConfig.docs.sidebar.autoCollapseCategories` è GLOBALE ed è a `true`: +// aprendo un capitolo, i fratelli si richiudono. Per questa sidebar sarebbe +// preferibile `false` (vedi nota in docusaurus.config.ts). + +// Albero condiviso dai tre percorsi (it / liceo / its). Quando i percorsi +// divergeranno, duplicare e personalizzare per chiave. +const tree: SidebarsConfig[string] = [ + 'intro', + { + type: 'category', + label: 'Manuale del Programmatore', + collapsed: false, // volume-sorgente: aperto + items: [ + { + type: 'category', + label: 'Le basi', + collapsed: true, // capitolo: chiuso + items: [ + // lezione con solo esercizi rapidi → pagina singola + 'programmatore/le-basi/le-variabili', + // lezione con più tipi di pagina → categoria + { + type: 'category', + label: 'I tipi di dato', + collapsed: true, // lezione: chiusa + items: [ + 'programmatore/le-basi/i-tipi-di-dato/esercizi-rapidi', + 'programmatore/le-basi/i-tipi-di-dato/esercizio-convertitore', + 'programmatore/le-basi/i-tipi-di-dato/laboratorio-convertitore', + ], + }, + ], + }, + ], + }, + // { + // type: 'category', + // label: 'Manuale dell'Artefice', + // collapsed: false, + // items: [ /* capitoli > lezioni > esercizi */ ], + // }, + // { + // type: 'category', + // label: 'Manuale dell'Archivista', + // collapsed: false, + // items: [ /* capitoli > lezioni > esercizi */ ], + // }, +]; + const sidebars: SidebarsConfig = { - it: ['intro'], - liceo: ['intro'], - its: ['intro'], + it: tree, + liceo: tree, + its: tree, }; export default sidebars; diff --git a/src/components/AssignedExercises/index.tsx b/src/components/AssignedExercises/index.tsx new file mode 100644 index 0000000..b600498 --- /dev/null +++ b/src/components/AssignedExercises/index.tsx @@ -0,0 +1,58 @@ +/** + * AssignedExercises — elenco «Esercizi assegnati» mostrato in fondo a una + * lezione (Volumi 1–3). È DERIVATO: legge la mappa inversa `byLesson` dal global + * data del plugin `exercise-graph` per la chiave «/» della lezione + * corrente e renderizza una card per esercizio (riusa ). + * + * Viene auto-iniettato dallo swizzle DocItem/Footer; le lezioni non vengono + * mai editate a mano. Render `null` se la lezione non assegna esercizi. + */ +import { type ReactNode } from 'react'; +import Heading from '@theme/Heading'; +import { usePluginData } from '@docusaurus/useGlobalData'; +import { useAllDocsData } from '@docusaurus/plugin-content-docs/client'; +import ExerciseLink from '@site/src/components/ExerciseLink'; +import { + resolvePermalink, + type ExerciseGraphData, +} from '@site/src/lib/docResolve'; + +import styles from './styles.module.css'; + +interface AssignedExercisesProps { + /** Chiave della lezione corrente: «/». */ + lessonKey: string; +} + +export default function AssignedExercises({ + lessonKey, +}: AssignedExercisesProps): ReactNode { + const data = usePluginData('exercise-graph') as ExerciseGraphData | undefined; + const allData = useAllDocsData(); + const items = data?.byLesson?.[lessonKey]; + if (!items || items.length === 0) return null; + + return ( +
+ + Esercizi assegnati + +
+ {items.map((item) => { + const to = resolvePermalink(allData, 'apprendista', item.docId); + if (!to) return null; + return ( + + {item.title} + + ); + })} +
+
+ ); +} diff --git a/src/components/AssignedExercises/styles.module.css b/src/components/AssignedExercises/styles.module.css new file mode 100644 index 0000000..650d1e2 --- /dev/null +++ b/src/components/AssignedExercises/styles.module.css @@ -0,0 +1,21 @@ +/* Sezione «Esercizi assegnati» in fondo a una lezione. Sta FUORI dal corpo + markdown (iniettata dallo swizzle DocItem/Footer): non eredita la tipografia + della prosa, quindi definisce il proprio spazio e separatore. */ +.section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--at-border); +} + +.heading { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 700; + color: var(--at-fg-strong); + margin-bottom: 0.75rem; +} + +.list { + display: flex; + flex-direction: column; +} diff --git a/src/components/Exercise/index.tsx b/src/components/Exercise/index.tsx new file mode 100644 index 0000000..d00fb83 --- /dev/null +++ b/src/components/Exercise/index.tsx @@ -0,0 +1,343 @@ +/** + * Componenti per le pagine-esercizio del Volume 4 (Biblioteca dell'Apprendista). + * + * Modello IBRIDO: una lezione con soli esercizi rapidi è una pagina singola; + * se ha anche esercizi dedicati / laboratori diventa una cartella con una pagina + * per tipo. La provenienza è dichiarata nel FRONTMATTER (assigned_in/theory) e la + * card è DERIVATA + auto-iniettata dallo swizzle DocItem/Content. + * + * card compatta derivata dal global data (badge + provenienza) + * un esercizietto (nelle pagine batch) + * ```py live readonly … ``` soluzione (PyRunner non editabile) + * + * Numerazione: id interno volume.capitolo.lezione.esercizio (es. 1.3.10.2). + * A video l'esercizio mostra la forma corta lezione.esercizio (es. 10.2). + * Tutti i riferimenti sono identificatori on-site (path/permalink), mai esterni. + */ +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import Link from '@docusaurus/Link'; +import Heading from '@theme/Heading'; +import { usePluginData } from '@docusaurus/useGlobalData'; +import { useDoc, useAllDocsData } from '@docusaurus/plugin-content-docs/client'; +import Icon, { type IconName } from '@site/src/components/Icon'; +import { + resolvePermalink, + volumeLabel, + type ExerciseGraphData, + type ExerciseKind, + type LessonRef, +} from '@site/src/lib/docResolve'; + +import styles from './styles.module.css'; + +// ─── LessonMeta ────────────────────────────────────────────────────────────── + +/** + * Card di provenienza dell'esercizio. È DERIVATA: legge il record dal global + * data del plugin `exercise-graph` (chiave = docId della pagina corrente) e ne + * risolve i permalink. Non prende props — viene auto-iniettata dallo swizzle + * DocItem/Content. Render `null` se la pagina non è un esercizio noto. + * + * Tipo di pagina-esercizio (badge): + * rapidi → batch di esercizietti «a batteria» (più in pagina) + * esercizio → un esercizio completo, pagina dedicata + * laboratorio → laboratorio/progetto, pagina dedicata + * + * NB: il «capitolo» della lezione-sorgente non è derivabile a build-time + * (volumi flat, capitoli solo nelle sidebar) → la crumb è «Volume › Lezione». + */ +const KIND: Record = { + rapidi: { label: 'Esercizi rapidi', icon: 'dumbbell' }, + esercizio: { label: 'Esercizio', icon: 'pen' }, + laboratorio: { label: 'Laboratorio', icon: 'flask' }, +}; + +export function LessonMeta(): ReactNode { + const data = usePluginData('exercise-graph') as ExerciseGraphData | undefined; + const allData = useAllDocsData(); + const { metadata } = useDoc(); + const record = data?.exercises?.[metadata.id]; + if (!record) return null; + + const k = KIND[record.kind] ?? KIND.rapidi; + const section = record.n ?? record.lessonId; + const lessonTo = resolvePermalink( + allData, + record.assignedIn.volume, + record.assignedIn.docId, + ); + + const theory = record.theory + .map((t: LessonRef) => ({ + to: resolvePermalink(allData, t.volume, t.docId), + label: t.title, + })) + .filter((t): t is { to: string; label: string } => t.to !== null); + + return ( + + ); +} + +// ─── Exercise ──────────────────────────────────────────────────────────────── + +interface ExerciseProps { + /** Numero a video, forma corta lezione.esercizio, es. «10.2». */ + n: string; + /** Titolo dell'esercizio, es. «Calcola la media». */ + title: string; + children: ReactNode; +} + +export function Exercise({ n, title, children }: ExerciseProps): ReactNode { + const id = `esercizio-${n.replace(/\./g, '-')}`; + return ( +
+ + {n} + {title} + + {children} +
+ ); +} + +// ─── Solution ──────────────────────────────────────────────────────────────── + +/** Millisecondi di pressione continua per rivelare la soluzione. */ +const HOLD_MS = 3000; + +interface SolutionProps { + /** Etichetta principale (prominente). Default: «Soluzione». */ + label?: string; + /** Riga di servizio (attenuata). Default: «tieni premuto per visualizzare». */ + hint?: string; + children: ReactNode; +} + +/** + * Soluzione a rivelazione protetta: per evitare lo spoiler da misclick, il + * corpo si apre solo dopo aver tenuto premuto il pulsante per {@link HOLD_MS}. + * Feedback: una barra di avanzamento (nell'accento per-tipo della pagina) + * riempie il pulsante e l'icona passa in cross-fade da tessera-di-puzzle (a + * riposo) a mano-che-preme (in pressione); al rilascio anticipato tutto torna + * indietro. + * Una volta rivelata, il pulsante diventa un normale toggle apri/chiudi + * (niente più rischio spoiler). + * + * Il progress è guidato da requestAnimationFrame scritto inline sull'elemento + * (non da una transition CSS) così resta visibile anche con + * `prefers-reduced-motion`, dove le transition globali sono azzerate. + */ +export function Solution({ + label = 'Soluzione', + hint = 'tieni premuto per visualizzare', + children, +}: SolutionProps): ReactNode { + const [revealed, setRevealed] = useState(false); + const [open, setOpen] = useState(false); + const [holding, setHolding] = useState(false); + + const fillRef = useRef(null); + const rafRef = useRef(null); + const startRef = useRef(0); + // gesto di pressione in corso (da puntatore) + soppressione del click che + // ogni gesto di puntatore emette al rilascio: senza, il click finale + // richiuderebbe la soluzione appena rivelata dall'hold. + const gestureRef = useRef(false); + const suppressClickRef = useRef(false); + + const setFill = useCallback((p: number) => { + if (fillRef.current) fillRef.current.style.transform = `scaleX(${p})`; + }, []); + + const stopRaf = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }, []); + + const startHold = useCallback( + (e: ReactPointerEvent) => { + // Già rivelata o tasto non primario: lascia che sia il click a gestire. + if (revealed || e.button !== 0) return; + e.preventDefault(); + gestureRef.current = true; + setHolding(true); + startRef.current = performance.now(); + // durante l'hold la barra segue il RAF fotogramma per fotogramma + if (fillRef.current) fillRef.current.style.transition = 'none'; + stopRaf(); + const step = (now: number) => { + const p = Math.min(1, (now - startRef.current) / HOLD_MS); + setFill(p); + if (p >= 1) { + stopRaf(); + setRevealed(true); + setOpen(true); + return; + } + rafRef.current = requestAnimationFrame(step); + }; + rafRef.current = requestAnimationFrame(step); + }, + [revealed, stopRaf, setFill], + ); + + // fine di un gesto di puntatore (rilascio, uscita o annullamento) + const endGesture = useCallback(() => { + stopRaf(); + if (gestureRef.current) { + gestureRef.current = false; + suppressClickRef.current = true; + } + setHolding(false); + // al rilascio la barra rientra con un breve ease (deterministico, inline) + if (fillRef.current) { + fillRef.current.style.transition = 'transform 160ms var(--ease-out)'; + } + setFill(0); + }, [stopRaf, setFill]); + + const onClick = useCallback(() => { + // Sopprimi il click sintetico che segue ogni gesto di puntatore. + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + // Qui ci arriva solo l'attivazione da tastiera (Enter/Space) o il click su + // soluzione già rivelata: la prima rivela, il secondo fa toggle. + if (revealed) setOpen((v) => !v); + else { + setRevealed(true); + setOpen(true); + } + }, [revealed]); + + useEffect(() => () => stopRaf(), [stopRaf]); + + return ( +
+ + {open &&
{children}
} +
+ ); +} + +export default Exercise; diff --git a/src/components/Exercise/styles.module.css b/src/components/Exercise/styles.module.css new file mode 100644 index 0000000..1d09966 --- /dev/null +++ b/src/components/Exercise/styles.module.css @@ -0,0 +1,394 @@ +/* LessonMeta — card di riferimento esercizio. + Stile ripreso dal redesign Claude Design (progetto "Rainbow bits", + ExerciseCard), riadattato ai token, font e icone del progetto. + Niente bordo-accento sinistro: l'accento sta nel pannello "ancora". */ + +.card { + /* mappa la palette del design sui nostri token (che gestiscono già dark/light) */ + --c-mono: var(--font-mono-ui); + --c-serif: var(--font-body); + --c-bg-from: var(--at-bg-panel); + --c-bg-to: var(--at-bg-subtle); + --c-border: var(--at-border); + --c-border-soft: color-mix(in srgb, var(--at-border) 55%, transparent); + --c-fg-strong: var(--at-fg-strong); + --c-fg-body: var(--at-fg-body); + --c-muted: var(--at-muted); + --c-faint: var(--at-faint); + /* accento per-tipo: un solo colore, i chip si derivano da qui */ + --ex-accent: var(--at-accent); + --ex-accent-soft: var(--at-accent-soft); + + --c-accent: var(--ex-accent); + --c-accent-soft: var(--ex-accent-soft); + --c-accent-dim: var(--ex-accent-soft); + --c-accent-chip: color-mix(in srgb, var(--ex-accent) 9%, transparent); + --c-accent-chip-bd: color-mix(in srgb, var(--ex-accent) 24%, transparent); + --c-anchor-bg: color-mix(in srgb, var(--ex-accent) 4%, transparent); + + position: relative; + background: linear-gradient(180deg, var(--c-bg-from), var(--c-bg-to)); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + overflow: hidden; + margin: 0 0 32px; +} + +/* Il colore per-tipo arriva dall'accento di pagina (custom.css, + [data-exercise-kind]): la card eredita --at-accent, niente override locale. */ + +.inner { + display: flex; + align-items: stretch; +} + +/* ── ancora (sinistra): tipo + numero ───────────────────── */ +.anchor { + display: flex; + flex-direction: row; + align-items: center; + gap: 15px; + padding: 18px 22px; + border-right: 1px solid var(--c-border); + background: var(--c-anchor-bg); + min-width: 180px; +} + +.iconTile { + align-self: center; + /* quadrata, alta quanto il blocco label-tipo + numero esercizio */ + width: 46px; + height: 46px; + flex-shrink: 0; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + background: var(--c-accent-chip); + border: 1px solid var(--c-accent-chip-bd); + color: var(--c-accent); +} + +.anchorText { + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + min-width: 0; +} + +.typeLabel { + font-family: var(--c-mono); + font-size: 12.5px; + font-weight: 600; + line-height: 1.2; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--c-accent-soft); +} + +.section { + font-family: var(--c-mono); + font-size: 25px; + font-weight: 600; + line-height: 1; + letter-spacing: -0.01em; + color: var(--c-fg-strong); +} + +/* ── meta (destra): assegnato in / teoria ───────────────── */ +.meta { + flex: 1; + min-width: 0; + padding: 16px 26px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 12px; +} + +.row { + display: flex; + align-items: center; + gap: 14px; +} + +.label { + width: 124px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--c-mono); + font-size: 11px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--c-faint); + white-space: nowrap; +} + +/* l'icona duotone ha il viewBox quadrato: la centriamo otticamente sulla + x-height del testo maiuscoletto */ +.label :global(svg) { + display: block; + margin-top: -1px; +} + +.divider { + height: 1px; + background: var(--c-border-soft); +} + +/* ── breadcrumb ─────────────────────────────────────────── */ +.crumbs { + font-family: var(--c-serif); + font-size: 16px; + line-height: 1.4; + color: var(--c-muted); +} + +.chevron { + color: var(--c-faint); + font-family: var(--c-mono); + font-size: 0.82em; + padding: 0 4px; + user-select: none; +} + +.crumbLeaf { + color: var(--c-accent-soft); + font-weight: 600; + text-decoration: none; + border-bottom: 1px solid var(--c-accent-chip-bd); + padding-bottom: 1px; +} + +.crumbLeaf:hover { + color: var(--c-accent); + border-bottom-color: var(--c-accent); + text-decoration: none; +} + +/* ── link teoria ────────────────────────────────────────── */ +.theory { + display: inline-flex; + flex-wrap: wrap; + gap: 4px 16px; + align-items: baseline; +} + +.theoryLink { + font-family: var(--c-serif); + font-size: 16px; + color: var(--c-fg-body); + text-decoration: none; + border-bottom: 1px solid var(--c-accent-dim); + padding-bottom: 1px; + transition: color var(--dur-fast) var(--ease-out); +} + +.theoryLink:hover { + color: var(--c-accent-soft); + text-decoration: none; +} + +/* ── responsive: su schermi stretti impila ancora + meta ── */ +@media (max-width: 600px) { + .inner { + flex-direction: column; + } + .anchor { + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 16px; + border-right: none; + border-bottom: 1px solid var(--c-border); + min-width: 0; + } + .label { + width: auto; + } + .row { + flex-wrap: wrap; + gap: 6px 12px; + } +} + +/* ─── Exercise ─────────────────────────────────────────────────────────────── */ + +.exercise { + margin: 40px 0; + scroll-margin-top: 80px; +} + +.exHeading { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 16px; +} + +.exNum { + flex-shrink: 0; + font-family: var(--font-mono-ui); + font-size: 0.78em; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--at-accent); + background: var(--at-accent-bg); + border: 1px solid var(--at-accent-chip-border); + border-radius: var(--radius-sm); + /* padding verticale asimmetrico in em: le cifre non hanno discendenti, così + lo spazio vuoto del descender finisce tutto sotto e il chip sembra basso- + pesante. Più padding sopra che sotto ricentra otticamente l'inchiostro + (compensa ≈0.16em di overhang del descender); in em così scala col font. */ + padding: 0.3em 10px 0.14em; + line-height: 1; +} + +.exTitle { + min-width: 0; +} + +/* ─── Solution (tieni-premuto-per-rivelare) ────────────────────────────────── */ + +.solution { + /* segue l'accento per-tipo della pagina (rapidi=ambra, esercizio=viola, + laboratorio=teal); fuori da una pagina-esercizio ricade sul blu di default */ + --sol: var(--at-accent); + margin: 16px 0 0; + border: 1px solid var(--at-border); + border-radius: var(--radius-md); + background: var(--at-bg-subtle); + overflow: hidden; +} + +.solutionSummary { + position: relative; + display: flex; + align-items: center; + gap: 11px; + width: 100%; + margin: 0; + padding: 11px 16px; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; + font-family: var(--font-mono-ui); + color: var(--at-fg-strong); + user-select: none; + -webkit-touch-callout: none; + /* la pressione lunga su touch non deve selezionare testo o scrollare */ + touch-action: none; + transition: background var(--dur-fast) var(--ease-out); +} + +.solutionSummary:hover { + background: var(--at-bg-chip); +} + +/* barra di avanzamento: riempie il pulsante durante la pressione. Lo scaleX e + la sua transition sono guidati inline da JS (requestAnimationFrame durante + l'hold, breve ease al rilascio): vedi src/components/Exercise/index.tsx. */ +.solutionFill { + position: absolute; + inset: 0; + transform: scaleX(0); + transform-origin: left center; + background: color-mix(in srgb, var(--sol) 16%, transparent); + pointer-events: none; +} + +[data-holding] .solutionFill { + /* bordo destro come "testa" della barra, per leggere meglio l'avanzamento */ + box-shadow: inset -2px 0 0 0 color-mix(in srgb, var(--sol) 55%, transparent); +} + +/* contenuti sopra la barra */ +.solutionIconWrap, +.solutionLabels, +.solutionChevron { + position: relative; + z-index: 1; +} + +/* cross-fade icona: tessera-di-puzzle (a riposo) ⇄ mano-che-preme (in pressione) */ +.solutionIconWrap { + flex-shrink: 0; + width: 32px; + height: 32px; + display: grid; + place-items: center; + color: var(--sol); +} + +.solutionIcon { + grid-area: 1 / 1; + transition: + opacity var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out); +} + +.solutionIconRest { + opacity: 1; + transform: scale(1); +} + +.solutionIconHold { + opacity: 0; + transform: scale(0.78); +} + +[data-holding] .solutionIconRest { + opacity: 0; + transform: scale(0.78); +} + +[data-holding] .solutionIconHold { + opacity: 1; + transform: scale(1); +} + +.solutionLabels { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.solutionLabel { + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.3; +} + +.solutionHint { + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.01em; + line-height: 1.2; + color: var(--at-faint); +} + +.solutionChevron { + margin-left: auto; + color: var(--at-muted); + transform: rotate(90deg); + transition: transform var(--dur-fast) var(--ease-out); +} + +[aria-expanded='true'] .solutionChevron { + transform: rotate(-90deg); +} + +.solutionBody { + position: relative; + z-index: 1; + padding: 4px 16px 14px; + border-top: 1px solid var(--at-border); +} diff --git a/src/components/ExerciseLink/index.tsx b/src/components/ExerciseLink/index.tsx new file mode 100644 index 0000000..971c84f --- /dev/null +++ b/src/components/ExerciseLink/index.tsx @@ -0,0 +1,72 @@ +/** + * ExerciseLink — card-link da usare nelle lezioni (volumi 1–3) per elencare gli + * esercizi assegnati, che vivono nel Volume 4 (Biblioteca dell'Apprendista). + * + * Il riferimento è SEMPRE un identificatore on-site: `to` è il path del sito + * (senza baseUrl, lo aggiunge Docusaurus), es. + * /apprendista/programmatore/le-basi/le-variabili/calcola-la-media + * Mai riferimenti interni esterni al sito (niente paideia_id o simili). + */ +import React, { type ReactNode } from 'react'; +import Link from '@docusaurus/Link'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import type { Difficulty } from '@site/src/lib/docResolve'; + +import styles from './styles.module.css'; + +interface ExerciseLinkProps { + /** Path on-site dell'esercizio (senza baseUrl). Es. `/apprendista/...`. */ + to: string; + /** Difficoltà, opzionale: mostra un chip colorato. */ + difficulty?: Difficulty; + /** Tempo stimato, opzionale. Es. `20 min`. */ + time?: string; + /** Titolo dell'esercizio. */ + children: ReactNode; +} + +const DIFFICULTY_LABEL: Record = { + facile: 'Facile', + medio: 'Medio', + difficile: 'Difficile', +}; + +export default function ExerciseLink({ + to, + difficulty, + time, + children, +}: ExerciseLinkProps): ReactNode { + return ( + +