var _useParams_ASR   = ReactRouterDOM.useParams;
var _useNavigate_ASR = ReactRouterDOM.useNavigate;
var _useLocation_ASR = ReactRouterDOM.useLocation;
var _useState_ASR    = React.useState;
var _useEffect_ASR   = React.useEffect;
var _useRef_ASR      = React.useRef;

/**
 * AssessmentRoomPage — one room of one visit (route
 * `/assessment/:id/room/:roomId`).
 *
 * Spec: architecture/MOULD-ASSESSMENT-MIGRATION-BRIEF.md §5.1–5.3 and §6
 * slices 1–2. Record: AssessmentStore.jsx / ADR-009.
 *
 * The room's own fields (name, which way it faces, odour, ventilation) came in
 * slice 1. Slice 2 adds the component matrix: the source app's desktop grid
 * does not survive 390 px, so it becomes **stacked rows with progressive
 * disclosure** (§5.3) — a collapsed row shows the label, whether the component
 * is present, the three 0–3 scores and the photo count; tapping it opens a
 * bottom sheet for near-external-wall, materials, notes and area.
 *
 * Slice 3 made the photo strip real (a photo was a Scan, ADR-009 D3). Since
 * the App review of 2026-10-05, "Add photo" stores ANY photo on the component
 * (attachments[], ASR_PhotoSection) and each photo offers "Scan for mould",
 * which parks a capture intent carrying the photo's id and hands over to
 * AnalysisPage; that writes `assessmentRef` (with `photoId`) onto the saved
 * scan and links it back here. And the room's lab **Samples** live at the bottom of this screen, created at
 * inspection time and filled in whenever the lab reports (decision 1).
 *
 * Every field writes straight through the store (300 ms debounce, pagehide
 * flush). There is no Save button on any assessment screen (§5.6).
 *
 * File-local helpers use the `asr` prefix (no module system — every top-level
 * var is a window global, CLAUDE.md hard rule).
 */

var ASR_INPUT_CLS = 'w-full bg-surface border-none rounded-xl text-sm p-3 shadow-soft ring-1 ring-sage/20 focus:ring-2 focus:ring-primary font-semibold text-forest placeholder:text-sage/60 placeholder:font-medium';
var ASR_LABEL_CLS = 'text-[10px] font-black text-sage uppercase tracking-[0.15em] ml-1';

/**
 * The 0–3 odour scale, paired with the band colours (CLAUDE.md: never colour
 * alone — every step carries its word, and the colour rides a dot beside it).
 *
 * The dot, rather than a coloured fill, is deliberate: the band colours are
 * light-to-mid (good #0fbd80 · fair #d4a373 · poor #D4836B · very_poor #c1440e)
 * and neither white nor forest text is legible across all four of them. The
 * selected chip therefore uses the app's standard solid-forest/white treatment
 * and keeps the band colour as the dot.
 */
var ASR_ODOUR_STEPS = [
    { value: 0, label: 'None',       hex: '#0fbd80' },
    { value: 1, label: 'Faint',      hex: '#d4a373' },
    { value: 2, label: 'Noticeable', hex: '#D4836B' },
    { value: 3, label: 'Strong',     hex: '#c1440e' }
];

/**
 * The DMAT 0–3 component scale, in the same idiom as the odour control: the
 * band colour rides a dot, the word carries the meaning (CLAUDE.md — never
 * colour alone). The DMAT definition of each step (0 none · 1 ≤ a sheet of
 * paper · 2 paper→door · 3 ≥ a standard door) is the chip's title, so the
 * inspector can check it without the words crowding a 390 px row.
 */
var ASR_SCORE_STEPS = [
    { value: 0, label: 'None',     hex: '#0fbd80', desc: 'None' },
    { value: 1, label: 'Minor',    hex: '#d4a373', desc: '≤ Sheet of paper' },
    { value: 2, label: 'Moderate', hex: '#D4836B', desc: 'Paper → Door' },
    { value: 3, label: 'Severe',   hex: '#c1440e', desc: '≥ Standard door' }
];

/**
 * The three axes every component is scored on. `label` and `near` are the
 * enterprise form's words verbatim (EMA_SectionComponents.jsx Section C);
 * `short` is the row chip's one-word version, which is all that fits.
 */
var ASR_SCORE_AXES = [
    { field: 'damage',  short: 'Damage',   label: 'Damage or Stains' },
    { field: 'mould',   short: 'Mould',    label: 'Visible Mould' },
    { field: 'wetDamp', short: 'Wet/Damp', label: 'Wet or Damp' }
];

/** asrStep — the scale entry for a 0–3 value (band dot + word). */
function asrStep(value) {
    return ASR_SCORE_STEPS[(value >= 0 && value <= 3) ? value : 0];
}

/**
 * asrComponentDefs — the DMAT component definitions, keyed, in the order the
 * room screen stacks them. Read from AppConstants at call time (never at file
 * load) so this file has no load-order requirement against it.
 *
 * AppConstants.ASSESSMENT_COMPONENTS is the DMAT source of truth for labels and
 * material lists — it is the list the retired enterprise form used, reused here
 * verbatim rather than restated.
 */
function asrComponentDefs() {
    var defs = {};
    var list = (AppConstants && AppConstants.ASSESSMENT_COMPONENTS) || [];
    for (var i = 0; i < list.length; i++) defs[list[i].key] = list[i];
    return defs;
}

/** asrComponentOf — the stored row for a key, or the default the screen shows
 *  until the first tap writes one (asDefaultComponent owns those defaults). */
function asrComponentOf(room, key) {
    var components = (room && room.components) || [];
    for (var i = 0; i < components.length; i++) {
        if (components[i].key === key) return components[i];
    }
    return asDefaultComponent(key);
}

/** asrPhotoCount — photos on a component, de-duplicated across photoIds and
 *  scanIds (they carry the SAME ids — asComponentPhotoIds owns that rule). */
function asrPhotoCount(comp) {
    // Every picture once: uploaded photos (scanned or not) plus scan-flow
    // photos no upload claims — asComponentPhotoItems owns that rule.
    return asComponentPhotoItems(comp).length;
}

/**
 * asrVerdictChip — what a component photo's scan concluded, as a word AND a
 * band colour (CLAUDE.md: never colour alone).
 *
 * The thresholds are the vision service's own (`AWSVisionService.verdictFor`:
 * ≥80 / ≥60 / ≥40), restated here rather than called because a stored scan
 * carries only its numbers, and this must read a record saved by ANY engine,
 * including one saved before that service existed.
 *
 * Three states, not two. 40–59 is the model's "Uncertain" band and it is NOT
 * "clean" — collapsing it into a negative would put a green chip on a photo the
 * model could not judge, which is the one thing an inspector must not be told.
 * A scan with no percentage at all (a legacy record) reads "No result" rather
 * than inventing one.
 */
function asrVerdictChip(scan) {
    var pct = (scan && typeof scan.percentage === 'number' && isFinite(scan.percentage)) ? scan.percentage : null;
    if (pct === null) return { key: 'unknown', label: 'No result', hex: '#d4a373' };
    if (pct >= 80) return { key: 'positive', label: 'Mould', hex: '#c1440e' };
    if (pct >= 60) return { key: 'positive', label: 'Mould', hex: '#D4836B' };
    if (pct >= 40) return { key: 'uncertain', label: 'Uncertain', hex: '#d4a373' };
    return { key: 'negative', label: 'Clear', hex: '#0fbd80' };
}

/** asrTodayISO — local YYYY-MM-DD. toISOString() would give UTC, which is
 *  yesterday on an Australian evening — the wrong default for "collected
 *  today" (the same rule GenusFeedback uses for its result date). */
function asrTodayISO() {
    var d = new Date();
    var m = d.getMonth() + 1;
    var day = d.getDate();
    return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day);
}

/** asrShortDate — an ISO date as a short local date for the sample rows. */
function asrShortDate(iso) {
    if (!iso) return '';
    var d = new Date(iso);
    if (isNaN(d.getTime())) return String(iso);
    return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
}

/** The five §3 sample types, in the inspector's words. */
var ASR_SAMPLE_TYPE_LABELS = {
    tape: 'Tape lift', swab: 'Swab', air: 'Air', bulk: 'Bulk', other: 'Other'
};

/** asrComponentLabel — a component key's DMAT label, or the key itself if
 *  AppConstants has never heard of it (a record from a newer build). */
function asrComponentLabel(key) {
    if (!key) return '';
    var defs = (AppConstants && AppConstants.ASSESSMENT_COMPONENTS) || [];
    for (var i = 0; i < defs.length; i++) {
        if (defs[i].key === key) return defs[i].label;
    }
    return key;
}

var ASR_SHEET_LABEL_CLS = 'text-[10px] font-black text-sage uppercase tracking-[0.15em]';

/**
 * asrPhotoDeleteCopy(item) — the confirm sheet's words for one photo card
 * (2026-10-05). Three cases, because the consequences differ:
 *   • an uploaded photo, not scanned — gone from the component, this device
 *     and the cloud backup;
 *   • an uploaded photo that was scanned — the same, and its scan STAYS in
 *     scan history (it is a scan in its own right);
 *   • a photo taken through the scan flow — it IS the scan, so only the link
 *     to this component goes; the scan stays in scan history.
 */
function asrPhotoDeleteCopy(item) {
    var uploaded = !!(item && item.photoId);
    var scanned = !!(item && item.scanId);
    if (!uploaded) {
        return {
            title: 'Remove this photo from the component?',
            body: 'The photo is a scan, so it stays in your scan history. It is only removed from this component and from this visit\u2019s report.',
            confirm: 'Remove photo'
        };
    }
    return {
        title: 'Delete this photo?',
        body: 'It is removed from this component, this device and this visit\u2019s report. This also removes it from your cloud backup.' +
            (scanned ? ' Its scan stays in your scan history.' : ''),
        confirm: 'Delete photo'
    };
}

/**
 * ASR_PhotoDeleteConfirm — the in-app confirm for deleting one component photo
 * (2026-10-05). Same pattern as ASR_DiscardConfirm (DESIGN.md §3.3, §4): an
 * alertdialog on a scrim, the SAFE choice ("Keep photo") primary and focused,
 * Escape keeps the photo, never window.confirm.
 */
