№ 014
Punch Card
A file uploader built as a card reader — the punched card feeds through exactly as far as the upload has got.
- Version
- 1.0.0
- Download
- 13 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- file upload, retro, progress
Usage
Copy punch-card.css and punch-card.js (and assets/, if there is one) into your project, then:
new PunchCard(element, {
upload: PunchCard.xhr('/api/upload'), // or any (file, onProgress, signal) => Promise
accept: ['.pdf', 'image/*'], // optional: extensions and/or MIME types
maxSize: 10 * 1024 * 1024, // optional, bytes
onComplete(file, response) {},
onError(file, error) {},
})
A file uploader built as a card reader. Drop a file (or browse) and it becomes a perforated data card,
which the machine takes in. The card feeds through the reader exactly as far as the upload has really
got: it waits when the transfer stalls and is only drawn fully inside once the upload succeeds.
onProgress(fraction) reports progress; call onProgress(null) when the size is unknown, and the reader
shows an indeterminate PROCESSING state instead of a percentage.
The perforations are a pattern drawn from the file's name, size and type: a fingerprint, not its contents.Source
/* Punch Card — a file uploader built as a card reader. Laid out on a 460-unit grid (--u),
so the machine scales as one piece with the width of its container. */
.pc {
--pc-ivory: #eae3d3;
--pc-ivory-lt: #f3ede1;
--pc-ivory-dk: #d8ccb6;
--pc-ivory-dk2: #c6b89e;
--pc-paper: #f4e8d4;
--pc-paper-line: #ddcbb2;
--pc-charcoal: #252c30;
--pc-recess: #3d4245;
--pc-amber: #e6a04b;
--pc-amber-lt: #f3c27a;
--pc-amber-dk: #8a561f;
--pc-sage: #a5b8a8;
--pc-sage-dk: #8c9f90;
--pc-shadow: rgba(48, 40, 30, 0.22);
--pc-pixel: "DotGothic16", ui-monospace, monospace;
--pc-display: "Silkscreen", "DotGothic16", ui-monospace, monospace;
container-type: inline-size;
position: relative;
width: 100%;
color: var(--pc-charcoal);
user-select: none;
}
.pc *,
.pc *::before,
.pc *::after {
box-sizing: border-box;
}
.pc-stage {
--u: calc(100cqw / 460);
--open: 0;
position: relative;
width: 100%;
height: calc(var(--u) * 560);
}
/* one-step pixel corners */
.pc-head,
.pc-body,
.pc-lcd,
.pc-lcd-glass {
--st: calc(var(--u) * 3);
clip-path: polygon(
var(--st) 0, calc(100% - var(--st)) 0, calc(100% - var(--st)) var(--st), 100% var(--st),
100% calc(100% - var(--st)), calc(100% - var(--st)) calc(100% - var(--st)), calc(100% - var(--st)) 100%,
var(--st) 100%, var(--st) calc(100% - var(--st)), 0 calc(100% - var(--st)), 0 var(--st), var(--st) var(--st)
);
}
/* hard shadow under the whole machine, as its own layer (clip-path would cut a drop-shadow) */
.pc-stage::before {
content: "";
position: absolute;
left: calc(var(--u) * 8);
top: calc(var(--u) * 290);
width: calc(var(--u) * 460);
height: calc(var(--u) * 262);
background: var(--pc-shadow);
}
/* ── reader head: the upper block with the intake slot ── */
.pc-head {
position: absolute;
left: calc(var(--u) * 18);
right: calc(var(--u) * 18);
top: calc(var(--u) * 186);
height: calc(var(--u) * 110);
background: var(--pc-charcoal);
}
.pc-head::before {
content: "";
position: absolute;
inset: calc(var(--u) * 3);
background: var(--pc-ivory);
clip-path: inherit;
box-shadow: inset 0 calc(var(--u) * 3) 0 var(--pc-ivory-lt), inset 0 calc(var(--u) * -10) 0 var(--pc-ivory-dk);
}
/* the dark recess the card drops into */
.pc-recess {
position: absolute;
left: calc(var(--u) * 30);
right: calc(var(--u) * 30);
top: calc(var(--u) * 16);
height: calc(var(--u) * 58);
background: var(--pc-recess);
box-shadow: inset 0 calc(var(--u) * 6) 0 var(--pc-charcoal);
}
/* the upper lip lifts a few units when the reader opens */
.pc-lip {
position: absolute;
left: calc(var(--u) * -6);
right: calc(var(--u) * -6);
top: calc(var(--u) * -6);
height: calc(var(--u) * 8);
background: var(--pc-ivory-dk2);
border-bottom: calc(var(--u) * 2) solid var(--pc-charcoal);
transform: translateY(calc(var(--u) * -3 * var(--open)));
}
/* the card travels in this window; it vanishes at the roller line */
.pc-feed {
position: absolute;
inset: 0 0 auto;
height: calc(var(--u) * 260);
overflow: hidden;
clip-path: inset(-50% 0 0 0);
}
.pc-card {
position: absolute;
left: calc(var(--u) * 80);
top: 0;
width: calc(var(--u) * 300);
height: calc(var(--u) * 250);
background: var(--pc-charcoal);
/* punch-card corner cut at the top left, in pixel steps */
clip-path: polygon(
calc(var(--u) * 14) 0, 100% 0, 100% 100%, 0 100%, 0 calc(var(--u) * 14),
calc(var(--u) * 4) calc(var(--u) * 14), calc(var(--u) * 4) calc(var(--u) * 9), calc(var(--u) * 9) calc(var(--u) * 9),
calc(var(--u) * 9) calc(var(--u) * 4), calc(var(--u) * 14) calc(var(--u) * 4)
);
}
.pc-card[hidden] { display: none; }
.pc-card-face {
position: absolute;
inset: calc(var(--u) * 2);
background: var(--pc-paper);
clip-path: inherit;
box-shadow: inset calc(var(--u) * -3) 0 0 #e7d8c0;
}
.pc-card-id,
.pc-card-name {
position: absolute;
top: calc(var(--u) * 10);
font: 400 calc(var(--u) * 9.5) / 1 var(--pc-pixel);
letter-spacing: 0.14em;
white-space: nowrap;
}
.pc-card-id { left: calc(var(--u) * 18); }
.pc-card-name {
right: calc(var(--u) * 14);
max-width: calc(var(--u) * 120);
overflow: hidden;
text-overflow: ellipsis;
color: #6b6152;
}
.pc-field-rule {
position: absolute;
top: calc(var(--u) * 28);
bottom: calc(var(--u) * 6);
width: calc(var(--u) * 1);
background: var(--pc-paper-line);
}
.pc-holes {
position: absolute;
left: calc(var(--u) * 16);
top: calc(var(--u) * 32);
width: calc(var(--u) * 256.6);
}
.pc-hole { fill: none; }
.pc-hole.is-on { fill: var(--pc-charcoal); }
.pc-hole.is-hidden { visibility: hidden; }
/* small machines: half the rows, so the pattern stays a pattern */
@container (max-width: 300px) {
.pc-hole.is-odd { visibility: hidden; }
}
/* rollers across the slot, with the amber scanning head in front */
.pc-rollers {
position: absolute;
left: calc(var(--u) * 44);
right: calc(var(--u) * 44);
top: calc(var(--u) * 250);
height: calc(var(--u) * 18);
background:
linear-gradient(var(--pc-charcoal) 0 0) left / calc(var(--u) * 16) 100% no-repeat,
linear-gradient(var(--pc-charcoal) 0 0) right / calc(var(--u) * 16) 100% no-repeat,
linear-gradient(#5b6164 0 calc(var(--u) * 3), #34393c 0);
box-shadow: 0 calc(var(--u) * 2) 0 var(--pc-charcoal);
}
.pc-scanner {
position: absolute;
left: calc(var(--u) * 56);
right: calc(var(--u) * 56);
top: calc(var(--u) * 3);
height: calc(var(--u) * 15);
border: calc(var(--u) * 2) solid var(--pc-charcoal);
background: linear-gradient(#7c6a52 0 calc(var(--u) * 3), #5e5141 0 calc(var(--u) * 8), #4a4034 0);
}
/* awake: the scanning head turns amber (a solid colour, not a glow) */
[data-state="over"] .pc-scanner,
[data-state="checking"] .pc-scanner,
[data-state="uploading"] .pc-scanner,
[data-state="pulling"] .pc-scanner,
[data-state="failed"] .pc-scanner {
background: linear-gradient(var(--pc-amber-lt) 0 calc(var(--u) * 3), var(--pc-amber) 0 calc(var(--u) * 8), var(--pc-amber-dk) 0);
}
/* ── body ── */
.pc-body {
position: absolute;
left: 0;
right: 0;
top: calc(var(--u) * 282);
height: calc(var(--u) * 262);
background: var(--pc-charcoal);
}
.pc-body::before {
content: "";
position: absolute;
inset: calc(var(--u) * 3) calc(var(--u) * 3) calc(var(--u) * 3);
background: var(--pc-ivory);
clip-path: inherit;
box-shadow:
inset calc(var(--u) * 3) calc(var(--u) * 3) 0 var(--pc-ivory-lt),
inset 0 calc(var(--u) * -8) 0 var(--pc-ivory-dk);
}
.pc-label {
position: absolute;
left: calc(var(--u) * 26);
right: calc(var(--u) * 26);
top: calc(var(--u) * 18);
display: flex;
align-items: center;
gap: calc(var(--u) * 10);
font: 400 calc(var(--u) * 9) / 1 var(--pc-pixel);
letter-spacing: 0.16em;
white-space: nowrap;
}
.pc-rule {
flex: 1;
height: calc(var(--u) * 1.5);
background: #8a8174;
}
/* status light: dark at rest, amber while reading, sage once a file is stored */
.pc-led {
width: calc(var(--u) * 8);
height: calc(var(--u) * 8);
border: calc(var(--u) * 1.5) solid var(--pc-charcoal);
background: #6f675a;
}
[data-state="checking"] .pc-led,
[data-state="uploading"] .pc-led,
[data-state="pulling"] .pc-led { background: var(--pc-amber); }
[data-state="stored"] .pc-led { background: var(--pc-sage); }
.pc-lcd {
position: absolute;
left: calc(var(--u) * 28);
right: calc(var(--u) * 28);
top: calc(var(--u) * 38);
height: calc(var(--u) * 100);
background: var(--pc-charcoal);
}
.pc-lcd-glass {
--st: calc(var(--u) * 2);
position: absolute;
inset: calc(var(--u) * 5);
background: var(--pc-sage);
box-shadow: inset calc(var(--u) * 3) calc(var(--u) * 3) 0 var(--pc-sage-dk);
}
.pc-lcd-title,
.pc-lcd-pct {
position: absolute;
top: calc(var(--u) * 14);
font: 700 calc(var(--u) * 19) / 1 var(--pc-display);
letter-spacing: 0.02em;
white-space: nowrap;
}
.pc-lcd-title { left: calc(var(--u) * 16); }
.pc-lcd-pct {
right: calc(var(--u) * 16);
top: calc(var(--u) * 11);
font: 400 calc(var(--u) * 23) / 1 var(--pc-pixel); /* Silkscreen's % reads as a Z */
font-variant-numeric: tabular-nums;
}
.pc-lcd-sub {
position: absolute;
left: calc(var(--u) * 16);
right: calc(var(--u) * 16);
top: calc(var(--u) * 42);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 400 calc(var(--u) * 8.5) / 1 var(--pc-pixel);
letter-spacing: 0.14em;
opacity: 0.85;
}
.pc-bar {
position: absolute;
left: calc(var(--u) * 16);
right: calc(var(--u) * 16);
bottom: calc(var(--u) * 12);
height: calc(var(--u) * 18);
display: flex;
gap: calc(var(--u) * 2);
padding: calc(var(--u) * 2.5);
border: calc(var(--u) * 2) solid var(--pc-charcoal);
}
.pc-bar i {
flex: 1;
background: rgba(37, 44, 48, 0.1);
}
.pc-bar i.is-on { background: var(--pc-charcoal); }
/* drop strip: the uploader's affordances stay plainly visible */
.pc-drop {
position: absolute;
left: calc(var(--u) * 24);
right: calc(var(--u) * 24);
top: calc(var(--u) * 154);
height: calc(var(--u) * 80);
display: flex;
align-items: center;
gap: calc(var(--u) * 14);
padding: 0 calc(var(--u) * 16) 0 calc(var(--u) * 26);
border: calc(var(--u) * 1.5) solid var(--pc-ivory-dk2);
cursor: pointer;
}
.pc-drop::before {
content: "";
position: absolute;
inset: calc(var(--u) * 6);
border: calc(var(--u) * 1.2) dashed #b9ab92;
pointer-events: none;
}
[data-state="over"] .pc-drop { background: #e2d8c3; }
[data-state="over"] .pc-drop::before { border-color: var(--pc-charcoal); border-style: solid; }
.pc-drop-icon {
width: calc(var(--u) * 18);
fill: var(--pc-charcoal);
flex: none;
}
.pc-drop-icon svg { display: block; width: 100%; }
.pc-drop-text {
flex: 1;
min-width: 0;
font: 400 calc(var(--u) * 10.5) / 1 var(--pc-pixel);
letter-spacing: 0.2em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pc-drop-rule {
width: calc(var(--u) * 1.5);
height: calc(var(--u) * 34);
background: #a99c86;
flex: none;
}
.pc-actions {
position: relative;
display: flex;
gap: calc(var(--u) * 8);
}
.pc-btn {
--st: calc(var(--u) * 2);
position: relative;
min-width: calc(var(--u) * 84);
min-height: max(44px, calc(var(--u) * 36));
padding: 0 calc(var(--u) * 12);
border: calc(var(--u) * 2) solid var(--pc-charcoal);
background: var(--pc-ivory-lt);
box-shadow: inset 0 calc(var(--u) * -4) 0 var(--pc-ivory-dk);
font: 400 calc(var(--u) * 11) / 1 var(--pc-pixel);
letter-spacing: 0.16em;
color: var(--pc-charcoal);
cursor: pointer;
}
.pc-btn.is-primary {
background: var(--pc-sage);
box-shadow: inset 0 calc(var(--u) * -4) 0 var(--pc-sage-dk);
}
.pc-btn:active {
box-shadow: none;
transform: translateY(calc(var(--u) * 1));
}
.pc-btn:focus-visible {
outline: calc(var(--u) * 2) solid var(--pc-amber);
outline-offset: calc(var(--u) * 2);
}
.pc-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.pc-foot {
position: absolute;
top: calc(var(--u) * 542);
width: calc(var(--u) * 54);
height: calc(var(--u) * 12);
background: var(--pc-charcoal);
}
.pc-foot-l { left: calc(var(--u) * 24); }
.pc-foot-r { right: calc(var(--u) * 24); }
.pc-live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
} /* Punch Card — usage:
* new PunchCard(element, {
* upload: PunchCard.xhr('/api/upload'), // or any (file, onProgress, signal) => Promise
* accept: ['.pdf', 'image/*'], // optional: extensions and/or MIME types
* maxSize: 10 * 1024 * 1024, // optional, bytes
* onComplete(file, response) {},
* onError(file, error) {},
* })
*
* A file uploader built as a card reader. Drop a file (or browse) and it becomes a perforated data card,
* which the machine takes in. The card feeds through the reader exactly as far as the upload has really
* got: it waits when the transfer stalls and is only drawn fully inside once the upload succeeds.
* onProgress(fraction) reports progress; call onProgress(null) when the size is unknown, and the reader
* shows an indeterminate PROCESSING state instead of a percentage.
* The perforations are a pattern drawn from the file's name, size and type: a fingerprint, not its contents.
*/
(function () {
// Card travel, in units of a 460-unit-wide machine (see punch-card.css).
const PRESENTED = 10; // card top when it stands on the rollers
const INIT = 16; // the rollers take hold
const FEED = 150; // travel that follows the upload from 0 to 100%
const INSIDE = 266; // card top once it is fully inside
const PITCH = 15.6, HOLE = 7; // perforation grid
// choreography (s)
const T = { appear: 0.15, appeared: 0.42, grip: 0.72, pull: 0.35, close: 0.1, jolt: 0.06, back: 0.2, eject: 0.3 };
const COLS = 17, ROWS = 13;
const FIELDS = [1, 4, 4, 4, 4]; // columns per field on the card
const BAR = 12; // LCD progress blocks
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const unit = (t, from, dur) => clamp((t - from) / dur, 0, 1);
const easeOut = (t) => 1 - (1 - t) ** 3;
const easeIn = (t) => t ** 3;
const easeInOut = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
const pad = (n, w) => String(n).padStart(w, '0');
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const sizeText = (b) => (b < 1024 ? `${b} B` : b < 1048576 ? `${(b / 1024).toFixed(b < 10240 ? 1 : 0)} KB` : `${(b / 1048576).toFixed(1)} MB`);
const extOf = (name) => (name.includes('.') ? name.split('.').pop().toUpperCase().slice(0, 4) : 'FILE');
const sprite = (art) => {
const rows = art.trim().split('\n').map((r) => r.trim());
let d = '';
rows.forEach((row, y) => {
for (let x = 0; x < row.length; ) {
if (row[x] !== 'X') { x++; continue; }
const s = x;
while (row[x] === 'X') x++;
d += `M${s} ${y}h${x - s}v1h${s - x}z`;
}
});
return `<svg viewBox="0 0 ${rows[0].length} ${rows.length}" shape-rendering="crispEdges" aria-hidden="true"><path d="${d}"/></svg>`;
};
const UPLOAD_ICON = sprite(`
.....XX.....
....XXXX....
...XXXXXX...
..XX.XX.XX..
.....XX.....
.....XX.....
.....XX.....
XX...XX...XX
XX........XX
XX........XX
XXXXXXXXXXXX
XXXXXXXXXXXX`);
/* ── the card's perforations: a structured fingerprint of harmless metadata ── */
const hash = (s) => {
let h = 2166136261;
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
return h >>> 0;
};
const rng = (seed) => () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
// Each field gets its own density; holes come in short runs with gaps, so the card reads as
// deliberate rhythm rather than noise. The first column is a fixed index track.
const perforations = (file) => {
const r = rng(hash(`${file.name}|${file.size}|${file.type}|${file.lastModified || 0}`));
const grid = [];
let col = 0;
FIELDS.forEach((n, f) => {
const density = f === 0 ? 0 : 0.38 + r() * 0.3;
for (let k = 0; k < n; k++, col++) {
const holes = [];
if (f === 0) {
for (let y = 0; y < ROWS; y++) holes.push(y % 2 === 0 || y === 3 || y === 8);
} else {
let run = 0;
for (let y = 0; y < ROWS; y++) {
const on = run < 3 && r() < density + (run > 0 ? 0.15 : 0);
holes.push(on);
run = on ? run + 1 : 0;
}
if (holes.filter(Boolean).length < 3) holes[Math.floor(r() * ROWS)] = holes[Math.floor(r() * ROWS)] = true;
}
grid.push(holes);
}
});
return grid;
};
/* ── a real uploader over XMLHttpRequest, for upload: ── */
const xhr = (url, { field = 'file', headers = {}, method = 'POST' } = {}) => (file, onProgress, signal) =>
new Promise((resolve, reject) => {
const req = new XMLHttpRequest();
req.open(method, url);
Object.entries(headers).forEach(([k, v]) => req.setRequestHeader(k, v));
req.upload.onprogress = (e) => onProgress(e.lengthComputable ? e.loaded / e.total : null);
req.onload = () => (req.status >= 200 && req.status < 300 ? resolve(req.response) : reject(new Error(`SERVER ERROR ${req.status}`)));
req.onerror = () => reject(new Error('CONNECTION LOST'));
req.onabort = () => reject(Object.assign(new Error('CANCELLED'), { name: 'AbortError' }));
signal.addEventListener('abort', () => req.abort());
const body = new FormData();
body.append(field, file);
req.send(body);
});
class PunchCard {
constructor(root, { upload, accept = [], maxSize = Infinity, onComplete, onError } = {}) {
this.root = root;
this.upload = upload;
this.accept = accept;
this.maxSize = maxSize;
this.onComplete = onComplete;
this.onError = onError;
this.build();
this.reset();
this.render(this.clock());
}
// State, apart from the DOM. Everything shown comes from this and a timestamp, so the reel can
// rebuild any moment by replaying the files and taps from the start.
reset() {
this.serial = 0;
this.run = 0; // id of the current transfer; stale callbacks are ignored
this.state = 'idle'; // idle | over | checking | uploading | pulling | stored | failed | rejected | ejecting
this.since = 0; // clock time the state began
this.file = null;
this.progress = 0; // real progress, 0..1, or null when unknown
this.shown = 0; // progress the card shows, easing toward the real value (never past it)
this.done = null;
this.cardY = PRESENTED;
this.cardFrom = PRESENTED; // where the card was when the state began
this.lastTick = 0;
this.lastProgressAt = 0;
}
clock() {
return performance.now() / 1000;
}
build() {
this.root.classList.add('pc');
const holes = [];
for (let x = 0; x < COLS; x++) {
for (let y = 0; y < ROWS; y++) holes.push(`<rect class="pc-hole${y % 2 ? ' is-odd' : ''}" data-c="${x}" data-r="${y}" x="${x * PITCH}" y="${y * PITCH}" width="${HOLE}" height="${HOLE}"/>`);
}
let edge = 0;
const rules = FIELDS.slice(0, -1).map((n) => {
edge += n;
return `<span class="pc-field-rule" style="left: calc(var(--u) * ${(16 + edge * PITCH - (PITCH - HOLE) / 2).toFixed(1)})"></span>`;
}).join('');
const accept = this.accept.join(',');
this.root.innerHTML = `
<div class="pc-stage">
<div class="pc-head">
<div class="pc-recess"><span class="pc-lip"></span></div>
</div>
<div class="pc-feed">
<div class="pc-card" hidden>
<div class="pc-card-face">
<span class="pc-card-id"></span>
<span class="pc-card-name"></span>
${rules}
<svg class="pc-holes" viewBox="0 0 ${(COLS - 1) * PITCH + HOLE} ${(ROWS - 1) * PITCH + HOLE}" shape-rendering="crispEdges" aria-hidden="true">${holes.join('')}</svg>
</div>
</div>
</div>
<div class="pc-rollers" aria-hidden="true"><span class="pc-scanner"></span></div>
<div class="pc-body">
<div class="pc-label"><span>DATA READER / MK 01</span><i class="pc-rule"></i><i class="pc-led"></i></div>
<div class="pc-lcd">
<div class="pc-lcd-glass">
<span class="pc-lcd-title"></span>
<span class="pc-lcd-pct"></span>
<span class="pc-lcd-sub"></span>
<span class="pc-bar">${'<i></i>'.repeat(BAR)}</span>
</div>
</div>
<div class="pc-drop">
<span class="pc-drop-icon">${UPLOAD_ICON}</span>
<span class="pc-drop-text">DROP FILE HERE</span>
<span class="pc-drop-rule"></span>
<span class="pc-actions">
<button type="button" class="pc-btn" data-act="browse">BROWSE</button>
</span>
<input class="pc-input" type="file" ${accept ? `accept="${esc(accept)}"` : ''} tabindex="-1" aria-hidden="true">
</div>
</div>
<span class="pc-foot pc-foot-l"></span><span class="pc-foot pc-foot-r"></span>
<p class="pc-live" aria-live="polite"></p>
</div>`;
const q = (s) => this.root.querySelector(s);
this.stage = q('.pc-stage');
this.card = q('.pc-card');
this.cardId = q('.pc-card-id');
this.cardName = q('.pc-card-name');
this.holeEls = [...this.root.querySelectorAll('.pc-hole')];
this.title = q('.pc-lcd-title');
this.pct = q('.pc-lcd-pct');
this.sub = q('.pc-lcd-sub');
this.blocks = [...this.root.querySelectorAll('.pc-bar i')];
this.drop = q('.pc-drop');
this.dropText = q('.pc-drop-text');
this.actions = q('.pc-actions');
this.input = q('.pc-input');
this.live = q('.pc-live');
this.input.addEventListener('change', () => {
if (this.input.files[0]) this.take(this.input.files[0]);
this.input.value = '';
});
this.actions.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'browse') this.input.click();
if (act === 'retry') this.retry();
if (act === 'remove' || act === 'cancel') this.remove();
});
// the whole drop strip opens the file picker too (the button is the keyboard route)
this.drop.addEventListener('click', (e) => {
if (!e.target.closest('button') && this.canTake()) this.input.click();
});
// drag and drop anywhere on the machine
let depth = 0;
this.stage.addEventListener('dragenter', (e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
if (depth++ === 0 && this.canTake()) this.set('over');
});
this.stage.addEventListener('dragover', (e) => {
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
e.dataTransfer.dropEffect = this.canTake() ? 'copy' : 'none';
});
this.stage.addEventListener('dragleave', () => {
if (--depth === 0 && this.state === 'over') this.set(this.file ? this.restState : 'idle');
});
this.stage.addEventListener('drop', (e) => {
e.preventDefault();
depth = 0;
const file = e.dataTransfer.files[0];
if (file && this.canTake()) this.take(file);
else if (this.state === 'over') this.set('idle');
});
}
canTake() {
return !['checking', 'uploading', 'pulling', 'ejecting'].includes(this.state);
}
set(state, now = this.clock()) {
if (state !== 'over') this.restState = state;
this.cardFrom = this.cardY;
this.state = state;
this.since = now;
this.loop();
}
/* ── the file's journey ── */
take(file, now = this.clock()) {
this.file = file;
this.serial++;
this.progress = 0;
this.shown = 0;
this.grid = perforations(file);
this.paintCard();
const problem = this.check(file);
this.cardY = PRESENTED;
this.set(problem ? 'rejected' : 'checking', now);
if (problem) {
this.error = problem;
this.announce(`${file.name} not accepted: ${problem}`);
return;
}
this.announce(`${file.name}, ${sizeText(file.size)}, inserted`);
this.start(now);
}
check(file) {
if (file.size > this.maxSize) return `LARGER THAN ${sizeText(this.maxSize)}`;
if (!this.accept.length) return null;
const name = file.name.toLowerCase(), type = (file.type || '').toLowerCase();
const ok = this.accept.some((a) => {
a = a.trim().toLowerCase();
if (a.startsWith('.')) return name.endsWith(a);
if (a.endsWith('/*')) return type.startsWith(a.slice(0, -1));
return type === a;
});
return ok ? null : `${this.accept.map((a) => a.replace(/^\./, '').replace('/*', '').toUpperCase()).join(', ')} ONLY`;
}
start(now = this.clock()) {
const file = this.file;
const run = ++this.run;
this.abort = new AbortController();
this.lastProgressAt = now;
this.announced = 0;
// the transfer starts at once; the card's arrival is only presentation (see tick)
if (!this.upload) {
this.fail(new Error('NO UPLOADER SET'), run);
return;
}
Promise.resolve()
.then(() => this.upload(file, (p) => this.report(run, p), this.abort.signal))
.then((response) => this.finish(run, response))
.catch((err) => this.fail(err, run));
}
// progress from the uploader: a fraction, or null when the size is unknown
report(run, p, now = this.clock()) {
if (run !== this.run) return;
const next = p === null || p === undefined ? null : clamp(p, 0, 1);
if (next === null || next > (this.progress ?? -1)) this.lastProgressAt = now; // a stall is no forward movement
this.progress = next;
if (this.progress !== null && this.progress - this.announced >= 0.25) {
this.announced = Math.floor(this.progress * 4) / 4;
this.announce(`${Math.round(this.announced * 100)} percent`);
}
this.loop();
}
// the upload succeeded: the card catches up to 100%, then the final pull begins
finish(run, response) {
if (run !== this.run) return;
this.progress = 1;
this.done = { response };
this.loop();
}
fail(err, run) {
if (run !== this.run || err?.name === 'AbortError') return;
this.error = (err?.message || 'UPLOAD FAILED').toUpperCase().slice(0, 28);
this.set('failed');
this.announce(`Upload failed: ${this.error}. Retry or remove.`);
this.onError?.(this.file, err);
}
retry() {
if (!this.file || this.state !== 'failed') return;
this.progress = 0;
this.shown = 0;
this.done = null;
this.set('checking');
this.announce('Retrying');
this.start();
}
// cancels a transfer or clears a failed / rejected card: the card comes back out
remove() {
if (!this.file) return;
this.run++;
this.abort?.abort();
this.done = null;
this.set('ejecting');
this.announce('File removed');
}
announce(text) {
this.live.textContent = text;
}
paintCard() {
const f = this.file;
this.cardId.textContent = `DATA CARD / ${pad(this.serial, 3)}`;
this.cardName.textContent = f.name.toUpperCase();
this.cardName.title = f.name;
this.holeEls.forEach((h) => {
const on = this.grid[+h.dataset.c][+h.dataset.r];
h.classList.toggle('is-on', on);
});
}
/* ── per frame ── */
tick(now) {
const dt = clamp(now - (this.lastTick || now), 0, 0.1);
this.lastTick = now;
const t = now - this.since;
const rm = reducedMotion.matches;
// the card shows progress smoothly but never runs ahead of the real transfer
if (this.progress !== null) this.shown = Math.min(this.progress, this.shown + (this.progress - this.shown) * (1 - Math.exp(-dt / 0.15)));
switch (this.state) {
case 'checking':
this.cardY = rm ? PRESENTED : lerp(PRESENTED - 26, PRESENTED, easeOut(unit(t, T.appear, T.appeared - T.appear)));
if (t >= (rm ? 0 : T.appeared)) this.set('uploading', now);
break;
case 'uploading': {
const grip = rm ? 1 : easeInOut(unit(t, 0, T.grip - T.appeared));
this.cardY = PRESENTED + INIT * grip + FEED * (this.progress === null ? 0 : this.shown);
if (this.done && this.shown > 0.995) this.set('pulling', now);
break;
}
case 'pulling':
this.cardY = rm ? INSIDE : lerp(this.cardFrom, INSIDE, easeIn(unit(t, 0, T.pull)));
if (t >= (rm ? 0 : T.pull + T.close)) {
const { response } = this.done || {};
this.done = null;
this.set('stored', now);
this.announce(`${this.file.name} stored`);
this.onComplete?.(this.file, response);
}
break;
case 'failed':
this.cardY = rm ? this.cardFrom : this.cardFrom - 8 * easeOut(unit(t, 0, T.back)); // backs out a little
break;
case 'ejecting':
this.cardY = rm ? PRESENTED : lerp(this.cardFrom, PRESENTED - 30, easeOut(unit(t, 0, T.eject)));
if (t >= (rm ? 0 : T.eject)) {
this.file = null;
this.set('idle', now);
}
break;
default:
}
}
render(now) {
const t = now - this.since;
const s = this.state;
const rm = reducedMotion.matches;
const pctNum = Math.round(this.shown * 100);
// card
const showCard = this.file && s !== 'idle' && s !== 'stored' && s !== 'over';
this.card.hidden = !showCard;
if (showCard) {
this.card.style.transform = `translateY(calc(var(--u) * ${Math.round(this.cardY)}))`;
// perforations appear column by column as the card arrives
const cols = s === 'checking' && !rm ? Math.floor(unit(t, T.appear, T.appeared - T.appear - 0.04) * COLS + 0.999) : COLS;
if (this.shownCols !== cols) {
this.shownCols = cols;
this.holeEls.forEach((h) => h.classList.toggle('is-hidden', +h.dataset.c >= cols));
}
}
// machine: the slot opens for a file and closes once it is stored
const open = s === 'over' ? 0.6
: s === 'pulling' ? 1 - unit(t, T.pull, T.close)
: ['checking', 'uploading', 'failed', 'rejected', 'ejecting'].includes(s) ? 1 : 0;
this.stage.style.setProperty('--open', open.toFixed(2));
this.root.dataset.state = s;
const jolt = s === 'stored' && t < T.jolt && !rm;
this.stage.style.transform = jolt ? 'translateY(calc(var(--u) * 1))' : '';
// display
const name = this.file ? `${this.file.name.toUpperCase()} · ${sizeText(this.file.size)}` : '';
const stalled = s === 'uploading' && this.progress !== null && this.progress < 1 && now - this.lastProgressAt > 1.5;
const lcd = {
idle: ['AWAITING FILE', '00%', 'DROP A FILE OR BROWSE'],
over: ['RELEASE TO INSERT', '', 'THE READER IS OPEN'],
checking: ['CHECKING', '', name],
uploading: this.progress === null ? ['PROCESSING', '', name] : ['TRANSFERRING', `${pad(pctNum, 2)}%`, stalled ? 'WAITING FOR NETWORK…' : name],
pulling: ['TRANSFERRING', '100%', name],
stored: ['FILE STORED', '', name],
failed: ['! UPLOAD FAILED', this.progress === null ? '' : `${pad(Math.round(this.progress * 100), 2)}%`, this.error],
rejected: ['! NOT ACCEPTED', '', this.error],
ejecting: ['CARD RETURNED', '', name],
}[s];
this.title.textContent = lcd[0];
this.pct.textContent = lcd[1];
this.sub.textContent = lcd[2];
this.sub.title = this.file?.name || '';
// progress blocks; a marching block when progress is unknown
const filled = s === 'stored' || s === 'pulling' ? BAR : Math.floor(this.shown * BAR + 1e-6);
const march = s === 'uploading' && this.progress === null ? Math.floor(now / 0.12) % (BAR + 2) : -9;
this.blocks.forEach((b, i) => {
const on = s === 'idle' || s === 'over' || s === 'rejected' ? false : i < filled || Math.abs(i - march) < 2 && march >= 0 && i <= march;
b.classList.toggle('is-on', on);
});
// drop strip and its actions
const mode = s === 'failed' ? 'failed' : s === 'rejected' ? 'rejected' : ['checking', 'uploading', 'pulling'].includes(s) ? 'busy' : 'ready';
if (this.mode !== mode) {
this.mode = mode;
const btn = (act, label) => `<button type="button" class="pc-btn${act === 'browse' || act === 'retry' ? ' is-primary' : ''}" data-act="${act}">${label}</button>`;
this.actions.innerHTML = {
ready: btn('browse', 'BROWSE'),
busy: btn('cancel', 'CANCEL'),
failed: btn('remove', 'REMOVE') + btn('retry', 'RETRY'),
rejected: btn('remove', 'REMOVE') + btn('browse', 'BROWSE'),
}[mode];
this.dropText.textContent = { ready: s === 'stored' ? 'DROP ANOTHER FILE' : 'DROP FILE HERE', busy: 'READING CARD…', failed: 'TRY AGAIN?', rejected: 'CHOOSE ANOTHER' }[mode];
}
if (mode === 'ready') this.dropText.textContent = s === 'stored' ? 'DROP ANOTHER FILE' : 'DROP FILE HERE';
}
/* ── animation: runs while anything moves ── */
loop() {
if (this.running) return;
this.running = true;
this.lastTick = this.clock();
const frame = () => {
const now = this.clock();
this.tick(now);
this.render(now);
const t = now - this.since;
const settling = { idle: false, over: false, stored: t < T.jolt + 0.05, rejected: false, failed: t < T.back + 0.05 }[this.state];
if (settling === false) {
this.running = false;
return;
}
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
}
PunchCard.xhr = xhr;
PunchCard.perforations = perforations;
PunchCard.T = T;
window.PunchCard = PunchCard;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Punch Card</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DotGothic16&family=Silkscreen:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="punch-card.css">
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 32px 16px;
/* warm stone grey */
background: #bfb7ae;
font-family: "DotGothic16", ui-monospace, monospace;
color: #252c30;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 480px;
gap: 36px;
}
.title {
margin: 0;
font-size: 14px;
letter-spacing: 0.5em;
margin-right: -0.5em;
}
#reader { width: 100%; }
/* demo only: how the simulated upload behaves */
.sim {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
font-size: 11px;
letter-spacing: 0.14em;
}
.sim label {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border: 2px solid #252c30;
background: #eae3d3;
cursor: pointer;
}
.sim input { accent-color: #252c30; }
</style>
</head>
<body>
<main class="page">
<p class="title">THE PUNCH CARD</p>
<div id="reader"></div>
<fieldset class="sim" style="border: 0; padding: 0; margin: 0">
<legend style="width: 100%; text-align: center; margin-bottom: 10px; font-size: 11px; letter-spacing: 0.3em">SIMULATED NETWORK</legend>
<label><input type="radio" name="sim" value="normal" checked> NORMAL</label>
<label><input type="radio" name="sim" value="slow"> SLOW + STALL</label>
<label><input type="radio" name="sim" value="unknown"> UNKNOWN SIZE</label>
<label><input type="radio" name="sim" value="fail"> FAILS</label>
</fieldset>
</main>
<script src="punch-card.js"></script>
<script>
// A stand-in for a real upload, so the demo runs without a server.
// In a real app: upload: PunchCard.xhr('/api/upload')
const simulate = (file, onProgress, signal) => new Promise((resolve, reject) => {
const mode = document.querySelector('input[name="sim"]:checked').value;
const total = mode === 'slow' ? 6.5 : 3.2; // seconds
let elapsed = 0;
const timer = setInterval(() => {
elapsed += 0.12;
let p = Math.min(1, (elapsed / total) * (0.8 + Math.random() * 0.4));
if (mode === 'slow' && p > 0.43 && elapsed < total * 0.6) p = 0.43; // the connection stalls
if (mode === 'fail' && p > 0.58) {
clearInterval(timer);
reject(new Error('CONNECTION LOST'));
return;
}
onProgress(mode === 'unknown' ? null : p);
if (elapsed >= total) {
clearInterval(timer);
resolve({ ok: true });
}
}, 120);
signal.addEventListener('abort', () => {
clearInterval(timer);
reject(Object.assign(new Error('CANCELLED'), { name: 'AbortError' }));
});
});
new PunchCard(document.getElementById('reader'), {
upload: simulate,
maxSize: 25 * 1024 * 1024,
onComplete: (file) => console.log('Stored', file.name),
});
</script>
</body>
</html>