Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- platform/aios_grid_fields.json +28 -2
- platform/modules/product_data.py +166 -36
- platform/modules/products.py +103 -7
- web/src/customer-grid/CustomerGrid.tsx +109 -8
- web/src/customer-grid/ScriptViewPanel.tsx +9 -2
- web/src/customer-grid/ViewSidebar.tsx +17 -1
- web/src/customer-grid/iconShapes.ts +812 -776
- web/src/customer-grid/types.ts +0 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "9a6a561",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v29",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 514 |
-
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 562 |
-
if
|
| 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
|
| 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 |
-
|
| 764 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
'(
|
| 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
|
| 803 |
-
# the
|
| 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 |
-
#
|
| 808 |
-
#
|
| 809 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 810 |
view_rows = {r["code"] for r in rows
|
| 811 |
-
if
|
| 812 |
-
and isinstance(r.get("
|
| 813 |
-
and r["
|
| 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 (
|
| 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 |
-
|
| 826 |
-
|
| 827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 209 |
-
# active products share a code, so this branch is a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2706 |
-
|
| 2707 |
-
|
| 2708 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
{
|
|
|
|
|
|
|
| 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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
/
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
//
|
| 435 |
-
//
|
| 436 |
-
//
|
| 437 |
-
//
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
/
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
//
|
| 451 |
-
// accepted the name,
|
| 452 |
-
//
|
| 453 |
-
//
|
| 454 |
-
//
|
| 455 |
-
//
|
| 456 |
-
//
|
| 457 |
-
//
|
| 458 |
-
//
|
| 459 |
-
//
|
| 460 |
-
//
|
| 461 |
-
//
|
| 462 |
-
//
|
| 463 |
-
//
|
| 464 |
-
// a
|
| 465 |
-
//
|
| 466 |
-
//
|
| 467 |
-
//
|
| 468 |
-
//
|
| 469 |
-
//
|
| 470 |
-
//
|
| 471 |
-
//
|
| 472 |
-
//
|
| 473 |
-
//
|
| 474 |
-
//
|
| 475 |
-
//
|
| 476 |
-
//
|
| 477 |
-
//
|
| 478 |
-
//
|
| 479 |
-
//
|
| 480 |
-
//
|
| 481 |
-
//
|
| 482 |
-
//
|
| 483 |
-
//
|
| 484 |
-
//
|
| 485 |
-
//
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
*
|
| 556 |
-
*
|
| 557 |
-
*
|
| 558 |
-
*
|
| 559 |
-
*
|
| 560 |
-
*
|
| 561 |
-
*
|
| 562 |
-
*
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
calendar: "
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
//
|
| 578 |
-
|
| 579 |
-
//
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
catalog: "
|
| 584 |
-
|
| 585 |
-
//
|
| 586 |
-
//
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
//
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
//
|
| 632 |
-
|
| 633 |
-
//
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
//
|
| 645 |
-
//
|
| 646 |
-
//
|
| 647 |
-
|
| 648 |
-
//
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
//
|
| 681 |
-
//
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
/
|
| 685 |
-
|
| 686 |
-
"
|
| 687 |
-
"
|
| 688 |
-
"
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
*
|
| 696 |
-
*
|
| 697 |
-
*
|
| 698 |
-
*
|
| 699 |
-
*
|
| 700 |
-
*
|
| 701 |
-
*
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
/**
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
*
|
| 737 |
-
*
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
*
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
*/
|
| 767 |
-
export
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 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
|
|
|