var ASR_PhotoDeleteConfirm = function (props) {
    var keepRef = _useRef_ASR(null);
    var copy = asrPhotoDeleteCopy(props.item);
    _useEffect_ASR(function () {
        if (keepRef.current && keepRef.current.focus) keepRef.current.focus();
        var onKeyDown = function (e) {
            if (e.key !== 'Escape' && e.keyCode !== 27) return;
            e.stopPropagation();
            props.onKeep();
        };
        document.addEventListener('keydown', onKeyDown, true);
        return function () { document.removeEventListener('keydown', onKeyDown, true); };
    }, []);
    return (
        <div className="fixed inset-0 z-[110] flex items-end justify-center p-3 bg-forest/40" data-testid="photo-delete-confirm">
            <div
                role="alertdialog"
                aria-modal="true"
                aria-labelledby="asr-photo-delete-heading"
                aria-describedby="asr-photo-delete-body"
                className="w-full max-w-md bg-background-light rounded-xl shadow-clay p-5 space-y-3"
                style={{ marginBottom: 'env(safe-area-inset-bottom)' }}
            >
                <h2 id="asr-photo-delete-heading" className="text-base font-extrabold text-forest">{copy.title}</h2>
                <p id="asr-photo-delete-body" data-testid="photo-delete-body" className="text-xs font-semibold text-forest/80 leading-relaxed">
                    {copy.body}
                </p>
                <button
                    ref={keepRef}
                    type="button"
                    data-testid="photo-delete-keep"
                    onClick={props.onKeep}
                    className="w-full min-h-[44px] bg-forest text-white font-extrabold py-3 rounded-xl shadow-clay btn-nature hover:brightness-110 transition-all"
                >
                    Keep photo
                </button>
                <button
                    type="button"
                    data-testid="photo-delete-confirm-delete"
                    onClick={props.onDelete}
                    className="w-full min-h-[44px] bg-surface text-forest font-extrabold py-3 rounded-xl shadow-soft border border-sage/30 btn-nature hover:ring-2 hover:ring-primary/40 transition-all"
                >
                    {copy.confirm}
                </button>
            </div>
        </div>
    );
};

/**
 * ASR_PhotoSection — the component sheet's photos (App review 2026-10-05).
 *
 * A component photo is no longer automatically a scan. "Add photo" stores any
 * picture (downscaled, its own IndexedDB key) on the component; each photo then
 * says whether it has been scanned:
 *   • scanned     — the verdict chip ("● Clear" / "● Mould" / "● Uncertain"),
 *                   and "View scan" opens the linked scan;
 *   • not scanned — a neutral "Not scanned" chip (dashed grey ring: the
 *                   untouched glyph, never a band colour — a green dot on an
 *                   unscanned photo would be a false finding) and a
 *                   "Scan for mould" button that runs the ordinary scan flow.
 * Photos taken through the scan flow before this change are scans already and
 * render exactly as before.
 *
 * Two-column cards rather than the old 84 px strip: a 44 px action under each
 * photo does not fit an 84 px tile, and inspectors need to see the picture.
 */
var ASR_PhotoSection = function (props) {
    var items = props.items || [];
    var inputRef = _useRef_ASR(null);
    var busyState = _useState_ASR(false);
    var busy = busyState[0];
    var setBusy = busyState[1];
    var errState = _useState_ASR('');
    var err = errState[0];
    var setErr = errState[1];
    // The photo card whose delete is being confirmed (2026-10-05), or null.
    var confirmState = _useState_ASR(null);
    var confirming = confirmState[0];
    var setConfirming = confirmState[1];

    var photoIds = [];
    for (var i = 0; i < items.length; i++) if (items[i].photoId) photoIds.push(items[i].photoId);
    var images = useComponentPhotoImages(photoIds);

    var onFile = function (e) {
        var file = e.target && e.target.files && e.target.files[0];
        // Reset so choosing the same file twice still fires a change.
        if (e.target) e.target.value = '';
        if (!file || !props.onUpload) return;
        setBusy(true);
        setErr('');
        Promise.resolve(props.onUpload(file)).then(function () {
            setBusy(false);
        })['catch'](function (error) {
            setBusy(false);
            setErr((error && error.message) || 'Could not add this photo. Please try again.');
        });
    };

    return (
        <div data-testid="component-photos-strip" className="space-y-2">
            <div className="flex items-baseline justify-between">
                <label className={ASR_SHEET_LABEL_CLS}>Photos</label>
                {items.length > 0 && (
                    <span className="text-[11px] font-bold text-forest">{items.length} photo{items.length !== 1 ? 's' : ''}</span>
                )}
            </div>
            <ul className="grid grid-cols-2 gap-3" aria-label="Component photos">
                {items.map(function (item) {
                    var image = item.photoId ? images[item.photoId] : null;
                    var src = (image && image.thumbnail) || (item.scan && item.scan.thumbnail) || '';
                    var scanned = !!item.scanId;
                    var chip = scanned ? asrVerdictChip(item.scan) : null;
                    var missingScan = scanned && !item.scan;
                    var statusWord = !scanned ? 'Not scanned' : (missingScan ? 'Scan removed' : chip.label);
                    return (
                        <li
                            key={item.key}
                            data-testid="component-photo"
                            data-photo-id={item.photoId || ''}
                            data-scan-id={item.scanId || ''}
                            data-verdict={scanned ? (missingScan ? 'missing' : chip.key) : 'not-scanned'}
                            className="bg-surface rounded-xl border border-stone-100/50 shadow-soft overflow-hidden flex flex-col"
                        >
                            <div className="relative aspect-square bg-background-light">
                                {/* Delete (2026-10-05): 44 px, forest glyph on a solid
                                    white disc so it reads on any photo. Confirmed in-app. */}
                                {props.onDelete && (
                                    <button
                                        type="button"
                                        data-testid="component-photo-delete"
                                        aria-label={item.photoId ? 'Delete this photo' : 'Remove this photo from the component'}
                                        onClick={function () { setConfirming(item); }}
                                        className="absolute top-1 right-1 z-10 w-11 h-11 rounded-full bg-surface text-forest shadow-soft flex items-center justify-center btn-nature hover:ring-2 hover:ring-primary/40 touch-manipulation"
                                    >
                                        <span className="material-symbols-outlined text-lg" aria-hidden="true">delete</span>
                                    </button>
                                )}
                                {src ? (
                                    <img alt="" className="w-full h-full object-cover" src={src} />
                                ) : (
                                    <span className="w-full h-full flex flex-col items-center justify-center gap-1 text-forest">
                                        <span className="material-symbols-outlined text-2xl" aria-hidden="true">image</span>
                                        {item.photoId && image === null && (
                                            <span className="text-[10px] font-bold px-2 text-center">Photo not on this device</span>
                                        )}
                                    </span>
                                )}
                                {/* Word first, colour second. White text only on the
                                    solid forest scrim; the neutral chip is forest
                                    text on a white fill (legibility rule). */}
                                {scanned ? (
                                    <span data-testid="component-photo-status" className="absolute inset-x-0 bottom-0 bg-forest/85 flex items-center justify-center gap-1 py-1">
                                        {!missingScan && <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: chip.hex }} aria-hidden="true"></span>}
                                        <span className="text-[10px] font-black text-white uppercase tracking-tight">{statusWord}</span>
                                    </span>
                                ) : (
                                    <span data-testid="component-photo-status" className="absolute left-2 bottom-2 bg-surface rounded-full flex items-center gap-1 px-2 py-0.5 shadow-soft">
                                        <span className="w-2 h-2 rounded-full border border-dashed border-stone-500" aria-hidden="true"></span>
                                        <span className="text-[10px] font-black text-stone-700 uppercase tracking-tight">Not scanned</span>
                                    </span>
                                )}
                            </div>
                            {scanned ? (
                                <button
                                    type="button"
                                    data-testid="component-photo-open"
                                    disabled={missingScan}
                                    aria-label={missingScan ? 'This scan is no longer stored' : ('View scan, ' + statusWord)}
                                    onClick={function () { if (!missingScan) props.onOpenScan(item.scanId); }}
                                    className={'h-11 w-full flex items-center justify-center gap-1.5 text-xs font-extrabold text-forest border-t border-stone-100/50 ' +
                                        (missingScan ? 'cursor-default' : 'btn-nature hover:ring-2 hover:ring-inset hover:ring-primary/40')}
                                >
                                    <span className="material-symbols-outlined text-base" aria-hidden="true">visibility</span>
                                    {missingScan ? 'Scan removed' : 'View scan'}
                                </button>
                            ) : (
                                <button
                                    type="button"
                                    data-testid="component-photo-scan"
                                    disabled={!src}
                                    aria-label="Scan this photo for mould"
                                    onClick={function () { if (src) props.onScan(item.photoId); }}
                                    className={'h-11 w-full flex items-center justify-center gap-1.5 text-xs font-extrabold border-t border-stone-100/50 touch-manipulation ' +
                                        (src ? 'text-white bg-forest btn-nature' : 'text-stone-700 cursor-not-allowed')}
                                >
                                    <span className="material-symbols-outlined text-base" aria-hidden="true">biotech</span>
                                    Scan for mould
                                </button>
                            )}
                        </li>
                    );
                })}
                <li>
                    <button
                        type="button"
                        data-testid="component-add-photo"
                        disabled={busy}
                        aria-busy={busy ? 'true' : 'false'}
                        onClick={function () { if (inputRef.current) inputRef.current.click(); }}
                        className="w-full aspect-square rounded-xl border-2 border-dashed border-sage/50 bg-surface flex flex-col items-center justify-center gap-1 btn-nature hover:border-primary transition-all text-forest touch-manipulation"
                    >
                        {busy ? (
                            <span className="w-6 h-6 rounded-full border-2 border-forest/20 border-t-forest animate-spin motion-reduce:animate-none" aria-hidden="true"></span>
                        ) : (
                            <span className="material-symbols-outlined text-2xl" aria-hidden="true">add_a_photo</span>
                        )}
                        <span className="text-[11px] font-black uppercase tracking-tight">{busy ? 'Adding…' : 'Add photo'}</span>
                    </button>
                    <input
                        ref={inputRef}
                        type="file"
                        accept="image/*"
                        data-testid="component-photo-input"
                        className="hidden"
                        aria-hidden="true"
                        tabIndex={-1}
                        onChange={onFile}
                    />
                </li>
            </ul>
            {err && (
                <p role="alert" className="text-[11px] font-bold text-red-800 flex items-center gap-1">
                    <span className="material-symbols-outlined text-sm" aria-hidden="true">error</span>
                    {err}
                </p>
            )}
            <p className="text-[11px] font-medium text-forest/70 leading-relaxed">
                Add any photo, context shots included. Scan a photo when you want the AI mould check on it.
            </p>
            {confirming && (
                <ASR_PhotoDeleteConfirm
                    item={confirming}
                    onKeep={function () { setConfirming(null); }}
                    onDelete={function () {
                        var target = confirming;
                        setConfirming(null);
                        if (props.onDelete) props.onDelete(target);
                    }}
                />
            )}
        </div>
    );
};

/**
 * ASR_ComponentSheet — the per-component bottom sheet (§5.3).
 *
 * `fixed inset-0 z-[100]` with a rounded panel inset from the edges, the same
 * idiom as OnboardingOverlay. No dim layer: the sheet covers the screen at
 * phone width anyway, and a scrim over a form the inspector is mid-way through
 * reads as an error state. Escape closes it, but only when focus is INSIDE the
 * sheet — bound document-wide it would close on any stray Escape, including one
 * meant for something else on the page (the OnboardingOverlay review-F9 rule).
 *
 * Everything writes through `updateComponent` on change; "Done" only closes.
 */
