Parked sketch · Drag-and-drop file field pattern

Sketched 21 May 2026. Dan’s ask: “sketch a pattern for drag-and-drop images into a target field area on a page, that lights up when a file is detected, and becomes an attachment to the field expecting image, images or CSV etc.”

A reusable UI component that any portfolio form (ingest form, contractor record, watch record, claim sidecar form, land-registry document slot) can drop in to capture files via drag-and-drop without needing a server roundtrip during the design phase.


Problem statement

Across the portfolio there are many places where a form needs an attachment:

Where What’s expected Current state
Contents-claim ingest form (_ingest-form.html) photo per item + receipt per replacement text-only field — Dan pastes filename, no drop
Contractor records yard sign / work photo / business card manually copied to pa/contractors/photos/ then HTML-referenced
Watch records dial + caseback + serial-plate shots same manual copy + reference
Land registry deed PDF / survey / title insurance placeholder awaiting scan flags
Utility / sense / harvest records CSV imports of readings, time entries, sales non-existent — no drop pattern anywhere yet

Every one of these would benefit from a “drag a file in, see it attached, export with the form data” flow — without needing to wire up a server, S3 bucket, or auth gate.


Architectural choices — three depths

Limits: ~5MB practical per-file (localStorage quotas + base64 30% bloat); fine for receipts, photos, single PDFs; not for large videos or multi-MB scans.

Depth 2 — Worker-backed direct R2 upload (when MVP outgrows localStorage)

Limits: needs new Worker + R2 bucket + auth wiring; overkill for the MVP use cases above.

Depth 3 — GitHub-API direct commit (the substrate-extreme play)

Limits: browser-side GitHub PAT exposure unless behind auth proxy; rate-limited; not ideal for high-frequency.

Decision for the first build: Depth 1. Lift to Depth 2/3 later if a specific surface needs it.


Visual state machine

Five states the drop zone cycles through:

STASH5
STASH6
STASH7
STASH8
STASH9

Drop-target HTML/CSS/JS sketch (Depth 1 MVP)

HTML — the dropzone partial

<div class="dropzone" 
     data-field="replacement_receipt"
     data-accept="image/*,application/pdf"
     data-multiple="false"
     data-max-mb="5">
  <input type="file" accept="image/*,application/pdf" hidden>
  <div class="dropzone__idle">
    <span class="dropzone__icon">📎</span>
    <p class="dropzone__cta">Drop a file here or <button type="button">click to browse</button></p>
    <p class="dropzone__hint">accepts: image, PDF · max 5MB</p>
  </div>
  <div class="dropzone__preview" hidden>
    <!-- Populated on success: filename, size, thumbnail/icon, [Remove] button -->
  </div>
</div>

CSS — the state styling

.dropzone {
  border: 2px dashed var(--line-strong);
  border-radius: 8px;
  padding: 1.5rem;
  text-align: center;
  background: rgba(0,0,0,0.02);
  transition: all 0.15s ease;
  cursor: pointer;
}
.dropzone:hover { background: rgba(44,74,58,0.04); border-color: var(--accent); }
.dropzone.is-hover {
  border-style: solid; border-color: var(--accent);
  background: rgba(44,74,58,0.08);
  transform: scale(1.01);
}
.dropzone.is-active { opacity: 0.6; cursor: wait; }
.dropzone.is-success { 
  border-style: solid; border-color: #1f6b3d; 
  background: rgba(31,107,61,0.04); 
}
.dropzone.is-error { 
  border-color: #8b2020; 
  background: rgba(139,32,32,0.04); 
}
.dropzone__icon { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
.dropzone__cta { font-size: 0.95rem; }
.dropzone__cta button { 
  background: none; border: none; padding: 0; 
  color: var(--accent); text-decoration: underline; cursor: pointer;
  font-family: inherit; font-size: inherit;
}
.dropzone__hint { font-size: 0.75rem; color: var(--ink-soft); margin-top: 0.3rem; }

JS — the drop handler

class Dropzone {
  constructor(el) {
    this.el = el;
    this.input = el.querySelector('input[type="file"]');
    this.accept = (el.dataset.accept || '').split(',').filter(Boolean);
    this.multiple = el.dataset.multiple === 'true';
    this.maxMB = parseFloat(el.dataset.maxMb || '5');
    this.field = el.dataset.field;
    this.files = [];  // {name, type, size, dataUrl, ts}
    this.bind();
  }

  bind() {
    // Drag events on the dropzone itself
    ['dragenter', 'dragover'].forEach(ev => {
      this.el.addEventListener(ev, e => {
        e.preventDefault();
        this.el.classList.add('is-hover');
      });
    });
    ['dragleave', 'drop'].forEach(ev => {
      this.el.addEventListener(ev, e => {
        e.preventDefault();
        this.el.classList.remove('is-hover');
      });
    });

    // Actual drop
    this.el.addEventListener('drop', e => this.handleFiles(e.dataTransfer.files));

    // Click-to-browse fallback
    this.el.querySelector('button')?.addEventListener('click', e => {
      e.stopPropagation();
      this.input.click();
    });
    this.input.addEventListener('change', e => this.handleFiles(e.target.files));
  }

  validate(file) {
    if (file.size > this.maxMB * 1024 * 1024) {
      return `too large: ${(file.size/1024/1024).toFixed(1)}MB > ${this.maxMB}MB`;
    }
    if (this.accept.length && !this.accept.some(pat => {
      if (pat.endsWith('/*')) return file.type.startsWith(pat.slice(0, -1));
      return file.type === pat || file.name.toLowerCase().endsWith(pat);
    })) {
      return `type '${file.type}' not in accept list`;
    }
    return null;
  }

  async handleFiles(fileList) {
    this.el.classList.add('is-active');
    for (const file of fileList) {
      const err = this.validate(file);
      if (err) {
        this.showError(err);
        this.el.classList.remove('is-active');
        return;
      }
      const dataUrl = await this.readAsDataURL(file);
      this.files.push({
        name: file.name, type: file.type, size: file.size,
        dataUrl, ts: new Date().toISOString(),
      });
      if (!this.multiple) break;
    }
    this.el.classList.remove('is-active');
    this.renderPreview();
    this.persist();
    this.el.dispatchEvent(new CustomEvent('dropzone:change', {
      detail: { field: this.field, files: this.files },
      bubbles: true,
    }));
  }

  readAsDataURL(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject(reader.error);
      reader.readAsDataURL(file);
    });
  }

  renderPreview() {
    const idle = this.el.querySelector('.dropzone__idle');
    const preview = this.el.querySelector('.dropzone__preview');
    idle.hidden = true;
    preview.hidden = false;
    preview.innerHTML = this.files.map((f, i) => `
      <div class="dropzone__file">
        ${f.type.startsWith('image/') 
          ? `<img src="${f.dataUrl}" alt="${f.name}" style="max-width:80px;max-height:80px;">`
          : '<span style="font-size:2rem;">📄</span>'}
        <div>
          <strong>${f.name}</strong><br>
          <small>${(f.size/1024).toFixed(1)} KB</small>
          <button type="button" data-remove="${i}">Remove</button>
        </div>
      </div>
    `).join('');
    preview.querySelectorAll('[data-remove]').forEach(btn => {
      btn.addEventListener('click', e => {
        const i = parseInt(e.target.dataset.remove);
        this.files.splice(i, 1);
        this.persist();
        if (this.files.length === 0) {
          preview.hidden = true; idle.hidden = false;
        } else {
          this.renderPreview();
        }
      });
    });
    this.el.classList.add('is-success');
  }

  showError(msg) {
    this.el.classList.add('is-error');
    // Show transient toast or inline error
  }

  persist() {
    const key = `dropzone:${this.field}`;
    localStorage.setItem(key, JSON.stringify(this.files));
  }

  hydrate() {
    const key = `dropzone:${this.field}`;
    const saved = localStorage.getItem(key);
    if (saved) {
      this.files = JSON.parse(saved);
      if (this.files.length) this.renderPreview();
    }
  }
}

