fsanyoto commited on
Commit
374573a
Β·
verified Β·
1 Parent(s): bf02254

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "e09d5b9",
3
  "releases": [
4
  {
5
  "version": "v29",
 
1
  {
2
+ "current": "9a6a561",
3
  "releases": [
4
  {
5
  "version": "v29",
VERSION CHANGED
@@ -1 +1 @@
1
- e09d5b9
 
1
+ 9a6a561
platform/aios_grid_fields.json CHANGED
@@ -464,12 +464,19 @@
464
  "source": "odoo",
465
  "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
466
  },
 
 
 
 
 
 
 
467
  {
468
  "key": "dos",
469
  "label": "Days of supply",
470
  "type": "int",
471
  "source": "odoo",
472
- "description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
473
  },
474
  {
475
  "key": "cover_gap_d",
@@ -477,7 +484,15 @@
477
  "type": "int",
478
  "source": "odoo",
479
  "default": false,
480
- "description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
 
 
 
 
 
 
 
 
481
  },
482
  {
483
  "key": "stock_bucket",
@@ -486,6 +501,17 @@
486
  "source": "odoo",
487
  "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
488
  },
 
 
 
 
 
 
 
 
 
 
 
489
  {
490
  "key": "needs_pricing",
491
  "label": "Needs pricing",
 
464
  "source": "odoo",
465
  "description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
466
  },
467
+ {
468
+ "key": "incoming",
469
+ "label": "Inbound units",
470
+ "type": "int",
471
+ "source": "odoo",
472
+ "description": "Units already on order and not yet received, from Odoo's own incoming quantity on the product. In the product's STOCK unit of measure, the same unit as On hand, so the two can be added. Both cover gap columns count this as stock."
473
+ },
474
  {
475
  "key": "dos",
476
  "label": "Days of supply",
477
  "type": "int",
478
  "source": "odoo",
479
+ "description": "Days of supply at the LTM rate, from ON-HAND stock only; null means it never sells through. It deliberately IGNORES inbound units, because dead stock and overstock are questions about what is on the shelf. The cover gap columns are the ones that count inbound. Consolidated; absent for a BU-scoped caller."
480
  },
481
  {
482
  "key": "cover_gap_d",
 
484
  "type": "int",
485
  "source": "odoo",
486
  "default": false,
487
+ "description": "Days of cover minus supplier lead time. Negative means it runs out before a reorder lands. Counts INBOUND units as stock (owner, 2026-08-19), so this reads longer than Days of supply on any SKU with an open purchase order."
488
+ },
489
+ {
490
+ "key": "cover_gap_units",
491
+ "label": "Cover gap (units)",
492
+ "type": "int",
493
+ "source": "odoo",
494
+ "default": false,
495
+ "description": "The recommended reorder quantity: units of demand over the lead time that on-hand plus inbound does not cover. POSITIVE means buy this many; negative is surplus units; blank means we cannot say, because the SKU has no lead time on file or never sells through. Rounded AWAY from zero, so a real shortfall never rounds down to nothing."
496
  },
497
  {
498
  "key": "stock_bucket",
 
501
  "source": "odoo",
502
  "description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
503
  },
504
+ {
505
+ "key": "discontinued",
506
+ "label": "Discontinued",
507
+ "type": "select",
508
+ "source": "odoo",
509
+ "options": [
510
+ "Yes",
511
+ "No"
512
+ ],
513
+ "description": "Whether Odoo carries the Discontinued product tag on this SKU. Every row gets an explicit Yes or No rather than a blank, because a blank reads as an inactive condition in the filter engine and would silently WIDEN any view that filtered on it."
514
+ },
515
  {
516
  "key": "needs_pricing",
517
  "label": "Needs pricing",
platform/modules/product_data.py CHANGED
@@ -53,6 +53,7 @@ template and this deliberately mirrors its signature and its row shape.
53
  """
54
  import datetime as _dt
55
  import json
 
56
  import zlib
57
  from pathlib import Path
58
 
@@ -422,6 +423,12 @@ def pool(team_id=None, t=None):
422
  # SKU that is not re-SKUed. `directory()` derives the category identically.
423
  "product": meta.get("product") or code,
424
  "category": meta.get("category") or "(uncategorized)",
 
 
 
 
 
 
425
  # ⭐⭐ W33-T43 (R2 / amendment A2) β€” ODOO'S OWN PRODUCT ID, beside the hashed `pid`.
426
  # R2 retires `ut_odoo_products` onto this key and keeps every data column it had; this
427
  # is that column. β›” NOT DERIVABLE DOWNSTREAM: `pid` is `crc32(default_code)` here,
@@ -509,11 +516,51 @@ def pool(team_id=None, t=None):
509
  # "does the shelf outlast a reorder AT FISCH'S RATE" β€” reading the consolidated figure
510
  # here would print a buy signal computed from both units' velocity beside a days-of-supply
511
  # computed from one, and the two columns would disagree on the same row.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  lead = s.get("lead_days")
513
- if isinstance(dos, (int, float)) and isinstance(lead, (int, float)) and lead > 0:
514
- row["cover_gap_d"] = int(round(dos - lead))
515
- row["buy_now"] = "Buy now" if dos < lead else "OK"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  else:
 
517
  row["cover_gap_d"] = None
518
  row["buy_now"] = None
519
  rows.append(row)
@@ -546,7 +593,19 @@ def pool(team_id=None, t=None):
546
  #: which is the difference between this and a filter engine's `toNum(null) === 0`;
547
  #: Β· `IF` with a non-boolean condition returns BLANK ("no truthiness"), so a blank comparison
548
  #: propagates out as a blank cell rather than taking the false branch.
549
- BUY_SIGNAL_FORMULA = 'IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "")'
 
 
 
 
 
 
 
 
 
 
 
 
550
 
551
 
552
  def _buy_signal_formula(row):
@@ -558,14 +617,10 @@ def _buy_signal_formula(row):
558
  return None
559
  return v if v == v and v not in (float('inf'), float('-inf')) else None
560
 
561
- lead, dos = num(row.get("lead_days")), num(row.get("dos"))
562
- if lead is None: # `{lead_days} > 0` is blank -> IF(blank, …) is blank
563
- return ""
564
- if not lead > 0:
565
- return "" # the formula's own else-branch
566
- if dos is None: # `{dos} < {lead_days}` is blank -> IF(blank, …) is blank
567
  return ""
568
- return "Buy now" if dos < lead else "OK"
569
 
570
 
571
  def validate(team_id=None, t=None):
@@ -760,12 +815,14 @@ def validate(team_id=None, t=None):
760
  buy = [r for r in rows if r.get("buy_now") == "Buy now"]
761
  ok_rows = [r for r in rows if r.get("buy_now") == "OK"]
762
  blank = [r for r in rows if r.get("buy_now") is None]
763
- mis = sum(1 for r in buy if not (r["dos"] < r["lead_days"]))
764
- mis += sum(1 for r in ok_rows if not (r["dos"] >= r["lead_days"]))
 
 
 
 
765
  # A blank must be UNKNOWN β€” never a row we could have answered and quietly did not.
766
- mis += sum(1 for r in blank
767
- if isinstance(r.get("dos"), (int, float))
768
- and isinstance(r.get("lead_days"), (int, float)) and r["lead_days"] > 0)
769
  checks.append({
770
  "check": "Buy signal partitions the catalogue (buy + ok + unknown == rows, none "
771
  "misclassified)",
@@ -794,37 +851,110 @@ def validate(team_id=None, t=None):
794
  if (r.get("buy_now") or "") != _buy_signal_formula(r))
795
  checks.append({
796
  "check": 'Buy signal as a FORMULA field == the retired preset column, per SKU '
797
- '(IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), ""))',
798
  "ours": len(rows) - len(disagree), "theirs": len(rows),
799
  "ok": not disagree,
800
  "detail": {"disagreeing_skus": disagree[:10], "n_disagree": len(disagree)},
801
  })
802
- # The other half of "the same figures": the SHARED Buy list view can no longer filter on
803
- # the retired column, and its replacement conditions must select the same SKUs. They are
804
- # `dos isNotEmpty AND lead_days isNotEmpty AND lead_days > 0 AND dos < lead_days`
805
  # (_seed_wave17.views), so this reproduces exactly that conjunction.
806
  #
807
- # β›” THE NEGATIVE CONTROL IS WHY THIS IS NOT `cover_gap_d < 0`, which reads like the
808
- # obvious filter and is WRONG: `cover_gap_d` is `int(round(dos - lead))`, so a genuine
809
- # gap of -0.4 days rounds to 0 and that SKU drops off a buy list it belongs on.
 
 
 
 
 
 
 
 
 
810
  view_rows = {r["code"] for r in rows
811
- if isinstance(r.get("dos"), (int, float))
812
- and isinstance(r.get("lead_days"), (int, float))
813
- and r["lead_days"] > 0 and r["dos"] < r["lead_days"]}
814
- signal_rows = {r["code"] for r in buy}
815
- rounding_would_miss = sorted(
816
- c for c in signal_rows
817
- if next((r for r in rows if r["code"] == c), {}).get("cover_gap_d") == 0)
818
  checks.append({
819
- "check": "Buy list view conditions (dos/lead_days, no retired column) select "
820
- "exactly the Buy-now SKUs",
821
  "ours": len(view_rows), "theirs": len(signal_rows),
822
  "ok": view_rows == signal_rows,
823
  "detail": {"only_in_view": sorted(view_rows - signal_rows)[:10],
824
- "only_in_signal": sorted(signal_rows - view_rows)[:10],
825
- # Reported, not asserted: how many SKUs a `cover_gap_d < 0` filter would
826
- # have silently dropped. 0 today does not make that filter correct.
827
- "cover_gap_rounds_to_zero": len(rounding_would_miss)},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
  })
829
  checks.extend(validate_measures(t=t, team_id=team_id,
830
  pool_codes={r["code"] for r in rows}))
 
53
  """
54
  import datetime as _dt
55
  import json
56
+ import math as _math
57
  import zlib
58
  from pathlib import Path
59
 
 
423
  # SKU that is not re-SKUed. `directory()` derives the category identically.
424
  "product": meta.get("product") or code,
425
  "category": meta.get("category") or "(uncategorized)",
426
+ # ⭐⭐ OWNER 2026-08-19 β€” the Odoo Discontinued tag, surfaced so the buy list can drop
427
+ # them. β›” ALWAYS "Yes" OR "No", NEVER BLANK: an unknown value is an INACTIVE condition
428
+ # in the tri-state filter engine, so a view filtering on a blank column IGNORES the
429
+ # leaf and WIDENS to the whole catalogue. That is the exact failure `_seed_wave17`'s
430
+ # buy-list comment was written about, and a blank here would reintroduce it.
431
+ "discontinued": meta.get("discontinued") or "No",
432
  # ⭐⭐ W33-T43 (R2 / amendment A2) β€” ODOO'S OWN PRODUCT ID, beside the hashed `pid`.
433
  # R2 retires `ut_odoo_products` onto this key and keeps every data column it had; this
434
  # is that column. β›” NOT DERIVABLE DOWNSTREAM: `pid` is `crc32(default_code)` here,
 
516
  # "does the shelf outlast a reorder AT FISCH'S RATE" β€” reading the consolidated figure
517
  # here would print a buy signal computed from both units' velocity beside a days-of-supply
518
  # computed from one, and the two columns would disagree on the same row.
519
+ # ⭐⭐ OWNER 2026-08-19 β€” INBOUND COUNTS AS STOCK FOR THE COVER GAP. Verbatim: *"Cover gap
520
+ # day also should INCLUDE the incoming SKUs so it assumes those as stock even though its
521
+ # inbound"*, and *"we need the 'Cover gap (units)' basically tell us how much to buy"*.
522
+ #
523
+ # β›” `dos` AND `stock_bucket` DELIBERATELY DO NOT MOVE. They answer "what is on the shelf",
524
+ # which is the question dead-stock, excess and overstock are built on; folding inbound into
525
+ # them would change the Inventory module's meaning from a buy-list request. So a row can
526
+ # honestly read `dos 5, lead 30, cover gap +12` when 900 units are on the water, and the
527
+ # two columns' own descriptions say which is which.
528
+ #
529
+ # ⚠ THE VELOCITY BASIS IS UNCHANGED AND IS **TRAILING** TWELVE MONTHS, not a forward
530
+ # projection. `daily` here is the same `qty_ltm / 365` that produced `dos` upstream, so the
531
+ # new columns cannot disagree with the old one about how fast the SKU moves. The owner
532
+ # described the existing column as "based on the next 8 months Unit sales"; it is not, and
533
+ # that is REPORTED rather than quietly repaired β€” changing the basis in the same edit that
534
+ # adds inbound would make it impossible to tell which change moved which number.
535
  lead = s.get("lead_days")
536
+ # ⚠ FROM THE CATALOGUE ROW (`meta`), NOT THE INVENTORY ROW (`e`). Both live on this SKU
537
+ # and only one of them was read from Odoo's product record.
538
+ incoming = meta.get("incoming")
539
+ row["incoming"] = incoming
540
+ daily = (qty_ltm / 365.0) if isinstance(qty_ltm, (int, float)) and qty_ltm > 0 else None
541
+ on_shelf = row["on_hand"]
542
+ effective = ((on_shelf or 0.0) + (incoming or 0.0)
543
+ if isinstance(on_shelf, (int, float)) else None)
544
+ if daily and isinstance(effective, (int, float)) and isinstance(lead, (int, float)) \
545
+ and lead > 0:
546
+ # Units of demand the lead time will consume, less everything we already hold or have
547
+ # bought. POSITIVE is a shortfall to buy; negative is surplus. Signed on purpose: a
548
+ # floor at zero would make the buy-list filter depend on the floor rather than on the
549
+ # measurement, and would flatten "covered by 2 units" into "covered by 400".
550
+ short = daily * lead - effective
551
+ # β›” AWAY FROM ZERO, NEVER `round()`. `cover_gap_d` is `int(round(...))` and the seed's
552
+ # own comment records why that makes it unsafe to SELECT on: a real gap of -0.4 days
553
+ # rounds to 0 and the SKU drops off a list it belongs on. A shortfall of 0.4 units is
554
+ # still a shortfall, so it must land on 1, not 0. `validate()` holds this on the
555
+ # boundary rather than trusting the expression.
556
+ row["cover_gap_units"] = int(_math.ceil(short) if short > 0 else _math.floor(short))
557
+ row["cover_gap_d"] = int(round(effective / daily - lead))
558
+ # ⭐ THE SIGNAL IS NOW A FUNCTION OF THE SAME NUMBER THE COLUMN SHOWS, so the words and
559
+ # the quantity on one row can no longer disagree β€” which they would have the moment
560
+ # the gap started counting inbound and the signal did not.
561
+ row["buy_now"] = "Buy now" if row["cover_gap_units"] > 0 else "OK"
562
  else:
563
+ row["cover_gap_units"] = None
564
  row["cover_gap_d"] = None
565
  row["buy_now"] = None
566
  rows.append(row)
 
593
  #: which is the difference between this and a filter engine's `toNum(null) === 0`;
594
  #: Β· `IF` with a non-boolean condition returns BLANK ("no truthiness"), so a blank comparison
595
  #: propagates out as a blank cell rather than taking the false branch.
596
+ #: ⭐⭐ REPOINTED 2026-08-19 ONTO `cover_gap_units`, AND THE REASON IS THE OWNER'S OWN INSTRUCTION.
597
+ #: The old formula was `IF({lead_days} > 0, IF({dos} < {lead_days}, "Buy now", "OK"), "")`. Once
598
+ #: the cover gap counts inbound stock, `{dos} < {lead_days}` is no longer the same question: it
599
+ #: would keep saying "Buy now" for a SKU whose replenishment is already on the water, which is the
600
+ #: shape of confusion the owner opened this work with. `cover_gap_units` already carries the whole
601
+ #: rule β€” lead time present and positive, velocity known, inbound counted, rounded away from zero
602
+ #: β€” so one reference replaces three and the words cannot disagree with the number beside them.
603
+ #:
604
+ #: ⚠ ONE ENGINE BEHAVIOUR CARRIES THE BLANK CASE, and it is why no `isNotEmpty` guard is needed:
605
+ #: `cmp` returns BLANK unless BOTH sides read as numbers, and `IF` with a non-boolean condition
606
+ #: returns BLANK. So a SKU with no lead time or no sell-through has `cover_gap_units = null`, the
607
+ #: comparison is blank, and the cell is blank β€” never the false branch, and never a coerced 0.
608
+ BUY_SIGNAL_FORMULA = 'IF({cover_gap_units} > 0, "Buy now", "OK")'
609
 
610
 
611
  def _buy_signal_formula(row):
 
617
  return None
618
  return v if v == v and v not in (float('inf'), float('-inf')) else None
619
 
620
+ gap = num(row.get("cover_gap_units"))
621
+ if gap is None: # `{cover_gap_units} > 0` is blank -> IF(blank, …) is blank
 
 
 
 
622
  return ""
623
+ return "Buy now" if gap > 0 else "OK"
624
 
625
 
626
  def validate(team_id=None, t=None):
 
815
  buy = [r for r in rows if r.get("buy_now") == "Buy now"]
816
  ok_rows = [r for r in rows if r.get("buy_now") == "OK"]
817
  blank = [r for r in rows if r.get("buy_now") is None]
818
+ # ⚠ RE-DERIVED FROM `cover_gap_units`, NOT FROM `dos < lead_days`, SINCE 2026-08-19. The
819
+ # signal counts inbound stock now; the old predicate does not, so leaving it here made the
820
+ # check disagree with the column on **796 rows** β€” every SKU with an open purchase order.
821
+ # It was the check that was stale, and it caught the definition move exactly as intended.
822
+ mis = sum(1 for r in buy if not (r["cover_gap_units"] > 0))
823
+ mis += sum(1 for r in ok_rows if not (r["cover_gap_units"] <= 0))
824
  # A blank must be UNKNOWN β€” never a row we could have answered and quietly did not.
825
+ mis += sum(1 for r in blank if isinstance(r.get("cover_gap_units"), (int, float)))
 
 
826
  checks.append({
827
  "check": "Buy signal partitions the catalogue (buy + ok + unknown == rows, none "
828
  "misclassified)",
 
851
  if (r.get("buy_now") or "") != _buy_signal_formula(r))
852
  checks.append({
853
  "check": 'Buy signal as a FORMULA field == the retired preset column, per SKU '
854
+ '(' + BUY_SIGNAL_FORMULA + ')',
855
  "ours": len(rows) - len(disagree), "theirs": len(rows),
856
  "ok": not disagree,
857
  "detail": {"disagreeing_skus": disagree[:10], "n_disagree": len(disagree)},
858
  })
859
+ # The other half of "the same figures": the SHARED Buy list view's conditions must select
860
+ # exactly the Buy-now SKUs. They are `discontinued neq Yes AND cover_gap_units gt 0`
 
861
  # (_seed_wave17.views), so this reproduces exactly that conjunction.
862
  #
863
+ # ⭐⭐ THE FOUR-LEAF CONJUNCTION COLLAPSED TO TWO ON 2026-08-19, and the guard the old
864
+ # leaves provided is now STRUCTURAL rather than spelled out. The two `isNotEmpty` leaves
865
+ # existed because the client filter engine's `toNum(null)` is **0**, so a bare
866
+ # `dos < lead_days` read a SKU with no days-of-supply as `0 < 30` = TRUE and put every
867
+ # never-selling product on the buy list. `cover_gap_units > 0` inverts that accident into
868
+ # a safety: a blank reads as 0, and `0 > 0` is FALSE, so an unknown SKU is EXCLUDED. The
869
+ # filter now fails CLOSED on exactly the rows the old one failed OPEN on.
870
+ #
871
+ # β›” AND THE ROUNDING TRAP IS THE REASON IT IS NOT `cover_gap_d < 0`. That column is
872
+ # `int(round(...))`, so a real gap of -0.4 days rounds to 0 and the SKU silently drops off
873
+ # a list it belongs on. `cover_gap_units` rounds AWAY from zero for that reason, and the
874
+ # boundary control below proves it on the rows where the two disagree.
875
  view_rows = {r["code"] for r in rows
876
+ if (r.get("discontinued") or "") != "Yes"
877
+ and isinstance(r.get("cover_gap_units"), (int, float))
878
+ and r["cover_gap_units"] > 0}
879
+ signal_rows = {r["code"] for r in buy if (r.get("discontinued") or "") != "Yes"}
 
 
 
880
  checks.append({
881
+ "check": "Buy list view conditions (discontinued neq Yes AND cover_gap_units gt 0) "
882
+ "select exactly the Buy-now SKUs that are not discontinued",
883
  "ours": len(view_rows), "theirs": len(signal_rows),
884
  "ok": view_rows == signal_rows,
885
  "detail": {"only_in_view": sorted(view_rows - signal_rows)[:10],
886
+ "only_in_signal": sorted(signal_rows - view_rows)[:10]},
887
+ })
888
+
889
+ # β›”β›” THE BOUNDARY CONTROL, and it is the single check that proves the collapse above did
890
+ # not silently SHRINK the list. It re-derives the rows where `cover_gap_d` rounds to 0 but
891
+ # the true gap is negative β€” precisely the SKUs the old comment warned a `cover_gap_d < 0`
892
+ # filter would lose β€” and asserts every one of them is still selected by the new predicate.
893
+ # A SKU short by 0.4 days of demand must produce `cover_gap_units >= 1`, not 0.
894
+ # ⚠ DISCONTINUED ROWS ARE OUT OF THIS POPULATION, and finding that out is why the control
895
+ # is written as a control. Its first run went RED naming SKU `15001`, which is a genuine
896
+ # sub-one-day shortfall AND carries Odoo's Discontinued tag: correctly absent from the buy
897
+ # list, for the other reason. Counting it as "dropped by rounding" would have made a
898
+ # working exclusion look like a rounding defect on every future run.
899
+ boundary = [r for r in rows
900
+ if r.get("cover_gap_d") == 0
901
+ and (r.get("discontinued") or "") != "Yes"
902
+ and isinstance(r.get("cover_gap_units"), (int, float))
903
+ and r["cover_gap_units"] > 0]
904
+ missed = sorted(r["code"] for r in boundary if r["code"] not in view_rows)
905
+ checks.append({
906
+ "check": "Rounding boundary: every SKU whose cover gap rounds to 0 DAYS but is a real "
907
+ "shortfall in UNITS is still on the buy list",
908
+ "ours": len(boundary) - len(missed), "theirs": len(boundary),
909
+ "ok": not missed,
910
+ "detail": {"dropped_by_rounding": missed[:10],
911
+ # Reported, not asserted: these are the rows a `cover_gap_d < 0` filter
912
+ # would have lost. A 0 here does not make that filter correct.
913
+ "n_saved_by_rounding_away_from_zero": len(boundary)},
914
+ })
915
+
916
+ # ⭐ RULE 8 β€” the Discontinued column against an INDEPENDENT Odoo aggregate.
917
+ # β›” IT RE-RESOLVES THE TAG BY NAME rather than reusing the id the column was built from.
918
+ # Sharing that binding would let the check and the column carry the same bug and agree
919
+ # about it ([[gate-and-nc-must-not-share-a-binding]]).
920
+ tag_id = products.discontinued_tag_id.__wrapped__()
921
+ ours_disc = len([r for r in rows if (r.get("discontinued") or "") == "Yes"])
922
+ theirs_disc = O.get_odoo().search_count(
923
+ 'product.product',
924
+ [('active', '=', True), ('product_tag_ids', 'in', [tag_id])]) if tag_id else -1
925
+ checks.append({
926
+ "check": f"Discontinued SKUs == Odoo products carrying the {products.DISCONTINUED_TAG}"
927
+ f" tag (resolved by name, id {tag_id})",
928
+ "ours": ours_disc, "theirs": theirs_disc,
929
+ # Codes merge variants, so ours may be <= theirs; a tag that resolved to nothing is a
930
+ # hard red, because the column would read "nobody is discontinued" and look fine.
931
+ "ok": tag_id is not None and 0 < ours_disc <= theirs_disc,
932
+ "detail": {"tag_resolved": tag_id is not None,
933
+ "note": "ours counts SKU CODES, theirs counts product RECORDS; variants "
934
+ "sharing a code merge into one row, so ours <= theirs."},
935
+ })
936
+
937
+ # ⭐ RULE 8 β€” inbound units against a SECOND, INDEPENDENTLY DERIVED source (open purchase
938
+ # order lines). β›” SHAPE AND DIRECTION, NOT EQUALITY: the two are in different units of
939
+ # measure on ~107 codes and asserting equality would go red forever on a correct
940
+ # difference. `products.incoming_from_po` documents the measurement.
941
+ po_units, po_report = products.incoming_from_po()
942
+ ours_inc = sum(float(r.get("incoming") or 0.0) for r in rows)
943
+ theirs_inc = sum(po_units.values())
944
+ with_inbound = {r["code"] for r in rows if float(r.get("incoming") or 0.0) > 0}
945
+ po_codes = {c for c, v in po_units.items() if v > 0}
946
+ checks.append({
947
+ "check": "Inbound units (Odoo incoming_qty) reconcile to open purchase order lines",
948
+ "ours": round(ours_inc, 1), "theirs": round(theirs_inc, 1),
949
+ # A truncated PO read makes the oracle itself untrustworthy, so it is a red.
950
+ "ok": (not po_report["truncated"]) and theirs_inc > 0
951
+ and abs(ours_inc - theirs_inc) <= 0.10 * max(ours_inc, theirs_inc)
952
+ and len(with_inbound & po_codes) >= 0.85 * len(po_codes),
953
+ "detail": {"skus_with_inbound_ours": len(with_inbound),
954
+ "skus_with_inbound_po": len(po_codes),
955
+ "in_both": len(with_inbound & po_codes),
956
+ "po_lines": po_report, "uom_note": "purchase uom vs stock uom; the shipped "
957
+ "column is in the STOCK uom so it can be added to On hand."},
958
  })
959
  checks.extend(validate_measures(t=t, team_id=team_id,
960
  pool_codes={r["code"] for r in rows}))
platform/modules/products.py CHANGED
@@ -167,7 +167,8 @@ def directory(t=None, team_id=None):
167
 
168
 
169
  def catalogue():
170
- """`{sku_code: {'product', 'category', 'id'}}` β€” EVERY ACTIVE product, sold or not.
 
171
 
172
  β›” THIS IS THE CATALOGUE UNIVERSE, AND IT IS DELIBERATELY NOT `directory()`. `directory()`'s
173
  row set IS the union of two revenue `read_group`s over `sale.order.line` (`:155`), so a SKU
@@ -193,7 +194,16 @@ def catalogue():
193
  `modules/backorders.py:161` already uses).
194
  """
195
  dom = [('active', '=', True)]
196
- prods = O.search_read('product.product', dom, ['id', 'default_code', 'display_name', 'name'],
 
 
 
 
 
 
 
 
 
197
  limit=50000)
198
  n = O.get_odoo().search_count('product.product', dom)
199
  if len(prods) != n:
@@ -202,11 +212,19 @@ def catalogue():
202
  f"a search_count of {n}. A short catalogue renders as a plausible smaller grid with "
203
  f"nothing reporting it; raise the limit before shipping this.")
204
  code_cat = _code_category()
 
205
  out = {}
206
  for p in prods:
207
  code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
208
- # First record wins, matching `_code_category`'s own convention. MEASURED 2026-08-11: zero
209
- # active products share a code, so this branch is a guard, not a merge policy.
 
 
 
 
 
 
 
210
  # ⭐⭐ W33-T43 (R2 / amendment A2) β€” `id` RIDES THE ROW. Odoo's `product.product` id is
211
  # already in hand (the `pid:{id}` fallback above uses it and then threw it away), and it is
212
  # the ONE column `ut_odoo_products` had that `product_data` lacked. R2 merges onto the
@@ -215,12 +233,90 @@ def catalogue():
215
  # ⚠ A product's grid pid is `crc32(default_code)`, NOT the Odoo id β€” unlike a CUSTOMER row,
216
  # whose pid IS the partner id. That asymmetry is exactly why this cannot be derived
217
  # downstream in `aios_grid.py` and has to be carried from the source read.
218
- out.setdefault(code, {'product': p.get('display_name') or p.get('name') or code,
219
- 'category': code_cat.get(code, '(uncategorized)'),
220
- 'id': p['id']})
 
 
 
 
 
 
 
 
221
  return out
222
 
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  #: ⭐⭐ WAVE 30 / W30-T34 (the carried W29-T52) β€” THE PRICELIST STRATUM, PER LIST.
225
  #:
226
  #: MEASURED LIVE 2026-08-12, and every one of these numbers shaped the design rather than
 
167
 
168
 
169
  def catalogue():
170
+ """`{sku_code: {'product', 'category', 'id', 'discontinued', 'incoming'}}` β€” EVERY ACTIVE
171
+ product, sold or not.
172
 
173
  β›” THIS IS THE CATALOGUE UNIVERSE, AND IT IS DELIBERATELY NOT `directory()`. `directory()`'s
174
  row set IS the union of two revenue `read_group`s over `sale.order.line` (`:155`), so a SKU
 
194
  `modules/backorders.py:161` already uses).
195
  """
196
  dom = [('active', '=', True)]
197
+ # ⭐ `product_tag_ids` and `incoming_qty` RIDE THIS READ rather than paying for their own.
198
+ # This function already pulls every active product once; a second full pull for either column
199
+ # costs ~6 s on somebody's first page load for data that is already in flight.
200
+ # ⚠ `incoming_qty` is an UNSTORED computed field. It reads fine per record, but Odoo refuses
201
+ # to aggregate it (`Fault 2: Cannot aggregate field 'incoming_qty'`), so nothing downstream
202
+ # may push a filter or a read_group on it back to Odoo. It is materialised into the pool here
203
+ # and summed on our side; `validate()` reconciles that sum against purchase-order lines.
204
+ prods = O.search_read('product.product', dom,
205
+ ['id', 'default_code', 'display_name', 'name',
206
+ 'product_tag_ids', 'incoming_qty'],
207
  limit=50000)
208
  n = O.get_odoo().search_count('product.product', dom)
209
  if len(prods) != n:
 
212
  f"a search_count of {n}. A short catalogue renders as a plausible smaller grid with "
213
  f"nothing reporting it; raise the limit before shipping this.")
214
  code_cat = _code_category()
215
+ disc_tag = discontinued_tag_id()
216
  out = {}
217
  for p in prods:
218
  code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
219
+ # First record wins, matching `_code_category`'s own convention.
220
+ # β›” THE 2026-08-11 MEASUREMENT ("zero active products share a code, so this branch is a
221
+ # guard, not a merge policy") HAS EXPIRED. RE-MEASURED 2026-08-19: **5,873 active products
222
+ # carry 5,872 distinct codes** because `2112-12` is now on two records. So this IS a merge
223
+ # policy today, and it is the standing reason `validate()`'s row-count leg reads 5872
224
+ # against an Odoo `search_count` of 5873, along with all three pricelist legs being short
225
+ # by exactly one. Those reds are this duplicate, not a truncated read.
226
+ # ⚠ Which is also why `incoming` ACCUMULATES below instead of taking the first record's
227
+ # value: a merge policy that keeps one record's name must still sum the other's stock.
228
  # ⭐⭐ W33-T43 (R2 / amendment A2) β€” `id` RIDES THE ROW. Odoo's `product.product` id is
229
  # already in hand (the `pid:{id}` fallback above uses it and then threw it away), and it is
230
  # the ONE column `ut_odoo_products` had that `product_data` lacked. R2 merges onto the
 
233
  # ⚠ A product's grid pid is `crc32(default_code)`, NOT the Odoo id β€” unlike a CUSTOMER row,
234
  # whose pid IS the partner id. That asymmetry is exactly why this cannot be derived
235
  # downstream in `aios_grid.py` and has to be carried from the source read.
236
+ row = out.setdefault(code, {'product': p.get('display_name') or p.get('name') or code,
237
+ 'category': code_cat.get(code, '(uncategorized)'),
238
+ 'id': p['id'],
239
+ 'discontinued': 'No', 'incoming': 0.0})
240
+ # β›” ACROSS EVERY RECORD SHARING THE CODE, not just the first one that won above. A SKU
241
+ # whose variants are separate records carries its inbound on whichever record the PO named,
242
+ # and 'first record wins' would drop the rest β€” a buy list that under-counts what is
243
+ # already on the water tells you to re-order stock you have already bought.
244
+ row['incoming'] += float(p.get('incoming_qty') or 0.0)
245
+ if disc_tag is not None and disc_tag in (p.get('product_tag_ids') or ()):
246
+ row['discontinued'] = 'Yes'
247
  return out
248
 
249
 
250
+ #: ⭐ The Odoo product tag that marks a SKU withdrawn from the line. Owner, 2026-08-19: *"we have
251
+ #: a status Field called discontinued from Odoo. Surface it right now so we can filter correctly."*
252
+ #: MEASURED that day: it is not a FIELD at all β€” `product.product` has no `discontinued` column and
253
+ #: no selection carrying the word. It is `product.tag`, on **497 of 5,873 active products**, every
254
+ #: one of them `active=True` and `sale_ok=True`, so nothing else in Odoo distinguishes them and the
255
+ #: buy list was quoting them as live SKUs.
256
+ DISCONTINUED_TAG = 'Discontinued'
257
+
258
+
259
+ @lru_cache(maxsize=1)
260
+ def discontinued_tag_id():
261
+ """The `product.tag` id for `DISCONTINUED_TAG`, or `None` if the tag is not there.
262
+
263
+ ⚠ RESOLVED BY NAME, NOT BY ID, for the reason `PRICELIST_COLUMNS` gives: an id hard-coded here
264
+ keeps pointing at whatever inherits it if the tag is deleted and re-made, and every product
265
+ would be silently mislabelled. A missing tag yields `None`, which marks the whole catalogue
266
+ `No` β€” so `validate()` reconciles the count against Odoo rather than trusting this, and goes
267
+ RED on the difference instead of shipping a column that quietly says nobody is discontinued.
268
+ """
269
+ rows = O.search_read('product.tag', [('name', '=ilike', DISCONTINUED_TAG)], ['id'], limit=2)
270
+ return rows[0]['id'] if rows else None
271
+
272
+
273
+ def incoming_from_po(prods=None):
274
+ """`({code: open_po_units}, report)` β€” inbound derived from PURCHASE ORDER LINES.
275
+
276
+ β›” THIS IS THE ORACLE, NOT THE SOURCE. The shipped `incoming` column comes from
277
+ `product.product.incoming_qty` (see `catalogue()`); this derives the same quantity a second,
278
+ independent way so `validate()` can hold them against each other rather than reconciling a
279
+ number with itself β€” the self-sealing failure `product_data.validate` already carries a scar
280
+ for ([[gate-and-nc-must-not-share-a-binding]]).
281
+
282
+ β›”β›” AND THE TWO ARE NOT EXPECTED TO BE EQUAL, WHICH IS WHY THE SOURCE IS THE ONE IT IS.
283
+ MEASURED 2026-08-19: totals 179,979 vs 173,775 units, differing on **107 of ~490 codes**, and
284
+ the differences are a UNIT OF MEASURE gap, not an error. `purchase.order.line.product_qty` is
285
+ in the line's PURCHASE uom while `incoming_qty` is in the product's STOCK uom, so the `SP-*`
286
+ family reads exactly 4x apart (400 vs 100, 320 vs 80, 240 vs 60). `on_hand` and `qty_ltm` are
287
+ both in the STOCK uom, so `incoming_qty` is the only one of the two that can be ADDED to a
288
+ shelf quantity. A buy list built on the purchase uom would over-count inbound fourfold on
289
+ those SKUs and tell the team not to re-order stock that is not coming.
290
+
291
+ So the check this feeds asserts SHAPE and DIRECTION (same SKUs carry inbound, totals within a
292
+ stated band) and REPORTS the per-code gap. Asserting equality would go red forever on a
293
+ difference that is correct.
294
+ """
295
+ lines = O.search_read('purchase.order.line', [('state', '=', 'purchase')],
296
+ ['product_id', 'product_qty', 'qty_received'], limit=50000)
297
+ n = O.get_odoo().search_count('purchase.order.line', [('state', '=', 'purchase')])
298
+ by_id = {p['id']: p for p in (prods if prods is not None else [])}
299
+ if not by_id:
300
+ for p in O.search_read('product.product', [('active', '=', True)],
301
+ ['id', 'default_code'], limit=50000):
302
+ by_id[p['id']] = p
303
+ out, off_catalogue = {}, 0.0
304
+ for line in lines:
305
+ pid = (line.get('product_id') or [None])[0]
306
+ open_q = float(line.get('product_qty') or 0.0) - float(line.get('qty_received') or 0.0)
307
+ if open_q <= 0:
308
+ continue
309
+ rec = by_id.get(pid)
310
+ if rec is None: # ordered against an ARCHIVED product: real, but off-grid
311
+ off_catalogue += open_q
312
+ continue
313
+ code = (str(rec['default_code']).strip() if rec.get('default_code') else f"pid:{pid}")
314
+ out[code] = out.get(code, 0.0) + open_q
315
+ return out, {'lines_read': len(lines), 'lines_total': n,
316
+ 'truncated': len(lines) != n,
317
+ 'units_on_archived_products': round(off_catalogue, 1)}
318
+
319
+
320
  #: ⭐⭐ WAVE 30 / W30-T34 (the carried W29-T52) β€” THE PRICELIST STRATUM, PER LIST.
321
  #:
322
  #: MEASURED LIVE 2026-08-12, and every one of these numbers shaped the design rather than
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -65,7 +65,8 @@ import type { GridChatAnswer, GridChatEdit, GridChatField, GridChatRunResult,
65
  GridChatViewSpec } from "./GridChat.tsx";
66
  import { ViewAgentPanel, seedName, seedScript } from "./ViewAgentPanel";
67
  import {
68
- createScriptView, listScriptViews, readScriptView, runScriptView, saveScriptView,
 
69
  } from "./scriptViews";
70
  import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews";
71
  import { defaultViewConfig, useGridColumns } from "./useGridColumns";
@@ -157,7 +158,7 @@ import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName,
157
  MAX_FROZEN, choiceOptions, choiceVocabulary, clampFrozenCount, cleanDisplay, formulaOf,
158
  topicForScope,
159
  isDateFamilyType, isFilterGroup, isGroupableField, isMachineOwned,
160
- isDerivedLink, isUserSchemaField, TOTAL_GROUP_KEY,
161
  isNumericFieldType,
162
  machineFoundRows, reFindConsequences,
163
  isPickType, mayEditField,
@@ -2384,7 +2385,11 @@ function CustomerGridSurface({
2384
  const activeScriptId = scriptRows.some((row) => row.id === activeViewId) ? activeViewId : null;
2385
  const [scriptDoc, setScriptDoc] = useState<ScriptViewRecord | null>(null);
2386
  const [scriptRun, setScriptRun] = useState<ScriptRun | null>(null);
2387
- const [scriptBusy, setScriptBusy] = useState<"" | "run" | "save">("");
 
 
 
 
2388
  useEffect(() => {
2389
  if (activeScriptId === null) {
2390
  setScriptDoc(null);
@@ -2452,6 +2457,31 @@ function CustomerGridSurface({
2452
  setScriptBusy("");
2453
  });
2454
  }, [activeScriptId]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2455
  const lockedNote = useMemo(
2456
  // ⚠ The label is the ARTEFACT's source name when there is one, and otherwise nothing: the
2457
  // payload carries no database name, and "This database" is unambiguous on a surface whose
@@ -2702,10 +2732,13 @@ function CustomerGridSurface({
2702
  unanswerable: true,
2703
  };
2704
  }
2705
- const blank = computedRows.filter((r) => {
2706
- const v = r[named.key];
2707
- return v === null || v === undefined || String(v).trim() === "";
2708
- }).length;
 
 
 
2709
  return {
2710
  text: `${blank} of ${total} records have no ${named.label}, so ${total - blank} do. ` +
2711
  `Counted over the records this view is showing.`,
@@ -4070,6 +4103,43 @@ function CustomerGridSurface({
4070
  signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot create a source view.");
4071
  return;
4072
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4073
  const acceptedName = uniqueDisplayName(name, views.map((view) => view.name));
4074
  /**
4075
  * ⭐⭐ WAVE 27 Β· OWNER ITEM 9 / RULING R4 β€” **A NEW VIEW IS BLANK. ALL OF IT.**
@@ -4127,7 +4197,8 @@ function CustomerGridSurface({
4127
  },
4128
  // `fields`, not `config` (R4): the blank is derived from the COLUMNS, and reading the live
4129
  // config here at all is what item 9 deletes.
4130
- [fields, persistView, views, queryBinding, scope]
 
4131
  );
4132
  const renameView = useCallback(
4133
  (id: string, name: string) => {
@@ -6316,6 +6387,11 @@ function CustomerGridSurface({
6316
  saveState={saveState}
6317
  onSelect={selectView}
6318
  onCreate={createView}
 
 
 
 
 
6319
  onRename={renameView}
6320
  onNote={setViewNote}
6321
  onDuplicate={duplicateView}
@@ -6968,8 +7044,33 @@ function CustomerGridSurface({
6968
  run={scriptRun}
6969
  onRun={onScriptRun}
6970
  onSave={onScriptSave}
 
 
 
 
 
 
 
 
6971
  />
6972
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6973
  {displayMode === "form" && (
6974
  <FormInterface
6975
  /* ⚠ THE TABLE KEY, not the `TopicConfig` object. C's hand-off wrote
 
65
  GridChatViewSpec } from "./GridChat.tsx";
66
  import { ViewAgentPanel, seedName, seedScript } from "./ViewAgentPanel";
67
  import {
68
+ createScriptView, listScriptViews, readScriptView, revertScriptView, runScriptView,
69
+ saveScriptView,
70
  } from "./scriptViews";
71
  import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews";
72
  import { defaultViewConfig, useGridColumns } from "./useGridColumns";
 
158
  MAX_FROZEN, choiceOptions, choiceVocabulary, clampFrozenCount, cleanDisplay, formulaOf,
159
  topicForScope,
160
  isDateFamilyType, isFilterGroup, isGroupableField, isMachineOwned,
161
+ isDerivedLink, isUserSchemaField, isBlankValue, TOTAL_GROUP_KEY,
162
  isNumericFieldType,
163
  machineFoundRows, reFindConsequences,
164
  isPickType, mayEditField,
 
2385
  const activeScriptId = scriptRows.some((row) => row.id === activeViewId) ? activeViewId : null;
2386
  const [scriptDoc, setScriptDoc] = useState<ScriptViewRecord | null>(null);
2387
  const [scriptRun, setScriptRun] = useState<ScriptRun | null>(null);
2388
+ /* ⚠ W37-T46 β€” "revert" JOINS THIS UNION rather than getting a `reverting` boolean of its own.
2389
+ One busy value can only be in one state, which is what stops a roll-back landing while a save
2390
+ is in flight; two independent booleans would let both controls arm at once and the later
2391
+ response would silently win. */
2392
+ const [scriptBusy, setScriptBusy] = useState<"" | "run" | "save" | "revert">("");
2393
  useEffect(() => {
2394
  if (activeScriptId === null) {
2395
  setScriptDoc(null);
 
2457
  setScriptBusy("");
2458
  });
2459
  }, [activeScriptId]);
2460
+ /**
2461
+ * ⭐⭐ W37-T46 / D-338 β€” ROLL THE CUSTOM VIEW BACK TO AN EARLIER VERSION.
2462
+ *
2463
+ * β›” THE RESPONSE'S VIEW IS WHAT LANDS IN `scriptDoc`, and that is the whole clause rather than
2464
+ * a refresh nicety. The panel header reads `v${view.version}` and `restored from v${restoredFrom}`
2465
+ * off this document, so a revert that writes the store and leaves `scriptDoc` stale would roll
2466
+ * the view back correctly and then LIE about which version is open. The server records the
2467
+ * roll-back as a NEW version (v2 + v1 becomes v3, `restoredFrom: 1`), so the number always moves
2468
+ * forward and a stale header is visible as a wrong number rather than as nothing at all.
2469
+ * ⚠ `setScriptBump` for the same reason `onScriptSave` calls it: the rail row carries the
2470
+ * version, so it goes stale on exactly the same event.
2471
+ */
2472
+ const onScriptRevert = useCallback((version: number) => {
2473
+ if (activeScriptId === null) return;
2474
+ setScriptBusy("revert");
2475
+ void revertScriptView(activeScriptId, version).then((answer) => {
2476
+ if (answer.view) {
2477
+ setScriptDoc(answer.view);
2478
+ setScriptBump((b) => b + 1);
2479
+ } else if (answer.error) {
2480
+ signal(TOAST_EVENT, answer.error);
2481
+ }
2482
+ setScriptBusy("");
2483
+ });
2484
+ }, [activeScriptId]);
2485
  const lockedNote = useMemo(
2486
  // ⚠ The label is the ARTEFACT's source name when there is one, and otherwise nothing: the
2487
  // payload carries no database name, and "This database" is unambiguous on a surface whose
 
2732
  unanswerable: true,
2733
  };
2734
  }
2735
+ /* ⭐⭐ D-394 β€” THE ONE PREDICATE, and the fix is which QUESTION is asked rather than how the
2736
+ count is taken. This tested null / undefined / whitespace only, so on a column the
2737
+ connector back-fills with `"(none)"` it reported PERFECT coverage: "0 of 5872 records
2738
+ have no Supplier" over 2,291 rows that visibly read "(none)" on the same screen. The
2739
+ sentinel is named once in `types.ts`; see the note there for why the filter's `isEmpty`
2740
+ deliberately answers differently. */
2741
+ const blank = computedRows.filter((r) => isBlankValue(r[named.key])).length;
2742
  return {
2743
  text: `${blank} of ${total} records have no ${named.label}, so ${total - blank} do. ` +
2744
  `Counted over the records this view is showing.`,
 
4103
  signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot create a source view.");
4104
  return;
4105
  }
4106
+ /**
4107
+ * ⭐⭐ W37-T41 (owner item 10 / R2) β€” PICKING "Custom" MINTS A SCRIPT VIEW, NOT A WORKSPACE ONE.
4108
+ *
4109
+ * β›” THIS BRANCH IS THE HOP THE HOLD DID NOT NAME, and without it the whole release is
4110
+ * cosmetic. `icons.test.ts::HELD_MODES` listed three release conditions (join
4111
+ * `CREATABLE_MODES`, add the `displayMode === "script"` branch, delete the entry) and every
4112
+ * one of them is about a REGISTRY. None of them reaches the create path, so all three could
4113
+ * land, `mode_parity` could go green, and picking Custom would persist an ordinary workspace
4114
+ * view carrying `config.display.mode = "script"`: `activeScriptId` stays null because no row
4115
+ * in E's store has that id, `<ScriptView>` never mounts, and the person gets a chip reading
4116
+ * "Custom" over a grid. That is wave 27's `swipe` defect arriving through the door nobody
4117
+ * gated ([[gate-can-report-green-on-nothing]]).
4118
+ *
4119
+ * ⚠ A SCRIPT VIEW IS NOT A WORKSPACE VIEW. Its identity, source and version history live in
4120
+ * E's per-database store (`/api/v1/script-views`, contract C3), which is what makes R3's
4121
+ * "unlimited versions per database" expressible at all. So `permissions` is deliberately
4122
+ * unused here: a script view has no `personal` / `collaborative` split to carry, and
4123
+ * pretending otherwise would invent a wall the server does not enforce.
4124
+ * ⚠ The name is deduped against the RAIL, not `views`, because the rail is where a collision
4125
+ * would actually be visible: script projections sit beside stored views there.
4126
+ * ⚠ EMPTY SOURCE IS THE NORMAL FIRST STATE, not a slip. `routes_script_views._clean_source`
4127
+ * takes `allow_empty=True` on CREATE alone, precisely so this pick cannot 400.
4128
+ */
4129
+ if (mode === "script") {
4130
+ const scriptName = uniqueDisplayName(name, railViews.map((view) => view.name));
4131
+ void createScriptView(scope, "", scriptName).then((answer) => {
4132
+ if (!answer.view) {
4133
+ signal(TOAST_EVENT, answer.error ?? "That Custom View could not be created.");
4134
+ return;
4135
+ }
4136
+ setScriptRows((rows) => [...rows, answer.view as ScriptViewRow]);
4137
+ openScriptView(answer.view);
4138
+ setScriptDoc(answer.view);
4139
+ setScriptRun(null);
4140
+ });
4141
+ return;
4142
+ }
4143
  const acceptedName = uniqueDisplayName(name, views.map((view) => view.name));
4144
  /**
4145
  * ⭐⭐ WAVE 27 Β· OWNER ITEM 9 / RULING R4 β€” **A NEW VIEW IS BLANK. ALL OF IT.**
 
4197
  },
4198
  // `fields`, not `config` (R4): the blank is derived from the COLUMNS, and reading the live
4199
  // config here at all is what item 9 deletes.
4200
+ // ⚠ `railViews` and `openScriptView` join for W37-T41's mint-on-pick branch above.
4201
+ [fields, persistView, views, queryBinding, scope, railViews, openScriptView]
4202
  );
4203
  const renameView = useCallback(
4204
  (id: string, name: string) => {
 
6387
  saveState={saveState}
6388
  onSelect={selectView}
6389
  onCreate={createView}
6390
+ /* ⭐ W37-T41 β€” the SAME predicate that decides whether script views are listed at all
6391
+ (`scriptRows`' effect), so the offer and the rail can never disagree. Passing a
6392
+ literal `true` here would offer a Custom View on a pool scope, where the created
6393
+ view is real on the server and appears in no rail. */
6394
+ scriptable={hostWorkspace && isUserTable}
6395
  onRename={renameView}
6396
  onNote={setViewNote}
6397
  onDuplicate={duplicateView}
 
7044
  run={scriptRun}
7045
  onRun={onScriptRun}
7046
  onSave={onScriptSave}
7047
+ /* ⭐⭐ W37-T46 / D-338 β€” THE PROP THAT WAS NEVER PASSED. `revertScriptView` shipped,
7048
+ `ScriptViewPanel` accepted `onRevert`, and the server recorded a roll-back as a
7049
+ NEW version, all correctly; this mount forwarded six props and not this one, so
7050
+ the "Earlier | v1" pill rendered permanently `disabled` and D-338 stayed open
7051
+ behind three green tickets. A whole feature reached by nothing is this wave's
7052
+ dominant shape ([[reachable-is-not-the-same-as-built]]). */
7053
+ reverting={scriptBusy === "revert"}
7054
+ onRevert={onScriptRevert}
7055
  />
7056
  )}
7057
+ {/* ⭐⭐ W37-T41 β€” A VIEW SET TO "Custom" WITH NO CUSTOM VIEW UNDER IT.
7058
+ β›” THIS IS A REAL STATE, not a gate-shaped formality. W37-T20 taught the host to STORE
7059
+ `mode: "script"`, so a workspace view can now carry the mode while `activeScriptId`
7060
+ is null (nothing in E's store answers to a workspace view's id). Before this branch
7061
+ that view painted a GRID under a chip reading "Custom", which is exactly the
7062
+ silently-wrong render `mode_parity`'s law D exists to forbid.
7063
+ ⚠ `activeScriptId !== null` above stays the mount for the real panel: a script view
7064
+ is a rail PROJECTION, so its identity is the row in E's store. This branch handles
7065
+ the other direction and says so rather than guessing. */}
7066
+ {displayMode === "script" && activeScriptId === null && (
7067
+ <div className="cg-script">
7068
+ <p className="cg-script-empty cg-script-empty--pad">
7069
+ This view is set to Custom and no Custom View is stored under it. Pick one from the
7070
+ rail, or create a new Custom View.
7071
+ </p>
7072
+ </div>
7073
+ )}
7074
  {displayMode === "form" && (
7075
  <FormInterface
7076
  /* ⚠ THE TABLE KEY, not the `TopicConfig` object. C's hand-off wrote
web/src/customer-grid/ScriptViewPanel.tsx CHANGED
@@ -282,7 +282,14 @@ export const ScriptView = memo(function ScriptView({
282
  ⚠ `trimmed` is PRINTED when it is non-zero. A history capped at 40 that silently
283
  showed 40 would let a reader conclude the view was only ever saved 40 times; saying
284
  how many were dropped is the difference between a partial record and a wrong one. */}
285
- {(view.history?.length ?? 0) > 0 ? (
 
 
 
 
 
 
 
286
  <div className="cg-script-history" aria-label="Earlier versions">
287
  <span className="cg-script-history-lead">Earlier</span>
288
  {[...(view.history ?? [])].map((h) => (
@@ -290,7 +297,7 @@ export const ScriptView = memo(function ScriptView({
290
  type="button"
291
  key={h.version}
292
  className="cg-script-history-item"
293
- disabled={readOnly || reverting || onRevert === undefined}
294
  title={`Go back to version ${h.version}, saved by ${h.author || "somebody"}`}
295
  onClick={() => onRevert?.(h.version)}
296
  >
 
282
  ⚠ `trimmed` is PRINTED when it is non-zero. A history capped at 40 that silently
283
  showed 40 would let a reader conclude the view was only ever saved 40 times; saying
284
  how many were dropped is the difference between a partial record and a wrong one. */}
285
+ {/* β›” W37-T46 β€” THE STRIP IS ABSENT WHEN THE CALLER CANNOT REVERT, which is what the
286
+ prop's own doc twelve lines up already prescribed ("The control is not rendered at
287
+ all when it is absent, rather than rendered and refused"). Line 293 shipped
288
+ `onRevert === undefined` folded into `disabled` instead, so the one mount in the
289
+ product rendered a permanently dead "Earlier | v1" pill and the comment disowned the
290
+ code beside it. Two facts, two shapes: NOTHING TO GO BACK TO is an absent strip, and
291
+ BUSY is a disabled button. */}
292
+ {onRevert !== undefined && (view.history?.length ?? 0) > 0 ? (
293
  <div className="cg-script-history" aria-label="Earlier versions">
294
  <span className="cg-script-history-lead">Earlier</span>
295
  {[...(view.history ?? [])].map((h) => (
 
297
  type="button"
298
  key={h.version}
299
  className="cg-script-history-item"
300
+ disabled={readOnly || reverting}
301
  title={`Go back to version ${h.version}, saved by ${h.author || "somebody"}`}
302
  onClick={() => onRevert?.(h.version)}
303
  >
web/src/customer-grid/ViewSidebar.tsx CHANGED
@@ -135,6 +135,19 @@ interface ViewSidebarProps {
135
  * creation carries both. `permissions` is always explicit (C4: absent on create means
136
  * personal, so an omission would silently contradict the form). */
137
  onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  onRename: (id: string, name: string) => void;
139
  /** Persist a view's description. "" is a real value β€” it clears it. */
140
  onNote: (id: string, note: string) => void;
@@ -317,6 +330,7 @@ export default function ViewSidebar({
317
  saveState,
318
  onSelect,
319
  onCreate,
 
320
  onRename,
321
  onNote,
322
  onDuplicate,
@@ -1152,7 +1166,9 @@ export default function ViewSidebar({
1152
  </div>
1153
  ) : (
1154
  <>
1155
- {CREATABLE_MODES.map((mode) => (
 
 
1156
  <button
1157
  key={mode}
1158
  type="button"
 
135
  * creation carries both. `permissions` is always explicit (C4: absent on create means
136
  * personal, so an omission would silently contradict the form). */
137
  onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
138
+ /**
139
+ * ⭐⭐ W37-T41 β€” may this surface hold a CUSTOM VIEW? The row is dropped when it cannot.
140
+ *
141
+ * β›” REQUIRED, NOT OPTIONAL, and that is the difference between this and a default. Script
142
+ * views are listed and resolved only on `ut_*` databases: `CustomerGrid`'s `scriptRows` effect
143
+ * gates on `hostWorkspace && isUserTable`, so a Custom View minted on a pool scope or a Query
144
+ * preview would be real on the server and invisible in every rail
145
+ * ([[permitted-is-not-answerable]]). An optional prop would let a new mount forget the question
146
+ * and quietly get the answer that ships the broken case.
147
+ * ⚠ It hides the CREATE ROW, never the mode: a stored `script` view still renders, still
148
+ * carries its icon and label, and still reads back as itself.
149
+ */
150
+ scriptable: boolean;
151
  onRename: (id: string, name: string) => void;
152
  /** Persist a view's description. "" is a real value β€” it clears it. */
153
  onNote: (id: string, note: string) => void;
 
330
  saveState,
331
  onSelect,
332
  onCreate,
333
+ scriptable,
334
  onRename,
335
  onNote,
336
  onDuplicate,
 
1166
  </div>
1167
  ) : (
1168
  <>
1169
+ {/* ⚠ W37-T41 β€” FILTERED, not a second list. `CREATABLE_MODES` stays the one roster
1170
+ every gate reads; this drops the one row this surface cannot honour. */}
1171
+ {CREATABLE_MODES.filter((mode) => mode !== "script" || scriptable).map((mode) => (
1172
  <button
1173
  key={mode}
1174
  type="button"
web/src/customer-grid/iconShapes.ts CHANGED
@@ -1,776 +1,812 @@
1
- // ---------------------------------------------------------------------------
2
- // customer-grid / iconShapes.ts
3
- // Wave-8 items I18 + I20 β€” ONE geometry source for the grid's icon vocabulary,
4
- // rendered by TWO very different painters:
5
- //
6
- // - React <FieldTypeIcon> / <ModeIcon> β€” DOM svg in panels, popovers, menus
7
- // - glide headerIcons sprites β€” canvas, drawn from an SVG *string*
8
- //
9
- // Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
10
- // never be a React component. Keeping the paths as DATA (IconShape[]) and giving
11
- // each painter its own thin renderer is what stops the two from drifting β€” the
12
- // alternative (hand-copying every path into a template literal) guarantees the
13
- // header and the panel eventually disagree about what a "date" looks like.
14
- //
15
- // Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
16
- // currentColor. Vector paths only β€” NEVER emoji (owner constant).
17
- // ---------------------------------------------------------------------------
18
-
19
- import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
20
- import {
21
- LP_BLUE,
22
- LP_BLUE_DEEP,
23
- LP_GREEN,
24
- LP_GREEN_DEEP,
25
- LP_LINE,
26
- LP_MUTED,
27
- LP_RED,
28
- LP_RED_DEEP,
29
- LP_YELLOW,
30
- LP_YELLOW_DEEP,
31
- } from "./theme";
32
-
33
- /** One drawing primitive. `fill: true` fills the path instead of stroking it
34
- * (the rating star is the only shape that reads better solid). */
35
- export type IconShape = { d: string; fill?: boolean };
36
-
37
- /** A circle as a path β€” two half-arcs. Sprites are SVG *source*, so every shape
38
- * has to survive being serialized into a string; paths do, <circle> elements
39
- * would need a second serializer branch for no benefit. */
40
- const circle = (cx: number, cy: number, r: number): string =>
41
- `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
42
-
43
- const CALENDAR: IconShape[] = [
44
- { d: "M3.2 4.6h9.6v8.2H3.2z" },
45
- { d: "M3.2 7.2h9.6" },
46
- { d: "M5.8 3v3.2" },
47
- { d: "M10.2 3v3.2" },
48
- ];
49
-
50
- /**
51
- * Field type β†’ icon geometry. A TOTAL record on purpose: adding a FieldType
52
- * without an icon is a compile error, not a silently blank header.
53
- */
54
- export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
55
- text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
56
- status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
57
- currency: [
58
- { d: "M8 2.8v10.4" },
59
- { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
60
- ],
61
- int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
62
- date: CALENDAR,
63
- pct: [
64
- { d: circle(4.6, 4.6, 1.6) },
65
- { d: circle(11.4, 11.4, 1.6) },
66
- { d: "M12.2 3.9L3.8 12.3" },
67
- ],
68
- select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
69
- user: [
70
- { d: circle(8, 6, 2.4) },
71
- { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
72
- ],
73
- multiselect: [
74
- { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
75
- { d: "M7.2 5.7h6M7.2 10.3h6" },
76
- ],
77
- checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
78
- phone: [
79
- { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
80
- ],
81
- email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
82
- url: [
83
- { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
84
- { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
85
- { d: "M6.2 9.8l3.6-3.6" },
86
- ],
87
- rating: [
88
- {
89
- d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
90
- fill: true,
91
- },
92
- ],
93
- created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
94
- formula: [
95
- { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
96
- { d: "M4.2 7.6h4.6" },
97
- { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
98
- ],
99
- // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
100
- // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
101
- // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
102
- // (`created_time` owns the closed rim + hands).
103
- automation: [
104
- { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
105
- { d: "M4.2 6L5.3 4.1 3.1 4.5" },
106
- { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
107
- ],
108
- // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
109
- // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
110
- // the formula fx (that computes over the ROW) and not a bare number (int owns ##).
111
- metric: [
112
- { d: "M3.2 3.2v9.6h9.6" },
113
- { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
114
- ],
115
- // Wave-23 C7 β€” TWO BRACES facing each other with a dot between them: the universal mark for
116
- // "a structured document", and the one glyph in this table that draws its own SYNTAX rather
117
- // than a picture of what the value means. Deliberately not a document page (nothing here owns
118
- // that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
119
- // of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
120
- // reading as parentheses at small sizes.
121
- json: [
122
- { d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
123
- { d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
124
- { d: circle(8, 8, 0.85), fill: true },
125
- ],
126
- // ⭐ Wave-27 item 13 (R13) β€” THE ANGLE BRACKETS, the mark every editor on earth uses for
127
- // "this is source". Drawn as two chevrons with a slash leaning between them, which is what
128
- // separates it from `json` two entries up: json draws BRACES (a document's own syntax), code
129
- // draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this",
130
- // and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and
131
- // `text` already trade on that reading).
132
- code: [
133
- { d: "M5.6 4.9 2.6 8l3 3.1" },
134
- { d: "M10.4 4.9 13.4 8l-3 3.1" },
135
- { d: "M9.1 3.6 6.9 12.4" },
136
- ],
137
- // Wave-19 R7 β€” a framed picture: the mount, a sun, and the hill line every photo glyph
138
- // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
139
- image: [
140
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
141
- { d: circle(6, 6.3, 1.1) },
142
- { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
143
- ],
144
- // ⭐ 2026-08-07 β€” TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as
145
- // "this points at something else". Drawn as two rounded rectangles overlapping at the centre
146
- // rather than as an arrow into a box: an arrow would mean navigation, and a link column is a
147
- // relation that exists in both directions whether or not you follow it.
148
- link: [
149
- { d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" },
150
- { d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" },
151
- { d: "M5.6 8h4.8" },
152
- ],
153
- // ⭐ 2026-08-07 β€” THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values
154
- // collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would
155
- // claim SUM when the function is chosen per column) and not a funnel (that is filtering,
156
- // which is what `limit` does β€” a different half of the same field).
157
- rollup: [
158
- { d: "M3.2 4.4h9.6" },
159
- { d: "M4.8 8h6.4" },
160
- { d: "M6.6 11.6h2.8" },
161
- ],
162
- // ⭐ WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β€” AI ENRICHMENT.
163
- // The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
164
- // on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
165
- // second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
166
- // (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
167
- // head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
168
- // ⚠ TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
169
- // The small companion is what makes the mark say "sparkle".
170
- ai_enrich: [
171
- { d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
172
- { d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
173
- ],
174
- };
175
-
176
- /**
177
- * Display mode β†’ icon geometry (I18). Also total: the wave-8 Dashboard mode
178
- * cannot land in DISPLAY_MODES without the compiler demanding its icon here.
179
- */
180
- export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
181
- grid: [
182
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
183
- { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
184
- ],
185
- list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
186
- // I19c β€” a framed set of bars: "several charts", not "one chart".
187
- chart: [
188
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
189
- { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
190
- ],
191
- // I10 (C2) β€” the LEGACY key. Kept so this record stays total over DisplayMode, which is
192
- // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
193
- // promise. Same drawing: a stored 'dashboard' IS a chart.
194
- dashboard: [
195
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
196
- { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
197
- ],
198
- calendar: CALENDAR,
199
- kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
200
- map: [
201
- { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
202
- { d: circle(8, 6.1, 1.3) },
203
- ],
204
- // 2026-08-02 item 7 β€” the time-series view. Drawn as a framed grid with a trend running
205
- // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
206
- // a per-row line toggle. Deliberately not the plain `line` chart mark β€” a chart view and a
207
- // time-series table must not be the same picture in the same rail.
208
- timeseries: [
209
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
210
- { d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
211
- { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
212
- ],
213
- // Wave-18 C6-CATALOG β€” an OPEN BOOK: two facing leaves with a spine between them, and a
214
- // product block sitting on the left one. Every other mode in this table draws a way of
215
- // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
216
- // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
217
- // not a framed grid (`grid`/`chart`/`timeseries` share the frame).
218
- catalog: [
219
- { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
220
- { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
221
- { d: "M3.9 6.4h2.3v2.8H3.9z" },
222
- ],
223
- // Wave-23 C9 β€” a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
224
- // them. Every other mode here draws a way of ARRANGING records that already exist; this one
225
- // has to read as a record being MADE, so it is the only mark whose bottom line is open.
226
- // Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
227
- // (an edit affordance means something else app-wide).
228
- // ⭐ Wave-27 C3 (owner item 8) β€” a CARD WITH TWO ARROWS LEAVING IT, left and right. Every
229
- // other mark here draws an arrangement of many records; this one has to read as ONE record
230
- // with two exits, because that is exactly what the deck is. Deliberately not a stack of cards
231
- // (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in
232
- // this vocabulary draws a body part, and it would read as "drag" rather than "decide").
233
- swipe: [
234
- { d: "M5.4 3.4h5.2v9.2H5.4z" },
235
- { d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" },
236
- { d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" },
237
- ],
238
- form: [
239
- { d: "M3.4 2.8h9.2v10.4H3.4z" },
240
- { d: "M5.6 5.6h4.8" },
241
- { d: "M5.6 8h4.8" },
242
- { d: "M5.6 10.6h2.6" },
243
- ],
244
- // W36-T04 β€” angle brackets over a baseline: the universal mark for "this is code", and the one
245
- // shape in this table that is about the AUTHORING rather than about the arrangement of rows.
246
- script: [
247
- { d: "M6 5.4 3.2 8l2.8 2.6M10 5.4 12.8 8 10 10.6" },
248
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
249
- ],
250
- };
251
-
252
- /**
253
- * Wave-9 I16 β€” one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
254
- * vocabulary cannot ship without a drawing.
255
- *
256
- * ⚠ The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
257
- * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
258
- * picker is NOT a native `<select>` β€” it is a radio-row list like the mode switcher, which
259
- * is the only shape that can carry a real mark.
260
- *
261
- * The key type is a string union declared here rather than imported from chartData.ts: this
262
- * module is a leaf (types + theme only) and chartData imports IT, not the reverse.
263
- */
264
- export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
265
- export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
266
- bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
267
- line: [
268
- { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
269
- { d: circle(5.8, 7.8, 0.9) },
270
- { d: circle(8.4, 9.8, 0.9) },
271
- ],
272
- area: [
273
- { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
274
- { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
275
- ],
276
- donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
277
- // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
278
- // reads as "one number" beside four marks that all read as "a distribution".
279
- kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
280
- // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
281
- // values, not a distribution), with a header band and a column rule.
282
- table: [
283
- { d: "M2.6 3.4h10.8v9.2H2.6z" },
284
- { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
285
- ],
286
- };
287
-
288
- export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
289
- bar: "Bar",
290
- line: "Line",
291
- area: "Area",
292
- donut: "Donut",
293
- kpi: "Single value",
294
- table: "Table",
295
- };
296
-
297
- /** I16 β€” the pastel each chart kind wears, same family rule as MODE_TONE. */
298
- export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
299
- bar: "blue",
300
- line: "green",
301
- area: "green",
302
- donut: "yellow",
303
- kpi: "neutral",
304
- table: "neutral",
305
- };
306
-
307
- /**
308
- * Wave-9 contract C5 (I15) β€” folder icon geometry. TOTAL over `FolderShape`, so a shape key
309
- * added to the wire contract in types.ts cannot ship without a drawing.
310
- *
311
- * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
312
- * the same rail and read as one family. `folder` is first because it is the default every
313
- * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
314
- */
315
- export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
316
- folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
317
- star: [
318
- { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
319
- ],
320
- flag: [
321
- { d: "M4.2 13.4V2.9" },
322
- { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
323
- ],
324
- tag: [
325
- { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
326
- { d: circle(5.6, 5.6, 1) },
327
- ],
328
- bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
329
- // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
330
- // Reusing the geometry rather than drawing a second "chart" is the whole point of the
331
- // one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
332
- grid: MODE_SHAPES.grid,
333
- chart: MODE_SHAPES.dashboard,
334
- map: MODE_SHAPES.map,
335
- users: TYPE_SHAPES.user,
336
- clock: TYPE_SHAPES.created_time,
337
- heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
338
- bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
339
- };
340
-
341
- /**
342
- * Tone key β†’ the pastel it FILLS with, and the -d weight it STROKES with.
343
- *
344
- * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
345
- * that a base pastel at that size smudges β€” LP_BLUE measures 1.88:1 on white. So the pastel
346
- * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
347
- * outline (an outline that reads at all).
348
- *
349
- * The default tone is `neutral` β€” HOST's C5 key, not "grey". The whitelist is SHARED between
350
- * the two ends, so the name matters more than the word: a tone the host does not recognise
351
- * degrades to the default and the user's choice silently disappears on reload.
352
- */
353
- export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
354
- neutral: { fill: LP_LINE, stroke: LP_MUTED },
355
- blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
356
- green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
357
- yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
358
- red: { fill: LP_RED, stroke: LP_RED_DEEP },
359
- };
360
-
361
- export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
362
- neutral: "Neutral",
363
- blue: "Blue",
364
- green: "Green",
365
- yellow: "Yellow",
366
- red: "Red",
367
- };
368
-
369
- export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
370
- folder: "Folder",
371
- star: "Star",
372
- flag: "Flag",
373
- tag: "Tag",
374
- bookmark: "Bookmark",
375
- grid: "Table",
376
- chart: "Chart",
377
- map: "Map",
378
- users: "People",
379
- clock: "Clock",
380
- heart: "Heart",
381
- bolt: "Priority",
382
- };
383
-
384
- /**
385
- * Wave-9 I14 β€” the tone each CREATABLE view type wears in the "+ Create new…" flyout.
386
- *
387
- * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
388
- * is a list you read rather than scan. Assigned by family, not by rotation: the two
389
- * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
390
- * share yellow, chart is green because it is the analytical one, map is red because it is
391
- * the geographic one. Folder is grey β€” it is not a view, and the flyout's last row should
392
- * not compete with the six above it.
393
- */
394
- /**
395
- * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
396
- * sits beside the geometry, the way TYPE_LABELS does β€” the mode switcher, the create flyout
397
- * and the create prompt now read ONE table instead of three. C2's "Dashboard" β†’ "Chart"
398
- * rename is a single line here as a direct result.
399
- */
400
- export const MODE_LABELS: Record<DisplayMode, string> = {
401
- grid: "Grid",
402
- chart: "Chart",
403
- // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
404
- // read before normalisation must never render a blank switcher chip.
405
- dashboard: "Chart",
406
- list: "List",
407
- calendar: "Calendar",
408
- kanban: "Kanban",
409
- map: "Map",
410
- timeseries: "Time series",
411
- catalog: "Catalog",
412
- // Wave-23 C9 β€” the mode that COLLECTS records. "Form", the word the whole product uses for
413
- // it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
414
- // be the one surface calling it something else.
415
- form: "Form",
416
- // ⭐ Wave-27 C3 (item 8) β€” the owner's own word for it. Not "Triage" or "Review": the gesture
417
- // IS the name here, and the two candidates both collide with vocabulary this product already
418
- // spends elsewhere (a `review` automation decision, the retired review lanes).
419
- swipe: "Swipe",
420
- // W36-T04 β€” the owner's own words are "code script"; "Script" is the half that is not implied
421
- // by the icon, and the product has no other surface competing for the noun.
422
- script: "Script",
423
- };
424
-
425
- /**
426
- * I14 β€” the view types the "+ Create new…" flyout OFFERS, in the order it lists them.
427
- *
428
- * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
429
- * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
430
- * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β€” one list cannot
431
- * express both. Living in this pure data module means the gate can assert the offered set
432
- * without importing a React component, and I10 becomes a one-line edit in one file.
433
- */
434
- // 2026-08-02 item 7 β€” `timeseries` was deliberately held OUT of this list until the host
435
- // accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
436
- // wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
437
- // not know, so offering it early would let a user create a view that silently reverts to a
438
- // grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
439
- // `DISPLAY_MODES += timeseries`, so it is offerable now.
440
- //
441
- // wave17 GRID, owner item 10 β€” THE ORDER BELOW IS THE OWNER'S, stated verbatim:
442
- // Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
443
- //
444
- // ⚠ It supersedes the two orderings this list has carried before it, and the reasoning that
445
- // produced them is now WRONG rather than merely outranked, so it is not left here to be
446
- // re-applied: `timeseries` was "listed last… it belongs beside Chart", and `list` sat second
447
- // as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
448
- // a product decision, so it is asserted in `verify_icons` rather than left to a comment β€”
449
- // nothing else on screen would go red if a future edit re-sorted it "sensibly".
450
- // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
451
- // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
452
- // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
453
- // It lands LAST by contract: the owner's seven-mode order above is a product decision and the
454
- // new mode joins the end of it rather than being sorted into it.
455
- // ⭐ Wave-27 C3 (owner item 8) β€” `swipe` is HELD OUT of this list, and the reason corrects a
456
- // mistake this file made an hour earlier.
457
- //
458
- // β›” THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a
459
- // mode the HOST has not accepted", and one session owning both registries this wave genuinely
460
- // does close that half β€” which is what made it tempting to skip. But the rule the hold really
461
- // encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not
462
- // exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch
463
- // for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered
464
- // a grid under a chip reading "View Β· Swipe" β€” surviving reload, with the agreement leg green
465
- // because it only ever compared two lists.
466
- //
467
- // So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox
468
- // handshake: `verify_icons` DERIVES the condition β€” either `swipe` is absent here, or
469
- // `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other
470
- // direction, sitting unoffered because its host mirror never landed (DEBT D-90).
471
- //
472
- // β›” THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE.
473
- // Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted
474
- // `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded
475
- // "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β€” a negative control FOR the
476
- // swipe carry β€” and the owner could not find the view because **nothing imported the file.**
477
- // The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies
478
- // it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]).
479
- // A hold that is safe to leave in place is a hold nothing forces you to lift.
480
- // The audit query that found it, after the battery did not: for each artifact a wave adds, grep
481
- // for its CONSUMER β€” who imports/mounts/registers it β€” excluding the file itself, `_test/`, css
482
- // and comments. `SwipeView` had four hits and three were prose.
483
- // Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
484
- // It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
485
- // mode joins the end of it rather than being sorted into it.
486
- export const CREATABLE_MODES: DisplayMode[] = [
487
- "grid",
488
- "chart",
489
- "calendar",
490
- "kanban",
491
- "timeseries",
492
- "map",
493
- "list",
494
- "catalog",
495
- "swipe",
496
- // Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so
497
- // `form` is offerable and its `HELD_MODES` entry came out in the same edit β€” the hold's own text
498
- // named this mount as its release condition. ⚠ Found by `verify_icons.py::mode_parity` law E
499
- // during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built
500
- // the Interface group, and between them the DOOR was never opened β€” the wave's biggest new
501
- // surface was unreachable behind two green tickets.
502
- "form",
503
- ];
504
-
505
- /**
506
- * ⭐ WAVE-29 R6 (owner item 10) β€” the kind dropdown splits under TWO headers, and the strings are
507
- * the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before),
508
- * not "Custom interface".
509
- *
510
- * The line the two groups draw: a **View** ARRANGES the records β€” the same rows, re-shaped (a
511
- * grid, a board, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a picture of the
512
- * world with records placed on it, a catalog is a published artifact, a form is a door records come
513
- * IN through and shows no records at all.
514
- *
515
- * ⭐ WAVE-33 item 9 AMENDED THAT LINE, and the owner drew it one notch differently than R6 did:
516
- * `swipe` and `timeseries` moved from View to Interface. The reading that makes both rulings one
517
- * rule β€” and the one the next mode should be grouped by β€” is **what the surface is FOR**, not how
518
- * many rows it shows. A deck triages records one at a time and WRITES to them; a trend answers a
519
- * question about a measure over time; neither hands you the row set re-shaped, which is what every
520
- * remaining View does. ⚠ Do NOT re-derive the split from `MODE_TONE` below: the tones still say
521
- * `catalog`/`form` are neutral because they arrange nothing, and `swipe`/`timeseries` are toned,
522
- * so tone and group are no longer the same cut. Group membership lives HERE and only here.
523
- *
524
- * ⚠ TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it
525
- * vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this
526
- * map, and a mode whose group label matched nothing would silently stop being offered while
527
- * every existing check (paintable, labelled, toned, unique, ordered) stayed green.
528
- * `dashboard` is grouped like the `chart` it is the legacy spelling of β€” it is never offered, and
529
- * a partial map is a worse answer than an unused entry.
530
- */
531
- export const MODE_GROUP_LABELS = ["View", "Interface"] as const;
532
- export type ModeGroup = (typeof MODE_GROUP_LABELS)[number];
533
-
534
- export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
535
- grid: "View",
536
- list: "View",
537
- kanban: "View",
538
- calendar: "View",
539
- chart: "View",
540
- dashboard: "View",
541
- // ⭐ WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
542
- // of under View, when a user toggle it." See the amended taxonomy note above β€” a deck and a
543
- // trend are both surfaces a person WORKS IN, not re-shapings of the row set.
544
- timeseries: "Interface",
545
- swipe: "Interface",
546
- map: "Interface",
547
- catalog: "Interface",
548
- form: "Interface",
549
- // W36-T04 β€” "Interface", by the W33 item-9 taxonomy: a script view is a surface somebody WORKS
550
- // IN (writes, runs, reads an answer), not a re-shaping of the row set.
551
- script: "Interface",
552
- };
553
-
554
- /**
555
- * The offered modes, split into R6's two groups β€” DERIVED, never a third hand-written list.
556
- *
557
- * ⚠ ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which
558
- * is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and
559
- * is separately asserted. The two rulings are compatible read this way and only this way: R6 moved
560
- * `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but
561
- * every pair it ordered is still in that relative order here.
562
- */
563
- export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] =
564
- MODE_GROUP_LABELS.map((label) => ({
565
- label,
566
- modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label),
567
- }));
568
-
569
- export const MODE_TONE: Record<DisplayMode, FolderTone> = {
570
- grid: "blue",
571
- list: "blue",
572
- chart: "green",
573
- dashboard: "green",
574
- calendar: "yellow",
575
- kanban: "yellow",
576
- map: "red",
577
- // Green with `chart`: it is the other analytical mode, and the two belong to one family.
578
- timeseries: "green",
579
- // Wave-18 C6-CATALOG β€” NEUTRAL, and it is the honest pick rather than the leftover one. The
580
- // four colour tones each name a family of ways to arrange records (blue = tabular, green =
581
- // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β€” it is a
582
- // published artifact. Giving it a colour would file it under a family it is not in.
583
- catalog: "neutral",
584
- // Wave-23 C9 β€” NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
585
- // name families of ways to ARRANGE records (blue tabular, green analytical, yellow
586
- // board/date, red spatial). A form arranges nothing β€” it is a door records come in through β€”
587
- // so giving it a colour would file it under a family it is not in.
588
- form: "neutral",
589
- // ⭐ Wave-27 C3 β€” YELLOW, with `kanban`, and this is a family claim rather than a leftover:
590
- // a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the
591
- // two are one family seen at two zooms β€” all the lanes at once, or one card at a time. Filing
592
- // it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those
593
- // two are neutral: this mode does arrange records, and it arranges them by the board's field.
594
- swipe: "yellow",
595
- // W36-T04 β€” NEUTRAL, by `catalog`'s and `form`'s rule rather than by elimination: the four
596
- // tones name families of ways to ARRANGE records (blue tabular, green analytical, yellow
597
- // board/date, red spatial). A script arranges nothing; it emits whatever it computes.
598
- script: "neutral",
599
- };
600
-
601
- /**
602
- * Human labels for every field type. Lives here beside the icons so the two
603
- * halves of "how a field type presents itself" stay in one file (ColumnMenu
604
- * imports it rather than keeping a second copy).
605
- */
606
- export const TYPE_LABELS: Record<FieldType, string> = {
607
- // ⭐ WAVE-29 item 3 β€” the owner's own words: "Change 'Single line text' to 'Text', keep it
608
- // simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus
609
- // its long-text sibling); this product has no multi-line text kind, so the qualifier
610
- // distinguished the type from nothing and only made the commonest row in the menu the longest.
611
- // βœ… The STORED key is `"text"` and always was β€” the old string was never persisted anywhere,
612
- // client or server, so this is a label change with no migration behind it.
613
- text: "Text",
614
- select: "Single select",
615
- multiselect: "Multi select",
616
- user: "Assignee",
617
- int: "Number",
618
- currency: "Currency",
619
- pct: "Percent",
620
- date: "Date",
621
- checkbox: "Checkbox",
622
- phone: "Phone number",
623
- email: "Email",
624
- url: "URL",
625
- rating: "Rating",
626
- created_time: "Created time",
627
- formula: "Formula",
628
- // Wave-18 C5-AUTOFIELD (D's spec, applied by C).
629
- automation: "Automation",
630
- // Wave-22 C7 β€” spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
631
- // on headers and the field gear, not the create menu.
632
- metric: "Metric",
633
- // Wave-19 R7 β€” the picture column.
634
- image: "Image",
635
- // Wave-23 C7 β€” the structured-document column. "JSON" rather than "Structured data": it is
636
- // the word on the wire, in the viewer's raw tab and in every error the server can return, and
637
- // a friendlier synonym would be the only place in the product using a different one.
638
- json: "JSON",
639
- // ⭐ 2026-08-07 β€” Airtable's own wording, deliberately. "Link to another record" is what a
640
- // person migrating from Airtable searches this menu for, and inventing a synonym ("Relation",
641
- // "Reference") would make the feature they came for look absent.
642
- link: "Link to another record",
643
- rollup: "Rollup",
644
- // ⭐ Wave-27 item 13 (R13) β€” "Code", not "Snippet" or "Source": it is the word the field kind
645
- // is called everywhere else in this wave (the ruling, the language picker, the viewer header),
646
- // and it says what the column holds without implying the product will run it.
647
- code: "Code",
648
- // ⭐ WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β€” the owner's own noun for the kind: "a field kind
649
- // called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
650
- // "Generate" (that names the verb, and the column's value is the point, not the act).
651
- ai_enrich: "AI enrichment",
652
- status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
653
- };
654
-
655
- /**
656
- * ⭐ WAVE-29 C7 (item 17) β€” THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is
657
- * what the totals row and the per-group subtotals compute. Server twin:
658
- * `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and
659
- * compares them name-for-name in order β€” the cross-language boundary is the one a type cannot
660
- * police, so it gets a gate.
661
- *
662
- * β›” ONE CLIENT LIST, IMPORTED β€” never re-declared. `aggregations.ts` and the field editor import
663
- * from here rather than keeping their own copy, which is why this lives in the pure data module
664
- * beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and
665
- * the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a
666
- * mirror that is an import cannot fall out of step at all.
667
- *
668
- * β›” NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg`
669
- * and gatekeeps a STORED value β€” renaming it would silently turn saved charts into sums. This
670
- * list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column
671
- * summary and a rollup fold say the same word for the same operation.
672
- *
673
- * ⚠ `median` is net-new β€” in neither `CHART_AGGS` nor `ROLLUP_FNS`.
674
- * ⚠ `count` counts ROWS in the scope, not non-blank cells.
675
- * ⚠ Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator
676
- * for that is ALREADY `isNumericFieldType` (types.ts) β€” do not write a second one. `count` is
677
- * legal on any type.
678
- */
679
- // ⭐ W29-T74 β€” an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed
680
- // `AggName`, so a second literal here would be a type that has to be kept in step by eye with a
681
- // type the compiler already owns β€” the same defect as the array below, one level up.
682
- export type FieldAgg = AggName;
683
-
684
- /** ORDERED β€” the order is the picker's order, on both engines. */
685
- export const FIELD_AGGS: readonly FieldAgg[] = [
686
- "sum",
687
- "average",
688
- "median",
689
- "min",
690
- "max",
691
- "count",
692
- ];
693
-
694
- /**
695
- * Human labels, in the summary bar's own compact register (Airtable's wording).
696
- *
697
- * ⚠ `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already
698
- * has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the
699
- * same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables
700
- * called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one.
701
- */
702
- export const FIELD_AGG_LABELS: Record<FieldAgg, string> = {
703
- sum: "Sum",
704
- average: "Average",
705
- median: "Median",
706
- min: "Min",
707
- max: "Max",
708
- count: "Count",
709
- };
710
-
711
-
712
- // ------------------------------------------------------------ glide sprites
713
-
714
- /** Serialize one shape to SVG source in an explicit colour (canvas sprites get
715
- * no `currentColor` β€” glide hands the painter the theme colours directly). */
716
- function shapeSource(s: IconShape, color: string): string {
717
- return s.fill
718
- ? `<path d="${s.d}" fill="${color}"/>`
719
- : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
720
- `stroke-linecap="round" stroke-linejoin="round"/>`;
721
- }
722
-
723
- function sprite(shapes: IconShape[]) {
724
- return ({ fgColor }: { fgColor: string }) =>
725
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
726
- shapes.map((s) => shapeSource(s, fgColor)).join("") +
727
- `</svg>`;
728
- }
729
-
730
- /** Glide header-icon NAME for a field type β€” the `icon` a GridColumn asks for. */
731
- export function typeIconName(type: FieldType): string {
732
- return `t_${type}`;
733
- }
734
-
735
- /**
736
- * The sprite map handed to <DataEditor headerIcons>. One entry per field type
737
- * (I20 draws the type mark in every column header), built from the same shapes
738
- * the React icons use.
739
- *
740
- * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
741
- * why theme.ts must set it β€” the library default is #FFFFFF, i.e. invisible on
742
- * our header (that was I21's actual bug, not a too-pale hex of ours).
743
- */
744
- export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
745
- Object.fromEntries(
746
- (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
747
- );
748
-
749
- /**
750
- * The header sprite map handed to <DataEditor headerIcons>. Two families:
751
- *
752
- * t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
753
- * from the same shapes the React icons use. Painted by glide in
754
- * `theme.fgIconHeader`.
755
- * aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
756
- * OUTLINE ONLY: a dark-grey ring with a transparent interior, per
757
- * the owner. It still deliberately IGNORES the colours glide hands
758
- * it, for the reason wave-8 recorded - glide's "special" variant is
759
- * accentColor behind bgHeader, which under the C1 pastels is a pale
760
- * glyph on a pale disc, i.e. I21 in a new costume.
761
- * ⚠ It is NO LONGER a column `overlayIcon`. Glide draws an overlay
762
- * at a hard-coded offset from the TYPE mark on the far LEFT of the
763
- * header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
764
- * aligned. It is now painted by CustomerGrid's `drawHeader`
765
- * callback at `infoMarkRect()` - see overlayPlacement.ts.
766
- */
767
- export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
768
- ...TYPE_SPRITES,
769
- aiosInfo: () =>
770
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
771
- `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
772
- `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
773
- `stroke-linecap="round"/>` +
774
- `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
775
- `</svg>`,
776
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / iconShapes.ts
3
+ // Wave-8 items I18 + I20 β€” ONE geometry source for the grid's icon vocabulary,
4
+ // rendered by TWO very different painters:
5
+ //
6
+ // - React <FieldTypeIcon> / <ModeIcon> β€” DOM svg in panels, popovers, menus
7
+ // - glide headerIcons sprites β€” canvas, drawn from an SVG *string*
8
+ //
9
+ // Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
10
+ // never be a React component. Keeping the paths as DATA (IconShape[]) and giving
11
+ // each painter its own thin renderer is what stops the two from drifting β€” the
12
+ // alternative (hand-copying every path into a template literal) guarantees the
13
+ // header and the panel eventually disagree about what a "date" looks like.
14
+ //
15
+ // Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
16
+ // currentColor. Vector paths only β€” NEVER emoji (owner constant).
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
20
+ import {
21
+ LP_BLUE,
22
+ LP_BLUE_DEEP,
23
+ LP_GREEN,
24
+ LP_GREEN_DEEP,
25
+ LP_LINE,
26
+ LP_MUTED,
27
+ LP_RED,
28
+ LP_RED_DEEP,
29
+ LP_YELLOW,
30
+ LP_YELLOW_DEEP,
31
+ } from "./theme";
32
+
33
+ /** One drawing primitive. `fill: true` fills the path instead of stroking it
34
+ * (the rating star is the only shape that reads better solid). */
35
+ export type IconShape = { d: string; fill?: boolean };
36
+
37
+ /** A circle as a path β€” two half-arcs. Sprites are SVG *source*, so every shape
38
+ * has to survive being serialized into a string; paths do, <circle> elements
39
+ * would need a second serializer branch for no benefit. */
40
+ const circle = (cx: number, cy: number, r: number): string =>
41
+ `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
42
+
43
+ const CALENDAR: IconShape[] = [
44
+ { d: "M3.2 4.6h9.6v8.2H3.2z" },
45
+ { d: "M3.2 7.2h9.6" },
46
+ { d: "M5.8 3v3.2" },
47
+ { d: "M10.2 3v3.2" },
48
+ ];
49
+
50
+ /**
51
+ * Field type β†’ icon geometry. A TOTAL record on purpose: adding a FieldType
52
+ * without an icon is a compile error, not a silently blank header.
53
+ */
54
+ export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
55
+ text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
56
+ status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
57
+ currency: [
58
+ { d: "M8 2.8v10.4" },
59
+ { d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
60
+ ],
61
+ int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
62
+ date: CALENDAR,
63
+ pct: [
64
+ { d: circle(4.6, 4.6, 1.6) },
65
+ { d: circle(11.4, 11.4, 1.6) },
66
+ { d: "M12.2 3.9L3.8 12.3" },
67
+ ],
68
+ select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
69
+ user: [
70
+ { d: circle(8, 6, 2.4) },
71
+ { d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
72
+ ],
73
+ multiselect: [
74
+ { d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
75
+ { d: "M7.2 5.7h6M7.2 10.3h6" },
76
+ ],
77
+ checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
78
+ phone: [
79
+ { d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
80
+ ],
81
+ email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
82
+ url: [
83
+ { d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
84
+ { d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
85
+ { d: "M6.2 9.8l3.6-3.6" },
86
+ ],
87
+ rating: [
88
+ {
89
+ d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
90
+ fill: true,
91
+ },
92
+ ],
93
+ created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
94
+ formula: [
95
+ { d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
96
+ { d: "M4.2 7.6h4.6" },
97
+ { d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
98
+ ],
99
+ // Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
100
+ // with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
101
+ // Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
102
+ // (`created_time` owns the closed rim + hands).
103
+ automation: [
104
+ { d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
105
+ { d: "M4.2 6L5.3 4.1 3.1 4.5" },
106
+ { d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
107
+ ],
108
+ // Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
109
+ // series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
110
+ // the formula fx (that computes over the ROW) and not a bare number (int owns ##).
111
+ metric: [
112
+ { d: "M3.2 3.2v9.6h9.6" },
113
+ { d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
114
+ ],
115
+ // Wave-23 C7 β€” TWO BRACES facing each other with a dot between them: the universal mark for
116
+ // "a structured document", and the one glyph in this table that draws its own SYNTAX rather
117
+ // than a picture of what the value means. Deliberately not a document page (nothing here owns
118
+ // that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
119
+ // of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
120
+ // reading as parentheses at small sizes.
121
+ json: [
122
+ { d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
123
+ { d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
124
+ { d: circle(8, 8, 0.85), fill: true },
125
+ ],
126
+ // ⭐ Wave-27 item 13 (R13) β€” THE ANGLE BRACKETS, the mark every editor on earth uses for
127
+ // "this is source". Drawn as two chevrons with a slash leaning between them, which is what
128
+ // separates it from `json` two entries up: json draws BRACES (a document's own syntax), code
129
+ // draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this",
130
+ // and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and
131
+ // `text` already trade on that reading).
132
+ code: [
133
+ { d: "M5.6 4.9 2.6 8l3 3.1" },
134
+ { d: "M10.4 4.9 13.4 8l-3 3.1" },
135
+ { d: "M9.1 3.6 6.9 12.4" },
136
+ ],
137
+ // Wave-19 R7 β€” a framed picture: the mount, a sun, and the hill line every photo glyph
138
+ // resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
139
+ image: [
140
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
141
+ { d: circle(6, 6.3, 1.1) },
142
+ { d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
143
+ ],
144
+ // ⭐ 2026-08-07 β€” TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as
145
+ // "this points at something else". Drawn as two rounded rectangles overlapping at the centre
146
+ // rather than as an arrow into a box: an arrow would mean navigation, and a link column is a
147
+ // relation that exists in both directions whether or not you follow it.
148
+ link: [
149
+ { d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" },
150
+ { d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" },
151
+ { d: "M5.6 8h4.8" },
152
+ ],
153
+ // ⭐ 2026-08-07 β€” THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values
154
+ // collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would
155
+ // claim SUM when the function is chosen per column) and not a funnel (that is filtering,
156
+ // which is what `limit` does β€” a different half of the same field).
157
+ rollup: [
158
+ { d: "M3.2 4.4h9.6" },
159
+ { d: "M4.8 8h6.4" },
160
+ { d: "M6.6 11.6h2.8" },
161
+ ],
162
+ // ⭐ WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β€” AI ENRICHMENT.
163
+ // The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
164
+ // on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
165
+ // second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
166
+ // (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
167
+ // head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
168
+ // ⚠ TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
169
+ // The small companion is what makes the mark say "sparkle".
170
+ ai_enrich: [
171
+ { d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
172
+ { d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
173
+ ],
174
+ };
175
+
176
+ /**
177
+ * Display mode β†’ icon geometry (I18). Also total: the wave-8 Dashboard mode
178
+ * cannot land in DISPLAY_MODES without the compiler demanding its icon here.
179
+ */
180
+ export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
181
+ grid: [
182
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
183
+ { d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
184
+ ],
185
+ list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
186
+ // I19c β€” a framed set of bars: "several charts", not "one chart".
187
+ chart: [
188
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
189
+ { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
190
+ ],
191
+ // I10 (C2) β€” the LEGACY key. Kept so this record stays total over DisplayMode, which is
192
+ // what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
193
+ // promise. Same drawing: a stored 'dashboard' IS a chart.
194
+ dashboard: [
195
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
196
+ { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
197
+ ],
198
+ calendar: CALENDAR,
199
+ kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
200
+ map: [
201
+ { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
202
+ { d: circle(8, 6.1, 1.3) },
203
+ ],
204
+ // 2026-08-02 item 7 β€” the time-series view. Drawn as a framed grid with a trend running
205
+ // through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
206
+ // a per-row line toggle. Deliberately not the plain `line` chart mark β€” a chart view and a
207
+ // time-series table must not be the same picture in the same rail.
208
+ timeseries: [
209
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
210
+ { d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
211
+ { d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
212
+ ],
213
+ // Wave-18 C6-CATALOG β€” an OPEN BOOK: two facing leaves with a spine between them, and a
214
+ // product block sitting on the left one. Every other mode in this table draws a way of
215
+ // arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
216
+ // with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
217
+ // not a framed grid (`grid`/`chart`/`timeseries` share the frame).
218
+ catalog: [
219
+ { d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
220
+ { d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
221
+ { d: "M3.9 6.4h2.3v2.8H3.9z" },
222
+ ],
223
+ // Wave-23 C9 β€” a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
224
+ // them. Every other mode here draws a way of ARRANGING records that already exist; this one
225
+ // has to read as a record being MADE, so it is the only mark whose bottom line is open.
226
+ // Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
227
+ // (an edit affordance means something else app-wide).
228
+ // ⭐ Wave-27 C3 (owner item 8) β€” a CARD WITH TWO ARROWS LEAVING IT, left and right. Every
229
+ // other mark here draws an arrangement of many records; this one has to read as ONE record
230
+ // with two exits, because that is exactly what the deck is. Deliberately not a stack of cards
231
+ // (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in
232
+ // this vocabulary draws a body part, and it would read as "drag" rather than "decide").
233
+ swipe: [
234
+ { d: "M5.4 3.4h5.2v9.2H5.4z" },
235
+ { d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" },
236
+ { d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" },
237
+ ],
238
+ form: [
239
+ { d: "M3.4 2.8h9.2v10.4H3.4z" },
240
+ { d: "M5.6 5.6h4.8" },
241
+ { d: "M5.6 8h4.8" },
242
+ { d: "M5.6 10.6h2.6" },
243
+ ],
244
+ // W36-T04 β€” angle brackets over a baseline: the universal mark for "this is code", and the one
245
+ // shape in this table that is about the AUTHORING rather than about the arrangement of rows.
246
+ script: [
247
+ { d: "M6 5.4 3.2 8l2.8 2.6M10 5.4 12.8 8 10 10.6" },
248
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
249
+ ],
250
+ };
251
+
252
+ /**
253
+ * Wave-9 I16 β€” one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
254
+ * vocabulary cannot ship without a drawing.
255
+ *
256
+ * ⚠ The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
257
+ * an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
258
+ * picker is NOT a native `<select>` β€” it is a radio-row list like the mode switcher, which
259
+ * is the only shape that can carry a real mark.
260
+ *
261
+ * The key type is a string union declared here rather than imported from chartData.ts: this
262
+ * module is a leaf (types + theme only) and chartData imports IT, not the reverse.
263
+ */
264
+ export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
265
+ export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
266
+ bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
267
+ line: [
268
+ { d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
269
+ { d: circle(5.8, 7.8, 0.9) },
270
+ { d: circle(8.4, 9.8, 0.9) },
271
+ ],
272
+ area: [
273
+ { d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
274
+ { d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
275
+ ],
276
+ donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
277
+ // A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
278
+ // reads as "one number" beside four marks that all read as "a distribution".
279
+ kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
280
+ // Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
281
+ // values, not a distribution), with a header band and a column rule.
282
+ table: [
283
+ { d: "M2.6 3.4h10.8v9.2H2.6z" },
284
+ { d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
285
+ ],
286
+ };
287
+
288
+ export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
289
+ bar: "Bar",
290
+ line: "Line",
291
+ area: "Area",
292
+ donut: "Donut",
293
+ kpi: "Single value",
294
+ table: "Table",
295
+ };
296
+
297
+ /** I16 β€” the pastel each chart kind wears, same family rule as MODE_TONE. */
298
+ export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
299
+ bar: "blue",
300
+ line: "green",
301
+ area: "green",
302
+ donut: "yellow",
303
+ kpi: "neutral",
304
+ table: "neutral",
305
+ };
306
+
307
+ /**
308
+ * Wave-9 contract C5 (I15) β€” folder icon geometry. TOTAL over `FolderShape`, so a shape key
309
+ * added to the wire contract in types.ts cannot ship without a drawing.
310
+ *
311
+ * Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
312
+ * the same rail and read as one family. `folder` is first because it is the default every
313
+ * pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
314
+ */
315
+ export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
316
+ folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
317
+ star: [
318
+ { d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
319
+ ],
320
+ flag: [
321
+ { d: "M4.2 13.4V2.9" },
322
+ { d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
323
+ ],
324
+ tag: [
325
+ { d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
326
+ { d: circle(5.6, 5.6, 1) },
327
+ ],
328
+ bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
329
+ // Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
330
+ // Reusing the geometry rather than drawing a second "chart" is the whole point of the
331
+ // one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
332
+ grid: MODE_SHAPES.grid,
333
+ chart: MODE_SHAPES.dashboard,
334
+ map: MODE_SHAPES.map,
335
+ users: TYPE_SHAPES.user,
336
+ clock: TYPE_SHAPES.created_time,
337
+ heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
338
+ bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
339
+ };
340
+
341
+ /**
342
+ * Tone key β†’ the pastel it FILLS with, and the -d weight it STROKES with.
343
+ *
344
+ * Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
345
+ * that a base pastel at that size smudges β€” LP_BLUE measures 1.88:1 on white. So the pastel
346
+ * is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
347
+ * outline (an outline that reads at all).
348
+ *
349
+ * The default tone is `neutral` β€” HOST's C5 key, not "grey". The whitelist is SHARED between
350
+ * the two ends, so the name matters more than the word: a tone the host does not recognise
351
+ * degrades to the default and the user's choice silently disappears on reload.
352
+ */
353
+ export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
354
+ neutral: { fill: LP_LINE, stroke: LP_MUTED },
355
+ blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
356
+ green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
357
+ yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
358
+ red: { fill: LP_RED, stroke: LP_RED_DEEP },
359
+ };
360
+
361
+ export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
362
+ neutral: "Neutral",
363
+ blue: "Blue",
364
+ green: "Green",
365
+ yellow: "Yellow",
366
+ red: "Red",
367
+ };
368
+
369
+ export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
370
+ folder: "Folder",
371
+ star: "Star",
372
+ flag: "Flag",
373
+ tag: "Tag",
374
+ bookmark: "Bookmark",
375
+ grid: "Table",
376
+ chart: "Chart",
377
+ map: "Map",
378
+ users: "People",
379
+ clock: "Clock",
380
+ heart: "Heart",
381
+ bolt: "Priority",
382
+ };
383
+
384
+ /**
385
+ * Wave-9 I14 β€” the tone each CREATABLE view type wears in the "+ Create new…" flyout.
386
+ *
387
+ * The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
388
+ * is a list you read rather than scan. Assigned by family, not by rotation: the two
389
+ * record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
390
+ * share yellow, chart is green because it is the analytical one, map is red because it is
391
+ * the geographic one. Folder is grey β€” it is not a view, and the flyout's last row should
392
+ * not compete with the six above it.
393
+ */
394
+ /**
395
+ * Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
396
+ * sits beside the geometry, the way TYPE_LABELS does β€” the mode switcher, the create flyout
397
+ * and the create prompt now read ONE table instead of three. C2's "Dashboard" β†’ "Chart"
398
+ * rename is a single line here as a direct result.
399
+ */
400
+ export const MODE_LABELS: Record<DisplayMode, string> = {
401
+ grid: "Grid",
402
+ chart: "Chart",
403
+ // Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
404
+ // read before normalisation must never render a blank switcher chip.
405
+ dashboard: "Chart",
406
+ list: "List",
407
+ calendar: "Calendar",
408
+ kanban: "Kanban",
409
+ map: "Map",
410
+ timeseries: "Time series",
411
+ catalog: "Catalog",
412
+ // Wave-23 C9 β€” the mode that COLLECTS records. "Form", the word the whole product uses for
413
+ // it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
414
+ // be the one surface calling it something else.
415
+ form: "Form",
416
+ // ⭐ Wave-27 C3 (item 8) β€” the owner's own word for it. Not "Triage" or "Review": the gesture
417
+ // IS the name here, and the two candidates both collide with vocabulary this product already
418
+ // spends elsewhere (a `review` automation decision, the retired review lanes).
419
+ swipe: "Swipe",
420
+ // W36-T04 β€” the owner's own words are "code script"; "Script" is the half that is not implied
421
+ // by the icon, and the product has no other surface competing for the noun.
422
+ //
423
+ // ⭐⭐ W37-T41 (owner ruling, 2026-08-19) β€” RENAMED TO "Custom", and the one-word form is the
424
+ // ruling rather than a shortening of it. Every wave-37 governing text calls this surface the
425
+ // "Custom View": PRD items 10 / R2, T41's own `done-when`, D-391, and `ScriptViewPanel`'s
426
+ // shipped copy ("Picking Custom View mints a view with an EMPTY source"). "Script" was the
427
+ // product calling one thing two names, which is the defect `swipe`'s label note warns about
428
+ // one table up.
429
+ //
430
+ // β›” IT IS NOT SPELLED "Custom View" HERE, and the reason is this table's job rather than a
431
+ // preference. Every value is a KIND NOUN that four call sites COMPOSE into a phrase:
432
+ // `ViewSidebar` renders `New ${MODE_LABELS[creating].toLowerCase()} view` (:1060), the same
433
+ // string as the flyout's aria-label (:1045), `${MODE_LABELS[mode]} view, locked` (:1619) and
434
+ // `It also stays a ${MODE_LABELS[mode].toLowerCase()}.` (:1626). A label already carrying the
435
+ // noun reads "New custom view view" at two of them. One word keeps the create prompt reading
436
+ // "New custom view", the locked aria-label reading "Custom view, locked", and the picker row
437
+ // reading "Custom" beside Grid, Chart and Map, which are kind nouns too.
438
+ script: "Custom",
439
+ };
440
+
441
+ /**
442
+ * I14 β€” the view types the "+ Create new…" flyout OFFERS, in the order it lists them.
443
+ *
444
+ * Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
445
+ * C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
446
+ * rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β€” one list cannot
447
+ * express both. Living in this pure data module means the gate can assert the offered set
448
+ * without importing a React component, and I10 becomes a one-line edit in one file.
449
+ */
450
+ // 2026-08-02 item 7 β€” `timeseries` was deliberately held OUT of this list until the host
451
+ // accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
452
+ // wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
453
+ // not know, so offering it early would let a user create a view that silently reverts to a
454
+ // grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
455
+ // `DISPLAY_MODES += timeseries`, so it is offerable now.
456
+ //
457
+ // wave17 GRID, owner item 10 β€” THE ORDER BELOW IS THE OWNER'S, stated verbatim:
458
+ // Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
459
+ //
460
+ // ⚠ It supersedes the two orderings this list has carried before it, and the reasoning that
461
+ // produced them is now WRONG rather than merely outranked, so it is not left here to be
462
+ // re-applied: `timeseries` was "listed last… it belongs beside Chart", and `list` sat second
463
+ // as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
464
+ // a product decision, so it is asserted in `verify_icons` rather than left to a comment β€”
465
+ // nothing else on screen would go red if a future edit re-sorted it "sensibly".
466
+ // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
467
+ // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
468
+ // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
469
+ // It lands LAST by contract: the owner's seven-mode order above is a product decision and the
470
+ // new mode joins the end of it rather than being sorted into it.
471
+ // ⭐ Wave-27 C3 (owner item 8) β€” `swipe` is HELD OUT of this list, and the reason corrects a
472
+ // mistake this file made an hour earlier.
473
+ //
474
+ // β›” THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a
475
+ // mode the HOST has not accepted", and one session owning both registries this wave genuinely
476
+ // does close that half β€” which is what made it tempting to skip. But the rule the hold really
477
+ // encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not
478
+ // exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch
479
+ // for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered
480
+ // a grid under a chip reading "View Β· Swipe" β€” surviving reload, with the agreement leg green
481
+ // because it only ever compared two lists.
482
+ //
483
+ // So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox
484
+ // handshake: `verify_icons` DERIVES the condition β€” either `swipe` is absent here, or
485
+ // `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other
486
+ // direction, sitting unoffered because its host mirror never landed (DEBT D-90).
487
+ //
488
+ // β›” THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE.
489
+ // Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted
490
+ // `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded
491
+ // "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β€” a negative control FOR the
492
+ // swipe carry β€” and the owner could not find the view because **nothing imported the file.**
493
+ // The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies
494
+ // it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]).
495
+ // A hold that is safe to leave in place is a hold nothing forces you to lift.
496
+ // The audit query that found it, after the battery did not: for each artifact a wave adds, grep
497
+ // for its CONSUMER β€” who imports/mounts/registers it β€” excluding the file itself, `_test/`, css
498
+ // and comments. `SwipeView` had four hits and three were prose.
499
+ // Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
500
+ // It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
501
+ // mode joins the end of it rather than being sorted into it.
502
+ export const CREATABLE_MODES: DisplayMode[] = [
503
+ "grid",
504
+ "chart",
505
+ "calendar",
506
+ "kanban",
507
+ "timeseries",
508
+ "map",
509
+ "list",
510
+ "catalog",
511
+ "swipe",
512
+ // Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so
513
+ // `form` is offerable and its `HELD_MODES` entry came out in the same edit β€” the hold's own text
514
+ // named this mount as its release condition. ⚠ Found by `verify_icons.py::mode_parity` law E
515
+ // during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built
516
+ // the Interface group, and between them the DOOR was never opened β€” the wave's biggest new
517
+ // surface was unreachable behind two green tickets.
518
+ "form",
519
+ // ⭐⭐ W37-T41 (owner item 10 / R2) β€” `script` JOINS THE OFFER, and this is the release of the
520
+ // longest-held entry in this file. The hold's own release condition, written into
521
+ // `icons.test.ts::HELD_MODES`, named THREE things that had to land in one change: the name
522
+ // joins this list, `CustomerGrid.tsx` gains a `displayMode === "script"` branch, and the
523
+ // HELD_MODES entry comes out. All three are in this change.
524
+ //
525
+ // β›” AND A FOURTH THAT THE HOLD DID NOT NAME, which is the one that decides whether the
526
+ // feature works: `CustomerGrid.tsx::createView` had to learn that picking this mode mints a
527
+ // SCRIPT VIEW through `createScriptView` rather than persisting a workspace view. A script
528
+ // view is not a workspace view (it is its source and its version history, in E's own
529
+ // per-database store), so the three hops above alone would have offered the mode, written
530
+ // `config.display.mode = "script"` into a stored view, left `activeScriptId` null and drawn
531
+ // nothing, with `mode_parity` GREEN. That is wave 27's `swipe` defect exactly, reached
532
+ // through a different door.
533
+ // ⚠ OFFERED ONLY WHERE IT CAN EXIST. `ViewSidebar` takes `scriptable` and drops this row
534
+ // when it is false: script views are listed and resolved only on `ut_*` databases
535
+ // (`CustomerGrid`'s `scriptRows` effect gates on `hostWorkspace && isUserTable`), so offering
536
+ // it on a pool scope would mint a view the rail can never show
537
+ // ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
538
+ "script",
539
+ ];
540
+
541
+ /**
542
+ * ⭐ WAVE-29 R6 (owner item 10) β€” the kind dropdown splits under TWO headers, and the strings are
543
+ * the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before),
544
+ * not "Custom interface".
545
+ *
546
+ * The line the two groups draw: a **View** ARRANGES the records β€” the same rows, re-shaped (a
547
+ * grid, a board, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a picture of the
548
+ * world with records placed on it, a catalog is a published artifact, a form is a door records come
549
+ * IN through and shows no records at all.
550
+ *
551
+ * ⭐ WAVE-33 item 9 AMENDED THAT LINE, and the owner drew it one notch differently than R6 did:
552
+ * `swipe` and `timeseries` moved from View to Interface. The reading that makes both rulings one
553
+ * rule β€” and the one the next mode should be grouped by β€” is **what the surface is FOR**, not how
554
+ * many rows it shows. A deck triages records one at a time and WRITES to them; a trend answers a
555
+ * question about a measure over time; neither hands you the row set re-shaped, which is what every
556
+ * remaining View does. ⚠ Do NOT re-derive the split from `MODE_TONE` below: the tones still say
557
+ * `catalog`/`form` are neutral because they arrange nothing, and `swipe`/`timeseries` are toned,
558
+ * so tone and group are no longer the same cut. Group membership lives HERE and only here.
559
+ *
560
+ * ⚠ TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it
561
+ * vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this
562
+ * map, and a mode whose group label matched nothing would silently stop being offered while
563
+ * every existing check (paintable, labelled, toned, unique, ordered) stayed green.
564
+ * `dashboard` is grouped like the `chart` it is the legacy spelling of β€” it is never offered, and
565
+ * a partial map is a worse answer than an unused entry.
566
+ */
567
+ export const MODE_GROUP_LABELS = ["View", "Interface"] as const;
568
+ export type ModeGroup = (typeof MODE_GROUP_LABELS)[number];
569
+
570
+ export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
571
+ grid: "View",
572
+ list: "View",
573
+ kanban: "View",
574
+ calendar: "View",
575
+ chart: "View",
576
+ dashboard: "View",
577
+ // ⭐ WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
578
+ // of under View, when a user toggle it." See the amended taxonomy note above β€” a deck and a
579
+ // trend are both surfaces a person WORKS IN, not re-shapings of the row set.
580
+ timeseries: "Interface",
581
+ swipe: "Interface",
582
+ map: "Interface",
583
+ catalog: "Interface",
584
+ form: "Interface",
585
+ // W36-T04 β€” "Interface", by the W33 item-9 taxonomy: a script view is a surface somebody WORKS
586
+ // IN (writes, runs, reads an answer), not a re-shaping of the row set.
587
+ script: "Interface",
588
+ };
589
+
590
+ /**
591
+ * The offered modes, split into R6's two groups β€” DERIVED, never a third hand-written list.
592
+ *
593
+ * ⚠ ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which
594
+ * is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and
595
+ * is separately asserted. The two rulings are compatible read this way and only this way: R6 moved
596
+ * `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but
597
+ * every pair it ordered is still in that relative order here.
598
+ */
599
+ export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] =
600
+ MODE_GROUP_LABELS.map((label) => ({
601
+ label,
602
+ modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label),
603
+ }));
604
+
605
+ export const MODE_TONE: Record<DisplayMode, FolderTone> = {
606
+ grid: "blue",
607
+ list: "blue",
608
+ chart: "green",
609
+ dashboard: "green",
610
+ calendar: "yellow",
611
+ kanban: "yellow",
612
+ map: "red",
613
+ // Green with `chart`: it is the other analytical mode, and the two belong to one family.
614
+ timeseries: "green",
615
+ // Wave-18 C6-CATALOG β€” NEUTRAL, and it is the honest pick rather than the leftover one. The
616
+ // four colour tones each name a family of ways to arrange records (blue = tabular, green =
617
+ // analytical, yellow = board/date, red = spatial); a catalog arranges nothing β€” it is a
618
+ // published artifact. Giving it a colour would file it under a family it is not in.
619
+ catalog: "neutral",
620
+ // Wave-23 C9 β€” NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
621
+ // name families of ways to ARRANGE records (blue tabular, green analytical, yellow
622
+ // board/date, red spatial). A form arranges nothing β€” it is a door records come in through β€”
623
+ // so giving it a colour would file it under a family it is not in.
624
+ form: "neutral",
625
+ // ⭐ Wave-27 C3 β€” YELLOW, with `kanban`, and this is a family claim rather than a leftover:
626
+ // a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the
627
+ // two are one family seen at two zooms β€” all the lanes at once, or one card at a time. Filing
628
+ // it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those
629
+ // two are neutral: this mode does arrange records, and it arranges them by the board's field.
630
+ swipe: "yellow",
631
+ // W36-T04 β€” NEUTRAL, by `catalog`'s and `form`'s rule rather than by elimination: the four
632
+ // tones name families of ways to ARRANGE records (blue tabular, green analytical, yellow
633
+ // board/date, red spatial). A script arranges nothing; it emits whatever it computes.
634
+ script: "neutral",
635
+ };
636
+
637
+ /**
638
+ * Human labels for every field type. Lives here beside the icons so the two
639
+ * halves of "how a field type presents itself" stay in one file (ColumnMenu
640
+ * imports it rather than keeping a second copy).
641
+ */
642
+ export const TYPE_LABELS: Record<FieldType, string> = {
643
+ // ⭐ WAVE-29 item 3 β€” the owner's own words: "Change 'Single line text' to 'Text', keep it
644
+ // simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus
645
+ // its long-text sibling); this product has no multi-line text kind, so the qualifier
646
+ // distinguished the type from nothing and only made the commonest row in the menu the longest.
647
+ // βœ… The STORED key is `"text"` and always was β€” the old string was never persisted anywhere,
648
+ // client or server, so this is a label change with no migration behind it.
649
+ text: "Text",
650
+ select: "Single select",
651
+ multiselect: "Multi select",
652
+ user: "Assignee",
653
+ int: "Number",
654
+ currency: "Currency",
655
+ pct: "Percent",
656
+ date: "Date",
657
+ checkbox: "Checkbox",
658
+ phone: "Phone number",
659
+ email: "Email",
660
+ url: "URL",
661
+ rating: "Rating",
662
+ created_time: "Created time",
663
+ formula: "Formula",
664
+ // Wave-18 C5-AUTOFIELD (D's spec, applied by C).
665
+ automation: "Automation",
666
+ // Wave-22 C7 β€” spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
667
+ // on headers and the field gear, not the create menu.
668
+ metric: "Metric",
669
+ // Wave-19 R7 β€” the picture column.
670
+ image: "Image",
671
+ // Wave-23 C7 β€” the structured-document column. "JSON" rather than "Structured data": it is
672
+ // the word on the wire, in the viewer's raw tab and in every error the server can return, and
673
+ // a friendlier synonym would be the only place in the product using a different one.
674
+ json: "JSON",
675
+ // ⭐ 2026-08-07 β€” Airtable's own wording, deliberately. "Link to another record" is what a
676
+ // person migrating from Airtable searches this menu for, and inventing a synonym ("Relation",
677
+ // "Reference") would make the feature they came for look absent.
678
+ link: "Link to another record",
679
+ rollup: "Rollup",
680
+ // ⭐ Wave-27 item 13 (R13) β€” "Code", not "Snippet" or "Source": it is the word the field kind
681
+ // is called everywhere else in this wave (the ruling, the language picker, the viewer header),
682
+ // and it says what the column holds without implying the product will run it.
683
+ code: "Code",
684
+ // ⭐ WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β€” the owner's own noun for the kind: "a field kind
685
+ // called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
686
+ // "Generate" (that names the verb, and the column's value is the point, not the act).
687
+ ai_enrich: "AI enrichment",
688
+ status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
689
+ };
690
+
691
+ /**
692
+ * ⭐ WAVE-29 C7 (item 17) β€” THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is
693
+ * what the totals row and the per-group subtotals compute. Server twin:
694
+ * `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and
695
+ * compares them name-for-name in order β€” the cross-language boundary is the one a type cannot
696
+ * police, so it gets a gate.
697
+ *
698
+ * β›” ONE CLIENT LIST, IMPORTED β€” never re-declared. `aggregations.ts` and the field editor import
699
+ * from here rather than keeping their own copy, which is why this lives in the pure data module
700
+ * beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and
701
+ * the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a
702
+ * mirror that is an import cannot fall out of step at all.
703
+ *
704
+ * β›” NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg`
705
+ * and gatekeeps a STORED value β€” renaming it would silently turn saved charts into sums. This
706
+ * list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column
707
+ * summary and a rollup fold say the same word for the same operation.
708
+ *
709
+ * ⚠ `median` is net-new β€” in neither `CHART_AGGS` nor `ROLLUP_FNS`.
710
+ * ⚠ `count` counts ROWS in the scope, not non-blank cells.
711
+ * ⚠ Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator
712
+ * for that is ALREADY `isNumericFieldType` (types.ts) β€” do not write a second one. `count` is
713
+ * legal on any type.
714
+ */
715
+ // ⭐ W29-T74 β€” an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed
716
+ // `AggName`, so a second literal here would be a type that has to be kept in step by eye with a
717
+ // type the compiler already owns β€” the same defect as the array below, one level up.
718
+ export type FieldAgg = AggName;
719
+
720
+ /** ORDERED β€” the order is the picker's order, on both engines. */
721
+ export const FIELD_AGGS: readonly FieldAgg[] = [
722
+ "sum",
723
+ "average",
724
+ "median",
725
+ "min",
726
+ "max",
727
+ "count",
728
+ ];
729
+
730
+ /**
731
+ * Human labels, in the summary bar's own compact register (Airtable's wording).
732
+ *
733
+ * ⚠ `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already
734
+ * has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the
735
+ * same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables
736
+ * called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one.
737
+ */
738
+ export const FIELD_AGG_LABELS: Record<FieldAgg, string> = {
739
+ sum: "Sum",
740
+ average: "Average",
741
+ median: "Median",
742
+ min: "Min",
743
+ max: "Max",
744
+ count: "Count",
745
+ };
746
+
747
+
748
+ // ------------------------------------------------------------ glide sprites
749
+
750
+ /** Serialize one shape to SVG source in an explicit colour (canvas sprites get
751
+ * no `currentColor` β€” glide hands the painter the theme colours directly). */
752
+ function shapeSource(s: IconShape, color: string): string {
753
+ return s.fill
754
+ ? `<path d="${s.d}" fill="${color}"/>`
755
+ : `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
756
+ `stroke-linecap="round" stroke-linejoin="round"/>`;
757
+ }
758
+
759
+ function sprite(shapes: IconShape[]) {
760
+ return ({ fgColor }: { fgColor: string }) =>
761
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
762
+ shapes.map((s) => shapeSource(s, fgColor)).join("") +
763
+ `</svg>`;
764
+ }
765
+
766
+ /** Glide header-icon NAME for a field type β€” the `icon` a GridColumn asks for. */
767
+ export function typeIconName(type: FieldType): string {
768
+ return `t_${type}`;
769
+ }
770
+
771
+ /**
772
+ * The sprite map handed to <DataEditor headerIcons>. One entry per field type
773
+ * (I20 draws the type mark in every column header), built from the same shapes
774
+ * the React icons use.
775
+ *
776
+ * Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
777
+ * why theme.ts must set it β€” the library default is #FFFFFF, i.e. invisible on
778
+ * our header (that was I21's actual bug, not a too-pale hex of ours).
779
+ */
780
+ export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
781
+ Object.fromEntries(
782
+ (Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
783
+ );
784
+
785
+ /**
786
+ * The header sprite map handed to <DataEditor headerIcons>. Two families:
787
+ *
788
+ * t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
789
+ * from the same shapes the React icons use. Painted by glide in
790
+ * `theme.fgIconHeader`.
791
+ * aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
792
+ * OUTLINE ONLY: a dark-grey ring with a transparent interior, per
793
+ * the owner. It still deliberately IGNORES the colours glide hands
794
+ * it, for the reason wave-8 recorded - glide's "special" variant is
795
+ * accentColor behind bgHeader, which under the C1 pastels is a pale
796
+ * glyph on a pale disc, i.e. I21 in a new costume.
797
+ * ⚠ It is NO LONGER a column `overlayIcon`. Glide draws an overlay
798
+ * at a hard-coded offset from the TYPE mark on the far LEFT of the
799
+ * header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
800
+ * aligned. It is now painted by CustomerGrid's `drawHeader`
801
+ * callback at `infoMarkRect()` - see overlayPlacement.ts.
802
+ */
803
+ export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
804
+ ...TYPE_SPRITES,
805
+ aiosInfo: () =>
806
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
807
+ `<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
808
+ `<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
809
+ `stroke-linecap="round"/>` +
810
+ `<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
811
+ `</svg>`,
812
+ };
web/src/customer-grid/types.ts CHANGED
The diff for this file is too large to render. See raw diff