var ASR_ComponentSheet = function (props) {
    var def = props.def;
    var comp = props.comp;
    var patch = props.onPatch;
    var onClose = props.onClose;
    var sheetRef = _useRef_ASR(null);
    var headingRef = _useRef_ASR(null);

    _useEffect_ASR(function () {
        if (headingRef.current && headingRef.current.focus) headingRef.current.focus();
    }, []);

    _useEffect_ASR(function () {
        var onKeyDown = function (e) {
            if (e.key !== 'Escape' && e.keyCode !== 27) return;
            var sheet = sheetRef.current;
            if (!sheet) return;
            var active = document.activeElement;
            var inside = !!active && (active === sheet || (typeof sheet.contains === 'function' && sheet.contains(active)));
            if (!inside) return;
            e.stopPropagation();
            onClose();
        };
        document.addEventListener('keydown', onKeyDown);
        return function () { document.removeEventListener('keydown', onKeyDown); };
    }, [onClose]);

    var toggleMaterial = function (mat) {
        var mats = (comp.materials || []).slice();
        var idx = mats.indexOf(mat);
        if (idx >= 0) mats.splice(idx, 1); else mats.push(mat);
        patch({ materials: mats });
    };

    var toggleNearExt = function (i) {
        var next = (comp.nearExtWall || [false, false, false]).slice();
        next[i] = !next[i];
        patch({ nearExtWall: next });
    };

    return (
        <div className="fixed inset-0 z-[100] flex items-stretch justify-center p-3" data-testid="component-sheet">
            <div
                ref={sheetRef}
                role="dialog"
                aria-modal="true"
                aria-labelledby="asr-sheet-heading"
                className="w-full max-w-md bg-background-light rounded-xl shadow-clay h-full overflow-y-auto"
                style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
            >
                <div className="sticky top-0 bg-background-light px-5 pt-5 pb-3 flex items-start justify-between gap-3 border-b border-stone-100/50">
                    <div className="min-w-0">
                        <h2 id="asr-sheet-heading" ref={headingRef} tabIndex={-1} className="text-base font-extrabold text-forest outline-none truncate">{def.label}</h2>
                        <p className="text-[11px] font-semibold text-muted">Score what you can see. Everything here is optional.</p>
                    </div>
                    <div className="flex flex-col items-end gap-1 shrink-0">
                        <button
                            data-testid="component-sheet-done"
                            onClick={onClose}
                            className="px-4 py-2 rounded-xl bg-forest text-white text-xs font-extrabold shadow-clay btn-nature hover:brightness-110 transition-all"
                        >
                            Done
                        </button>
                        <AssessmentSavedChip />
                    </div>
                </div>

                <div className="px-5 py-4 space-y-5">
                    {/* The three 0–3 segmented controls. */}
                    {ASR_SCORE_AXES.map(function (axis) {
                        return (
                            <div key={axis.field} className="space-y-2">
                                <label className={ASR_SHEET_LABEL_CLS}>{axis.label}</label>
                                <div className="grid grid-cols-4 gap-2">
                                    {ASR_SCORE_STEPS.map(function (step) {
                                        var selected = comp[axis.field] === step.value;
                                        return (
                                            <button
                                                key={step.value}
                                                title={step.value + ' — ' + step.desc}
                                                data-testid={'component-score-' + axis.field + '-' + step.value}
                                                aria-pressed={selected ? 'true' : 'false'}
                                                onClick={function () { var p = {}; p[axis.field] = step.value; patch(p); }}
                                                className={'flex flex-col items-center gap-1 py-2.5 rounded-xl text-[11px] font-black btn-nature transition-all ' +
                                                    (selected ? 'bg-forest text-white shadow-clay' : 'bg-surface border border-stone-100/50 text-forest shadow-soft hover:bg-forest hover:text-white')}
                                            >
                                                <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: step.hex }}></span>
                                                {step.value} {step.label}
                                            </button>
                                        );
                                    })}
                                </div>
                            </div>
                        );
                    })}

                    {/* Near external wall — one per axis, the enterprise form's trio. */}
                    <div className="space-y-2">
                        <label className={ASR_SHEET_LABEL_CLS}>Near ext. wall</label>
                        <div className="space-y-2">
                            {ASR_SCORE_AXES.map(function (axis, i) {
                                var checked = !!(comp.nearExtWall || [])[i];
                                return (
                                    <button
                                        key={axis.field}
                                        data-testid={'component-nearext-' + axis.field}
                                        aria-pressed={checked ? 'true' : 'false'}
                                        onClick={function () { toggleNearExt(i); }}
                                        className="w-full flex items-center gap-2.5 bg-surface rounded-xl px-3 py-2.5 shadow-soft border border-stone-100/50 btn-nature text-left"
                                    >
                                        <span className={'w-4 h-4 rounded border-2 flex items-center justify-center shrink-0 transition-colors ' +
                                            (checked ? 'bg-forest border-forest' : 'border-sage/40 bg-surface')}>
                                            {checked && <span className="material-symbols-outlined text-white text-[11px]">check</span>}
                                        </span>
                                        <span className="text-xs font-bold text-forest">{axis.label}</span>
                                    </button>
                                );
                            })}
                        </div>
                    </div>

                    {/* Materials — the component's own list, verbatim. */}
                    <div className="space-y-2">
                        <label className={ASR_SHEET_LABEL_CLS}>Material type</label>
                        <div className="flex flex-wrap gap-1.5">
                            {(def.materials || []).map(function (mat) {
                                var active = (comp.materials || []).indexOf(mat) >= 0;
                                return (
                                    <button
                                        key={mat}
                                        data-testid={'component-material-' + mat}
                                        aria-pressed={active ? 'true' : 'false'}
                                        onClick={function () { toggleMaterial(mat); }}
                                        className={'px-3 py-2 rounded-full text-xs font-bold btn-nature transition-all ' +
                                            (active ? 'bg-forest text-white shadow-clay' : 'bg-surface border border-stone-100/50 text-forest shadow-soft hover:bg-forest hover:text-white')}
                                    >
                                        {mat}
                                    </button>
                                );
                            })}
                        </div>
                    </div>

                    {/* Notes + affected area. */}
                    <div className="space-y-2">
                        <label className={ASR_SHEET_LABEL_CLS}>Notes</label>
                        <textarea
                            data-testid="component-notes"
                            rows={3}
                            maxLength={500}
                            className={ASR_INPUT_CLS}
                            placeholder="What you saw on this component…"
                            value={comp.notes}
                            onChange={function (e) { patch({ notes: e.target.value }); }}
                        />
                    </div>

                    <div className="space-y-2">
                        <label className={ASR_SHEET_LABEL_CLS}>Affected area (m²)</label>
                        <input
                            type="number"
                            inputMode="decimal"
                            min="0"
                            step="any"
                            data-testid="component-area"
                            className={ASR_INPUT_CLS}
                            placeholder="e.g. 2.5"
                            value={comp.affectedAreaM2 === null ? '' : comp.affectedAreaM2}
                            onChange={function (e) { patch({ affectedAreaM2: e.target.value }); }}
                        />
                    </div>

                    {/* Photos (App review 2026-10-05). Any photo can be added —
                        context shots included — and each one is scanned only
                        when the inspector asks ("Scan for mould"). */}
                    <ASR_PhotoSection
                        items={props.photos || []}
                        onUpload={props.onUploadPhoto}
                        onScan={props.onScanPhoto}
                        onOpenScan={props.onOpenPhoto}
                        onDelete={props.onDeletePhoto}
                    />
                </div>
            </div>
        </div>
    );
};

/**
 * The eight bearings a room can face, as the compass draws them: degrees
 * clockwise from north (N at the top) and the spoken name for the radio's
 * aria-label. Keyed by the stored AS_DIRECTIONS values, which stay the record.
 */
var ASR_DIRECTION_INFO = {
    N:  { deg: 0,   name: 'North' },
    NE: { deg: 45,  name: 'North-east' },
    E:  { deg: 90,  name: 'East' },
    SE: { deg: 135, name: 'South-east' },
    S:  { deg: 180, name: 'South' },
    SW: { deg: 225, name: 'South-west' },
    W:  { deg: 270, name: 'West' },
    NW: { deg: 315, name: 'North-west' }
};

/**
 * ASR_Compass — "Which way it faces" as a round dial rather than a pill grid:
 * the eight options sit at their true bearings, so the inspector matches the
 * screen to the room instead of translating "SE" into a direction.
 *
 * Geometry is percentage-based (the dial is `aspect-square`, capped at 260 px),
 * so it shrinks with the card — at 320 px wide the card leaves ~240 px and the
 * 44 px targets still sit ~73 px apart on the ring. The dial art is an inline
 * SVG in a 0–100 viewBox; the options are real buttons laid over it.
 *
 * Semantics: a radiogroup with a roving tabindex. Arrow keys move clockwise
 * (Right/Down) or anticlockwise (Left/Up) and select as they go, as radios do.
 * Tapping — or Space/Enter on — the selected bearing clears it, keeping the
 * old pill grid's `selected ? null : dir` contract.
 */
var ASR_COMPASS_RING = 39; // option-centre radius, % of the dial width

