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> | |