diff --git a/14-css-animation-builder/index.html b/14-css-animation-builder/index.html
new file mode 100644
index 0000000..0573d3e
--- /dev/null
+++ b/14-css-animation-builder/index.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+ CSS Animation Builder
+
+
+
+
+ CSS Animation Builder
+
+
+
+
+
+
+ Generated CSS
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/14-css-animation-builder/script.js b/14-css-animation-builder/script.js
new file mode 100644
index 0000000..0b67d40
--- /dev/null
+++ b/14-css-animation-builder/script.js
@@ -0,0 +1,162 @@
+
+
+const box = document.getElementById('preview-box');
+const output = document.getElementById('output');
+const copyBtn = document.getElementById('copy-btn');
+
+const controls = {
+ tx: document.getElementById('tx'),
+ ty: document.getElementById('ty'),
+ scale: document.getElementById('scale'),
+ rotate: document.getElementById('rotate'),
+ opacity: document.getElementById('opacity'),
+ duration: document.getElementById('duration'),
+ delay: document.getElementById('delay'),
+ easing: document.getElementById('easing'),
+ iterations: document.getElementById('iterations'),
+ direction: document.getElementById('direction')
+};
+
+function getSettings() {
+ const tx = Number(controls.tx.value) || 0;
+ const ty = Number(controls.ty.value) || 0;
+ const scale = Number(controls.scale.value) || 1;
+ const rotate = Number(controls.rotate.value) || 0;
+ const opacity = Number(controls.opacity.value);
+ const duration = Number(controls.duration.value) || 1;
+ const delay = Number(controls.delay.value) || 0;
+ const easing = controls.easing.value || 'ease';
+ const iterationsRaw = Number(controls.iterations.value);
+ const iterations = iterationsRaw > 0 ? iterationsRaw : 1;
+ const direction = controls.direction.value || 'normal';
+
+ return {
+ tx,
+ ty,
+ scale,
+ rotate,
+ opacity,
+ duration,
+ delay,
+ easing,
+ iterations,
+ direction
+ };
+}
+
+function ensureStyleTag() {
+ let styleTag = document.getElementById('dynamic-keyframes');
+ if (!styleTag) {
+ styleTag = document.createElement('style');
+ styleTag.id = 'dynamic-keyframes';
+ document.head.appendChild(styleTag);
+ }
+ return styleTag;
+}
+
+function buildCSS(settings) {
+ const {
+ tx,
+ ty,
+ scale,
+ rotate,
+ opacity,
+ duration,
+ delay,
+ easing,
+ iterations,
+ direction
+ } = settings;
+
+ const fromTransform = 'translate(0px, 0px) scale(1) rotate(0deg)';
+ const toTransform = `translate(${tx}px, ${ty}px) scale(${scale}) rotate(${rotate}deg)`;
+ const fromOpacity = 1;
+ const toOpacity = opacity;
+
+ const animationLine = `animation: myAnim ${duration}s ${easing} ${delay}s ${iterations} ${direction};`;
+
+ const keyframes = `
+@keyframes myAnim {
+ from {
+ transform: ${fromTransform};
+ opacity: ${fromOpacity};
+ }
+ to {
+ transform: ${toTransform};
+ opacity: ${toOpacity};
+ }
+}
+`.trim();
+
+ const css = `.my-element {
+ ${animationLine}
+}
+
+${keyframes}
+`;
+
+ return { css, keyframes, animationLine };
+}
+
+function applyAnimation() {
+ const settings = getSettings();
+ const { css, keyframes, animationLine } = buildCSS(settings);
+
+ // Restart animation
+ box.style.animation = 'none';
+ // Force reflow so the browser picks up the reset
+ void box.offsetWidth;
+
+ box.style.animation = `myAnim ${settings.duration}s ${settings.easing} ${settings.delay}s ${settings.iterations} ${settings.direction}`;
+
+ const styleTag = ensureStyleTag();
+ styleTag.textContent = keyframes;
+
+ output.value = css;
+}
+
+function attachListeners() {
+ Object.values(controls).forEach((el) => {
+ const eventName = el.tagName === 'SELECT' ? 'change' : 'input';
+ el.addEventListener(eventName, applyAnimation);
+ });
+
+ copyBtn.addEventListener('click', () => {
+ const text = output.value.trim();
+ if (!text) return;
+
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => flashCopyState(), () => fallbackCopy(text));
+ } else {
+ fallbackCopy(text);
+ }
+ });
+}
+
+function fallbackCopy(text) {
+ output.focus();
+ output.select();
+ try {
+ document.execCommand('copy');
+ flashCopyState();
+ } catch (err) {
+ // ignore
+ } finally {
+ output.setSelectionRange(0, 0);
+ output.blur();
+ }
+}
+
+function flashCopyState() {
+ const original = copyBtn.textContent;
+ copyBtn.textContent = 'copied';
+ copyBtn.disabled = true;
+
+ setTimeout(() => {
+ copyBtn.textContent = original;
+ copyBtn.disabled = false;
+ }, 1200);
+}
+
+attachListeners();
+applyAnimation();
\ No newline at end of file
diff --git a/14-css-animation-builder/styles.css b/14-css-animation-builder/styles.css
new file mode 100644
index 0000000..4e97fb4
--- /dev/null
+++ b/14-css-animation-builder/styles.css
@@ -0,0 +1,353 @@
+:root {
+ color-scheme: dark;
+ --bg: #050816;
+ --panel: #0f172a;
+ --panel-soft: #111827;
+ --accent: #22c55e;
+ --accent-soft: #16a34a33;
+ --text: #e5e7eb;
+ --muted: #9ca3af;
+ --border: #1f2937;
+ --radius: 12px;
+ --shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.7);
+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
+ "Segoe UI", sans-serif;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+}
+
+body {
+ font-family: var(--font-sans);
+ background: radial-gradient(circle at top, #1e293b 0, #020617 45%, #000 100%);
+ color: var(--text);
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ padding: 24px;
+}
+
+main.container {
+ width: 100%;
+ max-width: 1100px;
+ background: linear-gradient(145deg, #020617 0, #020617 40%, #020617 100%);
+ border-radius: 20px;
+ box-shadow: var(--shadow-soft);
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ padding: 24px;
+ display: grid;
+ grid-template-rows: auto auto auto;
+ gap: 20px;
+}
+
+@media (min-width: 960px) {
+ main.container {
+ grid-template-rows: auto 1fr auto;
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.3fr);
+ grid-template-areas:
+ "title title"
+ "preview controls"
+ "output output";
+ }
+}
+
+h1 {
+ font-size: 1.5rem;
+ margin: 0 0 8px;
+ letter-spacing: 0.04em;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+h1::before {
+ content: "";
+ width: 10px;
+ height: 22px;
+ border-radius: 999px;
+ background: linear-gradient(180deg, #22c55e, #22c55e33);
+}
+
+.preview-section {
+ grid-area: preview;
+ background: radial-gradient(circle at top left, #0f172a, #020617 55%);
+ border-radius: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.preview-section h2 {
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ color: var(--muted);
+ margin: 0;
+}
+
+.preview-stage {
+ flex: 1;
+ min-height: 220px;
+ border-radius: 14px;
+ background: radial-gradient(circle at 20% 0, #22c55e22, transparent 60%),
+ radial-gradient(circle at 80% 120%, #22c55e11, transparent 60%);
+ border: 1px solid rgba(15, 23, 42, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.box {
+ width: 120px;
+ height: 120px;
+ border-radius: 26px;
+ background: conic-gradient(
+ from 160deg,
+ #bbf7d0 0,
+ #22c55e 40%,
+ #4ade80 65%,
+ #22c55e 100%
+ );
+ box-shadow: 0 20px 45px rgba(34, 197, 94, 0.37);
+ transform-origin: center;
+}
+
+.preview-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.75rem;
+ color: var(--muted);
+}
+
+.preview-meta span {
+ opacity: 0.9;
+}
+
+.preview-pill {
+ padding: 4px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(148, 163, 184, 0.3);
+ background: rgba(15, 23, 42, 0.7);
+}
+
+/* Controls */
+
+.controls-section {
+ grid-area: controls;
+ display: grid;
+ gap: 16px;
+ background: radial-gradient(circle at top, #020617, #020617 60%);
+ padding: 18px;
+ border-radius: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+}
+
+.group {
+ background: linear-gradient(145deg, #020617, #020617);
+ border-radius: 14px;
+ padding: 14px 14px 10px;
+ border: 1px solid rgba(30, 64, 175, 0.55);
+}
+
+.group h2 {
+ font-size: 0.9rem;
+ margin: 0 0 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: var(--muted);
+}
+
+.controls-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px 14px;
+}
+
+@media (min-width: 720px) {
+ .controls-grid {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ }
+}
+
+.control {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 0.78rem;
+}
+
+.control-line {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+
+.control label {
+ color: var(--muted);
+}
+
+.control span.value {
+ color: var(--accent);
+ font-variant-numeric: tabular-nums;
+}
+
+input[type="range"] {
+ width: 100%;
+ appearance: none;
+ height: 4px;
+ border-radius: 999px;
+ background: #020617;
+ outline: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 999px;
+ background: var(--accent);
+ box-shadow: 0 0 0 4px var(--accent-soft);
+ border: 2px solid #022c22;
+ cursor: pointer;
+}
+
+input[type="range"]::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 999px;
+ background: var(--accent);
+ box-shadow: 0 0 0 4px var(--accent-soft);
+ border: 2px solid #022c22;
+ cursor: pointer;
+}
+
+input[type="number"],
+select {
+ width: 100%;
+ padding: 6px 8px;
+ border-radius: 8px;
+ border: 1px solid rgba(55, 65, 81, 0.9);
+ background: rgba(15, 23, 42, 0.95);
+ color: var(--text);
+ font-size: 0.8rem;
+}
+
+input[type="number"]:focus,
+select:focus {
+ outline: 1px solid var(--accent);
+ border-color: var(--accent);
+}
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ appearance: none;
+ margin: 0;
+}
+
+/* Output */
+
+.output-section {
+ grid-area: output;
+ background: radial-gradient(circle at top, #020617, #020617 70%);
+ border-radius: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.25);
+ padding: 16px 18px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.output-section h2 {
+ font-size: 0.9rem;
+ margin: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: var(--muted);
+}
+
+textarea#output {
+ width: 100%;
+ min-height: 130px;
+ max-height: 240px;
+ resize: vertical;
+ border-radius: 10px;
+ border: 1px solid rgba(31, 41, 55, 0.9);
+ background: #020617;
+ color: #e5e7eb;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
+ "Liberation Mono", "Courier New", monospace;
+ font-size: 0.8rem;
+ padding: 10px 11px;
+ line-height: 1.5;
+ white-space: pre;
+}
+
+button#copy-btn {
+ align-self: flex-end;
+ margin-top: 4px;
+ padding: 7px 13px;
+ border-radius: 999px;
+ border: 1px solid rgba(34, 197, 94, 0.9);
+ background: radial-gradient(circle at top, #22c55e, #16a34a);
+ color: #022c22;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ letter-spacing: 0.03em;
+}
+
+button#copy-btn:hover {
+ filter: brightness(1.08);
+}
+
+button#copy-btn:active {
+ transform: translateY(1px);
+}
+
+
+@media (max-width: 768px) {
+ body {
+ padding: 16px 10px;
+ }
+
+ main.container {
+ padding: 16px 14px;
+ gap: 16px;
+ max-width: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .preview-section,
+ .controls-section,
+ .output-section {
+ padding: 14px;
+ }
+
+ .preview-stage {
+ min-height: 200px;
+ }
+
+ textarea#output {
+ min-height: 160px;
+ }
+}
diff --git a/xx-dice/index.html b/xx-dice/index.html
index 7c333e4..6215a73 100644
--- a/xx-dice/index.html
+++ b/xx-dice/index.html
@@ -75,14 +75,30 @@
font-size: 16px;
cursor: pointer;
}
+
+ button:focus-visible {
+ outline: 3px solid #61dafb;
+ outline-offset: 2px;
+ }
+
+ .result {
+ margin-top: 16px;
+ font-size: 18px;
+ }
@@ -126,7 +165,10 @@
2
-
+
+
+ Pulsa el botón para lanzar el dado.
+