var ASR_Compass = function (props) {
    var value = props.value;
    var onChange = props.onChange;
    var groupRef = _useRef_ASR(null);
    var info = value ? ASR_DIRECTION_INFO[value] : null;
    // Roving tabindex: the selected option, else North, is the one tab stop.
    var tabStop = info ? value : 'N';

    var focusDir = function (dir) {
        var el = groupRef.current && groupRef.current.querySelector('[data-testid="room-direction-' + dir + '"]');
        if (el && el.focus) el.focus();
    };

    var onKeyDown = function (e, dir) {
        var step = 0;
        if (e.key === 'ArrowRight' || e.key === 'ArrowDown') step = 1;
        if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') step = -1;
        if (!step) return;
        e.preventDefault();
        var i = AS_DIRECTIONS.indexOf(dir);
        var next = AS_DIRECTIONS[(i + step + AS_DIRECTIONS.length) % AS_DIRECTIONS.length];
        onChange(next);
        focusDir(next);
    };

    return (
        <div className="mt-2 flex flex-col items-center gap-2">
            <div
                ref={groupRef}
                role="radiogroup"
                aria-label="Which way it faces"
                data-testid="room-direction-compass"
                className="relative w-full max-w-[260px] aspect-square mx-auto select-none"
            >
                {/* The dial: cream face, sage ring and ticks, forest needle.
                    Decorative — the buttons carry every bit of meaning. */}
                <svg viewBox="0 0 100 100" className="absolute inset-0 w-full h-full" aria-hidden="true">
                    <circle cx="50" cy="50" r="49" fill="#F7F6F3" stroke="#86a697" strokeOpacity="0.45" strokeWidth="1" />
                    <circle cx="50" cy="50" r="26" fill="none" stroke="#86a697" strokeOpacity="0.3" strokeWidth="0.6" strokeDasharray="1.2 2" />
                    {[0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5, 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5].map(function (deg) {
                        var major = deg % 45 === 0;
                        return (
                            <line
                                key={deg}
                                x1="50" y1="1.5" x2="50" y2={major ? 6 : 4}
                                stroke="#86a697"
                                strokeWidth={major ? 1 : 0.6}
                                strokeLinecap="round"
                                transform={'rotate(' + deg + ' 50 50)'}
                            />
                        );
                    })}
                    {info && (
                        <g
                            data-testid="room-direction-needle"
                            className="transition-transform duration-300 motion-reduce:transition-none"
                            style={{ transform: 'rotate(' + info.deg + 'deg)', transformOrigin: '50px 50px' }}
                        >
                            {/* Tapered needle from the hub to just short of the
                                selected option (ring 39 − half a 44 px target). */}
                            <polygon points="50,23 52.6,50 47.4,50" fill="#1a4332" />
                        </g>
                    )}
                    <circle cx="50" cy="50" r={info ? 4 : 3} fill={info ? '#1a4332' : '#86a697'} />
                    {info && <circle cx="50" cy="50" r="1.4" fill="#F7F6F3" />}
                </svg>

                {AS_DIRECTIONS.map(function (dir) {
                    var d = ASR_DIRECTION_INFO[dir];
                    var selected = value === dir;
                    var rad = d.deg * Math.PI / 180;
                    // The wrapper owns the centring translate; the button owns the
                    // press effect. They must not share an element: .btn-nature:active
                    // sets `transform: scale()`, which REPLACES translate(-50%,-50%) —
                    // the button jumped ~22 px mid-tap and the release landed off it,
                    // so taps were dropped (Rozario, 28 Sep).
                    return (
                        <span
                            key={dir}
                            className="absolute w-11 h-11 -translate-x-1/2 -translate-y-1/2"
                            style={{
                                left: (50 + ASR_COMPASS_RING * Math.sin(rad)) + '%',
                                top: (50 - ASR_COMPASS_RING * Math.cos(rad)) + '%'
                            }}
                        >
                            <button
                                type="button"
                                role="radio"
                                aria-checked={selected ? 'true' : 'false'}
                                aria-label={d.name}
                                tabIndex={dir === tabStop ? 0 : -1}
                                data-testid={'room-direction-' + dir}
                                // Radio semantics: tapping the selected option keeps
                                // it (a double tap must not wipe the answer); "Clear"
                                // below the dial unsets it.
                                onClick={function () { if (!selected) onChange(dir); }}
                                onKeyDown={function (e) { onKeyDown(e, dir); }}
                                className={'w-full h-full rounded-full flex items-center justify-center text-xs font-black tracking-wide btn-nature touch-manipulation focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ' +
                                    // No hover fill: on touch screens :hover sticks after
                                    // a tap and an unselected option looked selected.
                                    (selected ? 'bg-forest text-white shadow-clay' : 'bg-surface ring-1 ring-sage/30 text-forest shadow-soft hover:ring-forest/50')}
                            >
                                {dir}
                            </button>
                        </span>
                    );
                })}
            </div>
            {/* The reading in words, below the dial (never on it — a centred
                label would sit under the needle whenever it points south). */}
            <div className="flex items-center gap-1">
                <p data-testid="room-direction-readout" aria-live="polite" className="text-[11px] font-bold text-forest">
                    {info ? 'Faces ' + info.name.toLowerCase() : 'Not set — tap a direction'}
                </p>
                {info && (
                    <button
                        type="button"
                        data-testid="room-direction-clear"
                        onClick={function () { onChange(null); }}
                        className="min-h-[44px] px-3 text-[11px] font-bold text-forest underline underline-offset-2 touch-manipulation"
                    >
                        Clear
                    </button>
                )}
            </div>
            <ASR_DirectionHelp />
        </div>
    );
};

/**
 * ASR_DirectionHelp — "Why direction matters?" progressive disclosure under the
 * compass: why the inspector records orientation (sun exposure drives how fast
 * a room dries, and so its condensation/mould risk). Collapsed by default so
 * the form stays short; the copy is general Australian guidance, not derived
 * from the property's location.
 */
var ASR_DirectionHelp = function () {
    var openState = _useState_ASR(false);
    var open = openState[0];
    var setOpen = openState[1];
    return (
        <div className="w-full">
            <button
                type="button"
                data-testid="room-direction-help-toggle"
                aria-expanded={open ? 'true' : 'false'}
                aria-controls="room-direction-help"
                onClick={function () { setOpen(!open); }}
                className="mx-auto min-h-[44px] px-3 flex items-center gap-1 text-xs font-bold text-forest touch-manipulation"
            >
                <span className="underline underline-offset-2">Why direction matters?</span>
                <span className="material-symbols-outlined text-base" aria-hidden="true">{open ? 'expand_less' : 'expand_more'}</span>
            </button>
            {open && (
                <div
                    id="room-direction-help"
                    data-testid="room-direction-help"
                    className="mt-1 rounded-xl bg-surface ring-1 ring-sage/20 p-4 space-y-3 text-xs leading-relaxed text-forest"
                >
                    <p>
                        Australia is in the southern hemisphere, so across most of the country the sun travels through
                        the northern sky. North-facing rooms get the most direct sun all year. The midday sun is highest
                        in summer, when eaves easily shade it, and lowest in winter, when it reaches deep into
                        north-facing rooms.
                    </p>
                    <p>
                        South-facing walls and rooms get little or no direct sun from roughly April to August. The
                        effect is strongest in the southern capitals, where the winter midday sun reaches only about 24°
                        above the horizon in Hobart, 29° in Melbourne and 33° in Sydney. That makes south-facing rooms
                        the coldest and slowest to dry, and the most likely to get condensation and mould. In summer the
                        sun rises in the south-east and sets in the south-west, so south walls do catch some
                        early-morning and late-afternoon sun.
                    </p>
                    <p>
                        East-facing rooms get gentle morning sun all year. West-facing rooms get strong, hot afternoon
                        sun, especially in summer, which dries them out but can overheat them.
                    </p>
                    <p>
                        Tropical Australia works differently. North of the Tropic of Capricorn (roughly Rockhampton,
                        Townsville, Cairns and Darwin), the summer sun passes almost directly overhead and even slightly
                        to the south at midday. South walls there get sun during the wet season. Orientation matters
                        less than shading, airflow and high year-round humidity, which drive mould risk whichever way a
                        room faces.
                    </p>
                    <p className="font-bold">
                        For the assessment, a south-facing room in a temperate city in winter is the classic high-risk
                        combination for condensation-driven mould.
                    </p>
                </div>
            )}
        </div>
    );
};

/**
 * ASR_ComponentRow — one collapsed row (§5.3). A div rather than a button
 * because it CONTAINS a button (the present toggle), and nesting buttons is
 * invalid markup that browsers resolve unpredictably.
 */
var ASR_ComponentRow = function (props) {
    var def = props.def;
    var comp = props.comp;
    var open = props.onOpen;
    var patch = props.onPatch;
    var present = comp.present === true;
    var photos = asrPhotoCount(comp);
    // asComponentScored is the store's ONE "assessed" definition (ADR-009 D4) —
    // the same test behind the room's n/8 counter, so row and counter agree.
    var assessed = asComponentScored(comp);

    var openIfKey = function (e) {
        if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 13 || e.keyCode === 32) {
            e.preventDefault();
            open();
        }
    };

    return (
        <div
            role="button"
            tabIndex={0}
            data-testid={'component-row-' + def.key}
            data-assessed={present ? (assessed ? 'true' : 'false') : undefined}
            onClick={open}
            onKeyDown={openIfKey}
            className="w-full bg-surface rounded-xl p-3 shadow-soft border border-stone-100/50 btn-nature cursor-pointer space-y-2"
        >
            <div className="flex items-center gap-2">
                <div className="flex-1 min-w-0">
                    <p className="text-sm font-extrabold text-forest truncate">{def.label}</p>
                    {/* Form state, in the shared vocabulary (ADR-009 D9). An
                        explicit "0 None" is `editedAt`-stamped and reads as a
                        finding — "Assessed · nothing found" — never as the
                        untouched state it used to be indistinguishable from. */}
                    {present && !assessed && (
                        <AssessmentStateChip testid={'component-unassessed-' + def.key} state="untouched" label="Not assessed yet" />
                    )}
                    {present && assessed && (
                        <AssessmentStateChip
                            testid={'component-assessed-' + def.key}
                            state="complete"
                            label={asComponentNothingFound(comp) ? 'Assessed · nothing found' : 'Assessed'}
                        />
                    )}
                </div>
                <button
                    data-testid={'component-present-' + def.key}
                    aria-pressed={present ? 'true' : 'false'}
                    onClick={function (e) { e.stopPropagation(); patch({ present: !present }); }}
                    className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-background-light btn-nature shrink-0"
                >
                    <span className={'w-3.5 h-3.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ' +
                        (present ? 'bg-forest border-forest' : 'border-sage/40 bg-surface')}>
                        {present && <span className="material-symbols-outlined text-white text-[9px]">check</span>}
                    </span>
                    <span className="text-[10px] font-black text-forest uppercase tracking-wider">Present</span>
                </button>
                {/* arrow_forward, not a chevron glyph: the Material Symbols font is a
                    SUBSET (assets/fonts/symbols.icons.txt) and has no chevron_right —
                    an unlisted ligature renders as the literal word. */}
                <span className="material-symbols-outlined text-forest text-lg shrink-0">arrow_forward</span>
            </div>

            {present ? (
                <div className="flex items-center gap-1.5 flex-wrap">
                    {/* Untouched components read NEUTRAL, not green: a score of 0
                        is the good band, and three green dots on a component
                        nobody has opened says "checked, all clear". Until it is
                        assessed each chip is a grey dot and a dash; after, the
                        band colour rides the dot beside the number as before
                        (never colour alone — the word and number carry it). */}
                    {ASR_SCORE_AXES.map(function (axis) {
                        var step = asrStep(comp[axis.field]);
                        return (
                            <span
                                key={axis.field}
                                data-testid={'component-chip-' + def.key + '-' + axis.field}
                                className={'flex items-center gap-1 px-2 py-1 rounded-full bg-background-light text-[10px] font-black ' +
                                    (assessed ? 'text-forest' : 'text-stone-500')}
                            >
                                <span className="w-2 h-2 rounded-full" style={{ backgroundColor: assessed ? step.hex : '#d6d3d1' }}></span>
                                {axis.short} {assessed ? comp[axis.field] : '—'}
                            </span>
                        );
                    })}
                    <span data-testid={'component-photos-' + def.key} className="ml-auto text-[10px] font-bold text-sage">
                        {photos} photo{photos !== 1 ? 's' : ''}
                    </span>
                </div>
            ) : (
                <p data-testid={'component-absent-' + def.key} className="text-[11px] font-bold text-sage">Not present</p>
            )}
        </div>
    );
};

/**
 * ASR_Sheet — the shared bottom-sheet shell for the two lab sheets (slice 3).
 *
 * Same idiom as ASR_ComponentSheet: a full-height rounded panel inset from the
 * edges, no scrim, Escape scoped to focus INSIDE the sheet (bound document-wide
 * it would swallow an Escape meant for something else — OnboardingOverlay review
 * F9). Unlike the component sheet these two have an explicit Save: a sample is
 * one record the inspector means to create, not a field they are editing, and
 * autosaving a half-typed lab reference would litter the visit.
 */
/**
 * ASR_DiscardConfirm — the in-app "Discard unsaved changes?" guard (ADR-009
 * D9). Never window.confirm: a native dialog is unstyleable, blocks the page,
 * and is suppressed outright in some embedded webviews — which would silently
 * turn the guard into "discard".
 *
 * The SAFE choice is primary and takes focus: "Keep editing". Escape also
 * keeps editing. Discard is the secondary, deliberate tap.
 */
