From 0d8d9126a7b363b0509aec58847fe784ce94200f Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:25:50 +0300 Subject: [PATCH 1/2] focus: say whose tab this is, and fold the engineer's half away Read off screenshots of an 85H50AI at 1440x900, 1366x768 and 390x844 rather than off the source: the Focus tab put two jobs on one panel at one weight -- an owner's, make the picture sharp, and an ISP engineer's, tune what the camera counts as sharp -- and the engineer's won the first impression. - On a stock camera (isp.af unset, the profile's IIR1 in force) the panel opened with a yellow box: "3 zones are reading at the top of the camera's counter ... Lower the first gain until this clears." An owner reads "my camera is broken", and nothing on screen was called the first gain. - Three numbers were all called sharpest: the outlined 3x3 block said 14 793, the sentence above it said 56 099 at "row 5, column 4" -- a cell of the 15x17 grid that view does not draw -- and the 4x4 said 24 553 of the same star chart. - Near and Far were two bare words, 32px tall on a desktop and the smallest thing on the panel; nothing said hold, and a refused move let go in silence. - Fourteen bare number boxes and four verbs sat straight under them, and on the 1366x768 laptop the fold landed exactly on GAINS. - Measure by hand told the owner of a sealed zoom block to turn a barrel. - On the phone a tapped tab scrolled itself into view and took Back off the left edge (x=-259) with no scrollbar to say so. - The tab could be opened before the first frame landed, and showed the designer's form with every box empty. So: the panel opens by sending an owner to the live page, when the host names one (`focus.liveHref`), and says what this tab is for. The designer is a closed
with every box named. The counter warning moves beside the gain it names (Scale) and the grid status states the count as a fact. The status line reports the outlined block in the readout's own words and value, with a block "best so far" that only a reading advances. Near and Far wear the live page's icons and names, are finger-sized at every width, say what they are doing and why the lens did not move. Try it carries the trial's length and Measure it its cost. The hand sweep speaks in the motor's terms where there is one. Back sticks on the phone and the bar fades where there is more of it. A missing frame is said rather than drawn around. tests/ui-check.html: seventeen new checks, watched red on the previous build first (22 reds, the rest being "best seen" renamed to "best so far"). --- dist/editor.css | 21 +++ dist/editor.js | 387 +++++++++++++++++++++++++++++++++++--------- src/editor.css | 21 +++ src/editor.js | 387 +++++++++++++++++++++++++++++++++++--------- tests/ui-check.html | 158 +++++++++++++++++- 5 files changed, 811 insertions(+), 163 deletions(-) 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..f6dc663 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 @@ -390,8 +403,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,7 +837,7 @@ 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); @@ -3966,6 +3989,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 +4015,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 +4052,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 +4078,33 @@ 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) { + if (!focusBlocks) return; + const c = coarsen(sum, focusBlocks, { detail: focusBest ? zoneDetail(focusBest) : null }); + const v = c.best === null ? null : c.blocks[c.best].value; + if (blockBestGrain !== focusBlocks) { blockBest = null; blockBestGrain = focusBlocks; } + if (v !== null && (blockBest === null || v > blockBest)) blockBest = v; + } + function renderFocus() { + paintFilterSat(); if (!focusStatus) return; focusStatus.replaceChildren(); if (focusErr) { @@ -4060,37 +4120,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 +4219,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 +4234,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 +4267,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 +4279,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 +4341,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 +4360,7 @@ export function mountEditor(root, { if (ev.repeat) return; if (lensOwned) return; ev.preventDefault(); - moveSend(verb, null); + moveSend(verb, moveRefused); }); } @@ -4550,15 +4648,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 +4693,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 +4731,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 +4754,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 +4778,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 +4794,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 +4836,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 +4945,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 +4983,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 +5058,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 +5067,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 +5172,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 +5183,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 +5192,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 +5433,10 @@ 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(); + 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..f6dc663 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 @@ -390,8 +403,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,7 +837,7 @@ 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); @@ -3966,6 +3989,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 +4015,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 +4052,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 +4078,33 @@ 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) { + if (!focusBlocks) return; + const c = coarsen(sum, focusBlocks, { detail: focusBest ? zoneDetail(focusBest) : null }); + const v = c.best === null ? null : c.blocks[c.best].value; + if (blockBestGrain !== focusBlocks) { blockBest = null; blockBestGrain = focusBlocks; } + if (v !== null && (blockBest === null || v > blockBest)) blockBest = v; + } + function renderFocus() { + paintFilterSat(); if (!focusStatus) return; focusStatus.replaceChildren(); if (focusErr) { @@ -4060,37 +4120,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 +4219,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 +4234,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 +4267,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 +4279,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 +4341,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 +4360,7 @@ export function mountEditor(root, { if (ev.repeat) return; if (lensOwned) return; ev.preventDefault(); - moveSend(verb, null); + moveSend(verb, moveRefused); }); } @@ -4550,15 +4648,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 +4693,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 +4731,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 +4754,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 +4778,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 +4794,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 +4836,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 +4945,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 +4983,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 +5058,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 +5067,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 +5172,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 +5183,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 +5192,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 +5433,10 @@ 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(); + 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..de81100 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -18,8 +18,14 @@ 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]] }) } }); 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 +67,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 +2020,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 +2109,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 +2163,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 +2328,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 +2439,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 +2737,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 +2801,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 +3153,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 +3313,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(); } From 8dc8ba4f6e7a849432bb352f3c25f01e32515ef6 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:45:21 +0300 Subject: [PATCH 2/2] focus: answer the review -- a stale nudge, the block record under All zones, and the bar's fade Three findings from the automated review of #40: two taken as written, one that did not reproduce. 1. A keyboard nudge holds nothing, so leaving Focus with one unanswered left its generation current, and a refusal arriving after the panel had been rebuilt wrote into the new panel's line. setMode now bumps the generation on the way out, as a release does. 2. The block "best so far" stopped advancing under All zones, so coming back to 3x3 showed the record as it stood at the switch and called a peak swept past under All zones never seen. The review suggested discarding the record on every readout change; not taken -- a readout switch is not a new scene, and the fine hold already survives one. The last block readout's record keeps advancing instead. 3. "The fade is set only by a scroll listener": not in practice. The canvas's resize observer fires at first layout with or without a frame, and a check written for the state before any frame or scroll passed against the previous build at 400px. The bar, the name and the chip are observed as well, for the case the canvas cannot see -- a chip appearing when a frame lands pushes the tabs and can tip a bar that fitted into one that does not -- and the bar is measured once at reveal for a browser with no ResizeObserver. The check stays as a guard on the initial state. --- dist/editor.js | 29 +++++++++++++++++++++++++---- src/editor.js | 29 +++++++++++++++++++++++++---- tests/ui-check.html | 11 +++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/dist/editor.js b/dist/editor.js index f6dc663..71a9795 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -387,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, @@ -842,6 +844,15 @@ export function mountEditor(root, { 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. @@ -4096,10 +4107,15 @@ export function mountEditor(root, { * a 3x3 block's, and a record carried across would be a target in the * wrong units. */ function noteBlockBest(sum) { - if (!focusBlocks) return; - const c = coarsen(sum, focusBlocks, { detail: focusBest ? zoneDetail(focusBest) : null }); + /* 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 !== focusBlocks) { blockBest = null; blockBestGrain = focusBlocks; } + if (blockBestGrain !== g) { blockBest = null; blockBestGrain = g; } if (v !== null && (blockBest === null || v > blockBest)) blockBest = v; } @@ -5435,6 +5451,11 @@ export function mountEditor(root, { * panel nobody is looking at. */ 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 diff --git a/src/editor.js b/src/editor.js index f6dc663..71a9795 100644 --- a/src/editor.js +++ b/src/editor.js @@ -387,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, @@ -842,6 +844,15 @@ export function mountEditor(root, { 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. @@ -4096,10 +4107,15 @@ export function mountEditor(root, { * a 3x3 block's, and a record carried across would be a target in the * wrong units. */ function noteBlockBest(sum) { - if (!focusBlocks) return; - const c = coarsen(sum, focusBlocks, { detail: focusBest ? zoneDetail(focusBest) : null }); + /* 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 !== focusBlocks) { blockBest = null; blockBestGrain = focusBlocks; } + if (blockBestGrain !== g) { blockBest = null; blockBestGrain = g; } if (v !== null && (blockBest === null || v > blockBest)) blockBest = v; } @@ -5435,6 +5451,11 @@ export function mountEditor(root, { * panel nobody is looking at. */ 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 diff --git a/tests/ui-check.html b/tests/ui-check.html index de81100..43cabf3 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -26,6 +26,17 @@ 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));