1
0

Initial commit

add blocksystem files.
This commit is contained in:
Alonso Cárdenas
2026-05-30 12:17:06 -04:00
parent 5885de3ef3
commit 244123a26f
15 changed files with 9022 additions and 0 deletions

896
blocksystem/src/App.vue Normal file
View File

@@ -0,0 +1,896 @@
<template>
<div class="app">
<!-- Start screen -->
<div v-if="screen === null" class="center">
<h1>🧱 Blocks</h1>
<button @click="screen = 'exercises'">Demo de Ejercicios</button>
<button @click="screen = 'creator'">Crear Ejercicio</button>
</div>
<!-- Demo Exercises screen -->
<div v-else-if="screen === 'exercises'" class="page">
<div class="bar">
<button @click="screen = null">Volver</button>
<button @click="resetAll">Reiniciar</button>
</div>
<div class="exercise-scroll">
<div class="exercise-list">
<Blocks v-for="(ex, idx) in demoExercises" :key="'demo-' + demoKeys[idx]" :exercise="ex"
@complete="r => onComplete('demo', idx, r)" />
</div>
</div>
</div>
<!-- User Exercises screen -->
<div v-else-if="screen === 'user-exercises'" class="page">
<div class="bar">
<button @click="screen = null">Volver</button>
<button @click="resetAllUser">Reiniciar</button>
<button @click="screen = 'creator'" style="margin-left:auto">+ Crear</button>
</div>
<div class="exercise-scroll">
<div class="exercise-list">
<div v-if="!userExercises.length" class="empty-state">No hay ejercicios creados todavía.</div>
<Blocks v-for="(ex, idx) in userExercises" :key="'user-' + userKeys[idx]" :exercise="ex"
@complete="r => onComplete('user', idx, r)" />
</div>
</div>
</div>
<!-- Creator screen -->
<div v-else-if="screen === 'creator'" class="page">
<div class="bar">
<button @click="screen = 'user-exercises'">Volver</button>
<span style="font-weight:600">Crear ejercicio</span>
<button v-if="draft.length" class="btn-load" @click="loadDraft" style="margin-left:auto">
Cargar {{ draft.length }} ejercicio{{ draft.length > 1 ? 's' : '' }}
</button>
</div>
<div class="creator-scroll">
<div class="creator-layout">
<!-- LEFT: form -->
<div class="creator-left">
<!-- Draft list -->
<div v-if="draft.length" class="draft-list">
<div v-for="(ex, i) in draft" :key="i" class="draft-item"
:class="{ 'draft-item--pinned': pinnedPreview === ex }"
@click="pinnedPreview = pinnedPreview === ex ? null : ex">
<span class="draft-nums">{{ ex.grids.filter(g => g.mode !== 'target').map(g => g.label || g.value).join(' + ') }}</span>
<span class="draft-dropzones"> {{ ex.grids.filter(g => g.mode === 'target').map(g => g.label).join(', ') || 'sin banca' }}</span>
<button class="btn-icon btn-delete"
@click.stop="draft.splice(i, 1); if (pinnedPreview === ex) pinnedPreview = null"></button>
</div>
</div>
<!-- Form -->
<div class="creator-form">
<!-- Exercise type -->
<div class="field">
<label>Tipo de ejercicio</label>
<div class="mode-toggle">
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'move-blocks' }"
@click="form.exerciseType = 'move-blocks'">Mover bloques</button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'input-check' }"
@click="form.exerciseType = 'input-check'">Escribir número</button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.exerciseType === 'sides' }"
@click="form.exerciseType = 'sides'">Lados </button>
</div>
</div>
<!-- Layout -->
<div class="field" v-if="form.exerciseType !== 'sides'">
<label>Disposición</label>
<div class="mode-toggle">
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row' }"
@click="form.layout = 'row'">Fila </button>
<button v-if="form.exerciseType !== 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'column' }"
@click="form.layout = 'column'">Columna </button>
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row-answer-right' }"
@click="form.layout = 'row-answer-right'">Resp. </button>
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'row-answer-bottom' }"
@click="form.layout = 'row-answer-bottom'">Resp. </button>
<button v-if="form.exerciseType === 'move-blocks'" class="mode-btn" :class="{ 'mode-btn--active': form.layout === 'column' }"
@click="form.layout = 'column'">Columna </button>
</div>
</div>
<!-- Answer position for input-check -->
<div class="field" v-if="form.exerciseType === 'input-check'">
<label>Posición de la casilla</label>
<div class="mode-toggle">
<button class="mode-btn" :class="{ 'mode-btn--active': form.answerPosition === 'right' }"
@click="form.answerPosition = 'right'">Derecha </button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.answerPosition === 'bottom' }"
@click="form.answerPosition = 'bottom'">Abajo </button>
</div>
</div>
<div class="field">
<label>Opciones</label>
<div class="mode-toggle">
<button class="mode-btn" :class="{ 'mode-btn--active': !form.hideBorders }"
@click="form.hideBorders = false">Bordes visibles</button>
<button class="mode-btn" :class="{ 'mode-btn--active': form.hideBorders }"
@click="form.hideBorders = true">Sin bordes</button>
</div>
</div>
<div class="field">
<label>Enunciado <span class="hint">(opcional)</span></label>
<input v-model="form.text" type="text" placeholder="Ej: Pedro tiene 5 manzanas..." />
</div>
<div class="field">
<label>Grids <span class="hint"> bloques por grupo</span></label>
<div class="grids-col">
<div v-for="(g, i) in form.grids" :key="i" class="grid-entry">
<div class="grid-row">
<span class="grid-idx">{{ i + 1 }}</span>
<input class="grid-num-input" type="number" min="1" max="20" placeholder=""
:value="g.value > 0 ? g.value : ''" @input="onGridValueInput(i, $event)"
@blur="onGridBlur(i, $event)" />
<input v-if="g.value > 0" class="grid-label-input" type="text" :placeholder="'Nombre ' + (i + 1)"
v-model="g.label" />
</div>
<!-- Fruit picker dropdown select -->
<div v-if="g.value > 0" class="fruit-select-wrap">
<select class="fruit-select" v-model="g.fruit">
<option :value="null">🟥 Bloques de color</option>
<option v-for="f in FRUITS" :key="f.key" :value="f.key">
{{ f.emoji }} {{ f.label }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- move-blocks / sides: banca config -->
<div class="field" v-if="(form.exerciseType === 'move-blocks' || form.exerciseType === 'sides') && validGrids.length">
<label>Banca <span class="hint"> respuesta esperada por origen</span></label>
<div class="grid-row" style="margin-bottom:0.5rem">
<input class="grid-label-input" type="text" placeholder="Etiqueta respuesta" v-model="form.bancaLabel"
style="max-width:180px" />
</div>
<div class="needs-table">
<div v-for="(g, i) in validGrids" :key="i" class="needs-row">
<span class="needs-grid-idx">{{ form.grids.indexOf(g) + 1 }}</span>
<span class="needs-label">{{ g.label || ('Grid ' + (form.grids.indexOf(g) + 1)) }}</span>
<span class="needs-arrow"></span>
<input class="grid-num-input grid-num-input--sm" type="number" min="0" max="20" placeholder="0"
:value="g.needs > 0 ? g.needs : ''" @input="onGridNeedsInput(form.grids.indexOf(g), $event)" />
<span class="needs-hint">bloques</span>
</div>
</div>
</div>
<!-- input-check: expected answer -->
<div class="field" v-if="form.exerciseType === 'input-check' && validGrids.length">
<label>Respuesta correcta</label>
<div class="grid-row">
<input class="grid-num-input" type="number" min="0" max="99" placeholder="?"
v-model.number="form.expectedAnswer" />
<span class="needs-hint">valor esperado</span>
</div>
</div>
<button class="btn-add-draft" @click="addToDraft" :disabled="!canAdd">
+ Agregar a la lista
</button>
</div>
</div>
<!-- RIGHT: live preview -->
<div class="creator-right">
<div class="preview-label">Vista previa</div>
<div v-if="!previewExercise" class="preview-empty">
Ingresa al menos un número para ver el ejercicio.
</div>
<div v-else class="preview-card" :class="{ 'preview-card--last': !previewIsLive }">
<p v-if="!previewIsLive" class="preview-last-label">Último agregado</p>
<div class="preview-blocks-wrap">
<Blocks :key="previewKey" :exercise="previewExercise" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import Blocks from './components/Blocks.vue';
// ── SCREEN ──
const screen = ref(null);
// ── FRUIT CATALOGUE ──
// Keys must match what Blocks.vue / FRUIT_DICT accepts
// Keys must match FRUIT_DICT in Blocks.vue exactly
const FRUITS = [
{ key: 'manzana', emoji: '🍎', label: 'Manzana' },
{ key: 'banana', emoji: '🍌', label: 'Banana' },
{ key: 'naranja', emoji: '🍊', label: 'Naranja' },
{ key: 'frutilla', emoji: '🍓', label: 'Frutilla' },
{ key: 'uva', emoji: '🍇', label: 'Uva' },
{ key: 'limon', emoji: '🍋', label: 'Limón' },
{ key: 'zanahoria', emoji: '🥕', label: 'Zanahoria' },
{ key: 'mango', emoji: '🥭', label: 'Mango' },
{ key: 'tomate', emoji: '🍅', label: 'Tomate' },
{ key: 'palta', emoji: '🥑', label: 'Palta' },
{ key: 'kiwi', emoji: '🥝', label: 'Kiwi' },
{ key: 'pina', emoji: '🍍', label: 'Piña' },
];
// ── DEMO EXERCISES ──
// New schema:
// exerciseType: 'move-blocks' | 'input-check' | 'mark-options'
// layout: 'row' | 'column' | 'row-answer-right' | 'row-answer-bottom'
// answerPosition (input-check only): 'right' | 'bottom'
// grids[].mode: 'active' | 'static' | 'target'
// grids[].id: string (used to match origins in answerSlot)
// answerSlot: { from: 'any' | string[], count: number } (for target grids)
// answerSlot: { expects: number } (for input-check at exercise level)
const demoExercises = ref([
// ── move-blocks: operandos activos, banca target a la derecha ──
{
exerciseType: 'move-blocks',
layout: 'row-answer-right',
sidesAnswerLabel: '',
text: "María tiene 2 manzanas y Juan tiene 3. ¿Cuántas tienen en total?",
grids: [
{ id: 'maria', mode: 'active', value: 2, label: 'María', fruit: 'manzana' },
{ id: 'juan', mode: 'active', value: 3, label: 'Juan', fruit: 'zanahoria' },
{ id: 'resp', mode: 'target', label: 'Total', answerSlot: { from: 'any', count: 5 } },
],
},
// ── move-blocks: por origen específico, banca abajo ──
{
exerciseType: 'move-blocks',
layout: 'row-answer-bottom',
text: "Pedro tiene 3 manzanas y Alicia tiene 2. Pedro le da 2 a Alicia. ¿Cuántas tiene Alicia?",
grids: [
{ id: 'pedro', mode: 'active', value: 3, label: 'Pedro', fruit: 'manzana' },
{ id: 'alicia', mode: 'active', value: 2, label: 'Alicia', fruit: 'frutilla' },
{ id: 'resp', mode: 'target', label: 'Alicia al final',
answerSlot: { from: [{ id: 'pedro', count: 2 }, { id: 'alicia', count: 2 }] } },
],
},
// ── input-check: grids estáticos, casilla de número a la derecha ──
{
exerciseType: 'input-check',
layout: 'row',
answerPosition: 'right',
hideBorders: false,
text: "¿Cuántos hay en total?",
answerSlot: { expects: 5 },
grids: [
{ id: 'pollos', mode: 'static', value: 2, label: 'Pollos', fruit: 'naranja' },
{ id: 'hamburguesas', mode: 'static', value: 3, label: 'Hamburguesas' },
],
},
// ── input-check: casilla de número abajo ──
{
exerciseType: 'input-check',
layout: 'row',
answerPosition: 'bottom',
text: "¿Cuántos perros hay en el patio?",
answerSlot: { expects: 6 },
grids: [
{ id: 'g1', mode: 'static', value: 4, label: 'Grupo 1', fruit: 'banana' },
{ id: 'g2', mode: 'static', value: 2, label: 'Grupo 2', fruit: 'banana' },
],
},
// ── sides: banca en el centro, fuentes alternando izquierda/derecha ──
{
exerciseType: 'sides',
text: "Junta los bloques de ambos lados en el centro.",
grids: [
{ id: 'izq1', mode: 'active', value: 3, label: 'Grupo A', fruit: 'manzana' },
{ id: 'der1', mode: 'active', value: 2, label: 'Grupo B', fruit: 'banana' },
{ id: 'izq2', mode: 'active', value: 2, label: 'Grupo C', fruit: 'naranja' },
{ id: 'resp', mode: 'target', label: 'Total',
answerSlot: { from: 'any', count: 6 } },
],
},
// ── mark-options: dos grids estáticos, el alumno marca cuál tiene menos ── { exerciseType: 'mark-options', layout: 'row', text: "¿Dónde hay menos? Marca con una X.", answerSlot: { correctId: 'koalas' }, grids: [ { id: 'pandas', mode: 'static', value: 6, label: 'Pandas' }, { id: 'koalas', mode: 'static', value: 5, label: 'Koalas' }, ], },
]);
// ── USER EXERCISES ──
const userExercises = ref([]);
// ── KEYS (para forzar re-render al resetear) ──
const demoKeys = ref(demoExercises.value.map((_, i) => i * 100));
const userKeys = ref([]);
const growUserArrays = (exercises) => {
exercises.forEach(() => {
userKeys.value.push(userKeys.value.length * 100 + Date.now() % 1000);
});
};
// ── COMPLETE ──
const onComplete = (_group, _idx, _result) => {};
// ── RESET ──
const resetAll = () => { demoKeys.value = demoKeys.value.map(k => k + 1000); };
const resetAllUser = () => { userKeys.value = userKeys.value.map(k => k + 1000); };
// ── CREATOR ──
const defaultGrid = () => ({ value: '', label: '', needs: '', fruit: null });
const defaultForm = () => ({
text: '',
exerciseType: 'move-blocks',
layout: 'row-answer-right',
answerPosition: 'right',
grids: [defaultGrid()],
bancaLabel: '',
expectedAnswer: '',
});
const form = ref(defaultForm());
const draft = ref([]);
const lastAdded = ref(null);
const pinnedPreview = ref(null);
const validGrids = computed(() =>
form.value.grids.filter(g => parseInt(g.value) > 0)
);
const canAdd = computed(() => {
if (!validGrids.value.length) return false;
if (form.value.exerciseType === 'move-blocks')
return validGrids.value.some(g => parseInt(g.needs) > 0);
if (form.value.exerciseType === 'input-check')
return form.value.expectedAnswer !== '' && !isNaN(Number(form.value.expectedAnswer));
if (form.value.exerciseType === 'sides')
return validGrids.value.some(g => parseInt(g.needs) > 0);
return validGrids.value.length >= 2; // mark-options
});
// Grid inputs — cascade: fill → next slot appears
const onGridValueInput = (i, e) => {
const raw = parseInt(e.target.value);
const val = isNaN(raw) ? '' : Math.min(20, Math.max(1, raw));
e.target.value = val === '' ? '' : val;
form.value.grids[i].value = val;
if (i === form.value.grids.length - 1 && val > 0 && form.value.grids.length < 7)
form.value.grids.push(defaultGrid());
};
const onGridBlur = (i, e) => {
const parsed = parseInt(e.target.value);
if (!e.target.value || isNaN(parsed) || parsed <= 0) form.value.grids[i].value = '';
const gs = form.value.grids;
let last = gs.length - 1;
while (last > 0 && (!gs[last].value || parseInt(gs[last].value) <= 0)) last--;
form.value.grids = [...gs.slice(0, last + 1), defaultGrid()];
};
const onGridNeedsInput = (i, e) => {
const raw = parseInt(e.target.value);
const val = isNaN(raw) ? '' : Math.min(20, Math.max(0, raw));
e.target.value = val === '' ? '' : val;
form.value.grids[i].needs = val;
};
// ── PREVIEW ──
const previewKey = ref(0);
const previewIsLive = computed(() => validGrids.value.length > 0 && !pinnedPreview.value);
const previewExercise = computed(() => {
if (pinnedPreview.value) return pinnedPreview.value;
if (!validGrids.value.length) return lastAdded.value;
const srcGrids = validGrids.value.map((g, i) => ({
id: 'g' + i,
mode: form.value.exerciseType === 'move-blocks' ? 'active' : 'static',
value: parseInt(g.value),
label: g.label || String(g.value),
...(g.fruit ? { fruit: g.fruit } : {}),
}));
if (form.value.exerciseType === 'move-blocks') {
const answers = validGrids.value
.filter(g => parseInt(g.needs) > 0)
.map((g, _i, arr) => ({ id: 'g' + validGrids.value.indexOf(g), count: parseInt(g.needs) }));
return {
exerciseType: 'move-blocks',
layout: form.value.layout,
hideBorders: form.value.hideBorders,
text: form.value.text,
grids: [
...srcGrids,
{
id: 'target',
mode: 'target',
label: form.value.bancaLabel || 'Respuesta',
answerSlot: answers.length
? { from: answers }
: { from: 'any', count: srcGrids.reduce((s, g) => s + g.value, 0) },
},
],
};
}
if (form.value.exerciseType === 'input-check') {
return {
exerciseType: 'input-check',
layout: form.value.layout,
answerPosition: form.value.answerPosition,
hideBorders: form.value.hideBorders,
text: form.value.text,
answerSlot: { expects: Number(form.value.expectedAnswer) },
grids: srcGrids,
};
}
// sides: target in center, sources alternate left/right
if (form.value.exerciseType === 'sides') {
const answers = validGrids.value
.filter(g => parseInt(g.needs) > 0)
.map(g => ({ id: 'g' + validGrids.value.indexOf(g), count: parseInt(g.needs) }));
return {
exerciseType: 'sides',
hideBorders: form.value.hideBorders,
text: form.value.text,
grids: [
...srcGrids,
{
id: 'target',
mode: 'target',
label: form.value.bancaLabel || 'Respuesta',
answerSlot: answers.length
? { from: answers }
: { from: 'any', count: srcGrids.reduce((s, g) => s + g.value, 0) },
},
],
};
}
// mark-options
return {
exerciseType: 'mark-options',
layout: 'row',
text: form.value.text,
answerSlot: { correctId: srcGrids[0]?.id },
grids: srcGrids,
};
});
watch(() => JSON.stringify(form.value), () => {
if (pinnedPreview.value && validGrids.value.length > 0) pinnedPreview.value = null;
}, { deep: true });
const addToDraft = () => {
if (!canAdd.value || !previewExercise.value) return;
const ex = JSON.parse(JSON.stringify(previewExercise.value));
draft.value.push(ex);
lastAdded.value = ex;
form.value = defaultForm();
};
const loadDraft = () => {
growUserArrays(draft.value);
userExercises.value.push(...draft.value);
draft.value = [];
lastAdded.value = null;
screen.value = 'user-exercises';
};
</script>
<style scoped>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.app {
width: 100%;
height: 100vh;
font-family: system-ui, sans-serif;
font-size: 14px;
background: #f0f4f8;
display: flex;
flex-direction: column;
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 0.75rem;
}
.center h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.page {
height: 100vh;
display: flex;
flex-direction: column;
}
.bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: white;
border-bottom: 1px solid #ddd;
font-weight: 600;
color: #333;
flex-shrink: 0;
z-index: 500;
}
.exercise-scroll {
flex: 1;
overflow-y: auto;
padding: 1.5rem 1rem 3rem;
}
.exercise-list {
max-width: 860px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* ── Creator ── */
.creator-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.creator-layout {
max-width: 900px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.creator-left {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.creator-form {
background: white;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field label {
font-size: 0.78rem;
font-weight: 700;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.hint {
font-weight: 400;
color: #9ca3af;
text-transform: none;
letter-spacing: 0;
}
.field input[type="text"] {
width: 100%;
padding: 0.38rem 0.55rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
font-family: inherit;
background: white;
color: #333;
}
.field input[type="text"]:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
.grids-col {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.grid-entry {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.grid-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.grid-idx {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e0e7ff;
color: #6366f1;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.needs-table {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.needs-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
}
.needs-grid-idx {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e0e7ff;
color: #6366f1;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.needs-label {
color: #374151;
font-weight: 600;
min-width: 60px;
}
.needs-arrow { color: #9ca3af; }
.needs-hint { color: #9ca3af; font-size: 0.75rem; }
.grid-num-input {
width: 58px;
padding: 0.35rem 0.4rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
font-family: inherit;
background: white;
color: #333;
text-align: center;
}
.grid-num-input:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
.grid-num-input--sm { width: 46px; }
.grid-label-input {
flex: 1;
padding: 0.35rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.8rem;
font-family: inherit;
background: white;
color: #555;
max-width: 130px;
}
.grid-label-input:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
/* Preview */
.creator-right {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preview-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #94a3b8;
}
.preview-empty {
background: white;
border: 1px dashed #d1d5db;
border-radius: 8px;
padding: 2rem 1rem;
text-align: center;
color: #9ca3af;
font-size: 0.85rem;
}
.preview-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 10px;
overflow: visible;
}
.preview-card--last {
border-color: #c7d2fe;
background: #fafbff;
}
.preview-last-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #818cf8;
padding: 0.5rem 0.75rem 0;
}
.preview-blocks-wrap {
pointer-events: none;
user-select: none;
}
/* Buttons */
button {
padding: 0.4rem 0.9rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
color: #333;
}
button:hover { background: #f0f0f0; }
button:disabled { opacity: 0.5; cursor: default; }
.btn-icon {
padding: 0.2rem 0.45rem;
font-size: 0.78rem;
border-color: #e5e7eb;
color: #6b7280;
}
.btn-add-draft { align-self: flex-start; }
.btn-load {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.btn-load:hover { background: #4f46e5; }
.mode-toggle {
display: flex;
gap: 0;
border: 1px solid #d1d5db;
border-radius: 6px;
overflow: hidden;
width: fit-content;
}
.mode-btn {
padding: 0.3rem 0.75rem;
border: none;
border-radius: 0;
font-size: 0.8rem;
font-weight: 500;
background: white;
color: #6b7280;
cursor: pointer;
}
.mode-btn:hover { background: #f0f0f0; }
.mode-btn--active { background: #6366f1; color: white; }
.mode-btn--active:hover { background: #4f46e5; }
.empty-state {
text-align: center;
color: #6b7280;
padding: 2rem 0;
font-size: 0.9rem;
}
.draft-list {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.draft-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
font-size: 0.82rem;
}
.draft-item:last-child { border-bottom: none; }
.draft-item:hover { background: #fafbff; }
.draft-item--pinned { background: #eef2ff; }
.draft-nums { font-weight: 600; color: #374151; }
.draft-dropzones { color: #9ca3af; font-size: 0.78rem; }
/* ── Fruit picker (select) ── */
.fruit-select-wrap {
padding: 2px 0 4px 26px;
}
.fruit-select {
padding: 0.3rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
background: white;
color: #374151;
cursor: pointer;
width: 100%;
max-width: 200px;
}
.fruit-select:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
</style>