var ASR_DiscardConfirm = function (props) {
    var keepRef = _useRef_ASR(null);
    _useEffect_ASR(function () {
        if (keepRef.current && keepRef.current.focus) keepRef.current.focus();
        var onKeyDown = function (e) {
            if (e.key !== 'Escape' && e.keyCode !== 27) return;
            e.stopPropagation();
            props.onKeep();
        };
        document.addEventListener('keydown', onKeyDown, true);
        return function () { document.removeEventListener('keydown', onKeyDown, true); };
    }, []);
    return (
        <div className="fixed inset-0 z-[110] flex items-end justify-center p-3 bg-forest/40" data-testid="discard-confirm">
            <div
                role="alertdialog"
                aria-modal="true"
                aria-labelledby="asr-discard-heading"
                aria-describedby="asr-discard-body"
                className="w-full max-w-md bg-background-light rounded-xl shadow-clay p-5 space-y-3"
                style={{ marginBottom: 'env(safe-area-inset-bottom)' }}
            >
                <h2 id="asr-discard-heading" className="text-base font-extrabold text-forest">Discard unsaved changes?</h2>
                <p id="asr-discard-body" className="text-xs font-semibold text-forest/80 leading-relaxed">
                    {props.body || 'What you entered in this sheet has not been saved.'}
                </p>
                <button
                    ref={keepRef}
                    data-testid="discard-keep"
                    onClick={props.onKeep}
                    className="w-full min-h-[44px] bg-forest text-white font-extrabold py-3 rounded-xl shadow-clay btn-nature hover:brightness-110 transition-all"
                >
                    Keep editing
                </button>
                <button
                    data-testid="discard-confirm-discard"
                    onClick={props.onDiscard}
                    className="w-full min-h-[44px] bg-surface text-forest font-extrabold py-3 rounded-xl shadow-soft border border-sage/30 btn-nature hover:bg-forest hover:text-white transition-all"
                >
                    Discard changes
                </button>
            </div>
        </div>
    );
};

var ASR_Sheet = function (props) {
    var sheetRef = _useRef_ASR(null);
    var headingRef = _useRef_ASR(null);
    var onClose = props.onClose;
    var dirty = !!props.dirty;
    var confirmState = _useState_ASR(false);
    var confirming = confirmState[0];
    var setConfirming = confirmState[1];

    // The dirty guard (D9): closing a sheet with unsaved edits asks first.
    // A clean sheet closes at once — asking about nothing is its own friction.
    var requestClose = function () {
        if (dirty) setConfirming(true);
        else onClose();
    };
    var requestCloseRef = _useRef_ASR(requestClose);
    requestCloseRef.current = requestClose;

    _useEffect_ASR(function () {
        if (headingRef.current && headingRef.current.focus) headingRef.current.focus();
    }, []);

    _useEffect_ASR(function () {
        var onKeyDown = function (e) {
            if (e.key !== 'Escape' && e.keyCode !== 27) return;
            var sheet = sheetRef.current;
            if (!sheet) return;
            var active = document.activeElement;
            var inside = !!active && (active === sheet || (typeof sheet.contains === 'function' && sheet.contains(active)));
            if (!inside) return;
            e.stopPropagation();
            requestCloseRef.current();
        };
        document.addEventListener('keydown', onKeyDown);
        return function () { document.removeEventListener('keydown', onKeyDown); };
    }, []);

    return (
        <div className="fixed inset-0 z-[100] flex items-stretch justify-center p-3" data-testid={props.testid} data-dirty={dirty ? 'true' : 'false'}>
            <div
                ref={sheetRef}
                role="dialog"
                aria-modal="true"
                aria-labelledby={props.testid + '-heading'}
                className="w-full max-w-md bg-background-light rounded-xl shadow-clay h-full overflow-y-auto"
                style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
            >
                <div className="sticky top-0 bg-background-light px-5 pt-5 pb-3 flex items-start justify-between gap-3 border-b border-stone-100/50">
                    <div className="min-w-0">
                        <h2 id={props.testid + '-heading'} ref={headingRef} tabIndex={-1} className="text-base font-extrabold text-forest outline-none truncate">{props.title}</h2>
                        <p className="text-[11px] font-semibold text-muted">{props.subtitle}</p>
                        {/* "changed" — the explicit-Save sheet's own form state.
                            Not a save status: nothing is written until Save. */}
                        {dirty && (
                            <div className="pt-1">
                                <AssessmentStateChip testid={props.testid + '-unsaved'} state="changed" label="Unsaved changes" />
                            </div>
                        )}
                    </div>
                    <button
                        data-testid={props.testid + '-cancel'}
                        onClick={requestClose}
                        aria-label={dirty ? 'Close — you have unsaved changes' : 'Close'}
                        className="w-11 h-11 rounded-xl bg-surface text-forest flex items-center justify-center shrink-0 shadow-soft btn-nature hover:bg-forest hover:text-white transition-all"
                    >
                        <span className="material-symbols-outlined text-lg">close</span>
                    </button>
                </div>
                <div className="px-5 py-4 space-y-4">{props.children}</div>
            </div>
            {confirming && (
                <ASR_DiscardConfirm
                    body={props.discardBody}
                    onKeep={function () {
                        setConfirming(false);
                        if (headingRef.current && headingRef.current.focus) headingRef.current.focus();
                    }}
                    onDiscard={function () { setConfirming(false); onClose(); }}
                />
            )}
        </div>
    );
};

/**
 * ASR_SampleSheet — "Add sample": what was taken, from where, and when.
 *
 * Collected at inspection time with no result — that is the whole point of the
 * design (decision 1: lab results arrive the same day, three days later, or
 * weeks later). Only the type is pre-set; everything else is optional, because
 * an inspector bagging a sample on a ladder has a reference number and not much
 * else, and blocking the save on a lab name they have not chosen yet would push
 * them back to paper.
 */
var ASR_SampleSheet = function (props) {
    var typeState = _useState_ASR('tape');
    var sampleType = typeState[0]; var setSampleType = typeState[1];
    var refState = _useState_ASR('');
    var sampleRef = refState[0]; var setSampleRef = refState[1];
    var compState = _useState_ASR('');
    var componentKey = compState[0]; var setComponentKey = compState[1];
    var scanState = _useState_ASR('');
    var scanId = scanState[0]; var setScanId = scanState[1];
    var labState = _useState_ASR('');
    var lab = labState[0]; var setLab = labState[1];
    var collectedState = _useState_ASR(asrTodayISO());
    var collectedAt = collectedState[0]; var setCollectedAt = collectedState[1];
    var sentState = _useState_ASR('');
    var sentAt = sentState[0]; var setSentAt = sentState[1];

    // Form state (D9): dirty = anything differs from what the sheet opened
    // with. It drives the "Unsaved changes" chip and the discard guard. Save
    // stays ENABLED on a clean sheet (deviation, reported): the defaults — a
    // tape lift collected today — are a complete, valid record, and an
    // inspector bagging a sample on a ladder must be able to log it in one tap.
    var initialRef = _useRef_ASR({ collectedAt: collectedAt });
    var dirty = sampleType !== 'tape' || sampleRef !== '' || componentKey !== '' || scanId !== '' ||
        lab !== '' || collectedAt !== initialRef.current.collectedAt || sentAt !== '';

    return (
        <ASR_Sheet
            testid="sample-sheet"
            title="Add sample"
            subtitle="Record it now; the result can land weeks later."
            dirty={dirty}
            discardBody="This sample has not been added to the room yet."
            onClose={props.onClose}
        >
            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Sample type</label>
                <div className="grid grid-cols-5 gap-1.5">
                    {AS_SAMPLE_TYPES.map(function (t) {
                        var selected = sampleType === t;
                        return (
                            <button
                                key={t}
                                data-testid={'sample-type-' + t}
                                aria-pressed={selected ? 'true' : 'false'}
                                onClick={function () { setSampleType(t); }}
                                className={'py-2 rounded-xl text-[10px] font-black btn-nature transition-all ' +
                                    (selected ? 'bg-forest text-white shadow-clay' : 'bg-surface border border-stone-100/50 text-forest shadow-soft hover:bg-forest hover:text-white')}
                            >
                                {ASR_SAMPLE_TYPE_LABELS[t]}
                            </button>
                        );
                    })}
                </div>
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Sample reference</label>
                <input
                    type="text"
                    data-testid="sample-ref"
                    className={ASR_INPUT_CLS}
                    placeholder="Bag or lab reference, e.g. MB-14"
                    value={sampleRef}
                    onChange={function (e) { setSampleRef(e.target.value); }}
                />
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Component (optional)</label>
                <select
                    data-testid="sample-component"
                    className={ASR_INPUT_CLS + ' appearance-none'}
                    value={componentKey}
                    onChange={function (e) { setComponentKey(e.target.value); }}
                >
                    <option value="">Not tied to one component</option>
                    {(props.components || []).map(function (c) {
                        return <option key={c.key} value={c.key}>{c.label}</option>;
                    })}
                </select>
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Photo (optional)</label>
                <select
                    data-testid="sample-scan"
                    className={ASR_INPUT_CLS + ' appearance-none'}
                    value={scanId}
                    onChange={function (e) { setScanId(e.target.value); }}
                >
                    <option value="">No photo</option>
                    {(props.photos || []).map(function (p) {
                        return (
                            <option key={p.id} value={p.id}>
                                {asrComponentLabel(p.componentKey)} · {p.scan ? (p.scan.verdict || 'Scan') : 'Scan'}
                            </option>
                        );
                    })}
                </select>
                <p className="text-[11px] font-medium text-forest/70 leading-relaxed">
                    Linking the photo means a genus result also lands on that scan, where the classifier learns from it.
                </p>
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Laboratory</label>
                <input
                    type="text"
                    data-testid="sample-lab"
                    className={ASR_INPUT_CLS}
                    placeholder="Who is testing it"
                    value={lab}
                    onChange={function (e) { setLab(e.target.value); }}
                />
            </div>

            <div className="grid grid-cols-2 gap-3">
                <div className="space-y-2">
                    <label className={ASR_SHEET_LABEL_CLS}>Collected</label>
                    <input
                        type="date"
                        data-testid="sample-collected"
                        className={ASR_INPUT_CLS}
                        value={collectedAt}
                        onChange={function (e) { setCollectedAt(e.target.value); }}
                    />
                </div>
                <div className="space-y-2">
                    <label className={ASR_SHEET_LABEL_CLS}>Sent (optional)</label>
                    <input
                        type="date"
                        data-testid="sample-sent"
                        className={ASR_INPUT_CLS}
                        value={sentAt}
                        onChange={function (e) { setSentAt(e.target.value); }}
                    />
                </div>
            </div>

            <button
                data-testid="sample-save"
                onClick={function () {
                    props.onSave({
                        sampleType: sampleType,
                        sampleRef: sampleRef,
                        componentKey: componentKey || null,
                        scanId: scanId || null,
                        lab: lab,
                        collectedAt: collectedAt || null,
                        sentAt: sentAt || null
                    });
                }}
                className="w-full bg-forest text-white font-extrabold py-3 rounded-xl shadow-clay btn-nature hover:brightness-110 transition-all"
            >
                Save sample
            </button>
        </ASR_Sheet>
    );
};

