diff --git a/dist/editor.css b/dist/editor.css index 58db6f4..d51370b 100644 --- a/dist/editor.css +++ b/dist/editor.css @@ -56,6 +56,9 @@ buttons further from a finger-sized target exactly on the devices most likely to be touched. */ .re-btn { padding: 9px 11px; } + /* Near and Far keep their words: the rule above strips button labels to + fit the top bar, and a focus bracket alone does not say which way. */ + .re-btn.re-hold > span { display: inline; } } @@ -154,6 +157,14 @@ .re-btn:disabled { opacity: .45; cursor: default; } .re-btn.re-pri { background: #5c70e8; border-color: #5c70e8; color: #fff; } .re-btn.re-sm { padding: 4px 8px; font-size: 11px; } +/* Near and Far. The one control here worked with a finger at the camera, so + * finger-sized at every width and not only under the phone rule: they were + * 32px on a desktop, the smallest thing on the panel. */ +.re-btn.re-hold { min-height: 44px; padding: 9px 14px; } +/* A link in a panel. The accent is 3.5:1 on the raised panel, under the + * minimum for text, so links take a lighter tint of it (8:1). */ +.re-root a { color: #a9b8ff; } +.re-root a:hover { color: #fff; } .re-chip { display: inline-flex; align-items: center; gap: 7px; padding: 4px 9px; border: 1px solid #2c313d; border-radius: 7px; background: #232734; font-size: 11px; } .re-chip-k { font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; @@ -246,6 +257,16 @@ /* The bar gets somewhere to go rather than off the edge. */ .re-top { overflow-x: auto; scrollbar-width: none; } .re-top::-webkit-scrollbar { display: none; } + /* Back is the way out, and a tapped tab scrolls itself into view and + takes everything left of it off the screen -- seen with Back at + x=-259 on a 390px phone. So it sticks to the left edge, with the bar's + own colour behind it so the tabs pass underneath. */ + .re-top > .re-back { position: sticky; left: 0; z-index: 1; background: #1c1f27; + box-shadow: 8px 0 8px -4px #1c1f27; } + /* With the scrollbar hidden nothing said there was more bar to the + right. The editor sets re-more while there is; this fades that edge. */ + .re-top.re-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); } /* A finger, not a mouse: 44px is the guideline and the main tab bar was 25. 12px text is 18px on a 1.5 line, so 13px of padding makes 44 with no border and 12px makes 44 with one. */ diff --git a/dist/editor.js b/dist/editor.js index 0122189..71a9795 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -67,6 +67,13 @@ const ICON = { '', 15), dropper: svg('', 14), + /* The focus brackets the camera's own live page draws on its Near and Far, + * so the same lens wears the same glyph on both: a wide subject for near, + * a distant one for far. */ + near: svg('' + + '', 15), + far: svg('' + + '', 15), }; /* @@ -300,7 +307,13 @@ export function mountEditor(root, { * These DO get a countdown, unlike `move`, and for a reason the lens * buttons do not have: a filter that measures the wrong thing looks like a * camera that will not focus, so there is nothing on screen to tell an - * operator which change to undo. The clock undoes it for them. */ + * operator which change to undo. The clock undoes it for them. + * + * `liveHref`, optional, is where the host's live picture is. The panel + * opens by sending an owner there: this tab drives the lens over a STILL + * and exists to tune the filter, and titled "Focus" with a Near and a Far + * of its own it read as the place to focus a camera. Without one the + * sentence is simply not said. */ focus, /* How long to wait for the module to arrive and answer. The camera's own * loader gives the CDN eight seconds; a test harness under a virtual clock @@ -374,7 +387,9 @@ export function mountEditor(root, { root.append(splash); // Reveal on the sheet, or on the deadline. The interface being ugly is // recoverable; the interface never appearing is not. - acquireStylesheet(base, startupTimeoutMs).then(reveal); + // And measure the bar the moment it is first seen, for a browser with + // no ResizeObserver, where nothing else would. + acquireStylesheet(base, startupTimeoutMs).then(reveal).then(() => moreBar()); } const state = { info: null, probe: null, cfa: 0, demosaic: 3, black: 0, white: 1023, @@ -390,8 +405,18 @@ export function mountEditor(root, { /* ---- chrome ---- */ const top = el('div', 're-top'); - const backBtn = el('button', 're-btn re-sm', ICON.back); + const backBtn = el('button', 're-btn re-sm re-back', ICON.back); backBtn.title = 'Back'; + /* On a phone the bar scrolls, and a tab that was just tapped scrolls + * itself into view -- taking everything left of it, Back included, off + * the screen with no scrollbar to say so. Back is made sticky in the + * stylesheet; this is the other half, a class while there is more bar to + * the right, so the stylesheet can fade that edge. Toggled rather than + * measured in CSS because nothing in CSS knows where a scroll is. */ + const moreBar = () => { + top.classList.toggle('re-more', top.scrollLeft + top.clientWidth < top.scrollWidth - 1); + }; + top.addEventListener('scroll', moreBar, { passive: true }); const nameEl = el('span', '', 'No frame'); nameEl.style.cssText = 'font-size:13px;font-weight:500'; const sensorChip = el('span', 're-chip'); @@ -814,11 +839,20 @@ export function mountEditor(root, { * window ever changing. Watching the element itself catches all of those, * where a window listener catches only the first. */ - const onResize = () => { drawChart(); drawMarks(); drawFocusMarks(); }; + const onResize = () => { drawChart(); drawMarks(); drawFocusMarks(); moreBar(); }; let ro = null; if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(onResize); ro.observe(canvas); + /* The bar's fade depends on what the bar holds, and the canvas says + * nothing about that. It does fire at first layout even without a + * frame -- measured at 400px, the fade was there before any frame or + * scroll -- but the name and the chip grow when a frame lands, and + * that is what pushes the tabs about and can tip a bar that fitted + * into one that does not, with nothing on the canvas changing. */ + ro.observe(top); + ro.observe(nameEl); + ro.observe(sensorChip); } window.addEventListener('resize', onResize); /* A press that ends anywhere but on the button still has to stop the lens. @@ -3966,6 +4000,15 @@ export function mountEditor(root, { */ let focusSum = null, focusBest = null, focusErr = null; let focusHold = null, focusTimer = null, focusStatus = null; + /* The line under Near and Far: how to use them, what they are doing, and + * why the lens did not move when it did not. */ + let moveSay = null; + /* The best BLOCK value seen since the hold was last cleared, in the + * readout's own units. focusBest tracks the peak zone of the fine grid; a + * 3x3 draws block means, and a "best so far" beside a block mean has to + * be a block mean or the two numbers cannot be compared. Kept per readout + * size, because a 4x4 block is not a 3x3 block. */ + let blockBest = null, blockBestGrain = null; /* Bumped whenever polling stops or restarts. A read already in flight * carries the number it started under, and an answer whose number has * moved on is dropped: it was measured of a poll that no longer exists -- @@ -3983,6 +4026,7 @@ export function mountEditor(root, { * lens can never be beaten, so it reads as "you are getting worse". */ function resetFocusState() { focusSum = null; focusBest = null; focusErr = null; focusHold = null; + blockBest = null; /* Measured against one scene, one lens position and one filter. A new * frame is none of those, and a ring left over from the last one * points at a zone that no longer means anything. */ @@ -4019,6 +4063,7 @@ export function mountEditor(root, { focusErr = null; if (!focusHold) focusHold = peakHold(); focusBest = focusHold.push(sum); + noteBlockBest(sum); if (handFrames && handFrames.length < HAND_MAX) { handFrames.push({ fv: sum.fv, state: sum.state, sat: sum.satZone, rows: sum.rows, cols: sum.cols, peak: sum.peak, @@ -4044,7 +4089,38 @@ export function mountEditor(root, { loop(); } + /* Where a block sits, in the words the picture is read with. The 3x3 is + * the default readout and gets plain names; anything finer is named by + * row and column OF THAT READOUT, which is what is drawn. Naming a cell + * of the measured 15x17 grid under a 3x3 -- "row 5, column 4" -- pointed + * at nothing on screen. */ + function placeName(row, col, rows, cols) { + if (rows === 3 && cols === 3) { + const v = ['top', '', 'bottom'][row], h = ['left', '', 'right'][col]; + return v && h ? v + ' ' + h : (v || h || 'centre'); + } + return 'row ' + (row + 1) + ', column ' + (col + 1); + } + + /* The block record, advanced by a reading and by nothing else. A readout + * change starts a fresh one at the next reading: a 4x4 block's mean is not + * a 3x3 block's, and a record carried across would be a target in the + * wrong units. */ + function noteBlockBest(sum) { + /* While every zone is drawn, the last block readout's record goes on + * advancing, so coming back to it finds the best of everything read + * since -- not a record frozen at the moment of the switch, which + * would call a peak swept past under All zones "never seen". */ + const g = focusBlocks || blockBestGrain; + if (!g) return; + const c = coarsen(sum, g, { detail: focusBest ? zoneDetail(focusBest) : null }); + const v = c.best === null ? null : c.blocks[c.best].value; + if (blockBestGrain !== g) { blockBest = null; blockBestGrain = g; } + if (v !== null && (blockBest === null || v > blockBest)) blockBest = v; + } + function renderFocus() { + paintFilterSat(); if (!focusStatus) return; focusStatus.replaceChildren(); if (focusErr) { @@ -4060,37 +4136,57 @@ export function mountEditor(root, { return; } const s = focusSum; - const at = s.peakAt - ? `row ${s.peakAt.row + 1}, column ${s.peakAt.col + 1}` - : 'nowhere it could measure'; + /* + * The peak in the readout's own terms, so the panel and the picture + * describe one thing. At 3x3 or 4x4 that is the outlined block -- its + * mean, and where it sits; with every zone drawn it is the peak zone. + * The line used to quote the fine peak under every readout, so the + * outlined square said 14 793, the sentence said 56 099 at "row 5, + * column 4", and a 4x4 said 24 553 of the same star chart: three + * numbers on screen, all called sharpest. + */ + let where, value, best; + if (focusBlocks) { + const c = coarsen(s, focusBlocks, { detail: focusBest ? zoneDetail(focusBest) : null }); + const b = c.best === null ? null : c.blocks[c.best]; + where = b ? placeName(b.row, b.col, c.rows, c.cols) : 'nowhere it could measure'; + value = b ? b.value : null; + /* Read, not updated, here: the record advances only when a reading + * arrives. Advancing it on every render put the reading already on + * screen straight back into a record that had just been cleared -- + * by Start over, or by a new filter -- so "best so far" was never + * genuinely fresh. */ + best = blockBestGrain === focusBlocks ? blockBest : null; + } else { + where = s.peakAt + ? `row ${s.peakAt.row + 1}, column ${s.peakAt.col + 1} of the ` + + `${s.rows}×${s.cols} zones` + : 'nowhere it could measure'; + value = s.peak; + best = focusBest ? focusBest.bestOverall : null; + } const line = el('p', 're-note'); line.dataset.act = 'focus-status'; /* The held best, not this instant's -- the number a person turning a * barrel is trying to beat, and the one still on screen after they - * have swept past it. */ + * have swept past it. + * + * A pinned counter is stated here as a count, beside the other things + * the grid could not measure, and not as a warning. It is one -- a + * value that has stopped being able to move looks exactly like a + * sharp, steady picture -- but the thing to do about it is a gain in + * the filter designer, so the warning and the instruction sit there, + * next to the input they name. On a stock camera the yellow box fired + * the moment the tab opened and read as "this camera is broken". */ line.textContent = - `Sharpest at ${at}: ${s.peak === null ? '—' : s.peak}` + - (focusBest ? `, best seen ${focusBest.bestOverall}` : '') + '. ' + + `Sharpest at ${where}: ${value === null ? '—' : value}` + + (best !== null ? `, best so far ${best}` : '') + '. ' + `${s.measured} of ${s.rows * s.cols} zones measured` + (s.unlit ? `, ${s.unlit} too dark` : '') + - (s.clipped ? `, ${s.clipped} blown out` : '') + '.'; + (s.clipped ? `, ${s.clipped} blown out` : '') + + (s.saturated ? `, ${s.saturated} reading at the top of the camera’s counter` + + (s.peakSaturated ? ' (the sharpest among them)' : '') : '') + '.'; focusStatus.append(line); - /* A pinned counter is the one fault here that reads as a good result: - * the number is large and steady, which looks like a sharp, stable - * picture. It is neither -- it is a value that has stopped being able - * to move, and every comparison made against it is worthless. */ - if (s.saturated) { - const box = el('div', 're-notice re-warn', ICON.warn); - box.append(Object.assign(el('div'), { - textContent: `${s.saturated} zone${s.saturated === 1 ? ' is' : 's are'} ` + - 'reading at the top of the camera\u2019s counter' + - (s.peakSaturated ? ', the sharpest among them' : '') + - '. The filter\u2019s gain is higher than the counter can hold, so ' + - 'those readings cannot rise and a comparison between two of them ' + - 'means nothing. Lower the first gain until this clears.', - })); - focusStatus.append(box); - } if (!s.measured) { const box = el('div', 're-notice re-warn', ICON.warn); box.append(Object.assign(el('div'), { @@ -4139,14 +4235,14 @@ export function mountEditor(root, { * window, raising an unhandled rejection each time round. */ function moveSend(verb, onFail) { const gen = moveGen; - const fail = function () { - if (onFail && gen === moveGen) onFail(); + const fail = function (e) { + if (onFail && gen === moveGen) onFail(e); }; let p; try { p = focus.move(verb); } catch (e) { - fail(); + fail(e); return; } if (p && typeof p.catch === 'function') { @@ -4154,6 +4250,20 @@ export function mountEditor(root, { } } + /* What the buttons are doing, said under them. A hold used to end in + * silence when the camera refused it, so a lens the camera was not + * driving looked exactly like one that had reached its stop, and the + * host's reason -- a motor driver not loaded, a port shut -- reached + * nobody. */ + const HOLD_HINT = 'Hold to move the lens; let go to stop.'; + function sayMove(text) { + if (moveSay) moveSay.textContent = text; + } + function moveRefused(e) { + sayMove('The lens did not move: ' + + (e && e.message ? e.message : (e || 'the camera refused')) + '.'); + } + /* A release from a pointer that never owned the hold is somebody else's * finger. An event carrying no pointerId at all is not a pointer's report * and always counts. */ @@ -4173,6 +4283,9 @@ export function mountEditor(root, { moveVerb = null; movePointer = null; moveGen++; + /* Back to the hint, unless a refusal has just been written there: that + * is the one thing worth leaving on screen after the button comes up. */ + if (moveSay && /^Moving/.test(moveSay.textContent)) sayMove(HOLD_HINT); /* Told to stop even though it would time out anyway: the deadline is * the fallback, not the plan, and a lens that keeps creeping after the * button came up reads as a broken control. A stop that itself fails @@ -4182,7 +4295,7 @@ export function mountEditor(root, { function moveAsk() { if (!moveVerb) return; - moveSend(moveVerb, moveRelease); + moveSend(moveVerb, function (e) { moveRefused(e); moveRelease(); }); } /* The sweep needs to KNOW whether a move happened, where the hold-to-run @@ -4244,6 +4357,7 @@ export function mountEditor(root, { moveTimer = setInterval(moveAsk, (focus && focus.moveRepeatMs) || 250); moveGiveUp = setTimeout(moveRelease, (focus && focus.moveMaxMs) || MOVE_MAX_MS); + sayMove(verb === 'near' ? 'Moving nearer…' : 'Moving further away…'); moveAsk(); if (ev && ev.preventDefault) ev.preventDefault(); }; @@ -4262,7 +4376,7 @@ export function mountEditor(root, { if (ev.repeat) return; if (lensOwned) return; ev.preventDefault(); - moveSend(verb, null); + moveSend(verb, moveRefused); }); } @@ -4550,15 +4664,44 @@ export function mountEditor(root, { return new Promise(function (r) { setTimeout(r, ms); }); } - function buildFilterDesigner(panel) { - const box = el('div', 're-panel'); - box.style.marginTop = '10px'; - box.append(Object.assign(el('div', 're-shead'), { - innerHTML: '

Filter

', + /* The counter warning's home: beside the gain it tells the operator to + * lower. Set by the designer, cleared with it, painted by renderFocus. */ + let filterSatBox = null; + + function paintFilterSat() { + if (!filterSatBox) return; + filterSatBox.replaceChildren(); + const s = focusSum; + if (!s || !s.saturated) return; + /* A pinned counter is the one fault here that reads as a good result: + * the number is large and steady, which looks like a sharp, stable + * picture. It is neither -- it is a value that has stopped being able + * to move, and every comparison made against it is worthless. */ + const box = el('div', 're-notice re-warn', ICON.warn); + box.append(Object.assign(el('div'), { + textContent: `${s.saturated} zone${s.saturated === 1 ? ' is' : 's are'} ` + + 'reading at the top of the camera’s counter' + + (s.peakSaturated ? ', the sharpest among them' : '') + + '. The filter’s gain is higher than the counter can hold, so ' + + 'those readings cannot rise and a comparison between two of them ' + + 'means nothing. Lower Scale, the first of the gains, until this clears.', })); + filterSatBox.append(box); + } + + /* + * Folded away by default. Fourteen number boxes and four verbs are the + * engineer's half of the panel, and they sat directly under Near and Far + * at the same weight, with the fold of a laptop screen landing exactly on + * the first row of bare numbers. An owner never needs any of it; the + * operator who does knows to open it. + */ + function buildFilterDesigner(panel) { + const hold = Math.max(5, focus.holdSeconds || 30); + const box = el('div'); box.append(Object.assign(el('p', 're-note'), { textContent: 'What the camera counts as detail. Change it, watch the ' + - 'squares, and keep it if the sharpest zone reads higher than it did.', + 'squares, and keep it if the sharpest block reads higher than it did.', })); const status = el('div'); @@ -4566,20 +4709,35 @@ export function mountEditor(root, { * the grid's own above it; both are notices and both can be warnings. */ status.dataset.act = 'af-status'; box.append(status); + filterSatBox = el('div'); + filterSatBox.dataset.act = 'af-sat'; + filterSatBox.style.cssText = 'margin-top:7px'; + box.append(filterSatBox); const rows = el('div'); box.append(rows); /* The ranges are the camera's register fields, and the panel refuses * out of range rather than letting the camera refuse silently: these * are written into a few bits each and nothing downstream checks. */ + /* Every box named. Seven bare numbers in a row were this panel's own + * "row 5, column 4": the counter warning says "lower Scale", and there + * has to be a box on screen that says Scale. */ const FIELDS = [ { k: 'gain', label: 'Gains', n: 7, lo: -511, hi: 511, first: [0, 255], - hint: 'An input scale, then three pairs — one per section.' }, + names: ['Scale', '1a', '1b', '2a', '2b', '3a', '3b'], + said: ['input scale', 'section 1, first', 'section 1, second', + 'section 2, first', 'section 2, second', 'section 3, first', + 'section 3, second'], + hint: 'Scale is the input gain; then two coefficients per section.' }, { k: 'shift', label: 'Shifts', n: 4, lo: 0, hi: 7, + names: ['1', '2', '3', '4'], + said: ['stage 1', 'stage 2', 'stage 3', 'stage 4'], hint: 'How far each stage divides its result down.' }, { k: 'coring', label: 'Coring', n: 3, lo: 0, hi: 2047, slope: [1, 15], - hint: 'Threshold, slope, limit. A high threshold throws away the ' + - 'small detail that coming into focus produces.' }, + names: ['Threshold', 'Slope', 'Limit'], + said: ['threshold', 'slope', 'limit'], + hint: 'A high threshold throws away the small detail that coming ' + + 'into focus produces.' }, ]; const inputs = {}; let enables = []; @@ -4589,12 +4747,16 @@ export function mountEditor(root, { row.style.cssText = 'margin-top:9px'; row.append(Object.assign(el('div', 're-cap'), { textContent: f.label })); const line = el('div'); - line.style.cssText = 'display:flex;gap:5px;flex-wrap:wrap;margin-top:4px'; + line.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-top:4px'; inputs[f.k] = []; for (let i = 0; i < f.n; i++) { + const lab = el('label'); + lab.style.cssText = 'display:flex;flex-direction:column;gap:2px'; + lab.append(Object.assign(el('span', 're-note'), { textContent: f.names[i] })); const inp = el('input', 're-in'); inp.type = 'number'; inp.dataset.act = 'af-' + f.k + '-' + i; + inp.setAttribute('aria-label', f.label.replace(/s$/, '') + ': ' + f.said[i]); inp.style.cssText = 'width:64px'; /* Per-slot bounds where a slot differs from its neighbours: the * first gain is an unsigned input scale, not a coefficient, and @@ -4608,10 +4770,11 @@ export function mountEditor(root, { inp.min = String(lo); inp.max = String(hi); inputs[f.k].push(inp); - line.append(inp); + lab.append(inp); + line.append(lab); } row.append(line); - row.append(Object.assign(el('span', 'hint'), { textContent: f.hint })); + row.append(Object.assign(el('div', 're-note'), { textContent: f.hint })); return row; }; FIELDS.forEach((f) => rows.append(mkRow(f))); @@ -4631,12 +4794,13 @@ export function mountEditor(root, { const cb = el('input'); cb.type = 'checkbox'; cb.dataset.act = 'af-enable-' + i; + cb.setAttribute('aria-label', 'Section ' + (i + 1)); lab.append(cb, Object.assign(el('span'), { textContent: String(i + 1) })); enLine.append(lab); return cb; }); enRow.append(enLine); - enRow.append(Object.assign(el('span', 'hint'), { + enRow.append(Object.assign(el('div', 're-note'), { textContent: 'Section 3 reads higher as the picture blurs, so leaving ' + 'it off is most of why this filter tracks focus at all.', })); @@ -4646,7 +4810,9 @@ export function mountEditor(root, { acts.style.cssText = 'display:flex;gap:8px;margin-top:10px;flex-wrap:wrap'; const send = el('button', 're-btn re-pri', ''); send.dataset.act = 'af-apply'; - send.textContent = 'Try it'; + /* The trial's length is on the button. "Try it" alone did not say the + * camera would be written to, nor that it would be put back. */ + send.textContent = 'Try it for ' + hold + 's'; send.disabled = true; const back = el('button', 're-btn', ''); back.dataset.act = 'af-reload'; @@ -4686,6 +4852,17 @@ export function mountEditor(root, { acts.append(hand, handDone); } box.append(acts); + /* What each verb costs, before it is pressed. Measure it drives the + * lens for several seconds; the sentence saying it would be put back + * used to appear only after the click. */ + box.append(Object.assign(el('div', 're-note'), { + style: 'margin-top:6px', + textContent: 'Try it puts the filter on the camera for ' + hold + ' seconds ' + + 'and takes it back unless you keep it.' + + (measure ? ' Measure it moves the lens out and back, eight steps ' + + 'each way, and reports how sharply the filter peaks.' : '') + + ' Measure by hand does the same while you move the lens yourself.', + })); const say = (msg, warn) => { status.replaceChildren(); @@ -4784,10 +4961,10 @@ export function mountEditor(root, { * with what this one reads -- different filters count detail * differently, so the number to beat has to start again. */ /* Findings belong to the filter they were measured under. */ - focusHold = null; focusBest = null; focusOdd = null; + focusHold = null; focusBest = null; focusOdd = null; blockBest = null; renderFocus(); startFocusPoll(); - armHold(status, send, Math.max(5, focus.holdSeconds || 30), + armHold(status, send, hold, 'Applied to the camera.', { revert: () => focus.revertFilters(), keep: focus.keepFilters @@ -4822,10 +4999,17 @@ export function mountEditor(root, { /* Findings from the last sweep describe the lens where it was. */ focusOdd = null; handFrames = []; + /* In the motor's terms where there is one. The intro adapts; + * this did not, and told the owner of a sealed zoom block to + * turn a barrel they cannot reach. */ + const how = typeof focus.move === 'function' + ? 'Hold Far until the picture is well out of focus, then hold ' + + 'Near right through focus to the other end.' + : 'Turn the lens slowly from one end of its travel to the ' + + 'other, right through focus.'; const tick = () => { const n = handFrames ? handFrames.length : 0; - say('Turn the lens slowly from one end of its travel to the ' + - 'other, right through focus. Keep going \u2014 ' + n + + say(how + ' Keep going \u2014 ' + n + ' reading' + (n === 1 ? '' : 's') + ' so far.'); }; tick(); @@ -4890,7 +5074,7 @@ export function mountEditor(root, { } load(); - panel.append(box); + panel.append(foldout('Tune the focus filter (advanced)', (body) => body.append(box))); } function buildFocus() { @@ -4899,48 +5083,100 @@ export function mountEditor(root, { * last one made are detached and must not be kept -- disabling a node * nobody can see is a leak that also hides a bug. */ motorBtns = []; + moveSay = null; + filterSatBox = null; const panel = el('div', 're-panel'); panel.append(Object.assign(el('div', 're-shead'), { innerHTML: '

Focus

', })); - /* Says what to actually DO, and that differs: a camera with a motor is - * focused from this panel, one without is focused at the camera. Telling - * someone to turn a lens they could drive from here, or to hold a button - * that is not on screen, is worse than saying nothing. */ - const drive = focus && typeof focus.move === 'function' - ? 'Hold Near or Far until the bright patch is where you want it sharp.' - : 'Turn the lens until the bright patch is where you want it sharp.'; - panel.append(Object.assign(el('p', 're-note'), { - textContent: 'Each square is one of the camera\'s focus zones, brightest where ' + - 'the picture has the most detail. ' + drive + ' The picture behind is the ' + - 'frame you captured — the squares are live.', - })); + const motor = focus && typeof focus.move === 'function'; + /* + * Whose panel this is, before anything else. An owner who wants a + * sharp picture focuses on the live page, where the picture moves; + * this one drives the lens over a STILL and exists to tune the filter. + * Titled Focus and carrying a Near and a Far of its own it read as the + * place to focus a camera, and the first thing it then showed on a + * stock camera was a warning about a gain. So the live page comes + * first, when the host has said where it is, and what this tab is for + * comes second. + * + * The rest says what to actually DO, and that differs: a camera with a + * motor is focused from this panel, one without is focused at the + * camera. Telling someone to turn a lens they could drive from here, + * or to hold a button that is not on screen, is worse than saying + * nothing. + */ + const intro = el('p', 're-note'); + if (focus && focus.liveHref) { + intro.append(motor + ? 'To focus the camera, use the lens controls on the ' + : 'To focus the camera by eye, watch the '); + const a = el('a', '', 'Live page'); + a.href = focus.liveHref; + intro.append(a, motor ? '. ' : ' while you turn the lens. '); + } + intro.append('This tab tunes what the camera counts as sharp. Each square is ' + + 'one of its focus zones, brightest where the picture has the most ' + + 'detail; the squares are live, the picture behind is the still frame ' + + 'you captured. ' + (motor + ? 'Hold Near or Far to move the lens and watch the numbers.' + : 'Turn the lens and watch the numbers.')); + panel.append(intro); + /* Reachable before a frame exists -- the tab is built at mount and the + * first capture takes seconds -- and the readings are live either way. + * Said, rather than left as a panel beside an empty stage. Gone with + * the next rebuild, which opening a frame triggers. */ + if (!state.info) { + panel.append(Object.assign(el('p', 're-note'), { + textContent: 'Waiting for a frame to draw the zones over. The readings ' + + 'below are live already.', + })); + } focusStatus = el('div'); + focusStatus.dataset.act = 'focus-grid-status'; panel.append(focusStatus); - const row = el('div'); - row.style.cssText = 'display:flex;gap:8px;margin-top:9px;flex-wrap:wrap'; - /* Only where there is a motor to drive. Same rule the Capture button and * the Plates tab follow: a control that can never work is worse than * none, and most cameras focus by hand. */ - if (focus && typeof focus.move === 'function') { - [['near', 'Near'], ['far', 'Far']].forEach(function (pair) { - const b = el('button', 're-btn', ''); - b.dataset.act = 'focus-' + pair[0]; - b.textContent = pair[1]; - b.disabled = lensOwned; - holdToRun(b, pair[0]); - motorBtns.push(b); - row.append(b); - }); + if (motor) { + const row = el('div'); + row.style.cssText = 'display:flex;gap:8px;margin-top:9px;flex-wrap:wrap'; + /* The live page's pair, on the same lens: an icon, a name a screen + * reader can say, and a hint. Two bare words in a row of bare words + * did not look like something to hold. */ + [['near', 'Near', 'Focus near', 'Pull focus nearer'], + ['far', 'Far', 'Focus far', 'Push focus further away']] + .forEach(function (t) { + const b = el('button', 're-btn re-hold', ICON[t[0]]); + b.dataset.act = 'focus-' + t[0]; + b.append(Object.assign(el('span'), { textContent: t[1] })); + b.setAttribute('aria-label', t[2]); + b.title = t[3] + ' — hold to move, let go to stop'; + b.disabled = lensOwned; + holdToRun(b, t[0]); + motorBtns.push(b); + row.append(b); + }); + panel.append(row); + moveSay = el('p', 're-note'); + moveSay.dataset.act = 'focus-move-say'; + moveSay.setAttribute('role', 'status'); + moveSay.style.cssText = 'margin:5px 0 0'; + moveSay.textContent = HOLD_HINT; + panel.append(moveSay); } + const row = el('div'); + row.style.cssText = 'display:flex;gap:8px;margin-top:9px;flex-wrap:wrap;align-items:center'; /* How coarse the readout is. The fine grid is still what gets measured - * -- this only changes what is drawn over the picture. */ + * -- this only changes what is drawn over the picture. Captioned: a + * bare 3x3 / 4x4 / All zones wedged between Far and a reset button was + * a control with no name. */ + row.append(Object.assign(el('span', 're-cap'), { textContent: 'Readout' })); const grain = el('div', 're-seg'); grain.dataset.act = 'focus-grain'; - [[3, '3\u00d73'], [4, '4\u00d74'], [0, 'All zones']].forEach(function (pair) { + [[3, '3×3'], [4, '4×4'], [0, 'All zones']].forEach(function (pair) { const b = el('button', '', ''); b.type = 'button'; b.dataset.act = 'grain-' + pair[0]; @@ -4952,6 +5188,9 @@ export function mountEditor(root, { o.setAttribute('aria-pressed', o === b ? 'true' : 'false'); }); + /* The status line speaks in the readout's terms, so it moves + * with the readout. */ + renderFocus(); drawFocusMarks(); }); grain.append(b); @@ -4960,7 +5199,8 @@ export function mountEditor(root, { const reset = el('button', 're-btn', ''); reset.dataset.act = 'focus-reset'; - reset.textContent = 'Reset the best'; + reset.textContent = 'Start over'; + reset.title = 'Forget the best so far and measure afresh'; /* A held peak from before the lens moved, or from another scene, is a * target that can never be beaten and reads as "you are getting worse". */ reset.addEventListener('click', () => { @@ -4968,7 +5208,7 @@ export function mountEditor(root, { * would otherwise land afterwards and push the very peak that was * just discarded back into a fresh hold. */ /* Findings belong to the filter they were measured under. */ - focusHold = null; focusBest = null; focusOdd = null; + focusHold = null; focusBest = null; focusOdd = null; blockBest = null; renderFocus(); startFocusPoll(); }); @@ -5209,7 +5449,15 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); handStop(); focusStatus = null; } + if (m !== 'focus') { + stopFocusPoll(); moveRelease(); sweepStop(); handStop(); + /* Bumped here as well as by a release: a keyboard nudge holds + * nothing, so leaving with one still unanswered left its + * generation current, and its refusal -- arriving after the panel + * had been rebuilt -- was written into the new panel's line. */ + moveGen++; + focusStatus = null; moveSay = null; filterSatBox = null; + } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; diff --git a/src/editor.css b/src/editor.css index 58db6f4..d51370b 100644 --- a/src/editor.css +++ b/src/editor.css @@ -56,6 +56,9 @@ buttons further from a finger-sized target exactly on the devices most likely to be touched. */ .re-btn { padding: 9px 11px; } + /* Near and Far keep their words: the rule above strips button labels to + fit the top bar, and a focus bracket alone does not say which way. */ + .re-btn.re-hold > span { display: inline; } } @@ -154,6 +157,14 @@ .re-btn:disabled { opacity: .45; cursor: default; } .re-btn.re-pri { background: #5c70e8; border-color: #5c70e8; color: #fff; } .re-btn.re-sm { padding: 4px 8px; font-size: 11px; } +/* Near and Far. The one control here worked with a finger at the camera, so + * finger-sized at every width and not only under the phone rule: they were + * 32px on a desktop, the smallest thing on the panel. */ +.re-btn.re-hold { min-height: 44px; padding: 9px 14px; } +/* A link in a panel. The accent is 3.5:1 on the raised panel, under the + * minimum for text, so links take a lighter tint of it (8:1). */ +.re-root a { color: #a9b8ff; } +.re-root a:hover { color: #fff; } .re-chip { display: inline-flex; align-items: center; gap: 7px; padding: 4px 9px; border: 1px solid #2c313d; border-radius: 7px; background: #232734; font-size: 11px; } .re-chip-k { font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; @@ -246,6 +257,16 @@ /* The bar gets somewhere to go rather than off the edge. */ .re-top { overflow-x: auto; scrollbar-width: none; } .re-top::-webkit-scrollbar { display: none; } + /* Back is the way out, and a tapped tab scrolls itself into view and + takes everything left of it off the screen -- seen with Back at + x=-259 on a 390px phone. So it sticks to the left edge, with the bar's + own colour behind it so the tabs pass underneath. */ + .re-top > .re-back { position: sticky; left: 0; z-index: 1; background: #1c1f27; + box-shadow: 8px 0 8px -4px #1c1f27; } + /* With the scrollbar hidden nothing said there was more bar to the + right. The editor sets re-more while there is; this fades that edge. */ + .re-top.re-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); } /* A finger, not a mouse: 44px is the guideline and the main tab bar was 25. 12px text is 18px on a 1.5 line, so 13px of padding makes 44 with no border and 12px makes 44 with one. */ diff --git a/src/editor.js b/src/editor.js index 0122189..71a9795 100644 --- a/src/editor.js +++ b/src/editor.js @@ -67,6 +67,13 @@ const ICON = { '', 15), dropper: svg('', 14), + /* The focus brackets the camera's own live page draws on its Near and Far, + * so the same lens wears the same glyph on both: a wide subject for near, + * a distant one for far. */ + near: svg('' + + '', 15), + far: svg('' + + '', 15), }; /* @@ -300,7 +307,13 @@ export function mountEditor(root, { * These DO get a countdown, unlike `move`, and for a reason the lens * buttons do not have: a filter that measures the wrong thing looks like a * camera that will not focus, so there is nothing on screen to tell an - * operator which change to undo. The clock undoes it for them. */ + * operator which change to undo. The clock undoes it for them. + * + * `liveHref`, optional, is where the host's live picture is. The panel + * opens by sending an owner there: this tab drives the lens over a STILL + * and exists to tune the filter, and titled "Focus" with a Near and a Far + * of its own it read as the place to focus a camera. Without one the + * sentence is simply not said. */ focus, /* How long to wait for the module to arrive and answer. The camera's own * loader gives the CDN eight seconds; a test harness under a virtual clock @@ -374,7 +387,9 @@ export function mountEditor(root, { root.append(splash); // Reveal on the sheet, or on the deadline. The interface being ugly is // recoverable; the interface never appearing is not. - acquireStylesheet(base, startupTimeoutMs).then(reveal); + // And measure the bar the moment it is first seen, for a browser with + // no ResizeObserver, where nothing else would. + acquireStylesheet(base, startupTimeoutMs).then(reveal).then(() => moreBar()); } const state = { info: null, probe: null, cfa: 0, demosaic: 3, black: 0, white: 1023, @@ -390,8 +405,18 @@ export function mountEditor(root, { /* ---- chrome ---- */ const top = el('div', 're-top'); - const backBtn = el('button', 're-btn re-sm', ICON.back); + const backBtn = el('button', 're-btn re-sm re-back', ICON.back); backBtn.title = 'Back'; + /* On a phone the bar scrolls, and a tab that was just tapped scrolls + * itself into view -- taking everything left of it, Back included, off + * the screen with no scrollbar to say so. Back is made sticky in the + * stylesheet; this is the other half, a class while there is more bar to + * the right, so the stylesheet can fade that edge. Toggled rather than + * measured in CSS because nothing in CSS knows where a scroll is. */ + const moreBar = () => { + top.classList.toggle('re-more', top.scrollLeft + top.clientWidth < top.scrollWidth - 1); + }; + top.addEventListener('scroll', moreBar, { passive: true }); const nameEl = el('span', '', 'No frame'); nameEl.style.cssText = 'font-size:13px;font-weight:500'; const sensorChip = el('span', 're-chip'); @@ -814,11 +839,20 @@ export function mountEditor(root, { * window ever changing. Watching the element itself catches all of those, * where a window listener catches only the first. */ - const onResize = () => { drawChart(); drawMarks(); drawFocusMarks(); }; + const onResize = () => { drawChart(); drawMarks(); drawFocusMarks(); moreBar(); }; let ro = null; if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(onResize); ro.observe(canvas); + /* The bar's fade depends on what the bar holds, and the canvas says + * nothing about that. It does fire at first layout even without a + * frame -- measured at 400px, the fade was there before any frame or + * scroll -- but the name and the chip grow when a frame lands, and + * that is what pushes the tabs about and can tip a bar that fitted + * into one that does not, with nothing on the canvas changing. */ + ro.observe(top); + ro.observe(nameEl); + ro.observe(sensorChip); } window.addEventListener('resize', onResize); /* A press that ends anywhere but on the button still has to stop the lens. @@ -3966,6 +4000,15 @@ export function mountEditor(root, { */ let focusSum = null, focusBest = null, focusErr = null; let focusHold = null, focusTimer = null, focusStatus = null; + /* The line under Near and Far: how to use them, what they are doing, and + * why the lens did not move when it did not. */ + let moveSay = null; + /* The best BLOCK value seen since the hold was last cleared, in the + * readout's own units. focusBest tracks the peak zone of the fine grid; a + * 3x3 draws block means, and a "best so far" beside a block mean has to + * be a block mean or the two numbers cannot be compared. Kept per readout + * size, because a 4x4 block is not a 3x3 block. */ + let blockBest = null, blockBestGrain = null; /* Bumped whenever polling stops or restarts. A read already in flight * carries the number it started under, and an answer whose number has * moved on is dropped: it was measured of a poll that no longer exists -- @@ -3983,6 +4026,7 @@ export function mountEditor(root, { * lens can never be beaten, so it reads as "you are getting worse". */ function resetFocusState() { focusSum = null; focusBest = null; focusErr = null; focusHold = null; + blockBest = null; /* Measured against one scene, one lens position and one filter. A new * frame is none of those, and a ring left over from the last one * points at a zone that no longer means anything. */ @@ -4019,6 +4063,7 @@ export function mountEditor(root, { focusErr = null; if (!focusHold) focusHold = peakHold(); focusBest = focusHold.push(sum); + noteBlockBest(sum); if (handFrames && handFrames.length < HAND_MAX) { handFrames.push({ fv: sum.fv, state: sum.state, sat: sum.satZone, rows: sum.rows, cols: sum.cols, peak: sum.peak, @@ -4044,7 +4089,38 @@ export function mountEditor(root, { loop(); } + /* Where a block sits, in the words the picture is read with. The 3x3 is + * the default readout and gets plain names; anything finer is named by + * row and column OF THAT READOUT, which is what is drawn. Naming a cell + * of the measured 15x17 grid under a 3x3 -- "row 5, column 4" -- pointed + * at nothing on screen. */ + function placeName(row, col, rows, cols) { + if (rows === 3 && cols === 3) { + const v = ['top', '', 'bottom'][row], h = ['left', '', 'right'][col]; + return v && h ? v + ' ' + h : (v || h || 'centre'); + } + return 'row ' + (row + 1) + ', column ' + (col + 1); + } + + /* The block record, advanced by a reading and by nothing else. A readout + * change starts a fresh one at the next reading: a 4x4 block's mean is not + * a 3x3 block's, and a record carried across would be a target in the + * wrong units. */ + function noteBlockBest(sum) { + /* While every zone is drawn, the last block readout's record goes on + * advancing, so coming back to it finds the best of everything read + * since -- not a record frozen at the moment of the switch, which + * would call a peak swept past under All zones "never seen". */ + const g = focusBlocks || blockBestGrain; + if (!g) return; + const c = coarsen(sum, g, { detail: focusBest ? zoneDetail(focusBest) : null }); + const v = c.best === null ? null : c.blocks[c.best].value; + if (blockBestGrain !== g) { blockBest = null; blockBestGrain = g; } + if (v !== null && (blockBest === null || v > blockBest)) blockBest = v; + } + function renderFocus() { + paintFilterSat(); if (!focusStatus) return; focusStatus.replaceChildren(); if (focusErr) { @@ -4060,37 +4136,57 @@ export function mountEditor(root, { return; } const s = focusSum; - const at = s.peakAt - ? `row ${s.peakAt.row + 1}, column ${s.peakAt.col + 1}` - : 'nowhere it could measure'; + /* + * The peak in the readout's own terms, so the panel and the picture + * describe one thing. At 3x3 or 4x4 that is the outlined block -- its + * mean, and where it sits; with every zone drawn it is the peak zone. + * The line used to quote the fine peak under every readout, so the + * outlined square said 14 793, the sentence said 56 099 at "row 5, + * column 4", and a 4x4 said 24 553 of the same star chart: three + * numbers on screen, all called sharpest. + */ + let where, value, best; + if (focusBlocks) { + const c = coarsen(s, focusBlocks, { detail: focusBest ? zoneDetail(focusBest) : null }); + const b = c.best === null ? null : c.blocks[c.best]; + where = b ? placeName(b.row, b.col, c.rows, c.cols) : 'nowhere it could measure'; + value = b ? b.value : null; + /* Read, not updated, here: the record advances only when a reading + * arrives. Advancing it on every render put the reading already on + * screen straight back into a record that had just been cleared -- + * by Start over, or by a new filter -- so "best so far" was never + * genuinely fresh. */ + best = blockBestGrain === focusBlocks ? blockBest : null; + } else { + where = s.peakAt + ? `row ${s.peakAt.row + 1}, column ${s.peakAt.col + 1} of the ` + + `${s.rows}×${s.cols} zones` + : 'nowhere it could measure'; + value = s.peak; + best = focusBest ? focusBest.bestOverall : null; + } const line = el('p', 're-note'); line.dataset.act = 'focus-status'; /* The held best, not this instant's -- the number a person turning a * barrel is trying to beat, and the one still on screen after they - * have swept past it. */ + * have swept past it. + * + * A pinned counter is stated here as a count, beside the other things + * the grid could not measure, and not as a warning. It is one -- a + * value that has stopped being able to move looks exactly like a + * sharp, steady picture -- but the thing to do about it is a gain in + * the filter designer, so the warning and the instruction sit there, + * next to the input they name. On a stock camera the yellow box fired + * the moment the tab opened and read as "this camera is broken". */ line.textContent = - `Sharpest at ${at}: ${s.peak === null ? '—' : s.peak}` + - (focusBest ? `, best seen ${focusBest.bestOverall}` : '') + '. ' + + `Sharpest at ${where}: ${value === null ? '—' : value}` + + (best !== null ? `, best so far ${best}` : '') + '. ' + `${s.measured} of ${s.rows * s.cols} zones measured` + (s.unlit ? `, ${s.unlit} too dark` : '') + - (s.clipped ? `, ${s.clipped} blown out` : '') + '.'; + (s.clipped ? `, ${s.clipped} blown out` : '') + + (s.saturated ? `, ${s.saturated} reading at the top of the camera’s counter` + + (s.peakSaturated ? ' (the sharpest among them)' : '') : '') + '.'; focusStatus.append(line); - /* A pinned counter is the one fault here that reads as a good result: - * the number is large and steady, which looks like a sharp, stable - * picture. It is neither -- it is a value that has stopped being able - * to move, and every comparison made against it is worthless. */ - if (s.saturated) { - const box = el('div', 're-notice re-warn', ICON.warn); - box.append(Object.assign(el('div'), { - textContent: `${s.saturated} zone${s.saturated === 1 ? ' is' : 's are'} ` + - 'reading at the top of the camera\u2019s counter' + - (s.peakSaturated ? ', the sharpest among them' : '') + - '. The filter\u2019s gain is higher than the counter can hold, so ' + - 'those readings cannot rise and a comparison between two of them ' + - 'means nothing. Lower the first gain until this clears.', - })); - focusStatus.append(box); - } if (!s.measured) { const box = el('div', 're-notice re-warn', ICON.warn); box.append(Object.assign(el('div'), { @@ -4139,14 +4235,14 @@ export function mountEditor(root, { * window, raising an unhandled rejection each time round. */ function moveSend(verb, onFail) { const gen = moveGen; - const fail = function () { - if (onFail && gen === moveGen) onFail(); + const fail = function (e) { + if (onFail && gen === moveGen) onFail(e); }; let p; try { p = focus.move(verb); } catch (e) { - fail(); + fail(e); return; } if (p && typeof p.catch === 'function') { @@ -4154,6 +4250,20 @@ export function mountEditor(root, { } } + /* What the buttons are doing, said under them. A hold used to end in + * silence when the camera refused it, so a lens the camera was not + * driving looked exactly like one that had reached its stop, and the + * host's reason -- a motor driver not loaded, a port shut -- reached + * nobody. */ + const HOLD_HINT = 'Hold to move the lens; let go to stop.'; + function sayMove(text) { + if (moveSay) moveSay.textContent = text; + } + function moveRefused(e) { + sayMove('The lens did not move: ' + + (e && e.message ? e.message : (e || 'the camera refused')) + '.'); + } + /* A release from a pointer that never owned the hold is somebody else's * finger. An event carrying no pointerId at all is not a pointer's report * and always counts. */ @@ -4173,6 +4283,9 @@ export function mountEditor(root, { moveVerb = null; movePointer = null; moveGen++; + /* Back to the hint, unless a refusal has just been written there: that + * is the one thing worth leaving on screen after the button comes up. */ + if (moveSay && /^Moving/.test(moveSay.textContent)) sayMove(HOLD_HINT); /* Told to stop even though it would time out anyway: the deadline is * the fallback, not the plan, and a lens that keeps creeping after the * button came up reads as a broken control. A stop that itself fails @@ -4182,7 +4295,7 @@ export function mountEditor(root, { function moveAsk() { if (!moveVerb) return; - moveSend(moveVerb, moveRelease); + moveSend(moveVerb, function (e) { moveRefused(e); moveRelease(); }); } /* The sweep needs to KNOW whether a move happened, where the hold-to-run @@ -4244,6 +4357,7 @@ export function mountEditor(root, { moveTimer = setInterval(moveAsk, (focus && focus.moveRepeatMs) || 250); moveGiveUp = setTimeout(moveRelease, (focus && focus.moveMaxMs) || MOVE_MAX_MS); + sayMove(verb === 'near' ? 'Moving nearer…' : 'Moving further away…'); moveAsk(); if (ev && ev.preventDefault) ev.preventDefault(); }; @@ -4262,7 +4376,7 @@ export function mountEditor(root, { if (ev.repeat) return; if (lensOwned) return; ev.preventDefault(); - moveSend(verb, null); + moveSend(verb, moveRefused); }); } @@ -4550,15 +4664,44 @@ export function mountEditor(root, { return new Promise(function (r) { setTimeout(r, ms); }); } - function buildFilterDesigner(panel) { - const box = el('div', 're-panel'); - box.style.marginTop = '10px'; - box.append(Object.assign(el('div', 're-shead'), { - innerHTML: '

Filter

', + /* The counter warning's home: beside the gain it tells the operator to + * lower. Set by the designer, cleared with it, painted by renderFocus. */ + let filterSatBox = null; + + function paintFilterSat() { + if (!filterSatBox) return; + filterSatBox.replaceChildren(); + const s = focusSum; + if (!s || !s.saturated) return; + /* A pinned counter is the one fault here that reads as a good result: + * the number is large and steady, which looks like a sharp, stable + * picture. It is neither -- it is a value that has stopped being able + * to move, and every comparison made against it is worthless. */ + const box = el('div', 're-notice re-warn', ICON.warn); + box.append(Object.assign(el('div'), { + textContent: `${s.saturated} zone${s.saturated === 1 ? ' is' : 's are'} ` + + 'reading at the top of the camera’s counter' + + (s.peakSaturated ? ', the sharpest among them' : '') + + '. The filter’s gain is higher than the counter can hold, so ' + + 'those readings cannot rise and a comparison between two of them ' + + 'means nothing. Lower Scale, the first of the gains, until this clears.', })); + filterSatBox.append(box); + } + + /* + * Folded away by default. Fourteen number boxes and four verbs are the + * engineer's half of the panel, and they sat directly under Near and Far + * at the same weight, with the fold of a laptop screen landing exactly on + * the first row of bare numbers. An owner never needs any of it; the + * operator who does knows to open it. + */ + function buildFilterDesigner(panel) { + const hold = Math.max(5, focus.holdSeconds || 30); + const box = el('div'); box.append(Object.assign(el('p', 're-note'), { textContent: 'What the camera counts as detail. Change it, watch the ' + - 'squares, and keep it if the sharpest zone reads higher than it did.', + 'squares, and keep it if the sharpest block reads higher than it did.', })); const status = el('div'); @@ -4566,20 +4709,35 @@ export function mountEditor(root, { * the grid's own above it; both are notices and both can be warnings. */ status.dataset.act = 'af-status'; box.append(status); + filterSatBox = el('div'); + filterSatBox.dataset.act = 'af-sat'; + filterSatBox.style.cssText = 'margin-top:7px'; + box.append(filterSatBox); const rows = el('div'); box.append(rows); /* The ranges are the camera's register fields, and the panel refuses * out of range rather than letting the camera refuse silently: these * are written into a few bits each and nothing downstream checks. */ + /* Every box named. Seven bare numbers in a row were this panel's own + * "row 5, column 4": the counter warning says "lower Scale", and there + * has to be a box on screen that says Scale. */ const FIELDS = [ { k: 'gain', label: 'Gains', n: 7, lo: -511, hi: 511, first: [0, 255], - hint: 'An input scale, then three pairs — one per section.' }, + names: ['Scale', '1a', '1b', '2a', '2b', '3a', '3b'], + said: ['input scale', 'section 1, first', 'section 1, second', + 'section 2, first', 'section 2, second', 'section 3, first', + 'section 3, second'], + hint: 'Scale is the input gain; then two coefficients per section.' }, { k: 'shift', label: 'Shifts', n: 4, lo: 0, hi: 7, + names: ['1', '2', '3', '4'], + said: ['stage 1', 'stage 2', 'stage 3', 'stage 4'], hint: 'How far each stage divides its result down.' }, { k: 'coring', label: 'Coring', n: 3, lo: 0, hi: 2047, slope: [1, 15], - hint: 'Threshold, slope, limit. A high threshold throws away the ' + - 'small detail that coming into focus produces.' }, + names: ['Threshold', 'Slope', 'Limit'], + said: ['threshold', 'slope', 'limit'], + hint: 'A high threshold throws away the small detail that coming ' + + 'into focus produces.' }, ]; const inputs = {}; let enables = []; @@ -4589,12 +4747,16 @@ export function mountEditor(root, { row.style.cssText = 'margin-top:9px'; row.append(Object.assign(el('div', 're-cap'), { textContent: f.label })); const line = el('div'); - line.style.cssText = 'display:flex;gap:5px;flex-wrap:wrap;margin-top:4px'; + line.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-top:4px'; inputs[f.k] = []; for (let i = 0; i < f.n; i++) { + const lab = el('label'); + lab.style.cssText = 'display:flex;flex-direction:column;gap:2px'; + lab.append(Object.assign(el('span', 're-note'), { textContent: f.names[i] })); const inp = el('input', 're-in'); inp.type = 'number'; inp.dataset.act = 'af-' + f.k + '-' + i; + inp.setAttribute('aria-label', f.label.replace(/s$/, '') + ': ' + f.said[i]); inp.style.cssText = 'width:64px'; /* Per-slot bounds where a slot differs from its neighbours: the * first gain is an unsigned input scale, not a coefficient, and @@ -4608,10 +4770,11 @@ export function mountEditor(root, { inp.min = String(lo); inp.max = String(hi); inputs[f.k].push(inp); - line.append(inp); + lab.append(inp); + line.append(lab); } row.append(line); - row.append(Object.assign(el('span', 'hint'), { textContent: f.hint })); + row.append(Object.assign(el('div', 're-note'), { textContent: f.hint })); return row; }; FIELDS.forEach((f) => rows.append(mkRow(f))); @@ -4631,12 +4794,13 @@ export function mountEditor(root, { const cb = el('input'); cb.type = 'checkbox'; cb.dataset.act = 'af-enable-' + i; + cb.setAttribute('aria-label', 'Section ' + (i + 1)); lab.append(cb, Object.assign(el('span'), { textContent: String(i + 1) })); enLine.append(lab); return cb; }); enRow.append(enLine); - enRow.append(Object.assign(el('span', 'hint'), { + enRow.append(Object.assign(el('div', 're-note'), { textContent: 'Section 3 reads higher as the picture blurs, so leaving ' + 'it off is most of why this filter tracks focus at all.', })); @@ -4646,7 +4810,9 @@ export function mountEditor(root, { acts.style.cssText = 'display:flex;gap:8px;margin-top:10px;flex-wrap:wrap'; const send = el('button', 're-btn re-pri', ''); send.dataset.act = 'af-apply'; - send.textContent = 'Try it'; + /* The trial's length is on the button. "Try it" alone did not say the + * camera would be written to, nor that it would be put back. */ + send.textContent = 'Try it for ' + hold + 's'; send.disabled = true; const back = el('button', 're-btn', ''); back.dataset.act = 'af-reload'; @@ -4686,6 +4852,17 @@ export function mountEditor(root, { acts.append(hand, handDone); } box.append(acts); + /* What each verb costs, before it is pressed. Measure it drives the + * lens for several seconds; the sentence saying it would be put back + * used to appear only after the click. */ + box.append(Object.assign(el('div', 're-note'), { + style: 'margin-top:6px', + textContent: 'Try it puts the filter on the camera for ' + hold + ' seconds ' + + 'and takes it back unless you keep it.' + + (measure ? ' Measure it moves the lens out and back, eight steps ' + + 'each way, and reports how sharply the filter peaks.' : '') + + ' Measure by hand does the same while you move the lens yourself.', + })); const say = (msg, warn) => { status.replaceChildren(); @@ -4784,10 +4961,10 @@ export function mountEditor(root, { * with what this one reads -- different filters count detail * differently, so the number to beat has to start again. */ /* Findings belong to the filter they were measured under. */ - focusHold = null; focusBest = null; focusOdd = null; + focusHold = null; focusBest = null; focusOdd = null; blockBest = null; renderFocus(); startFocusPoll(); - armHold(status, send, Math.max(5, focus.holdSeconds || 30), + armHold(status, send, hold, 'Applied to the camera.', { revert: () => focus.revertFilters(), keep: focus.keepFilters @@ -4822,10 +4999,17 @@ export function mountEditor(root, { /* Findings from the last sweep describe the lens where it was. */ focusOdd = null; handFrames = []; + /* In the motor's terms where there is one. The intro adapts; + * this did not, and told the owner of a sealed zoom block to + * turn a barrel they cannot reach. */ + const how = typeof focus.move === 'function' + ? 'Hold Far until the picture is well out of focus, then hold ' + + 'Near right through focus to the other end.' + : 'Turn the lens slowly from one end of its travel to the ' + + 'other, right through focus.'; const tick = () => { const n = handFrames ? handFrames.length : 0; - say('Turn the lens slowly from one end of its travel to the ' + - 'other, right through focus. Keep going \u2014 ' + n + + say(how + ' Keep going \u2014 ' + n + ' reading' + (n === 1 ? '' : 's') + ' so far.'); }; tick(); @@ -4890,7 +5074,7 @@ export function mountEditor(root, { } load(); - panel.append(box); + panel.append(foldout('Tune the focus filter (advanced)', (body) => body.append(box))); } function buildFocus() { @@ -4899,48 +5083,100 @@ export function mountEditor(root, { * last one made are detached and must not be kept -- disabling a node * nobody can see is a leak that also hides a bug. */ motorBtns = []; + moveSay = null; + filterSatBox = null; const panel = el('div', 're-panel'); panel.append(Object.assign(el('div', 're-shead'), { innerHTML: '

Focus

', })); - /* Says what to actually DO, and that differs: a camera with a motor is - * focused from this panel, one without is focused at the camera. Telling - * someone to turn a lens they could drive from here, or to hold a button - * that is not on screen, is worse than saying nothing. */ - const drive = focus && typeof focus.move === 'function' - ? 'Hold Near or Far until the bright patch is where you want it sharp.' - : 'Turn the lens until the bright patch is where you want it sharp.'; - panel.append(Object.assign(el('p', 're-note'), { - textContent: 'Each square is one of the camera\'s focus zones, brightest where ' + - 'the picture has the most detail. ' + drive + ' The picture behind is the ' + - 'frame you captured — the squares are live.', - })); + const motor = focus && typeof focus.move === 'function'; + /* + * Whose panel this is, before anything else. An owner who wants a + * sharp picture focuses on the live page, where the picture moves; + * this one drives the lens over a STILL and exists to tune the filter. + * Titled Focus and carrying a Near and a Far of its own it read as the + * place to focus a camera, and the first thing it then showed on a + * stock camera was a warning about a gain. So the live page comes + * first, when the host has said where it is, and what this tab is for + * comes second. + * + * The rest says what to actually DO, and that differs: a camera with a + * motor is focused from this panel, one without is focused at the + * camera. Telling someone to turn a lens they could drive from here, + * or to hold a button that is not on screen, is worse than saying + * nothing. + */ + const intro = el('p', 're-note'); + if (focus && focus.liveHref) { + intro.append(motor + ? 'To focus the camera, use the lens controls on the ' + : 'To focus the camera by eye, watch the '); + const a = el('a', '', 'Live page'); + a.href = focus.liveHref; + intro.append(a, motor ? '. ' : ' while you turn the lens. '); + } + intro.append('This tab tunes what the camera counts as sharp. Each square is ' + + 'one of its focus zones, brightest where the picture has the most ' + + 'detail; the squares are live, the picture behind is the still frame ' + + 'you captured. ' + (motor + ? 'Hold Near or Far to move the lens and watch the numbers.' + : 'Turn the lens and watch the numbers.')); + panel.append(intro); + /* Reachable before a frame exists -- the tab is built at mount and the + * first capture takes seconds -- and the readings are live either way. + * Said, rather than left as a panel beside an empty stage. Gone with + * the next rebuild, which opening a frame triggers. */ + if (!state.info) { + panel.append(Object.assign(el('p', 're-note'), { + textContent: 'Waiting for a frame to draw the zones over. The readings ' + + 'below are live already.', + })); + } focusStatus = el('div'); + focusStatus.dataset.act = 'focus-grid-status'; panel.append(focusStatus); - const row = el('div'); - row.style.cssText = 'display:flex;gap:8px;margin-top:9px;flex-wrap:wrap'; - /* Only where there is a motor to drive. Same rule the Capture button and * the Plates tab follow: a control that can never work is worse than * none, and most cameras focus by hand. */ - if (focus && typeof focus.move === 'function') { - [['near', 'Near'], ['far', 'Far']].forEach(function (pair) { - const b = el('button', 're-btn', ''); - b.dataset.act = 'focus-' + pair[0]; - b.textContent = pair[1]; - b.disabled = lensOwned; - holdToRun(b, pair[0]); - motorBtns.push(b); - row.append(b); - }); + if (motor) { + const row = el('div'); + row.style.cssText = 'display:flex;gap:8px;margin-top:9px;flex-wrap:wrap'; + /* The live page's pair, on the same lens: an icon, a name a screen + * reader can say, and a hint. Two bare words in a row of bare words + * did not look like something to hold. */ + [['near', 'Near', 'Focus near', 'Pull focus nearer'], + ['far', 'Far', 'Focus far', 'Push focus further away']] + .forEach(function (t) { + const b = el('button', 're-btn re-hold', ICON[t[0]]); + b.dataset.act = 'focus-' + t[0]; + b.append(Object.assign(el('span'), { textContent: t[1] })); + b.setAttribute('aria-label', t[2]); + b.title = t[3] + ' — hold to move, let go to stop'; + b.disabled = lensOwned; + holdToRun(b, t[0]); + motorBtns.push(b); + row.append(b); + }); + panel.append(row); + moveSay = el('p', 're-note'); + moveSay.dataset.act = 'focus-move-say'; + moveSay.setAttribute('role', 'status'); + moveSay.style.cssText = 'margin:5px 0 0'; + moveSay.textContent = HOLD_HINT; + panel.append(moveSay); } + const row = el('div'); + row.style.cssText = 'display:flex;gap:8px;margin-top:9px;flex-wrap:wrap;align-items:center'; /* How coarse the readout is. The fine grid is still what gets measured - * -- this only changes what is drawn over the picture. */ + * -- this only changes what is drawn over the picture. Captioned: a + * bare 3x3 / 4x4 / All zones wedged between Far and a reset button was + * a control with no name. */ + row.append(Object.assign(el('span', 're-cap'), { textContent: 'Readout' })); const grain = el('div', 're-seg'); grain.dataset.act = 'focus-grain'; - [[3, '3\u00d73'], [4, '4\u00d74'], [0, 'All zones']].forEach(function (pair) { + [[3, '3×3'], [4, '4×4'], [0, 'All zones']].forEach(function (pair) { const b = el('button', '', ''); b.type = 'button'; b.dataset.act = 'grain-' + pair[0]; @@ -4952,6 +5188,9 @@ export function mountEditor(root, { o.setAttribute('aria-pressed', o === b ? 'true' : 'false'); }); + /* The status line speaks in the readout's terms, so it moves + * with the readout. */ + renderFocus(); drawFocusMarks(); }); grain.append(b); @@ -4960,7 +5199,8 @@ export function mountEditor(root, { const reset = el('button', 're-btn', ''); reset.dataset.act = 'focus-reset'; - reset.textContent = 'Reset the best'; + reset.textContent = 'Start over'; + reset.title = 'Forget the best so far and measure afresh'; /* A held peak from before the lens moved, or from another scene, is a * target that can never be beaten and reads as "you are getting worse". */ reset.addEventListener('click', () => { @@ -4968,7 +5208,7 @@ export function mountEditor(root, { * would otherwise land afterwards and push the very peak that was * just discarded back into a fresh hold. */ /* Findings belong to the filter they were measured under. */ - focusHold = null; focusBest = null; focusOdd = null; + focusHold = null; focusBest = null; focusOdd = null; blockBest = null; renderFocus(); startFocusPoll(); }); @@ -5209,7 +5449,15 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); handStop(); focusStatus = null; } + if (m !== 'focus') { + stopFocusPoll(); moveRelease(); sweepStop(); handStop(); + /* Bumped here as well as by a release: a keyboard nudge holds + * nothing, so leaving with one still unanswered left its + * generation current, and its refusal -- arriving after the panel + * had been rebuilt -- was written into the new panel's line. */ + moveGen++; + focusStatus = null; moveSay = null; filterSatBox = null; + } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; diff --git a/tests/ui-check.html b/tests/ui-check.html index 57d3879..43cabf3 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -18,8 +18,25 @@ if (new URLSearchParams(location.search).get('narrow') === '1') { const host = document.createElement('div'); document.body.append(host); + // Five tabs and a Capture button, which is what a camera's page carries and + // what overflows a 400px bar -- the sticky Back below is only exercised + // when there is somewhere for the bar to scroll to. const ed = mountEditor(host, { base: '../dist/', autoCapture: false, - plates: { readerSupported: false, reader: () => Promise.reject(new Error('none')) } }); + plates: { readerSupported: false, reader: () => Promise.reject(new Error('none')) }, + capture: () => Promise.reject(new Error('none')), + focus: { intervalMs: 5000, + zones: () => Promise.resolve({ rows: 1, cols: 1, zones: [[0, 100, 0, 10, 1000, 0]] }) } }); + // Before a frame, and before anything has scrolled. The fade was set by + // a scroll listener and by the canvas's resize observer, and with no + // frame the canvas has nothing to report -- so a bar that overflowed + // from the first paint wore no fade until something moved it. + await new Promise((r) => setTimeout(r, 400)); + { + const bar = host.querySelector('.re-top'); + t('the bar says there is more to the right from the start', + bar.scrollWidth <= bar.clientWidth + 1 || bar.classList.contains('re-more'), + `${bar.className} scrollWidth ${bar.scrollWidth} clientWidth ${bar.clientWidth}`); + } const bytes = new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()); await ed.open(bytes, 'phone.dng'); await new Promise((r) => setTimeout(r, 400)); @@ -61,6 +78,32 @@ top.scrollWidth <= top.clientWidth + 1 || getComputedStyle(top).overflowX === 'auto', `scrollWidth ${top.scrollWidth}, clientWidth ${top.clientWidth}`); + /* + * Back is the way out, and on a phone it was the first thing the scrolling + * bar carried off the left edge: a tapped tab scrolls itself into view and + * takes the exit with it. Seen on a 390px screen with Back at x=-259. So it + * sticks -- and the bar says there is more to the right, because a hidden + * scrollbar says nothing. + */ + if (top.scrollWidth > top.clientWidth + 1) { + top.scrollLeft = top.scrollWidth; + await new Promise((r) => setTimeout(r, 40)); + const back = host.querySelector('.re-back'); + const bb = back ? back.getBoundingClientRect() : null, tb = top.getBoundingClientRect(); + t('Back stays on screen however far the bar is scrolled', + !!bb && bb.left >= tb.left - 1 && bb.right <= tb.right + 1, + bb ? `Back at ${bb.left.toFixed(0)}..${bb.right.toFixed(0)} of ` + + `${tb.left.toFixed(0)}..${tb.right.toFixed(0)}` : '(no .re-back)'); + t('and at its end the bar claims nothing more to the right', + !top.classList.contains('re-more')); + top.scrollLeft = 0; + await new Promise((r) => setTimeout(r, 40)); + t('but at its start it says there is', top.classList.contains('re-more')); + } else { + t('the bar overflows at 400px, so the sticky Back is exercised', false, + `scrollWidth ${top.scrollWidth}, clientWidth ${top.clientWidth}`); + } + const small = [...host.querySelectorAll('.re-top button, .re-insp button')] .filter((b) => b.offsetParent !== null) .filter((b) => b.getBoundingClientRect().height < 40); @@ -1988,6 +2031,22 @@ pk.left > box.left + box.width * 0.5, `peak at ${(pk.left - box.left).toFixed(0)}px of ${box.width.toFixed(0)}px`); } + { + // One peak, one vocabulary. The status used to name a cell of the + // 15x17 grid -- "row 5, column 4" -- that this view does not draw, and + // quote its value, while the outlined block carried a different + // number: three figures on screen all called "sharpest". + const status = host.querySelector('[data-act="focus-status"]').textContent; + t('the panel names the block in the picture’s own words', + /Sharpest at top right: /.test(status), status.slice(0, 60)); + const pk = host.querySelector('.re-fz-peak'); + const cx = +pk.getAttribute('x') + (+pk.getAttribute('width')) / 2; + const drawn = nums().find((n) => Math.abs(+n.getAttribute('x') - cx) < 1); + const said = /Sharpest at [^:]+: (\d+)/.exec(status); + t('and reports the number drawn in it', + !!drawn && !!said && drawn.textContent.replace(/ /g, '') === said[1], + `drawn ${drawn ? drawn.textContent : '(none)'}, said ${said ? said[1] : '(none)'}`); + } // The fine grid is still the measurement; this only changes what is drawn. const grain = (label) => [...host.querySelectorAll('[data-act="focus-grain"] button')] @@ -2061,6 +2120,51 @@ t('the empty-block notice is handled', false, e.message); } +/* Whose tab this is. An owner who wants a sharp picture focuses on the live + * page, where the picture moves; this tab tunes what the camera counts as + * sharp over a still. Titled "Focus" and offering the same Near and Far, it + * said neither, and the first thing it showed on a stock camera was a yellow + * warning about a gain. So it points the owner where to go, says what it is + * for, and does not draw a grid around a frame that has not arrived. */ +try { + const pause = (ms) => new Promise((r) => setTimeout(r, ms)); + const grid = { rows: 1, cols: 1, zones: [[0, 100, 0, 10, 1000, 0]] }; + const mkS = (extra) => { + const host = document.createElement('div'); + document.body.append(host); + const ed = mountEditor(host, { base: '../dist/', styles: false, autoCapture: false, + focus: Object.assign({ intervalMs: 5000, zones: () => Promise.resolve(grid) }, extra) }); + [...host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click(); + return { host, ed }; + }; + const text = (g) => g.host.querySelector('.re-insp').textContent; + + let g = mkS({ liveHref: '/cgi-bin/live.cgi' }); + await pause(80); + const a = g.host.querySelector('.re-insp a'); + t('the panel points an owner at the live page', + !!a && a.getAttribute('href') === '/cgi-bin/live.cgi' && /Live page/.test(a.textContent), + a ? a.outerHTML.slice(0, 80) : '(no link)'); + t('and says what this tab is for instead', + /tunes what the camera counts as sharp/.test(text(g)), text(g).slice(0, 120)); + t('a frame not yet captured is said, not drawn around', + /Waiting for a frame/.test(text(g)), text(g).slice(0, 160)); + const bytes = new Uint8Array(await (await fetch('./fixture.dng')).arrayBuffer()); + await g.ed.open(bytes, 'fixture.dng'); + await pause(300); + t('and the note goes when one arrives', !/Waiting for a frame/.test(text(g)), + text(g).slice(0, 160)); + g.ed.destroy(); + + g = mkS({}); + await pause(80); + t('no link without somewhere to send them', !g.host.querySelector('.re-insp a') && + !/Live page/.test(text(g)), text(g).slice(0, 100)); + g.ed.destroy(); +} catch (e) { + t('the signpost is handled', false, e.message); +} + /* The poll reads a camera over a network, so every one of these is a question * about time: two reads in flight at once, an answer that outlives the thing * that asked for it, and a frame replaced under a grid measured of another. */ @@ -2070,7 +2174,7 @@ const HIGH = 773, MED = 429, LOW = 85; // blend() of h2 = 900 / 500 / 100 const best = (el) => { const s = el.querySelector('[data-act="focus-status"]'); - const m = s && s.textContent.match(/best seen (\d+)/); + const m = s && s.textContent.match(/best so far (\d+)/); return m ? +m[1] : null; }; const toFocus = (el) => [...el.querySelectorAll('.re-seg button')] @@ -2235,6 +2339,18 @@ t('a motor grows both buttons', !!btn(g.host, 'near') && !!btn(g.host, 'far')); t('and the panel says to hold them', /Hold Near or Far/.test(notes(g.host)), notes(g.host).slice(0, 90)); + // They were two plain words in a row of plain words. The Live page's pair + // carry an icon, a name and a hint; these are the same lens. + t('and the buttons say what they are', + /near/i.test(btn(g.host, 'near').getAttribute('aria-label') || '') && + /far/i.test(btn(g.host, 'far').getAttribute('aria-label') || ''), + (btn(g.host, 'near').getAttribute('aria-label') || '(none)') + ' / ' + + (btn(g.host, 'far').getAttribute('aria-label') || '(none)')); + { + const say = g.host.querySelector('[data-act="focus-move-say"]'); + t('and how to use them, beside them', + !!say && /Hold to move/.test(say.textContent), say ? say.textContent : '(none)'); + } // Held: it has to keep asking, or the camera stops on its own deadline. down(btn(g.host, 'near')); @@ -2334,6 +2450,14 @@ await pause(200); t('a rejected move stops the repeat rather than running to the cap', calls <= 3, calls + ' calls'); + // ...and says so. The hold used to let go in silence, so a lens the + // camera was not driving looked exactly like one that had reached its + // stop, and the host's reason for the refusal reached nobody. + { + const say = host.querySelector('[data-act="focus-move-say"]'); + t('and the refusal is said beside the button', + !!say && /motor jammed/.test(say.textContent), say ? say.textContent : '(none)'); + } ed.destroy(); } @@ -2624,6 +2748,22 @@ g = await mk({}); t('a host that can tune grows the panel', !!q(g.host, 'af-apply')); + // Fourteen bare number boxes and four verbs are the engineer's half of the + // panel, and they sat directly under Near and Far at the same weight. The + // half an owner never needs is folded, and every box is named, so "lower + // the first gain" points at something. + { + const d = q(g.host, 'af-apply').closest('details'); + t('the filter designer is folded away by default', !!d && !d.open, + d ? 'open=' + d.open : '(not in a details)'); + const boxes = [...g.host.querySelectorAll('input[data-act^="af-"]')]; + const nameless = boxes.filter((i) => !i.labels.length && !i.getAttribute('aria-label')); + t('and every input in it is named', boxes.length > 0 && nameless.length === 0, + nameless.map((i) => i.dataset.act).join(',') || `${boxes.length} named`); + t('Try it says how long the trial lasts', + /Try it for \d+ ?s/.test(q(g.host, 'af-apply').textContent), + q(g.host, 'af-apply').textContent); + } // Read back, not assumed: the camera may be running a sensor profile's // filter, and a panel that opened on defaults would offer to keep a filter // the operator never chose. @@ -2672,7 +2812,7 @@ { const best = (h) => { const s = h.querySelector('[data-act="focus-status"]'); - const m = s && s.textContent.match(/best seen (\d+)/); + const m = s && s.textContent.match(/best so far (\d+)/); return m ? +m[1] : null; }; const gg = await mk({ grid: { rows: 1, cols: 2, @@ -3024,6 +3164,18 @@ t('the live grid says when the counter is full', /top of the camera/.test(g.host.textContent), ''); t('and that it is the sharpest zone', /the sharpest among them/.test(g.host.textContent), ''); + // ...as a fact in the status line. The yellow box and the knob to turn + // belong beside the knob: on a stock camera this fired the moment the tab + // opened and read as "the camera is broken", telling an owner to lower a + // gain they had never seen, in a section below the fold. + t('said in the status line rather than as a warning over the grid', + /top of the camera/.test(g.host.querySelector('[data-act="focus-status"]').textContent) && + !g.host.querySelector('[data-act="focus-grid-status"] .re-warn'), + g.host.querySelector('[data-act="focus-status"]').textContent.slice(-80)); + t('and the warning sits beside the gain it names', + !!q(g.host, 'af-sat') && !!q(g.host, 'af-sat').querySelector('.re-warn') && + /Scale/.test(q(g.host, 'af-sat').textContent), + q(g.host, 'af-sat') ? q(g.host, 'af-sat').textContent.slice(-70) : '(no af-sat)'); g.ed.destroy(); // Dirt on the dome sits a few millimetres from the lens, so across a sweep @@ -3172,6 +3324,17 @@ const g5 = await mk({ lens: true, settle: 30 }); t('a motorised camera is offered both', !!q(g5.host, 'af-measure') && !!q(g5.host, 'af-hand'), `measure ${!!q(g5.host, 'af-measure')}, hand ${!!q(g5.host, 'af-hand')}`); + // The intro adapts to the motor; the sweep's own prompt did not, and + // told the owner of a sealed zoom block to turn a barrel they cannot + // reach. + q(g5.host, 'af-hand').click(); + { + const s = q(g5.host, 'af-status').textContent; + t('and its hand sweep says to hold the buttons, not turn a barrel', + /Hold Far/.test(s) && !/Turn the lens/.test(s), s.slice(0, 80)); + } + q(g5.host, 'af-hand-stop').click(); + await pause(50); g5.ed.destroy(); }