Spaces:
Running
Running
| <script lang="ts"> | |
| import Dataframe from '@gradio/dataframe'; | |
| import { pipeline } from '@xenova/transformers'; | |
| let rawData = ''; | |
| let cleanedData = ''; | |
| let cleaningSteps: any[] = []; | |
| let showSteps = false; | |
| let fileInput: HTMLInputElement; | |
| let inputValue: { data: string[][]; headers: string[] } = { data: [[]], headers: [] }; | |
| let cleanedValue: { data: string[][]; headers: string[] } = { data: [[]], headers: [] }; | |
| // Cleaning UI state | |
| let cleaningInProgress = false; | |
| let cleaningError: string | null = null; | |
| let modelWarning: string | null = null; | |
| let progressCurrent = 0; | |
| let progressTotal = 0; | |
| // Cleaning toggles | |
| let optDedupeRows = true; | |
| let optDedupeColumns = false; | |
| let optRemoveSymbols = true; | |
| let optCollapseSpaces = true; | |
| let optHtmlToText = true; | |
| // Extra LLM instructions to augment standard cleaning | |
| let extraInstructions: string = ''; | |
| function parseCSVorTSV(text: string) { | |
| if (!text) return { data: [[]], headers: [] }; | |
| const lines = text.trim().split(/\r?\n/); | |
| if (lines.length === 0) return { data: [[]], headers: [] }; | |
| const sep = lines[0].includes('\t') ? '\t' : ','; | |
| const headers = lines[0].split(sep).map(h => h.trim()); | |
| const data = lines.slice(1).map(line => line.split(sep).map(cell => cell.trim())); | |
| return normalizeTable({ data, headers }); | |
| } | |
| function hasNonEmptyTable(value: { data: string[][]; headers: string[] } | null | undefined): boolean { | |
| if (!value) return false; | |
| const headersOk = Array.isArray(value.headers) && value.headers.length > 0; | |
| const rowsOk = Array.isArray(value.data) && value.data.length > 0; | |
| if (!headersOk || !rowsOk) return false; | |
| // At least one non-empty cell | |
| return value.data.some((row) => Array.isArray(row) && row.some((cell) => String(cell ?? '').trim() !== '')); | |
| } | |
| function normalizeTable(value: { data: unknown[][]; headers: unknown[] } | null | undefined): { data: string[][]; headers: string[] } { | |
| if (!value || !Array.isArray(value.headers) || value.headers.length === 0) { | |
| return { data: [], headers: [] }; | |
| } | |
| const headers: string[] = value.headers.map((h) => String(h ?? '')); | |
| const width = headers.length; | |
| const data: string[][] = (Array.isArray(value.data) ? value.data : []).map((row) => { | |
| const safeRow = Array.isArray(row) ? row : []; | |
| const padded: string[] = Array.from({ length: width }, (_, i) => String(safeRow[i] ?? '')); | |
| return padded; | |
| }); | |
| return { headers, data }; | |
| } | |
| function padRowToWidth(row: unknown[] | null | undefined, width: number): string[] { | |
| const safeRow = Array.isArray(row) ? row : []; | |
| return Array.from({ length: width }, (_, i) => String(safeRow[i] ?? '')); | |
| } | |
| function htmlToText(input: string): string { | |
| return input.replace(/<[^>]*>/g, ''); | |
| } | |
| function removeSymbols(input: string): string { | |
| // Keep letters, numbers, common punctuation; remove emojis and other symbols | |
| return input | |
| .replace(/[\p{Extended_Pictographic}]/gu, '') | |
| .replace(/[\u200B-\u200D\uFEFF]/g, '') | |
| .replace(/[^\p{L}\p{N}\s\-_'",.;:!?()\[\]{}@#$%&*/+\\=<>^~`|]/gu, ''); | |
| } | |
| function collapseSpaces(input: string): string { | |
| return input.replace(/\s+/g, ' ').trim(); | |
| } | |
| function dedupeRows(headers: string[], rows: string[][]): string[][] { | |
| const seen = new Set<string>(); | |
| const out: string[][] = []; | |
| for (const r of rows) { | |
| const key = r.join('\u0001'); | |
| if (!seen.has(key)) { | |
| seen.add(key); | |
| out.push(padRowToWidth(r, headers.length)); | |
| } | |
| } | |
| return out; | |
| } | |
| function dedupeColumns(headers: string[], rows: string[][]): { headers: string[]; rows: string[][] } { | |
| const seen = new Set<string>(); | |
| const keepIdx: number[] = []; | |
| const outHeaders: string[] = []; | |
| headers.forEach((h, i) => { | |
| if (!seen.has(h)) { | |
| seen.add(h); | |
| keepIdx.push(i); | |
| outHeaders.push(h); | |
| } | |
| }); | |
| const outRows = rows.map((r) => keepIdx.map((i) => r[i] ?? '')); | |
| return { headers: outHeaders, rows: outRows }; | |
| } | |
| function updateInputValueFromRaw() { | |
| inputValue = parseCSVorTSV(rawData); | |
| } | |
| function updateRawFromInputValue() { | |
| // Convert inputValue back to CSV string | |
| if (!inputValue.headers.length) return; | |
| const sep = ','; | |
| const lines = [inputValue.headers.join(sep), ...inputValue.data.map(row => row.join(sep))]; | |
| rawData = lines.join('\n'); | |
| } | |
| function handleFileUpload(event: Event) { | |
| const files = (event.target as HTMLInputElement).files; | |
| if (files && files.length > 0) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| rawData = e.target?.result as string; | |
| updateInputValueFromRaw(); | |
| }; | |
| reader.readAsText(files[0]); | |
| } | |
| } | |
| function handleInputChange(e: CustomEvent) { | |
| inputValue = normalizeTable(e.detail); | |
| updateRawFromInputValue(); | |
| } | |
| let textCleaner: any = null; | |
| let loadingModel = false; | |
| // Heuristic fallback cleaner if model output is unusable | |
| function fallbackClean(text: string): string { | |
| if (!text) return ''; | |
| // Remove URLs | |
| let out = text.replace(/https?:\/\/\S+/g, ''); | |
| // Remove emojis and non-text pictographs | |
| out = out.replace(/[\p{Extended_Pictographic}]/gu, ''); | |
| // Remove HTML tags and zero-width characters | |
| out = out.replace(/<[^>]*>/g, '').replace(/[\u200B-\u200D\uFEFF]/g, ''); | |
| // Normalize quotes and whitespace | |
| out = out | |
| .replace(/[\u2018\u2019\u201A\u201B]/g, "'") | |
| .replace(/[\u201C\u201D\u201E\u201F]/g, '"') | |
| .replace(/[\u2013\u2014]/g, '-') | |
| .normalize('NFC') | |
| .replace(/"{2,}/g, '"') | |
| .replace(/'{2,}/g, "'") | |
| .replace(/[!?.]{2,}/g, (m: string) => m[0]) | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| // Remove stray leading 'clean:' tokens if any echoed | |
| out = out.replace(/^\s*clean:\s*/i, ''); | |
| return out; | |
| } | |
| function isLikelyNumericOrDate(text: string): boolean { | |
| const t = (text || '').trim(); | |
| if (!t) return false; | |
| // Currency/number like $12.00, 12, 12.0 USD | |
| if (/^[\p{Sc}]?\s?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d+)?(?:\s?(?:USD|EUR|GBP|JPY|AUD|CAD))?$/iu.test(t)) return true; | |
| // Date-like: YYYY-MM-DD, YYYY/MM/DD, DD-MM-YYYY, MM-DD-YYYY, with ., / or - | |
| if (/^(?:\d{4}[\-\/.]\d{2}[\-\/.]\d{2}|\d{2}[\-\/.]\d{2}[\-\/.]\d{4})$/.test(t)) return true; | |
| return false; | |
| } | |
| function jaccardTokenSimilarity(a: string, b: string): number { | |
| const toTokens = (s: string) => (s.match(/[\p{L}\p{N}\-']+/gu) ?? []).map((t) => t.toLowerCase()); | |
| const A = new Set(toTokens(a)); | |
| const B = new Set(toTokens(b)); | |
| if (A.size === 0 && B.size === 0) return 1; | |
| let inter = 0; | |
| for (const t of A) if (B.has(t)) inter += 1; | |
| const union = new Set([...A, ...B]).size; | |
| return union === 0 ? 0 : inter / union; | |
| } | |
| function isPlausibleClean(source: string, candidate: string): boolean { | |
| if (!candidate) return false; | |
| const len = candidate.length; | |
| const srcLen = source.length; | |
| if (len > Math.max(8, Math.floor(srcLen * 1.4)) || len < Math.floor(srcLen * 0.4)) return false; | |
| const punctOnly = candidate.replace(/[\p{L}\p{N}]/gu, '').length / Math.max(1, len) > 0.35; | |
| if (punctOnly) return false; | |
| const sim = jaccardTokenSimilarity(source, candidate); | |
| if (sim < 0.5) return false; | |
| return true; | |
| } | |
| async function cleanTextWithModel(text: string): Promise<string> { | |
| if (!text || !text.trim()) return ''; | |
| // Skip model for numeric/date-ish content | |
| if (isLikelyNumericOrDate(text)) return fallbackClean(text); | |
| if (!textCleaner) { | |
| loadingModel = true; | |
| // Use a small instruction-capable text2text model for deterministic edits | |
| // If unavailable, this will throw and we fallback deterministically. | |
| textCleaner = await pipeline('text2text-generation', 'Xenova/t5-small'); | |
| loadingModel = false; | |
| } | |
| const prompt = | |
| 'clean: ' + | |
| text | |
| .replace(/https?:\/\/\S+/g, '') | |
| .replace(/[\p{Extended_Pictographic}]/gu, '') | |
| .replace(/\s+/g, ' ') | |
| .trim() + | |
| (extraInstructions && extraInstructions.trim().length > 0 | |
| ? `\nrules: ${extraInstructions.trim()}` | |
| : ''); | |
| try { | |
| const output = await textCleaner(prompt, { | |
| max_new_tokens: 64, | |
| temperature: 0, | |
| do_sample: false | |
| }); | |
| const generated = output?.[0]?.generated_text ?? ''; | |
| let candidate = generated.split('\n')[0].trim().replace(/^"|"$/g, ''); | |
| // Normalize whitespace and quotes | |
| candidate = candidate | |
| .replace(/^\s*clean:\s*/i, '') | |
| .replace(/\bclean:\s*/gi, '') | |
| .replace(/[\u2018\u2019\u201A\u201B]/g, "'") | |
| .replace(/[\u201C\u201D\u201E\u201F]/g, '"') | |
| .replace(/[\u2013\u2014]/g, '-') | |
| .replace(/<[^>]*>/g, '') | |
| .replace(/[\u200B-\u200D\uFEFF]/g, '') | |
| .replace(/"{2,}/g, '"') | |
| .replace(/'{2,}/g, "'") | |
| .replace(/[!?.]{2,}/g, (m: string) => m[0]) | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| if (!isPlausibleClean(text, candidate)) return fallbackClean(text); | |
| return candidate; | |
| } catch (e) { | |
| return fallbackClean(text); | |
| } | |
| } | |
| async function analyzeAndClean() { | |
| cleaningError = null; | |
| modelWarning = null; | |
| showSteps = false; | |
| cleaningSteps = []; | |
| cleanedValue = { data: [[]], headers: [] }; | |
| cleanedData = ''; | |
| // Ensure there is a table to clean | |
| if (!inputValue.headers?.length) { | |
| cleaningError = 'No headers detected. Please paste a table with headers in the first row.'; | |
| return; | |
| } | |
| // Start with current headers/rows | |
| let outHeaders = [...inputValue.headers]; | |
| let workingRows = inputValue.data.map((r) => padRowToWidth(r, outHeaders.length)); | |
| // Deterministic transforms applied first | |
| if (optDedupeColumns) { | |
| const dc = dedupeColumns(outHeaders, workingRows); | |
| outHeaders = dc.headers; | |
| workingRows = dc.rows.map((r) => padRowToWidth(r, outHeaders.length)); | |
| } | |
| if (optDedupeRows) { | |
| workingRows = dedupeRows(outHeaders, workingRows); | |
| } | |
| // Iterate rows | |
| cleaningInProgress = true; | |
| progressCurrent = 0; | |
| const numRows = workingRows.length; | |
| const numCols = outHeaders.length; | |
| progressTotal = numRows * numCols; | |
| const outRows: string[][] = []; | |
| for (const row of workingRows) { | |
| const baseRow = padRowToWidth(row, numCols); | |
| const cleanedCells: string[] = []; | |
| for (let c = 0; c < numCols; c += 1) { | |
| let cell = (baseRow?.[c] ?? '').toString(); | |
| // Apply deterministic cell transforms | |
| if (optHtmlToText) cell = htmlToText(cell); | |
| if (optRemoveSymbols) cell = removeSymbols(cell); | |
| if (optCollapseSpaces) cell = collapseSpaces(cell); | |
| let cleaned = ''; | |
| try { | |
| cleaned = await cleanTextWithModel(cell); | |
| } catch (e) { | |
| if (!modelWarning) modelWarning = 'Model unavailable. Used deterministic fallback cleaning.'; | |
| cleaned = fallbackClean(cell); | |
| } | |
| if (!cleaned) cleaned = fallbackClean(cell); | |
| cleanedCells.push(cleaned); | |
| progressCurrent += 1; | |
| } | |
| const newRow = cleanedCells; | |
| outRows.push(newRow); | |
| } | |
| cleanedValue = normalizeTable({ headers: outHeaders, data: outRows }); | |
| // Keep a CSV copy in cleanedData for export convenience | |
| const sep = ','; | |
| const lines = [outHeaders.join(sep), ...outRows.map(r => r.join(sep))]; | |
| cleanedData = lines.join('\n'); | |
| // Note for transparency | |
| cleaningSteps = [{ step: `Cleaned all ${numCols} columns and replaced values in the preview.`, accepted: true }]; | |
| showSteps = true; | |
| cleaningInProgress = false; | |
| setTimeout(() => { | |
| const resultsSection = document.querySelector('.results-card'); | |
| if (resultsSection) { | |
| resultsSection.scrollIntoView({ | |
| behavior: 'smooth', | |
| block: 'start', | |
| inline: 'nearest' | |
| }); | |
| } | |
| }, 100); | |
| } | |
| function toggleStep(idx: number) { | |
| cleaningSteps[idx].accepted = !cleaningSteps[idx].accepted; | |
| } | |
| function exportCleaned() { | |
| // Export cleanedValue as CSV | |
| if (!cleanedValue.headers.length) return; | |
| const sep = ','; | |
| const lines = [cleanedValue.headers.join(sep), ...cleanedValue.data.map(row => row.join(sep))]; | |
| const csv = lines.join('\n'); | |
| const blob = new Blob([csv], { type: 'text/csv' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'cleaned_data.csv'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| $: updateInputValueFromRaw(); | |
| // No-op reactive blocks here | |
| </script> | |
| <div class="app-container df-theme"> | |
| <header class="app-header"> | |
| <div class="header-content"> | |
| <h1>Tabular Data Cleaner</h1> | |
| <p class="subtitle">Clean and transform your spreadsheet data with AI assistance</p> | |
| </div> | |
| </header> | |
| <main class="app-main"> | |
| <div class="content-grid"> | |
| <!-- Input Section --> | |
| <section class="input-card"> | |
| <div class="card-header"> | |
| <h2>Import Data</h2> | |
| <p class="card-subtitle">Upload a file or paste your CSV/TSV data</p> | |
| </div> | |
| <div class="upload-area"> | |
| <input type="file" accept=".csv,.tsv,.txt" bind:this={fileInput} on:change={handleFileUpload} id="file-input" class="file-input" /> | |
| <label for="file-input" class="file-label"> | |
| <div class="upload-icon">📁</div> | |
| <span>Choose file or drag & drop</span> | |
| </label> | |
| </div> | |
| <div class="divider"> | |
| <span>or</span> | |
| </div> | |
| <textarea | |
| id="data-input" | |
| bind:value={rawData} | |
| rows="8" | |
| class="data-textarea" | |
| placeholder="Paste CSV or TSV data here..." | |
| on:input={updateInputValueFromRaw} | |
| ></textarea> | |
| {#if hasNonEmptyTable(inputValue)} | |
| <div class="preview-section"> | |
| <h3>Data Preview</h3> | |
| <div class="dataframe-wrapper"> | |
| <Dataframe | |
| bind:value={inputValue} | |
| show_search="search" | |
| show_row_numbers={true} | |
| show_copy_button={true} | |
| show_fullscreen_button={true} | |
| editable={true} | |
| on:change={handleInputChange} | |
| /> | |
| </div> | |
| </div> | |
| {/if} | |
| </section> | |
| <!-- Controls Section --> | |
| <section class="controls-card"> | |
| <div class="card-header"> | |
| <h2>Cleaning Options</h2> | |
| </div> | |
| <div class="control-group"> | |
| <h3>LLM Instructions</h3> | |
| <textarea | |
| rows="3" | |
| bind:value={extraInstructions} | |
| class="instructions-textarea" | |
| placeholder="e.g., expand abbreviations, fix capitalization..." | |
| ></textarea> | |
| </div> | |
| <div class="control-group"> | |
| <h3>Transform Options</h3> | |
| <div class="checkbox-group"> | |
| <label class="checkbox-label"> | |
| <input type="checkbox" bind:checked={optDedupeRows} /> | |
| <span class="checkmark"></span> | |
| Deduplicate rows | |
| </label> | |
| <label class="checkbox-label"> | |
| <input type="checkbox" bind:checked={optDedupeColumns} /> | |
| <span class="checkmark"></span> | |
| Deduplicate columns | |
| </label> | |
| <label class="checkbox-label"> | |
| <input type="checkbox" bind:checked={optRemoveSymbols} /> | |
| <span class="checkmark"></span> | |
| Remove symbols/emojis | |
| </label> | |
| <label class="checkbox-label"> | |
| <input type="checkbox" bind:checked={optCollapseSpaces} /> | |
| <span class="checkmark"></span> | |
| Remove extra spaces | |
| </label> | |
| <label class="checkbox-label"> | |
| <input type="checkbox" bind:checked={optHtmlToText} /> | |
| <span class="checkmark"></span> | |
| Convert HTML to text | |
| </label> | |
| </div> | |
| </div> | |
| <div class="action-buttons"> | |
| <button | |
| class="btn-primary" | |
| on:click={analyzeAndClean} | |
| disabled={cleaningInProgress || loadingModel || !inputValue.headers?.length} | |
| > | |
| {#if loadingModel} | |
| <span class="spinner"></span> | |
| Loading model... | |
| {:else if cleaningInProgress} | |
| <span class="spinner"></span> | |
| Cleaning {progressCurrent}/{progressTotal}... | |
| {:else} | |
| ✨ Clean Data | |
| {/if} | |
| </button> | |
| <button | |
| class="btn-secondary" | |
| on:click={exportCleaned} | |
| disabled={!hasNonEmptyTable(cleanedValue)} | |
| > | |
| 💾 Export | |
| </button> | |
| </div> | |
| {#if cleaningError} | |
| <div class="alert alert-error">{cleaningError}</div> | |
| {/if} | |
| {#if modelWarning} | |
| <div class="alert alert-warning">{modelWarning}</div> | |
| {/if} | |
| </section> | |
| </div> | |
| <!-- Results Section --> | |
| {#if hasNonEmptyTable(cleanedValue)} | |
| <section class="results-card"> | |
| <div class="card-header"> | |
| <h2>Cleaned Results</h2> | |
| <p class="card-subtitle">Review and export your cleaned data</p> | |
| </div> | |
| <div class="dataframe-wrapper"> | |
| <Dataframe | |
| bind:value={cleanedValue} | |
| show_search="search" | |
| show_row_numbers={true} | |
| show_copy_button={true} | |
| show_fullscreen_button={true} | |
| editable={false} | |
| /> | |
| </div> | |
| </section> | |
| {/if} | |
| </main> | |
| </div> | |
| <style> | |
| /* Reset and base styles */ | |
| :global(*) { | |
| box-sizing: border-box; | |
| } | |
| :global(body) { | |
| margin: 0; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; | |
| background: #f8fafc; | |
| color: #1e293b; | |
| line-height: 1.6; | |
| } | |
| /* App container */ | |
| .app-container { | |
| min-height: 100vh; | |
| } | |
| /* Header */ | |
| .app-header { | |
| margin: 0 auto; | |
| padding: 2rem; | |
| } | |
| .header-content { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 0 2rem; | |
| text-align: center; | |
| } | |
| .app-header h1 { | |
| font-size: 2.5rem; | |
| font-weight: 700; | |
| margin: 0 0 0.5rem 0; | |
| background: linear-gradient(135deg, #667eea, #764ba2); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .subtitle { | |
| font-size: 1.1rem; | |
| color: #64748b; | |
| margin: 0; | |
| font-weight: 400; | |
| } | |
| /* Main content */ | |
| .app-main { | |
| max-width: 1200px; | |
| margin: 0 auto; | |
| padding: 2rem; | |
| } | |
| .content-grid { | |
| display: grid; | |
| grid-template-columns: 650px 350px; | |
| gap: 2rem; | |
| margin-bottom: 2rem; | |
| justify-content: center; | |
| } | |
| @media (max-width: 768px) { | |
| .content-grid { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: auto auto; | |
| max-width: 100%; | |
| } | |
| .input-card, .controls-card, .results-card { | |
| width: 100%; | |
| } | |
| .control-group { | |
| padding: 0; | |
| } | |
| } | |
| /* Card styles */ | |
| .input-card, .controls-card, .results-card { | |
| background: white; | |
| border-radius: 16px; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
| border: 1px solid rgba(226, 232, 240, 0.8); | |
| overflow: hidden; | |
| } | |
| .controls-card { | |
| width: 350px; | |
| height: min-content; | |
| } | |
| .card-header { | |
| padding: 1.5rem 1.5rem 1rem 1.5rem; | |
| border-bottom: 1px solid #f1f5f9; | |
| } | |
| .card-header h2 { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| margin: 0 0 0.25rem 0; | |
| color: #1e293b; | |
| } | |
| .card-subtitle { | |
| font-size: 0.9rem; | |
| color: #64748b; | |
| margin: 0; | |
| } | |
| /* Upload area */ | |
| .upload-area { | |
| padding: 1.5rem; | |
| } | |
| .file-input { | |
| display: none; | |
| } | |
| .file-label { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 2rem; | |
| border: 2px dashed #cbd5e1; | |
| border-radius: 12px; | |
| background: #f8fafc; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .file-label:hover { | |
| border-color: #667eea; | |
| background: #f1f5f9; | |
| } | |
| .upload-icon { | |
| font-size: 2rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .file-label span { | |
| color: #475569; | |
| font-weight: 500; | |
| } | |
| /* Divider */ | |
| .divider { | |
| display: flex; | |
| align-items: center; | |
| margin: 0 1.5rem; | |
| text-align: center; | |
| } | |
| .divider::before, | |
| .divider::after { | |
| content: ''; | |
| flex: 1; | |
| height: 1px; | |
| background: #e2e8f0; | |
| } | |
| .divider span { | |
| padding: 0 1rem; | |
| color: #64748b; | |
| font-size: 0.875rem; | |
| background: white; | |
| } | |
| /* Textareas */ | |
| .data-textarea, .instructions-textarea { | |
| padding: 1rem; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 8px; | |
| font-size: 0.875rem; | |
| resize: vertical; | |
| transition: border-color 0.2s ease; | |
| box-sizing: border-box; | |
| line-height: 1.5; | |
| min-height: 120px; | |
| } | |
| .data-textarea { | |
| margin: 1.5rem; | |
| width: calc(100% - 3rem); | |
| font-family: 'JetBrains Mono', 'Fira Code', monospace; | |
| } | |
| .instructions-textarea { | |
| width: 100%; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; | |
| } | |
| .data-textarea:focus, .instructions-textarea:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |
| } | |
| /* Control groups */ | |
| .control-group { | |
| padding: 0 1.5rem 1.5rem 1.5rem; | |
| } | |
| .control-group h3 { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin: 0 0 0.75rem 0; | |
| color: #374151; | |
| } | |
| /* Checkbox styles */ | |
| .checkbox-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| } | |
| .checkbox-label { | |
| display: flex; | |
| align-items: center; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| color: #374151; | |
| } | |
| .checkbox-label input[type="checkbox"] { | |
| display: none; | |
| } | |
| .checkmark { | |
| width: 18px; | |
| height: 18px; | |
| border: 2px solid #d1d5db; | |
| border-radius: 4px; | |
| margin-right: 0.75rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s ease; | |
| } | |
| .checkbox-label input[type="checkbox"]:checked + .checkmark { | |
| background: #667eea; | |
| border-color: #667eea; | |
| } | |
| .checkbox-label input[type="checkbox"]:checked + .checkmark::after { | |
| content: '✓'; | |
| color: white; | |
| font-size: 12px; | |
| font-weight: bold; | |
| } | |
| /* Buttons */ | |
| .action-buttons { | |
| padding: 0 1.5rem 1.5rem 1.5rem; | |
| display: flex; | |
| gap: 0.75rem; | |
| } | |
| .btn-primary, .btn-secondary { | |
| padding: 0.75rem 1.5rem; | |
| border-radius: 8px; | |
| border: none; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #667eea, #764ba2); | |
| color: white; | |
| flex: 1; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| transform: translateY(-1px); | |
| box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); | |
| } | |
| .btn-secondary { | |
| background: #f8fafc; | |
| color: #475569; | |
| border: 1px solid #e2e8f0; | |
| } | |
| .btn-secondary:hover:not(:disabled) { | |
| background: #f1f5f9; | |
| } | |
| .btn-primary:disabled, .btn-secondary:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| /* Spinner */ | |
| .spinner { | |
| width: 16px; | |
| height: 16px; | |
| border: 2px solid transparent; | |
| border-top: 2px solid currentColor; | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| /* Alerts */ | |
| .alert { | |
| margin: 0 1.5rem 1rem 1.5rem; | |
| padding: 0.75rem 1rem; | |
| border-radius: 8px; | |
| font-size: 0.875rem; | |
| } | |
| .alert-error { | |
| background: #fef2f2; | |
| color: #dc2626; | |
| border: 1px solid #fecaca; | |
| } | |
| .alert-warning { | |
| background: #fffbeb; | |
| color: #d97706; | |
| border: 1px solid #fed7aa; | |
| } | |
| /* Preview section */ | |
| .preview-section { | |
| padding: 0 1.5rem 1.5rem 1.5rem; | |
| } | |
| .preview-section h3 { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| margin: 0 0 1rem 0; | |
| color: #374151; | |
| } | |
| /* Results card */ | |
| .results-card { | |
| grid-column: 1 / -1; | |
| } | |
| .results-card .dataframe-wrapper { | |
| margin: 1.5rem; | |
| } | |
| /* Theme overrides */ | |
| .df-theme { | |
| --gr-df-table-text: #1e293b ; | |
| } | |
| </style> | |