/**
 * ASR_ResultSheet — "Add lab result", days or weeks after the visit (§5.1b).
 *
 * Prefilled from an existing result when editing, so a correction is an edit and
 * not a re-entry. The genus field offers the catalog's six genera as a
 * `datalist` rather than a select: a lab reports whatever it found, and a
 * closed list would force an inspector to record something the report does not
 * say. Saving one that names a genus also writes the scan's
 * `genusLabConfirmation` — the parent does that, never this form.
 */
var ASR_ResultSheet = function (props) {
    var sample = props.sample;
    var existing = sample.result || {};
    var receivedState = _useState_ASR(sample.resultReceivedAt || asrTodayISO());
    var resultReceivedAt = receivedState[0]; var setResultReceivedAt = receivedState[1];
    var genusState = _useState_ASR(existing.genus || '');
    var genus = genusState[0]; var setGenus = genusState[1];
    var speciesState = _useState_ASR(existing.speciesText || '');
    var speciesText = speciesState[0]; var setSpeciesText = speciesState[1];
    var countState = _useState_ASR(existing.sporeCount === null || existing.sporeCount === undefined ? '' : String(existing.sporeCount));
    var sporeCount = countState[0]; var setSporeCount = countState[1];
    var unitState = _useState_ASR(existing.unit || '');
    var unit = unitState[0]; var setUnit = unitState[1];
    var interpState = _useState_ASR(existing.interpretation || '');
    var interpretation = interpState[0]; var setInterpretation = interpState[1];
    var notesState = _useState_ASR(existing.notes || '');
    var notes = notesState[0]; var setNotes = notesState[1];

    // Form state (D9): the values the sheet opened with, frozen, so "changed"
    // means changed from THAT — typing a genus and deleting it again is clean.
    var initialRef = _useRef_ASR({
        resultReceivedAt: resultReceivedAt, genus: genus, speciesText: speciesText,
        sporeCount: sporeCount, unit: unit, interpretation: interpretation, notes: notes
    });
    var init = initialRef.current;
    var dirty = resultReceivedAt !== init.resultReceivedAt || genus !== init.genus ||
        speciesText !== init.speciesText || sporeCount !== init.sporeCount || unit !== init.unit ||
        interpretation !== init.interpretation || notes !== init.notes;

    // The catalog's six genus labels, alphabetical (ADR-0007: genus is
    // descriptive, never a danger ranking, so the list is never re-ordered).
    var genusOptions = (typeof window.GenusCatalog !== 'undefined' && window.GenusCatalog.ENTRIES)
        ? Object.keys(window.GenusCatalog.ENTRIES).sort()
        : [];

    return (
        <ASR_Sheet
            testid="result-sheet"
            title={sample.result ? 'Edit lab result' : 'Add lab result'}
            subtitle={(ASR_SAMPLE_TYPE_LABELS[sample.sampleType] || 'Sample') + (sample.sampleRef ? ' · ' + sample.sampleRef : '')}
            dirty={dirty}
            discardBody={sample.result ? 'Your edits to this lab result have not been saved.' : 'This lab result has not been recorded yet.'}
            onClose={props.onClose}
        >
            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Result received</label>
                <input
                    type="date"
                    data-testid="result-received"
                    className={ASR_INPUT_CLS}
                    value={resultReceivedAt}
                    onChange={function (e) { setResultReceivedAt(e.target.value); }}
                />
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Genus</label>
                <input
                    type="text"
                    data-testid="result-genus"
                    list="asr-genus-options"
                    className={ASR_INPUT_CLS}
                    placeholder="As the report names it"
                    value={genus}
                    onChange={function (e) { setGenus(e.target.value); }}
                />
                <datalist id="asr-genus-options">
                    {genusOptions.map(function (g) { return <option key={g} value={g} />; })}
                </datalist>
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Species / strain</label>
                <input
                    type="text"
                    data-testid="result-species"
                    className={ASR_INPUT_CLS}
                    placeholder="If the report identifies one"
                    value={speciesText}
                    onChange={function (e) { setSpeciesText(e.target.value); }}
                />
            </div>

            <div className="grid grid-cols-2 gap-3">
                <div className="space-y-2">
                    <label className={ASR_SHEET_LABEL_CLS}>Count</label>
                    <input
                        type="number"
                        inputMode="decimal"
                        step="any"
                        min="0"
                        data-testid="result-spore-count"
                        className={ASR_INPUT_CLS}
                        placeholder="e.g. 1400"
                        value={sporeCount}
                        onChange={function (e) { setSporeCount(e.target.value); }}
                    />
                </div>
                <div className="space-y-2">
                    <label className={ASR_SHEET_LABEL_CLS}>Unit</label>
                    <select
                        data-testid="result-unit"
                        className={ASR_INPUT_CLS + ' appearance-none'}
                        value={unit}
                        onChange={function (e) { setUnit(e.target.value); }}
                    >
                        <option value="">Not stated</option>
                        {AS_SAMPLE_UNITS.map(function (u) { return <option key={u} value={u}>{u}</option>; })}
                    </select>
                </div>
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Interpretation</label>
                <select
                    data-testid="result-interpretation"
                    className={ASR_INPUT_CLS + ' appearance-none'}
                    value={interpretation}
                    onChange={function (e) { setInterpretation(e.target.value); }}
                >
                    <option value="">Not stated</option>
                    {AS_INTERPRETATIONS.map(function (v) { return <option key={v} value={v}>{v}</option>; })}
                </select>
            </div>

            <div className="space-y-2">
                <label className={ASR_SHEET_LABEL_CLS}>Notes</label>
                <textarea
                    data-testid="result-notes"
                    rows={3}
                    maxLength={500}
                    className={ASR_INPUT_CLS}
                    placeholder="Anything the report says that matters here…"
                    value={notes}
                    onChange={function (e) { setNotes(e.target.value); }}
                />
            </div>

            {/* Save is disabled until something changed (D9): an unchanged
                edit has nothing to save, and a brand-new result that is only
                today's date would record a lab report that says nothing. The
                reason is stated, not implied by a faded button. */}
            {!dirty && (
                <p data-testid="result-save-hint" className="text-[11px] font-bold text-stone-600 px-1">
                    {sample.result ? 'No changes to save yet.' : 'Enter what the lab reported to save it.'}
                </p>
            )}
            <button
                data-testid="result-save"
                disabled={!dirty}
                aria-disabled={!dirty ? 'true' : undefined}
                onClick={function () {
                    if (!dirty) return;
                    props.onSave({
                        resultReceivedAt: resultReceivedAt || asrTodayISO(),
                        genus: genus,
                        speciesText: speciesText,
                        sporeCount: sporeCount,
                        unit: unit,
                        interpretation: interpretation,
                        notes: notes
                    });
                }}
                className="w-full min-h-[44px] bg-forest text-white font-extrabold py-3 rounded-xl shadow-clay btn-nature hover:brightness-110 transition-all disabled:bg-stone-300 disabled:text-stone-700 disabled:shadow-none disabled:cursor-not-allowed"
            >
                Save result
            </button>
            <p className="text-[11px] font-medium text-forest/70 leading-relaxed">
                Recording this after the visit is complete stamps an amendment — the visit was never locked.
            </p>
        </ASR_Sheet>
    );
};

/**
 * ASR_SampleRow — one collected sample: what it is, where from, and whether the
 * lab has reported. The chip is amber for pending and green for reported, each
 * carrying its own words (never colour alone).
 */
var ASR_SampleRow = function (props) {
    var sample = props.sample;
    var reported = !!sample.result;
    var parts = [ASR_SAMPLE_TYPE_LABELS[sample.sampleType] || 'Sample'];
    if (sample.sampleRef) parts.push(sample.sampleRef);
    if (sample.componentKey) parts.push(asrComponentLabel(sample.componentKey));

    return (
        <div
            id={'sample-' + sample.id}
            data-testid="sample-row"
            data-sample-id={sample.id}
            className={'bg-surface rounded-xl p-3 shadow-soft border space-y-2 ' +
                (props.focused ? 'border-forest ring-2 ring-primary' : 'border-stone-100/50')}
        >
            <div className="flex items-center gap-2">
                <p className="text-sm font-extrabold text-forest flex-1 min-w-0 truncate">{parts.join(' · ')}</p>
                <span
                    data-testid={reported ? 'sample-chip-reported' : 'sample-chip-pending'}
                    className={'text-[9px] px-2 py-0.5 rounded-full font-black uppercase tracking-tight shrink-0 ' +
                        (reported ? 'bg-primary/20 text-forest' : 'bg-[#d4a373]/25 text-forest')}
                >
                    {reported ? 'Reported ' + asrShortDate(sample.resultReceivedAt) : 'Result pending'}
                </span>
            </div>

            {reported && (
                <p data-testid="sample-result-line" className="text-[11px] font-semibold text-muted">
                    {[sample.result.genus, sample.result.speciesText,
                      (sample.result.sporeCount === null ? '' : sample.result.sporeCount + (sample.result.unit ? ' ' + sample.result.unit : '')),
                      sample.result.interpretation]
                        .filter(function (s) { return !!s; }).join(' · ') || 'Recorded with no detail'}
                </p>
            )}

            <div className="flex items-center gap-2">
                <button
                    data-testid="sample-result-open"
                    onClick={props.onResult}
                    className="flex-1 bg-background-light text-forest text-xs font-extrabold py-2 rounded-xl btn-nature hover:bg-forest hover:text-white transition-all"
                >
                    {reported ? 'Edit result' : 'Add lab result'}
                </button>
                <button
                    data-testid="sample-remove"
                    aria-label="Remove this sample"
                    onClick={props.onRemove}
                    className="w-9 h-9 rounded-xl bg-background-light text-forest flex items-center justify-center shrink-0 btn-nature hover:bg-forest hover:text-white transition-all"
                >
                    <span className="material-symbols-outlined text-base">delete</span>
                </button>
            </div>
        </div>
    );
};

