fsanyoto commited on
Commit
05f25ff
Β·
verified Β·
1 Parent(s): c7eaf1d

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "bf8387b",
3
  "releases": [
4
  {
5
  "version": "v53",
 
1
  {
2
+ "current": "5d8aacc",
3
  "releases": [
4
  {
5
  "version": "v53",
VERSION CHANGED
@@ -1 +1 @@
1
- bf8387b
 
1
+ 5d8aacc
api/routes_odoo_tables.py CHANGED
@@ -831,6 +831,24 @@ def _json_arg(raw, what):
831
  return val
832
 
833
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
  @router.get("/odoo-tables/{table_key}/rows")
835
  def odoo_table_rows(table_key: str,
836
  offset: int = Query(default=0, ge=0),
@@ -839,6 +857,7 @@ def odoo_table_rows(table_key: str,
839
  filterConj: str = Query(default="and"),
840
  sorts: str = Query(default=None),
841
  search: str = Query(default=None),
 
842
  session: Session = Depends(require_session)):
843
  """ONE WINDOW over a connected grid, read straight from the mirror (contract C2).
844
 
@@ -985,6 +1004,10 @@ def odoo_table_rows(table_key: str,
985
  base_where = (f'({spec["where"]}) AND {wall_sql}'
986
  if (spec["where"] and wall_sql) else (wall_sql or spec["where"]))
987
  limits, tree = [], _json_arg(filters, "filters")
 
 
 
 
988
  sort_spec = _json_arg(sorts, "sorts") or []
989
 
990
  # R6's SECOND SENTENCE, ON THE WALL ITSELF. The precision note further down covers the
@@ -1046,7 +1069,7 @@ def odoo_table_rows(table_key: str,
1046
  "recommendation": "compare against a whole number, or use a range that does not sit "
1047
  "on a fractional boundary"})
1048
 
1049
- where, params = base_where, list(wall_params)
1050
  try:
1051
  pred = _fs().compile_filter_tree(
1052
  tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols,
@@ -1060,13 +1083,25 @@ def odoo_table_rows(table_key: str,
1060
  raise err(400, "filter_unsupported",
1061
  "a condition on an aggregate column belongs in HAVING, and that path is "
1062
  "deliberately not built for windowed grids")
1063
- where = f"({where}) AND {pred.sql}" if where else pred.sql
1064
- params.extend(pred.params)
1065
  if str(search or "").strip():
1066
  got = _fs().compile_search(search.strip(), cols)
1067
  if got is not None:
1068
- where = f"({where}) AND {got.sql}" if where else got.sql
1069
- params.extend(got.params)
 
 
 
 
 
 
 
 
 
 
 
 
1070
 
1071
  # ── the order, made TOTAL ─────────────────────────────────────────────────────────────────
1072
  # β›” `tiebreak_sql` is not optional here: without a total order, LIMIT/OFFSET may return one
 
831
  return val
832
 
833
 
834
+ def _clean_include_pids(raw):
835
+ """Bounded existing Link choices, or a named bad-argument refusal.
836
+
837
+ A picker condition narrows what a person may add. It never rewrites existing links, so a
838
+ read-through window carries those ids as an explicit exception. The normal table and row wall
839
+ remains outside that exception in `odoo_table_rows` below.
840
+ """
841
+ if raw is None:
842
+ return []
843
+ if not isinstance(raw, list):
844
+ raise ValueError("includePids must be a JSON list of record ids")
845
+ if len(raw) > 500:
846
+ raise ValueError("includePids may name at most 500 existing linked records")
847
+ if any(type(pid) is not int or pid <= 0 for pid in raw):
848
+ raise ValueError("includePids must contain positive integer record ids")
849
+ return sorted(set(raw))
850
+
851
+
852
  @router.get("/odoo-tables/{table_key}/rows")
853
  def odoo_table_rows(table_key: str,
854
  offset: int = Query(default=0, ge=0),
 
857
  filterConj: str = Query(default="and"),
858
  sorts: str = Query(default=None),
859
  search: str = Query(default=None),
860
+ includePids: str = Query(default=None),
861
  session: Session = Depends(require_session)):
862
  """ONE WINDOW over a connected grid, read straight from the mirror (contract C2).
863
 
 
1004
  base_where = (f'({spec["where"]}) AND {wall_sql}'
1005
  if (spec["where"] and wall_sql) else (wall_sql or spec["where"]))
1006
  limits, tree = [], _json_arg(filters, "filters")
1007
+ try:
1008
+ include_pids = _clean_include_pids(_json_arg(includePids, "includePids"))
1009
+ except ValueError as e:
1010
+ raise err(400, "bad_argument", str(e))
1011
  sort_spec = _json_arg(sorts, "sorts") or []
1012
 
1013
  # R6's SECOND SENTENCE, ON THE WALL ITSELF. The precision note further down covers the
 
1069
  "recommendation": "compare against a whole number, or use a range that does not sit "
1070
  "on a fractional boundary"})
1071
 
1072
+ predicate_sql, predicate_params = None, []
1073
  try:
1074
  pred = _fs().compile_filter_tree(
1075
  tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols,
 
1083
  raise err(400, "filter_unsupported",
1084
  "a condition on an aggregate column belongs in HAVING, and that path is "
1085
  "deliberately not built for windowed grids")
1086
+ predicate_sql = pred.sql
1087
+ predicate_params.extend(pred.params)
1088
  if str(search or "").strip():
1089
  got = _fs().compile_search(search.strip(), cols)
1090
  if got is not None:
1091
+ predicate_sql = (f"({predicate_sql}) AND {got.sql}"
1092
+ if predicate_sql else got.sql)
1093
+ predicate_params.extend(got.params)
1094
+
1095
+ # Existing choices survive a new picker condition, but the table's permanent wall stays
1096
+ # outside this OR. A caller cannot use an old link id to reveal a record the database wall
1097
+ # would otherwise deny.
1098
+ if include_pids and predicate_sql:
1099
+ slots = ", ".join("?" for _ in include_pids)
1100
+ predicate_sql = f"(({predicate_sql}) OR ({spec['id']} IN ({slots})))"
1101
+ predicate_params.extend(include_pids)
1102
+ where = (f"({base_where}) AND ({predicate_sql})" if base_where and predicate_sql
1103
+ else (predicate_sql or base_where))
1104
+ params = [*wall_params, *predicate_params]
1105
 
1106
  # ── the order, made TOTAL ─────────────────────────────────────────────────────────────────
1107
  # β›” `tiebreak_sql` is not optional here: without a total order, LIMIT/OFFSET may return one
api/routes_shares.py CHANGED
@@ -878,6 +878,39 @@ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
878
  }
879
 
880
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  def _people(tenant):
882
  """[{username, name}] for this tenant β€” same population as `assignable_people`, with the
883
  BINDING identity alongside the display one."""
 
878
  }
879
 
880
 
881
+ @router.delete("/share/field/{oid}/mine")
882
+ def remove_my_field_share(oid: str, session: Session = Depends(require_session)):
883
+ """Remove only this session's direct grant on one shared field.
884
+
885
+ This is deliberately not a shortened form of the replacing PUT route. A grantee may leave
886
+ a column shared to them, but may not submit a list that can revoke anybody else. A wildcard
887
+ grant is also not removable here: deleting it would remove the column from everybody.
888
+ """
889
+ table_key, field_key = shares.split_field_oid(oid)
890
+ if not table_key or not field_key:
891
+ raise err(404, "no_object", "no such column, or it is not shared with this account")
892
+ oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
893
+ rec = shares.grants("field", oid, st=session.runtime)
894
+ uname = str(session.uname or "").strip().lower()
895
+ direct = [entry for entry in rec["entries"] if entry.get("user") == uname]
896
+ if not direct:
897
+ if shares.role_for("field", oid, uname, is_admin=session.admin, st=session.runtime):
898
+ raise err(409, "not_direct_grant",
899
+ "this column is shared with everyone in this workspace, so there is no "
900
+ "personal grant to remove. Nothing was changed.")
901
+ raise err(404, "no_object", "no such column, or it is not shared with this account")
902
+ if shares.role_for("field", oid, uname, is_admin=session.admin, st=session.runtime) == "owner":
903
+ raise err(409, "owner_cannot_leave",
904
+ "you own this column. Reassign or delete it instead of removing yourself from it.")
905
+ kept = [entry for entry in rec["entries"] if entry.get("user") != uname]
906
+ try:
907
+ shares.set_grants("field", oid, kept, owner=rec["owner"], st=session.runtime,
908
+ expect=rec["entries"])
909
+ except shares.GrantsChanged as exc:
910
+ raise err(409, "grants_changed", str(exc))
911
+ return {"removed": True, "field": field_key}
912
+
913
+
914
  def _people(tenant):
915
  """[{username, name}] for this tenant β€” same population as `assignable_people`, with the
916
  BINDING identity alongside the display one."""
api/routes_tables.py CHANGED
@@ -2491,6 +2491,11 @@ def _with_dropped(ut, out, body):
2491
  def _refusal_sentence(ut, session, body, table_key="", fkey=""):
2492
  """Why was this column refused? The specific reason when we can name one, the general list
2493
  otherwise β€” never a specific-sounding guess."""
 
 
 
 
 
2494
  # ⭐ WAVE-34 (R13): the enrichment column's own sentence, named BEFORE the automation bag
2495
  # below. `_clean_field` DERIVES `field.automation` for this kind, so a refused enrichment
2496
  # column would otherwise be explained by the flow law -- "pick a flow, or make this an
@@ -2569,7 +2574,11 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
2569
  migrated = dict(migrated or {}, workspace=True)
2570
  except Exception: # noqa: BLE001
2571
  migrated = dict(migrated or {}, workspace=False)
2572
- field = ut.patch_field(table_key, fkey, body, st=session.runtime)
 
 
 
 
2573
  if not field:
2574
  # C3: the same narrator as `add_field`. Flagging a SECOND column is the same act as
2575
  # adding one, so it must get the same sentence naming the column that already holds the
@@ -2581,6 +2590,8 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
2581
  field = synced.get("field") or field
2582
  _refresh_relations(session)
2583
  out = {"field": field}
 
 
2584
  # ⭐⭐ W41-T15 / C5 β€” the same report `add_field` carries, on the door that RETARGETS a link.
2585
  # This one matters more: pointing an existing link at `customer_data` deletes the backlink the
2586
  # old target held (the stale-inverse sweep) and cannot make a new one, so the column silently
 
2491
  def _refusal_sentence(ut, session, body, table_key="", fkey=""):
2492
  """Why was this column refused? The specific reason when we can name one, the general list
2493
  otherwise β€” never a specific-sounding guess."""
2494
+ # W42-T36: the validator returns only ``None``. Keep the option-cap wording in its
2495
+ # single owner so both add and patch tell the person exactly what was refused.
2496
+ option_refusal = ut.option_cap_refusal((body or {}).get("options"))
2497
+ if option_refusal:
2498
+ return option_refusal
2499
  # ⭐ WAVE-34 (R13): the enrichment column's own sentence, named BEFORE the automation bag
2500
  # below. `_clean_field` DERIVES `field.automation` for this kind, so a refused enrichment
2501
  # column would otherwise be explained by the flow law -- "pick a flow, or make this an
 
2574
  migrated = dict(migrated or {}, workspace=True)
2575
  except Exception: # noqa: BLE001
2576
  migrated = dict(migrated or {}, workspace=False)
2577
+ # A type change can clear values that no longer have a meaning in the target column. Keep
2578
+ # that report separate from the field itself: callers that only need the field retain the
2579
+ # established shape, while a future editor can state the consequence from the same response.
2580
+ retype = {}
2581
+ field = ut.patch_field(table_key, fkey, body, st=session.runtime, preview=retype)
2582
  if not field:
2583
  # C3: the same narrator as `add_field`. Flagging a SECOND column is the same act as
2584
  # adding one, so it must get the same sentence naming the column that already holds the
 
2590
  field = synced.get("field") or field
2591
  _refresh_relations(session)
2592
  out = {"field": field}
2593
+ if retype:
2594
+ out["retype"] = retype
2595
  # ⭐⭐ W41-T15 / C5 β€” the same report `add_field` carries, on the door that RETARGETS a link.
2596
  # This one matters more: pointing an existing link at `customer_data` deletes the backlink the
2597
  # old target held (the stale-inverse sweep) and cannot make a new one, so the column silently
platform/core/store.py CHANGED
@@ -656,13 +656,15 @@ class Store:
656
  def upload_bytes(self, path_in_repo, data, message=None):
657
  """Persist RAW BYTES at an arbitrary path in the tenant dataset (wave-8 C5, documents).
658
  Bytes are never cached in-process: large, cold, fetched on explicit user action."""
 
 
 
659
  if not self.available():
660
  raise RuntimeError('No HF_TOKEN configured β€” persistence is unavailable.')
661
  # D-315: a blob is still this tenant's data. ⚠ No `parent_commit` here and that is correct,
662
  # not an omission: a blob is written at its own path and read whole, so two writers cannot
663
  # silently merge into one damaged document the way `{name}.json` can. The concurrency
664
  # hazard D-305 describes is specific to the shared JSON blob.
665
- data_binding.check_write(self.repo, f'write bytes {path_in_repo!r}')
666
  with self._lock:
667
  self._ensure_repo()
668
  HfApi(token=_token()).upload_file(
@@ -693,9 +695,9 @@ class Store:
693
  tolerable no-op. "You are not allowed to touch this store" is not a no-op β€” it is the
694
  thing the operator has to be told.
695
  """
 
696
  if not self.available():
697
  return False
698
- data_binding.check_write(self.repo, f'delete {path_in_repo!r}')
699
  try:
700
  HfApi(token=_token()).delete_file(
701
  path_in_repo=path_in_repo, repo_id=self.repo, repo_type='dataset',
@@ -729,9 +731,9 @@ class Store:
729
  have actually SEEN β€” and that any `update` queued behind it replays on top β€” instead of
730
  being uploaded over an hour of unseen work.
731
  """
 
732
  if not self.available():
733
  raise RuntimeError('No HF_TOKEN configured β€” persistence is unavailable.')
734
- data_binding.check_write(self.repo, f'put {name!r}')
735
  with self._key_lock(name):
736
  def _replace(_current):
737
  return json.loads(json.dumps(data))
 
656
  def upload_bytes(self, path_in_repo, data, message=None):
657
  """Persist RAW BYTES at an arbitrary path in the tenant dataset (wave-8 C5, documents).
658
  Bytes are never cached in-process: large, cold, fetched on explicit user action."""
659
+ # Refusal dominates availability: an uncredentialed staging process must still learn that
660
+ # it is forbidden from targeting production, rather than receive a generic token error.
661
+ data_binding.check_write(self.repo, f'write bytes {path_in_repo!r}')
662
  if not self.available():
663
  raise RuntimeError('No HF_TOKEN configured β€” persistence is unavailable.')
664
  # D-315: a blob is still this tenant's data. ⚠ No `parent_commit` here and that is correct,
665
  # not an omission: a blob is written at its own path and read whole, so two writers cannot
666
  # silently merge into one damaged document the way `{name}.json` can. The concurrency
667
  # hazard D-305 describes is specific to the shared JSON blob.
 
668
  with self._lock:
669
  self._ensure_repo()
670
  HfApi(token=_token()).upload_file(
 
695
  tolerable no-op. "You are not allowed to touch this store" is not a no-op β€” it is the
696
  thing the operator has to be told.
697
  """
698
+ data_binding.check_write(self.repo, f'delete {path_in_repo!r}')
699
  if not self.available():
700
  return False
 
701
  try:
702
  HfApi(token=_token()).delete_file(
703
  path_in_repo=path_in_repo, repo_id=self.repo, repo_type='dataset',
 
731
  have actually SEEN β€” and that any `update` queued behind it replays on top β€” instead of
732
  being uploaded over an hour of unseen work.
733
  """
734
+ data_binding.check_write(self.repo, f'put {name!r}')
735
  if not self.available():
736
  raise RuntimeError('No HF_TOKEN configured β€” persistence is unavailable.')
 
737
  with self._key_lock(name):
738
  def _replace(_current):
739
  return json.loads(json.dumps(data))
platform/core/user_tables.py CHANGED
@@ -940,7 +940,9 @@ def checkbox_mark(field):
940
  """
941
  f = field if isinstance(field, dict) else {}
942
  return {'icon': f.get('icon') or CHECKBOX_ICON_DEFAULT,
943
- 'color': f.get('color') or CHECKBOX_COLOR_DEFAULT}
 
 
944
 
945
 
946
  def rating_symbol(field):
@@ -1209,7 +1211,7 @@ def clean_fields(raw):
1209
  icon = str(f.get('icon') or '').strip().lower()
1210
  if icon in CHECKBOX_ICONS:
1211
  entry['icon'] = icon
1212
- cb_color = str(f.get('color') or '').strip().upper()
1213
  if _OPTION_COLOR_RE.fullmatch(cb_color):
1214
  entry['color'] = cb_color
1215
  if f.get('default') is True:
@@ -3066,7 +3068,14 @@ def _clean_field(raw, previous=None):
3066
  or '').strip().lower()
3067
  if icon in CHECKBOX_ICONS:
3068
  out['icon'] = icon
3069
- color_raw = raw.get('color') if 'color' in raw else prev.get('color')
 
 
 
 
 
 
 
3070
  color = str(color_raw or '').strip().upper()
3071
  if _OPTION_COLOR_RE.fullmatch(color):
3072
  out['color'] = color
 
940
  """
941
  f = field if isinstance(field, dict) else {}
942
  return {'icon': f.get('icon') or CHECKBOX_ICON_DEFAULT,
943
+ # ``colour`` was accepted by an earlier field-editor draft. Read it for
944
+ # old definitions, but keep the product wire canonical as ``color``.
945
+ 'color': f.get('color') or f.get('colour') or CHECKBOX_COLOR_DEFAULT}
946
 
947
 
948
  def rating_symbol(field):
 
1211
  icon = str(f.get('icon') or '').strip().lower()
1212
  if icon in CHECKBOX_ICONS:
1213
  entry['icon'] = icon
1214
+ cb_color = str(f.get('color') or f.get('colour') or '').strip().upper()
1215
  if _OPTION_COLOR_RE.fullmatch(cb_color):
1216
  entry['color'] = cb_color
1217
  if f.get('default') is True:
 
3068
  or '').strip().lower()
3069
  if icon in CHECKBOX_ICONS:
3070
  out['icon'] = icon
3071
+ # Accept the editor's British-spelling draft key as input as well as the
3072
+ # established API spelling, then persist one canonical ``color`` key.
3073
+ if 'color' in raw:
3074
+ color_raw = raw.get('color')
3075
+ elif 'colour' in raw:
3076
+ color_raw = raw.get('colour')
3077
+ else:
3078
+ color_raw = prev.get('color') or prev.get('colour')
3079
  color = str(color_raw or '').strip().upper()
3080
  if _OPTION_COLOR_RE.fullmatch(color):
3081
  out['color'] = color
platform/harness/measure_filter.py CHANGED
@@ -559,6 +559,12 @@ def resolve_pair(rule, today, team_id=None, allowed_pids=None):
559
  f"question, and both look like they work.")
560
  cmp = _CMP[op]
561
 
 
 
 
 
 
 
562
  pool = _pool_of(allowed_pids)
563
  con = _sem._store_con()
564
  try:
 
559
  f"question, and both look like they work.")
560
  cmp = _CMP[op]
561
 
562
+ # Validate BOTH windows before touching the store. Otherwise a malformed right-hand
563
+ # condition can be hidden by an unrelated cache-warming error from the left-hand query,
564
+ # leaving the caller with neither its refusal nor a safe answer.
565
+ _query_parts(lkey, rule.get('window'), today, team_id)
566
+ _query_parts(rkey, rhs.get('window'), today, team_id)
567
+
568
  pool = _pool_of(allowed_pids)
569
  con = _sem._store_con()
570
  try:
platform/harness/runtime.py CHANGED
@@ -267,7 +267,11 @@ class TenantRuntime:
267
  first_odoo = next((e["id"] for e in entries if e.get("type") == "odoo"), None)
268
  if first_odoo and keychain.unlocked():
269
  return first_odoo
270
- if self.key == "royal-imports" and os.environ.get("ODOO_URL"):
 
 
 
 
271
  return ENV_ODOO_FLAG_KEY
272
  return None
273
 
 
267
  first_odoo = next((e["id"] for e in entries if e.get("type") == "odoo"), None)
268
  if first_odoo and keychain.unlocked():
269
  return first_odoo
270
+ # Tenant #0's compiled connector is the ENV fallback. Its presence is the
271
+ # capability contract for the system card; consulting ODOO_URL here instead
272
+ # makes the card (and its pause address) depend on the host process rather
273
+ # than the same source that odoo_source() resolves.
274
+ if self.key == "royal-imports" and self.odoo_source() is not None:
275
  return ENV_ODOO_FLAG_KEY
276
  return None
277
 
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -6,7 +6,7 @@ import { FieldAgentChat } from "./FieldAgentChat";
6
  import type { FieldProposal } from "./fieldAgent";
7
  import { AnchoredOverlay } from "./OverlaySurface";
8
  import type { AnchorRect } from "./OverlaySurface";
9
- import { FieldTypeIcon, MenuLabel } from "./icons";
10
  import { FieldSelectButton } from "./FieldSelect";
11
  // ⭐⭐ W40-T24 β€” the pre-set definition lock. It lives in `display.ts` rather than here because a
12
  // rule stated inside JSX cannot be LOADED by a node gate; `display.ts`'s header carries the full
@@ -106,7 +106,13 @@ interface FieldConfigExtra {
106
  * None for a link with no bag, so a create without this is a column that never appears. */
107
  /** ⭐⭐ W42-T08 (R1, C1) β€” plus, OPTIONALLY, the two-hop `via`. Absent is today's one-hop
108
  * join, byte-identical to what this menu emitted before the hop existed. */
109
- link?: { table: string; single?: boolean; via?: HopViaBag };
 
 
 
 
 
 
110
  /** ⭐ 2026-08-07 β€” a `rollup` column's aggregate. Same requirement, same reason.
111
  * ⭐ 2026-08-09 β€” or, INSTEAD, a `source` binding: the read-through kind, which summarises
112
  * rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
@@ -324,20 +330,11 @@ interface ColumnMenuProps {
324
  * DIFFERENT ACT from `onDelete`, and therefore a different door: it destroys no definition and
325
  * no values, and pointing "Remove from my fields" at the delete handler would be a benign word
326
  * on a destructive action.
327
- * ⭐⭐ W42-T13 (instruction 2, D-485) β€” REQUIRED NOW, AND THAT IS THE WHOLE TICKET. It shipped
328
- * OPTIONAL and unwired, and the control below is gated on the handler's own presence
329
- * (`canRemoveFromMine && onRemoveFromMine`), so with no host feeding it the row did not render
330
- * at all: on screen, indistinguishable from a feature nobody built. Required means an unfed
331
- * mount stops compiling instead of quietly offering nothing.
332
- *
333
- * β›” WHAT IT MAY NOT BE IS A SILENT NO-OP. No server door drops you off a field's own grant
334
- * list: `PUT /share/{kind}/{oid}` needs `may_administer`, which a plain grantee does not hold,
335
- * and sending `entries: []` there is the OWNER revoking everyone, a different act. So the host
336
- * hands this the same refusal `useFieldBulkDoors::onRemoveField` already speaks for the Fields
337
- * manager, which says so in one sentence. A button that renders, is pressed, and does nothing
338
- * is strictly worse than a button that is absent.
339
  */
340
- onRemoveFromMine: () => void;
341
  /** ⭐⭐ Wave-34 (R13) β€” run this AI enrichment column over many rows.
342
  * `scope` is `"blank"` (only cells still empty) or `"always"` (every row the AI
343
  * wrote). OPTIONAL for `onDelete`'s reason: a host that has not wired the door shows
@@ -1147,6 +1144,8 @@ function TypePicker({
1147
  type="button"
1148
  role="option"
1149
  aria-selected={value === r.value}
 
 
1150
  className={"cg-type-row" + (value === r.value ? " is-on" : "")}
1151
  onClick={() => onPick(r.value)}
1152
  >
@@ -1162,30 +1161,19 @@ function TypePicker({
1162
  }
1163
  size={16}
1164
  />
1165
- {/* ⭐⭐ W42-T37 (R24) β€” THE LABEL AND THE LINE THAT SAYS WHAT IT DOES.
1166
- β›” INSIDE the button, not a sibling of it: arrow-key navigation collects
1167
- `button.cg-type-row`, so a description outside would be a row the keyboard
1168
- walks past and a screen reader reads detached from its option.
1169
- ⚠ The layout is stated here rather than in index.css because that stylesheet
1170
- belongs to another fence this wave; `.cg-type-label` keeps its own `flex: 1`
1171
- and only its single-line clamp is lifted, which is the property a second line
1172
- needs. */}
1173
  <span
1174
- className="cg-type-label"
1175
- style={{
1176
- display: "flex", flexDirection: "column", gap: 1,
1177
- whiteSpace: "normal", overflow: "visible", minWidth: 0,
1178
- }}
1179
  >
1180
- <span>{r.label}</span>
1181
- <span
1182
- style={{
1183
- fontSize: "var(--lp-fs-3xs)", color: "var(--lp-muted)",
1184
- fontWeight: 400, lineHeight: 1.3,
1185
- }}
1186
- >
1187
- {TYPE_DESCRIPTIONS[r.value]}
1188
- </span>
1189
  </span>
1190
  {value === r.value && (
1191
  <svg
@@ -1556,6 +1544,10 @@ function ExtraTypeEditor({
1556
  onLinkTable,
1557
  linkSingle = false,
1558
  onLinkSingle,
 
 
 
 
1559
  hop,
1560
  rollupPreset = null,
1561
  rollupMode = "link",
@@ -1624,6 +1616,10 @@ function ExtraTypeEditor({
1624
  onLinkTable?: (v: string) => void;
1625
  linkSingle?: boolean;
1626
  onLinkSingle?: (v: boolean) => void;
 
 
 
 
1627
  /**
1628
  * ⭐⭐ W42-T08 (R1, C1 + C2) β€” the HOP question, and it is REQUIRED at every mount on purpose.
1629
  *
@@ -2045,6 +2041,19 @@ function ExtraTypeEditor({
2045
  />
2046
  <span>Allow only one linked record</span>
2047
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
2048
  {/* β›”β›” THIS SENTENCE IS FALSE ONCE A HOP IS SET, so it is not shown then. With a hop the
2049
  cell does not hold "the records you pick": it holds the records the middle step
2050
  resolves, and a panel asserting otherwise beside the control that changed it is the
@@ -2812,6 +2821,8 @@ export default function ColumnMenu({
2812
  */
2813
  const [linkTable, setLinkTable] = useState("");
2814
  const [linkSingle, setLinkSingle] = useState(false);
 
 
2815
  // ⭐⭐ W42-T08 (R1, C1) β€” the HOP draft for the create pane. `topic: ""` is "no hop", and it is
2816
  // the default, so a Link field created without touching this question emits exactly the bag it
2817
  // emitted before this existed.
@@ -2976,6 +2987,12 @@ export default function ColumnMenu({
2976
  });
2977
  const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
2978
  const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
 
 
 
 
 
 
2979
  // β›”β›” W42-T08 β€” SEEDED FROM THE STORED BAG, and this is not a nicety. The relational save
2980
  // below is a REBUILD: it re-emits the whole `link` bag, so a hop the pane never read would be
2981
  // DELETED by the one click meant to rename the column. Round-tripping it is what makes the
@@ -3173,6 +3190,9 @@ export default function ColumnMenu({
3173
  link: {
3174
  table: linkTable,
3175
  ...(linkSingle ? { single: true } : {}),
 
 
 
3176
  ...(via ? { via } : {}),
3177
  },
3178
  };
@@ -3420,6 +3440,8 @@ export default function ColumnMenu({
3420
  ? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
3421
  : editLinkTable !== (field.link?.table ?? "") ||
3422
  editLinkSingle !== (field.link?.single === true) ||
 
 
3423
  // ⚠ COMPARED AS THE BAG, NOT AS THE DRAFT. `hopDraftFrom` normalises an absent `via` and
3424
  // an absent `window` to the same seeded draft the empty one produces, so comparing the
3425
  // drafts by `JSON.stringify` would report a hop change on a pane nobody touched, which is
@@ -3510,6 +3532,10 @@ export default function ColumnMenu({
3510
  // refuses the second.
3511
  : { link: { table: editLinkTable,
3512
  ...(editLinkSingle ? { single: true } : {}),
 
 
 
 
3513
  ...(hopVia(editHopDraft, hopScopeKey)
3514
  ? { via: hopVia(editHopDraft, hopScopeKey)! }
3515
  : {}) } }),
@@ -3872,9 +3898,16 @@ export default function ColumnMenu({
3872
  onAutomationUrlField={setAutomationUrlField}
3873
  linkTargets={linkTargets}
3874
  linkTable={linkTable}
3875
- onLinkTable={setLinkTable}
 
 
 
3876
  linkSingle={linkSingle}
3877
  onLinkSingle={setLinkSingle}
 
 
 
 
3878
  hop={{
3879
  topics: hopTopics,
3880
  // The hop travels through a governed topic, so its window vocabulary is the one the
@@ -4192,9 +4225,16 @@ export default function ColumnMenu({
4192
  onCodeLanguage={setEditCodeLanguage}
4193
  idPrefix="cg-edit"
4194
  linkTable={editLinkTable}
4195
- onLinkTable={setEditLinkTable}
 
 
 
4196
  linkSingle={editLinkSingle}
4197
  onLinkSingle={setEditLinkSingle}
 
 
 
 
4198
  hop={{
4199
  topics: hopTopics,
4200
  windows: rollupSourceOffer.windows,
 
6
  import type { FieldProposal } from "./fieldAgent";
7
  import { AnchoredOverlay } from "./OverlaySurface";
8
  import type { AnchorRect } from "./OverlaySurface";
9
+ import { FieldTypeIcon, InfoMark, MenuLabel } from "./icons";
10
  import { FieldSelectButton } from "./FieldSelect";
11
  // ⭐⭐ W40-T24 β€” the pre-set definition lock. It lives in `display.ts` rather than here because a
12
  // rule stated inside JSX cannot be LOADED by a node gate; `display.ts`'s header carries the full
 
106
  * None for a link with no bag, so a create without this is a column that never appears. */
107
  /** ⭐⭐ W42-T08 (R1, C1) β€” plus, OPTIONALLY, the two-hop `via`. Absent is today's one-hop
108
  * join, byte-identical to what this menu emitted before the hop existed. */
109
+ link?: {
110
+ table: string;
111
+ single?: boolean;
112
+ via?: HopViaBag;
113
+ conditions?: RollupCondition[];
114
+ conditionConj?: "and" | "or";
115
+ };
116
  /** ⭐ 2026-08-07 β€” a `rollup` column's aggregate. Same requirement, same reason.
117
  * ⭐ 2026-08-09 β€” or, INSTEAD, a `source` binding: the read-through kind, which summarises
118
  * rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
 
330
  * DIFFERENT ACT from `onDelete`, and therefore a different door: it destroys no definition and
331
  * no values, and pointing "Remove from my fields" at the delete handler would be a benign word
332
  * on a destructive action.
333
+ * A host supplies this only when it has a real self-removal server door. The current API does
334
+ * not permit a grantee to withdraw only themself, so the grid intentionally leaves the row
335
+ * absent instead of wiring it to a toast or a destructive share rewrite.
 
 
 
 
 
 
 
 
 
336
  */
337
+ onRemoveFromMine?: () => void;
338
  /** ⭐⭐ Wave-34 (R13) β€” run this AI enrichment column over many rows.
339
  * `scope` is `"blank"` (only cells still empty) or `"always"` (every row the AI
340
  * wrote). OPTIONAL for `onDelete`'s reason: a host that has not wired the door shows
 
1144
  type="button"
1145
  role="option"
1146
  aria-selected={value === r.value}
1147
+ aria-label={r.label}
1148
+ aria-describedby={`cg-type-description-${r.value}`}
1149
  className={"cg-type-row" + (value === r.value ? " is-on" : "")}
1150
  onClick={() => onPick(r.value)}
1151
  >
 
1161
  }
1162
  size={16}
1163
  />
1164
+ {/* The type name is all this dense picker needs to scan. R24's load-bearing
1165
+ explanation lives on the compact (i): native title makes it discoverable on
1166
+ hover, while aria-describedby keeps it attached to the focused option. */}
1167
+ <span className="cg-type-label">{r.label}</span>
 
 
 
 
1168
  <span
1169
+ className="cg-type-info"
1170
+ title={TYPE_DESCRIPTIONS[r.value]}
1171
+ aria-hidden="true"
 
 
1172
  >
1173
+ <InfoMark />
1174
+ </span>
1175
+ <span id={`cg-type-description-${r.value}`} className="cg-sr-only">
1176
+ {TYPE_DESCRIPTIONS[r.value]}
 
 
 
 
 
1177
  </span>
1178
  {value === r.value && (
1179
  <svg
 
1544
  onLinkTable,
1545
  linkSingle = false,
1546
  onLinkSingle,
1547
+ linkConditions = [],
1548
+ onLinkConditions,
1549
+ linkConditionConj = "and",
1550
+ onLinkConditionConj,
1551
  hop,
1552
  rollupPreset = null,
1553
  rollupMode = "link",
 
1616
  onLinkTable?: (v: string) => void;
1617
  linkSingle?: boolean;
1618
  onLinkSingle?: (v: boolean) => void;
1619
+ linkConditions?: RollupCondition[];
1620
+ onLinkConditions?: (v: RollupCondition[]) => void;
1621
+ linkConditionConj?: "and" | "or";
1622
+ onLinkConditionConj?: (v: "and" | "or") => void;
1623
  /**
1624
  * ⭐⭐ W42-T08 (R1, C1 + C2) β€” the HOP question, and it is REQUIRED at every mount on purpose.
1625
  *
 
2041
  />
2042
  <span>Allow only one linked record</span>
2043
  </label>
2044
+ {linkTable ? (
2045
+ <RollupConditionList
2046
+ idPrefix={`${idPrefix}-link-condition`}
2047
+ title="Offer only"
2048
+ hint="Applied when choosing records. Links already saved in this column stay visible."
2049
+ conditions={linkConditions}
2050
+ onConditions={(value) => onLinkConditions?.(value)}
2051
+ conj={linkConditionConj}
2052
+ onConj={(value) => onLinkConditionConj?.(value)}
2053
+ targetFields={hopTargetFields}
2054
+ allowRef={false}
2055
+ />
2056
+ ) : null}
2057
  {/* β›”β›” THIS SENTENCE IS FALSE ONCE A HOP IS SET, so it is not shown then. With a hop the
2058
  cell does not hold "the records you pick": it holds the records the middle step
2059
  resolves, and a panel asserting otherwise beside the control that changed it is the
 
2821
  */
2822
  const [linkTable, setLinkTable] = useState("");
2823
  const [linkSingle, setLinkSingle] = useState(false);
2824
+ const [linkConditions, setLinkConditions] = useState<RollupCondition[]>([]);
2825
+ const [linkConditionConj, setLinkConditionConj] = useState<"and" | "or">("and");
2826
  // ⭐⭐ W42-T08 (R1, C1) β€” the HOP draft for the create pane. `topic: ""` is "no hop", and it is
2827
  // the default, so a Link field created without touching this question emits exactly the bag it
2828
  // emitted before this existed.
 
2987
  });
2988
  const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
2989
  const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
2990
+ const [editLinkConditions, setEditLinkConditions] = useState<RollupCondition[]>(
2991
+ () => field.link?.conditions ?? []
2992
+ );
2993
+ const [editLinkConditionConj, setEditLinkConditionConj] = useState<"and" | "or">(
2994
+ () => field.link?.conditionConj ?? "and"
2995
+ );
2996
  // β›”β›” W42-T08 β€” SEEDED FROM THE STORED BAG, and this is not a nicety. The relational save
2997
  // below is a REBUILD: it re-emits the whole `link` bag, so a hop the pane never read would be
2998
  // DELETED by the one click meant to rename the column. Round-tripping it is what makes the
 
3190
  link: {
3191
  table: linkTable,
3192
  ...(linkSingle ? { single: true } : {}),
3193
+ ...(linkConditions.length
3194
+ ? { conditions: linkConditions, conditionConj: linkConditionConj }
3195
+ : {}),
3196
  ...(via ? { via } : {}),
3197
  },
3198
  };
 
3440
  ? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
3441
  : editLinkTable !== (field.link?.table ?? "") ||
3442
  editLinkSingle !== (field.link?.single === true) ||
3443
+ JSON.stringify(editLinkConditions) !== JSON.stringify(field.link?.conditions ?? []) ||
3444
+ editLinkConditionConj !== (field.link?.conditionConj ?? "and") ||
3445
  // ⚠ COMPARED AS THE BAG, NOT AS THE DRAFT. `hopDraftFrom` normalises an absent `via` and
3446
  // an absent `window` to the same seeded draft the empty one produces, so comparing the
3447
  // drafts by `JSON.stringify` would report a hop change on a pane nobody touched, which is
 
3532
  // refuses the second.
3533
  : { link: { table: editLinkTable,
3534
  ...(editLinkSingle ? { single: true } : {}),
3535
+ ...(editLinkConditions.length
3536
+ ? { conditions: editLinkConditions,
3537
+ conditionConj: editLinkConditionConj }
3538
+ : {}),
3539
  ...(hopVia(editHopDraft, hopScopeKey)
3540
  ? { via: hopVia(editHopDraft, hopScopeKey)! }
3541
  : {}) } }),
 
3898
  onAutomationUrlField={setAutomationUrlField}
3899
  linkTargets={linkTargets}
3900
  linkTable={linkTable}
3901
+ onLinkTable={(value) => {
3902
+ setLinkTable(value);
3903
+ setLinkConditions([]);
3904
+ }}
3905
  linkSingle={linkSingle}
3906
  onLinkSingle={setLinkSingle}
3907
+ linkConditions={linkConditions}
3908
+ onLinkConditions={setLinkConditions}
3909
+ linkConditionConj={linkConditionConj}
3910
+ onLinkConditionConj={setLinkConditionConj}
3911
  hop={{
3912
  topics: hopTopics,
3913
  // The hop travels through a governed topic, so its window vocabulary is the one the
 
4225
  onCodeLanguage={setEditCodeLanguage}
4226
  idPrefix="cg-edit"
4227
  linkTable={editLinkTable}
4228
+ onLinkTable={(value) => {
4229
+ setEditLinkTable(value);
4230
+ setEditLinkConditions([]);
4231
+ }}
4232
  linkSingle={editLinkSingle}
4233
  onLinkSingle={setEditLinkSingle}
4234
+ linkConditions={editLinkConditions}
4235
+ onLinkConditions={setEditLinkConditions}
4236
+ linkConditionConj={editLinkConditionConj}
4237
+ onLinkConditionConj={setEditLinkConditionConj}
4238
  hop={{
4239
  topics: hopTopics,
4240
  windows: rollupSourceOffer.windows,
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -70,8 +70,8 @@ import {
70
  } from "./scriptViews";
71
  import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews";
72
  import { defaultViewConfig, headerMarksFor, useGridColumns } from "./useGridColumns";
73
- import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
74
- unresolvedConditions, useVisibleRows } from "./useVisibleRows";
75
  import { computeAggs } from "./aggregations";
76
  import type { CohortSets } from "./useVisibleRows";
77
  import type { MeasureSets } from "./useVisibleRows";
@@ -82,6 +82,8 @@ import JsonViewer from "./JsonViewer";
82
  import RecordDetail from "./RecordDetail";
83
  import ViewSidebar from "./ViewSidebar";
84
  import ColumnMenu from "./ColumnMenu";
 
 
85
  import type { ColumnMenuState, HopTopic } from "./ColumnMenu";
86
  import { HEADER_ICONS } from "./iconShapes";
87
  import { emitHostEvent, eventId } from "./hostBridge";
@@ -173,7 +175,7 @@ import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName,
173
  viewDisplayName,
174
  /* ⭐ W37-T26 (C4) β€” the view-membership leaf's two pure helpers. `viewRefsOf` is the ONE walk
175
  the resolver, the cycle detector and the gate share; `viewFilterCycle` is the refusal. */
176
- viewFilterCycle, viewRefsOf } from "./types";
177
  /* ⭐⭐ W41-T42 (owner instruction 23) β€” the pivot's whole vocabulary is DECLARED in `types.ts`
178
  and only consumed here, because W41-T43 (this file) and W41-T44 (`RecordDetail.tsx`) open the
179
  same feature through other doors and neither of them may edit `types.ts`. */
@@ -193,6 +195,7 @@ import type {
193
  PivotWindow,
194
  Row,
195
  RowHeightMode,
 
196
  SavedView,
197
  ViewConfig,
198
  } from "./types";
@@ -735,18 +738,10 @@ export function fieldDeleteDoor(
735
  * would take the column off the screen while it still exists in the store, so the refusal would
736
  * read as a success until the next workspace fetch put the column back.
737
  *
738
- * β›”β›” SHARE, REASSIGN AND REMOVE REFUSE OUT LOUD RATHER THAN DOING NOTHING, and that is the
739
- * whole point of wiring them at all (D-485: a control that silently no-ops is worse than one
740
- * that is absent). The server doors for the first two exist and are per object
741
- * (`PUT /share/field/{oid}` and `PUT /share/field/{oid}/owner`), but both need a PERSON picked,
742
- * and the only opener this tree may reach is the `aios:share-open` event, which carries ONE oid.
743
- * Firing it five times is exactly the five-dialog experience R3 asked to be replaced, so these
744
- * say so in one sentence instead. `onRemoveField` has no door at all: dropping yourself from a
745
- * colleague's grant list needs `may_administer`, which a plain grantee does not hold.
746
- *
747
- * ⚠ NOT CALLED FROM THIS COMPONENT YET, and that is a fence, not an oversight:
748
- * `customer-grid/Toolbar.tsx` declares none of the five props and is the only thing standing
749
- * between these doors and the panel. Wiring it is the next ticket's; see the handoff.
750
  */
751
  export interface FieldBulkDoorDeps {
752
  config: ViewConfig;
@@ -765,10 +760,7 @@ export interface FieldBulkDoorDeps {
765
 
766
  export interface FieldBulkDoors {
767
  onSetFieldsHidden: (keys: string[], hidden: boolean) => void;
768
- onShareFields: (keys: string[]) => void;
769
- onReassignFields: (keys: string[]) => void;
770
  onDeleteFields: (keys: string[]) => void;
771
- onRemoveField: (key: string) => void;
772
  }
773
 
774
  /**
@@ -900,30 +892,9 @@ export function useFieldBulkDoors(deps: FieldBulkDoorDeps): FieldBulkDoors {
900
  stampFieldDelete, updateConfig, visible]
901
  );
902
 
903
- const onShareFields = useCallback((keys: string[]) => {
904
- if (keys.length === 0) return;
905
- signal(TOAST_EVENT,
906
- "Sharing several fields in one go is not available yet, so share them one at a time from " +
907
- "each column's own menu.");
908
- }, []);
909
-
910
- const onReassignFields = useCallback((keys: string[]) => {
911
- if (keys.length === 0) return;
912
- signal(TOAST_EVENT,
913
- "Handing several fields to a new owner in one go is not available yet, so change the " +
914
- "owner one column at a time from its share panel.");
915
- }, []);
916
-
917
- const onRemoveField = useCallback((key: string) => {
918
- if (!key) return;
919
- signal(TOAST_EVENT,
920
- "Taking a shared field off your own list is not available yet, so hide it or ask the " +
921
- "person who owns it to withdraw it.");
922
- }, []);
923
-
924
  return useMemo(
925
- () => ({ onSetFieldsHidden, onShareFields, onReassignFields, onDeleteFields, onRemoveField }),
926
- [onDeleteFields, onReassignFields, onRemoveField, onSetFieldsHidden, onShareFields]
927
  );
928
  }
929
 
@@ -1235,6 +1206,9 @@ interface CustomerGridProps {
1235
  /** W42-T29 β€” the SOURCE record, withheld from its own candidate list so a record cannot be
1236
  * offered as a link to itself. Applied on top of any projection above. */
1237
  embeddedExcludeRecordId?: number;
 
 
 
1238
  }
1239
 
1240
  /**
@@ -1275,10 +1249,17 @@ export function scopeForLinkTable(table: unknown): SurfaceScope | undefined {
1275
  return table as `ut_${string}`;
1276
  }
1277
 
 
 
 
 
 
1278
  interface LinkGridModalProps {
1279
  label: string;
1280
  table: SurfaceScope;
1281
  recordIds: readonly number[];
 
 
1282
  editable?: boolean;
1283
  single?: boolean;
1284
  /** The source record, when this picker draws the source record's OWN database. */
@@ -1287,8 +1268,8 @@ interface LinkGridModalProps {
1287
  onClose: () => void;
1288
  }
1289
 
1290
- function LinkGridModal({ label, table, recordIds, editable = false, single = false,
1291
- excludeRecordId, onSave, onClose }: LinkGridModalProps) {
1292
  const panelRef = useRef<HTMLDivElement>(null);
1293
  const [selectedIds, setSelectedIds] = useState<number[]>(() => [...recordIds]);
1294
  const changeSelectedIds = useCallback((ids: number[]) => {
@@ -1348,6 +1329,8 @@ function LinkGridModal({ label, table, recordIds, editable = false, single = fal
1348
  embedded
1349
  embeddedRecordIds={editable ? undefined : recordIds}
1350
  embeddedExcludeRecordId={editable ? excludeRecordId : undefined}
 
 
1351
  embeddedSelectable={editable}
1352
  embeddedSelectedIds={selectedIds}
1353
  onEmbeddedSelectionChange={changeSelectedIds}
@@ -1425,6 +1408,8 @@ function CustomerGridSurface({
1425
  embeddedSelectedIds = [],
1426
  onEmbeddedSelectionChange,
1427
  embeddedExcludeRecordId,
 
 
1428
  }: CustomerGridProps = {}) {
1429
  const isQueryPreview = queryBinding !== undefined;
1430
  /**
@@ -1822,6 +1807,19 @@ function CustomerGridSurface({
1822
  ? `query:${queryBinding.workspaceBinding.key}`
1823
  : payload?.workspace?.storageKey ?? `${LOCAL_KEY_PREFIX}${scope}`;
1824
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1825
  // Initialize once per permission/data scope. Host state wins; local state
1826
  // fills only missing objects and keeps the standalone path useful.
1827
  useEffect(() => {
@@ -2256,6 +2254,45 @@ function CustomerGridSurface({
2256
  const isUserTable = !embedded && scope.startsWith("ut_");
2257
  const recordsMutable = payload?.recordsMutable !== false;
2258
  const canMutateRecords = isUserTable && recordsMutable;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2259
  /**
2260
  * ⭐⭐ W42-T12 (D-485, amendment A20) β€” THE FIELD MANAGER'S FIVE DOORS, CALLED AT LAST.
2261
  *
@@ -2590,6 +2627,24 @@ function CustomerGridSurface({
2590
  if (displayMode !== "map") setRoutePanelOpen(false);
2591
  }, [displayMode]);
2592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2593
  // ═══════════════════════════════════════════════════════════════════════════════════════
2594
  // ⭐⭐ WAVE 30 Β· W30-T42 (contract C2) β€” PAGING A READ-THROUGH GRID.
2595
  // ═══════════════════════════════════════════════════════════════════════════════════════
@@ -2601,20 +2656,22 @@ function CustomerGridSurface({
2601
  // question. So the predicate has to reach the evaluator that CAN answer it, which is SQL.
2602
  //
2603
  // β›” THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`).
2604
- const windowPredicate = useMemo(
2605
- () => windowPredicateKey(config.filters, config.filterConj, config.sorts, search),
2606
- [config.filters, config.filterConj, config.sorts, search]
2607
- );
 
2608
  const windowRequest = useCallback(
2609
  (offset: number) => ({
2610
  offset,
2611
  limit: WINDOW_ROWS,
2612
- filters: config.filters,
2613
- filterConj: config.filterConj,
2614
  sorts: config.sorts,
2615
  search,
 
2616
  }),
2617
- [config.filters, config.filterConj, config.sorts, search]
2618
  );
2619
  const sentPredicate = useRef<string | null>(null);
2620
  useEffect(() => {
@@ -2775,6 +2832,23 @@ function CustomerGridSurface({
2775
  });
2776
  }, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]);
2777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2778
  /**
2779
  * ⭐⭐ W37-T26 / CONTRACT C4 β€” `view id -> the pids that view resolves to`, for the VIEW
2780
  * MEMBERSHIP leaf (`Where View is [some view]`).
@@ -2872,16 +2946,17 @@ function CustomerGridSurface({
2872
  [activeViewId, views, config, updateConfig]
2873
  );
2874
 
2875
- const unresolvedCount = useMemo(
2876
  // ⭐ W37-T26 (C4) β€” `viewSets` rides this context too. Without it a view leaf would ALWAYS
2877
  // count as unresolved (the chip would cry wolf on a working filter) and, worse, a leaf naming
2878
  // a DELETED view would look identical to one naming a live one.
2879
  // ⭐⭐ D-229 β€” `fieldByKey` rides this context so a condition naming a column this table no
2880
  // longer has is COUNTED. `evalNode` ignores such a leaf (correctly), which means the grid was
2881
  // showing MORE rows than the filter panel claimed with nothing on screen saying so.
2882
- () => unresolvedConditions(config.filters, { cohortSets, viewSets, fieldByKey, today }),
2883
  [config.filters, cohortSets, viewSets, fieldByKey, today]
2884
  );
 
2885
 
2886
  // Wave-2 item 2c β€” COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
2887
  // derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
@@ -2905,9 +2980,9 @@ function CustomerGridSurface({
2905
  const scopedRows = useMemo(
2906
  () =>
2907
  cohortMode
2908
- ? computedRows.filter((r) => cohortMemberSet.has(r.pid))
2909
- : computedRows,
2910
- [cohortMode, computedRows, cohortMemberSet]
2911
  );
2912
 
2913
  const { visibleRows, pidToIndex } = useVisibleRows(
@@ -3400,7 +3475,7 @@ function CustomerGridSurface({
3400
  embeddedSelectable ? embeddedSelectedIds : [],
3401
  !embedded ? `aios:grid-selection:${scope}` : undefined
3402
  );
3403
- const embeddedSelectedKey = embeddedSelectedIds.join(",");
3404
  const selectedPidKey = [...selectedPids].join(",");
3405
  useEffect(() => {
3406
  if (!embeddedSelectable) return;
@@ -3409,8 +3484,13 @@ function CustomerGridSurface({
3409
  }, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
3410
  useEffect(() => {
3411
  if (!embeddedSelectable) return;
3412
- onEmbeddedSelectionChange?.([...selectedPids]);
3413
- }, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]);
 
 
 
 
 
3414
  /* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════
3415
  THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
3416
  thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
@@ -8258,18 +8338,21 @@ function CustomerGridSurface({
8258
  if (target && target.shared && target.kind === "route_order") deleteRouteField(key);
8259
  else deleteField(key);
8260
  }}
8261
- /* ⭐⭐ W42-T12 (D-485, amendment A20) β€” THE FIELD MANAGER'S FIVE DOORS AND WHO IS
8262
- LOOKING. This is the mount that renders the manager, so this is the mount that has
8263
- to feed it: a ticked selection with Hide, Show, Share, Reassign or Delete behind it
8264
- now reaches `useFieldBulkDoors`, which batches each act into a single config write.
8265
- `viewer` is what lets contract C2's rights pair be asked per row, so a colleague's
8266
- shared column offers "Remove from my fields" and never a delete. */
8267
  viewer={viewer}
8268
  onSetFieldsHidden={fieldBulkDoors.onSetFieldsHidden}
8269
- onShareFields={fieldBulkDoors.onShareFields}
8270
- onReassignFields={fieldBulkDoors.onReassignFields}
 
 
 
 
8271
  onDeleteFields={fieldBulkDoors.onDeleteFields}
8272
- onRemoveField={fieldBulkDoors.onRemoveField}
 
 
 
 
 
8273
  /* ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE COLOUR CONTROL. This was `colorOn` plus a
8274
  boolean toggle whose `true` arm guessed the column: `fields.find(type === "status")`,
8275
  so on a table with two status columns the person got the first one and had no way to
@@ -8295,6 +8378,7 @@ function CustomerGridSurface({
8295
  statusValues={statusValues}
8296
  measures={measures}
8297
  unresolvedCount={unresolvedCount}
 
8298
  lists={lists}
8299
  /* ⭐⭐ W37-T26 (C4) β€” the views this database's conditions may name: every SAVED view
8300
  except the one being edited (a view naming itself is the cycle a person reaches most
@@ -9597,34 +9681,14 @@ function CustomerGridSurface({
9597
  }
9598
  : undefined
9599
  }
9600
- /**
9601
- * ⭐⭐ W42-T13 (instruction 2, D-485) β€” "Remove from my fields" GETS ITS HOST.
9602
- *
9603
- * W41-T29 built the row in `ColumnMenu.tsx` and gated it on the handler's own presence
9604
- * (`canRemoveFromMine && onRemoveFromMine`), with the prop OPTIONAL and nobody passing
9605
- * it. So the row never rendered, at either mount, and the shipped feature looked exactly
9606
- * like a feature nobody built. The prop is required now; this is the feed.
9607
- *
9608
- * β›”β›” IT REFUSES OUT LOUD, AND THAT IS DELIBERATE, NOT A STUB. There is no server door
9609
- * for the act: `PUT /share/{kind}/{oid}` refuses anyone without `may_administer`, which
9610
- * a plain grantee (the only person who ever sees this row) does not hold, and its
9611
- * `entries: []` body is the OWNER revoking everyone, which is a different act with a
9612
- * different blast radius. `useFieldBulkDoors::onRemoveField` already speaks the one
9613
- * sentence that says so for the Fields manager's copy of this control, so both surfaces
9614
- * say the same thing rather than drifting into two wordings of one refusal.
9615
- * β›” NOT `deleteField`, and not the definition or route doors either. Dropping a share
9616
- * destroys nothing, so pointing this at a delete would put a benign word on a
9617
- * destructive act β€” the one failure W41-T29's note says must not ship.
9618
- *
9619
- * β›”β›” PASSED AT BOTH `<ColumnMenu>` MOUNTS. This file's own history is the reason the
9620
- * rule keeps being restated beside each prop: a prop fed at the full column menu and
9621
- * not at its "+" twin is the half-landing that `linkTargets` and `hopTopics` both
9622
- * carry warnings about, caught by grepping and never by the build.
9623
- */
9624
- onRemoveFromMine={() => {
9625
- fieldBulkDoors.onRemoveField(menuField.key);
9626
- setColumnMenu(null);
9627
- }}
9628
  onDuplicate={
9629
  // Creatable strata only β€” a base Odoo field offers no Duplicate (contract).
9630
  menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
@@ -9732,28 +9796,8 @@ function CustomerGridSurface({
9732
  onNote={() => undefined}
9733
  onHide={() => undefined}
9734
  onCreate={createField}
9735
- /**
9736
- * ⭐⭐ W42-T13 β€” THE SECOND MOUNT, and it takes the REAL door rather than an inert
9737
- * `() => undefined` like the action handlers above it. The row is not reachable here
9738
- * today (this mount opens straight into the create form, its nominal field is the
9739
- * locked identity column, and `locked` plus `mayRemoveField`'s refusals stand between
9740
- * it and the screen), and that is precisely why an inert stub would be so cheap to
9741
- * write and so expensive later: the
9742
- * moment anything makes it reachable, a silent no-op is D-485 restored at one door with
9743
- * the twin working. The two doors say the same sentence or neither does.
9744
- *
9745
- * ⚠ `lockedKey` and not `menuField.key`: this mount's `field` IS the locked identity
9746
- * column, so the twin's `menuField` does not exist in this scope. Copying the sibling
9747
- * body verbatim would have compiled against the wrong identifier or not at all.
9748
- */
9749
- onRemoveFromMine={() => {
9750
- fieldBulkDoors.onRemoveField(lockedKey);
9751
- setPlusMenu(null);
9752
- }}
9753
- /* The SAME answer as the sibling instance above, stated rather than defaulted: this is
9754
- the "+" menu and it creates through the identical `createField`, so a different value
9755
- here would mean one door offers the kind and its twin does not. The note above the
9756
- other instance carries the reasoning, including why it is now unconditional. */
9757
  geocodable={true}
9758
  onChangeField={() => undefined}
9759
  onCreateAndSwap={() => undefined}
@@ -10030,11 +10074,27 @@ function CustomerGridSurface({
10030
  onClose={closeJson}
10031
  />
10032
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10033
  {linkField && linkTargetScope && linkAt && (
10034
  <LinkGridModal
10035
  label={linkField.label}
10036
  table={linkTargetScope}
10037
  recordIds={linkedRecordIds}
 
 
10038
  editable={canEditField(linkField) && !isDerivedLink(linkField)}
10039
  single={linkField.link?.single === true}
10040
  excludeRecordId={linkTargetScope === scope ? linkAt.pid : undefined}
 
70
  } from "./scriptViews";
71
  import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews";
72
  import { defaultViewConfig, headerMarksFor, useGridColumns } from "./useGridColumns";
73
+ import { activeMeasureRuleIds, matchFilterTree, pendingMeasures, runPipeline, sliceForDisplay,
74
+ unresolvedConditionReasons, useVisibleRows } from "./useVisibleRows";
75
  import { computeAggs } from "./aggregations";
76
  import type { CohortSets } from "./useVisibleRows";
77
  import type { MeasureSets } from "./useVisibleRows";
 
82
  import RecordDetail from "./RecordDetail";
83
  import ViewSidebar from "./ViewSidebar";
84
  import ColumnMenu from "./ColumnMenu";
85
+ import FieldBatchAccessDialog from "./FieldBatchAccessDialog";
86
+ import type { FieldBatchAccessAction } from "./FieldBatchAccessDialog";
87
  import type { ColumnMenuState, HopTopic } from "./ColumnMenu";
88
  import { HEADER_ICONS } from "./iconShapes";
89
  import { emitHostEvent, eventId } from "./hostBridge";
 
175
  viewDisplayName,
176
  /* ⭐ W37-T26 (C4) β€” the view-membership leaf's two pure helpers. `viewRefsOf` is the ONE walk
177
  the resolver, the cycle detector and the gate share; `viewFilterCycle` is the refusal. */
178
+ viewFilterCycle, viewRefsOf, linkPickerFilterNodes } from "./types";
179
  /* ⭐⭐ W41-T42 (owner instruction 23) β€” the pivot's whole vocabulary is DECLARED in `types.ts`
180
  and only consumed here, because W41-T43 (this file) and W41-T44 (`RecordDetail.tsx`) open the
181
  same feature through other doors and neither of them may edit `types.ts`. */
 
195
  PivotWindow,
196
  Row,
197
  RowHeightMode,
198
+ RollupCondition,
199
  SavedView,
200
  ViewConfig,
201
  } from "./types";
 
738
  * would take the column off the screen while it still exists in the store, so the refusal would
739
  * read as a success until the next workspace fetch put the column back.
740
  *
741
+ * Share, reassign, and self-removal deliberately have no bulk door here. The first two APIs are
742
+ * per field and require a person picker; the last has no safe server operation for a grantee.
743
+ * Passing a toast callback would render a button that does not perform its named action, so the
744
+ * Toolbar receives `null` for all three until their real contracts exist.
 
 
 
 
 
 
 
 
745
  */
746
  export interface FieldBulkDoorDeps {
747
  config: ViewConfig;
 
760
 
761
  export interface FieldBulkDoors {
762
  onSetFieldsHidden: (keys: string[], hidden: boolean) => void;
 
 
763
  onDeleteFields: (keys: string[]) => void;
 
764
  }
765
 
766
  /**
 
892
  stampFieldDelete, updateConfig, visible]
893
  );
894
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
  return useMemo(
896
+ () => ({ onSetFieldsHidden, onDeleteFields }),
897
+ [onDeleteFields, onSetFieldsHidden]
898
  );
899
  }
900
 
 
1206
  /** W42-T29 β€” the SOURCE record, withheld from its own candidate list so a record cannot be
1207
  * offered as a link to itself. Applied on top of any projection above. */
1208
  embeddedExcludeRecordId?: number;
1209
+ /** W42-T35 β€” static conditions narrow candidates only; saved links are never rewritten. */
1210
+ embeddedLinkConditions?: readonly RollupCondition[];
1211
+ embeddedLinkConditionConj?: "and" | "or";
1212
  }
1213
 
1214
  /**
 
1249
  return table as `ut_${string}`;
1250
  }
1251
 
1252
+ /**
1253
+ * W42-T35 β€” Link conditions deliberately use the server's Rollup vocabulary while the grid
1254
+ * evaluator speaks the view vocabulary. This is the sole adapter: a picker condition is static,
1255
+ * flat and has no rank, view, cohort or dynamic-field arm.
1256
+ */
1257
  interface LinkGridModalProps {
1258
  label: string;
1259
  table: SurfaceScope;
1260
  recordIds: readonly number[];
1261
+ conditions?: readonly RollupCondition[];
1262
+ conditionConj?: "and" | "or";
1263
  editable?: boolean;
1264
  single?: boolean;
1265
  /** The source record, when this picker draws the source record's OWN database. */
 
1268
  onClose: () => void;
1269
  }
1270
 
1271
+ function LinkGridModal({ label, table, recordIds, conditions = [], conditionConj = "and",
1272
+ editable = false, single = false, excludeRecordId, onSave, onClose }: LinkGridModalProps) {
1273
  const panelRef = useRef<HTMLDivElement>(null);
1274
  const [selectedIds, setSelectedIds] = useState<number[]>(() => [...recordIds]);
1275
  const changeSelectedIds = useCallback((ids: number[]) => {
 
1329
  embedded
1330
  embeddedRecordIds={editable ? undefined : recordIds}
1331
  embeddedExcludeRecordId={editable ? excludeRecordId : undefined}
1332
+ embeddedLinkConditions={editable ? conditions : undefined}
1333
+ embeddedLinkConditionConj={conditionConj}
1334
  embeddedSelectable={editable}
1335
  embeddedSelectedIds={selectedIds}
1336
  onEmbeddedSelectionChange={changeSelectedIds}
 
1408
  embeddedSelectedIds = [],
1409
  onEmbeddedSelectionChange,
1410
  embeddedExcludeRecordId,
1411
+ embeddedLinkConditions = [],
1412
+ embeddedLinkConditionConj = "and",
1413
  }: CustomerGridProps = {}) {
1414
  const isQueryPreview = queryBinding !== undefined;
1415
  /**
 
1807
  ? `query:${queryBinding.workspaceBinding.key}`
1808
  : payload?.workspace?.storageKey ?? `${LOCAL_KEY_PREFIX}${scope}`;
1809
 
1810
+ /** The field-share route speaks this durable surface key, never a browser cache key. */
1811
+ const fieldGrantTableKey = isQueryPreview
1812
+ ? null
1813
+ : scope === "product"
1814
+ ? "product_data"
1815
+ : scope.startsWith("ut_")
1816
+ ? scope
1817
+ : "customer_data";
1818
+ const [fieldBatchAccess, setFieldBatchAccess] = useState<{
1819
+ action: FieldBatchAccessAction;
1820
+ keys: string[];
1821
+ } | null>(null);
1822
+
1823
  // Initialize once per permission/data scope. Host state wins; local state
1824
  // fills only missing objects and keeps the standalone path useful.
1825
  useEffect(() => {
 
2254
  const isUserTable = !embedded && scope.startsWith("ut_");
2255
  const recordsMutable = payload?.recordsMutable !== false;
2256
  const canMutateRecords = isUserTable && recordsMutable;
2257
+ const batchAccessFields = useMemo(
2258
+ () => (fieldBatchAccess?.keys ?? [])
2259
+ .map((key) => fieldByKey.get(key))
2260
+ .filter((field): field is Field => !!field)
2261
+ .map((field) => ({ key: field.key, label: field.label })),
2262
+ [fieldBatchAccess, fieldByKey]
2263
+ );
2264
+ const openFieldBatchAccess = useCallback((action: FieldBatchAccessAction, keys: string[]) => {
2265
+ if (!fieldGrantTableKey) {
2266
+ signal(TOAST_EVENT, "Field access is unavailable on this preview.");
2267
+ return;
2268
+ }
2269
+ setFieldBatchAccess({ action, keys: [...new Set(keys)] });
2270
+ }, [fieldGrantTableKey]);
2271
+ const removeFieldFromMine = useCallback((field: Field) => {
2272
+ if (!fieldGrantTableKey) return;
2273
+ void (async () => {
2274
+ const path = `${API_V1}/share/field/${encodeURIComponent(`${fieldGrantTableKey}:${field.key}`)}/mine`;
2275
+ const res = await fetch(path, { method: "DELETE", credentials: CREDENTIALS }).catch(() => null);
2276
+ if (!res) {
2277
+ signal(TOAST_EVENT, "Cannot reach the server.");
2278
+ return;
2279
+ }
2280
+ if (!res.ok) {
2281
+ const body = (await res.json().catch(() => null)) as { error?: { message?: unknown } } | null;
2282
+ const message = body?.error?.message;
2283
+ signal(TOAST_EVENT, typeof message === "string" && message.trim()
2284
+ ? message.trim()
2285
+ : `The server answered ${res.status}.`);
2286
+ return;
2287
+ }
2288
+ setFields((current) => current.filter((item) => item.key !== field.key));
2289
+ updateConfig(configWithoutFields({ ...config, order, visible: [...visible] }, [field.key]));
2290
+ clearTopicRowsCache(topic.rowsPath);
2291
+ signal(WORKSPACE_STALE_EVENT);
2292
+ signal(ROWS_STALE_EVENT);
2293
+ signal(TOAST_EVENT, `${field.label} was removed from your fields.`);
2294
+ })();
2295
+ }, [config, fieldGrantTableKey, order, setFields, topic.rowsPath, updateConfig, visible]);
2296
  /**
2297
  * ⭐⭐ W42-T12 (D-485, amendment A20) β€” THE FIELD MANAGER'S FIVE DOORS, CALLED AT LAST.
2298
  *
 
2627
  if (displayMode !== "map") setRoutePanelOpen(false);
2628
  }, [displayMode]);
2629
 
2630
+ // W42-T35: the same static Link condition must reach the server when the target table is
2631
+ // windowed. Filtering only the loaded page would make matching records outside that page
2632
+ // unreachable. Existing selections remain exceptions so a new condition never erases a link.
2633
+ const embeddedLinkFilterNodes = useMemo(
2634
+ () => linkPickerFilterNodes(embeddedLinkConditions),
2635
+ [embeddedLinkConditions]
2636
+ );
2637
+ const linkPickerFilters = embeddedSelectable && embeddedLinkFilterNodes.length > 0
2638
+ ? embeddedLinkFilterNodes
2639
+ : config.filters;
2640
+ const linkPickerFilterConj = embeddedSelectable && embeddedLinkFilterNodes.length > 0
2641
+ ? embeddedLinkConditionConj
2642
+ : config.filterConj;
2643
+ const embeddedLinkSelectionKey = embeddedSelectedIds.join(",");
2644
+ const linkPickerPreservedIds = embeddedSelectable && embeddedLinkFilterNodes.length > 0
2645
+ ? embeddedSelectedIds
2646
+ : [];
2647
+
2648
  // ═══════════════════════════════════════════════════════════════════════════════════════
2649
  // ⭐⭐ WAVE 30 Β· W30-T42 (contract C2) β€” PAGING A READ-THROUGH GRID.
2650
  // ═══════════════════════════════════════════════════════════════════════════════════════
 
2656
  // question. So the predicate has to reach the evaluator that CAN answer it, which is SQL.
2657
  //
2658
  // β›” THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`).
2659
+ const windowPredicate = useMemo(() => {
2660
+ const base = windowPredicateKey(linkPickerFilters, linkPickerFilterConj, config.sorts, search);
2661
+ return linkPickerPreservedIds.length ? `${base}|link:${embeddedLinkSelectionKey}` : base;
2662
+ }, [linkPickerFilters, linkPickerFilterConj, config.sorts, search, linkPickerPreservedIds,
2663
+ embeddedLinkSelectionKey]);
2664
  const windowRequest = useCallback(
2665
  (offset: number) => ({
2666
  offset,
2667
  limit: WINDOW_ROWS,
2668
+ filters: linkPickerFilters,
2669
+ filterConj: linkPickerFilterConj,
2670
  sorts: config.sorts,
2671
  search,
2672
+ includePids: linkPickerPreservedIds,
2673
  }),
2674
+ [linkPickerFilters, linkPickerFilterConj, config.sorts, search, linkPickerPreservedIds]
2675
  );
2676
  const sentPredicate = useRef<string | null>(null);
2677
  useEffect(() => {
 
2832
  });
2833
  }, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]);
2834
 
2835
+ // W42-T35 β€” a link filter constrains the records an editor may ADD, not the relationships that
2836
+ // already exist. The latter remain in the modal so a new rule cannot erase an older choice.
2837
+ const embeddedLinkEligibleIds = useMemo(() => {
2838
+ if (!embeddedSelectable || embeddedLinkFilterNodes.length === 0)
2839
+ return new Set(computedRows.map((row) => row.pid));
2840
+ return new Set(computedRows.filter((row) => matchFilterTree(
2841
+ embeddedLinkFilterNodes, embeddedLinkConditionConj, row, fieldByKey, { today, strict: true }
2842
+ )).map((row) => row.pid));
2843
+ }, [computedRows, embeddedSelectable, embeddedLinkFilterNodes, embeddedLinkConditionConj,
2844
+ fieldByKey, today]);
2845
+ const linkCandidateRows = useMemo(() => {
2846
+ if (!embeddedSelectable || embeddedLinkFilterNodes.length === 0) return computedRows;
2847
+ const saved = new Set(embeddedSelectedIds);
2848
+ return computedRows.filter((row) => saved.has(row.pid) || embeddedLinkEligibleIds.has(row.pid));
2849
+ }, [computedRows, embeddedSelectable, embeddedLinkFilterNodes, embeddedLinkEligibleIds,
2850
+ embeddedLinkSelectionKey]);
2851
+
2852
  /**
2853
  * ⭐⭐ W37-T26 / CONTRACT C4 β€” `view id -> the pids that view resolves to`, for the VIEW
2854
  * MEMBERSHIP leaf (`Where View is [some view]`).
 
2946
  [activeViewId, views, config, updateConfig]
2947
  );
2948
 
2949
+ const unresolvedReasons = useMemo(
2950
  // ⭐ W37-T26 (C4) β€” `viewSets` rides this context too. Without it a view leaf would ALWAYS
2951
  // count as unresolved (the chip would cry wolf on a working filter) and, worse, a leaf naming
2952
  // a DELETED view would look identical to one naming a live one.
2953
  // ⭐⭐ D-229 β€” `fieldByKey` rides this context so a condition naming a column this table no
2954
  // longer has is COUNTED. `evalNode` ignores such a leaf (correctly), which means the grid was
2955
  // showing MORE rows than the filter panel claimed with nothing on screen saying so.
2956
+ () => unresolvedConditionReasons(config.filters, { cohortSets, viewSets, fieldByKey, today }),
2957
  [config.filters, cohortSets, viewSets, fieldByKey, today]
2958
  );
2959
+ const unresolvedCount = unresolvedReasons.length;
2960
 
2961
  // Wave-2 item 2c β€” COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
2962
  // derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
 
2980
  const scopedRows = useMemo(
2981
  () =>
2982
  cohortMode
2983
+ ? linkCandidateRows.filter((r) => cohortMemberSet.has(r.pid))
2984
+ : linkCandidateRows,
2985
+ [cohortMode, linkCandidateRows, cohortMemberSet]
2986
  );
2987
 
2988
  const { visibleRows, pidToIndex } = useVisibleRows(
 
3475
  embeddedSelectable ? embeddedSelectedIds : [],
3476
  !embedded ? `aios:grid-selection:${scope}` : undefined
3477
  );
3478
+ const embeddedSelectedKey = embeddedLinkSelectionKey;
3479
  const selectedPidKey = [...selectedPids].join(",");
3480
  useEffect(() => {
3481
  if (!embeddedSelectable) return;
 
3484
  }, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
3485
  useEffect(() => {
3486
  if (!embeddedSelectable) return;
3487
+ const previous = new Set(embeddedSelectedIds);
3488
+ const next = [...selectedPids].filter((pid) =>
3489
+ embeddedLinkFilterNodes.length === 0 || embeddedLinkEligibleIds.has(pid) || previous.has(pid)
3490
+ );
3491
+ onEmbeddedSelectionChange?.(next);
3492
+ }, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids, embeddedSelectedIds,
3493
+ embeddedLinkFilterNodes, embeddedLinkEligibleIds]);
3494
  /* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════
3495
  THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
3496
  thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
 
8338
  if (target && target.shared && target.kind === "route_order") deleteRouteField(key);
8339
  else deleteField(key);
8340
  }}
 
 
 
 
 
 
8341
  viewer={viewer}
8342
  onSetFieldsHidden={fieldBulkDoors.onSetFieldsHidden}
8343
+ onShareFields={fieldGrantTableKey
8344
+ ? (keys) => openFieldBatchAccess("share", keys)
8345
+ : null}
8346
+ onReassignFields={fieldGrantTableKey
8347
+ ? (keys) => openFieldBatchAccess("reassign", keys)
8348
+ : null}
8349
  onDeleteFields={fieldBulkDoors.onDeleteFields}
8350
+ onRemoveField={fieldGrantTableKey
8351
+ ? (key) => {
8352
+ const field = fieldByKey.get(key);
8353
+ if (field) removeFieldFromMine(field);
8354
+ }
8355
+ : null}
8356
  /* ⭐⭐ W38-T07 (owner instruction 12) β€” THE ONE COLOUR CONTROL. This was `colorOn` plus a
8357
  boolean toggle whose `true` arm guessed the column: `fields.find(type === "status")`,
8358
  so on a table with two status columns the person got the first one and had no way to
 
8378
  statusValues={statusValues}
8379
  measures={measures}
8380
  unresolvedCount={unresolvedCount}
8381
+ unresolvedReasons={unresolvedReasons}
8382
  lists={lists}
8383
  /* ⭐⭐ W37-T26 (C4) β€” the views this database's conditions may name: every SAVED view
8384
  except the one being edited (a view naming itself is the cycle a person reaches most
 
9681
  }
9682
  : undefined
9683
  }
9684
+ onRemoveFromMine={
9685
+ menuField.shared && menuField.sharedRole !== "owner"
9686
+ ? () => {
9687
+ removeFieldFromMine(menuField);
9688
+ setColumnMenu(null);
9689
+ }
9690
+ : undefined
9691
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9692
  onDuplicate={
9693
  // Creatable strata only β€” a base Odoo field offers no Duplicate (contract).
9694
  menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
 
9796
  onNote={() => undefined}
9797
  onHide={() => undefined}
9798
  onCreate={createField}
9799
+ /* Keep the twin aligned: no self-removal affordance without a safe server door. */
9800
+ onRemoveFromMine={undefined}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9801
  geocodable={true}
9802
  onChangeField={() => undefined}
9803
  onCreateAndSwap={() => undefined}
 
10074
  onClose={closeJson}
10075
  />
10076
  )}
10077
+ {fieldBatchAccess && fieldGrantTableKey && batchAccessFields.length > 0 ? (
10078
+ <FieldBatchAccessDialog
10079
+ action={fieldBatchAccess.action}
10080
+ tableKey={fieldGrantTableKey}
10081
+ fields={batchAccessFields}
10082
+ onClose={() => setFieldBatchAccess(null)}
10083
+ onComplete={(message) => {
10084
+ clearTopicRowsCache(topic.rowsPath);
10085
+ signal(WORKSPACE_STALE_EVENT);
10086
+ signal(ROWS_STALE_EVENT);
10087
+ signal(TOAST_EVENT, message);
10088
+ }}
10089
+ />
10090
+ ) : null}
10091
  {linkField && linkTargetScope && linkAt && (
10092
  <LinkGridModal
10093
  label={linkField.label}
10094
  table={linkTargetScope}
10095
  recordIds={linkedRecordIds}
10096
+ conditions={linkField.link?.conditions}
10097
+ conditionConj={linkField.link?.conditionConj}
10098
  editable={canEditField(linkField) && !isDerivedLink(linkField)}
10099
  single={linkField.link?.single === true}
10100
  excludeRecordId={linkTargetScope === scope ? linkAt.pid : undefined}
web/src/customer-grid/FieldBatchAccessDialog.tsx ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / FieldBatchAccessDialog.tsx
3
+ //
4
+ // One small access editor for the Field manager's plural Share and Reassign
5
+ // actions. It deliberately uses the existing field-share routes rather than
6
+ // inventing a second permissions model: every selected field keeps its own
7
+ // grants, and the server remains the authority for each individual refusal.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import { useCallback, useEffect, useMemo, useState } from "react";
11
+ import { API_V1, CREDENTIALS } from "../apiContract";
12
+
13
+ export type FieldBatchAccessAction = "share" | "reassign";
14
+
15
+ interface Person {
16
+ user: string;
17
+ name: string;
18
+ }
19
+
20
+ interface Entry {
21
+ user: string;
22
+ role: "view" | "edit";
23
+ }
24
+
25
+ function asPeople(raw: unknown): Person[] {
26
+ if (!Array.isArray(raw)) return [];
27
+ const seen = new Set<string>();
28
+ const people: Person[] = [];
29
+ for (const item of raw) {
30
+ if (!item || typeof item !== "object") continue;
31
+ const candidate = item as Record<string, unknown>;
32
+ const user = typeof candidate.username === "string" ? candidate.username.trim().toLowerCase() : "";
33
+ const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
34
+ if (!user || seen.has(user)) continue;
35
+ seen.add(user);
36
+ people.push({ user, name: name || user });
37
+ }
38
+ return people;
39
+ }
40
+
41
+ function asEntries(raw: unknown): Entry[] {
42
+ if (!Array.isArray(raw)) return [];
43
+ const seen = new Set<string>();
44
+ const entries: Entry[] = [];
45
+ for (const item of raw) {
46
+ if (!item || typeof item !== "object") continue;
47
+ const candidate = item as Record<string, unknown>;
48
+ const user = typeof candidate.user === "string" ? candidate.user.trim().toLowerCase() : "";
49
+ const role = candidate.role === "view" || candidate.role === "edit" ? candidate.role : null;
50
+ if (!user || !role || seen.has(user)) continue;
51
+ seen.add(user);
52
+ entries.push({ user, role });
53
+ }
54
+ return entries;
55
+ }
56
+
57
+ async function replyMessage(res: Response): Promise<string> {
58
+ const body = (await res.json().catch(() => null)) as { error?: { message?: unknown } } | null;
59
+ const message = body?.error?.message;
60
+ return typeof message === "string" && message.trim() ? message.trim() : `The server answered ${res.status}.`;
61
+ }
62
+
63
+ export default function FieldBatchAccessDialog({
64
+ action,
65
+ tableKey,
66
+ fields,
67
+ onClose,
68
+ onComplete,
69
+ }: {
70
+ action: FieldBatchAccessAction;
71
+ tableKey: string;
72
+ fields: readonly { key: string; label: string }[];
73
+ onClose: () => void;
74
+ onComplete: (message: string) => void;
75
+ }) {
76
+ const [people, setPeople] = useState<Person[]>([]);
77
+ const [person, setPerson] = useState("");
78
+ const [role, setRole] = useState<Entry["role"]>("view");
79
+ const [busy, setBusy] = useState(false);
80
+ const [error, setError] = useState("");
81
+ const [result, setResult] = useState("");
82
+ const first = fields[0];
83
+ const title = action === "share" ? "Share fields" : "Reassign fields";
84
+ const subject = fields.length === 1 ? `β€œ${first?.label ?? "field"}”` : `${fields.length} fields`;
85
+
86
+ const pathFor = useCallback(
87
+ (key: string) => `${API_V1}/share/field/${encodeURIComponent(`${tableKey}:${key}`)}`,
88
+ [tableKey]
89
+ );
90
+
91
+ useEffect(() => {
92
+ let alive = true;
93
+ if (!first) return () => { alive = false; };
94
+ void (async () => {
95
+ const res = await fetch(pathFor(first.key), { credentials: CREDENTIALS }).catch(() => null);
96
+ if (!alive) return;
97
+ if (!res) {
98
+ setError("Cannot reach the server.");
99
+ return;
100
+ }
101
+ if (!res.ok) {
102
+ setError(await replyMessage(res));
103
+ return;
104
+ }
105
+ const body = (await res.json().catch(() => null)) as Record<string, unknown> | null;
106
+ const offered = asPeople(body?.people);
107
+ setPeople(offered);
108
+ if (offered.length === 0) setError("No eligible people were returned for this workspace.");
109
+ })();
110
+ return () => { alive = false; };
111
+ }, [first, pathFor]);
112
+
113
+ useEffect(() => {
114
+ const onKey = (event: KeyboardEvent) => {
115
+ if (event.key === "Escape" && !busy) onClose();
116
+ };
117
+ window.addEventListener("keydown", onKey);
118
+ return () => window.removeEventListener("keydown", onKey);
119
+ }, [busy, onClose]);
120
+
121
+ const canApply = !!person && !busy && !result;
122
+ const buttonText = action === "share" ? "Share" : "Reassign";
123
+ const personLabel = useMemo(
124
+ () => people.find((item) => item.user === person)?.name ?? person,
125
+ [people, person]
126
+ );
127
+
128
+ const apply = useCallback(() => {
129
+ if (!canApply) return;
130
+ void (async () => {
131
+ setBusy(true);
132
+ setError("");
133
+ let changed = 0;
134
+ const refused: string[] = [];
135
+ for (const field of fields) {
136
+ const path = pathFor(field.key);
137
+ const read = await fetch(path, { credentials: CREDENTIALS }).catch(() => null);
138
+ if (!read) {
139
+ refused.push(`${field.label}: Cannot reach the server.`);
140
+ continue;
141
+ }
142
+ if (!read.ok) {
143
+ refused.push(`${field.label}: ${await replyMessage(read)}`);
144
+ continue;
145
+ }
146
+ const current = (await read.json().catch(() => null)) as Record<string, unknown> | null;
147
+ const response = action === "share"
148
+ ? await fetch(path, {
149
+ method: "PUT",
150
+ credentials: CREDENTIALS,
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify({
153
+ entries: [
154
+ ...asEntries(current?.entries).filter((entry) => entry.user !== person),
155
+ { user: person, role },
156
+ ],
157
+ }),
158
+ }).catch(() => null)
159
+ : await fetch(`${path}/owner`, {
160
+ method: "PUT",
161
+ credentials: CREDENTIALS,
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ owner: person }),
164
+ }).catch(() => null);
165
+ if (!response) {
166
+ refused.push(`${field.label}: Cannot reach the server.`);
167
+ } else if (!response.ok) {
168
+ refused.push(`${field.label}: ${await replyMessage(response)}`);
169
+ } else {
170
+ changed += 1;
171
+ }
172
+ }
173
+ const noun = changed === 1 ? "field" : "fields";
174
+ const verb = action === "share" ? "shared with" : "reassigned to";
175
+ const message = changed > 0
176
+ ? `${changed} ${noun} ${verb} ${personLabel}.`
177
+ : "No selected fields were changed.";
178
+ setResult(message);
179
+ if (refused.length > 0) setError(refused.join(" "));
180
+ if (changed > 0) onComplete(message);
181
+ setBusy(false);
182
+ })();
183
+ }, [action, canApply, fields, onComplete, pathFor, person, personLabel, role]);
184
+
185
+ return (
186
+ <div className="shell-newdb-scrim" onClick={() => (busy ? null : onClose())}>
187
+ <section
188
+ className="shell-newdb shell-share"
189
+ role="dialog"
190
+ aria-modal="true"
191
+ aria-label={`${title} ${subject}`}
192
+ onClick={(event) => event.stopPropagation()}
193
+ >
194
+ <h2>{title}</h2>
195
+ <p className="shell-newdb-sub">
196
+ {action === "share"
197
+ ? `Give ${personLabel || "someone"} access to ${subject}. Existing access on each field stays in place.`
198
+ : `Make ${personLabel || "someone"} the owner of ${subject}. A field must already be shared before it can be reassigned.`}
199
+ </p>
200
+ <div className="shell-share-add">
201
+ <select
202
+ className="shell-share-select"
203
+ aria-label="Person"
204
+ value={person}
205
+ disabled={busy || !!result}
206
+ onChange={(event) => setPerson(event.target.value)}
207
+ >
208
+ <option value="">Choose a person</option>
209
+ {people.map((item) => <option key={item.user} value={item.user}>{item.name}</option>)}
210
+ </select>
211
+ {action === "share" ? (
212
+ <select
213
+ className="shell-share-select"
214
+ aria-label="Access level"
215
+ value={role}
216
+ disabled={busy || !!result}
217
+ onChange={(event) => setRole(event.target.value === "edit" ? "edit" : "view")}
218
+ >
219
+ <option value="view">Can view</option>
220
+ <option value="edit">Can edit</option>
221
+ </select>
222
+ ) : null}
223
+ </div>
224
+ {result ? <p className="shell-share-summary">{result}</p> : null}
225
+ {error ? <p className="shell-newdb-err">{error}</p> : null}
226
+ <div className="shell-newdb-actions">
227
+ <button type="button" disabled={busy} onClick={onClose}>{result ? "Done" : "Cancel"}</button>
228
+ {!result ? <button type="button" className="login-submit" disabled={!canApply} onClick={apply}>{busy ? "Applying..." : buttonText}</button> : null}
229
+ </div>
230
+ </section>
231
+ </div>
232
+ );
233
+ }
web/src/customer-grid/MapView.tsx CHANGED
@@ -511,6 +511,8 @@ export function MapView({
511
  const [routeCols, setRouteCols] = useState<
512
  (RouteNavRoute & { inputsHash: string; depot?: RouteDepot | null })[] | null
513
  >(null);
 
 
514
  /**
515
  * ⭐⭐ W42-T24 (contract C6) β€” THIS READER'S ROUTE FOLDERS, and they ride the SAME listing.
516
  *
@@ -1175,20 +1177,36 @@ export function MapView({
1175
  * the only way the STALE marker can exist at all.
1176
  */
1177
  const loadRouteCols = useCallback(async () => {
 
1178
  try {
1179
  const res = await fetch("/api/v1/customers/route-order", { credentials: "same-origin" });
1180
- const body = res.ok ? await res.json().catch(() => null) : null;
1181
- setRouteCols(Array.isArray(body?.fields) ? body.fields : []);
 
 
 
 
 
 
 
 
 
 
1182
  // W42-T23 / C6 β€” the rail's own folder ROWS travel with the listing.
1183
  setRouteFolders(Array.isArray(body?.folders) ? (body.folders as GridFolder[]) : []);
1184
  setNextLabel(typeof body?.nextLabel === "string" ? body.nextLabel : "");
1185
  } catch {
1186
- // A listing that cannot be fetched is "none known", not an error banner over the map: the
1187
- // planner still works, and the save below reports its own failure in its own words.
1188
  setRouteCols([]);
 
 
1189
  }
1190
  }, []);
1191
 
 
 
 
 
1192
  // ⭐ OWNER, 2026-08-23 β€” `routePanelOpen`, NOT `routeOn`. The saved-route picker is the first
1193
  // control in the panel and it is how a person reaches somebody's plan without solving one of
1194
  // their own, so the listing has to be in hand the moment the panel opens rather than after a
@@ -2499,6 +2517,8 @@ export function MapView({
2499
  openKey={openedRoute}
2500
  busyKey={routeBusyKey}
2501
  notice={railNotice}
 
 
2502
  onOpenRoute={openSavedRoute}
2503
  onCreateRoute={startNewRoute}
2504
  onRenameRoute={renameRoute}
 
511
  const [routeCols, setRouteCols] = useState<
512
  (RouteNavRoute & { inputsHash: string; depot?: RouteDepot | null })[] | null
513
  >(null);
514
+ /** A failed route-list read is not an empty route list. */
515
+ const [routeListError, setRouteListError] = useState<string | null>(null);
516
  /**
517
  * ⭐⭐ W42-T24 (contract C6) β€” THIS READER'S ROUTE FOLDERS, and they ride the SAME listing.
518
  *
 
1177
  * the only way the STALE marker can exist at all.
1178
  */
1179
  const loadRouteCols = useCallback(async () => {
1180
+ setRouteListError(null);
1181
  try {
1182
  const res = await fetch("/api/v1/customers/route-order", { credentials: "same-origin" });
1183
+ const body = await res.json().catch(() => null);
1184
+ if (!res.ok || !Array.isArray(body?.fields)) {
1185
+ const detail = body?.detail?.message || body?.detail || body?.error?.message;
1186
+ setRouteListError(typeof detail === "string" && detail.trim()
1187
+ ? detail
1188
+ : "Saved routes could not be loaded. Retry to see them.");
1189
+ setRouteCols([]);
1190
+ setRouteFolders([]);
1191
+ setNextLabel("");
1192
+ return;
1193
+ }
1194
+ setRouteCols(body.fields);
1195
  // W42-T23 / C6 β€” the rail's own folder ROWS travel with the listing.
1196
  setRouteFolders(Array.isArray(body?.folders) ? (body.folders as GridFolder[]) : []);
1197
  setNextLabel(typeof body?.nextLabel === "string" ? body.nextLabel : "");
1198
  } catch {
1199
+ setRouteListError("Saved routes could not be loaded. Retry to see them.");
 
1200
  setRouteCols([]);
1201
+ setRouteFolders([]);
1202
+ setNextLabel("");
1203
  }
1204
  }, []);
1205
 
1206
+ const retryRouteList = useCallback(() => {
1207
+ void loadRouteCols();
1208
+ }, [loadRouteCols]);
1209
+
1210
  // ⭐ OWNER, 2026-08-23 β€” `routePanelOpen`, NOT `routeOn`. The saved-route picker is the first
1211
  // control in the panel and it is how a person reaches somebody's plan without solving one of
1212
  // their own, so the listing has to be in hand the moment the panel opens rather than after a
 
2517
  openKey={openedRoute}
2518
  busyKey={routeBusyKey}
2519
  notice={railNotice}
2520
+ listError={routeListError}
2521
+ onListRetry={retryRouteList}
2522
  onOpenRoute={openSavedRoute}
2523
  onCreateRoute={startNewRoute}
2524
  onRenameRoute={renameRoute}
web/src/customer-grid/RouteNav.tsx CHANGED
@@ -155,6 +155,9 @@ export interface RouteNavProps {
155
  * row (standing rule 1's second sentence: reported, never silent).
156
  */
157
  notice: string;
 
 
 
158
  /** ONE CLICK OPENS A ROUTE. There is no second gesture and no confirm step. */
159
  onOpenRoute: (key: string) => void;
160
  /** CREATING IS A SEPARATE AFFORDANCE, never a row in the list of what exists. */
@@ -196,6 +199,8 @@ export function RouteNav({
196
  openKey,
197
  busyKey,
198
  notice,
 
 
199
  onOpenRoute,
200
  onCreateRoute,
201
  onRenameRoute,
@@ -482,7 +487,12 @@ export function RouteNav({
482
  ) : null}
483
 
484
  <div className="cg-route-nav-list" {...sectionDrop(ROOT_FOLDER_ID)}>
485
- {routes.length === 0 ? (
 
 
 
 
 
486
  <div className="cg-fold-empty">
487
  No routes yet. Plan one on the map, then save it to keep it here.
488
  </div>
 
155
  * row (standing rule 1's second sentence: reported, never silent).
156
  */
157
  notice: string;
158
+ /** A failed listing is distinct from the honest empty state and offers an immediate retry. */
159
+ listError: string | null;
160
+ onListRetry: () => void;
161
  /** ONE CLICK OPENS A ROUTE. There is no second gesture and no confirm step. */
162
  onOpenRoute: (key: string) => void;
163
  /** CREATING IS A SEPARATE AFFORDANCE, never a row in the list of what exists. */
 
199
  openKey,
200
  busyKey,
201
  notice,
202
+ listError,
203
+ onListRetry,
204
  onOpenRoute,
205
  onCreateRoute,
206
  onRenameRoute,
 
487
  ) : null}
488
 
489
  <div className="cg-route-nav-list" {...sectionDrop(ROOT_FOLDER_ID)}>
490
+ {listError ? (
491
+ <div className="cg-route-nav-notice" role="alert">
492
+ <span>{listError}</span>
493
+ <button type="button" className="cg-link-btn" onClick={onListRetry}>Retry</button>
494
+ </div>
495
+ ) : routes.length === 0 ? (
496
  <div className="cg-fold-empty">
497
  No routes yet. Plan one on the map, then save it to keep it here.
498
  </div>
web/src/customer-grid/SwipeView.tsx CHANGED
@@ -1,586 +1,586 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / SwipeView.tsx
3
- // ⭐ WAVE-27 item 8 (owner ruling R2, contract C3) β€” the SWIPE deck.
4
- //
5
- // One record at a time, decided left or right into two options of ONE
6
- // single-select. R2 fixes the shape and it is narrow on purpose:
7
- // Β· SINGLE-SELECT ONLY, one field, two of its options. No checkbox binding.
8
- // Β· the deck holds only the records whose bound field is EMPTY β€” a record
9
- // that already has a value has been decided, and re-asking is not triage.
10
- // Β· a swipe writes through the NORMAL cell door (`onSwipe`, which the host
11
- // maps to the same `patchAndRecord` a kanban card move uses), so undo,
12
- // echo-suppression and the permission wall all hold without being
13
- // re-implemented here.
14
- //
15
- // Honesty rules carried from the other modes:
16
- // Β· nothing is silently hidden β€” the deck states what is left, and the
17
- // empty state names the bound field rather than saying "all done".
18
- // Β· a lost binding is SHOWN, never repaired by guessing. `_clean_display`
19
- // validates that `fieldKey` names a field the table HAS and stops there;
20
- // it cannot know the field is still a select, or that the two options are
21
- // still in its vocabulary (its docstring draws that line explicitly). So
22
- // those three losses land here, and each one says what happened and
23
- // re-opens the picker β€” the `viewModes.tsx:190-194` rule, which exists
24
- // because the wave-7 trap was a picker silently falling back to its first
25
- // option.
26
- // ---------------------------------------------------------------------------
27
-
28
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
29
- // ⭐⭐ W41-T48 (owner instruction 22) β€” opening a record from the swipe deck folds the shell's
30
- // navigation rail exactly as a grid cell click does. Guard rationale: `MapView.tsx:2755`.
31
- import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
32
- import { choiceOptions } from "./types";
33
- import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
34
- // ⚠ `SurfaceScope` is `apiBridge`'s (a TYPE-only import, so nothing of that module is
35
- // loaded here) β€” the same import `RecordComments` takes for the same prop.
36
- import type { SurfaceScope } from "./apiBridge";
37
- import RecordComments from "./RecordComments";
38
- import { Documents } from "./Documents";
39
- import { formatDisplay } from "./cells";
40
- import { actionHref } from "./display";
41
- import { optionTint } from "./choiceColors";
42
- import { ModeIcon } from "./icons";
43
- // ⭐ W29-T73 β€” the line its three siblings all have, and the one this file shipped without.
44
- // `SwipeView.css` had ZERO references anywhere in `src/`, so T26's two new sections rendered
45
- // unstyled while `.cg-swipe-card` (which lives in `index.css`) kept the card looking right β€”
46
- // a partially-styled surface reads as a design choice, which is why nobody reported it and no
47
- // gate could see it: a side-effect import references no symbol ([[artifact-with-no-importer]]).
48
- import "./SwipeView.css";
49
-
50
- export type SwipeSpec = NonNullable<DisplaySpec["swipe"]>;
51
-
52
- /** The three ways a stored binding can rot that the host cannot see (see the header note). */
53
- type BindingFault =
54
- | { kind: "unbound" }
55
- | { kind: "field-gone"; fieldKey: string }
56
- | { kind: "not-select"; field: Field }
57
- | { kind: "option-gone"; field: Field; missing: string[] };
58
-
59
- function readBinding(spec: SwipeSpec | undefined, fieldByKey: Map<string, Field>):
60
- { ok: true; field: Field; spec: SwipeSpec } | { ok: false; fault: BindingFault } {
61
- if (!spec) return { ok: false, fault: { kind: "unbound" } };
62
- const field = fieldByKey.get(spec.fieldKey);
63
- if (!field) return { ok: false, fault: { kind: "field-gone", fieldKey: spec.fieldKey } };
64
- // R2 β€” SINGLE-select only. `multiselect` is deliberately not accepted: its cell holds a SET,
65
- // so "write this option" would mean append-or-replace, and a triage gesture that sometimes
66
- // adds and sometimes overwrites is two gestures wearing one button.
67
- if (field.type !== "select") return { ok: false, fault: { kind: "not-select", field } };
68
- const options = choiceOptions(field);
69
- const has = new Set(options.map((o) => o.toLowerCase()));
70
- const missing = [spec.leftOption, spec.rightOption].filter((o) => !has.has(o.toLowerCase()));
71
- if (missing.length) return { ok: false, fault: { kind: "option-gone", field, missing } };
72
- return { ok: true, field, spec };
73
- }
74
-
75
- /** The bound cell is EMPTY β€” the one predicate that decides deck membership (R2). */
76
- const isUndecided = (row: Row, key: string): boolean =>
77
- String(row[key] ?? "").trim() === "";
78
-
79
- export function SwipeView({
80
- rows,
81
- fields,
82
- fieldByKey,
83
- spec,
84
- cardKeys,
85
- detailKeys,
86
- scope,
87
- viewer,
88
- docs,
89
- docPayload,
90
- onDocAdd,
91
- onDocFetch,
92
- onDocDelete,
93
- titleKey,
94
- canWrite,
95
- readOnlyReason,
96
- canBind,
97
- onSpec,
98
- onSwipe,
99
- onOpen,
100
- }: {
101
- /** DISTINCT data rows from the full pipeline, overlay edits layered β€” the kanban's contract.
102
- * The deck re-derives from these, so a written record leaves it as soon as the optimistic
103
- * patch lands; there is no local cursor to drift out of step with the data. */
104
- rows: Row[];
105
- /** Every field, for the picker's field list. */
106
- fields: Field[];
107
- fieldByKey: Map<string, Field>;
108
- /** The stored binding, or undefined for a deck nobody has configured yet. */
109
- spec: SwipeSpec | undefined;
110
- /** The handful of visible fields the card lists under its title. */
111
- cardKeys: string[];
112
- titleKey: string;
113
- /** May this viewer write the BOUND field's value? (the kanban's `canMove`) */
114
- canWrite: boolean;
115
- /** Stated when the deck cannot be decided β€” permissions, or a computed column. */
116
- readOnlyReason: string | null;
117
- /** May this viewer change what the deck is bound TO? A VIEW-config act, so it is the
118
- * view-editing permission and NOT `canWrite` β€” conflating the two is
119
- * [[schema-role-is-not-a-value-wall]]. REQUIRED, because a picker that silently does
120
- * nothing is worse than no picker ([[wrong-parent-not-broken-control]]). */
121
- canBind: boolean;
122
- /** `undefined` DELETES the binding β€” absent is the honest unconfigured state, and storing a
123
- * half-binding is what `cleanDisplay` drops on both engines (the `kanbanClamp` law). */
124
- onSpec: (next: SwipeSpec | undefined) => void;
125
- onSwipe: (pid: number, value: string) => void;
126
- onOpen: (pid: number) => void;
127
- /**
128
- * ⭐⭐ WAVE-29 T26 (owner R9) β€” WHAT MAKES THE CARD A RECORD RATHER THAN A SUMMARY.
129
- *
130
- * R9: *"the swipe CARD itself renders the record detail inline β€” fields, comments and
131
- * attachments β€” so a reviewer decides without leaving the deck."* Everything below is that,
132
- * and every one of them is OPTIONAL for one reason: this deck runs on hosts that supply
133
- * different amounts. A card must render with none of them rather than throw β€” the standalone
134
- * embed has no `scope`, and a deployment with no document storage serves no handlers.
135
- */
136
- /** Every field the VIEW shows, in its order β€” not the three-key card summary. */
137
- detailKeys?: string[];
138
- /** The surface these records live on. Comments are keyed by it; absent β‡’ no comments section,
139
- * which is the pre-existing precondition `RecordDetail` already carries. */
140
- scope?: SurfaceScope;
141
- viewer?: Viewer;
142
- /** This record's attachments, and the plumbing. Handlers absent β‡’ the section is not rendered
143
- * at all, exactly as `RecordDetail` decides it (a dead upload control is a promise the app
144
- * cannot keep). */
145
- /** Keyed by pid, because a deck shows many records β€” `RecordDetail` takes ONE record's list
146
- * and that shape cannot serve a deck. */
147
- docs?: Record<string, CustomerDoc[]>;
148
- docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string };
149
- /** ⚠ EVERY HANDLER TAKES THE PID, unlike `RecordDetail`'s, whose host closes over the ONE
150
- * open record. A deck paints many records at once, so a handler bound to a single pid would
151
- * attach every reviewer's upload to whichever card happened to be open. */
152
- onDocAdd?: (
153
- pid: number,
154
- file: { name: string; mime: string; size: number; data_b64: string }
155
- ) => void;
156
- onDocFetch?: (pid: number, docId: string) => void;
157
- onDocDelete?: (pid: number, docId: string) => void;
158
- }) {
159
- // "Later" β€” session-only, never stored. A deck with no way past a record you cannot decide
160
- // blocks on that record forever, so this is navigation WITHIN the deck rather than a third
161
- // gesture: it writes nothing, survives no reload, and the count is disclosed in the empty
162
- // state rather than quietly shrinking the deck (rule 8b).
163
- const [later, setLater] = useState<Set<number>>(new Set());
164
- const deckRef = useRef<HTMLDivElement>(null);
165
-
166
- const binding = useMemo(() => readBinding(spec, fieldByKey), [spec, fieldByKey]);
167
- const boundKey = binding.ok ? binding.field.key : null;
168
-
169
- const undecided = useMemo(
170
- () => (boundKey ? rows.filter((r) => isUndecided(r, boundKey)) : []),
171
- [rows, boundKey]
172
- );
173
- const deck = useMemo(() => undecided.filter((r) => !later.has(r.pid)), [undecided, later]);
174
- const card = deck[0];
175
-
176
- // A record decided elsewhere (the grid, another user's echo) must not stay "later" forever β€”
177
- // otherwise the disclosed skip count drifts away from what is actually on the deck.
178
- useEffect(() => {
179
- setLater((prev) => {
180
- if (prev.size === 0) return prev;
181
- const live = new Set(undecided.map((r) => r.pid));
182
- const next = new Set([...prev].filter((pid) => live.has(pid)));
183
- return next.size === prev.size ? prev : next;
184
- });
185
- }, [undecided]);
186
-
187
- const decide = useCallback(
188
- (value: string) => {
189
- if (!card || !canWrite) return;
190
- onSwipe(card.pid, value);
191
- },
192
- [card, canWrite, onSwipe]
193
- );
194
-
195
- // ← / β†’ decide, and they are the SAME two actions the buttons are rather than a second code
196
- // path: the whole point of the mode is that one hand can clear a queue.
197
- useEffect(() => {
198
- if (!binding.ok || !canWrite || !card) return;
199
- const onKey = (e: KeyboardEvent) => {
200
- if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
201
- const el = document.activeElement as HTMLElement | null;
202
- // Never steal an arrow key from a text field or a native control the user is inside.
203
- if (el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA"))
204
- return;
205
- if (!deckRef.current?.contains(el ?? null) && el !== document.body) return;
206
- e.preventDefault();
207
- decide(e.key === "ArrowLeft" ? binding.spec.leftOption : binding.spec.rightOption);
208
- };
209
- window.addEventListener("keydown", onKey);
210
- return () => window.removeEventListener("keydown", onKey);
211
- }, [binding, canWrite, card, decide]);
212
-
213
- if (!binding.ok) {
214
- return (
215
- <div className="cg-swipe" ref={deckRef}>
216
- <SwipeBinder
217
- fault={binding.fault}
218
- fields={fields}
219
- fieldByKey={fieldByKey}
220
- spec={spec}
221
- canBind={canBind}
222
- onSpec={onSpec}
223
- />
224
- </div>
225
- );
226
- }
227
-
228
- const { field } = binding;
229
- const leftTint = optionTint(field, binding.spec.leftOption);
230
- const rightTint = optionTint(field, binding.spec.rightOption);
231
-
232
- return (
233
- <div className="cg-swipe" ref={deckRef} tabIndex={-1}>
234
- <div className="cg-swipe-bar">
235
- <span className="cg-swipe-bound">
236
- <ModeIcon mode="swipe" />
237
- Sorting into <strong>{field.label}</strong>
238
- </span>
239
- <span className="cg-swipe-left">
240
- {deck.length.toLocaleString()} {deck.length === 1 ? "record" : "records"} to sort
241
- </span>
242
- {canBind && (
243
- <button
244
- type="button"
245
- className="cg-link-btn"
246
- onClick={() => onSpec(undefined)}
247
- title="Choose a different field or different options for this deck"
248
- >
249
- Change
250
- </button>
251
- )}
252
- </div>
253
- {readOnlyReason && <div className="cg-kb-note">{readOnlyReason}</div>}
254
- {card ? (
255
- <div className="cg-swipe-deck">
256
- <button
257
- type="button"
258
- className="cg-swipe-side cg-swipe-side--left"
259
- disabled={!canWrite}
260
- style={leftTint ? { background: leftTint.bg, color: leftTint.fg } : undefined}
261
- onClick={() => decide(binding.spec.leftOption)}
262
- title={`Set ${field.label} to ${binding.spec.leftOption} (left arrow key)`}
263
- >
264
- <span aria-hidden className="cg-swipe-arrow">
265
- <svg width="16" height="16" viewBox="0 0 16 16">
266
- <path
267
- d="M11 3.2 6.2 8l4.8 4.8"
268
- fill="none"
269
- stroke="currentColor"
270
- strokeWidth="1.6"
271
- strokeLinecap="round"
272
- strokeLinejoin="round"
273
- />
274
- </svg>
275
- </span>
276
- {binding.spec.leftOption}
277
- </button>
278
- <article
279
- className="cg-swipe-card"
280
- role="button"
281
- tabIndex={0}
282
- onClick={(e) => {
283
- // ⭐ W41-T48 β€” the card IS the deck's record open, so it folds the rail.
284
- // ⚠ Nothing that is NOT an open can reach this line: every control inside the
285
- // card (a url cell, the comments box, the document tray) already stops its own
286
- // click, and the left/right decision buttons are siblings of the card, not
287
- // children of it. The deck also has no pointer drag to disambiguate β€” its swipe
288
- // is the two buttons and the arrow keys, neither of which opens anything.
289
- // ⚠ `<article role="button">`, not a native button, so a keyboard activation
290
- // never arrives here as a synthetic click; the keydown path below is the whole
291
- // keyboard story and the two cannot both fire for one gesture.
292
- if (e.detail <= 1) signal(NAV_MINIMIZE_EVENT);
293
- onOpen(card.pid);
294
- }}
295
- onKeyDown={(e) => {
296
- if (e.key !== "Enter" && e.key !== " ") return;
297
- e.preventDefault();
298
- // W41-T48 β€” `!e.repeat` is this path's `e.detail <= 1`: a HELD Enter or Space
299
- // auto-repeats keydown, and every one of those repeats is the SAME gesture.
300
- if (!e.repeat) signal(NAV_MINIMIZE_EVENT);
301
- onOpen(card.pid);
302
- }}
303
- >
304
- <h3 className="cg-swipe-title">{String(card[titleKey] ?? "")}</h3>
305
- <div className="cg-swipe-cells">
306
- {/* ⭐ WAVE-29 T26 (R9) β€” the RECORD's visible fields, not the three-key summary the
307
- kanban card lends. `detailKeys` falls back to `cardKeys` so a host that has not
308
- been widened yet renders exactly what it rendered before. */}
309
- {(detailKeys && detailKeys.length ? detailKeys : cardKeys).map((k) => {
310
- const f = fieldByKey.get(k);
311
- if (!f) return null;
312
- const text = formatDisplay(f, card[k]);
313
- if (!text) return null;
314
- // A url cell is a LINK here too (wave-26 item 11), through `display.ts`'s one
315
- // scheme guard β€” and every gesture that reaches it must be stopped from also
316
- // reaching the card, which is a button (the kanban card's note, same trap).
317
- const href = actionHref(f, card[k]);
318
- return (
319
- <div key={k} className="cg-swipe-cell">
320
- <span className="cg-lv-k">{f.label}</span>
321
- {href ? (
322
- <a
323
- className="cg-lv-v"
324
- href={href}
325
- target="_blank"
326
- rel="noopener noreferrer"
327
- title={text}
328
- onClick={(e) => e.stopPropagation()}
329
- onKeyDown={(e) => e.stopPropagation()}
330
- >
331
- {text}
332
- </a>
333
- ) : (
334
- <span className="cg-lv-v">{text}</span>
335
- )}
336
- </div>
337
- );
338
- })}
339
- </div>
340
- {/* ⭐ R9's other two thirds. Both stop their own events: the card is a `role="button"`
341
- that opens the record, and a click on a comment box or an upload control must not
342
- also open the modal it exists to make unnecessary (the kanban card's link trap,
343
- one surface over). */}
344
- {onDocAdd && onDocFetch && onDocDelete && (
345
- <div
346
- className="cg-swipe-docs"
347
- onClick={(e) => e.stopPropagation()}
348
- onKeyDown={(e) => e.stopPropagation()}
349
- role="presentation"
350
- >
351
- <Documents
352
- pid={Number(card.pid)}
353
- docs={docs?.[String(card.pid)] ?? []}
354
- docPayload={docPayload}
355
- onAdd={(file) => onDocAdd(card.pid, file)}
356
- onFetch={(docId) => onDocFetch(card.pid, docId)}
357
- onDelete={(docId) => onDocDelete(card.pid, docId)}
358
- />
359
- </div>
360
- )}
361
- {scope != null && (
362
- <div
363
- className="cg-swipe-comments"
364
- onClick={(e) => e.stopPropagation()}
365
- onKeyDown={(e) => e.stopPropagation()}
366
- role="presentation"
367
- >
368
- {/* ⚠ KEYED ON scope+pid, the same key `RecordDetail` uses: without it React
369
- reuses the mounted instance across a card change and paints one record's
370
- comments under another's name until the fetch returns. */}
371
- <RecordComments
372
- key={`${scope}:${Number(card.pid)}`}
373
- scope={scope}
374
- pid={Number(card.pid)}
375
- viewer={viewer}
376
- />
377
- </div>
378
- )}
379
- {canWrite && (
380
- <button
381
- type="button"
382
- className="cg-link-btn cg-swipe-later"
383
- onClick={(e) => {
384
- e.stopPropagation();
385
- setLater((prev) => new Set(prev).add(card.pid));
386
- }}
387
- >
388
- Decide later
389
- </button>
390
- )}
391
- </article>
392
- <button
393
- type="button"
394
- className="cg-swipe-side cg-swipe-side--right"
395
- disabled={!canWrite}
396
- style={rightTint ? { background: rightTint.bg, color: rightTint.fg } : undefined}
397
- onClick={() => decide(binding.spec.rightOption)}
398
- title={`Set ${field.label} to ${binding.spec.rightOption} (right arrow key)`}
399
- >
400
- {binding.spec.rightOption}
401
- <span aria-hidden className="cg-swipe-arrow">
402
- <svg width="16" height="16" viewBox="0 0 16 16">
403
- <path
404
- d="M5 3.2 9.8 8 5 12.8"
405
- fill="none"
406
- stroke="currentColor"
407
- strokeWidth="1.6"
408
- strokeLinecap="round"
409
- strokeLinejoin="round"
410
- />
411
- </svg>
412
- </span>
413
- </button>
414
- </div>
415
- ) : (
416
- // The empty state NAMES the bound field (C3), because "nothing to sort" is ambiguous
417
- // between "the deck is finished" and "the filter hid everything", and the two want
418
- // different next actions. `later` is disclosed rather than quietly subtracted.
419
- <div className="cg-mode-empty cg-swipe-empty">
420
- {later.size > 0 ? (
421
- <>
422
- <p>
423
- {later.size.toLocaleString()}{" "}
424
- {later.size === 1 ? "record is" : "records are"} set aside for later. Nothing else
425
- in this view is missing a <strong>{field.label}</strong>.
426
- </p>
427
- <button type="button" className="cg-btn" onClick={() => setLater(new Set())}>
428
- Bring them back
429
- </button>
430
- </>
431
- ) : (
432
- <p>
433
- Every record in this view already has a <strong>{field.label}</strong>. Widen the
434
- view's filters to sort more.
435
- </p>
436
- )}
437
- </div>
438
- )}
439
- </div>
440
- );
441
- }
442
-
443
- /**
444
- * The binding picker: field + two options. It is also the surface every LOST binding lands on,
445
- * with the loss stated above it β€” one place that answers "what is this deck for", rather than an
446
- * error screen beside a separate setup screen.
447
- */
448
- function SwipeBinder({
449
- fault,
450
- fields,
451
- fieldByKey,
452
- spec,
453
- canBind,
454
- onSpec,
455
- }: {
456
- fault: BindingFault;
457
- fields: Field[];
458
- fieldByKey: Map<string, Field>;
459
- spec: SwipeSpec | undefined;
460
- canBind: boolean;
461
- onSpec: (next: SwipeSpec | undefined) => void;
462
- }) {
463
- const selects = useMemo(
464
- () => fields.filter((f) => f.type === "select" && choiceOptions(f).length >= 2),
465
- [fields]
466
- );
467
- const [fieldKey, setFieldKey] = useState<string>(() => {
468
- if (spec && fieldByKey.get(spec.fieldKey)?.type === "select") return spec.fieldKey;
469
- return selects[0]?.key ?? "";
470
- });
471
- const chosen = fieldByKey.get(fieldKey);
472
- const options = useMemo(() => (chosen ? choiceOptions(chosen) : []), [chosen]);
473
- const [left, setLeft] = useState<string>("");
474
- const [right, setRight] = useState<string>("");
475
-
476
- // The two option selects follow the FIELD. Kept in an effect rather than derived so the user's
477
- // pick survives re-renders, and reset whenever the field changes β€” carrying an option from the
478
- // previous field would offer a value the new field cannot hold.
479
- useEffect(() => {
480
- setLeft(options[0] ?? "");
481
- setRight(options.find((o) => o !== options[0]) ?? "");
482
- }, [options]);
483
-
484
- const message = ((): string => {
485
- switch (fault.kind) {
486
- case "field-gone":
487
- return "The field this deck sorted into has been deleted. Pick another one.";
488
- case "not-select":
489
- return `β€œ${fault.field.label}” is no longer a single-select, so it has no options to`
490
- + " sort into. Pick another field.";
491
- case "option-gone":
492
- return `${fault.missing.map((m) => `β€œ${m}”`).join(" and ")} ${
493
- fault.missing.length === 1 ? "is" : "are"
494
- } no longer ${fault.missing.length === 1 ? "an option" : "options"} on β€œ${
495
- fault.field.label
496
- }”. Pick the sides again.`;
497
- default:
498
- return "Sort records one at a time into two options of a single-select field.";
499
- }
500
- })();
501
-
502
- if (!canBind) {
503
- return (
504
- <div className="cg-mode-empty">
505
- <p>{message}</p>
506
- <p>Its creator or an admin can set this view up.</p>
507
- </div>
508
- );
509
- }
510
- if (selects.length === 0) {
511
- // Honest and specific: the mode is not broken, the TABLE has nothing to bind to. Naming the
512
- // requirement is what turns a dead end into a next action.
513
- return (
514
- <div className="cg-mode-empty">
515
- <p>
516
- A swipe deck sorts into a single-select field with at least two options. This database
517
- does not have one yet β€” add a single-select column, then come back.
518
- </p>
519
- </div>
520
- );
521
- }
522
- const ready = !!chosen && !!left && !!right && left.toLowerCase() !== right.toLowerCase();
523
- return (
524
- <div className="cg-swipe-setup">
525
- <p className="cg-swipe-setup-note">{message}</p>
526
- <label className="cg-swipe-setup-row">
527
- <span>Sort into</span>
528
- <select
529
- className="cg-select"
530
- value={fieldKey}
531
- onChange={(e) => setFieldKey(e.target.value)}
532
- >
533
- {/* Every option carries an explicit `value`: a <select> whose value names nothing
534
- renders its FIRST option and reports a choice the user never made
535
- ([[cg-condition-builder-items]]). */}
536
- {selects.map((f) => (
537
- <option key={f.key} value={f.key}>
538
- {f.label}
539
- </option>
540
- ))}
541
- </select>
542
- </label>
543
- <div className="cg-swipe-setup-sides">
544
- <label className="cg-swipe-setup-row">
545
- <span>Swipe left</span>
546
- <select className="cg-select" value={left} onChange={(e) => setLeft(e.target.value)}>
547
- {options.map((o) => (
548
- <option key={o} value={o}>
549
- {o}
550
- </option>
551
- ))}
552
- </select>
553
- </label>
554
- <label className="cg-swipe-setup-row">
555
- <span>Swipe right</span>
556
- <select className="cg-select" value={right} onChange={(e) => setRight(e.target.value)}>
557
- {options.map((o) => (
558
- <option key={o} value={o}>
559
- {o}
560
- </option>
561
- ))}
562
- </select>
563
- </label>
564
- </div>
565
- {!ready && left && right && (
566
- // Stated, not silently refused: both engines DROP a binding whose sides are the same
567
- // option, so a Save that looked like it worked would simply not persist.
568
- <p className="cg-swipe-setup-warn">
569
- The two sides must be different options β€” otherwise both gestures do the same thing.
570
- </p>
571
- )}
572
- <button
573
- type="button"
574
- className="cg-btn cg-btn--primary"
575
- disabled={!ready}
576
- onClick={() =>
577
- chosen && onSpec({ fieldKey: chosen.key, leftOption: left, rightOption: right })
578
- }
579
- >
580
- Start sorting
581
- </button>
582
- </div>
583
- );
584
- }
585
-
586
- export default SwipeView;
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / SwipeView.tsx
3
+ // ⭐ WAVE-27 item 8 (owner ruling R2, contract C3) β€” the SWIPE deck.
4
+ //
5
+ // One record at a time, decided left or right into two options of ONE
6
+ // single-select. R2 fixes the shape and it is narrow on purpose:
7
+ // Β· SINGLE-SELECT ONLY, one field, two of its options. No checkbox binding.
8
+ // Β· the deck holds only the records whose bound field is EMPTY β€” a record
9
+ // that already has a value has been decided, and re-asking is not triage.
10
+ // Β· a swipe writes through the NORMAL cell door (`onSwipe`, which the host
11
+ // maps to the same `patchAndRecord` a kanban card move uses), so undo,
12
+ // echo-suppression and the permission wall all hold without being
13
+ // re-implemented here.
14
+ //
15
+ // Honesty rules carried from the other modes:
16
+ // Β· nothing is silently hidden β€” the deck states what is left, and the
17
+ // empty state names the bound field rather than saying "all done".
18
+ // Β· a lost binding is SHOWN, never repaired by guessing. `_clean_display`
19
+ // validates that `fieldKey` names a field the table HAS and stops there;
20
+ // it cannot know the field is still a select, or that the two options are
21
+ // still in its vocabulary (its docstring draws that line explicitly). So
22
+ // those three losses land here, and each one says what happened and
23
+ // re-opens the picker β€” the `viewModes.tsx:190-194` rule, which exists
24
+ // because the wave-7 trap was a picker silently falling back to its first
25
+ // option.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
29
+ // ⭐⭐ W41-T48 (owner instruction 22) β€” opening a record from the swipe deck folds the shell's
30
+ // navigation rail exactly as a grid cell click does. Guard rationale: `MapView.tsx:2755`.
31
+ import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
32
+ import { choiceOptions } from "./types";
33
+ import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
34
+ // ⚠ `SurfaceScope` is `apiBridge`'s (a TYPE-only import, so nothing of that module is
35
+ // loaded here) β€” the same import `RecordComments` takes for the same prop.
36
+ import type { SurfaceScope } from "./apiBridge";
37
+ import RecordComments from "./RecordComments";
38
+ import { Documents } from "./Documents";
39
+ import { formatDisplay } from "./cells";
40
+ import { actionHref } from "./display";
41
+ import { optionTint } from "./choiceColors";
42
+ import { ModeIcon } from "./icons";
43
+ // ⭐ W29-T73 β€” the line its three siblings all have, and the one this file shipped without.
44
+ // `SwipeView.css` had ZERO references anywhere in `src/`, so T26's two new sections rendered
45
+ // unstyled while `.cg-swipe-card` (which lives in `index.css`) kept the card looking right β€”
46
+ // a partially-styled surface reads as a design choice, which is why nobody reported it and no
47
+ // gate could see it: a side-effect import references no symbol ([[artifact-with-no-importer]]).
48
+ import "./SwipeView.css";
49
+
50
+ export type SwipeSpec = NonNullable<DisplaySpec["swipe"]>;
51
+
52
+ /** The three ways a stored binding can rot that the host cannot see (see the header note). */
53
+ type BindingFault =
54
+ | { kind: "unbound" }
55
+ | { kind: "field-gone"; fieldKey: string }
56
+ | { kind: "not-select"; field: Field }
57
+ | { kind: "option-gone"; field: Field; missing: string[] };
58
+
59
+ function readBinding(spec: SwipeSpec | undefined, fieldByKey: Map<string, Field>):
60
+ { ok: true; field: Field; spec: SwipeSpec } | { ok: false; fault: BindingFault } {
61
+ if (!spec) return { ok: false, fault: { kind: "unbound" } };
62
+ const field = fieldByKey.get(spec.fieldKey);
63
+ if (!field) return { ok: false, fault: { kind: "field-gone", fieldKey: spec.fieldKey } };
64
+ // R2 β€” SINGLE-select only. `multiselect` is deliberately not accepted: its cell holds a SET,
65
+ // so "write this option" would mean append-or-replace, and a triage gesture that sometimes
66
+ // adds and sometimes overwrites is two gestures wearing one button.
67
+ if (field.type !== "select") return { ok: false, fault: { kind: "not-select", field } };
68
+ const options = choiceOptions(field);
69
+ const has = new Set(options.map((o) => o.toLowerCase()));
70
+ const missing = [spec.leftOption, spec.rightOption].filter((o) => !has.has(o.toLowerCase()));
71
+ if (missing.length) return { ok: false, fault: { kind: "option-gone", field, missing } };
72
+ return { ok: true, field, spec };
73
+ }
74
+
75
+ /** The bound cell is EMPTY β€” the one predicate that decides deck membership (R2). */
76
+ const isUndecided = (row: Row, key: string): boolean =>
77
+ String(row[key] ?? "").trim() === "";
78
+
79
+ export function SwipeView({
80
+ rows,
81
+ fields,
82
+ fieldByKey,
83
+ spec,
84
+ cardKeys,
85
+ detailKeys,
86
+ scope,
87
+ viewer,
88
+ docs,
89
+ docPayload,
90
+ onDocAdd,
91
+ onDocFetch,
92
+ onDocDelete,
93
+ titleKey,
94
+ canWrite,
95
+ readOnlyReason,
96
+ canBind,
97
+ onSpec,
98
+ onSwipe,
99
+ onOpen,
100
+ }: {
101
+ /** DISTINCT data rows from the full pipeline, overlay edits layered β€” the kanban's contract.
102
+ * The deck re-derives from these, so a written record leaves it as soon as the optimistic
103
+ * patch lands; there is no local cursor to drift out of step with the data. */
104
+ rows: Row[];
105
+ /** Every field, for the picker's field list. */
106
+ fields: Field[];
107
+ fieldByKey: Map<string, Field>;
108
+ /** The stored binding, or undefined for a deck nobody has configured yet. */
109
+ spec: SwipeSpec | undefined;
110
+ /** The handful of visible fields the card lists under its title. */
111
+ cardKeys: string[];
112
+ titleKey: string;
113
+ /** May this viewer write the BOUND field's value? (the kanban's `canMove`) */
114
+ canWrite: boolean;
115
+ /** Stated when the deck cannot be decided β€” permissions, or a computed column. */
116
+ readOnlyReason: string | null;
117
+ /** May this viewer change what the deck is bound TO? A VIEW-config act, so it is the
118
+ * view-editing permission and NOT `canWrite` β€” conflating the two is
119
+ * [[schema-role-is-not-a-value-wall]]. REQUIRED, because a picker that silently does
120
+ * nothing is worse than no picker ([[wrong-parent-not-broken-control]]). */
121
+ canBind: boolean;
122
+ /** `undefined` DELETES the binding β€” absent is the honest unconfigured state, and storing a
123
+ * half-binding is what `cleanDisplay` drops on both engines (the `kanbanClamp` law). */
124
+ onSpec: (next: SwipeSpec | undefined) => void;
125
+ onSwipe: (pid: number, value: string) => void;
126
+ onOpen: (pid: number) => void;
127
+ /**
128
+ * ⭐⭐ WAVE-29 T26 (owner R9) β€” WHAT MAKES THE CARD A RECORD RATHER THAN A SUMMARY.
129
+ *
130
+ * R9: *"the swipe CARD itself renders the record detail inline β€” fields, comments and
131
+ * attachments β€” so a reviewer decides without leaving the deck."* Everything below is that,
132
+ * and every one of them is OPTIONAL for one reason: this deck runs on hosts that supply
133
+ * different amounts. A card must render with none of them rather than throw β€” the standalone
134
+ * embed has no `scope`, and a deployment with no document storage serves no handlers.
135
+ */
136
+ /** Every field the VIEW shows, in its order β€” not the three-key card summary. */
137
+ detailKeys?: string[];
138
+ /** The surface these records live on. Comments are keyed by it; absent β‡’ no comments section,
139
+ * which is the pre-existing precondition `RecordDetail` already carries. */
140
+ scope?: SurfaceScope;
141
+ viewer?: Viewer;
142
+ /** This record's attachments, and the plumbing. Handlers absent β‡’ the section is not rendered
143
+ * at all, exactly as `RecordDetail` decides it (a dead upload control is a promise the app
144
+ * cannot keep). */
145
+ /** Keyed by pid, because a deck shows many records β€” `RecordDetail` takes ONE record's list
146
+ * and that shape cannot serve a deck. */
147
+ docs?: Record<string, CustomerDoc[]>;
148
+ docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string };
149
+ /** ⚠ EVERY HANDLER TAKES THE PID, unlike `RecordDetail`'s, whose host closes over the ONE
150
+ * open record. A deck paints many records at once, so a handler bound to a single pid would
151
+ * attach every reviewer's upload to whichever card happened to be open. */
152
+ onDocAdd?: (
153
+ pid: number,
154
+ file: { name: string; mime: string; size: number; data_b64: string }
155
+ ) => void;
156
+ onDocFetch?: (pid: number, docId: string) => void;
157
+ onDocDelete?: (pid: number, docId: string) => void;
158
+ }) {
159
+ // "Later" β€” session-only, never stored. A deck with no way past a record you cannot decide
160
+ // blocks on that record forever, so this is navigation WITHIN the deck rather than a third
161
+ // gesture: it writes nothing, survives no reload, and the count is disclosed in the empty
162
+ // state rather than quietly shrinking the deck (rule 8b).
163
+ const [later, setLater] = useState<Set<number>>(new Set());
164
+ const deckRef = useRef<HTMLDivElement>(null);
165
+
166
+ const binding = useMemo(() => readBinding(spec, fieldByKey), [spec, fieldByKey]);
167
+ const boundKey = binding.ok ? binding.field.key : null;
168
+
169
+ const undecided = useMemo(
170
+ () => (boundKey ? rows.filter((r) => isUndecided(r, boundKey)) : []),
171
+ [rows, boundKey]
172
+ );
173
+ const deck = useMemo(() => undecided.filter((r) => !later.has(r.pid)), [undecided, later]);
174
+ const card = deck[0];
175
+
176
+ // A record decided elsewhere (the grid, another user's echo) must not stay "later" forever β€”
177
+ // otherwise the disclosed skip count drifts away from what is actually on the deck.
178
+ useEffect(() => {
179
+ setLater((prev) => {
180
+ if (prev.size === 0) return prev;
181
+ const live = new Set(undecided.map((r) => r.pid));
182
+ const next = new Set([...prev].filter((pid) => live.has(pid)));
183
+ return next.size === prev.size ? prev : next;
184
+ });
185
+ }, [undecided]);
186
+
187
+ const decide = useCallback(
188
+ (value: string) => {
189
+ if (!card || !canWrite) return;
190
+ onSwipe(card.pid, value);
191
+ },
192
+ [card, canWrite, onSwipe]
193
+ );
194
+
195
+ // ← / β†’ decide, and they are the SAME two actions the buttons are rather than a second code
196
+ // path: the whole point of the mode is that one hand can clear a queue.
197
+ useEffect(() => {
198
+ if (!binding.ok || !canWrite || !card) return;
199
+ const onKey = (e: KeyboardEvent) => {
200
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
201
+ const el = document.activeElement as HTMLElement | null;
202
+ // Never steal an arrow key from a text field or a native control the user is inside.
203
+ if (el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA"))
204
+ return;
205
+ if (!deckRef.current?.contains(el ?? null) && el !== document.body) return;
206
+ e.preventDefault();
207
+ decide(e.key === "ArrowLeft" ? binding.spec.leftOption : binding.spec.rightOption);
208
+ };
209
+ window.addEventListener("keydown", onKey);
210
+ return () => window.removeEventListener("keydown", onKey);
211
+ }, [binding, canWrite, card, decide]);
212
+
213
+ if (!binding.ok) {
214
+ return (
215
+ <div className="cg-swipe" ref={deckRef}>
216
+ <SwipeBinder
217
+ fault={binding.fault}
218
+ fields={fields}
219
+ fieldByKey={fieldByKey}
220
+ spec={spec}
221
+ canBind={canBind}
222
+ onSpec={onSpec}
223
+ />
224
+ </div>
225
+ );
226
+ }
227
+
228
+ const { field } = binding;
229
+ const leftTint = optionTint(field, binding.spec.leftOption);
230
+ const rightTint = optionTint(field, binding.spec.rightOption);
231
+
232
+ return (
233
+ <div className="cg-swipe" ref={deckRef} tabIndex={-1}>
234
+ <div className="cg-swipe-bar">
235
+ <span className="cg-swipe-bound">
236
+ <ModeIcon mode="swipe" />
237
+ Sorting into <strong>{field.label}</strong>
238
+ </span>
239
+ <span className="cg-swipe-left">
240
+ {deck.length.toLocaleString()} {deck.length === 1 ? "record" : "records"} to sort
241
+ </span>
242
+ {canBind && (
243
+ <button
244
+ type="button"
245
+ className="cg-link-btn"
246
+ onClick={() => onSpec(undefined)}
247
+ title="Choose a different field or different options for this deck"
248
+ >
249
+ Change
250
+ </button>
251
+ )}
252
+ </div>
253
+ {readOnlyReason && <div className="cg-kb-note">{readOnlyReason}</div>}
254
+ {card ? (
255
+ <div className="cg-swipe-deck">
256
+ <button
257
+ type="button"
258
+ className="cg-swipe-side cg-swipe-side--left"
259
+ disabled={!canWrite}
260
+ style={leftTint ? { background: leftTint.bg, color: leftTint.fg } : undefined}
261
+ onClick={() => decide(binding.spec.leftOption)}
262
+ title={`Set ${field.label} to ${binding.spec.leftOption} (left arrow key)`}
263
+ >
264
+ <span aria-hidden className="cg-swipe-arrow">
265
+ <svg width="16" height="16" viewBox="0 0 16 16">
266
+ <path
267
+ d="M11 3.2 6.2 8l4.8 4.8"
268
+ fill="none"
269
+ stroke="currentColor"
270
+ strokeWidth="1.6"
271
+ strokeLinecap="round"
272
+ strokeLinejoin="round"
273
+ />
274
+ </svg>
275
+ </span>
276
+ {binding.spec.leftOption}
277
+ </button>
278
+ <article
279
+ className="cg-swipe-card"
280
+ role="button"
281
+ tabIndex={0}
282
+ onClick={(e) => {
283
+ // ⭐ W41-T48 β€” the card IS the deck's record open, so it folds the rail.
284
+ // ⚠ Nothing that is NOT an open can reach this line: every control inside the
285
+ // card (a url cell, the comments box, the document tray) already stops its own
286
+ // click, and the left/right decision buttons are siblings of the card, not
287
+ // children of it. The deck also has no pointer drag to disambiguate β€” its swipe
288
+ // is the two buttons and the arrow keys, neither of which opens anything.
289
+ // ⚠ `<article role="button">`, not a native button, so a keyboard activation
290
+ // never arrives here as a synthetic click; the keydown path below is the whole
291
+ // keyboard story and the two cannot both fire for one gesture.
292
+ if (e.detail <= 1) signal(NAV_MINIMIZE_EVENT);
293
+ onOpen(card.pid);
294
+ }}
295
+ onKeyDown={(e) => {
296
+ if (e.key !== "Enter" && e.key !== " ") return;
297
+ e.preventDefault();
298
+ // W41-T48 β€” `!e.repeat` is this path's `e.detail <= 1`: a HELD Enter or Space
299
+ // auto-repeats keydown, and every one of those repeats is the SAME gesture.
300
+ if (!e.repeat) signal(NAV_MINIMIZE_EVENT);
301
+ onOpen(card.pid);
302
+ }}
303
+ >
304
+ <h3 className="cg-swipe-title">{String(card[titleKey] ?? "")}</h3>
305
+ <div className="cg-swipe-cells">
306
+ {/* ⭐ WAVE-29 T26 (R9) β€” the RECORD's visible fields, not the three-key summary the
307
+ kanban card lends. `detailKeys` falls back to `cardKeys` so a host that has not
308
+ been widened yet renders exactly what it rendered before. */}
309
+ {(detailKeys && detailKeys.length ? detailKeys : cardKeys).map((k) => {
310
+ const f = fieldByKey.get(k);
311
+ if (!f) return null;
312
+ const text = formatDisplay(f, card[k]);
313
+ if (!text) return null;
314
+ // A url cell is a LINK here too (wave-26 item 11), through `display.ts`'s one
315
+ // scheme guard β€” and every gesture that reaches it must be stopped from also
316
+ // reaching the card, which is a button (the kanban card's note, same trap).
317
+ const href = actionHref(f, card[k]);
318
+ return (
319
+ <div key={k} className="cg-swipe-cell">
320
+ <span className="cg-lv-k">{f.label}</span>
321
+ {href ? (
322
+ <a
323
+ className="cg-lv-v"
324
+ href={href}
325
+ target="_blank"
326
+ rel="noopener noreferrer"
327
+ title={text}
328
+ onClick={(e) => e.stopPropagation()}
329
+ onKeyDown={(e) => e.stopPropagation()}
330
+ >
331
+ {text}
332
+ </a>
333
+ ) : (
334
+ <span className="cg-lv-v">{text}</span>
335
+ )}
336
+ </div>
337
+ );
338
+ })}
339
+ </div>
340
+ {/* ⭐ R9's other two thirds. Both stop their own events: the card is a `role="button"`
341
+ that opens the record, and a click on a comment box or an upload control must not
342
+ also open the modal it exists to make unnecessary (the kanban card's link trap,
343
+ one surface over). */}
344
+ {onDocAdd && onDocFetch && onDocDelete && (
345
+ <div
346
+ className="cg-swipe-docs"
347
+ onClick={(e) => e.stopPropagation()}
348
+ onKeyDown={(e) => e.stopPropagation()}
349
+ role="presentation"
350
+ >
351
+ <Documents
352
+ pid={Number(card.pid)}
353
+ docs={docs?.[String(card.pid)] ?? []}
354
+ docPayload={docPayload}
355
+ onAdd={(file) => onDocAdd(card.pid, file)}
356
+ onFetch={(docId) => onDocFetch(card.pid, docId)}
357
+ onDelete={(docId) => onDocDelete(card.pid, docId)}
358
+ />
359
+ </div>
360
+ )}
361
+ {scope != null && (
362
+ <div
363
+ className="cg-swipe-comments"
364
+ onClick={(e) => e.stopPropagation()}
365
+ onKeyDown={(e) => e.stopPropagation()}
366
+ role="presentation"
367
+ >
368
+ {/* ⚠ KEYED ON scope+pid, the same key `RecordDetail` uses: without it React
369
+ reuses the mounted instance across a card change and paints one record's
370
+ comments under another's name until the fetch returns. */}
371
+ <RecordComments
372
+ key={`${scope}:${Number(card.pid)}`}
373
+ scope={scope}
374
+ pid={Number(card.pid)}
375
+ viewer={viewer}
376
+ />
377
+ </div>
378
+ )}
379
+ {canWrite && (
380
+ <button
381
+ type="button"
382
+ className="cg-link-btn cg-swipe-later"
383
+ onClick={(e) => {
384
+ e.stopPropagation();
385
+ setLater((prev) => new Set(prev).add(card.pid));
386
+ }}
387
+ >
388
+ Decide later
389
+ </button>
390
+ )}
391
+ </article>
392
+ <button
393
+ type="button"
394
+ className="cg-swipe-side cg-swipe-side--right"
395
+ disabled={!canWrite}
396
+ style={rightTint ? { background: rightTint.bg, color: rightTint.fg } : undefined}
397
+ onClick={() => decide(binding.spec.rightOption)}
398
+ title={`Set ${field.label} to ${binding.spec.rightOption} (right arrow key)`}
399
+ >
400
+ {binding.spec.rightOption}
401
+ <span aria-hidden className="cg-swipe-arrow">
402
+ <svg width="16" height="16" viewBox="0 0 16 16">
403
+ <path
404
+ d="M5 3.2 9.8 8 5 12.8"
405
+ fill="none"
406
+ stroke="currentColor"
407
+ strokeWidth="1.6"
408
+ strokeLinecap="round"
409
+ strokeLinejoin="round"
410
+ />
411
+ </svg>
412
+ </span>
413
+ </button>
414
+ </div>
415
+ ) : (
416
+ // The empty state NAMES the bound field (C3), because "nothing to sort" is ambiguous
417
+ // between "the deck is finished" and "the filter hid everything", and the two want
418
+ // different next actions. `later` is disclosed rather than quietly subtracted.
419
+ <div className="cg-mode-empty cg-swipe-empty">
420
+ {later.size > 0 ? (
421
+ <>
422
+ <p>
423
+ {later.size.toLocaleString()}{" "}
424
+ {later.size === 1 ? "record is" : "records are"} set aside for later. Nothing else
425
+ in this view is missing a <strong>{field.label}</strong>.
426
+ </p>
427
+ <button type="button" className="cg-btn" onClick={() => setLater(new Set())}>
428
+ Bring them back
429
+ </button>
430
+ </>
431
+ ) : (
432
+ <p>
433
+ Every record in this view already has a <strong>{field.label}</strong>. Widen the
434
+ view's filters to sort more.
435
+ </p>
436
+ )}
437
+ </div>
438
+ )}
439
+ </div>
440
+ );
441
+ }
442
+
443
+ /**
444
+ * The binding picker: field + two options. It is also the surface every LOST binding lands on,
445
+ * with the loss stated above it β€” one place that answers "what is this deck for", rather than an
446
+ * error screen beside a separate setup screen.
447
+ */
448
+ function SwipeBinder({
449
+ fault,
450
+ fields,
451
+ fieldByKey,
452
+ spec,
453
+ canBind,
454
+ onSpec,
455
+ }: {
456
+ fault: BindingFault;
457
+ fields: Field[];
458
+ fieldByKey: Map<string, Field>;
459
+ spec: SwipeSpec | undefined;
460
+ canBind: boolean;
461
+ onSpec: (next: SwipeSpec | undefined) => void;
462
+ }) {
463
+ const selects = useMemo(
464
+ () => fields.filter((f) => f.type === "select" && choiceOptions(f).length >= 2),
465
+ [fields]
466
+ );
467
+ const [fieldKey, setFieldKey] = useState<string>(() => {
468
+ if (spec && fieldByKey.get(spec.fieldKey)?.type === "select") return spec.fieldKey;
469
+ return selects[0]?.key ?? "";
470
+ });
471
+ const chosen = fieldByKey.get(fieldKey);
472
+ const options = useMemo(() => (chosen ? choiceOptions(chosen) : []), [chosen]);
473
+ const [left, setLeft] = useState<string>("");
474
+ const [right, setRight] = useState<string>("");
475
+
476
+ // The two option selects follow the FIELD. Kept in an effect rather than derived so the user's
477
+ // pick survives re-renders, and reset whenever the field changes β€” carrying an option from the
478
+ // previous field would offer a value the new field cannot hold.
479
+ useEffect(() => {
480
+ setLeft(options[0] ?? "");
481
+ setRight(options.find((o) => o !== options[0]) ?? "");
482
+ }, [options]);
483
+
484
+ const message = ((): string => {
485
+ switch (fault.kind) {
486
+ case "field-gone":
487
+ return "The field this deck sorted into has been deleted. Pick another one.";
488
+ case "not-select":
489
+ return `β€œ${fault.field.label}” is no longer a single-select, so it has no options to`
490
+ + " sort into. Pick another field.";
491
+ case "option-gone":
492
+ return `${fault.missing.map((m) => `β€œ${m}”`).join(" and ")} ${
493
+ fault.missing.length === 1 ? "is" : "are"
494
+ } no longer ${fault.missing.length === 1 ? "an option" : "options"} on β€œ${
495
+ fault.field.label
496
+ }”. Pick the sides again.`;
497
+ default:
498
+ return "Sort records one at a time into two options of a single-select field.";
499
+ }
500
+ })();
501
+
502
+ if (!canBind) {
503
+ return (
504
+ <div className="cg-mode-empty">
505
+ <p>{message}</p>
506
+ <p>Its creator or an admin can set this view up.</p>
507
+ </div>
508
+ );
509
+ }
510
+ if (selects.length === 0) {
511
+ // Honest and specific: the mode is not broken, the TABLE has nothing to bind to. Naming the
512
+ // requirement is what turns a dead end into a next action.
513
+ return (
514
+ <div className="cg-mode-empty">
515
+ <p>
516
+ A swipe deck sorts into a single-select field with at least two options. This database
517
+ does not have one yet β€” add a single-select column, then come back.
518
+ </p>
519
+ </div>
520
+ );
521
+ }
522
+ const ready = !!chosen && !!left && !!right && left.toLowerCase() !== right.toLowerCase();
523
+ return (
524
+ <div className="cg-swipe-setup">
525
+ <p className="cg-swipe-setup-note">{message}</p>
526
+ <label className="cg-swipe-setup-row">
527
+ <span>Sort into</span>
528
+ <select
529
+ className="cg-select"
530
+ value={fieldKey}
531
+ onChange={(e) => setFieldKey(e.target.value)}
532
+ >
533
+ {/* Every option carries an explicit `value`: a <select> whose value names nothing
534
+ renders its FIRST option and reports a choice the user never made
535
+ ([[cg-condition-builder-items]]). */}
536
+ {selects.map((f) => (
537
+ <option key={f.key} value={f.key}>
538
+ {f.label}
539
+ </option>
540
+ ))}
541
+ </select>
542
+ </label>
543
+ <div className="cg-swipe-setup-sides">
544
+ <label className="cg-swipe-setup-row">
545
+ <span>Swipe left</span>
546
+ <select className="cg-select" value={left} onChange={(e) => setLeft(e.target.value)}>
547
+ {options.map((o) => (
548
+ <option key={o} value={o}>
549
+ {o}
550
+ </option>
551
+ ))}
552
+ </select>
553
+ </label>
554
+ <label className="cg-swipe-setup-row">
555
+ <span>Swipe right</span>
556
+ <select className="cg-select" value={right} onChange={(e) => setRight(e.target.value)}>
557
+ {options.map((o) => (
558
+ <option key={o} value={o}>
559
+ {o}
560
+ </option>
561
+ ))}
562
+ </select>
563
+ </label>
564
+ </div>
565
+ {!ready && left && right && (
566
+ // Stated, not silently refused: both engines DROP a binding whose sides are the same
567
+ // option, so a Save that looked like it worked would simply not persist.
568
+ <p className="cg-swipe-setup-warn">
569
+ The two sides must be different options β€” otherwise both gestures do the same thing.
570
+ </p>
571
+ )}
572
+ <button
573
+ type="button"
574
+ className="cg-btn cg-btn--primary"
575
+ disabled={!ready}
576
+ onClick={() =>
577
+ chosen && onSpec({ fieldKey: chosen.key, leftOption: left, rightOption: right })
578
+ }
579
+ >
580
+ Start sorting
581
+ </button>
582
+ </div>
583
+ );
584
+ }
585
+
586
+ export default SwipeView;
web/src/customer-grid/Toolbar.tsx CHANGED
@@ -167,6 +167,8 @@ export interface ToolbarProps {
167
  * not, so the marker says something different and does not go away.
168
  */
169
  unresolvedCount?: number;
 
 
170
  /** Owner item 5 β€” the cohorts this user has, for `Where [Cohort] [is part of] […]`. */
171
  lists?: { id: string; name: string }[];
172
  /** ⭐⭐ W37-T26 (C4) β€” the views a `Where [View] [is] […]` condition may name. A PASS-THROUGH:
@@ -722,6 +724,7 @@ export default function Toolbar({
722
  measures = [],
723
  pendingMeasureCount = 0,
724
  unresolvedCount = 0,
 
725
  lists = [],
726
  viewChoices = [],
727
  cohortLock,
@@ -1322,9 +1325,10 @@ export default function Toolbar({
1322
  <span
1323
  className="cg-count cg-count-pending"
1324
  title={
1325
- `${unresolvedCount} condition${unresolvedCount === 1 ? "" : "s"} cannot be ` +
1326
- "evaluated: a date that does not resolve, or a list this view refers to that no " +
1327
- "longer exists. Rows are hidden rather than shown under a count that would be wrong."
 
1328
  }
1329
  >
1330
  Filter unresolved
 
167
  * not, so the marker says something different and does not go away.
168
  */
169
  unresolvedCount?: number;
170
+ /** Exact reasons for unresolved conditions, shown in the marker's native hover text. */
171
+ unresolvedReasons?: readonly string[];
172
  /** Owner item 5 β€” the cohorts this user has, for `Where [Cohort] [is part of] […]`. */
173
  lists?: { id: string; name: string }[];
174
  /** ⭐⭐ W37-T26 (C4) β€” the views a `Where [View] [is] […]` condition may name. A PASS-THROUGH:
 
724
  measures = [],
725
  pendingMeasureCount = 0,
726
  unresolvedCount = 0,
727
+ unresolvedReasons = [],
728
  lists = [],
729
  viewChoices = [],
730
  cohortLock,
 
1325
  <span
1326
  className="cg-count cg-count-pending"
1327
  title={
1328
+ unresolvedReasons.length
1329
+ ? unresolvedReasons.join("\n")
1330
+ : `${unresolvedCount} condition${unresolvedCount === 1 ? "" : "s"} cannot be ` +
1331
+ "evaluated. Rows are hidden rather than shown under a count that would be wrong."
1332
  }
1333
  >
1334
  Filter unresolved
web/src/customer-grid/apiBridge.ts CHANGED
@@ -329,6 +329,8 @@ export interface RowWindowRequest {
329
  filterConj?: string;
330
  sorts?: SortSpec | null;
331
  search?: string;
 
 
332
  }
333
 
334
  /**
@@ -360,6 +362,11 @@ export function windowRowsPath(tableKey: string, req: RowWindowRequest): string
360
  if (req.sorts && req.sorts.length) q.set("sorts", JSON.stringify(req.sorts));
361
  const search = (req.search ?? "").trim();
362
  if (search) q.set("search", search);
 
 
 
 
 
363
  return `odoo-tables/${encodeURIComponent(tableKey)}/rows?${q.toString()}`;
364
  }
365
 
 
329
  filterConj?: string;
330
  sorts?: SortSpec | null;
331
  search?: string;
332
+ /** Existing Link choices retained when a new picker condition no longer matches them. */
333
+ includePids?: readonly number[];
334
  }
335
 
336
  /**
 
362
  if (req.sorts && req.sorts.length) q.set("sorts", JSON.stringify(req.sorts));
363
  const search = (req.search ?? "").trim();
364
  if (search) q.set("search", search);
365
+ if (req.includePids && req.includePids.length) {
366
+ const ids = [...new Set(req.includePids.filter((pid) => Number.isInteger(pid) && pid > 0))]
367
+ .sort((a, b) => a - b);
368
+ if (ids.length) q.set("includePids", JSON.stringify(ids));
369
+ }
370
  return `odoo-tables/${encodeURIComponent(tableKey)}/rows?${q.toString()}`;
371
  }
372
 
web/src/customer-grid/icons.tsx CHANGED
@@ -104,6 +104,21 @@ export function FieldTypeIcon({
104
  );
105
  }
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  /** The display-mode mark for the View switcher (I18). */
108
  export function ModeIcon({ mode, size = 14 }: { mode: DisplayMode; size?: number }) {
109
  return (
 
104
  );
105
  }
106
 
107
+ /**
108
+ * The compact, outline-only information mark shared by field surfaces. Its canvas sibling is
109
+ * `HEADER_ICONS.aiosInfo`; keeping the same ring-and-i geometry makes an explanation read as the
110
+ * same affordance whether it sits in a grid header or beside a picker option.
111
+ */
112
+ export function InfoMark({ size = 14 }: { size?: number }) {
113
+ return (
114
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
115
+ <circle cx="8" cy="8" r="6.1" stroke="currentColor" strokeWidth="1.25" />
116
+ <path d="M8 7.4v3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
117
+ <circle cx="8" cy="5.1" r="0.85" fill="currentColor" />
118
+ </svg>
119
+ );
120
+ }
121
+
122
  /** The display-mode mark for the View switcher (I18). */
123
  export function ModeIcon({ mode, size = 14 }: { mode: DisplayMode; size?: number }) {
124
  return (
web/src/customer-grid/types.ts CHANGED
@@ -2609,6 +2609,12 @@ export interface Field {
2609
  reciprocal?: string;
2610
  /** Airtable's `prefersSingleRecordLink` */
2611
  single?: boolean;
 
 
 
 
 
 
2612
  /**
2613
  * ⭐⭐ W42-T09 (R1, contract C1) β€” THE TWO-HOP JOIN, DECLARED. **Absent is today's one-hop
2614
  * link and is byte-identical to every link stored before the hop existed**, so there is no
@@ -3362,6 +3368,23 @@ export interface FilterRule {
3362
  id?: string;
3363
  }
3364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3365
  /**
3366
  * Is this rule doing any work? A half-typed condition is INACTIVE and gets IGNORED by the
3367
  * combinator β€” not treated as `true`, which under `or` would show every record.
 
2609
  reciprocal?: string;
2610
  /** Airtable's `prefersSingleRecordLink` */
2611
  single?: boolean;
2612
+ /**
2613
+ * W42-T35 β€” static conditions that narrow the records offered by an ordinary Link picker.
2614
+ * They never rewrite existing link cells: a new filter constrains the next selection only.
2615
+ */
2616
+ conditions?: RollupCondition[];
2617
+ conditionConj?: "and" | "or";
2618
  /**
2619
  * ⭐⭐ W42-T09 (R1, contract C1) β€” THE TWO-HOP JOIN, DECLARED. **Absent is today's one-hop
2620
  * link and is byte-identical to every link stored before the hop existed**, so there is no
 
3368
  id?: string;
3369
  }
3370
 
3371
+ /**
3372
+ * W42-T35 β€” the server validates a Link's static Rollup-style conditions, while the picker
3373
+ * reuses the grid's existing filter engine. This adapter is the one vocabulary crossing; it
3374
+ * cannot introduce a dynamic field, a set statistic, a view, a cohort, or a rank condition.
3375
+ */
3376
+ export function linkPickerFilterNodes(conditions: readonly RollupCondition[]): FilterRule[] {
3377
+ return conditions.map((condition, index) => ({
3378
+ id: `link-picker-${index}-${condition.field}`,
3379
+ colId: condition.field,
3380
+ op: condition.op === "not_contains" ? "doesNotContain"
3381
+ : condition.op === "is_empty" ? "isEmpty"
3382
+ : condition.op === "is_not_empty" ? "isNotEmpty"
3383
+ : condition.op,
3384
+ value: condition.value ?? "",
3385
+ }));
3386
+ }
3387
+
3388
  /**
3389
  * Is this rule doing any work? A half-typed condition is INACTIVE and gets IGNORED by the
3390
  * combinator β€” not treated as `true`, which under `or` would show every record.
web/src/customer-grid/useVisibleRows.ts CHANGED
@@ -620,25 +620,27 @@ export function activeMeasureRuleIds(nodes: FilterNode[]): string[] {
620
  * Detectable without the field map: `within` and `dateMode` are shapes only a DATE condition
621
  * has, and `__cohort__` is a reserved key.
622
  */
623
- export function unresolvedConditions(nodes: FilterNode[], ctx?: EvalCtx): number {
624
- let n = 0;
625
  for (const node of nodes ?? []) {
626
  if (isFilterGroup(node)) {
627
- n += unresolvedConditions(node.children, ctx);
628
  continue;
629
  }
630
  if (!isRuleActive(node)) continue;
631
  if (isCohortRule(node)) {
632
  // Any unresolvable member of the set makes the whole condition unanswerable β€” the same
633
  // all-or-nothing rule evalNode applies, so the marker and the rows agree on WHY.
634
- if (cohortIds(node.value).some((id) => !ctx?.cohortSets?.[id])) n += 1;
 
635
  continue;
636
  }
637
  // C4 β€” a view leaf naming a view we cannot resolve (deleted, or not visible to this caller)
638
  // is unanswerable, so it is COUNTED and the toolbar says so. Without this line the grid would
639
  // empty with no explanation, which is D-124 exactly, arriving one leaf over.
640
  if (isViewRule(node)) {
641
- if (!ctx?.viewSets?.[viewRuleId(node.value)]) n += 1;
 
642
  continue;
643
  }
644
  // ⭐⭐ D-229 β€” a leaf whose COLUMN, or whose rhs COLUMN, this table no longer has. `evalNode`
@@ -648,18 +650,27 @@ export function unresolvedConditions(nodes: FilterNode[], ctx?: EvalCtx): number
648
  if (ctx?.fieldByKey && !isMeasureRule(node)) {
649
  const rhsKey = node.rhs ? rhsColId(node.rhs) ?? "" : null;
650
  if (!ctx.fieldByKey.has(node.colId) || (rhsKey !== null && !ctx.fieldByKey.has(rhsKey))) {
651
- n += 1;
 
652
  continue;
653
  }
654
  }
655
  if (isMeasureRule(node)) continue; // that is `pendingMeasures`' job
656
  if (node.op === "within") {
657
- if (!ctx?.today || !resolveWindow(node.dateWindow, ctx.today)) n += 1;
 
 
658
  } else if (node.dateMode) {
659
- if (!ctx?.today || resolveAnchor(node.dateMode, node.value, ctx.today) == null) n += 1;
 
 
660
  }
661
  }
662
- return n;
 
 
 
 
663
  }
664
 
665
  /** Root-level evaluation: the whole tree as one implicit group. Exported for
 
620
  * Detectable without the field map: `within` and `dateMode` are shapes only a DATE condition
621
  * has, and `__cohort__` is a reserved key.
622
  */
623
+ export function unresolvedConditionReasons(nodes: FilterNode[], ctx?: EvalCtx): string[] {
624
+ const reasons: string[] = [];
625
  for (const node of nodes ?? []) {
626
  if (isFilterGroup(node)) {
627
+ reasons.push(...unresolvedConditionReasons(node.children, ctx));
628
  continue;
629
  }
630
  if (!isRuleActive(node)) continue;
631
  if (isCohortRule(node)) {
632
  // Any unresolvable member of the set makes the whole condition unanswerable β€” the same
633
  // all-or-nothing rule evalNode applies, so the marker and the rows agree on WHY.
634
+ const unavailable = cohortIds(node.value).find((id) => !ctx?.cohortSets?.[id]);
635
+ if (unavailable) reasons.push(`The cohort "${unavailable}" used by this filter is unavailable.`);
636
  continue;
637
  }
638
  // C4 β€” a view leaf naming a view we cannot resolve (deleted, or not visible to this caller)
639
  // is unanswerable, so it is COUNTED and the toolbar says so. Without this line the grid would
640
  // empty with no explanation, which is D-124 exactly, arriving one leaf over.
641
  if (isViewRule(node)) {
642
+ const viewId = viewRuleId(node.value);
643
+ if (!ctx?.viewSets?.[viewId]) reasons.push(`The view "${viewId}" used by this filter is unavailable.`);
644
  continue;
645
  }
646
  // ⭐⭐ D-229 β€” a leaf whose COLUMN, or whose rhs COLUMN, this table no longer has. `evalNode`
 
650
  if (ctx?.fieldByKey && !isMeasureRule(node)) {
651
  const rhsKey = node.rhs ? rhsColId(node.rhs) ?? "" : null;
652
  if (!ctx.fieldByKey.has(node.colId) || (rhsKey !== null && !ctx.fieldByKey.has(rhsKey))) {
653
+ const missing = !ctx.fieldByKey.has(node.colId) ? node.colId : rhsKey;
654
+ reasons.push(`This filter uses the field "${missing}", which you cannot access.`);
655
  continue;
656
  }
657
  }
658
  if (isMeasureRule(node)) continue; // that is `pendingMeasures`' job
659
  if (node.op === "within") {
660
+ if (!ctx?.today || !resolveWindow(node.dateWindow, ctx.today)) {
661
+ reasons.push("This date-range condition cannot be resolved.");
662
+ }
663
  } else if (node.dateMode) {
664
+ if (!ctx?.today || resolveAnchor(node.dateMode, node.value, ctx.today) == null) {
665
+ reasons.push("This date condition cannot be resolved.");
666
+ }
667
  }
668
  }
669
+ return reasons;
670
+ }
671
+
672
+ export function unresolvedConditions(nodes: FilterNode[], ctx?: EvalCtx): number {
673
+ return unresolvedConditionReasons(nodes, ctx).length;
674
  }
675
 
676
  /** Root-level evaluation: the whole tree as one implicit group. Exported for
web/src/index.css CHANGED
@@ -601,6 +601,18 @@ body {
601
  text-overflow: ellipsis;
602
  white-space: nowrap;
603
  }
 
 
 
 
 
 
 
 
 
 
 
 
604
  .cg-type-check { color: var(--lp-blue-deep); flex: 0 0 auto; }
605
  /* W40-T29 (owner instruction 31) - the create picker's Advanced group: a hairline, then a quiet
606
  one-word heading, then the three kinds most people never want. Same typographic weight as
 
601
  text-overflow: ellipsis;
602
  white-space: nowrap;
603
  }
604
+ .cg-type-info {
605
+ display: inline-flex;
606
+ align-items: center;
607
+ justify-content: center;
608
+ flex: 0 0 auto;
609
+ width: 16px;
610
+ height: 16px;
611
+ color: var(--lp-muted);
612
+ cursor: help;
613
+ }
614
+ .cg-type-row:hover .cg-type-info,
615
+ .cg-type-row:focus-visible .cg-type-info { color: var(--lp-blue-deep); }
616
  .cg-type-check { color: var(--lp-blue-deep); flex: 0 0 auto; }
617
  /* W40-T29 (owner instruction 31) - the create picker's Advanced group: a hairline, then a quiet
618
  one-word heading, then the three kinds most people never want. Same typographic weight as
web/wiring_rows/wave42_c.py CHANGED
@@ -91,22 +91,28 @@ ROWS: list[tuple[str, str, str, str, str]] = [
91
  "filter-kit/FieldsHidePanel.tsx",
92
  r"onSetFieldsHidden: \(\(keys: string\[\], hidden: boolean\) => void\) \| null;",
93
  "customer-grid/CustomerGrid.tsx", r"onSetFieldsHidden=\{fieldBulkDoors\.onSetFieldsHidden\}"),
94
- ("W42-T12 onShareFields: one share dialog over the whole selection",
95
  "filter-kit/FieldsHidePanel.tsx",
96
  r"onShareFields: \(\(keys: string\[\]\) => void\) \| null;",
97
- "customer-grid/CustomerGrid.tsx", r"onShareFields=\{fieldBulkDoors\.onShareFields\}"),
98
- ("W42-T12 onReassignFields: R6(d)'s admin-only reassign has a handler",
 
99
  "filter-kit/FieldsHidePanel.tsx",
100
  r"onReassignFields: \(\(keys: string\[\]\) => void\) \| null;",
101
- "customer-grid/CustomerGrid.tsx", r"onReassignFields=\{fieldBulkDoors\.onReassignFields\}"),
 
102
  ("W42-T12 onDeleteFields: the batch delete door, never a loop over the per-key one",
103
  "filter-kit/FieldsHidePanel.tsx",
104
  r"onDeleteFields: \(\(keys: string\[\]\) => void\) \| null;",
105
  "customer-grid/CustomerGrid.tsx", r"onDeleteFields=\{fieldBulkDoors\.onDeleteFields\}"),
106
- ("W42-T12 onRemoveField: R6(b)'s other door, distinct from a delete",
107
  "filter-kit/FieldsHidePanel.tsx",
108
  r"onRemoveField: \(\(key: string\) => void\) \| null;",
109
- "customer-grid/CustomerGrid.tsx", r"onRemoveField=\{fieldBulkDoors\.onRemoveField\}"),
 
 
 
 
110
 
111
  # ⭐⭐ W42-T13 (instruction 2, D-485) β€” THE SEVENTH HANDLER, AND IT LIVES ON A DIFFERENT
112
  # COMPONENT FROM T12's SIX. `onRemoveFromMine` is a `ColumnMenuProps` member, not a
@@ -139,11 +145,10 @@ ROWS: list[tuple[str, str, str, str, str]] = [
139
  # and does nothing when pressed β€” D-485 turned from an absent control into the WORSE defect of
140
  # a silent one, with the whole battery green. The bundle door is the only thing on this
141
  # surface that speaks the refusal out loud, so pinning it pins the behaviour.
142
- ("W42-T13 onRemoveFromMine: R6(b)'s column-menu door is fed at BOTH ColumnMenu mounts",
143
- "customer-grid/ColumnMenu.tsx", r"onRemoveFromMine: \(\) => void;",
144
  "customer-grid/CustomerGrid.tsx",
145
- r"onRemoveFromMine=\{\(\) => \{[\s\S]*?fieldBulkDoors\.onRemoveField\("
146
- r"[\s\S]*onRemoveFromMine=\{\(\) => \{[\s\S]*?fieldBulkDoors\.onRemoveField\("),
147
 
148
  # ⭐⭐ W42-T58 (contract C4) β€” THE PER KEY VERDICT IS DECLARED IN ONE FILE AND READ IN ANOTHER.
149
  #
 
91
  "filter-kit/FieldsHidePanel.tsx",
92
  r"onSetFieldsHidden: \(\(keys: string\[\], hidden: boolean\) => void\) \| null;",
93
  "customer-grid/CustomerGrid.tsx", r"onSetFieldsHidden=\{fieldBulkDoors\.onSetFieldsHidden\}"),
94
+ ("W42-T12 bulk Share opens the field access editor",
95
  "filter-kit/FieldsHidePanel.tsx",
96
  r"onShareFields: \(\(keys: string\[\]\) => void\) \| null;",
97
+ "customer-grid/CustomerGrid.tsx",
98
+ r"onShareFields=\{fieldGrantTableKey[\s\S]*openFieldBatchAccess\(\"share\""),
99
+ ("W42-T12 bulk Reassign opens the field access editor",
100
  "filter-kit/FieldsHidePanel.tsx",
101
  r"onReassignFields: \(\(keys: string\[\]\) => void\) \| null;",
102
+ "customer-grid/CustomerGrid.tsx",
103
+ r"onReassignFields=\{fieldGrantTableKey[\s\S]*openFieldBatchAccess\(\"reassign\""),
104
  ("W42-T12 onDeleteFields: the batch delete door, never a loop over the per-key one",
105
  "filter-kit/FieldsHidePanel.tsx",
106
  r"onDeleteFields: \(\(keys: string\[\]\) => void\) \| null;",
107
  "customer-grid/CustomerGrid.tsx", r"onDeleteFields=\{fieldBulkDoors\.onDeleteFields\}"),
108
+ ("W42-T12 manager self-removal reaches the grantee-safe door",
109
  "filter-kit/FieldsHidePanel.tsx",
110
  r"onRemoveField: \(\(key: string\) => void\) \| null;",
111
+ "customer-grid/CustomerGrid.tsx",
112
+ r"onRemoveField=\{fieldGrantTableKey[\s\S]*removeFieldFromMine\(field\)"),
113
+ ("W42-T12 the batch editor is mounted by the only manager host",
114
+ "customer-grid/FieldBatchAccessDialog.tsx", r"export default function FieldBatchAccessDialog",
115
+ "customer-grid/CustomerGrid.tsx", r"<FieldBatchAccessDialog"),
116
 
117
  # ⭐⭐ W42-T13 (instruction 2, D-485) β€” THE SEVENTH HANDLER, AND IT LIVES ON A DIFFERENT
118
  # COMPONENT FROM T12's SIX. `onRemoveFromMine` is a `ColumnMenuProps` member, not a
 
145
  # and does nothing when pressed β€” D-485 turned from an absent control into the WORSE defect of
146
  # a silent one, with the whole battery green. The bundle door is the only thing on this
147
  # surface that speaks the refusal out loud, so pinning it pins the behaviour.
148
+ ("W42-T13 a shared grantee's ColumnMenu reaches self-removal",
149
+ "customer-grid/ColumnMenu.tsx", r"onRemoveFromMine\?: \(\) => void;",
150
  "customer-grid/CustomerGrid.tsx",
151
+ r"onRemoveFromMine=\{[\s\S]*removeFieldFromMine\(menuField\)"),
 
152
 
153
  # ⭐⭐ W42-T58 (contract C4) β€” THE PER KEY VERDICT IS DECLARED IN ONE FILE AND READ IN ANOTHER.
154
  #
web/wiring_rows/wave42_f.py CHANGED
@@ -33,6 +33,11 @@ _T24: list[tuple[str, str, str, str, str]] = [
33
  "customer-grid/RouteNav.tsx", r"onOpenRoute\(route\.key\)",
34
  "customer-grid/MapView.tsx", r"<RouteNav\b[^>]*\bonOpenRoute=\{openSavedRoute\}",
35
  ),
 
 
 
 
 
36
  (
37
  "route-nav-create-is-its-own-affordance",
38
  "customer-grid/RouteNav.tsx", r"onCreateRoute\(\);",
 
33
  "customer-grid/RouteNav.tsx", r"onOpenRoute\(route\.key\)",
34
  "customer-grid/MapView.tsx", r"<RouteNav\b[^>]*\bonOpenRoute=\{openSavedRoute\}",
35
  ),
36
+ (
37
+ "route-nav list failure reaches an error and retry, never an empty state",
38
+ "customer-grid/RouteNav.tsx", r"listError \? \(",
39
+ "customer-grid/MapView.tsx", r"<RouteNav\b[^>]*\blistError=\{routeListError\}[\s\S]{0,160}?\bonListRetry=\{retryRouteList\}",
40
+ ),
41
  (
42
  "route-nav-create-is-its-own-affordance",
43
  "customer-grid/RouteNav.tsx", r"onCreateRoute\(\);",