// Boot all dropzones on the page
document.querySelectorAll('.dropzone').forEach(el => new Dropzone(el).hydrate());

Export integration

Each Dropzone instance exposes its files via the dropzone:change CustomEvent. The host form’s existing “Export YAML” button (e.g. _ingest-form.html) reads the file data alongside text fields:

function exportForItem(seq) {
  const fields = collectTextFields(seq);
  const dropzones = document.querySelectorAll(`[data-seq="${seq}"] .dropzone`);
  const attachments = {};
  dropzones.forEach(dz => {
    const field = dz.dataset.field;
    const files = JSON.parse(localStorage.getItem(`dropzone:${field}`) || '[]');
    if (files.length) attachments[field] = files;
  });
  return { fields, attachments };
}

Exported JSON includes the inline data URLs. Claude (or a CLI script) parses the JSON, base64-decodes each file, writes to disk at the canonical path (e.g. pa/home-insurance/claims/_bulk-source/30-invoices/<receipt-name>.pdf), and commits.


Reuse plan — surfaces that immediately benefit

  1. _ingest-form.html (contents claim) — drop photo per item, drop receipt per replacement
  2. Contractor records — drop yard-sign / work photo / business-card straight into the record’s photo block
  3. Watch records — drop dial / caseback / serial-plate shots; embed in record’s photo grid
  4. Nikon cameras records — same as watches; drop body / lens / serial plates
  5. Land registry — drop deed PDF / survey / title-insurance into the placeholder slots
  6. Future utility / sense / harvest records — drop CSV exports to seed historical data
  7. Repair-drop records — drop pre-drop-off photos for warranty + claim reference

Implementation phases

Phase Build Outcome
P1 · MVP (2-3 hrs) Build the Dropzone class as a single ~/Code/home-projects/pa/_assets/dropzone.js + dropzone.css. Drop into _ingest-form.html first (replace text receipt field with dropzone). Verify export flow → JSON → Claude writes files → commit. One working surface; pattern proved
P2 · Spread (1 hr per surface) Lift the dropzone partial into contractor / watch / Nikon / land-registry records. Each surface adopts via 3 lines of HTML + the shared JS/CSS. All photo-needing surfaces support drop
P3 · CLI parser (1 hr) ~/bin/pa_dropzone_apply — reads dropzone-export JSON from clipboard or file, writes files to canonical paths, runs git add + commit One-command sync from form → disk → repo
P4 · Server-side upgrade (when needed) Migrate from data-URL → R2 direct-upload for surfaces that need larger files or multi-device sync Scales beyond 5MB

When to call this back up

Use any of these as the trigger to ship P1:

When called, reference this sketch + bank the result as feedback_dropzone_pattern_shipped.md.


Cross-references

Source: parked_sketch_drag_drop_file_field_pattern_2026-05-21.md · Rendered 2026-05-23 10:02