1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Equalify Iris — Try it</title>
<style>
:root {
--fg: #14161a;
--bg: #ffffff;
--muted: #4a4f57;
--accent: #0b4f9c; /* >= 4.5:1 on white */
--accent-fg: #ffffff;
--border: #6b7280;
--ok: #156534;
--err: #a31515;
--panel: #f3f5f8;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #f2f4f8; --bg: #14161a; --muted: #c2c8d2; --accent: #7db4ff;
--accent-fg: #08182e; --border: #8a93a3; --ok: #6ee7a0; --err: #ff9a9a; --panel: #1d2128;
}
}
* { box-sizing: border-box; }
body {
margin: 0; font: 1.05rem/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: var(--fg); background: var(--bg);
}
.skip-link {
position: absolute; left: -999px; top: 0; background: var(--accent); color: var(--accent-fg);
padding: .6rem 1rem; z-index: 10;
}
.skip-link:focus { left: 0; }
header, main, footer { max-width: 60rem; margin: 0 auto; padding: 0 1.25rem; }
header { padding-top: 2rem; }
h1 { font-size: 2rem; margin: 0 0 .25rem; }
.tagline { color: var(--muted); margin: 0 0 1.5rem; font-size: 1.15rem; }
main { padding-bottom: 3rem; }
section { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 1.25rem 1.5rem; margin: 1.25rem 0; }
h2 { font-size: 1.3rem; margin: 0 0 .75rem; }
h2:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; }
p { margin: .5rem 0; }
label { display: block; font-weight: 600; margin: 1rem 0 .35rem; }
.hint { color: var(--muted); font-weight: 400; font-size: .95rem; margin: 0 0 .35rem; }
input[type=file], input[type=text], textarea {
font: inherit; width: 100%; max-width: 32rem; padding: .55rem .65rem;
border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg);
}
textarea { min-height: 5rem; }
button {
font: inherit; font-weight: 600; cursor: pointer; border: 1px solid transparent;
border-radius: 8px; padding: .6rem 1.1rem; background: var(--accent); color: var(--accent-fg);
}
button.secondary { background: transparent; color: var(--accent); border-color: var(--accent); }
button[disabled] { opacity: .55; cursor: not-allowed; }
a { color: var(--accent); }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: center; margin-top: 1rem; }
.status { font-weight: 600; }
#error:not(:empty) {
background: var(--err); color: #fff; padding: .75rem 1rem; border-radius: 8px; margin: 1rem 0;
}
iframe { width: 100%; height: 32rem; border: 1px solid var(--border); border-radius: 8px; background: #fff; }
pre { overflow: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 1rem; max-height: 24rem; }
.spinner { display: inline-block; width: 1rem; height: 1rem; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; vertical-align: -2px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
footer { border-top: 1px solid var(--border); padding-top: 1.25rem; padding-bottom: 2rem; color: var(--muted); }
/* The public "pages made accessible" tally. Deliberately quiet: it sits under
the tagline, in body text, and is not a badge or a banner. */
.tally { margin: 0 0 1.5rem; color: var(--muted); }
.tally strong { color: var(--fg); }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
</style>
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header>
<h1>Equalify Iris</h1>
<p class="tagline">Turn images of document pages into accessible HTML.</p>
<!-- Filled from GET /v1/stats after load, and left hidden if that fails or
nothing has been converted yet: an empty boast is worse than none. Not a
live region — it is page content that happens to arrive late, not a
status update anyone is waiting on.
Carries how much Iris has converted and, once the endpoint has enough
documents in its window to answer for it, how well that went. -->
<p id="tally" class="tally" hidden></p>
</header>
<main id="main">
<!-- live regions -->
<div id="error" role="alert" aria-live="assertive"></div>
<div id="live" class="visually-hidden" role="status" aria-live="polite"></div>
<!-- Step 1: upload.
There is no sign-in step. The deployment holds one GitHub credential of its own,
server-side, so a visitor needs none — see docs/github-auth.md. This section is
therefore visible from the start, and `init()` at the bottom of the script takes it
away in the one case a browser cannot use this deployment at all: `server.api_token`
is set, so /v1 needs a shared secret this page does not have. -->
<section id="upload-section" aria-labelledby="upload-h">
<h2 id="upload-h" tabindex="-1">Upload your pages</h2>
<p class="hint">If Iris meets content that warrants a new agent, it may open a public “agent
suggestion” issue on the project repo. Anything you type as feedback is quoted in it.</p>
<form id="upload-form">
<label for="images">Pages (images or PDF)</label>
<!-- The formats and the size limit are facts about the vision model this
deployment runs, so they are fetched from GET /v1/limits rather than
written here: the page used to advertise TIFF, which the model has never
read, and said nothing at all about size — so an oversized photo was
accepted and then failed minutes later inside a model call. #limits-hint
stays empty if that fetch fails; the server enforces the same limits with
the same wording either way. -->
<p class="hint" id="images-hint">A PDF, or images in reading order. A PDF is split into one page per image, and the links in it are carried into the HTML. <span id="limits-hint"></span></p>
<!-- A fallback list, replaced from /v1/limits once it answers. It only filters
the file picker's dialog; it is not a check. -->
<input id="images" name="images" type="file" accept="image/png,image/jpeg,image/gif,image/webp,application/pdf" multiple aria-describedby="images-hint" required>
<div class="row">
<button id="convert-btn" type="submit" disabled>Convert to accessible HTML</button>
</div>
</form>
</section>
<!-- Step 2: progress -->
<section id="status-section" aria-labelledby="status-h" hidden>
<h2 id="status-h" tabindex="-1">Converting…</h2>
<!-- Visible status only (NOT a live region): it updates on every poll, so
announcing it would repeat. Screen-reader announcements go through the
dedicated #live region, fired once per step change (issue #18). -->
<p id="status-live" class="status" aria-busy="true"><span class="spinner" aria-hidden="true"></span> <span id="status-text">Starting…</span></p>
<!-- "a minute or two" was written when the form asked for review passes and
defaulted to 1. Uploads now run at the deployment's cap (3 by default), so
the honest estimate is longer — and it matches what the #live region has
always announced to screen-reader users. -->
<p class="hint">This can take a few minutes — you can leave this page and come back.</p>
</section>
<!-- Step 3: result -->
<section id="result-section" aria-labelledby="result-h" hidden>
<h2 id="result-h" tabindex="-1">Your accessible HTML</h2>
<p>Your document was converted to accessible HTML.</p>
<div class="row">
<!-- The warning belongs in this button's own label, not in a hint under the
row (issue #8). A paragraph after three buttons reads as describing all
three — and the other two do not open a tab: Download saves a file and
"Convert another document" stays on this page. Nothing associated it with
this button either, so a screen reader announced "View converted
document, button" and no more, which is the case the issue is about. -->
<button id="view-btn" type="button">View converted document (opens in a new tab)</button>
<button id="download-btn" type="button" class="secondary">Download HTML</button>
<button id="restart-btn" type="button" class="secondary">Convert another document</button>
</div>
<form id="feedback-form">
<label for="feedback-text">Not quite right? Tell Iris what to fix</label>
<p class="hint" id="fb-hint">Iris will re-run the conversion with your feedback applied (for example: “Keep footnotes separate from body text”).</p>
<input id="feedback-text" type="text" aria-describedby="fb-hint">
<div class="row">
<button id="feedback-btn" type="submit" class="secondary">Re-run with this feedback</button>
</div>
</form>
</section>
</main>
<footer>
<h2 class="visually-hidden">About</h2>
<p><strong>Equalify Iris is Open Source.</strong> Sustainability is key to sustaining its growth.
With that in mind, we hope you use and alter the codebase.</p>
<p><a href="https://github.com/EqualifyEverything/equalify-iris">View the source on GitHub</a>.</p>
</footer>
<script>
const API = '/v1';
// No credential anywhere on this page, deliberately. The deployment authenticates to
// GitHub with a token it holds server-side, so there is nothing for a visitor to sign
// in with and nothing in localStorage to leak.
let sessionId = null;
let sourceBase = 'document';
let currentBlobUrl = null;
// Tracks the last polled phase so step changes are announced to screen readers
// once, instead of re-announcing the same status on every poll (issue #18).
let lastPhase = null;
// Screen Wake Lock sentinel + whether a conversion is currently active (#16).
let wakeLock = null;
let converting = false;
// What this deployment accepts for an upload, from GET /v1/limits (see
// loadLimits). Null until it answers, and null forever if it never does — every
// reader treats that as "do not know", never as "no limit": the server is the one
// that enforces, and a page that guessed would either reject a fine file or
// promise one that fails.
let uploadLimits = null;
const $ = (id) => document.getElementById(id);
const show = (id) => { $(id).hidden = false; };
const hide = (id) => { $(id).hidden = true; };
const announce = (msg) => { $('status-text').textContent = msg; };
const live = (msg) => { $('live').textContent = msg; };
const setError = (msg) => { $('error').textContent = msg || ''; };
const focusHeading = (id) => { $(id).focus(); };
// Best-effort Screen Wake Lock: keep the screen awake while a conversion is in
// progress (issue #16). The browser auto-releases it when the tab is hidden, so
// we re-acquire on visibilitychange. No-op where unsupported or not permitted.
async function keepAwake() {
if (!('wakeLock' in navigator) || wakeLock) return;
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { wakeLock = null; });
} catch { wakeLock = null; }
}
function releaseAwake() {
try { if (wakeLock) wakeLock.release(); } catch {}
wakeLock = null;
}
document.addEventListener('visibilitychange', () => {
if (converting && document.visibilityState === 'visible') keepAwake();
});
// Every call to this deployment goes through here. It adds the API prefix and nothing
// else: there is no Authorization header to add, because the page holds no credential.
async function api(path, opts = {}) {
return fetch(API + path, opts);
}
async function errMessage(res) {
try { const j = await res.json(); return (j.error && j.error.message) || res.statusText; }
catch { return res.statusText; }
}
// ----- Step 1: upload -----
function updateConvertButton() {
$('convert-btn').disabled = $('images').files.length === 0;
}
$('images').addEventListener('change', updateConvertButton);
$('upload-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
setError('');
const files = $('images').files;
if (!files.length) { setError('Please choose at least one image.'); return; }
// Say no before the upload rather than after it. The server enforces the same
// limit (POST /v1/sessions), but an oversized page has to travel the whole way
// there first, and this is a file someone chose seconds ago on a connection
// that may be slow. Skipped entirely when /v1/limits did not answer: an unknown
// limit must not become a guessed one. PDFs are exempt because Iris rasterizes
// their pages itself, so the size of the PDF is not what reaches the model.
const tooBig = uploadLimits
? Array.from(files).find((f) => !/\.pdf$/i.test(f.name) && f.size > uploadLimits.maxBytes)
: null;
if (tooBig) {
// The limit itself is quoted from the served hint, so this message and the
// server's cannot disagree about the number.
setError('“' + tooBig.name + '” is too large to convert. ' + uploadLimits.hint);
return;
}
// Output download name mirrors the uploaded file.
sourceBase = (files[0].name.replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9._-]/g, '_')) || 'document';
const fd = new FormData();
for (const f of files) fd.append('images', f);
const btn = $('convert-btn');
btn.disabled = true; btn.setAttribute('aria-busy', 'true');
try {
const res = await api('/sessions', { method: 'POST', body: fd });
if (!res.ok) throw new Error(await errMessage(res));
const d = await res.json();
sessionId = d.session_id;
hide('upload-section'); show('status-section'); focusHeading('status-h');
announce('Starting… ' + d.image_count + ' page(s) uploaded.');
beginConverting('Converting your document.');
pollStatus();
} catch (e) {
converting = false; releaseAwake();
setError('Upload failed: ' + e.message);
} finally {
btn.removeAttribute('aria-busy');
updateConvertButton();
}
});
// ----- Step 3: poll status -----
// Every phase the API can report, plus `queued`. 'triage' and
// 'reconciliation' were removed with the dead Phase values — the pipeline
// never entered either, so those two labels could not be reached. An
// unrecognized phase still falls back to the raw value below rather than
// announcing nothing.
const PHASE_LABEL = {
queued: 'waiting for a free slot',
extraction: 'extracting content', assembly: 'assembling the document',
review: 'reviewing for accessibility', done: 'finishing up',
};
// One-time announcement when conversion starts: tells screen-reader users it
// runs in the background so they can leave and come back, without the repeated
// status chatter (issue #18).
function beginConverting(lead) {
lastPhase = null;
converting = true;
keepAwake();
$('status-live').setAttribute('aria-busy', 'true');
live((lead || 'Converting your document.') +
' This can take a few minutes — you can leave this page and come back; the conversion keeps running in the background.');
}
async function pollStatus() {
let d = null;
try {
const res = await api('/sessions/' + sessionId);
if (res.ok) d = await res.json();
} catch (e) {
// transient network error — fall through and keep polling
}
if (d && d.status === 'ready_for_review') {
converting = false; releaseAwake();
$('status-live').setAttribute('aria-busy', 'false');
announce('Done! Your accessible HTML is ready.');
live('Conversion complete — your accessible HTML is ready.');
try {
await showResult(d);
} catch (e) {
setError('The conversion finished, but the result could not be displayed: ' + e.message);
}
return; // terminal — stop polling
}
if (d && d.status === 'failed') {
converting = false; releaseAwake();
setError('Conversion failed: ' + (d.error || 'unknown error') + '. You can try again.');
hide('status-section'); show('upload-section'); focusHeading('upload-h');
return; // terminal — stop polling
}
// queued / running / a transient error / an unexpected shape: keep going
// so a single hiccup never strands the user on the spinner.
if (d && (d.phase || d.status)) {
// A session waiting for a concurrency slot is still `queued` and still
// carries its initial phase, so reporting the phase here would claim work
// that hasn't begun ("reading each page" before anything was read). Say
// what is actually happening instead — waiting is not a failure, and a
// user who is told they are in a queue waits differently than one who
// thinks a step has stalled.
const phase = d.status === 'queued' ? 'queued' : (d.phase || d.status);
const label = PHASE_LABEL[phase] || phase;
announce('Working — ' + label + '…'); // visible only — not a live region, so it never re-announces
if (phase !== lastPhase) { // announce a step change to screen readers exactly once
lastPhase = phase;
live(label.charAt(0).toUpperCase() + label.slice(1) + '.');
}
}
setTimeout(pollStatus, 2500);
}
// ----- Step 4: result -----
async function showResult(d) {
const res = await api('/sessions/' + sessionId + '/output');
const html = await res.text();
// Build a local blob URL so "View converted document" can open the
// rendered HTML in a new tab without the /output endpoint needing to be
// reachable unauthenticated.
const blob = new Blob([html], { type: 'text/html' });
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = URL.createObjectURL(blob);
$('view-btn').onclick = () => window.open(currentBlobUrl, '_blank');
$('download-btn').onclick = () => {
const a = document.createElement('a');
a.href = currentBlobUrl;
a.download = sourceBase + '_converted.html';
document.body.appendChild(a); a.click(); a.remove();
};
// A "Iris taught itself N new agent(s)" panel used to be built here from
// `d.pending_prs.new_agents`. Removed because it was dead twice over: no
// endpoint has ever returned `pending_prs` (see #30 Tier 4), so `built` was
// always `[]` and the branch never ran — and the sentence it would have
// shown is no longer true. Agents suggested during a run are drafted and
// filed as GitHub issues for a maintainer (pipeline/contribute.ts); nothing
// is built into the session and used, so there is no "taught itself" to
// report to the person converting a document. The run log's `agent_issue`
// events are where that information actually lives if it is wanted back.
hide('status-section'); show('result-section'); focusHeading('result-h');
}
$('restart-btn').addEventListener('click', () => {
sessionId = null; setError('');
$('upload-form').reset();
updateConvertButton();
hide('result-section'); show('upload-section'); focusHeading('upload-h');
});
$('feedback-form').addEventListener('submit', async (ev) => {
ev.preventDefault();
setError('');
const fb = $('feedback-text').value.trim();
if (!fb) { setError('Please enter some feedback first.'); return; }
try {
const res = await api('/sessions/' + sessionId + '/feedback', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ feedback: fb }),
});
if (!res.ok) throw new Error(await errMessage(res));
$('feedback-text').value = '';
hide('result-section'); show('status-section'); focusHeading('status-h');
announce('Re-running with your feedback…');
beginConverting('Re-running the conversion with your feedback.');
pollStatus();
} catch (e) {
converting = false; releaseAwake();
setError('Could not submit feedback: ' + e.message);
}
});
// ----- Public tally: pages Iris has made accessible -----
// GET /v1/stats is never handed the auth middleware, so it answers even on a deployment
// this page cannot otherwise use.
// Silent on every failure by design: the point is to celebrate a number, and a
// zero, a NaN or an error message in its place is worse than no line at all.
// How well it has been going, appended to the tally sentence when `/v1/stats`
// has enough documents in its window to say (it sends `quality: null` until
// then, and the server decides that, not this page).
//
// Returns '' on anything unexpected, for the same reason the tally hides itself
// on a zero: a visitor deciding whether to hand Iris a document is better served
// by one fewer sentence than by 'NaN% of documents' on the front page.
//
// The wording is as narrow as the measurement. `clean_rate` counts documents whose
// review loop ended with the reviewer reporting nothing left (src/pipeline/
// review.ts), which is not the same as the final axe pass coming back empty — a
// document can carry a violation the reviewer did not raise. So the sentence
// credits the reviewer rather than claiming the output was flawless; "came out
// clean" is what a visitor would hear from the shorter version, and it would be
// a stronger claim than the number supports.
function qualityClause(q) {
if (!q || typeof q !== 'object') return '';
// A real JSON number, not merely something Number() can coerce. `Number(null)`
// is 0 — and 0 is the WORST value the rate can take and the BEST value the pass
// count can take, so coercing would turn a field the server never sent into
// either the most alarming or the most flattering sentence on the page. Nested
// rather than shared so this function stays self-contained, which is what lets
// test/demo-tally.test.ts lift it out of here and run it.
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const clean = num(q.clean_rate);
const days = num(q.window_days);
if (clean === null || clean < 0 || clean > 1 || days === null || days < 1) return '';
// Floor, never round: 99.6% must not be published as '100%'. The one claim here
// that could be wrong in our favour is the one worth truncating.
//
// The epsilon is not a fudge, it is what makes this the floor of the RATE rather
// than of a binary artefact: `0.58 * 100` is 57.99999999999999, so a deployment
// with exactly 58% clean would publish 57%. Ratios of small integers are what
// this field carries — 29/50 is an ordinary window — and there are several such
// pairs under 200 documents. 1e-9 is far below any rate two document counts can
// differ by and far above the ~1e-14 error being corrected, so it cannot round a
// genuine 99.99% up to 100.
const pct = Math.floor(clean * 100 + 1e-9);
let out = ' — over the last ' + Math.round(days) + ' days, <strong>' + pct +
'% of documents finished with the reviewer finding nothing left to fix</strong>';
const rounds = num(q.mean_rounds);
if (rounds !== null && rounds >= 0) {
// Three branches, because two of them are claims and only one is a number.
//
// A mean of exactly 0 is the only value for which "no editor passes at all" is
// true: the loop returns as soon as the reviewer finds nothing, so 0 across the
// whole window means every document read clean on the first look. Any mean above
// 0 — 0.04 is 25 documents of which one needed a pass — means a document DID
// need one, and saying otherwise is the flattering direction this function is
// written to refuse everywhere else.
//
// Small-but-nonzero gets hedged words rather than 'averaging 0.0 editor passes',
// which reads like a number that failed to load. Same reason the exact-zero case
// gets words: the point is a sentence a visitor can believe.
//
// What this sentence must NOT grow into is an inference about why the number is
// low. The loop also stops as soon as a round changes nothing, so a small mean
// can be documents the loop gave up on rather than documents that needed little
// — the clean rate beside it is the claim about quality, and this is a count of
// passes and nothing more. Exactly 0 is still safe to speak for: convergence
// costs a round, so it can never produce a 0.
out += rounds === 0
? ', needing no editor passes at all'
: rounds < 0.05
? ', averaging under 0.1 editor passes'
: ', averaging ' + rounds.toFixed(1) + ' editor passes';
}
return out;
}
async function loadTally() {
try {
const res = await fetch(API + '/stats');
if (!res.ok) return;
const s = await res.json();
const pages = Number(s.pages_processed);
const docs = Number(s.documents_processed);
if (!Number.isFinite(pages) || pages < 1) return;
// The only interpolations below are numbers formatted here and a month +
// year rendered from a Date, so none of it can carry markup.
let text = 'Iris has made <strong>' + pages.toLocaleString() + ' ' +
(pages === 1 ? 'page' : 'pages') + '</strong> accessible';
// "across N documents" only when it says something the page count does
// not: if every document was a single page the two numbers are equal and
// the clause is noise.
if (Number.isFinite(docs) && docs > 1 && docs < pages) {
text += ' across <strong>' + docs.toLocaleString() + ' documents</strong>';
}
const since = s.since ? new Date(s.since) : null;
if (since && !isNaN(since.getTime())) {
text += ' since ' + since.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
}
$('tally').innerHTML = text + qualityClause(s.quality) + '.';
show('tally');
} catch {
// offline, blocked, or the endpoint is unreachable — leave the line hidden
}
}
loadTally();
// ----- Upload limits: what this deployment accepts -----
// GET /v1/limits is ungated too, and it has to be: the limits belong on the upload step,
// which is where someone is choosing a file. What makes it ungated is that the auth
// middleware is attached to /v1/me and /v1/sessions only, not where its mount line sits.
//
// Everything published here is derived server-side from the vision model in use
// (src/providers/imageLimits.ts). The page states nothing about formats or sizes
// on its own so that switching models cannot leave it confidently describing the
// old one — the previous version advertised TIFF, which no Claude model reads.
async function loadLimits() {
try {
const res = await fetch(API + '/limits');
if (!res.ok) return;
const l = await res.json();
const bytes = Number(l && l.image && l.image.max_bytes);
if (!Number.isFinite(bytes) || bytes < 1) return;
uploadLimits = { maxBytes: bytes, hint: String(l.image.hint || '') };
// textContent, not innerHTML: this string comes off the network, and it is a
// sentence rather than markup.
if (uploadLimits.hint) $('limits-hint').textContent = uploadLimits.hint;
// Narrow the file picker to what the model actually reads, replacing the
// fallback list in the markup.
const types = Array.isArray(l.image.media_types) ? l.image.media_types : [];
if (types.length) $('images').setAttribute('accept', types.concat('application/pdf').join(','));
} catch {
// offline or unreachable: leave the hint empty and let the server enforce
}
}
loadLimits();
// ----- Can this page use this deployment at all? -----
// The upload form is already on screen, so this only ever takes it away. `GET /v1/me` is
// the probe because it runs the same middleware a real upload does: 200 means the
// deployment resolved its GitHub account and accepted a request with no credential, 401
// means it did not, and the difference is not knowable from the markup.
//
// The two 401 causes are told apart by the server's own message rather than guessed at
// here — `server.api_token` is set (nothing a browser can supply), or the deployment's
// token is refused by GitHub. Both leave the visitor with nothing to do on this page, so
// both hide the form; what differs is who has to fix it, which is why the message is
// quoted rather than replaced.
//
// A thrown fetch (offline, server down) returns early and changes nothing. The form stays
// usable and the upload reports the failure itself — a page that hid its only control
// because one probe did not land would be unusable the moment a network blipped.
(async function init() {
let res;
try {
res = await api('/me');
} catch {
return;
}
if (res.ok) return;
setError('This deployment cannot convert documents from this page: ' + (await errMessage(res)));
hide('upload-section');
})();
</script>
</body>
</html>