feat(upload): add archive extraction and form polish

This commit is contained in:
Peter Steinberger
2026-01-03 22:02:39 +01:00
parent 63d94a06a7
commit 4abb5181bb
6 changed files with 853 additions and 76 deletions
+1
View File
@@ -14,3 +14,4 @@ count.txt
todos.json
.cta.json
.vscode
.env*.local
+91
View File
@@ -0,0 +1,91 @@
/* @vitest-environment node */
import { gzipSync, strToU8, zipSync } from 'fflate'
import { describe, expect, it } from 'vitest'
import { expandFiles } from './uploadFiles'
if (typeof File === 'undefined') {
class NodeFile extends Blob {
name: string
lastModified: number
constructor(parts: BlobPart[], name: string, options?: FilePropertyBag) {
super(parts, options)
this.name = name
this.lastModified = options?.lastModified ?? Date.now()
}
}
// @ts-expect-error Node test environment polyfill
globalThis.File = NodeFile
}
function buildTar(entries: Array<{ name: string; content: string }>) {
const blocks: Uint8Array[] = []
for (const entry of entries) {
const content = strToU8(entry.content)
const header = new Uint8Array(512)
writeString(header, entry.name, 0, 100)
writeString(header, '0000777', 100, 8)
writeString(header, '0000000', 108, 8)
writeString(header, '0000000', 116, 8)
writeString(header, content.length.toString(8).padStart(11, '0'), 124, 12)
writeString(header, '00000000000', 136, 12)
header[156] = '0'.charCodeAt(0)
writeString(header, 'ustar', 257, 6)
for (let i = 148; i < 156; i += 1) {
header[i] = 32
}
let sum = 0
for (const byte of header) sum += byte
writeString(header, sum.toString(8).padStart(6, '0'), 148, 6)
header[154] = 0
header[155] = 32
blocks.push(header)
blocks.push(content)
const pad = (512 - (content.length % 512)) % 512
if (pad) blocks.push(new Uint8Array(pad))
}
blocks.push(new Uint8Array(1024))
const total = blocks.reduce((sum, block) => sum + block.length, 0)
const buffer = new Uint8Array(total)
let offset = 0
for (const block of blocks) {
buffer.set(block, offset)
offset += block.length
}
return buffer
}
function writeString(target: Uint8Array, value: string, start: number, length: number) {
const bytes = strToU8(value)
target.set(bytes.subarray(0, length), start)
}
describe('expandFiles', () => {
it('expands zip archives into files', async () => {
const zip = zipSync({
'SKILL.md': strToU8('hello'),
'docs/readme.txt': strToU8('doc'),
})
const zipFile = new File([zip], 'pack.zip', { type: 'application/zip' })
const result = await expandFiles([zipFile])
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
})
it('expands gzipped tar archives into files', async () => {
const tar = buildTar([
{ name: 'SKILL.md', content: 'hi' },
{ name: 'notes.txt', content: 'yo' },
])
const tgz = gzipSync(tar)
const tgzFile = new File([tgz], 'bundle.tgz', { type: 'application/gzip' })
const result = await expandFiles([tgzFile])
expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
})
it('expands .gz single files', async () => {
const gz = gzipSync(strToU8('content'))
const gzFile = new File([gz], 'skill.md.gz', { type: 'application/gzip' })
const result = await expandFiles([gzFile])
expect(result.map((file) => file.name)).toEqual(['skill.md'])
})
})
+102
View File
@@ -0,0 +1,102 @@
import { gunzipSync, unzipSync } from 'fflate'
const TEXT_TYPES = new Map([
['md', 'text/markdown'],
['markdown', 'text/markdown'],
['txt', 'text/plain'],
['json', 'application/json'],
['yaml', 'text/yaml'],
['yml', 'text/yaml'],
['toml', 'text/plain'],
['js', 'text/javascript'],
['ts', 'text/plain'],
['tsx', 'text/plain'],
['jsx', 'text/plain'],
['css', 'text/css'],
['html', 'text/html'],
['svg', 'image/svg+xml'],
])
export async function expandFiles(selected: File[]) {
const expanded: File[] = []
for (const file of selected) {
const lower = file.name.toLowerCase()
if (lower.endsWith('.zip')) {
const entries = unzipSync(new Uint8Array(await readArrayBuffer(file)))
for (const [path, data] of Object.entries(entries)) {
if (!path || path.endsWith('/')) continue
expanded.push(
new File([data], normalizePath(path), {
type: guessContentType(path),
}),
)
}
continue
}
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
for (const entry of untar(unpacked)) {
expanded.push(
new File([entry.data], normalizePath(entry.path), {
type: guessContentType(entry.path),
}),
)
}
continue
}
if (lower.endsWith('.gz')) {
const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file)))
const name = file.name.replace(/\.gz$/i, '')
expanded.push(new File([unpacked], name, { type: guessContentType(name) }))
continue
}
expanded.push(file)
}
return expanded
}
async function readArrayBuffer(file: Blob) {
if (typeof file.arrayBuffer === 'function') {
return file.arrayBuffer()
}
return new Response(file).arrayBuffer()
}
function guessContentType(path: string) {
const ext = path.split('.').pop()?.toLowerCase()
if (!ext) return 'text/plain'
return TEXT_TYPES.get(ext) ?? 'text/plain'
}
function normalizePath(path: string) {
return path.replace(/^\.\/+/, '').replace(/^\/+/, '')
}
function untar(bytes: Uint8Array) {
const entries: Array<{ path: string; data: Uint8Array }> = []
let offset = 0
while (offset + 512 <= bytes.length) {
const header = bytes.subarray(offset, offset + 512)
if (header.every((byte) => byte === 0)) break
const name = readString(header.subarray(0, 100))
const size = readOctal(header.subarray(124, 136))
const typeflag = header[156]
offset += 512
const data = bytes.subarray(offset, offset + size)
offset += Math.ceil(size / 512) * 512
if (!name || typeflag === 53) continue
entries.push({ path: name, data })
}
return entries
}
function readString(bytes: Uint8Array) {
const end = bytes.indexOf(0)
const slice = end === -1 ? bytes : bytes.subarray(0, end)
return new TextDecoder().decode(slice).trim()
}
function readOctal(bytes: Uint8Array) {
const raw = readString(bytes)
return raw ? Number.parseInt(raw, 8) : 0
}
+72
View File
@@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { Upload } from './upload'
const generateUploadUrl = vi.fn()
const publishVersion = vi.fn()
vi.mock('convex/react', () => ({
useConvexAuth: () => ({ isAuthenticated: true }),
useMutation: () => generateUploadUrl,
useAction: () => publishVersion,
}))
describe('Upload route', () => {
beforeEach(() => {
generateUploadUrl.mockReset()
publishVersion.mockReset()
})
it('shows validation issues and disables publish by default', () => {
render(<Upload />)
const publishButton = screen.getByRole('button', { name: /publish/i })
expect(publishButton).toBeTruthy()
expect((publishButton as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByText(/Slug is required/i)).toBeTruthy()
expect(screen.getByText(/Display name is required/i)).toBeTruthy()
expect(screen.getByText(/Changelog is required/i)).toBeTruthy()
})
it('marks the input for folder uploads', async () => {
render(<Upload />)
const input = screen.getByTestId('upload-input')
await waitFor(() => {
expect(input.getAttribute('webkitdirectory')).not.toBeNull()
})
})
it('enables publish when fields and files are valid, and allows removing files', async () => {
render(<Upload />)
fireEvent.change(screen.getByPlaceholderText('my-skill-pack'), {
target: { value: 'cool-skill' },
})
fireEvent.change(screen.getByPlaceholderText('My Skill Pack'), {
target: { value: 'Cool Skill' },
})
fireEvent.change(screen.getByPlaceholderText('1.0.0'), {
target: { value: '1.2.3' },
})
fireEvent.change(screen.getByPlaceholderText('latest, beta'), {
target: { value: 'latest' },
})
fireEvent.change(screen.getByPlaceholderText('What changed in this version?'), {
target: { value: 'Initial drop.' },
})
const file = new File(['hello'], 'SKILL.md', { type: 'text/markdown' })
const input = screen.getByTestId('upload-input') as HTMLInputElement
fireEvent.change(input, { target: { files: [file] } })
const publishButton = screen.getByRole('button', { name: /publish/i }) as HTMLButtonElement
await waitFor(() => {
expect(publishButton.disabled).toBe(false)
})
expect(screen.getByText(/Ready to publish/i)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /remove/i }))
await waitFor(() => {
expect(publishButton.disabled).toBe(true)
})
expect(screen.getByText(/Add at least one file/i)).toBeTruthy()
})
})
+292 -76
View File
@@ -1,13 +1,17 @@
import { createFileRoute } from '@tanstack/react-router'
import { useAction, useConvexAuth, useMutation } from 'convex/react'
import { useState } from 'react'
import semver from 'semver'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../convex/_generated/api'
import { expandFiles } from '../lib/uploadFiles'
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export const Route = createFileRoute('/upload')({
component: Upload,
})
function Upload() {
export function Upload() {
const { isAuthenticated } = useConvexAuth()
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl)
const publishVersion = useAction(api.skills.publishVersion)
@@ -19,6 +23,78 @@ function Upload() {
const [changelog, setChangelog] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const maxBytes = 50 * 1024 * 1024
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files])
const hasSkillFile = useMemo(
() =>
files.some(
(file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md',
),
[files],
)
const sizeLabel = totalBytes ? formatBytes(totalBytes) : '0 B'
const trimmedSlug = slug.trim()
const trimmedName = displayName.trim()
const trimmedChangelog = changelog.trim()
const parsedTags = useMemo(
() =>
tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
[tags],
)
const validation = useMemo(() => {
const issues: string[] = []
if (!trimmedSlug) {
issues.push('Slug is required.')
} else if (!SLUG_PATTERN.test(trimmedSlug)) {
issues.push('Slug must be lowercase and use dashes only.')
}
if (!trimmedName) {
issues.push('Display name is required.')
}
if (!semver.valid(version)) {
issues.push('Version must be valid semver (e.g. 1.0.0).')
}
if (parsedTags.length === 0) {
issues.push('At least one tag is required.')
}
if (!trimmedChangelog) {
issues.push('Changelog is required.')
}
if (files.length === 0) {
issues.push('Add at least one file.')
}
if (!hasSkillFile) {
issues.push('SKILL.md is required.')
}
if (totalBytes > maxBytes) {
issues.push('Total file size exceeds 50MB.')
}
return {
issues,
ready: issues.length === 0,
}
}, [
trimmedSlug,
trimmedName,
version,
parsedTags.length,
trimmedChangelog,
files.length,
hasSkillFile,
totalBytes,
maxBytes,
])
useEffect(() => {
if (!fileInputRef.current) return
fileInputRef.current.setAttribute('webkitdirectory', '')
fileInputRef.current.setAttribute('directory', '')
}, [])
if (!isAuthenticated) {
return (
@@ -30,18 +106,13 @@ function Upload() {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (files.length === 0) return
if (!validation.ready) return
setError(null)
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > 50 * 1024 * 1024) {
if (totalBytes > maxBytes) {
setError('Total size exceeds 50MB per version.')
return
}
if (
!files.some(
(file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md',
)
) {
if (!hasSkillFile) {
setError('SKILL.md is required.')
return
}
@@ -84,74 +155,207 @@ function Upload() {
setStatus('Published.')
}
async function handleFilesSelected(selected: File[]) {
if (selected.length === 0) return
setError(null)
setStatus('Preparing files…')
const expanded = await expandFiles(selected)
setStatus(null)
const next = new Map<string, File>()
for (const file of files) {
const key = `${file.webkitRelativePath || file.name}:${file.size}`
next.set(key, file)
}
for (const file of expanded) {
const key = `${file.webkitRelativePath || file.name}:${file.size}`
next.set(key, file)
}
setFiles(Array.from(next.values()))
}
function handleRemoveFile(target: File) {
setFiles((current) =>
current.filter(
(file) =>
`${file.webkitRelativePath || file.name}:${file.size}` !==
`${target.webkitRelativePath || target.name}:${target.size}`,
),
)
}
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault()
setIsDragging(false)
handleFilesSelected(Array.from(event.dataTransfer.files ?? []))
}
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault()
setIsDragging(true)
}
function handleDragLeave() {
setIsDragging(false)
}
return (
<main className="section">
<h1 className="section-title">Publish a skill</h1>
<p className="section-subtitle">Bundle SKILL.md + text files, then ship.</p>
<form className="card" onSubmit={handleSubmit} style={{ display: 'grid', gap: 16 }}>
<label>
Slug
<input
className="search-input"
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="my-skill-pack"
/>
</label>
<label>
Display name
<input
className="search-input"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="My Skill Pack"
/>
</label>
<label>
Version
<input
className="search-input"
value={version}
onChange={(event) => setVersion(event.target.value)}
placeholder="1.0.0"
/>
</label>
<label>
Tags (comma-separated)
<input
className="search-input"
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="latest, beta"
/>
</label>
<label>
Changelog
<textarea
className="search-input"
rows={3}
value={changelog}
onChange={(event) => setChangelog(event.target.value)}
placeholder="What changed in this version?"
/>
</label>
<label>
Files (must include SKILL.md)
<input
type="file"
multiple
onChange={(event) => setFiles(Array.from(event.target.files ?? []))}
/>
</label>
<button className="btn btn-primary" type="submit">
Publish
</button>
{error ? (
<div className="stat" style={{ color: '#b84a3a' }}>
{error}
<main className="section upload-shell">
<header className="upload-header">
<div>
<span className="upload-kicker">Publish</span>
<h1 className="upload-title">Publish a skill</h1>
<p className="upload-subtitle">Bundle SKILL.md + text files. Tag it, version it, ship it.</p>
</div>
<div className="upload-badge">
50 MB max
<span className="upload-badge-sub">per version</span>
</div>
</header>
<form className="upload-card" onSubmit={handleSubmit}>
<div className="upload-grid">
<div className="upload-fields">
<label className="upload-field">
<span>Slug</span>
<input
className="search-input upload-input"
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="my-skill-pack"
/>
</label>
<label className="upload-field">
<span>Display name</span>
<input
className="search-input upload-input"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="My Skill Pack"
/>
</label>
<div className="upload-row">
<label className="upload-field">
<span>Version</span>
<input
className="search-input upload-input"
value={version}
onChange={(event) => setVersion(event.target.value)}
placeholder="1.0.0"
/>
</label>
<label className="upload-field">
<span>Tags</span>
<input
className="search-input upload-input"
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="latest, beta"
/>
</label>
</div>
<label className="upload-field">
<span>Changelog</span>
<textarea
className="search-input upload-input"
rows={4}
value={changelog}
onChange={(event) => setChangelog(event.target.value)}
placeholder="What changed in this version?"
/>
</label>
</div>
) : null}
{status ? <div className="stat">{status}</div> : null}
<div className="upload-side">
<div
className={`dropzone${isDragging ? ' is-dragging' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
fileInputRef.current?.click()
}
}}
onClick={() => fileInputRef.current?.click()}
>
<div className="dropzone-icon"></div>
<div>
<strong>Drop a folder, files, or zip</strong>
<p>Click to choose a folder. Archives auto-extract.</p>
</div>
<input
ref={fileInputRef}
className="dropzone-input"
type="file"
multiple
data-testid="upload-input"
onChange={(event) => void handleFilesSelected(Array.from(event.target.files ?? []))}
/>
</div>
<div className="upload-summary">
<div>
<strong>{files.length}</strong> files · <span>{sizeLabel}</span>
</div>
<div className={`upload-requirement${hasSkillFile ? ' ok' : ''}`}>
SKILL.md {hasSkillFile ? 'found' : 'required'}
</div>
{files.length ? (
<div className="upload-filelist">
{files.map((file) => (
<div
key={`${file.webkitRelativePath || file.name}:${file.size}`}
className="upload-file"
>
<span>{file.webkitRelativePath || file.name}</span>
<span>{formatBytes(file.size)}</span>
<button
className="upload-remove"
type="button"
onClick={() => handleRemoveFile(file)}
>
Remove
</button>
</div>
))}
</div>
) : (
<p className="upload-muted">No files selected yet.</p>
)}
{files.length ? (
<button className="btn" type="button" onClick={() => setFiles([])}>
Clear selection
</button>
) : null}
</div>
<div className="upload-notes">
<strong>Checks</strong>
<ul>
<li>Include SKILL.md</li>
<li>50 MB max per version</li>
<li>Changelog required</li>
<li>Valid semver version</li>
</ul>
</div>
</div>
</div>
<div className="upload-footer">
<button className="btn btn-primary" type="submit" disabled={!validation.ready}>
Publish
</button>
{!validation.ready ? (
<div className="upload-validation">
{validation.issues.map((issue) => (
<div key={issue} className="upload-validation-item">
{issue}
</div>
))}
</div>
) : (
<div className="upload-ready">Ready to publish.</div>
)}
{error ? <div className="stat upload-error">{error}</div> : null}
{status ? <div className="stat">{status}</div> : null}
</div>
</form>
</main>
)
@@ -178,3 +382,15 @@ async function hashFile(file: File) {
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}
function formatBytes(bytes: number) {
if (!Number.isFinite(bytes)) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unit = 0
while (size >= 1024 && unit < units.length - 1) {
size /= 1024
unit += 1
}
return `${size.toFixed(size < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
}
+295
View File
@@ -125,6 +125,13 @@ code {
box-shadow: 0 10px 20px rgba(29, 26, 23, 0.12);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
transform: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
color: white;
@@ -185,6 +192,283 @@ code {
padding: 32px 24px 72px;
}
.upload-shell {
position: relative;
}
.upload-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.upload-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--accent-deep);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.7rem;
}
.upload-title {
font-family: var(--font-display);
font-size: clamp(2.2rem, 3.5vw, 3rem);
letter-spacing: -0.03em;
margin: 8px 0 10px;
}
.upload-subtitle {
color: var(--ink-soft);
max-width: 560px;
line-height: 1.6;
margin: 0;
}
.upload-badge {
background: linear-gradient(140deg, #ffddc9, #ffe9df);
color: #9a3a24;
border-radius: 999px;
padding: 12px 18px;
font-weight: 700;
box-shadow: 0 12px 24px rgba(255, 107, 74, 0.18);
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
}
.upload-badge-sub {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.upload-card {
background: linear-gradient(180deg, #ffffff 0%, #fff6f1 100%);
border-radius: calc(var(--radius-lg) + 6px);
border: 1px solid rgba(255, 107, 74, 0.18);
padding: 28px;
box-shadow: 0 24px 60px rgba(29, 26, 23, 0.12);
display: flex;
flex-direction: column;
gap: 24px;
}
.upload-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(260px, 0.7fr);
gap: 28px;
}
.upload-fields {
display: grid;
gap: 18px;
}
.upload-field {
display: grid;
gap: 10px;
font-weight: 600;
}
.upload-field span {
font-size: 0.9rem;
color: var(--ink-soft);
}
.upload-input {
border: 1px solid rgba(29, 26, 23, 0.14);
border-radius: 14px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.9);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
}
.upload-input:focus {
border-color: rgba(255, 107, 74, 0.6);
box-shadow:
0 0 0 2px rgba(255, 107, 74, 0.2),
inset 0 0 0 1px rgba(255, 255, 255, 0.8);
}
.upload-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.upload-side {
display: grid;
gap: 16px;
align-content: start;
}
.dropzone {
border: 2px dashed rgba(255, 107, 74, 0.5);
border-radius: 24px;
padding: 20px;
background: radial-gradient(circle at top, #fff3ec 0%, #ffffff 65%);
display: grid;
gap: 12px;
text-align: center;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.dropzone:hover {
transform: translateY(-2px);
box-shadow: 0 18px 30px rgba(255, 107, 74, 0.15);
}
.dropzone.is-dragging {
border-color: var(--accent);
box-shadow: 0 20px 40px rgba(255, 107, 74, 0.25);
background: radial-gradient(circle at top, #ffe1d3 0%, #fff6f1 70%);
}
.dropzone-icon {
width: 54px;
height: 54px;
border-radius: 16px;
margin: 0 auto;
display: grid;
place-items: center;
font-size: 1.4rem;
background: linear-gradient(160deg, #ff6b4a, #f08b5f);
color: white;
box-shadow: inset 0 0 0 3px rgba(255, 255, 255, 0.4);
}
.dropzone-input {
display: none;
}
.upload-summary {
border-radius: 20px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(29, 26, 23, 0.08);
padding: 16px;
display: grid;
gap: 10px;
font-size: 0.9rem;
}
.upload-requirement {
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 107, 74, 0.12);
color: #9a3a24;
font-weight: 600;
width: fit-content;
}
.upload-requirement.ok {
background: rgba(43, 198, 164, 0.18);
color: #1a6b5b;
}
.upload-filelist {
display: grid;
gap: 6px;
max-height: 220px;
overflow: auto;
}
.upload-filelist .upload-file {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.85rem;
color: var(--ink-soft);
}
.upload-file span:first-child {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-remove {
border: none;
background: transparent;
color: var(--accent-deep);
font-weight: 600;
cursor: pointer;
}
.upload-remove:hover {
text-decoration: underline;
}
.upload-more {
font-size: 0.8rem;
color: var(--ink-soft);
}
.upload-muted {
color: var(--ink-soft);
margin: 0;
}
.upload-notes {
background: #fff8f3;
border: 1px solid rgba(255, 107, 74, 0.12);
border-radius: 18px;
padding: 16px;
font-size: 0.85rem;
color: var(--ink-soft);
}
.upload-notes ul {
padding-left: 18px;
margin: 8px 0 0;
display: grid;
gap: 6px;
}
.upload-footer {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.upload-validation {
display: grid;
gap: 6px;
font-size: 0.85rem;
color: #9a3a24;
}
.upload-validation-item {
display: flex;
align-items: center;
gap: 8px;
}
.upload-validation-item::before {
content: "•";
}
.upload-ready {
font-size: 0.9rem;
font-weight: 600;
color: #1a6b5b;
}
.upload-error {
color: #b84a3a;
}
.section-title {
font-family: var(--font-display);
font-size: 1.8rem;
@@ -259,6 +543,17 @@ code {
background: transparent;
}
@media (max-width: 900px) {
.upload-header {
flex-direction: column;
align-items: flex-start;
}
.upload-grid {
grid-template-columns: 1fr;
}
}
.mono {
font-family: var(--font-mono);
}