diff --git a/backend/slabs.py b/backend/slabs.py index d4e2f1d..81d3cac 100644 --- a/backend/slabs.py +++ b/backend/slabs.py @@ -13,6 +13,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse, Response from .db import get_connection +from .thickness_band import load_thickness_terms, resolve_thickness_band_id router = APIRouter(prefix="/api/v1/slabs") @@ -133,6 +134,12 @@ async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]: conn = get_connection() if payload["sku"] is not None and _sku_taken(conn, payload["sku"]): return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409) + # METADATA-15 / QUALITY-95: the thickness band is derived, never owner-chosen. + # Resolve it from the measured thickness against the live synced Woo bands and + # overwrite whatever the client sent (the UI has no band control). + band = resolve_thickness_band_id(payload.get("thickness_in"), load_thickness_terms(conn)) + if band is not None: + payload = {**payload, "thickness_term_id": band} now = _now_iso() data = _encode(payload) allowed_cols = [f for f in ALLOWED_FIELDS if f in data] @@ -203,6 +210,10 @@ async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]: live = [val] # single-id field: just show what was submitted return _err("stale_field", f"{field} not in current taxonomy", {"field": field, "submitted": val, "current_options": live}, status=422) + eff_thickness = payload.get("thickness_in", current["thickness_in"]) + band = resolve_thickness_band_id(eff_thickness, load_thickness_terms(conn)) + if band is not None: + payload = {**payload, "thickness_term_id": band} data = _encode(payload) cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data] if not cols: diff --git a/backend/tests/test_slabs_api.py b/backend/tests/test_slabs_api.py index 4567e0f..c4577e4 100644 --- a/backend/tests/test_slabs_api.py +++ b/backend/tests/test_slabs_api.py @@ -33,6 +33,23 @@ def _seed_taxonomy(conn): # tags conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)") conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)") + # Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the + # live Woo attribute, because the derivation parses them. + conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)") + for tid, name, slug in [ + (347, 'Under 1/2"', 'xthin'), + (348, '1/2" - 3/4"', '2-3qtr'), + (349, '3/4" - 1"', '3-4qtr'), + (350, '1"- 1 1/2"', '4-6qtr'), + (351, '1 1/2" - 2"', '6-8qtr'), + (352, '2" - 3"', '8-12qtr'), + (353, 'Over 3"', '12qtr'), + ]: + conn.execute( + "INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) " + "VALUES (?, 2, ?, ?, ?)", + (tid, name, slug, tid), + ) conn.commit() @pytest.fixture @@ -103,6 +120,55 @@ def test_create_and_fetch(client, db): assert body[key] == payload[key] +def test_create_derives_thickness_band(client, db): + """QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one. + + The client-supplied thickness_term_id (1, an Edge Type term) must be ignored — + the UI has no band control, so the server's derivation is authoritative. + """ + slab_id = str(uuid.uuid4()) + payload = { + "id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20, + "bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, + "thickness_term_id": 1, + } + resp = client.post("/api/v1/slabs", json=payload) + assert resp.status_code == 201 + assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1 + + +def test_update_rebands_when_thickness_changes(client, db): + """A thickness edit moves the slab to the band the new measurement rounds into.""" + slab_id = str(uuid.uuid4()) + create = client.post("/api/v1/slabs", json={ + "id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20, + "bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, + }) + assert create.status_code == 201 + assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4" + put = client.put(f"/api/v1/slabs/{slab_id}", json={ + "client_rev": create.json()["server_rev"], "thickness_in": 2.75, + }) + assert put.status_code == 200 + assert put.json()["thickness_term_id"] == 352 # 2" - 3" + + +def test_update_ignores_client_supplied_band(client, db): + """A stale client band cannot pin the slab to the wrong band.""" + slab_id = str(uuid.uuid4()) + create = client.post("/api/v1/slabs", json={ + "id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20, + "bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, + }) + assert create.status_code == 201 + assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2" + put = client.put(f"/api/v1/slabs/{slab_id}", json={ + "client_rev": create.json()["server_rev"], "thickness_term_id": 347, + }) + assert put.status_code == 200 + assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band + + def test_duplicate_sku(client, db): slab_id = str(uuid.uuid4()) payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None} diff --git a/backend/tests/test_thickness_band.py b/backend/tests/test_thickness_band.py new file mode 100644 index 0000000..972422d --- /dev/null +++ b/backend/tests/test_thickness_band.py @@ -0,0 +1,94 @@ +"""Thickness band resolution (METADATA-15 / QUALITY-95). + +The owner never picks the Woo thickness band in the UI — the server derives it +from the measured thickness_in against the live synced bands. These tests pin the +derivation on the real live band names (from the UAT taxonomy) and on the +quarter-inch slug fallback. +""" + +import pytest + +from backend.thickness_band import ( + parse_inch_token, + resolve_thickness_band_id, + term_bounds, +) + +# The live UAT Thickness attribute (pa_thk) as synced: name, slug. +LIVE_TERMS = [ + {"id": 347, "name": "Under 1/2\"", "slug": "xthin"}, + {"id": 348, "name": "1/2\" - 3/4\"", "slug": "2-3qtr"}, + {"id": 349, "name": "3/4\" - 1\"", "slug": "3-4qtr"}, + {"id": 350, "name": "1\"- 1 1/2\"", "slug": "4-6qtr"}, + {"id": 351, "name": "1 1/2\" - 2\"", "slug": "6-8qtr"}, + {"id": 352, "name": "2\" - 3\"", "slug": "8-12qtr"}, + {"id": 353, "name": "Over 3\"", "slug": "12qtr"}, +] + + +@pytest.mark.parametrize( + "text,expected", + [ + ("1 1/2", 1.5), + ("3/4", 0.75), + ("2", 2.0), + ("1.75", 1.75), + ("2\"", 2.0), + ("1 1/2\"", 1.5), + ], +) +def test_parse_inch_token(text, expected): + assert parse_inch_token(text) == pytest.approx(expected) + + +def test_parse_inch_token_rejects_junk(): + assert parse_inch_token("") is None + assert parse_inch_token(" ") is None + + +def test_term_bounds_from_live_names(): + assert term_bounds(LIVE_TERMS[0]) == (0.0, 0.5) + assert term_bounds(LIVE_TERMS[1]) == (0.5, 0.75) + assert term_bounds(LIVE_TERMS[3]) == (1.0, 1.5) + assert term_bounds(LIVE_TERMS[6]) == (3.0, float("inf")) + + +def test_term_bounds_falls_back_to_qtr_slug(): + assert term_bounds({"id": 9, "name": "weird", "slug": "4-6qtr"}) == (1.0, 1.5) + assert term_bounds({"id": 9, "name": "weird", "slug": "12qtr"}) == (3.0, float("inf")) + assert term_bounds({"id": 9, "name": "weird", "slug": "xthin"}) == (0.0, 0.5) + + +@pytest.mark.parametrize( + "thickness,expected_id", + [ + (0.25, 347), # Under 1/2" + (0.5, 348), # boundary rounds UP into 1/2"-3/4", not down + (0.625, 348), + (0.75, 349), # boundary rounds UP + (0.875, 349), + (1.0, 350), # QUALITY-95: 1 inch rounds up into 1"-1 1/2", not a hardcoded band + (1.25, 350), + (1.5, 351), + (1.75, 351), # the UAT sample thickness + (2.0, 352), + (2.75, 352), + (3.0, 353), + (4.0, 353), + ], +) +def test_resolve_rounds_up_on_boundary(thickness, expected_id): + assert resolve_thickness_band_id(thickness, LIVE_TERMS) == expected_id + + +def test_resolve_handles_missing_inputs(): + assert resolve_thickness_band_id(None, LIVE_TERMS) is None + assert resolve_thickness_band_id(-1, LIVE_TERMS) is None + assert resolve_thickness_band_id(1.0, []) is None + # A term with no parseable name/slug is skipped, never guessed at. + assert resolve_thickness_band_id(1.0, [{"id": 1, "name": "mystery", "slug": "mystery"}]) is None + + +def test_resolve_is_id_order_independent(): + """The result must not depend on the sync order of the terms.""" + assert resolve_thickness_band_id(1.75, list(reversed(LIVE_TERMS))) == 351 diff --git a/backend/thickness_band.py b/backend/thickness_band.py new file mode 100644 index 0000000..75de5fa --- /dev/null +++ b/backend/thickness_band.py @@ -0,0 +1,177 @@ +"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95). + +The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a +synced band term. We map thickness_in into the first half-open band [low, high) +that contains it, derived only from the live synced term names/slugs — never a +hardcoded Woo term id. + +Round-up semantics: bands are half-open on the upper bound, so a value that sits +on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that +value (1"–1 1/2"), not the band that ends there (3/4"–1"). +""" + +from __future__ import annotations + +import re +from typing import Any, Iterable, Optional + +# Mixed number / fraction / decimal inch token, optional trailing quote. +_INCH_RE = re.compile( + r"(?P\d+)\s+(?P\d+)\s*/\s*(?P\d+)" # 1 1/2 + r"|(?P\d+)\s*/\s*(?P\d+)" # 1/2 + r"|(?P\d+(?:\.\d+)?)", # 1 or 1.5 + re.IGNORECASE, +) +_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE) +_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE) + + +def parse_inch_token(text: str) -> Optional[float]: + """Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches.""" + t = (text or "").strip().rstrip("\"'").strip() + if not t: + return None + m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t) + if not m: + return None + if m.group("a") is not None: + return float(m.group("a")) + float(m.group("n")) / float(m.group("d")) + if m.group("n2") is not None: + return float(m.group("n2")) / float(m.group("d2")) + if m.group("dec") is not None: + return float(m.group("dec")) + return None + + +def _bounds_from_name(name: str) -> Optional[tuple[float, float]]: + """Return half-open [low, high) inches from a Woo thickness term name.""" + raw = (name or "").strip() + low_s = raw.lower() + if not raw: + return None + + # Under X / less than X / < X + m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE) + if m: + hi = parse_inch_token(m.group(1)) + if hi is not None: + return (0.0, hi) + + # Over X / above X / > X / X+ + m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE) + if m: + lo = parse_inch_token(m.group(1)) + if lo is not None: + return (lo, float("inf")) + m = re.match(r"^(.+?)\+\s*$", raw) + if m: + lo = parse_inch_token(m.group(1)) + if lo is not None: + return (lo, float("inf")) + + # Range: "A - B" / "A–B" / "A- B" (quotes optional on either side) + parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1) + if len(parts) == 2: + lo = parse_inch_token(parts[0]) + hi = parse_inch_token(parts[1]) + if lo is not None and hi is not None and hi > lo: + return (lo, hi) + + # Single value band treated as [v, v+epsilon) — rare + only = parse_inch_token(raw) + if only is not None: + return (only, only + 1e-9) + return None + + +def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]: + """Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'.""" + s = (slug or "").strip().lower() + if not s: + return None + if s in {"xthin", "under", "thin"}: + return (0.0, 0.5) + m = _QTR_RANGE_RE.match(s) + if m: + lo = int(m.group(1)) * 0.25 + hi = int(m.group(2)) * 0.25 + if hi > lo: + return (lo, hi) + m = _QTR_MIN_RE.match(s) + if m: + lo = int(m.group(1)) * 0.25 + return (lo, float("inf")) + return None + + +def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]: + """Bounds for one synced thickness term; name first, slug fallback.""" + return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug( + str(term.get("slug") or "") + ) + + +def resolve_thickness_band_id( + thickness_in: float | int | None, + terms: Iterable[dict[str, Any]], +) -> Optional[int]: + """Pick the synced thickness term id for thickness_in, or None if unknown. + + terms: iterable of {id, name, slug?} from the live Woo thickness attribute. + """ + if thickness_in is None: + return None + try: + t = float(thickness_in) + except (TypeError, ValueError): + return None + if t < 0: + return None + + scored: list[tuple[float, float, int]] = [] # (low, high, id) + for term in terms: + b = term_bounds(term) + if b is None: + continue + tid = term.get("id") + if tid is None: + continue + lo, hi = b + scored.append((lo, hi, int(tid))) + + if not scored: + return None + + # Prefer the tightest band that contains t under half-open [low, high). + matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi] + if matches: + matches.sort() # tightest span first + return matches[0][3] + + # If nothing matched (gaps in taxonomy), pick nearest band by lower edge. + scored.sort(key=lambda x: x[0]) + for lo, hi, tid in scored: + if t < hi: + return tid + return scored[-1][2] + + +def load_thickness_terms(conn) -> list[dict[str, Any]]: + """Live Thickness attribute terms from the synced Woo cache.""" + row = conn.execute( + "SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?", + ("thickness", "thickness"), + ).fetchone() + if row is None: + # Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open) + row = conn.execute( + "SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'" + ).fetchone() + if row is None: + return [] + attr_id = row[0] + rows = conn.execute( + "SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id", + (attr_id,), + ).fetchall() + return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows] diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index d2ff6a1..4f962bb 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -199,7 +199,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read | QUALITY-80 | Pipeline vector TV-6: 3:4 crop, 90 percent fill at extremes, transparent PNG, shorter side at least 800 when the source allows. | shipped | — | | QUALITY-85 | Pipeline vector TV-7: an undersized source warns, is not upscaled, and is not publishable until retake. | shipped | — | | QUALITY-90 | Pipeline vector TV-8: bdft 0.96 at 12.50 per bdft gives a 12.00 recommendation, and an override of 150.00 is stored. | M3 | — | -| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | — | +| QUALITY-95 | Pipeline vector TV-9: a 1 inch thickness rounds up into the synced Woo thickness band, not a hardcoded one. | M3 | backend/tests/test_thickness_band.py | | QUALITY-100 | Pipeline vector TV-10: half-up 2 decimal rounding, including 96.04 to 96, 0.641 to 0.64, 11.525 to 11.53, 12.345 to 12.35. | shipped | — | | QUALITY-105 | Dishonest proxies that do not count as pass: hardcoded or fixture taxonomy treated as synced, a Woo mock that never hits the real store for the exit walk, unit tests alone without a real SLAB-UAT-* draft, consumer-key auth, inference wired as dead config or required for create, claiming ready with empty fields, or parking work on the wrong branch. | M3 | — | | QUALITY-110 | The M3 exit evidence is a real SLAB-UAT-* Woo draft matching the reviewed ready set with inference off, plus test-woo and test-inference results. | M3 | — | diff --git a/docs/UAT-PRACTICE.md b/docs/UAT-PRACTICE.md index 21b0878..078804c 100644 --- a/docs/UAT-PRACTICE.md +++ b/docs/UAT-PRACTICE.md @@ -121,7 +121,9 @@ hard stop. Switch to a supported browser. 3. **Wood categories.** Pick at least one from the synced list. 4. **Edge type, figure, grade.** Pick from the synced lists. One edge type, at least one figure, exactly one grade. -5. **Thickness band.** Pick the Woo band that the measured thickness rounds up into. +5. **Thickness band.** Derived, not picked: the server bands the measured thickness up into + the synced Woo band and Review shows it read-only. Verify the shown band is the one the + captured thickness rounds up into (`QUALITY-95`). 6. **Moisture.** Keep the default (kiln-dried) or change it. 7. **fig-* tags.** Optional; pick any from the synced list. 8. **feat-* tags.** Zero or more. Only pick existing `feat-*` tags. diff --git a/docs/contracts/UX.md b/docs/contracts/UX.md index 8881014..f297846 100644 --- a/docs/contracts/UX.md +++ b/docs/contracts/UX.md @@ -12,7 +12,7 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a | CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake | | CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers | | CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure | -| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality | +| METADATA-10, METADATA-15, METADATA-30, QUALITY-95 | required taxonomy fields and cardinality; the thickness band is derived from the measurement, not picked | | METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability | | PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging | | REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits | @@ -59,6 +59,9 @@ Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45. optional; price; title, short_description, and description. - **Primary category.** The owner declares one primary wood category and any secondary ones; the default is the first selected. +- **Thickness band is derived, not picked.** The measured thickness bands up into the synced Woo + band server-side (`METADATA-15`, `QUALITY-95`); Review shows the assigned band read-only and + offers no owner control for it. - **Inline nudges.** Every empty required field shows an inline nudge as the owner works; there is no single submit-time gap list. - **Calculated fields.** Widths, sqft, and bdft are read-only and not overridable. diff --git a/frontend/package.json b/frontend/package.json index 8c31b89..6e077c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "test": "node --import tsx --test src/**/*.test.ts", + "test": "node --import tsx --test --test-reporter=spec src/**/*.test.ts", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed" }, diff --git a/frontend/src/create/review/MultiSelect.svelte b/frontend/src/create/review/MultiSelect.svelte index 7f818ed..d59fd3b 100644 --- a/frontend/src/create/review/MultiSelect.svelte +++ b/frontend/src/create/review/MultiSelect.svelte @@ -9,7 +9,11 @@ // (METADATA-10: "parents group only and are never assigned"); only the // leaf rows are selectable. Filtering hides a parent group when none of // its leaves match. + // + // #90 closed state shows selected NAMES (not only a count). Done dispatches + // `commit` so the parent can persist + re-review. import { createEventDispatcher } from 'svelte'; + import { selectedSummary } from './woodPrimary'; export interface Opt { id: number; name: string; ai: boolean } export interface Group { parent: { id: number; name: string }; leaves: Opt[] } @@ -33,6 +37,7 @@ // Effective selectable set (leaves when grouped, options when flat). $: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[]; + $: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string])); $: q = filter.trim().toLowerCase(); $: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts; // Grouped: keep a parent only if at least one of its leaves survives the filter. @@ -42,6 +47,7 @@ .filter((g) => g.leaves.length > 0) : []; $: count = selected.length; + $: summary = selectedSummary(selected, nameById, placeholder); $: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id)); function toggle(id: number) { @@ -55,13 +61,17 @@ for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id); } function close() { open = false; filter = ''; dispatch('commit'); } - function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; } + function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) { open = false; filter = ''; dispatch('commit'); } } + // Rows are focusable options (role=option): Enter/Space toggles, matching click. + function onRowKey(e: KeyboardEvent, id: number) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); } + } -
+
@@ -85,9 +95,11 @@ class="ms-row leaf" class:on={selected.includes(o.id)} role="option" + tabindex="0" aria-selected={selected.includes(o.id)} data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`} on:click={() => toggle(o.id)} + on:keydown={(e) => onRowKey(e, o.id)} > {o.name}{#if o.ai}AI{/if} @@ -104,9 +116,11 @@ class="ms-row" class:on={selected.includes(o.id)} role="option" + tabindex="0" aria-selected={selected.includes(o.id)} data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`} on:click={() => toggle(o.id)} + on:keydown={(e) => onRowKey(e, o.id)} > {o.name}{#if o.ai}AI{/if} @@ -127,8 +141,9 @@