var AssessmentRoomPage = function () {
    var params = _useParams_ASR();
    var navigate = _useNavigate_ASR();
    var location = _useLocation_ASR();
    var store = useAssessments();
    // Photos are Scans (ADR-009 D3), so this screen reads both stores: the
    // aggregate for the ids, ScanStore for the thumbnail and the verdict.
    var scanStore = useScanStore();

    // All declared BEFORE the loading / not-found early return: hooks cannot
    // sit behind a branch.
    var openState = _useState_ASR(null);     // component key whose sheet is open
    var openKey = openState[0];
    var setOpenKey = openState[1];
    var subfloorState = _useState_ASR(false); // "Subfloor (if inspected)" disclosure
    var subfloorOpen = subfloorState[0];
    var setSubfloorOpen = subfloorState[1];
    var sampleSheetState = _useState_ASR(false);
    var sampleSheetOpen = sampleSheetState[0];
    var setSampleSheetOpen = sampleSheetState[1];
    var resultForState = _useState_ASR(null); // sample id whose result sheet is open
    var resultForId = resultForState[0];
    var setResultForId = resultForState[1];

    // The sample the visit screen's pending list asked for. MemoryRouter keeps
    // routes off the URL, so the jump target travels as navigation state.
    var focusSampleId = (location && location.state && location.state.focusSampleId) || null;

    var assessment = store.getById(params.id);
    var room = null;
    if (assessment) {
        for (var i = 0; i < assessment.rooms.length; i++) {
            if (assessment.rooms[i].id === params.roomId) room = assessment.rooms[i];
        }
    }

    /**
     * The append retry (slice 3). A scan carrying `assessmentRef` for this room
     * is the truth about where the photo belongs; the component's `scanIds` is
     * the convenience copy. If the append could not land when the scan was saved
     * — a store that failed to hydrate, a provider that was not mounted — it is
     * re-run here on mount. `attachScan` is idempotent by scan id, so the common
     * case (already attached) writes nothing and, crucially, stamps no spurious
     * amendment on a completed visit.
     */
    var attachScan = store.attachScan;
    var scans = scanStore.scans;
    _useEffect_ASR(function () {
        if (!assessment || !room) return;
        for (var s = 0; s < scans.length; s++) {
            var ref = scans[s] && scans[s].assessmentRef;
            if (!ref || ref.assessmentId !== assessment.id || ref.roomId !== room.id) continue;
            var key = ref.componentKey;
            var attached = false;
            for (var c = 0; c < room.components.length; c++) {
                if (room.components[c].key !== key) continue;
                if (asComponentPhotoIds(room.components[c]).indexOf(scans[s].id) < 0) continue;
                attached = true;
                // A scan made FROM an uploaded photo must also be linked back
                // to that photo (2026-10-05) — retry that half too.
                var atts = room.components[c].attachments || [];
                for (var at = 0; at < atts.length; at++) {
                    if (ref.photoId && atts[at].id === ref.photoId && !atts[at].scanId) attached = false;
                }
            }
            if (!attached) attachScan(assessment.id, room.id, key, scans[s].id, ref.photoId || null);
        }
    }, [assessment, room, scans, attachScan]);

    // Scroll the linked sample into view once it has rendered. Looked up by id
    // rather than held as a ref: the row is rendered by a child component, and
    // threading a ref through it would buy nothing a getElementById does not.
    _useEffect_ASR(function () {
        if (!focusSampleId) return;
        var el = document.getElementById('sample-' + focusSampleId);
        if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center' });
    }, [focusSampleId, room]);

    if (!store.hydrated || !room) {
        return (
            <Layout>
                <main className="flex-1 w-full mx-auto pb-36 overflow-y-auto overflow-x-hidden">
                    <PageHeader title="Room" showMenu={true} />
                    <div className="px-5 pt-4">
                        <div className="bio-bg bio-bg-10 rounded-xl p-5 shadow-soft border border-stone-100/50 text-center">
                            <p className="text-sm font-extrabold text-forest">
                                {store.hydrated ? 'This room is no longer here' : 'Loading this room…'}
                            </p>
                            {store.hydrated && (
                                <button
                                    onClick={function () { navigate('/assessment/' + params.id); }}
                                    className="mt-4 w-full bg-forest text-white font-extrabold py-3 rounded-xl shadow-clay btn-nature hover:brightness-110 transition-all"
                                >
                                    Back to the visit
                                </button>
                            )}
                        </div>
                    </div>
                </main>
            </Layout>
        );
    }

    var patch = function (fieldName, value) {
        var p = {};
        p[fieldName] = value;
        store.updateRoom(assessment.id, room.id, p);
    };

    /* The lead followed by the visit team, de-duplicated (slice 5). Always at
       least the lead, so the select is never empty. */
    var assessorNames = asVisitAssessorNames(assessment);

    var patchComponent = function (key, p) {
        store.updateComponent(assessment.id, room.id, key, p);
    };

    var defs = asrComponentDefs();
    var openDef = openKey ? defs[openKey] : null;

    /** asrPhotosOf — the component's photos as {id, componentKey, scan}. A scan
     *  the user has since deleted still lists (the component recorded it) but
     *  renders as a placeholder rather than vanishing silently. */
    var photosOf = function (comp) {
        var ids = asComponentPhotoIds(comp);
        var out = [];
        for (var i = 0; i < ids.length; i++) {
            var found = null;
            for (var s = 0; s < scans.length; s++) {
                if (scans[s].id === ids[i]) found = scans[s];
            }
            out.push({ id: ids[i], componentKey: comp.key, scan: found });
        }
        return out;
    };

    // Every photo in the room, for the sample sheet's optional photo picker.
    var roomPhotos = [];
    for (var pc = 0; pc < room.components.length; pc++) {
        roomPhotos = roomPhotos.concat(photosOf(room.components[pc]));
    }
    var roomPhotoCount = asRoomPhotoCount(room);
    // Section state (D9), derived: every primary assessed or marked absent.
    var roomProgress = asRoomProgress(room);

    // The components actually present in this room, for the sample sheet's
    // optional component picker — never the full nine, because a sample cannot
    // come from a component the inspector has said is not here.
    var presentComponents = [];
    for (var ac = 0; ac < AS_COMPONENT_KEYS.length; ac++) {
        var key = AS_COMPONENT_KEYS[ac];
        var comp = asrComponentOf(room, key);
        if (comp.present === true && defs[key]) presentComponents.push({ key: key, label: defs[key].label });
    }

    var roomSamples = [];
    for (var sm = 0; sm < assessment.samples.length; sm++) {
        if (assessment.samples[sm].roomId === room.id) roomSamples.push(assessment.samples[sm]);
    }
    var pendingSamples = 0;
    for (var ps = 0; ps < roomSamples.length; ps++) {
        if (!roomSamples[ps].result) pendingSamples += 1;
    }

    var resultSample = null;
    for (var rs = 0; rs < roomSamples.length; rs++) {
        if (roomSamples[rs].id === resultForId) resultSample = roomSamples[rs];
    }

    /** photoItemsOf — the sheet's photo cards: one per picture (uploaded or
     *  scan-flow), each with its scan record when it has one, in the order
     *  they were taken. */
    var photoItemsOf = function (comp) {
        var items = asComponentPhotoItems(comp);
        var out = [];
        for (var i = 0; i < items.length; i++) {
            var found = null;
            for (var s2 = 0; s2 < scans.length; s2++) {
                if (items[i].scanId && scans[s2].id === items[i].scanId) found = scans[s2];
            }
            var at = items[i].createdAt ? Date.parse(items[i].createdAt) : (found && found.timestamp ? Number(found.timestamp) : 0);
            out.push({
                key: items[i].photoId || ('scan-' + items[i].scanId),
                photoId: items[i].photoId,
                scanId: items[i].scanId,
                scan: found,
                at: isFinite(at) ? at : 0
            });
        }
        out.sort(function (a, b) { return a.at - b.at; });
        return out;
    };

    /** uploadPhoto — "Add photo" (App review 2026-10-05): store ANY photo on
     *  the component without scanning it. Image first (its own key), metadata
     *  second, so the aggregate never names a picture that is not stored.
     *  Rejects with a plain-English message; the sheet shows it. */
    var uploadPhoto = function (componentKey, file) {
        var photoId = asGenerateId();
        var createdAt = new Date().toISOString();
        return asPrepareComponentPhoto(file).then(function (prepared) {
            return StorageService.saveComponentPhoto(photoId, {
                v: 1, id: photoId, assessmentId: assessment.id, roomId: room.id, componentKey: componentKey,
                createdAt: createdAt, mime: prepared.mime, dataURL: prepared.dataURL,
                thumbnail: prepared.thumbnail, exif: prepared.exif
            }).then(function () {
                var written = store.addComponentPhoto(assessment.id, room.id, componentKey,
                    { id: photoId, createdAt: createdAt, mime: prepared.mime });
                if (!written) throw new Error('Could not add this photo. Please try again.');
            }, function () {
                throw new Error('Could not store this photo on the device. Please try again.');
            });
        });
    };

    /**
     * deletePhoto — the confirmed delete of one photo card (2026-10-05).
     *
     * Order matters: the AGGREGATE first (attachment gone + tombstone, stamped,
     * so a completed visit's change log reads "Photos 2 → 1"), then the local
     * image key, then the cloud objects. The cloud delete is queued in the sync
     * state and attempted now; offline or signed out, the next backup pass
     * retries it, and the tombstone stops a restore resurrecting it meanwhile.
     * A scan linked to the photo is NOT deleted — it stays in scan history.
     */
    var deletePhoto = function (componentKey, item) {
        var written = store.removeComponentPhoto(assessment.id, room.id, componentKey,
            { photoId: item.photoId || null, scanId: item.scanId || null });
        if (!written) {
            store.announce('Couldn\u2019t delete the photo. Please try again.');
            return;
        }
        if (item.photoId) {
            Promise.resolve(StorageService.removeComponentPhoto(item.photoId))['catch'](function (err) {
                console.warn('AssessmentRoomPage: local photo image not removed (orphan key):', err);
            });
            if (typeof SyncService !== 'undefined' && SyncService.deleteComponentPhoto) {
                SyncService.deleteComponentPhoto(item.photoId);
            }
            store.announce('Photo deleted');
        } else {
            store.announce('Photo removed from this component. The scan is still in your scan history.');
        }
    };

    /** scanPhoto — "Scan for mould" on an uploaded photo: park the capture
     *  intent WITH the photo's id and hand over to the ordinary scan flow,
     *  which loads that image, scans it, and links the scan back. */
    var scanPhoto = function (componentKey, photoId) {
        var ref = store.startCapture(assessment.id, room.id, componentKey, photoId);
        if (!ref) return;
        setOpenKey(null);
        navigate('/analysis');
    };

    /**
     * saveResult — record the lab result, then mirror a named genus onto the
     * linked scan in the EXACT shape GenusFeedback writes (brief §3).
     *
     * Order matters and so does the try/catch: the sample result is the
     * inspector's record and is written first and unconditionally; the genus
     * write-through is a bonus for the classifier's ground-truth set and must
     * never be able to cost them the result they just typed.
     */
    var saveResult = function (sample, result) {
        store.setSampleResult(assessment.id, sample.id, result);
        setResultForId(null);
        try {
            var confirmation = asGenusConfirmationFromSample(sample, result);
            if (confirmation) {
                scanStore.updateScan(sample.scanId, { genusLabConfirmation: confirmation });
            }
        } catch (err) {
            console.warn('AssessmentRoomPage: genus write-through failed (the lab result is saved):', err);
        }
    };

    return (
        <Layout>
            <main data-testid="assessment-room" className="flex-1 w-full mx-auto pb-36 overflow-y-auto overflow-x-hidden">
                <PageHeader title="Room" showMenu={true} backColor="text-forest" />

                <section className="px-5 pt-4 space-y-4">
                    {/* Name + orientation. Which way a room faces drives how it
                        dries, so it sits with the name, not in a sub-sheet. */}
                    <div className="bio-bg bio-bg-10 rounded-xl p-5 shadow-soft border border-stone-100/50 space-y-4">
                        <div>
                            {/* Save status shares the LABEL row, right-aligned, so its
                                changing text ("Saving…" / "Saved just now") never
                                resizes the field below (Rozario, 29 Sep). */}
                            <div className="flex items-center justify-between gap-3 min-h-[24px]">
                                <label className={ASR_LABEL_CLS}>Room name</label>
                                <div className="shrink-0"><AssessmentSavedChip updatedAt={assessment.updatedAt} /></div>
                            </div>
                            {/* The shared room list (AppConstants.ROOM_TYPES) as
                                suggestions, same Typeahead as the property page and
                                scan editor; free text still saves ("Laundry 1"). */}
                            <div className="mt-1">
                                <Typeahead
                                    allowCustom
                                    testId="room-name"
                                    options={AppConstants.ROOM_TYPES}
                                    inputClassName={ASR_INPUT_CLS}
                                    placeholder="e.g. Main Bedroom"
                                    value={room.name}
                                    onChange={function (name) { patch('name', name); }}
                                />
                            </div>
                            {room.name === 'Other' && (
                                <p data-testid="room-name-other-hint" className="mt-2 px-1 text-xs font-semibold text-forest leading-relaxed">
                                    Not on our list? Type what this area is — e.g. Utility Room or Study — so the report names it.
                                </p>
                            )}
                        </div>

                        {/* Assessed by (slice 5). A reference, not a copy: the
                            empty option stores `null`, which resolves to the
                            visit lead at read time — so correcting the lead's
                            name in the header corrects every room that follows
                            them. The list is the lead plus the visit team. */}
                        <div>
                            <label className={ASR_LABEL_CLS}>Assessed by</label>
                            <select
                                data-testid="room-assessor"
                                className={ASR_INPUT_CLS + ' appearance-none mt-1'}
                                value={room.assessorName || ''}
                                onChange={function (e) { patch('assessorName', e.target.value); }}
                            >
                                <option value="">
                                    {assessorNames.length ? 'Lead — ' + assessorNames[0] : 'The visit lead'}
                                </option>
                                {assessorNames.map(function (name) {
                                    return <option key={name} value={name}>{name}</option>;
                                })}
                            </select>
                        </div>

                        <div>
                            <label className={ASR_LABEL_CLS}>Which way it faces</label>
                            <ASR_Compass
                                value={room.direction}
                                onChange={function (dir) { patch('direction', dir); }}
                            />
                        </div>
                    </div>

                    {/* Odour — the DMAT 0–3 scale, in plain words. */}
                    <div className="bio-bg bio-bg-20 rounded-xl p-5 shadow-soft border border-stone-100/50 space-y-3">
                        <label className={ASR_LABEL_CLS}>Mould odour</label>
                        <div className="grid grid-cols-4 gap-2">
                            {ASR_ODOUR_STEPS.map(function (step) {
                                var selected = room.odour === step.value;
                                return (
                                    <button
                                        key={step.value}
                                        data-testid={'room-odour-' + step.value}
                                        aria-pressed={selected ? 'true' : 'false'}
                                        onClick={function () { patch('odour', step.value); }}
                                        className={'flex flex-col items-center gap-1 py-2.5 rounded-xl text-[11px] font-black btn-nature transition-all ' +
                                            (selected ? 'bg-forest text-white shadow-clay' : 'bg-surface border border-stone-100/50 text-forest shadow-soft hover:bg-forest hover:text-white')}
                                    >
                                        <span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: step.hex }}></span>
                                        {step.label}
                                    </button>
                                );
                            })}
                        </div>

                        {room.odour > 0 && (
                            <div className="flex flex-col gap-1 pt-1">
                                <label className={ASR_LABEL_CLS}>Where is it coming from?</label>
                                <input
                                    type="text"
                                    data-testid="room-odour-source"
                                    className={ASR_INPUT_CLS}
                                    placeholder="e.g. behind the wardrobe"
                                    value={room.odourSource}
                                    onChange={function (e) { patch('odourSource', e.target.value); }}
                                />
                            </div>
                        )}
                    </div>

                    {/* Ventilation. */}
                    <div className="bio-bg bio-bg-30 rounded-xl p-5 shadow-soft border border-stone-100/50 space-y-2">
                        <label className={ASR_LABEL_CLS}>Ventilation</label>
                        <select
                            data-testid="room-ventilation"
                            className={ASR_INPUT_CLS + ' appearance-none'}
                            value={room.ventilation}
                            onChange={function (e) { patch('ventilation', e.target.value); }}
                        >
                            <option value="">Not assessed</option>
                            {AS_VENTILATION.map(function (v) { return <option key={v} value={v}>{v}</option>; })}
                        </select>
                    </div>

                    {/* Components — stacked rows, one per DMAT component (§5.3).
                        The source app's grid is a desktop artefact; at 390 px it
                        becomes a list whose detail lives behind a sheet. */}
                    <div className="bio-bg bio-bg-40 rounded-xl p-5 shadow-soft border border-stone-100/50 space-y-3">
                        <div className="flex items-center justify-between gap-2">
                            <div className="flex items-center gap-2 min-w-0">
                                <h3 className="text-sm font-extrabold text-forest">Components</h3>
                                <AssessmentStateChip testid="room-progress" state={roomProgress.state} label={roomProgress.label} />
                            </div>
                            <span className="text-[10px] font-black text-forest uppercase tracking-widest text-right">
                                <span data-testid="room-components-scored">{asRoomScored(room)}/{AS_ROOM_COMPONENT_TARGET} scored</span>
                                {' · '}
                                <span data-testid="room-photo-count">{roomPhotoCount} photo{roomPhotoCount !== 1 ? 's' : ''}</span>
                            </span>
                        </div>

                        {AS_PRIMARY_COMPONENT_KEYS.map(function (key) {
                            var def = defs[key];
                            if (!def) return null;
                            return (
                                <ASR_ComponentRow
                                    key={key}
                                    def={def}
                                    comp={asrComponentOf(room, key)}
                                    onOpen={function () { setOpenKey(key); }}
                                    onPatch={function (p) { patchComponent(key, p); }}
                                />
                            );
                        })}

                        {/* Subfloor is the ninth key and only exists where one
                            was inspected — collapsed, not present by default, and
                            never part of the n/8 above. */}
                        <div className="pt-1">
                            <button
                                data-testid="subfloor-toggle"
                                aria-expanded={subfloorOpen ? 'true' : 'false'}
                                onClick={function () { setSubfloorOpen(!subfloorOpen); }}
                                className="w-full flex items-center justify-between gap-2 bg-surface rounded-xl px-3 py-2.5 shadow-soft border border-stone-100/50 btn-nature text-left"
                            >
                                <span className="text-xs font-extrabold text-forest">Subfloor (if inspected)</span>
                                <span className="material-symbols-outlined text-forest text-base">{subfloorOpen ? 'expand_less' : 'expand_more'}</span>
                            </button>
                            {subfloorOpen && defs.subfloor && (
                                <div className="mt-2">
                                    <ASR_ComponentRow
                                        def={defs.subfloor}
                                        comp={asrComponentOf(room, 'subfloor')}
                                        onOpen={function () { setOpenKey('subfloor'); }}
                                        onPatch={function (p) { patchComponent('subfloor', p); }}
                                    />
                                </div>
                            )}
                        </div>

                    </div>

                    {/* Samples — lab tests taken in this room (slice 3). They sit
                        on the ROOM, beneath the components, because that is where
                        they are taken and because a result arriving three days
                        later has to be findable without re-walking the house. */}
                    <div className="bio-bg bio-bg-20 rounded-xl p-5 shadow-soft border border-stone-100/50 space-y-3">
                        <div className="flex items-center justify-between gap-2">
                            <h3 className="text-sm font-extrabold text-forest">Samples</h3>
                            <span data-testid="room-samples-count" className="text-[10px] font-black text-forest uppercase tracking-widest">
                                {roomSamples.length} sample{roomSamples.length !== 1 ? 's' : ''}
                                {pendingSamples > 0 ? ' · ' + pendingSamples + ' pending' : ''}
                            </span>
                        </div>

                        {roomSamples.length === 0 && (
                            <p className="text-xs font-medium text-forest/70 leading-relaxed">
                                Nothing sent to a lab from this room yet. Record a sample when you take it — the result can be added whenever it lands.
                            </p>
                        )}

                        {roomSamples.map(function (sample) {
                            return (
                                <ASR_SampleRow
                                    key={sample.id}
                                    sample={sample}
                                    focused={focusSampleId === sample.id}
                                    onResult={function () { setResultForId(sample.id); }}
                                    onRemove={function () { store.removeSample(assessment.id, sample.id); }}
                                />
                            );
                        })}

                        <button
                            data-testid="add-sample"
                            onClick={function () { setSampleSheetOpen(true); }}
                            className="w-full bg-forest text-white font-extrabold py-3 rounded-xl shadow-clay flex items-center justify-center gap-2 btn-nature hover:brightness-110 transition-all"
                        >
                            <span className="material-symbols-outlined text-lg">science</span>
                            Add sample
                        </button>
                    </div>

                    <button
                        onClick={function () { navigate('/assessment/' + assessment.id); }}
                        className="w-full bg-forest text-white font-extrabold py-4 rounded-xl shadow-clay flex items-center justify-center gap-2 btn-nature hover:brightness-110 transition-all mb-8"
                    >
                        <span className="material-symbols-outlined">check_circle</span>
                        {/* Autosave already persisted every edit; the label says so
                            (Rozario's review, 5 Oct: "there are no options to save"). */}
                        Save and back to the visit
                    </button>
                    <p className="-mt-6 mb-8 text-center text-xs font-bold text-forest">
                        Every change saves as you go.
                    </p>
                </section>

                {openDef && (
                    <ASR_ComponentSheet
                        def={openDef}
                        comp={asrComponentOf(room, openDef.key)}
                        photos={photoItemsOf(asrComponentOf(room, openDef.key))}
                        onPatch={function (p) { patchComponent(openDef.key, p); }}
                        onUploadPhoto={function (file) { return uploadPhoto(openDef.key, file); }}
                        onScanPhoto={function (photoId) { scanPhoto(openDef.key, photoId); }}
                        onOpenPhoto={function (scanId) { navigate('/scan/' + scanId); }}
                        onDeletePhoto={function (item) { deletePhoto(openDef.key, item); }}
                        onClose={function () { setOpenKey(null); }}
                    />
                )}

                {sampleSheetOpen && (
                    <ASR_SampleSheet
                        components={presentComponents}
                        photos={roomPhotos}
                        onClose={function () { setSampleSheetOpen(false); }}
                        onSave={function (data) {
                            data.roomId = room.id;
                            store.addSample(assessment.id, data);
                            setSampleSheetOpen(false);
                        }}
                    />
                )}

                {resultSample && (
                    <ASR_ResultSheet
                        key={resultSample.id}
                        sample={resultSample}
                        onClose={function () { setResultForId(null); }}
                        onSave={function (result) { saveResult(resultSample, result); }}
                    />
                )}
            </main>
        </Layout>
    );
};

window.AssessmentRoomPage = AssessmentRoomPage;
