fsanyoto commited on
Commit
c36eba4
Β·
verified Β·
1 Parent(s): c9d432b

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
api/routes_customers.py CHANGED
@@ -93,60 +93,99 @@ def warm_default(rt):
93
  _pool_for(rt, None, None)
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  def _payload(session: Session):
97
- """`{fields, rows, today, pulled_at}` β€” X2 fixes this at the EXACT shape the pre-wave
98
- `/api/customers` served, which `verify_fields_contract.py` referees.
 
99
 
100
- The per-USER half (this user's field list, this user's overlay cells) is assembled fresh on
101
- every request from one workspace read. That read is a single store `get`, which is cheap next
102
- to the pool build β€” and it is the only arrangement in which nothing user-shaped can outlive
103
- the request that asked for it. See `_pool_rows` for what the cache holds and why.
104
  """
105
  import aios_grid
106
- from core import grid_events
107
 
108
- rows_src = _pool_rows(session)
109
-
110
- # ONE workspace read serves both the field list and the overlay values (doing it per stratum
111
- # would triple the store reads per request).
112
- ws = grid_events.table_workspace(_ctx_for(session, []), allowed_pids=None)
113
- fields = aios_grid.fields_from_workspace(ws)
114
- odoo_keys = [f["key"] for f in fields if f.get("source") == "odoo"]
115
- overlay_keys = [f["key"] for f in fields if f.get("source") == "overlay"]
116
- passthrough = {f["key"] for f in fields
117
- if f.get("source") == "odoo" and f.get("type") in ("text", "status", "date")}
118
- overlays = ws.get("overlays") or {}
119
-
120
- # ⚠ `rows_src` IS THE SHARED CACHED LIST β€” read it, never mutate it. Every row below is a NEW
121
- # dict; writing an overlay value onto `r` instead of `row` would put one user's private cell
122
- # into the copy the next same-scope user is served, which is the leak this split exists to
123
- # prevent, re-introduced one line lower down.
124
- rows = []
125
- for r in rows_src:
126
- pid = r.get("pid")
127
- row = {"pid": pid, "_created": r.get("_created") or ""}
128
- for k in odoo_keys:
129
- v = r.get(k)
130
- row[k] = v if k in passthrough else _round(v)
131
- stored = overlays.get(str(pid)) or {}
132
- for k in overlay_keys:
133
- row[k] = stored.get(k, "")
134
- rows.append(row)
135
-
136
- return {"fields": fields, "rows": rows,
137
  # `today` rides the payload because every relative date condition must resolve against
138
  # the TENANT's day, never the browser's β€” a client that falls back to its own clock
139
  # disagrees with the server for everyone west of it.
140
- "today": time.strftime("%Y-%m-%d"),
141
  "pulled_at": time.strftime("%Y-%m-%d %H:%M")}
142
 
143
 
144
- def _round(v):
145
- # bool is a subclass of int β€” guard it so True/False never becomes 1/0. Mirrors
146
- # royalimports_os/aios_grid.py._round exactly (embed == standalone on boolean-valued fields).
147
- return round(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else v
148
-
149
-
150
  def _ctx_for(session: Session, pids):
151
  """An EventCtx for the READ path β€” no fallback workspace, so a store outage is a 503 rather
152
  than a phantom in-memory workspace an API request cannot persist."""
 
93
  _pool_for(rt, None, None)
94
 
95
 
96
+ def _team_agent(session: Session):
97
+ return perms.scope_team_id(session.user), perms.scope_agent(session.user)
98
+
99
+
100
+ def _pool_stamp(rt, team_id, agent):
101
+ """The cached pool's build timestamp β€” the DATA STAMP in every measure-memo key, so a pool
102
+ refresh invalidates the memoised answers exactly when the underlying rows changed."""
103
+ entry = rt.pool_cache.get(("pool", team_id, agent))
104
+ return entry[0] if isinstance(entry, tuple) and entry else 0
105
+
106
+
107
+ def _measure_err(tag, e):
108
+ try:
109
+ import harness.telemetry as _tel
110
+ _tel.error(f"api:{tag}", e)
111
+ except Exception:
112
+ pass
113
+
114
+
115
+ def grid_assembly(session: Session, scope: str = "customer", storage_key: str = ""):
116
+ """ONE assembly of this session's grid state, shared by `/customers`, `/workspace` and the
117
+ events route (2026-07-31 β€” the standalone measure gap, owner item 1).
118
+
119
+ What it adds over the pre-wave hand-rolled `_payload` loop, and why it replaced it:
120
+
121
+ * rows go through `aios_grid.rows_from_pool` β€” the SAME builder the embedded host uses,
122
+ so `lat`/`lon` (the Map view's data), `_created` and every derived column ride each row
123
+ by construction instead of by a second loop that drifts. The hand loop was written to
124
+ mirror the pre-Map contract and silently dropped the coordinates: the shell's Map view
125
+ had nothing to plot ("the Map no longer works").
126
+ * `derived` carries the cohort column's cells AND the measure columns' values, resolved
127
+ through `core.measure_resolve` (EXIT-5's extraction of the `_cl_measure_*` family).
128
+ Without them every measure column the owner built rendered BLANK in the shell.
129
+ * `measures` (the offer) and `measure_sets` (condition answers) are computed here so the
130
+ events route can finally validate measure fields/conditions instead of refusing them
131
+ (an empty `measure_offer` made `clean_measure_field` reject every create over HTTP).
132
+
133
+ Memos live on the TENANT RUNTIME (`rt.measure_memo` / `rt.mset_memo`) β€” bounded by the
134
+ module's own clear-past-cap rule, keyed on (stamp, scope, pool identity, question), nothing
135
+ user-shaped in them.
136
+ """
137
+ import aios_grid
138
+ from core import grid_events, measure_resolve
139
+
140
+ rt = session.runtime
141
+ team_id, agent = _team_agent(session)
142
+ rows_src = _pool_for(rt, team_id, agent)
143
+ pids = frozenset(r["pid"] for r in rows_src if r.get("pid") is not None)
144
+ ws = grid_events.table_workspace(_ctx_for(session, pids), allowed_pids=pids)
145
+ workspace, fields, views, lists = aios_grid.workspace_wire(
146
+ ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key)
147
+
148
+ today = time.strftime("%Y-%m-%d")
149
+ stamp = _pool_stamp(rt, team_id, agent)
150
+ measures = measure_resolve.offer(team_id, on_error=_measure_err)
151
+ measure_sets = measure_resolve.condition_sets(
152
+ [v.get("config") or {} for v in (views or [])], None, team_id, pids, today, stamp,
153
+ rt.mset_memo, on_error=_measure_err)
154
+ # The derived channel: cohort membership cells + measure column values, ONE dict β€” the
155
+ # same read-only channel the embed host hands to rows_from_pool.
156
+ derived = aios_grid.cohort_cells(lists)
157
+ for pid, cells in measure_resolve.column_values(
158
+ fields, team_id, pids, today, stamp, rt.measure_memo,
159
+ on_error=_measure_err).items():
160
+ derived.setdefault(pid, {}).update(cells)
161
+
162
+ return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
163
+ "fields": fields, "views": views, "lists": lists, "derived": derived,
164
+ "measures": measures, "measure_sets": measure_sets, "today": today,
165
+ "team_id": team_id}
166
+
167
+
168
  def _payload(session: Session):
169
+ """`{fields, rows, today, pulled_at}` β€” X2's shape, which `verify_fields_contract.py`
170
+ referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
171
+ construction); see `grid_assembly` for what that fixed.
172
 
173
+ ⚠ `rows_src` is the SHARED cached list β€” `rows_from_pool` reads it and builds NEW dicts,
174
+ never mutating a cached row (the same-scope-second-user leak rule).
 
 
175
  """
176
  import aios_grid
 
177
 
178
+ g = grid_assembly(session)
179
+ rows = aios_grid.rows_from_pool(
180
+ g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
181
+ return {"fields": g["fields"], "rows": rows,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  # `today` rides the payload because every relative date condition must resolve against
183
  # the TENANT's day, never the browser's β€” a client that falls back to its own clock
184
  # disagrees with the server for everyone west of it.
185
+ "today": g["today"],
186
  "pulled_at": time.strftime("%Y-%m-%d %H:%M")}
187
 
188
 
 
 
 
 
 
 
189
  def _ctx_for(session: Session, pids):
190
  """An EventCtx for the READ path β€” no fallback workspace, so a store outage is a 503 rather
191
  than a phantom in-memory workspace an API request cannot persist."""
api/routes_grid.py CHANGED
@@ -76,23 +76,10 @@ def workspace(scope: str = "customer",
76
  wave-9 leak rule. Omitting it would hand a Fisch-scoped user a member list built by a
77
  full-access user.
78
  """
79
- import aios_grid
80
  from core import grid_events
81
- from routes_customers import allowed_pids
82
 
83
  scope = _scope_or_400(scope)
84
- try:
85
- pids = allowed_pids(session)
86
- ws = grid_events.table_workspace(_ctx(session, [], pids), allowed_pids=pids)
87
- except grid_events.StoreUnavailable:
88
- raise err(503, "store_unavailable", "the tenant store is unavailable")
89
-
90
- # β›” THE WIRE SHAPE IS THE SHARED PROJECTION (`aios_grid.workspace_wire`), not the store
91
- # shape. The first version returned the store dict with no `storageKey` β€” and the client
92
- # validator requires one, so the standalone shell DISCARDED the whole workspace: saved views
93
- # never rendered, `cohortMode` never arrived, and the Cohort route silently drew the Customer
94
- # surface. One projection for both servers is the fix that cannot drift.
95
- #
96
  # The storage key mirrors the host's own convention byte-for-byte (app.py's `_table_grid`
97
  # call sites) with the session's TENANT in the host's hardcoded slot, so localStorage state
98
  # carries across embed ⇄ standalone on the same browser.
@@ -104,13 +91,40 @@ def workspace(scope: str = "customer",
104
  else:
105
  storage_key = f"{session.tenant}:customer-list:{session.uname}:{bu}:{agent or 'all'}"
106
 
107
- workspace, _fields, _views, _lists = aios_grid.workspace_wire(
108
- ws, session.uname, pids, defs={}, scope_key=scope, storage_key=storage_key)
 
 
 
 
 
 
 
 
109
  # ADDITIVE to the wire: the caller's own overlay cells. The grid reads overlays from the
110
  # /customers rows, but this route is the cheap read-back a write can be verified against
111
  # (verify_api's overlay probe) without paying the pool call. Per-user by construction β€”
112
  # `table_workspace` is this session's workspace.
113
- workspace["overlays"] = ws.get("overlays") or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  # THE SURFACE STAMP β€” a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
116
  # ⚠ Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
@@ -128,7 +142,7 @@ def grid_events_route(body: dict = Body(default=None),
128
  session: Session = Depends(module_gate(MODULE))):
129
  """`{events: [<component event objects, verbatim>]}` β†’ `{results, doc?, toast?}`."""
130
  from core import grid_events
131
- from routes_customers import _payload, allowed_pids
132
 
133
  events = (body or {}).get("events")
134
  if events is None and isinstance(body, dict) and body.get("type"):
@@ -139,14 +153,23 @@ def grid_events_route(body: dict = Body(default=None),
139
  raise err(400, "too_many_events",
140
  f"at most {_MAX_EVENTS} events per request (the client's resend window)")
141
 
142
- payload = _payload(session)
143
- pids = frozenset(r["pid"] for r in payload["rows"] if r.get("pid") is not None)
144
  # Validated with the SAME predicate as the read route. An unrecognised scopeKey used to be
145
  # passed through verbatim, and `core.grid_events` only ever compares it to 'cohort' β€” so a
146
  # typo degraded silently to customer-scope behaviour on a WRITE. Read and write must agree on
147
  # what a scope is, or the surface you read is not the surface you wrote.
148
- ctx = _ctx(session, payload["fields"], pids,
149
- scope_key=_scope_or_400((body or {}).get("scopeKey")))
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  # Per-event results so the client can tell which of a batch landed β€” the component's own
152
  # bridge has no response channel at all, so this is strictly more than the embed gets.
 
76
  wave-9 leak rule. Omitting it would hand a Fisch-scoped user a member list built by a
77
  full-access user.
78
  """
 
79
  from core import grid_events
80
+ from routes_customers import grid_assembly
81
 
82
  scope = _scope_or_400(scope)
 
 
 
 
 
 
 
 
 
 
 
 
83
  # The storage key mirrors the host's own convention byte-for-byte (app.py's `_table_grid`
84
  # call sites) with the session's TENANT in the host's hardcoded slot, so localStorage state
85
  # carries across embed ⇄ standalone on the same browser.
 
91
  else:
92
  storage_key = f"{session.tenant}:customer-list:{session.uname}:{bu}:{agent or 'all'}"
93
 
94
+ # β›” THE WIRE SHAPE IS THE SHARED PROJECTION (`aios_grid.workspace_wire`), not the store
95
+ # shape. The first version returned the store dict with no `storageKey` β€” and the client
96
+ # validator requires one, so the standalone shell DISCARDED the whole workspace: saved views
97
+ # never rendered, `cohortMode` never arrived, and the Cohort route silently drew the Customer
98
+ # surface. One projection for both servers is the fix that cannot drift.
99
+ try:
100
+ g = grid_assembly(session, scope=scope, storage_key=storage_key)
101
+ except grid_events.StoreUnavailable:
102
+ raise err(503, "store_unavailable", "the tenant store is unavailable")
103
+ workspace = g["workspace"]
104
  # ADDITIVE to the wire: the caller's own overlay cells. The grid reads overlays from the
105
  # /customers rows, but this route is the cheap read-back a write can be verified against
106
  # (verify_api's overlay probe) without paying the pool call. Per-user by construction β€”
107
  # `table_workspace` is this session's workspace.
108
+ workspace["overlays"] = g["ws"].get("overlays") or {}
109
+ # ── the STANDALONE measure channel (owner item 1, 2026-07-31) ────────────────────────────
110
+ # The embed receives these as top-level render args; standalone lifts them off THIS route
111
+ # (useCustomerData merges them into the payload slots the grid already reads). They ride
112
+ # the workspace rather than /customers because a durable write re-reads exactly this route
113
+ # (WORKSPACE_STALE), so a new measure column populates without refetching the heavy pool.
114
+ workspace["measures"] = g["measures"]
115
+ workspace["measureSets"] = g["measure_sets"]
116
+ # Derived cells (cohort column + measure columns), keyed by pid. JSON object keys are
117
+ # strings; the client indexes with String(pid).
118
+ workspace["derived"] = {str(pid): cells for pid, cells in g["derived"].items()}
119
+ # Who is looking (permissions verdicts) + the assignee choices for `user`-typed columns β€”
120
+ # the two other host-only render args the shell was missing (fail-closed without them:
121
+ # restricted fields uneditable, assignee picker empty).
122
+ workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
123
+ try:
124
+ from core import users as _users
125
+ workspace["userOptions"] = _users.assignable_people()
126
+ except Exception:
127
+ workspace["userOptions"] = []
128
 
129
  # THE SURFACE STAMP β€” a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
130
  # ⚠ Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
 
142
  session: Session = Depends(module_gate(MODULE))):
143
  """`{events: [<component event objects, verbatim>]}` β†’ `{results, doc?, toast?}`."""
144
  from core import grid_events
145
+ from routes_customers import grid_assembly
146
 
147
  events = (body or {}).get("events")
148
  if events is None and isinstance(body, dict) and body.get("type"):
 
153
  raise err(400, "too_many_events",
154
  f"at most {_MAX_EVENTS} events per request (the client's resend window)")
155
 
 
 
156
  # Validated with the SAME predicate as the read route. An unrecognised scopeKey used to be
157
  # passed through verbatim, and `core.grid_events` only ever compares it to 'cohort' β€” so a
158
  # typo degraded silently to customer-scope behaviour on a WRITE. Read and write must agree on
159
  # what a scope is, or the surface you read is not the surface you wrote.
160
+ scope = _scope_or_400((body or {}).get("scopeKey"))
161
+ g = grid_assembly(session, scope=scope)
162
+ # ⚠ THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
163
+ # `clean_measure_field` had an empty admission list and every measure-column create over
164
+ # HTTP was silently refused; without `measure_keys`, `clean_filter_tree` stripped every
165
+ # measure CONDITION out of a saved view. The embed always passed these; the API adapter
166
+ # simply had not been given them β€” the standalone shell could read measures it could
167
+ # never write.
168
+ ctx = _ctx(session, g["fields"], g["pids"], scope_key=scope,
169
+ measure_keys=frozenset(m["key"] for m in g["measures"]),
170
+ resolved_ids=frozenset(g["measure_sets"]),
171
+ cohort_ids=frozenset(c["id"] for c in g["lists"]),
172
+ measure_offer=tuple(g["measures"]))
173
 
174
  # Per-event results so the client can tell which of a batch landed β€” the component's own
175
  # bridge has no response channel at all, so this is strictly more than the embed gets.
royalimports_os/aios_grid.py CHANGED
@@ -1137,6 +1137,11 @@ def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storag
1137
  fields = fields_from_workspace(ws, cohorts=bool(cohort_lists), scope_key=scope_key)
1138
  views = views_from_defs(defs or {}, ws.get('views'), fields)
1139
  workspace = {'storageKey': storage_key, 'views': views, 'lists': cohort_lists}
 
 
 
 
 
1140
 
1141
  # FOLDERS (owner item 11, contract C4), re-validated at SERVE time: a view or cohort can be
1142
  # deleted by a path that knows nothing about folders, and the placement map must not outlive
 
1137
  fields = fields_from_workspace(ws, cohorts=bool(cohort_lists), scope_key=scope_key)
1138
  views = views_from_defs(defs or {}, ws.get('views'), fields)
1139
  workspace = {'storageKey': storage_key, 'views': views, 'lists': cohort_lists}
1140
+ # Owner item 3 (2026-07-31): where this user left off. The client's own localStorage copy
1141
+ # wins when present; this is the server's answer for a FRESH browser, which used to fall
1142
+ # all the way to the system default view (and whatever display mode was stored on it).
1143
+ if ws.get('activeViewId'):
1144
+ workspace['activeViewId'] = str(ws['activeViewId'])
1145
 
1146
  # FOLDERS (owner item 11, contract C4), re-validated at SERVE time: a view or cohort can be
1147
  # deleted by a path that knows nothing about folders, and the placement map must not outlive
royalimports_os/core/grid_events.py CHANGED
@@ -374,6 +374,20 @@ def handle_one(event, ctx):
374
  ws.setdefault('fields', {})
375
  ws.setdefault('overlays', {})
376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  if kind == 'view_upsert':
378
  raw = event.get('view')
379
  if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict):
@@ -470,6 +484,22 @@ def handle_one(event, ctx):
470
  raw = dict(raw)
471
  raw['permissions'] = prior.get('permissions')
472
  raw['locked'] = prior.get('locked')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  # ⚠ A SYSTEM VIEW IS PER-USER STATE AND MUST NEVER BE SHARED. Caught in the wave-9
474
  # integration pass, before deploy: `all-customers` is every user's DEFAULT view and it
475
  # carries their own column widths, visible fields and row height. C4's read-default
 
374
  ws.setdefault('fields', {})
375
  ws.setdefault('overlays', {})
376
 
377
+ if kind == 'view_select':
378
+ # Owner item 3 (2026-07-31): remember WHERE THE USER IS so a fresh browser resumes
379
+ # there instead of the system default view. Presentation state only β€” the read side
380
+ # re-validates the id against what the caller may see, so nothing here needs an
381
+ # authorisation wall beyond the length cap. Never a re-render: the client is already
382
+ # on that view.
383
+ vid = str(event.get('viewId') or '').strip()[:120]
384
+ if vid and store.available():
385
+ try:
386
+ cl_mod.save_table_active_view(uname, vid)
387
+ except Exception as _ae:
388
+ _tel.error('grid:view-select', _ae)
389
+ return False
390
+
391
  if kind == 'view_upsert':
392
  raw = event.get('view')
393
  if not isinstance(raw, dict) or not isinstance(raw.get('config'), dict):
 
484
  raw = dict(raw)
485
  raw['permissions'] = prior.get('permissions')
486
  raw['locked'] = prior.get('locked')
487
+ # ── I12/C3 BELT (2026-07-31): a LOCKED view's DISPLAY MODE is frozen HOST-SIDE too.
488
+ # The client's switcher already refuses (setDisplayMode), and its comment has claimed
489
+ # "the host refuses too" since wave 9 β€” this makes that claim true for direct API
490
+ # callers. ⚠ MIRRORS types.ts `isModeFrozen` EXACTLY: SYSTEM views and the undeletable
491
+ # list:/cohort: projections are EXEMPT β€” their `locked` is legacy "undeletable", not
492
+ # mode-frozen, and freezing them would trap the default view in whatever mode it last
493
+ # saved (the owner's stuck-in-Map report is that trap, client-made). An upsert that
494
+ # UNLOCKS in the same write (authorised above) may change display freely.
495
+ _mode_frozen = (prior.get('locked') and raw.get('locked') is not False
496
+ and prior.get('kind') != 'system'
497
+ and view_id != 'all-customers')
498
+ if _mode_frozen:
499
+ if prior.get('config', {}).get('display'):
500
+ config['display'] = dict(prior['config']['display'])
501
+ else:
502
+ config.pop('display', None)
503
  # ⚠ A SYSTEM VIEW IS PER-USER STATE AND MUST NEVER BE SHARED. Caught in the wave-9
504
  # integration pass, before deploy: `all-customers` is every user's DEFAULT view and it
505
  # carries their own column widths, visible fields and row height. C4's read-default
royalimports_os/core/measure_resolve.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """core/measure_resolve.py β€” HOST-NEUTRAL measure resolution (EXIT-5, SHELL.md ladder step 3).
2
+
3
+ The `_cl_measure_offer` / `_cl_resolve_measures` / `_cl_measure_columns` family, OUT of app.py:
4
+ one implementation, two hosts. The Streamlit adapter passes its `st.session_state` memos and its
5
+ pending-view read; the API passes runtime-level dicts and no pending view (over HTTP the
6
+ `view_upsert` lands via `/grid/events` BEFORE the workspace re-read, so the saved views already
7
+ carry the freshest rule β€” there is no mid-run component value to read).
8
+
9
+ No streamlit import, no app import β€” harness only, so both adapters can call it.
10
+
11
+ Memo discipline (unchanged from the app.py originals):
12
+ * keys carry (stamp, scope, POOL IDENTITY, question) β€” the pool's identity, never its size,
13
+ because Cohort passes a different pool per cohort and two same-sized cohorts must not be
14
+ handed each other's answers.
15
+ * `column_values` memoises permanent failures as None but NEVER a transient one (datastore
16
+ still warming after a restart) β€” a memoised transient means blank measure cells that stay
17
+ blank for the whole session long after the data landed.
18
+ * both memos are bounded by clearing wholesale past a cap β€” session/runtime-lifetime caches,
19
+ not leaks.
20
+ """
21
+ import hashlib
22
+
23
+
24
+ def offer(team_id=None, on_error=None):
25
+ """The measures the condition builder / measure columns may use, or [] if the semantic
26
+ store is unavailable. The caller's BU narrows the offer (a company-level measure cannot be
27
+ answered for one business unit)."""
28
+ try:
29
+ from harness import measure_filter as _mf
30
+ return _mf.measure_fields(team_id)
31
+ except Exception as e: # no store / model problem -> offer nothing
32
+ if on_error:
33
+ on_error('measures', e)
34
+ return []
35
+
36
+
37
+ def _pool_key(allowed_pids):
38
+ """The pool's identity, hashed. Part of every ANSWER, not just the question: the zero-group
39
+ is reconstructed from it (a customer with no orders in the window has no row for SQL to
40
+ group, so `Sales < 100` would miss exactly the lapsed customers the filter is for)."""
41
+ return hashlib.blake2b(
42
+ repr(sorted(allowed_pids)).encode(), digest_size=8).hexdigest()
43
+
44
+
45
+ def condition_sets(view_configs, pending_cfg, team_id, allowed_pids, today, stamp, memo,
46
+ on_error=None):
47
+ """Every measure condition across `view_configs` (+ the optional pending config, which goes
48
+ LAST and overwrites β€” the freshest statement of what the user wants) -> `{ruleId: [pid,…]}`.
49
+
50
+ A rule that is incomplete (mid-edit), unresolvable, or errors is left OUT of the answer β€”
51
+ the client reads a missing id as PENDING and says "Calculating…" rather than showing a count
52
+ it cannot stand behind. Never resolve an incomplete rule: `to_num(None)` is 0 by the
53
+ engines' shared contract, so a valueless `Sales > …` would become `Sales > 0` β€” a wrong set
54
+ under a confident count.
55
+ """
56
+ from harness import measure_filter as _mf
57
+ by_id = {}
58
+ for cfg in list(view_configs or []) + [pending_cfg or {}]:
59
+ for rule in _mf.collect((cfg or {}).get('filters')):
60
+ rid = str(rule.get('id') or '')
61
+ if rid:
62
+ by_id[rid] = rule
63
+ rules = list(by_id.values())
64
+ if not rules:
65
+ return {}
66
+ pk = _pool_key(allowed_pids)
67
+ out = {}
68
+ for rule in rules:
69
+ if not _mf.rule_complete(rule):
70
+ continue
71
+ key = (stamp, team_id, pk, _mf.signature(rule))
72
+ if key not in memo:
73
+ try:
74
+ memo[key] = _mf.resolve_rule(rule, today, team_id, allowed_pids)
75
+ except Exception as e:
76
+ # An unresolvable condition must NOT become "everything" or "nothing" silently.
77
+ if on_error:
78
+ on_error('measure-resolve', e)
79
+ continue
80
+ out[str(rule['id'])] = sorted(memo[key])
81
+ if len(memo) > 200: # a lifetime cache, not a leak
82
+ memo.clear()
83
+ return out
84
+
85
+
86
+ def column_values(fields, team_id, allowed_pids, today, stamp, memo, on_error=None):
87
+ """Values for every FORMULA-MEASURE column among `fields` -> `{pid: {field key: value}}`,
88
+ handed to `rows_from_pool(derived=…)` beside the cohort cells.
89
+
90
+ A column that cannot be computed (store down, BU scope on a company-level measure,
91
+ unresolvable window) degrades to BLANK β€” the value dict simply omits that field key, and
92
+ the client renders empty cells, never $0: blank is "could not compute", 0 is a real zero.
93
+ """
94
+ mfields = [f for f in fields or [] if isinstance(f.get('measure'), dict)]
95
+ if not mfields:
96
+ return {}
97
+ from harness import measure_filter as _mf
98
+ from harness import windows as _wn
99
+ pk = _pool_key(allowed_pids)
100
+ out = {}
101
+ for f in mfields:
102
+ spec = f['measure']
103
+ w = _wn.normalize(spec.get('window'))
104
+ key = (stamp, team_id, pk, spec.get('key'),
105
+ None if w is None else tuple(sorted(w.items())))
106
+ if key not in memo:
107
+ try:
108
+ memo[key] = _mf.resolve_values(spec.get('key'), spec.get('window'),
109
+ today, team_id, allowed_pids)
110
+ except Exception as e:
111
+ if on_error:
112
+ on_error('measure-column', e)
113
+ # ⚠ ONLY A PERMANENT FAILURE MAY BE MEMOISED. "The data cache is still warming
114
+ # up after a restart" is TRANSIENT and self-healing β€” memoising it means a user
115
+ # who opened the page during that window gets blank measure cells that STAY
116
+ # blank for the whole session. A permanent failure (an unadmitted measure, an
117
+ # unresolvable window) still memoises, so the retry-storm this guard was
118
+ # written for cannot come back.
119
+ transient = False
120
+ try:
121
+ from harness import datastore as _ds
122
+ transient = not _ds.ready()
123
+ except Exception:
124
+ transient = False
125
+ if transient:
126
+ continue # no memo entry -> the next call tries again
127
+ memo[key] = None
128
+ vals = memo[key]
129
+ if vals is None:
130
+ continue
131
+ fkey = f['key']
132
+ for pid, v in vals.items():
133
+ out.setdefault(pid, {})[fkey] = v
134
+ if len(memo) > 100:
135
+ memo.clear()
136
+ return out
royalimports_os/core/table_store.py CHANGED
@@ -85,7 +85,7 @@ class TableStore:
85
  ws = (store.get(self.table_key) or {}).get(username, {}) or {}
86
  except Exception:
87
  ws = {}
88
- return {
89
  'views': dict(ws.get('views') or {}),
90
  'fields': dict(ws.get('fields') or {}),
91
  'overlays': dict(ws.get('overlays') or {}),
@@ -97,6 +97,12 @@ class TableStore:
97
  'folders': dict(ws.get('folders') or {}),
98
  'itemFolders': dict(ws.get('itemFolders') or {}),
99
  }
 
 
 
 
 
 
100
 
101
  # ---------------------------------------------------------------- write
102
  def _update(self, username, change):
@@ -116,6 +122,21 @@ class TableStore:
116
  # coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
117
  store.update(self.table_key, _up, flush='async')
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def save_folders(self, username, folders, item_folders):
120
  """Replace the folder stratum wholesale (wave-8 I11).
121
 
 
85
  ws = (store.get(self.table_key) or {}).get(username, {}) or {}
86
  except Exception:
87
  ws = {}
88
+ out = {
89
  'views': dict(ws.get('views') or {}),
90
  'fields': dict(ws.get('fields') or {}),
91
  'overlays': dict(ws.get('overlays') or {}),
 
97
  'folders': dict(ws.get('folders') or {}),
98
  'itemFolders': dict(ws.get('itemFolders') or {}),
99
  }
100
+ # 2026-07-31 (owner item 3): WHERE THE USER LEFT OFF survives a new browser. The
101
+ # client's localStorage copy wins when present; this is the server's answer for a
102
+ # fresh profile, which used to fall all the way to the system default view.
103
+ if ws.get('activeViewId'):
104
+ out['activeViewId'] = str(ws['activeViewId'])
105
+ return out
106
 
107
  # ---------------------------------------------------------------- write
108
  def _update(self, username, change):
 
122
  # coalesces in the background. Registry/auth writes elsewhere stay flush='sync'.
123
  store.update(self.table_key, _up, flush='async')
124
 
125
+ def save_active_view(self, username, view_id):
126
+ """Remember which view this user last opened (owner item 3, 2026-07-31).
127
+
128
+ Presentation state, not authorisation: the READ side re-validates the id against what
129
+ the caller may actually see, so a stale or foreign id degrades to the default view
130
+ rather than granting anything. Stored per user like every other stratum.
131
+ """
132
+ vid = str(view_id or '').strip()[:120]
133
+ if not vid or username == SHARED_KEY:
134
+ return
135
+
136
+ def _set(ws):
137
+ ws['activeViewId'] = vid
138
+ self._update(username, _set)
139
+
140
  def save_folders(self, username, folders, item_folders):
141
  """Replace the folder stratum wholesale (wave-8 I11).
142
 
royalimports_os/core/users.py CHANGED
@@ -236,3 +236,23 @@ def allowed_bus_labels(user):
236
  if not labels:
237
  return ['All', 'Fisch', 'Royal']
238
  return (['All'] + labels) if len(labels) > 1 else labels
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  if not labels:
237
  return ['All', 'Fisch', 'Royal']
238
  return (['All'] + labels) if len(labels) > 1 else labels
239
+
240
+
241
+ def assignable_people():
242
+ """Display names for `user`-typed overlay columns β€” the tenant's ACTIVE accounts.
243
+
244
+ Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
245
+ call rather than persisted with the column: a snapshot would keep offering people who
246
+ have left and never offer people who joined. Deactivated accounts are excluded; a value
247
+ already stored on a row is untouched β€” history should still say who owned something.
248
+ """
249
+ try:
250
+ reg = registry() or {}
251
+ except Exception:
252
+ return []
253
+ out = []
254
+ for username, u in reg.items():
255
+ if not isinstance(u, dict) or u.get('active') is False:
256
+ continue
257
+ out.append(str(u.get('name') or username))
258
+ return sorted(set(out))
royalimports_os/harness/runtime.py CHANGED
@@ -75,6 +75,11 @@ class TenantRuntime:
75
  tenant: object # harness.base.Tenant
76
  store_namespace: str = ""
77
  pool_cache: dict = field(default_factory=dict)
 
 
 
 
 
78
  _lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
79
 
80
  @property
 
75
  tenant: object # harness.base.Tenant
76
  store_namespace: str = ""
77
  pool_cache: dict = field(default_factory=dict)
78
+ #: EXIT-5 β€” the API adapter's memos for `core.measure_resolve` (the Streamlit adapter keeps
79
+ #: its own in st.session_state). Bounded by the module's own clear-past-cap rule; keyed on
80
+ #: (stamp, scope, pool identity, question) so nothing user-shaped lives in them.
81
+ measure_memo: dict = field(default_factory=dict)
82
+ mset_memo: dict = field(default_factory=dict)
83
  _lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
84
 
85
  @property
royalimports_os/modules/customer_data.py CHANGED
@@ -518,6 +518,7 @@ delete_table_field = _TSTORE.delete_field
518
  duplicate_table_field = _TSTORE.duplicate_field
519
  patch_table_overlay = _TSTORE.patch_overlay
520
  save_table_folders = _TSTORE.save_folders
 
521
 
522
 
523
  # ------------------------------------------------------------------ digest compatibility
 
518
  duplicate_table_field = _TSTORE.duplicate_field
519
  patch_table_overlay = _TSTORE.patch_overlay
520
  save_table_folders = _TSTORE.save_folders
521
+ save_table_active_view = _TSTORE.save_active_view # owner item 3: last-opened view, per user
522
 
523
 
524
  # ------------------------------------------------------------------ digest compatibility
web/dist-embed/index.html CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/apiContract.ts CHANGED
@@ -56,6 +56,12 @@ export const TOAST_EVENT = "aios:toast";
56
  * membership. A toast is a receipt, not a refresh. */
57
  export const WORKSPACE_STALE_EVENT = "aios:workspace-stale";
58
 
 
 
 
 
 
 
59
  export function signal(name: string, detail?: unknown): void {
60
  if (typeof window === "undefined") return;
61
  window.dispatchEvent(new CustomEvent(name, { detail }));
 
56
  * membership. A toast is a receipt, not a refresh. */
57
  export const WORKSPACE_STALE_EVENT = "aios:workspace-stale";
58
 
59
+ /** Owner item 10 (2026-07-31): the user CLICKED INTO THE WORK SURFACE (a saved view, a grid
60
+ * cell) β€” the frame should fold its navigation rail down to the slim strip so the table gets
61
+ * the width. Raised by the grid, handled by the shell; a no-op in the embed (no listener),
62
+ * which is exactly the host-neutral contract the other signals follow. */
63
+ export const NAV_MINIMIZE_EVENT = "aios:nav-minimize";
64
+
65
  export function signal(name: string, detail?: unknown): void {
66
  if (typeof window === "undefined") return;
67
  window.dispatchEvent(new CustomEvent(name, { detail }));
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
2
  import type { ReactNode } from "react";
3
  import { AnchoredOverlay } from "./OverlaySurface";
4
  import type { AnchorRect } from "./OverlaySurface";
 
5
  import { CREATABLE_TYPES, choiceOptions, directionLabel, parseOptions, ratingMax }
6
  from "./types";
7
  import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
@@ -293,6 +294,89 @@ function needsOptions(t: FieldType | "measure"): boolean {
293
  */
294
  type CreateKind = FieldType | "measure";
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  /** The Change-field dropdown's "New field" entries. A colon cannot occur in a real field key
297
  * (keys are slugs), so the prefix cannot collide with one. */
298
  const NEW_PREFIX = "new:";
@@ -395,9 +479,9 @@ function ExtraTypeEditor({
395
  idPrefix: string;
396
  }) {
397
  if (kind === "formula") {
398
- const referencable = fields.filter(
399
- (f) => f.type !== "formula" && f.key !== "__proto__"
400
- );
401
  return (
402
  <>
403
  <label>
@@ -435,7 +519,10 @@ function ExtraTypeEditor({
435
  <div className="cg-field-hint cg-formula-error">{formulaError}</div>
436
  ) : (
437
  <div className="cg-field-hint">
438
- Numbers, {"{field}"} references, + βˆ’ Γ— Γ· ( ), ABS, ROUND, MIN, MAX and IF.
 
 
 
439
  Errors show a blank cell.
440
  </div>
441
  )}
@@ -578,30 +665,37 @@ export default function ColumnMenu({
578
  ? parseOptions(cleanOptionText(retypeOptionText, retypeTo))
579
  : [];
580
 
581
- // Wave-5 item 9 β€” live formula validation for BOTH create surfaces. Refs must name real,
582
- // non-formula fields (the evaluator reads raw row values, so formula-over-formula would
583
- // silently be blank β€” refused at authoring time instead).
 
 
584
  const knownKeys = useMemo(
585
  () => new Set(fields.filter((f) => f.type !== "formula").map((f) => f.key)),
586
  [fields]
587
  );
588
- const formulaKeys = useMemo(
589
- () => new Set(fields.filter((f) => f.type === "formula").map((f) => f.key)),
590
- [fields]
591
- );
 
 
 
 
 
592
  const formulaCheck = useMemo(
593
  () =>
594
  formulaText.trim() === ""
595
  ? { ok: false, error: "Type a formula", refs: [] as string[] }
596
- : validateFormula(formulaText, knownKeys, formulaKeys),
597
- [formulaText, knownKeys, formulaKeys]
598
  );
599
  const swapFormulaCheck = useMemo(
600
  () =>
601
  swapFormulaText.trim() === ""
602
  ? { ok: false, error: "Type a formula", refs: [] as string[] }
603
- : validateFormula(swapFormulaText, knownKeys, formulaKeys),
604
- [swapFormulaText, knownKeys, formulaKeys]
605
  );
606
 
607
  const chosenMeasure = measures.find((m) => m.key === measureKey);
@@ -778,25 +872,19 @@ export default function ColumnMenu({
778
  onKeyDown={(event) => event.key === "Enter" && create()}
779
  />
780
  </label>
781
- <label>
782
- <span>Field type</span>
783
- <select
784
- className="cg-select"
 
 
 
 
785
  value={kind}
786
- onChange={(event) => setKind(event.target.value as CreateKind)}
787
- >
788
- {FIELD_TYPES.map((option) => (
789
- <option value={option.value} key={option.value}>
790
- {option.label}
791
- </option>
792
- ))}
793
- {/* Offered exactly when the host offers measures β€” the same availability rule as
794
- the filter's measure conditions, because it is the same machinery. */}
795
- {measures.length > 0 && (
796
- <option value="measure">Formula measure (over a period)</option>
797
- )}
798
- </select>
799
- </label>
800
  {kind === "measure" && (
801
  <>
802
  <label>
 
2
  import type { ReactNode } from "react";
3
  import { AnchoredOverlay } from "./OverlaySurface";
4
  import type { AnchorRect } from "./OverlaySurface";
5
+ import { FieldTypeIcon } from "./icons";
6
  import { CREATABLE_TYPES, choiceOptions, directionLabel, parseOptions, ratingMax }
7
  from "./types";
8
  import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
 
294
  */
295
  type CreateKind = FieldType | "measure";
296
 
297
+ /**
298
+ * Owner item 8 (2026-07-31) β€” the create pane's field-type picker: a find box over an icon
299
+ * listbox, replacing the icon-less native select. Each row wears the SAME mark its column
300
+ * header will wear (`FieldTypeIcon` ← TYPE_SHAPES), so the vocabulary teaches itself. The
301
+ * "measure" pseudo-kind borrows the currency mark: a measure column's real type is
302
+ * currency/int/pct, and inventing a 17th glyph for a pseudo-kind would put a mark on screen
303
+ * that no column ever wears.
304
+ */
305
+ function TypePicker({
306
+ value,
307
+ onPick,
308
+ offerMeasure,
309
+ }: {
310
+ value: CreateKind;
311
+ onPick: (k: CreateKind) => void;
312
+ offerMeasure: boolean;
313
+ }) {
314
+ const [q, setQ] = useState("");
315
+ const needle = q.trim().toLowerCase();
316
+ const rows: { value: CreateKind; label: string }[] = [
317
+ ...FIELD_TYPES,
318
+ ...(offerMeasure
319
+ ? [{ value: "measure" as CreateKind, label: "Formula measure (over a period)" }]
320
+ : []),
321
+ ].filter((r) => !needle || r.label.toLowerCase().includes(needle));
322
+ return (
323
+ <div className="cg-type-picker">
324
+ <div className="cg-type-search">
325
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
326
+ <circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" />
327
+ <path
328
+ d="m10.4 10.4 3.1 3.1"
329
+ stroke="currentColor"
330
+ strokeWidth="1.35"
331
+ strokeLinecap="round"
332
+ />
333
+ </svg>
334
+ <input
335
+ type="text"
336
+ value={q}
337
+ placeholder="Find a field type"
338
+ aria-label="Find a field type"
339
+ onChange={(event) => setQ(event.target.value)}
340
+ />
341
+ </div>
342
+ <div className="cg-type-list" role="listbox" aria-label="Field type">
343
+ {rows.map((r) => (
344
+ <button
345
+ key={r.value}
346
+ type="button"
347
+ role="option"
348
+ aria-selected={value === r.value}
349
+ className={"cg-type-row" + (value === r.value ? " is-on" : "")}
350
+ onClick={() => onPick(r.value)}
351
+ >
352
+ <FieldTypeIcon type={r.value === "measure" ? "currency" : r.value} size={16} />
353
+ <span className="cg-type-label">{r.label}</span>
354
+ {value === r.value && (
355
+ <svg
356
+ className="cg-type-check"
357
+ width="14"
358
+ height="14"
359
+ viewBox="0 0 16 16"
360
+ fill="none"
361
+ aria-hidden="true"
362
+ >
363
+ <path
364
+ d="m3.5 8.5 3 3 6-6.5"
365
+ stroke="currentColor"
366
+ strokeWidth="1.6"
367
+ strokeLinecap="round"
368
+ strokeLinejoin="round"
369
+ />
370
+ </svg>
371
+ )}
372
+ </button>
373
+ ))}
374
+ {rows.length === 0 && <div className="cg-pop-note">No matching type.</div>}
375
+ </div>
376
+ </div>
377
+ );
378
+ }
379
+
380
  /** The Change-field dropdown's "New field" entries. A colon cannot occur in a real field key
381
  * (keys are slugs), so the prefix cannot collide with one. */
382
  const NEW_PREFIX = "new:";
 
479
  idPrefix: string;
480
  }) {
481
  if (kind === "formula") {
482
+ // 2026-07-31 (owner item 2): other FORMULA fields are referencable now β€” evaluation is
483
+ // topological and a cycle is refused at validation, so the old exclusion is gone.
484
+ const referencable = fields.filter((f) => f.key !== "__proto__");
485
  return (
486
  <>
487
  <label>
 
519
  <div className="cg-field-hint cg-formula-error">{formulaError}</div>
520
  ) : (
521
  <div className="cg-field-hint">
522
+ Excel-style: numbers, text in quotes, {"{field}"} references, + βˆ’ Γ— Γ· ^ &amp; ( ),
523
+ IF Β· AND Β· OR Β· SUM Β· AVERAGE Β· COUNT Β· MIN Β· MAX Β· ROUND(UP/DOWN) Β· ABS Β· MOD Β·
524
+ SQRT Β· CONCATENATE Β· LEFT Β· RIGHT Β· MID Β· LEN Β· TRIM Β· UPPER Β· LOWER Β· PROPER Β·
525
+ TEXT Β· VALUE Β· TODAY Β· DAYS Β· YEAR Β· MONTH Β· DAY Β· IFERROR Β· ISBLANK.
526
  Errors show a blank cell.
527
  </div>
528
  )}
 
665
  ? parseOptions(cleanOptionText(retypeOptionText, retypeTo))
666
  : [];
667
 
668
+ // Wave-5 item 9, amended 2026-07-31 (owner item 2) β€” live formula validation for BOTH
669
+ // create surfaces. Refs may now name OTHER formula fields (evaluation is topological);
670
+ // what is refused is a CYCLE, checked against every formula's SOURCE. Both surfaces here
671
+ // CREATE a field, so no cycle is possible yet and no selfKey is passed β€” the retype path
672
+ // (in-place formula edit) would pass the edited field's key.
673
  const knownKeys = useMemo(
674
  () => new Set(fields.filter((f) => f.type !== "formula").map((f) => f.key)),
675
  [fields]
676
  );
677
+ const formulaSources = useMemo(() => {
678
+ const out = new Map<string, string>();
679
+ for (const f of fields) {
680
+ if (f.type !== "formula") continue;
681
+ const src = typeof f.formula === "string" ? f.formula : "";
682
+ if (src) out.set(f.key, src);
683
+ }
684
+ return out;
685
+ }, [fields]);
686
  const formulaCheck = useMemo(
687
  () =>
688
  formulaText.trim() === ""
689
  ? { ok: false, error: "Type a formula", refs: [] as string[] }
690
+ : validateFormula(formulaText, knownKeys, formulaSources),
691
+ [formulaText, knownKeys, formulaSources]
692
  );
693
  const swapFormulaCheck = useMemo(
694
  () =>
695
  swapFormulaText.trim() === ""
696
  ? { ok: false, error: "Type a formula", refs: [] as string[] }
697
+ : validateFormula(swapFormulaText, knownKeys, formulaSources),
698
+ [swapFormulaText, knownKeys, formulaSources]
699
  );
700
 
701
  const chosenMeasure = measures.find((m) => m.key === measureKey);
 
872
  onKeyDown={(event) => event.key === "Enter" && create()}
873
  />
874
  </label>
875
+ <div className="cg-type-block">
876
+ <span className="cg-type-title">Field type</span>
877
+ {/* Owner item 8 (2026-07-31): every type wears its own mark β€” the same TYPE_SHAPES
878
+ glyph its column header wears. A native <option> cannot hold an SVG (the ruling
879
+ iconShapes.ts records), so the control is a find box over a listbox of real
880
+ rows, Airtable-style. The measure row appears exactly when the host offers
881
+ measures β€” the same availability rule as the filter's measure conditions. */}
882
+ <TypePicker
883
  value={kind}
884
+ onPick={setKind}
885
+ offerMeasure={measures.length > 0}
886
+ />
887
+ </div>
 
 
 
 
 
 
 
 
 
 
888
  {kind === "measure" && (
889
  <>
890
  <label>
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -38,6 +38,7 @@ import ColumnMenu from "./ColumnMenu";
38
  import type { ColumnMenuState } from "./ColumnMenu";
39
  import { HEADER_ICONS } from "./iconShapes";
40
  import { emitHostEvent, eventId } from "./hostBridge";
 
41
  import { runExport } from "./export";
42
  import type { ExportFormat } from "./export";
43
  import { echoReemit, reconcileEchoView } from "./viewEcho";
@@ -46,7 +47,7 @@ import { newFolderId, pruneFolderStamps, reconcileFolders, resolveFolderId } fro
46
  import type { FolderStamps } from "./folders";
47
  import type { GridFolder, ViewPermissions } from "./types";
48
  import type { FieldStamps } from "./optimism";
49
- import { evalFormula, parseFormula } from "./formulaEngine";
50
  import type { FormulaAst } from "./formulaEngine";
51
  import { CalendarView, KanbanView, ListView, ModeSwitch } from "./viewModes";
52
  // The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) β€” the grid is now
@@ -328,6 +329,12 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
328
  fieldKey: string;
329
  anchor: AnchorRect;
330
  } | null>(null);
 
 
 
 
 
 
331
  /** The selection bar's "Add to cohort" popover (owner item 2's purpose for the checkboxes). */
332
  const [selAddOpen, setSelAddOpen] = useState(false);
333
  const [selListName, setSelListName] = useState("");
@@ -654,14 +661,23 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
654
  // row WITH this session's overlay edits layered, so editing a referenced field recomputes
655
  // live. A formula that does not parse, or errors on a row, yields BLANK β€” never a wrong
656
  // number (formulaEngine.ts). `created_time` copies the row's `_created`.
 
 
 
 
657
  const formulaAsts = useMemo(() => {
658
- const out: { key: string; ast: FormulaAst }[] = [];
659
  for (const f of fields) {
660
  if (f.type !== "formula") continue;
661
  const src = formulaOf(f);
662
- if (!src) continue;
663
- const p = parseFormula(src);
664
- if (p.ok) out.push({ key: f.key, ast: p.ast });
 
 
 
 
 
665
  }
666
  return out;
667
  }, [fields]);
@@ -671,17 +687,23 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
671
  );
672
  const computedRows = useMemo(() => {
673
  if (formulaAsts.length === 0 && createdTimeKeys.length === 0) return rawRows;
 
674
  return rawRows.map((r) => {
675
  const edits = overlayEdits[r.pid];
676
- const base = edits ? { ...r, ...edits } : r;
677
  const out: Row = { ...r };
678
- for (const k of createdTimeKeys)
679
  out[k] = (r._created as string | undefined) ?? null;
680
- for (const { key, ast } of formulaAsts)
681
- out[key] = evalFormula(ast, (k) => base[k]);
 
 
 
 
 
682
  return out;
683
  });
684
- }, [rawRows, overlayEdits, formulaAsts, createdTimeKeys]);
685
 
686
  // Wave-2 item 2c β€” COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
687
  // derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
@@ -753,7 +775,8 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
753
  overlayEdits,
754
  canEditField
755
  );
756
- const { gridSelection, selectedPids, onGridSelectionChange, selectPids, clearSelection } =
 
757
  useGridSelection(
758
  displayRows,
759
  displayPidToIndex,
@@ -1034,6 +1057,9 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
1034
  (cell: Item, event: CellClickedEventArgs) => {
1035
  const row = displayRows[cell[1]];
1036
  if (!row) return;
 
 
 
1037
  if (row.kind === "group-header") {
1038
  event.preventDefault();
1039
  setCollapsed((current) => {
@@ -1086,6 +1112,14 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
1086
  }
1087
  }
1088
  }
 
 
 
 
 
 
 
 
1089
  // Owner item 17 β€” A SINGLE CLICK HIGHLIGHTS. It used to open the record panel from here,
1090
  // which meant a user could not select a cell, read across a row, or copy a value without
1091
  // a drawer landing over the table. The highlight is glide's own doing: this handler
@@ -1097,7 +1131,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
1097
  // open a record; the hover Expand button below is its replacement, and half of this
1098
  // change is a table whose records cannot be opened at all.
1099
  },
1100
- [displayRows, visibleCols, fieldByKey, canEditField, overlayEdits]
1101
  );
1102
  /**
1103
  * Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the
@@ -1189,6 +1223,30 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
1189
  );
1190
  const onGridKeyDown = useCallback(
1191
  (event: GridKeyEventArgs) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1192
  const contextMenu =
1193
  event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
1194
  // DataEditor exposes public controlled selection without the row marker,
@@ -1212,7 +1270,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
1212
  height: 36,
1213
  });
1214
  },
1215
- [gridSelection.current, openHeaderMenu]
1216
  );
1217
 
1218
  const persistView = useCallback((view: SavedView) => {
@@ -1238,6 +1296,12 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
1238
  setSearch("");
1239
  setDetailPid(null);
1240
  setDisplayCap(DISPLAY_PAGE);
 
 
 
 
 
 
1241
  },
1242
  [views, activeViewId, config, persistView, fields]
1243
  );
@@ -3116,7 +3180,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
3116
  placement="bottom-start"
3117
  role="listbox"
3118
  ariaLabel={`Choose ${pickerField.label}`}
3119
- onDismiss={() => setPicker(null)}
3120
  dataKind="cell-picker"
3121
  >
3122
  <div className="cg-pop-title">{pickerField.label}</div>
@@ -3138,7 +3202,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
3138
  aria-label={`${n} star${n === 1 ? "" : "s"}`}
3139
  onClick={() => {
3140
  patchOverlay(picker.pid, { [pickerField.key]: String(n) });
3141
- setPicker(null);
3142
  }}
3143
  >
3144
  <StarIcon on={Number(pickerValue) >= n} />
@@ -3180,7 +3244,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
3180
  return;
3181
  }
3182
  patchOverlay(picker.pid, { [pickerField.key]: choice });
3183
- setPicker(null);
3184
  }}
3185
  >
3186
  <span className="cg-pick-pill"
@@ -3196,7 +3260,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
3196
  <button
3197
  type="button"
3198
  className="cg-btn"
3199
- onClick={() => setPicker(null)}
3200
  >
3201
  Done
3202
  </button>
@@ -3211,7 +3275,7 @@ export default function CustomerGrid({ scope = "customer" }: { scope?: SurfaceSc
3211
  className="cg-pick-row cg-pick-clear"
3212
  onClick={() => {
3213
  patchOverlay(picker.pid, { [pickerField.key]: "" });
3214
- setPicker(null);
3215
  }}
3216
  >
3217
  Clear
 
38
  import type { ColumnMenuState } from "./ColumnMenu";
39
  import { HEADER_ICONS } from "./iconShapes";
40
  import { emitHostEvent, eventId } from "./hostBridge";
41
+ import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
42
  import { runExport } from "./export";
43
  import type { ExportFormat } from "./export";
44
  import { echoReemit, reconcileEchoView } from "./viewEcho";
 
47
  import type { FolderStamps } from "./folders";
48
  import type { GridFolder, ViewPermissions } from "./types";
49
  import type { FieldStamps } from "./optimism";
50
+ import { evalFormula, orderFormulas, parseFormula } from "./formulaEngine";
51
  import type { FormulaAst } from "./formulaEngine";
52
  import { CalendarView, KanbanView, ListView, ModeSwitch } from "./viewModes";
53
  // The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) β€” the grid is now
 
329
  fieldKey: string;
330
  anchor: AnchorRect;
331
  } | null>(null);
332
+ /** Owner item 5 (2026-07-31) β€” closing a picker RETURNS FOCUS TO THE GRID, so Enter keeps
333
+ * navigating (pick β†’ Enter β†’ next record) instead of stranding focus on a dead overlay. */
334
+ const closePicker = useCallback(() => {
335
+ setPicker(null);
336
+ requestAnimationFrame(() => gridRef.current?.focus());
337
+ }, []);
338
  /** The selection bar's "Add to cohort" popover (owner item 2's purpose for the checkboxes). */
339
  const [selAddOpen, setSelAddOpen] = useState(false);
340
  const [selListName, setSelListName] = useState("");
 
661
  // row WITH this session's overlay edits layered, so editing a referenced field recomputes
662
  // live. A formula that does not parse, or errors on a row, yields BLANK β€” never a wrong
663
  // number (formulaEngine.ts). `created_time` copies the row's `_created`.
664
+ // 2026-07-31 (owner item 2): formulas may reference OTHER formulas now, so parse order is
665
+ // TOPOLOGICAL (orderFormulas) β€” a formula runs after the formulas it reads, cycle members
666
+ // never run (blank, never a stale number), and each row's results feed the next formula
667
+ // through a per-row scope.
668
  const formulaAsts = useMemo(() => {
669
+ const sources = new Map<string, string>();
670
  for (const f of fields) {
671
  if (f.type !== "formula") continue;
672
  const src = formulaOf(f);
673
+ if (src) sources.set(f.key, src);
674
+ }
675
+ const { order, cyclic } = orderFormulas(sources);
676
+ const out: { key: string; ast: FormulaAst }[] = [];
677
+ for (const key of order) {
678
+ if (cyclic.has(key)) continue;
679
+ const p = parseFormula(sources.get(key)!);
680
+ if (p.ok) out.push({ key, ast: p.ast });
681
  }
682
  return out;
683
  }, [fields]);
 
687
  );
688
  const computedRows = useMemo(() => {
689
  if (formulaAsts.length === 0 && createdTimeKeys.length === 0) return rawRows;
690
+ const env = { today: payload?.today };
691
  return rawRows.map((r) => {
692
  const edits = overlayEdits[r.pid];
693
+ const scope: Row = edits ? { ...r, ...edits } : { ...r };
694
  const out: Row = { ...r };
695
+ for (const k of createdTimeKeys) {
696
  out[k] = (r._created as string | undefined) ?? null;
697
+ scope[k] = out[k];
698
+ }
699
+ for (const { key, ast } of formulaAsts) {
700
+ const v = evalFormula(ast, (k) => scope[k], env);
701
+ out[key] = v;
702
+ scope[key] = v; // later formulas read this one's result β€” the topo order above
703
+ }
704
  return out;
705
  });
706
+ }, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]);
707
 
708
  // Wave-2 item 2c β€” COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
709
  // derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
 
775
  overlayEdits,
776
  canEditField
777
  );
778
+ const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
779
+ setActiveCell, clearSelection } =
780
  useGridSelection(
781
  displayRows,
782
  displayPidToIndex,
 
1057
  (cell: Item, event: CellClickedEventArgs) => {
1058
  const row = displayRows[cell[1]];
1059
  if (!row) return;
1060
+ // Owner item 10: clicking into the cells is "I am working now" β€” the frame folds its
1061
+ // navigation rail to the slim strip (a no-op in the embed; the shell listens).
1062
+ signal(NAV_MINIMIZE_EVENT);
1063
  if (row.kind === "group-header") {
1064
  event.preventDefault();
1065
  setCollapsed((current) => {
 
1112
  }
1113
  }
1114
  }
1115
+ // Owner item 6 (2026-07-31) β€” CLICKING THE CUSTOMER TICKS THE CHECKBOX. The row marker
1116
+ // is a ~32px strip; the identity cell is the widest, most natural target on the row, so
1117
+ // a click there toggles the same pid-anchored set the markers write ("more surface area
1118
+ // to select individual customers into a Cohort"). No preventDefault: glide still commits
1119
+ // the cell highlight below, so reading across the row keeps working.
1120
+ if (row.kind === "data" && field && field.key === lockedKey) {
1121
+ togglePid(row.record.pid);
1122
+ }
1123
  // Owner item 17 β€” A SINGLE CLICK HIGHLIGHTS. It used to open the record panel from here,
1124
  // which meant a user could not select a cell, read across a row, or copy a value without
1125
  // a drawer landing over the table. The highlight is glide's own doing: this handler
 
1131
  // open a record; the hover Expand button below is its replacement, and half of this
1132
  // change is a table whose records cannot be opened at all.
1133
  },
1134
+ [displayRows, visibleCols, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid]
1135
  );
1136
  /**
1137
  * Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the
 
1223
  );
1224
  const onGridKeyDown = useCallback(
1225
  (event: GridKeyEventArgs) => {
1226
+ // Owner item 5 (2026-07-31) β€” EXCEL-GRADE ENTER. When no editor is open (an open overlay
1227
+ // editor swallows its own keys before the canvas sees them), Enter moves the active cell
1228
+ // DOWN one record and Shift+Enter moves UP β€” never opening the record drawer, which is
1229
+ // what made keyboard runs down a column "really clunky". Glide's own overlay editor
1230
+ // already commits-and-moves-down on Enter, so typing β†’ Enter β†’ typing flows like Excel;
1231
+ // this handles the BETWEEN-edits half. Group headers are skipped in the direction of
1232
+ // travel. The drawer stays reachable by double-click and the hover Expand button.
1233
+ if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) {
1234
+ const cur = gridSelection.current?.cell;
1235
+ if (cur) {
1236
+ event.preventDefault();
1237
+ event.stopPropagation();
1238
+ event.cancel();
1239
+ const dir = event.shiftKey ? -1 : 1;
1240
+ let row = cur[1] + dir;
1241
+ while (row >= 0 && row < displayRows.length && displayRows[row]?.kind !== "data")
1242
+ row += dir;
1243
+ if (row >= 0 && row < displayRows.length) {
1244
+ setActiveCell(cur[0], row);
1245
+ gridRef.current?.scrollTo(cur[0], row, "vertical", 0, 0);
1246
+ }
1247
+ return;
1248
+ }
1249
+ }
1250
  const contextMenu =
1251
  event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
1252
  // DataEditor exposes public controlled selection without the row marker,
 
1270
  height: 36,
1271
  });
1272
  },
1273
+ [gridSelection.current, openHeaderMenu, displayRows, setActiveCell]
1274
  );
1275
 
1276
  const persistView = useCallback((view: SavedView) => {
 
1296
  setSearch("");
1297
  setDetailPid(null);
1298
  setDisplayCap(DISPLAY_PAGE);
1299
+ // Owner item 3 (2026-07-31): tell the host WHERE THE USER IS, so a fresh browser (no
1300
+ // localStorage copy) resumes on this view instead of the system default. Presentation
1301
+ // state β€” the host stores the id and the read side re-validates it.
1302
+ emitHostEvent({ id: eventId("view"), type: "view_select", viewId: id });
1303
+ // Owner item 10: opening a view is "I am working now" β€” the frame folds its nav rail.
1304
+ signal(NAV_MINIMIZE_EVENT);
1305
  },
1306
  [views, activeViewId, config, persistView, fields]
1307
  );
 
3180
  placement="bottom-start"
3181
  role="listbox"
3182
  ariaLabel={`Choose ${pickerField.label}`}
3183
+ onDismiss={closePicker}
3184
  dataKind="cell-picker"
3185
  >
3186
  <div className="cg-pop-title">{pickerField.label}</div>
 
3202
  aria-label={`${n} star${n === 1 ? "" : "s"}`}
3203
  onClick={() => {
3204
  patchOverlay(picker.pid, { [pickerField.key]: String(n) });
3205
+ closePicker();
3206
  }}
3207
  >
3208
  <StarIcon on={Number(pickerValue) >= n} />
 
3244
  return;
3245
  }
3246
  patchOverlay(picker.pid, { [pickerField.key]: choice });
3247
+ closePicker();
3248
  }}
3249
  >
3250
  <span className="cg-pick-pill"
 
3260
  <button
3261
  type="button"
3262
  className="cg-btn"
3263
+ onClick={closePicker}
3264
  >
3265
  Done
3266
  </button>
 
3275
  className="cg-pick-row cg-pick-clear"
3276
  onClick={() => {
3277
  patchOverlay(picker.pid, { [pickerField.key]: "" });
3278
+ closePicker();
3279
  }}
3280
  >
3281
  Clear
web/src/customer-grid/ViewSidebar.tsx CHANGED
@@ -211,9 +211,36 @@ export default function ViewSidebar({
211
  const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
212
  const [addName, setAddName] = useState("");
213
  const [dropTarget, setDropTarget] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
215
  const groups = groupByFolder(
216
- views,
217
  foldersOn ? (folders as GridFolder[]) : [],
218
  (v) => (folderIdOf ? folderIdOf(v.id) : null)
219
  );
@@ -299,7 +326,31 @@ export default function ViewSidebar({
299
  };
300
 
301
  return (
302
- <aside className="cg-views" aria-label="Customer views">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  {/*
304
  I13 β€” the "Views / All changes saved" header block is GONE and everything moved up.
305
 
@@ -337,6 +388,38 @@ export default function ViewSidebar({
337
  </button>
338
  </div>
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  {creating && (
341
  <div className="cg-view-create cg-create-form">
342
  <label htmlFor="cg-new-view">
@@ -543,9 +626,11 @@ export default function ViewSidebar({
543
  </AnchoredOverlay>
544
  )}
545
 
546
- <div className="cg-view-list">
547
  {groups.map((group) => {
548
  const gid = group.folder?.id ?? null;
 
 
549
  const isRoot = gid === null;
550
  const shut = gid != null && collapsed.has(gid);
551
  return (
 
211
  const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
212
  const [addName, setAddName] = useState("");
213
  const [dropTarget, setDropTarget] = useState<string | null>(null);
214
+ // Owner item 9 (2026-07-31) β€” "Find a view". Local and never persisted: a search is a
215
+ // moment, not a setting. Filtering runs BEFORE grouping so a folder with no matches
216
+ // simply drops out of the rail while the query is live.
217
+ const [viewQuery, setViewQuery] = useState("");
218
+ // Owner item 11 β€” this rail is the SECOND navigation bar: it folds to the same slim strip
219
+ // the shell nav does, remembered per browser under its own key.
220
+ const [railShut, setRailShut] = useState<boolean>(() => {
221
+ try {
222
+ return localStorage.getItem("aios-views-rail") === "1";
223
+ } catch {
224
+ return false;
225
+ }
226
+ });
227
+ const toggleRail = () =>
228
+ setRailShut((v) => {
229
+ const next = !v;
230
+ try {
231
+ localStorage.setItem("aios-views-rail", next ? "1" : "0");
232
+ } catch {
233
+ // storage can be blocked; the fold still works for the session
234
+ }
235
+ return next;
236
+ });
237
+ const viewNeedle = viewQuery.trim().toLowerCase();
238
+ const shownViews = viewNeedle
239
+ ? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
240
+ : views;
241
  const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
242
  const groups = groupByFolder(
243
+ shownViews,
244
  foldersOn ? (folders as GridFolder[]) : [],
245
  (v) => (folderIdOf ? folderIdOf(v.id) : null)
246
  );
 
326
  };
327
 
328
  return (
329
+ <aside
330
+ className={"cg-views" + (railShut ? " is-collapsed" : "")}
331
+ aria-label="Customer views"
332
+ >
333
+ {/* Owner item 11 β€” the rail's own minimize control: the same three-bars glyph as the
334
+ shell nav's, because the two rails are one idea ("a navigation bar folds"). */}
335
+ <div className="cg-views-top">
336
+ <button
337
+ type="button"
338
+ className="cg-rail-toggle"
339
+ aria-label={railShut ? "Expand views" : "Minimize views"}
340
+ aria-expanded={!railShut}
341
+ title={railShut ? "Expand views" : "Minimize views"}
342
+ onClick={toggleRail}
343
+ >
344
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
345
+ <path
346
+ d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
347
+ stroke="currentColor"
348
+ strokeWidth="1.35"
349
+ strokeLinecap="round"
350
+ />
351
+ </svg>
352
+ </button>
353
+ </div>
354
  {/*
355
  I13 β€” the "Views / All changes saved" header block is GONE and everything moved up.
356
 
 
388
  </button>
389
  </div>
390
 
391
+ {/* Owner item 9 β€” "Find a view". Borderless on the white rail (no box until it is
392
+ being used); focus paints the brand-purple outline. Clearing the query restores
393
+ the full rail β€” filtering never persists. */}
394
+ <div className="cg-find-view">
395
+ <svg
396
+ className="cg-find-view-icon"
397
+ width="14"
398
+ height="14"
399
+ viewBox="0 0 16 16"
400
+ fill="none"
401
+ aria-hidden="true"
402
+ >
403
+ <circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" />
404
+ <path
405
+ d="m10.4 10.4 3.1 3.1"
406
+ stroke="currentColor"
407
+ strokeWidth="1.35"
408
+ strokeLinecap="round"
409
+ />
410
+ </svg>
411
+ <input
412
+ type="text"
413
+ value={viewQuery}
414
+ placeholder="Find a view"
415
+ aria-label="Find a view"
416
+ onChange={(event) => setViewQuery(event.target.value)}
417
+ onKeyDown={(event) => {
418
+ if (event.key === "Escape") setViewQuery("");
419
+ }}
420
+ />
421
+ </div>
422
+
423
  {creating && (
424
  <div className="cg-view-create cg-create-form">
425
  <label htmlFor="cg-new-view">
 
626
  </AnchoredOverlay>
627
  )}
628
 
629
+ <div className="cg-view-list cg-views-scroll">
630
  {groups.map((group) => {
631
  const gid = group.folder?.id ?? null;
632
+ // Item 9 β€” while a search is live, a folder with no matches is noise, not a target.
633
+ if (viewNeedle && group.folder && group.items.length === 0) return null;
634
  const isRoot = gid === null;
635
  const shut = gid != null && collapsed.has(gid);
636
  return (
web/src/customer-grid/cells.ts CHANGED
@@ -46,14 +46,17 @@ export { checkboxOn, dateTimeText, formatDisplay } from "./display";
46
  * its colour across rows, sessions and users without anyone picking one β€” and a renamed option
47
  * simply gets a new colour rather than inheriting a stale mapping. */
48
  const PICK_TINTS = [
49
- // Wave-8 I7c: C1's cycle is blue -> green -> yellow -> red, "then 20%-darkened
50
- // variants". Realised as two passes over the same four hues -- the TINTS with a
51
- // measured `-deep` label, then the full PASTELS with ink. Eight stable pills,
52
- // every pairing >= 4.9:1 (ink on a pastel measures 7.9-11.1).
53
- { bg: "#EDF3FD", fg: "#4F6079" }, { bg: "#EBF6EF", fg: "#35754E" },
54
- { bg: "#FBF4E0", fg: "#7E6428" }, { bg: "#FCEEEC", fg: "#A3453C" },
55
- { bg: "#9DBFF2", fg: "#202433" }, { bg: "#A5D8B4", fg: "#202433" },
56
- { bg: "#F5D989", fg: "#202433" }, { bg: "#F0A8A0", fg: "#202433" },
 
 
 
57
  ];
58
 
59
  export function pickTint(v: string): { bg: string; fg: string } {
@@ -153,17 +156,30 @@ export function makeCell(field: Field, v: CellValue, editable: boolean): GridCel
153
  contentAlign: "right",
154
  ...ro,
155
  };
156
- case "formula":
157
  // Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
158
  // CustomerGrid's computedRows. Read-only by nature β€” the caller passes editable=false.
 
 
 
 
 
 
 
 
 
 
 
 
159
  return {
160
  kind: GridCellKind.Number,
161
- data: blank ? undefined : num(v),
162
- displayData: blank ? "" : numberText(num(v), field.format),
163
  contentAlign: "right",
164
  allowOverlay: false,
165
  readonly: true,
166
  };
 
167
  case "pct":
168
  return {
169
  kind: GridCellKind.Number,
 
46
  * its colour across rows, sessions and users without anyone picking one β€” and a renamed option
47
  * simply gets a new colour rather than inheriting a stale mapping. */
48
  const PICK_TINTS = [
49
+ // 2026-07-31 (owner item 7): ONE construction β€” every pill is a `-tint` wash carrying its
50
+ // family's `-deep` ink, the standing brand rule ("pastels are fills; text takes the deep
51
+ // weight"). The old second pass (full pastel + near-black ink) is GONE: it is what read as
52
+ // "black font on a saturated chip". Six families now β€” the four C1 hues, the brand purple,
53
+ // and a neutral grey β€” all mirrored from index.css tokens; every pairing measures β‰₯ 5.0:1.
54
+ { bg: "#EDF3FD", fg: "#4F6079" }, // blue-tint / LP_BLUE_TEXT
55
+ { bg: "#EBF6EF", fg: "#35754E" }, // green-tint / green-deep
56
+ { bg: "#FBF4E0", fg: "#7E6428" }, // yellow-tint/ yellow-deep
57
+ { bg: "#FCEEEC", fg: "#A3453C" }, // red-tint / red-deep
58
+ { bg: "#F1EEFB", fg: "#6B57A8" }, // purple-tint/ purple-deep (--lp-purple family)
59
+ { bg: "#EEF1F4", fg: "#4B5563" }, // neutral wash / slate β€” the sixth distinct family
60
  ];
61
 
62
  export function pickTint(v: string): { bg: string; fg: string } {
 
156
  contentAlign: "right",
157
  ...ro,
158
  };
159
+ case "formula": {
160
  // Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
161
  // CustomerGrid's computedRows. Read-only by nature β€” the caller passes editable=false.
162
+ // 2026-07-31 (owner item 2): a formula may now return TEXT (CONCATENATE, &, TEXT(),
163
+ // TRUE/FALSE) β€” a non-numeric result renders as a text cell, never as NaN.
164
+ const asNum = num(v);
165
+ if (!blank && typeof v === "string" && !Number.isFinite(asNum)) {
166
+ return {
167
+ kind: GridCellKind.Text,
168
+ data: v,
169
+ displayData: v,
170
+ allowOverlay: false,
171
+ readonly: true,
172
+ };
173
+ }
174
  return {
175
  kind: GridCellKind.Number,
176
+ data: blank ? undefined : asNum,
177
+ displayData: blank ? "" : numberText(asNum, field.format),
178
  contentAlign: "right",
179
  allowOverlay: false,
180
  readonly: true,
181
  };
182
+ }
183
  case "pct":
184
  return {
185
  kind: GridCellKind.Number,
web/src/customer-grid/formulaEngine.ts CHANGED
@@ -1,40 +1,49 @@
1
  // ---------------------------------------------------------------------------
2
  // customer-grid / formulaEngine.ts
3
- // Wave-5 I-B β€” the FORMULA field's engine. A pure module: no React, no DOM,
4
- // no imports from the rest of the grid, so it is testable under plain node
5
- // and portable into the wave-4 shell unchanged.
6
  //
7
- // GRAMMAR (v1, the shared contract β€” grid-wave2-split.md Β§WAVE 5):
8
  // numbers 1 0.5 .5 1200.75 (no separators, no exponents)
 
 
9
  // field refs {revenue_ytd} (slug charset [A-Za-z0-9_])
10
- // arithmetic + - * / ( ) (usual precedence, unary -)
11
- // functions ABS(x) ROUND(x[, p]) MIN(a, …) MAX(a, …) IF(c, a[, b])
12
- // comparisons < <= > >= = != (== accepted as =) β€” meaningful inside
13
- // IF's condition; a comparison anywhere else parses, but a
14
- // boolean is not a number, so the cell is BLANK.
 
 
 
 
 
 
 
 
15
  //
16
- // THE ONE RULE, applied everywhere: **any error β†’ BLANK, never a wrong
17
- // number.** A blank operand, an unknown ref, a divide-by-zero, a boolean fed
18
- // to arithmetic, a non-finite result β€” every one of them yields null, and
19
- // null propagates. Inventing 0 for a missing value is how a wrong number gets
20
- // a confident face (the falsy-`0` and NULL-sum lessons, [[cg-filter-engine-
21
- // sql-port]] / [[cg-measure-conditions]]); this engine refuses instead.
22
  //
23
- // Deliberate decisions, each asserted by the gate:
24
- // - BLANKS PROPAGATE: {a} + {b} with b blank is blank, and MIN/MAX with any
25
- // blank argument are blank. Coercing blank to 0 would state a number
26
- // nobody entered. (Airtable is looser here; we are not.)
27
- // - IF IS LAZY: only the taken branch evaluates, so the guard pattern
28
- // IF({x} = 0, 0, 1 / {x}) works β€” the whole reason IF exists in v1.
29
- // - IF's condition must be a BOOLEAN. IF({x}, …) does not coerce β€” there is
30
- // no truthiness in a financial formula.
31
- // - ROUND is HALF-AWAY-FROM-ZERO (Airtable's convention): ROUND(2.5) = 3,
32
- // ROUND(-2.5) = -3. This is a client-only display value with no Python
33
- // mirror by design (a second engine would be filter_sql-class drift), so
34
- // Airtable's convention wins over Python's banker's rounding.
35
- // - No formula-over-formula: the evaluator's getter serves RAW row values,
36
- // so a ref to another formula column finds nothing and the cell is blank.
37
- // Cycles are impossible by construction rather than by detection.
38
  // ---------------------------------------------------------------------------
39
 
40
  /** Source-length cap. The host's structural validation mirrors it; both sides
@@ -47,28 +56,75 @@ export const MAX_FORMULA_DEPTH = 24;
47
 
48
  export type FormulaAst =
49
  | { t: "num"; v: number }
 
 
50
  | { t: "ref"; k: string }
51
  | { t: "neg"; e: FormulaAst }
52
- | { t: "bin"; op: "+" | "-" | "*" | "/"; l: FormulaAst; r: FormulaAst }
53
  | { t: "cmp"; op: "<" | "<=" | ">" | ">=" | "=" | "!="; l: FormulaAst; r: FormulaAst }
54
  | { t: "call"; fn: FormulaFn; args: FormulaAst[] };
55
 
56
- export type FormulaFn = "ABS" | "ROUND" | "MIN" | "MAX" | "IF";
 
 
 
 
 
 
 
 
57
 
58
  const FN_ARITY: Record<FormulaFn, [number, number]> = {
59
  ABS: [1, 1],
60
  ROUND: [1, 2],
61
- MIN: [1, 10],
62
- MAX: [1, 10],
 
 
 
 
 
 
 
 
 
 
 
 
63
  IF: [2, 3],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  };
65
 
 
 
 
66
  export type ParseResult =
67
  | { ok: true; ast: FormulaAst; refs: string[] }
68
  | { ok: false; error: string };
69
 
70
  type Token =
71
  | { t: "num"; v: number }
 
72
  | { t: "ref"; k: string }
73
  | { t: "ident"; v: string }
74
  | { t: "op"; v: string };
@@ -97,6 +153,27 @@ function tokenize(src: string): Token[] | string {
97
  i = end + 1;
98
  continue;
99
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  // number: 1 1.5 .5 (a trailing dot β€” "5." β€” is refused)
101
  if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) {
102
  const m = /^(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)/.exec(src.slice(i));
@@ -107,21 +184,21 @@ function tokenize(src: string): Token[] | string {
107
  i += m[0].length;
108
  continue;
109
  }
110
- // function names
111
  if (/[A-Za-z_]/.test(c)) {
112
  const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i))!;
113
  out.push({ t: "ident", v: m[0] });
114
  i += m[0].length;
115
  continue;
116
  }
117
- // operators β€” two-char first
118
  const two = src.slice(i, i + 2);
119
- if (two === "<=" || two === ">=" || two === "!=" || two === "==") {
120
- out.push({ t: "op", v: two === "==" ? "=" : two });
121
  i += 2;
122
  continue;
123
  }
124
- if ("+-*/(),<>=".includes(c)) {
125
  out.push({ t: "op", v: c });
126
  i += 1;
127
  continue;
@@ -133,11 +210,14 @@ function tokenize(src: string): Token[] | string {
133
 
134
  /**
135
  * Recursive-descent parser. Grammar, lowest binding first:
136
- * expr := additive ((< <= > >= = !=) additive)? -- ONE comparison, no chains
 
137
  * additive:= mult ((+|-) mult)*
138
- * mult := unary ((*|/) unary)*
 
 
139
  * unary := "-" unary | primary
140
- * primary := number | ref | "(" expr ")" | FN "(" expr ("," expr)* ")"
141
  */
142
  export function parseFormula(src: string): ParseResult {
143
  if (typeof src !== "string" || src.trim() === "")
@@ -164,12 +244,12 @@ export function parseFormula(src: string): ParseResult {
164
 
165
  function parseExpr(depth: number): FormulaAst | null {
166
  if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
167
- const left = parseAdditive(depth);
168
  if (!left) return null;
169
  const t = peek();
170
  if (t?.t === "op" && CMP_OPS.has(t.v)) {
171
  pos += 1;
172
- const right = parseAdditive(depth);
173
  if (!right) return null;
174
  const again = peek();
175
  if (again?.t === "op" && CMP_OPS.has(again.v))
@@ -179,6 +259,18 @@ export function parseFormula(src: string): ParseResult {
179
  return left;
180
  }
181
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  function parseAdditive(depth: number): FormulaAst | null {
183
  let node = parseMult(depth);
184
  if (!node) return null;
@@ -194,19 +286,32 @@ export function parseFormula(src: string): ParseResult {
194
  }
195
 
196
  function parseMult(depth: number): FormulaAst | null {
197
- let node = parseUnary(depth);
198
  if (!node) return null;
199
  for (;;) {
200
  if (isOp("*") || isOp("/")) {
201
  const op = (peek() as { v: "*" | "/" }).v;
202
  pos += 1;
203
- const r = parseUnary(depth);
204
  if (!r) return null;
205
  node = { t: "bin", op, l: node, r };
206
  } else return node;
207
  }
208
  }
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  function parseUnary(depth: number): FormulaAst | null {
211
  if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
212
  if (isOp("-")) {
@@ -225,6 +330,10 @@ export function parseFormula(src: string): ParseResult {
225
  pos += 1;
226
  return { t: "num", v: t.v };
227
  }
 
 
 
 
228
  if (t.t === "ref") {
229
  pos += 1;
230
  return { t: "ref", k: t.k };
@@ -238,9 +347,14 @@ export function parseFormula(src: string): ParseResult {
238
  return inner;
239
  }
240
  if (t.t === "ident") {
241
- const fn = t.v.toUpperCase() as FormulaFn;
 
 
 
 
 
242
  if (!(fn in FN_ARITY))
243
- return fail(`unknown function ${t.v.toUpperCase()} β€” use ABS, ROUND, MIN, MAX or IF`);
244
  pos += 1;
245
  if (!isOp("(")) return fail(`${fn} must be called with parentheses: ${fn}(…)`);
246
  pos += 1;
@@ -277,7 +391,7 @@ export function parseFormula(src: string): ParseResult {
277
  const t = toks[pos];
278
  return {
279
  ok: false,
280
- error: `unexpected "${t.t === "op" || t.t === "ident" ? (t as { v: string }).v : t.t === "ref" ? `{${(t as { k: string }).k}}` : String((t as { v: number }).v)}" after the formula`,
281
  };
282
  }
283
  return { ok: true, ast, refs: collectRefs(ast) };
@@ -310,8 +424,15 @@ export function collectRefs(ast: FormulaAst): string[] {
310
  return out;
311
  }
312
 
313
- /** number | boolean | null inside the tree; the CELL result is number | null. */
314
- type Value = number | boolean | null;
 
 
 
 
 
 
 
315
 
316
  /**
317
  * A cell value coerced for arithmetic. Strict on purpose:
@@ -334,10 +455,27 @@ export function toOperand(raw: unknown): number | null {
334
  }
335
 
336
  function asNumber(v: Value): number | null {
337
- return typeof v === "number" && Number.isFinite(v) ? v : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  }
339
 
340
- /** Airtable's ROUND: half away from zero, precision may be negative (tens). */
341
  function roundHalfAway(x: number, p: number): number | null {
342
  if (!Number.isInteger(p) || p < -10 || p > 10) return null;
343
  const m = Math.pow(10, p);
@@ -346,20 +484,87 @@ function roundHalfAway(x: number, p: number): number | null {
346
  return Number.isFinite(out) ? out : null;
347
  }
348
 
349
- function evalNode(n: FormulaAst, get: (key: string) => unknown): Value {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  switch (n.t) {
351
  case "num":
352
  return n.v;
353
- case "ref":
354
- return toOperand(get(n.k));
 
 
 
 
 
 
 
 
 
 
355
  case "neg": {
356
- const v = asNumber(evalNode(n.e, get));
357
  return v == null ? null : -v;
358
  }
359
  case "bin": {
360
- const l = asNumber(evalNode(n.l, get));
 
 
 
 
361
  if (l == null) return null;
362
- const r = asNumber(evalNode(n.r, get));
363
  if (r == null) return null;
364
  let out: number;
365
  switch (n.op) {
@@ -370,54 +575,196 @@ function evalNode(n: FormulaAst, get: (key: string) => unknown): Value {
370
  if (r === 0) return null;
371
  out = l / r;
372
  break;
 
 
 
373
  }
374
  return Number.isFinite(out) ? out : null;
375
  }
376
  case "cmp": {
377
- const l = asNumber(evalNode(n.l, get));
378
- if (l == null) return null;
379
- const r = asNumber(evalNode(n.r, get));
380
- if (r == null) return null;
381
- switch (n.op) {
382
- case "<": return l < r;
383
- case "<=": return l <= r;
384
- case ">": return l > r;
385
- case ">=": return l >= r;
386
- case "=": return l === r;
387
- case "!=": return l !== r;
 
 
 
388
  }
 
389
  return null;
390
  }
391
  case "call": {
392
  switch (n.fn) {
393
  case "ABS": {
394
- const v = asNumber(evalNode(n.args[0], get));
395
  return v == null ? null : Math.abs(v);
396
  }
397
- case "ROUND": {
398
- const v = asNumber(evalNode(n.args[0], get));
 
 
399
  if (v == null) return null;
400
- const p = n.args.length > 1 ? asNumber(evalNode(n.args[1], get)) : 0;
401
- if (p == null) return null;
402
- return roundHalfAway(v, p);
 
 
 
 
 
 
 
403
  }
404
- case "MIN":
405
- case "MAX": {
406
- const vals: number[] = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  for (const a of n.args) {
408
- const v = asNumber(evalNode(a, get));
409
- if (v == null) return null; // blanks propagate β€” see the header
410
- vals.push(v);
411
  }
 
 
 
 
 
 
412
  return n.fn === "MIN" ? Math.min(...vals) : Math.max(...vals);
413
  }
414
  case "IF": {
415
- const c = evalNode(n.args[0], get);
416
  if (typeof c !== "boolean") return null; // no truthiness
417
  // LAZY: only the taken branch runs β€” IF({x} = 0, 0, 1/{x}) is the
418
  // guard pattern this exists for.
419
- if (c) return evalNode(n.args[1], get);
420
- return n.args.length > 2 ? evalNode(n.args[2], get) : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  }
422
  }
423
  return null;
@@ -427,46 +774,115 @@ function evalNode(n: FormulaAst, get: (key: string) => unknown): Value {
427
  }
428
  }
429
 
 
 
 
 
 
 
 
 
 
 
 
430
  /**
431
- * Evaluate a parsed formula over one row's values. Returns a FINITE number or
432
- * null (blank). A boolean result is null β€” a comparison is a question, not a
433
- * cell value. NEVER throws, whatever the inputs: the belt around the whole
434
- * engine, because a cell renderer that throws takes the grid with it.
435
  */
436
  export function evalFormula(
437
  ast: FormulaAst,
438
- get: (key: string) => unknown
439
- ): number | null {
 
440
  try {
441
- const v = evalNode(ast, get);
442
- return typeof v === "number" && Number.isFinite(v) ? v : null;
 
 
 
443
  } catch {
444
  return null;
445
  }
446
  }
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  /**
449
  * Create-form validation: parse + check every ref against the fields that
450
- * exist. `formulaKeys` are refused outright β€” the evaluator reads RAW row
451
- * values, so a formula-over-formula would silently be blank; saying so at
452
- * authoring time is the honest version of that constraint.
 
 
453
  */
454
  export function validateFormula(
455
  src: string,
456
  knownKeys: ReadonlySet<string>,
457
- formulaKeys: ReadonlySet<string>
 
458
  ): { ok: boolean; error?: string; refs: string[] } {
459
  const parsed = parseFormula(src);
460
  if (!parsed.ok) return { ok: false, error: parsed.error, refs: [] };
461
  for (const k of parsed.refs) {
462
- if (formulaKeys.has(k))
 
 
463
  return {
464
  ok: false,
465
- error: `{${k}} is another formula field β€” formulas may only reference data fields`,
 
 
 
 
 
 
 
 
 
 
 
466
  refs: parsed.refs,
467
  };
468
- if (!knownKeys.has(k))
469
- return { ok: false, error: `{${k}} is not a field of this table`, refs: parsed.refs };
470
  }
471
  return { ok: true, refs: parsed.refs };
472
  }
 
1
  // ---------------------------------------------------------------------------
2
  // customer-grid / formulaEngine.ts
3
+ // Wave-5 I-B, grown to EXCEL GRADE 2026-07-31 (owner item 2). A pure module:
4
+ // no React, no DOM, no imports from the rest of the grid, so it is testable
5
+ // under plain node and portable into the shell unchanged.
6
  //
7
+ // GRAMMAR (v2 β€” the shared contract; v1 remains a strict subset):
8
  // numbers 1 0.5 .5 1200.75 (no separators, no exponents)
9
+ // strings "text" with "" escaping a quote β€” Excel's own rule
10
+ // booleans TRUE FALSE (bare constants)
11
  // field refs {revenue_ytd} (slug charset [A-Za-z0-9_])
12
+ // arithmetic + - * / ^ ( ) (usual precedence; ^ is
13
+ // right-associative and UNARY MINUS BINDS TIGHTER, Excel's
14
+ // famous quirk: -2^2 = 4)
15
+ // text & (concatenation; blank -> "")
16
+ // comparisons < <= > >= = != (== and <> accepted) β€” numbers with
17
+ // numbers, text with text (case-insensitive, Excel's rule);
18
+ // a mixed or blank comparison is BLANK, never a guess.
19
+ // functions numeric ABS ROUND ROUNDUP ROUNDDOWN INT MOD SQRT POWER
20
+ // EXP LN SUM AVERAGE COUNT COUNTA MIN MAX
21
+ // logic IF AND OR NOT IFERROR ISBLANK
22
+ // text CONCATENATE/CONCAT LEFT RIGHT MID LEN TRIM
23
+ // UPPER LOWER PROPER VALUE TEXT
24
+ // date TODAY YEAR MONTH DAY DAYS (ISO YYYY-MM-DD)
25
  //
26
+ // THE ONE RULE, unchanged: **any error β†’ BLANK, never a wrong number.** An
27
+ // unknown ref, a divide-by-zero, a boolean fed to arithmetic, an unreadable
28
+ // date, an unknown TEXT() format β€” every one yields null, and null propagates
29
+ // through ARITHMETIC. Inventing 0 for a missing value is how a wrong number
30
+ // gets a confident face; this engine refuses instead.
 
31
  //
32
+ // Where v2 deliberately follows EXCEL rather than v1's stricter reading:
33
+ // - THE AGGREGATES SKIP BLANKS. SUM/AVERAGE/COUNT/COUNTA/MIN/MAX ignore
34
+ // blank arguments exactly as Excel ignores empty cells β€” that selectivity
35
+ // is the entire reason the aggregate forms exist beside `+`. (v1's
36
+ // MIN/MAX-propagate-blank rule is superseded; `+` still propagates.)
37
+ // - TEXT CONTEXT COERCES BLANK TO "". `{a} & {b}` with b blank is a, as in
38
+ // Excel. Text is display, not arithmetic β€” "" states nothing false.
39
+ // - A BOOLEAN RESULT DISPLAYS AS TRUE/FALSE (v1 blanked it). A comparison
40
+ // the user typed deserves its answer on screen.
41
+ // - FORMULA-OVER-FORMULA IS ALLOWED. Evaluation is topological
42
+ // (`orderFormulas`); a CYCLE refuses at authoring time and every member
43
+ // of one evaluates to blank β€” never a stale or half-updated number.
44
+ // - IF's condition must still be a real BOOLEAN (no numeric truthiness):
45
+ // this is a financial surface, and 0-means-false is a silent wrong branch.
46
+ // - ROUND stays HALF-AWAY-FROM-ZERO (Excel's convention too).
47
  // ---------------------------------------------------------------------------
48
 
49
  /** Source-length cap. The host's structural validation mirrors it; both sides
 
56
 
57
  export type FormulaAst =
58
  | { t: "num"; v: number }
59
+ | { t: "str"; v: string }
60
+ | { t: "bool"; v: boolean }
61
  | { t: "ref"; k: string }
62
  | { t: "neg"; e: FormulaAst }
63
+ | { t: "bin"; op: "+" | "-" | "*" | "/" | "^" | "&"; l: FormulaAst; r: FormulaAst }
64
  | { t: "cmp"; op: "<" | "<=" | ">" | ">=" | "=" | "!="; l: FormulaAst; r: FormulaAst }
65
  | { t: "call"; fn: FormulaFn; args: FormulaAst[] };
66
 
67
+ export type FormulaFn =
68
+ | "ABS" | "ROUND" | "ROUNDUP" | "ROUNDDOWN" | "INT" | "MOD" | "SQRT" | "POWER"
69
+ | "EXP" | "LN" | "SUM" | "AVERAGE" | "COUNT" | "COUNTA" | "MIN" | "MAX"
70
+ | "IF" | "AND" | "OR" | "NOT" | "IFERROR" | "ISBLANK"
71
+ | "CONCATENATE" | "LEFT" | "RIGHT" | "MID" | "LEN" | "TRIM"
72
+ | "UPPER" | "LOWER" | "PROPER" | "VALUE" | "TEXT"
73
+ | "TODAY" | "YEAR" | "MONTH" | "DAY" | "DAYS";
74
+
75
+ const MANY = 30; // variadic cap β€” bounded, far above any honest use
76
 
77
  const FN_ARITY: Record<FormulaFn, [number, number]> = {
78
  ABS: [1, 1],
79
  ROUND: [1, 2],
80
+ ROUNDUP: [1, 2],
81
+ ROUNDDOWN: [1, 2],
82
+ INT: [1, 1],
83
+ MOD: [2, 2],
84
+ SQRT: [1, 1],
85
+ POWER: [2, 2],
86
+ EXP: [1, 1],
87
+ LN: [1, 1],
88
+ SUM: [1, MANY],
89
+ AVERAGE: [1, MANY],
90
+ COUNT: [1, MANY],
91
+ COUNTA: [1, MANY],
92
+ MIN: [1, MANY],
93
+ MAX: [1, MANY],
94
  IF: [2, 3],
95
+ AND: [1, MANY],
96
+ OR: [1, MANY],
97
+ NOT: [1, 1],
98
+ IFERROR: [2, 2],
99
+ ISBLANK: [1, 1],
100
+ CONCATENATE: [1, MANY],
101
+ LEFT: [1, 2],
102
+ RIGHT: [1, 2],
103
+ MID: [3, 3],
104
+ LEN: [1, 1],
105
+ TRIM: [1, 1],
106
+ UPPER: [1, 1],
107
+ LOWER: [1, 1],
108
+ PROPER: [1, 1],
109
+ VALUE: [1, 1],
110
+ TEXT: [2, 2],
111
+ TODAY: [0, 0],
112
+ YEAR: [1, 1],
113
+ MONTH: [1, 1],
114
+ DAY: [1, 1],
115
+ DAYS: [2, 2],
116
  };
117
 
118
+ /** Excel spells it CONCAT in modern versions; both names, one function. */
119
+ const FN_ALIASES: Record<string, FormulaFn> = { CONCAT: "CONCATENATE" };
120
+
121
  export type ParseResult =
122
  | { ok: true; ast: FormulaAst; refs: string[] }
123
  | { ok: false; error: string };
124
 
125
  type Token =
126
  | { t: "num"; v: number }
127
+ | { t: "str"; v: string }
128
  | { t: "ref"; k: string }
129
  | { t: "ident"; v: string }
130
  | { t: "op"; v: string };
 
153
  i = end + 1;
154
  continue;
155
  }
156
+ // "string" β€” Excel's own escape: "" inside a string is one quote
157
+ if (c === '"') {
158
+ let j = i + 1;
159
+ let s = "";
160
+ for (;;) {
161
+ if (j >= src.length) return "a text value is missing its closing quote";
162
+ if (src[j] === '"') {
163
+ if (src[j + 1] === '"') {
164
+ s += '"';
165
+ j += 2;
166
+ continue;
167
+ }
168
+ break;
169
+ }
170
+ s += src[j];
171
+ j += 1;
172
+ }
173
+ out.push({ t: "str", v: s });
174
+ i = j + 1;
175
+ continue;
176
+ }
177
  // number: 1 1.5 .5 (a trailing dot β€” "5." β€” is refused)
178
  if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) {
179
  const m = /^(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)/.exec(src.slice(i));
 
184
  i += m[0].length;
185
  continue;
186
  }
187
+ // function names / TRUE / FALSE
188
  if (/[A-Za-z_]/.test(c)) {
189
  const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i))!;
190
  out.push({ t: "ident", v: m[0] });
191
  i += m[0].length;
192
  continue;
193
  }
194
+ // operators β€” two-char first. `<>` is Excel's not-equal; `==` tolerated as `=`.
195
  const two = src.slice(i, i + 2);
196
+ if (two === "<=" || two === ">=" || two === "!=" || two === "==" || two === "<>") {
197
+ out.push({ t: "op", v: two === "==" ? "=" : two === "<>" ? "!=" : two });
198
  i += 2;
199
  continue;
200
  }
201
+ if ("+-*/^&(),<>=".includes(c)) {
202
  out.push({ t: "op", v: c });
203
  i += 1;
204
  continue;
 
210
 
211
  /**
212
  * Recursive-descent parser. Grammar, lowest binding first:
213
+ * expr := concat ((< <= > >= = !=) concat)? -- ONE comparison, no chains
214
+ * concat := additive (& additive)* -- Excel: & binds below + -
215
  * additive:= mult ((+|-) mult)*
216
+ * mult := power ((*|/) power)*
217
+ * power := unary (^ power)? -- right-assoc; unary minus
218
+ * inside, so -2^2 = (-2)^2
219
  * unary := "-" unary | primary
220
+ * primary := number | string | TRUE | FALSE | ref | "(" expr ")" | FN "(" … ")"
221
  */
222
  export function parseFormula(src: string): ParseResult {
223
  if (typeof src !== "string" || src.trim() === "")
 
244
 
245
  function parseExpr(depth: number): FormulaAst | null {
246
  if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
247
+ const left = parseConcat(depth);
248
  if (!left) return null;
249
  const t = peek();
250
  if (t?.t === "op" && CMP_OPS.has(t.v)) {
251
  pos += 1;
252
+ const right = parseConcat(depth);
253
  if (!right) return null;
254
  const again = peek();
255
  if (again?.t === "op" && CMP_OPS.has(again.v))
 
259
  return left;
260
  }
261
 
262
+ function parseConcat(depth: number): FormulaAst | null {
263
+ let node = parseAdditive(depth);
264
+ if (!node) return null;
265
+ while (isOp("&")) {
266
+ pos += 1;
267
+ const r = parseAdditive(depth);
268
+ if (!r) return null;
269
+ node = { t: "bin", op: "&", l: node, r };
270
+ }
271
+ return node;
272
+ }
273
+
274
  function parseAdditive(depth: number): FormulaAst | null {
275
  let node = parseMult(depth);
276
  if (!node) return null;
 
286
  }
287
 
288
  function parseMult(depth: number): FormulaAst | null {
289
+ let node = parsePower(depth);
290
  if (!node) return null;
291
  for (;;) {
292
  if (isOp("*") || isOp("/")) {
293
  const op = (peek() as { v: "*" | "/" }).v;
294
  pos += 1;
295
+ const r = parsePower(depth);
296
  if (!r) return null;
297
  node = { t: "bin", op, l: node, r };
298
  } else return node;
299
  }
300
  }
301
 
302
+ function parsePower(depth: number): FormulaAst | null {
303
+ if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
304
+ const base = parseUnary(depth);
305
+ if (!base) return null;
306
+ if (isOp("^")) {
307
+ pos += 1;
308
+ const exp = parsePower(depth + 1); // right-associative: 2^3^2 = 2^(3^2)
309
+ if (!exp) return null;
310
+ return { t: "bin", op: "^", l: base, r: exp };
311
+ }
312
+ return base;
313
+ }
314
+
315
  function parseUnary(depth: number): FormulaAst | null {
316
  if (depth > MAX_FORMULA_DEPTH) return fail("formula is nested too deeply");
317
  if (isOp("-")) {
 
330
  pos += 1;
331
  return { t: "num", v: t.v };
332
  }
333
+ if (t.t === "str") {
334
+ pos += 1;
335
+ return { t: "str", v: t.v };
336
+ }
337
  if (t.t === "ref") {
338
  pos += 1;
339
  return { t: "ref", k: t.k };
 
347
  return inner;
348
  }
349
  if (t.t === "ident") {
350
+ const upper = t.v.toUpperCase();
351
+ if (upper === "TRUE" || upper === "FALSE") {
352
+ pos += 1;
353
+ return { t: "bool", v: upper === "TRUE" };
354
+ }
355
+ const fn = (FN_ALIASES[upper] ?? upper) as FormulaFn;
356
  if (!(fn in FN_ARITY))
357
+ return fail(`unknown function ${upper} β€” see the formula help for what is available`);
358
  pos += 1;
359
  if (!isOp("(")) return fail(`${fn} must be called with parentheses: ${fn}(…)`);
360
  pos += 1;
 
391
  const t = toks[pos];
392
  return {
393
  ok: false,
394
+ error: `unexpected "${t.t === "op" || t.t === "ident" ? (t as { v: string }).v : t.t === "ref" ? `{${(t as { k: string }).k}}` : String((t as { v: unknown }).v)}" after the formula`,
395
  };
396
  }
397
  return { ok: true, ast, refs: collectRefs(ast) };
 
424
  return out;
425
  }
426
 
427
+ /** number | string | boolean | null inside the tree; the CELL result is
428
+ * number | string | null (booleans display as "TRUE"/"FALSE"). */
429
+ type Value = number | string | boolean | null;
430
+
431
+ /** What TODAY() and friends resolve against. `today` is the TENANT's day
432
+ * (payload `today`), never the browser clock. */
433
+ export interface FormulaEnv {
434
+ today?: string;
435
+ }
436
 
437
  /**
438
  * A cell value coerced for arithmetic. Strict on purpose:
 
455
  }
456
 
457
  function asNumber(v: Value): number | null {
458
+ if (typeof v === "number" && Number.isFinite(v)) return v;
459
+ if (typeof v === "string") return toOperand(v);
460
+ return null;
461
+ }
462
+
463
+ /** Text context: blank -> "" (Excel), numbers in General format, booleans as
464
+ * their names. Text is display, not arithmetic β€” "" states nothing false. */
465
+ function asText(v: Value): string {
466
+ if (v == null) return "";
467
+ if (typeof v === "string") return v;
468
+ if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
469
+ return numToText(v);
470
+ }
471
+
472
+ /** Excel's "General": no separators, no trailing zeros, up to ~10 dp. */
473
+ function numToText(n: number): string {
474
+ if (Number.isInteger(n)) return String(n);
475
+ return String(Number(n.toFixed(10)));
476
  }
477
 
478
+ /** Airtable/Excel ROUND: half away from zero, precision may be negative (tens). */
479
  function roundHalfAway(x: number, p: number): number | null {
480
  if (!Number.isInteger(p) || p < -10 || p > 10) return null;
481
  const m = Math.pow(10, p);
 
484
  return Number.isFinite(out) ? out : null;
485
  }
486
 
487
+ /** An ISO date's parts, or null. Accepts "YYYY-MM-DD" and anything that starts
488
+ * with it ("YYYY-MM-DD HH:MM:SS") β€” the shapes the grid's date fields hold. */
489
+ function dateParts(v: Value): { y: number; m: number; d: number } | null {
490
+ if (typeof v !== "string") return null;
491
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v.trim());
492
+ if (!m) return null;
493
+ const y = Number(m[1]);
494
+ const mo = Number(m[2]);
495
+ const d = Number(m[3]);
496
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
497
+ return { y, m: mo, d };
498
+ }
499
+
500
+ function dateSerial(p: { y: number; m: number; d: number }): number {
501
+ return Date.UTC(p.y, p.m - 1, p.d) / 86_400_000;
502
+ }
503
+
504
+ /**
505
+ * TEXT(value, format) β€” the common Excel number formats, fail-closed:
506
+ * "0" "0.00" fixed decimals
507
+ * "#,##0" "#,##0.00" thousands separators
508
+ * "$#,##0.00" … a literal currency prefix
509
+ * "0%" "0.0%" percent of 1 (0.42 -> "42%")
510
+ * An unrecognised format is BLANK, never a guess at what it meant.
511
+ */
512
+ function textFormat(n: number, fmt: string): string | null {
513
+ const m = /^(\$?)(#,##0|0)(?:\.(0+))?(%?)$/.exec(fmt.trim());
514
+ if (!m) return null;
515
+ const [, cur, intPart, decimals, pct] = m;
516
+ let x = n;
517
+ if (pct === "%") x *= 100;
518
+ const dp = decimals ? decimals.length : 0;
519
+ const neg = x < 0 || Object.is(x, -0) ? "-" : "";
520
+ const fixed = Math.abs(x).toFixed(dp);
521
+ let [ints, frac] = fixed.split(".");
522
+ if (intPart === "#,##0") ints = ints.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
523
+ return neg + cur + ints + (dp ? "." + frac : "") + (pct === "%" ? "%" : "");
524
+ }
525
+
526
+ /** The aggregate forms: Excel-style, blanks SKIPPED (that selectivity is why
527
+ * they exist beside `+`). Returns the numeric args that resolved. */
528
+ function numericArgs(nodes: FormulaAst[], ev: (n: FormulaAst) => Value): number[] {
529
+ const out: number[] = [];
530
+ for (const a of nodes) {
531
+ const v = ev(a);
532
+ if (v == null) continue;
533
+ const n = asNumber(v);
534
+ if (n != null) out.push(n);
535
+ }
536
+ return out;
537
+ }
538
+
539
+ function evalNode(n: FormulaAst, get: (key: string) => unknown, env: FormulaEnv): Value {
540
+ const ev = (x: FormulaAst): Value => evalNode(x, get, env);
541
  switch (n.t) {
542
  case "num":
543
  return n.v;
544
+ case "str":
545
+ return n.v;
546
+ case "bool":
547
+ return n.v;
548
+ case "ref": {
549
+ const raw = get(n.k);
550
+ // Text stays text (LEFT/&/comparisons need it); numbers stay numbers.
551
+ if (typeof raw === "string") return raw;
552
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
553
+ if (typeof raw === "boolean") return null; // a checkbox is not a value here
554
+ return null;
555
+ }
556
  case "neg": {
557
+ const v = asNumber(ev(n.e));
558
  return v == null ? null : -v;
559
  }
560
  case "bin": {
561
+ if (n.op === "&") {
562
+ // Concatenation is TEXT context: blanks become "", nothing errors.
563
+ return asText(ev(n.l)) + asText(ev(n.r));
564
+ }
565
+ const l = asNumber(ev(n.l));
566
  if (l == null) return null;
567
+ const r = asNumber(ev(n.r));
568
  if (r == null) return null;
569
  let out: number;
570
  switch (n.op) {
 
575
  if (r === 0) return null;
576
  out = l / r;
577
  break;
578
+ case "^":
579
+ out = Math.pow(l, r);
580
+ break;
581
  }
582
  return Number.isFinite(out) ? out : null;
583
  }
584
  case "cmp": {
585
+ const lv = ev(n.l);
586
+ const rv = ev(n.r);
587
+ const ln = typeof lv === "number" ? lv : null;
588
+ const rn = typeof rv === "number" ? rv : null;
589
+ // numbers with numbers (numeric strings count as numbers, Excel's coercion)…
590
+ const lAsN = ln ?? (typeof lv === "string" ? toOperand(lv) : null);
591
+ const rAsN = rn ?? (typeof rv === "string" ? toOperand(rv) : null);
592
+ if (lAsN != null && rAsN != null) return cmpNums(n.op, lAsN, rAsN);
593
+ // …text with text, case-insensitively (Excel's rule)…
594
+ if (typeof lv === "string" && typeof rv === "string") {
595
+ const a = lv.trim().toLowerCase();
596
+ const b = rv.trim().toLowerCase();
597
+ if (a === "" || b === "") return null; // a blank comparison is not an answer
598
+ return cmpNums(n.op, a < b ? -1 : a > b ? 1 : 0, 0);
599
  }
600
+ // …anything mixed or blank is BLANK, never a guess.
601
  return null;
602
  }
603
  case "call": {
604
  switch (n.fn) {
605
  case "ABS": {
606
+ const v = asNumber(ev(n.args[0]));
607
  return v == null ? null : Math.abs(v);
608
  }
609
+ case "ROUND":
610
+ case "ROUNDUP":
611
+ case "ROUNDDOWN": {
612
+ const v = asNumber(ev(n.args[0]));
613
  if (v == null) return null;
614
+ const p = n.args.length > 1 ? asNumber(ev(n.args[1])) : 0;
615
+ if (p == null || !Number.isInteger(p) || p < -10 || p > 10) return null;
616
+ if (n.fn === "ROUND") return roundHalfAway(v, p);
617
+ const m = Math.pow(10, p);
618
+ const scaled = v * m;
619
+ const r = n.fn === "ROUNDUP"
620
+ ? Math.sign(scaled) * Math.ceil(Math.abs(scaled))
621
+ : Math.sign(scaled) * Math.floor(Math.abs(scaled));
622
+ const out = r / m;
623
+ return Number.isFinite(out) ? out : null;
624
  }
625
+ case "INT": {
626
+ const v = asNumber(ev(n.args[0]));
627
+ return v == null ? null : Math.floor(v); // Excel INT floors toward -∞
628
+ }
629
+ case "MOD": {
630
+ const a = asNumber(ev(n.args[0]));
631
+ const b = asNumber(ev(n.args[1]));
632
+ if (a == null || b == null || b === 0) return null;
633
+ return a - b * Math.floor(a / b); // Excel: sign follows the divisor
634
+ }
635
+ case "SQRT": {
636
+ const v = asNumber(ev(n.args[0]));
637
+ return v == null || v < 0 ? null : Math.sqrt(v);
638
+ }
639
+ case "POWER": {
640
+ const a = asNumber(ev(n.args[0]));
641
+ const b = asNumber(ev(n.args[1]));
642
+ if (a == null || b == null) return null;
643
+ const out = Math.pow(a, b);
644
+ return Number.isFinite(out) ? out : null;
645
+ }
646
+ case "EXP": {
647
+ const v = asNumber(ev(n.args[0]));
648
+ if (v == null) return null;
649
+ const out = Math.exp(v);
650
+ return Number.isFinite(out) ? out : null;
651
+ }
652
+ case "LN": {
653
+ const v = asNumber(ev(n.args[0]));
654
+ return v == null || v <= 0 ? null : Math.log(v);
655
+ }
656
+ case "SUM": {
657
+ const vals = numericArgs(n.args, ev);
658
+ return vals.reduce((a, b) => a + b, 0);
659
+ }
660
+ case "AVERAGE": {
661
+ const vals = numericArgs(n.args, ev);
662
+ return vals.length === 0 ? null : vals.reduce((a, b) => a + b, 0) / vals.length;
663
+ }
664
+ case "COUNT":
665
+ return numericArgs(n.args, ev).length;
666
+ case "COUNTA": {
667
+ let c = 0;
668
  for (const a of n.args) {
669
+ const v = ev(a);
670
+ if (v != null && v !== "") c += 1;
 
671
  }
672
+ return c;
673
+ }
674
+ case "MIN":
675
+ case "MAX": {
676
+ const vals = numericArgs(n.args, ev);
677
+ if (vals.length === 0) return null;
678
  return n.fn === "MIN" ? Math.min(...vals) : Math.max(...vals);
679
  }
680
  case "IF": {
681
+ const c = ev(n.args[0]);
682
  if (typeof c !== "boolean") return null; // no truthiness
683
  // LAZY: only the taken branch runs β€” IF({x} = 0, 0, 1/{x}) is the
684
  // guard pattern this exists for.
685
+ if (c) return ev(n.args[1]);
686
+ return n.args.length > 2 ? ev(n.args[2]) : null;
687
+ }
688
+ case "AND":
689
+ case "OR": {
690
+ // Strict booleans, evaluated lazily left to right (Excel short-circuits too).
691
+ for (const a of n.args) {
692
+ const v = ev(a);
693
+ if (typeof v !== "boolean") return null;
694
+ if (n.fn === "AND" && !v) return false;
695
+ if (n.fn === "OR" && v) return true;
696
+ }
697
+ return n.fn === "AND";
698
+ }
699
+ case "NOT": {
700
+ const v = ev(n.args[0]);
701
+ return typeof v === "boolean" ? !v : null;
702
+ }
703
+ case "IFERROR": {
704
+ // Our errors ARE blanks (the one rule), so IFERROR is the blank-coalesce.
705
+ const v = ev(n.args[0]);
706
+ return v == null ? ev(n.args[1]) : v;
707
+ }
708
+ case "ISBLANK": {
709
+ const v = ev(n.args[0]);
710
+ return v == null || v === "";
711
+ }
712
+ case "CONCATENATE":
713
+ return n.args.map((a) => asText(ev(a))).join("");
714
+ case "LEFT":
715
+ case "RIGHT": {
716
+ const s = asText(ev(n.args[0]));
717
+ const k = n.args.length > 1 ? asNumber(ev(n.args[1])) : 1;
718
+ if (k == null || !Number.isInteger(k) || k < 0) return null;
719
+ return n.fn === "LEFT" ? s.slice(0, k) : k === 0 ? "" : s.slice(-k);
720
+ }
721
+ case "MID": {
722
+ const s = asText(ev(n.args[0]));
723
+ const start = asNumber(ev(n.args[1]));
724
+ const len = asNumber(ev(n.args[2]));
725
+ if (start == null || len == null) return null;
726
+ if (!Number.isInteger(start) || !Number.isInteger(len) || start < 1 || len < 0)
727
+ return null; // Excel: MID is 1-based and refuses a 0 start
728
+ return s.slice(start - 1, start - 1 + len);
729
+ }
730
+ case "LEN":
731
+ return asText(ev(n.args[0])).length;
732
+ case "TRIM":
733
+ return asText(ev(n.args[0])).replace(/\s+/g, " ").trim();
734
+ case "UPPER":
735
+ return asText(ev(n.args[0])).toUpperCase();
736
+ case "LOWER":
737
+ return asText(ev(n.args[0])).toLowerCase();
738
+ case "PROPER":
739
+ return asText(ev(n.args[0])).replace(
740
+ /[A-Za-z]+/g,
741
+ (w) => w[0].toUpperCase() + w.slice(1).toLowerCase()
742
+ );
743
+ case "VALUE": {
744
+ const v = ev(n.args[0]);
745
+ return asNumber(v);
746
+ }
747
+ case "TEXT": {
748
+ const v = asNumber(ev(n.args[0]));
749
+ const fmt = ev(n.args[1]);
750
+ if (v == null || typeof fmt !== "string") return null;
751
+ return textFormat(v, fmt);
752
+ }
753
+ case "TODAY":
754
+ return typeof env.today === "string" && dateParts(env.today) ? env.today : null;
755
+ case "YEAR":
756
+ case "MONTH":
757
+ case "DAY": {
758
+ const p = dateParts(ev(n.args[0]));
759
+ if (!p) return null;
760
+ return n.fn === "YEAR" ? p.y : n.fn === "MONTH" ? p.m : p.d;
761
+ }
762
+ case "DAYS": {
763
+ // Excel argument order: DAYS(end, start).
764
+ const end = dateParts(ev(n.args[0]));
765
+ const start = dateParts(ev(n.args[1]));
766
+ if (!end || !start) return null;
767
+ return Math.round(dateSerial(end) - dateSerial(start));
768
  }
769
  }
770
  return null;
 
774
  }
775
  }
776
 
777
+ function cmpNums(op: "<" | "<=" | ">" | ">=" | "=" | "!=", l: number, r: number): boolean {
778
+ switch (op) {
779
+ case "<": return l < r;
780
+ case "<=": return l <= r;
781
+ case ">": return l > r;
782
+ case ">=": return l >= r;
783
+ case "=": return l === r;
784
+ case "!=": return l !== r;
785
+ }
786
+ }
787
+
788
  /**
789
+ * Evaluate a parsed formula over one row's values. Returns a FINITE number, a
790
+ * STRING (text results, and booleans as "TRUE"/"FALSE"), or null (blank).
791
+ * NEVER throws, whatever the inputs: the belt around the whole engine, because
792
+ * a cell renderer that throws takes the grid with it.
793
  */
794
  export function evalFormula(
795
  ast: FormulaAst,
796
+ get: (key: string) => unknown,
797
+ env: FormulaEnv = {}
798
+ ): number | string | null {
799
  try {
800
+ const v = evalNode(ast, get, env);
801
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
802
+ if (typeof v === "string") return v;
803
+ if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
804
+ return null;
805
  } catch {
806
  return null;
807
  }
808
  }
809
 
810
+ /**
811
+ * Dependency order for FORMULA-OVER-FORMULA (2026-07-31). Given every formula
812
+ * field's source by key, returns the keys in an evaluation order where a
813
+ * formula runs AFTER the formulas it references, plus the set caught in a
814
+ * CYCLE β€” those evaluate to blank (their members never run), never to a stale
815
+ * or half-updated number. A formula depending on a cyclic one still runs; the
816
+ * cyclic ref simply reads blank.
817
+ */
818
+ export function orderFormulas(sources: ReadonlyMap<string, string>): {
819
+ order: string[];
820
+ cyclic: Set<string>;
821
+ } {
822
+ const deps = new Map<string, string[]>();
823
+ for (const [key, src] of sources) {
824
+ const p = parseFormula(src);
825
+ deps.set(key, p.ok ? p.refs.filter((r) => sources.has(r) && r !== key) : []);
826
+ }
827
+ const order: string[] = [];
828
+ const state = new Map<string, 0 | 1 | 2>(); // 0 visiting-guard absent, 1 = on stack, 2 = done
829
+ const cyclic = new Set<string>();
830
+ const visit = (key: string, stack: string[]): boolean => {
831
+ const s = state.get(key);
832
+ if (s === 2) return !cyclic.has(key);
833
+ if (s === 1) {
834
+ // Everything from the first appearance of `key` on the stack is the cycle.
835
+ for (let i = stack.lastIndexOf(key); i < stack.length; i += 1) cyclic.add(stack[i]);
836
+ return false;
837
+ }
838
+ state.set(key, 1);
839
+ stack.push(key);
840
+ for (const d of deps.get(key) ?? []) visit(d, stack);
841
+ stack.pop();
842
+ state.set(key, 2);
843
+ if (!cyclic.has(key)) order.push(key);
844
+ return !cyclic.has(key);
845
+ };
846
+ for (const key of sources.keys()) visit(key, []);
847
+ return { order, cyclic };
848
+ }
849
+
850
  /**
851
  * Create-form validation: parse + check every ref against the fields that
852
+ * exist. Referencing ANOTHER formula is allowed (evaluation is topological);
853
+ * what is refused is a CYCLE β€” including the self-reference, its smallest
854
+ * case. `formulaSources` carries every formula field's source so the check
855
+ * can walk the graph; `selfKey` names the field being edited (absent on
856
+ * create, where no cycle is possible yet).
857
  */
858
  export function validateFormula(
859
  src: string,
860
  knownKeys: ReadonlySet<string>,
861
+ formulaSources: ReadonlyMap<string, string>,
862
+ selfKey?: string
863
  ): { ok: boolean; error?: string; refs: string[] } {
864
  const parsed = parseFormula(src);
865
  if (!parsed.ok) return { ok: false, error: parsed.error, refs: [] };
866
  for (const k of parsed.refs) {
867
+ if (!knownKeys.has(k) && !formulaSources.has(k))
868
+ return { ok: false, error: `{${k}} is not a field of this table`, refs: parsed.refs };
869
+ if (selfKey && k === selfKey)
870
  return {
871
  ok: false,
872
+ error: "a formula cannot reference itself",
873
+ refs: parsed.refs,
874
+ };
875
+ }
876
+ if (selfKey) {
877
+ const all = new Map(formulaSources);
878
+ all.set(selfKey, src);
879
+ const { cyclic } = orderFormulas(all);
880
+ if (cyclic.has(selfKey))
881
+ return {
882
+ ok: false,
883
+ error: "this formula would create a loop between formulas",
884
  refs: parsed.refs,
885
  };
 
 
886
  }
887
  return { ok: true, refs: parsed.refs };
888
  }
web/src/customer-grid/types.ts CHANGED
@@ -1241,6 +1241,19 @@ export interface GridWorkspace {
1241
  * everything created is global, exactly today's behavior.
1242
  */
1243
  scopeChoice?: boolean;
 
 
 
 
 
 
 
 
 
 
 
 
 
1244
  }
1245
 
1246
  /**
@@ -1361,6 +1374,9 @@ export function tableMode(counts: ScopeCounts | undefined): TableMode {
1361
 
1362
  export type HostEvent =
1363
  | { id: string; type: "view_upsert"; view: SavedView }
 
 
 
1364
  /**
1365
  * "Add to list" (owner item 6, 2026-07-26): push the customers a VIEW currently matches into
1366
  * a Cohort β€” a fixed, hand-curated set that does NOT re-populate as the data moves.
 
1241
  * everything created is global, exactly today's behavior.
1242
  */
1243
  scopeChoice?: boolean;
1244
+ /**
1245
+ * 2026-07-31 (owner item 1) β€” the STANDALONE measure channel. The embed receives these as
1246
+ * top-level render args; over HTTP they ride `/api/v1/workspace` (re-read after every
1247
+ * durable write), and `useCustomerData` lifts them into the payload slots the grid already
1248
+ * reads. `derived` is keyed by String(pid) β€” JSON object keys β€” and carries the cohort
1249
+ * column + measure-column cells to merge over the rows.
1250
+ */
1251
+ measures?: Measure[];
1252
+ measureSets?: Record<string, number[]>;
1253
+ derived?: Record<string, Record<string, string | number | null>>;
1254
+ viewer?: Viewer;
1255
+ userOptions?: string[];
1256
+ overlays?: Record<string, Record<string, string>>;
1257
  }
1258
 
1259
  /**
 
1374
 
1375
  export type HostEvent =
1376
  | { id: string; type: "view_upsert"; view: SavedView }
1377
+ /** Owner item 3 (2026-07-31): the user OPENED this view β€” presentation state the host
1378
+ * remembers so a fresh browser resumes there. Never a re-render; never authorisation. */
1379
+ | { id: string; type: "view_select"; viewId: string }
1380
  /**
1381
  * "Add to list" (owner item 6, 2026-07-26): push the customers a VIEW currently matches into
1382
  * a Cohort β€” a fixed, hand-curated set that does NOT re-populate as the data moves.
web/src/customer-grid/useCustomerData.ts CHANGED
@@ -24,7 +24,7 @@
24
 
25
  import { useCallback, useEffect, useRef, useState } from "react";
26
  import type { Dispatch, SetStateAction } from "react";
27
- import type { CustomersPayload, Field, Row } from "./types";
28
  import { fetchCustomers, fetchWorkspace, patchCustomer, setSurfaceScope } from "./apiBridge";
29
  import { WORKSPACE_STALE_EVENT } from "../apiContract";
30
  import type { SurfaceScope } from "./apiBridge";
@@ -53,6 +53,30 @@ function readHostPayload(): CustomersPayload | null {
53
  : null;
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  function readInitialPayload(): CustomersPayload | null {
57
  return readHostPayload() ?? readEmbed();
58
  }
@@ -186,7 +210,7 @@ export function useCustomerData(scope: SurfaceScope = "customer"): CustomerData
186
  async function load() {
187
  const [payload, workspace] = await Promise.all([fetchCustomers(), fetchWorkspace(scope)]);
188
  if (cancelled || !payload) return;
189
- setData(workspace ? { ...payload, workspace } : payload);
190
  }
191
 
192
  load();
@@ -195,11 +219,13 @@ export function useCustomerData(scope: SurfaceScope = "customer"): CustomerData
195
  // left panel agrees with what the user just did. Only the WORKSPACE is re-read,
196
  // never `/customers`: the rows come from a 15-minute-cached Odoo pull and no grid
197
  // event can change an Odoo column (it is read-only), so refetching them would buy
198
- // nothing and cost the expensive call.
 
 
199
  async function reread() {
200
  const ws = await fetchWorkspace(scope);
201
  if (cancelled || !ws) return; // absent stays absent β€” never blank a live panel
202
- setData((prev) => (prev ? { ...prev, workspace: ws } : prev));
203
  }
204
  const onStale = () => { void reread(); };
205
  if (typeof window !== "undefined") window.addEventListener(WORKSPACE_STALE_EVENT, onStale);
 
24
 
25
  import { useCallback, useEffect, useRef, useState } from "react";
26
  import type { Dispatch, SetStateAction } from "react";
27
+ import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
28
  import { fetchCustomers, fetchWorkspace, patchCustomer, setSurfaceScope } from "./apiBridge";
29
  import { WORKSPACE_STALE_EVENT } from "../apiContract";
30
  import type { SurfaceScope } from "./apiBridge";
 
53
  : null;
54
  }
55
 
56
+ /**
57
+ * 2026-07-31 (owner item 1) β€” the STANDALONE measure channel. In the embed the host passes
58
+ * `measures`/`measureSets`/`viewer`/`userOptions` as top-level payload keys and merges derived
59
+ * cells (cohort column + measure columns) into the rows before render. Over HTTP those ride
60
+ * `/api/v1/workspace` β€” the cheap call a durable write re-reads β€” so this lift is what makes
61
+ * a measure column populate, a measure condition resolve, and the assignee picker fill,
62
+ * without refetching the heavy pool. `derived` is keyed by String(pid) (JSON object keys).
63
+ */
64
+ function withWorkspace(payload: CustomersPayload, ws: GridWorkspace): CustomersPayload {
65
+ const out: CustomersPayload = { ...payload, workspace: ws };
66
+ if (Array.isArray(ws.measures)) out.measures = ws.measures;
67
+ if (ws.measureSets && typeof ws.measureSets === "object") out.measureSets = ws.measureSets;
68
+ if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;
69
+ if (Array.isArray(ws.userOptions)) out.userOptions = ws.userOptions;
70
+ const derived = ws.derived;
71
+ if (derived && typeof derived === "object" && Object.keys(derived).length > 0) {
72
+ out.rows = payload.rows.map((r) => {
73
+ const cells = derived[String(r.pid)];
74
+ return cells ? { ...r, ...cells } : r;
75
+ });
76
+ }
77
+ return out;
78
+ }
79
+
80
  function readInitialPayload(): CustomersPayload | null {
81
  return readHostPayload() ?? readEmbed();
82
  }
 
210
  async function load() {
211
  const [payload, workspace] = await Promise.all([fetchCustomers(), fetchWorkspace(scope)]);
212
  if (cancelled || !payload) return;
213
+ setData(workspace ? withWorkspace(payload, workspace) : payload);
214
  }
215
 
216
  load();
 
219
  // left panel agrees with what the user just did. Only the WORKSPACE is re-read,
220
  // never `/customers`: the rows come from a 15-minute-cached Odoo pull and no grid
221
  // event can change an Odoo column (it is read-only), so refetching them would buy
222
+ // nothing and cost the expensive call. The measure channel rides the workspace
223
+ // (see `withWorkspace`), so a NEW measure column's values arrive on this cheap
224
+ // re-read too β€” no pool refetch.
225
  async function reread() {
226
  const ws = await fetchWorkspace(scope);
227
  if (cancelled || !ws) return; // absent stays absent β€” never blank a live panel
228
+ setData((prev) => (prev ? withWorkspace(prev, ws) : prev));
229
  }
230
  const onStale = () => { void reread(); };
231
  if (typeof window !== "undefined") window.addEventListener(WORKSPACE_STALE_EVENT, onStale);
web/src/customer-grid/useGridSelection.ts CHANGED
@@ -29,6 +29,12 @@ export interface GridSelectionApi {
29
  * "N selected Β· Add to cohort" bar serves the map with no second selection
30
  * model and no second cohort picker to keep in sync. */
31
  selectPids: (pids: number[], mode: "replace" | "add") => void;
 
 
 
 
 
 
32
  clearSelection: () => void;
33
  }
34
 
@@ -89,10 +95,28 @@ export function useGridSelection(
89
  });
90
  }, []);
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  const clearSelection = useCallback(() => {
93
  setSelectedPids(new Set());
94
  setCurrent(undefined);
95
  }, []);
96
 
97
- return { gridSelection, selectedPids, onGridSelectionChange, selectPids, clearSelection };
 
98
  }
 
29
  * "N selected Β· Add to cohort" bar serves the map with no second selection
30
  * model and no second cohort picker to keep in sync. */
31
  selectPids: (pids: number[], mode: "replace" | "add") => void;
32
+ /** Owner item 6 (2026-07-31) β€” clicking the identity cell ticks the row's checkbox: one
33
+ * pid in or out of the same pid-anchored set the markers write. */
34
+ togglePid: (pid: number) => void;
35
+ /** Owner item 5 (2026-07-31) β€” Excel-grade Enter navigation: set the ACTIVE cell
36
+ * programmatically (clamped by the caller). Positional and ephemeral, like `current`. */
37
+ setActiveCell: (col: number, row: number) => void;
38
  clearSelection: () => void;
39
  }
40
 
 
95
  });
96
  }, []);
97
 
98
+ const togglePid = useCallback((pid: number) => {
99
+ setSelectedPids((prev) => {
100
+ const next = new Set(prev);
101
+ if (next.has(pid)) next.delete(pid);
102
+ else next.add(pid);
103
+ return next;
104
+ });
105
+ }, []);
106
+
107
+ const setActiveCell = useCallback((col: number, row: number) => {
108
+ setCurrent({
109
+ cell: [col, row],
110
+ range: { x: col, y: row, width: 1, height: 1 },
111
+ rangeStack: [],
112
+ });
113
+ }, []);
114
+
115
  const clearSelection = useCallback(() => {
116
  setSelectedPids(new Set());
117
  setCurrent(undefined);
118
  }, []);
119
 
120
+ return { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
121
+ setActiveCell, clearSelection };
122
  }
web/src/index.css CHANGED
@@ -102,6 +102,11 @@
102
  --lp-r-md: 6px; /* buttons, cards β€” the NEW default (was 4px) */
103
  --lp-r-lg: 10px; /* dialogs, panels */
104
  --lp-r-pill: 999px;
 
 
 
 
 
105
  --lp-surface: #ffffff;
106
  --lp-surface-2: #f7f7f6; /* warm-neutral; was #f4f6f9 (cool) */
107
 
@@ -215,18 +220,173 @@ body {
215
  font-size: var(--lp-fs-sm);
216
  }
217
 
218
- /* Saved-view rail */
 
219
  .cg-views {
220
- flex: 0 0 188px;
221
- width: 188px;
222
- min-width: 188px;
223
  height: 100%;
224
- padding: 10px 8px;
225
  border-right: 1px solid var(--lp-line);
226
- background: var(--cg-panel);
227
  color: var(--cg-text);
228
  user-select: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  }
 
230
  .cg-views-head {
231
  min-height: 44px;
232
  display: flex;
@@ -281,7 +441,7 @@ body {
281
  border-radius: var(--lp-r-md);
282
  }
283
  .cg-view-row:hover {
284
- background: #eef1f4;
285
  }
286
  .cg-view-row.is-active {
287
  background: var(--lp-blue-tint);
@@ -2096,11 +2256,72 @@ body {
2096
  .shell-side {
2097
  display: flex;
2098
  flex-direction: column;
2099
- width: 254px;
2100
- flex: 0 0 254px;
2101
  background: var(--lp-surface);
2102
  border-right: 1px solid var(--lp-line);
2103
  overflow-y: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2104
  }
2105
 
2106
  .shell-brand { display: flex; align-items: center; gap: 9px; padding: 18px 16px 14px; }
@@ -3406,13 +3627,15 @@ a.cg-map-ctl-b { text-decoration: none; }
3406
  /* I14 β€” the two create doors, stacked and LEFT-ALIGNED (the owner's word). `.cg-link-btn`
3407
  is a <button>, so it centres its label by default; every other use of that class sits in
3408
  a centred footer, which is why the alignment is set here and not on the shared class. */
3409
- .cg-create-new { padding: 2px 8px 2px; }
 
 
3410
  .cg-create-btn {
3411
  width: 100%;
3412
  text-align: left;
3413
- padding: 0 6px;
3414
  color: var(--lp-ink);
3415
- font-weight: 550;
3416
  }
3417
  /* Wave-10 item 13 β€” the trigger stays HELD while its flyout is open. Driven off the
3418
  `aria-expanded` the button already publishes, so the visual state cannot drift from the
 
102
  --lp-r-md: 6px; /* buttons, cards β€” the NEW default (was 4px) */
103
  --lp-r-lg: 10px; /* dialogs, panels */
104
  --lp-r-pill: 999px;
105
+ /* Owner item 12 (2026-07-31): ONE width for both rails β€” the shell navigation and the
106
+ views rail are the two halves of one chrome, and asymmetry read as unfinished. The
107
+ collapsed strip is the item-11 minimize target (both rails fold to it). */
108
+ --lp-rail-w: 240px;
109
+ --lp-rail-min: 48px;
110
  --lp-surface: #ffffff;
111
  --lp-surface-2: #f7f7f6; /* warm-neutral; was #f4f6f9 (cool) */
112
 
 
220
  font-size: var(--lp-fs-sm);
221
  }
222
 
223
+ /* Saved-view rail β€” the SECOND navigation bar (owner items 10/11/12, 2026-07-31): same
224
+ width as the shell nav, same white surface, same fold-to-strip minimize. */
225
  .cg-views {
226
+ flex: 0 0 var(--lp-rail-w);
227
+ width: var(--lp-rail-w);
228
+ min-width: var(--lp-rail-w);
229
  height: 100%;
230
+ padding: 6px 8px 10px;
231
  border-right: 1px solid var(--lp-line);
232
+ background: var(--lp-surface);
233
  color: var(--cg-text);
234
  user-select: none;
235
+ display: flex;
236
+ flex-direction: column;
237
+ overflow: hidden;
238
+ }
239
+ .cg-views.is-collapsed {
240
+ flex-basis: var(--lp-rail-min);
241
+ width: var(--lp-rail-min);
242
+ min-width: var(--lp-rail-min);
243
+ padding: 6px 6px 10px;
244
+ }
245
+ .cg-views.is-collapsed > :not(.cg-views-top) {
246
+ display: none;
247
+ }
248
+ .cg-views-top {
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: flex-end;
252
+ padding: 0 0 2px;
253
+ }
254
+ .cg-rail-toggle {
255
+ width: 28px;
256
+ height: 28px;
257
+ display: inline-flex;
258
+ align-items: center;
259
+ justify-content: center;
260
+ border: none;
261
+ border-radius: var(--lp-r-md);
262
+ background: transparent;
263
+ color: var(--lp-muted);
264
+ cursor: pointer;
265
+ }
266
+ .cg-rail-toggle:hover {
267
+ background: var(--lp-surface-2);
268
+ color: var(--lp-ink);
269
+ }
270
+ .cg-rail-toggle:focus-visible {
271
+ outline: 2px solid var(--lp-blue-deep);
272
+ outline-offset: 1px;
273
+ }
274
+ /* Owner item 9 β€” "Find a view": borderless on the white rail; the outline appears only on
275
+ focus, and it is the brand purple by the owner's explicit ask. The 8-state discipline
276
+ holds β€” no border at rest means no border-width change on focus; state is the outline. */
277
+ .cg-find-view {
278
+ position: relative;
279
+ margin: 0 2px 6px;
280
+ }
281
+ .cg-find-view-icon {
282
+ position: absolute;
283
+ left: 8px;
284
+ top: 50%;
285
+ transform: translateY(-50%);
286
+ color: var(--lp-muted);
287
+ pointer-events: none;
288
+ }
289
+ .cg-find-view input {
290
+ width: 100%;
291
+ height: 28px;
292
+ padding: 0 8px 0 28px;
293
+ border: none;
294
+ border-radius: var(--lp-r-md);
295
+ background: transparent;
296
+ color: var(--lp-ink);
297
+ font: inherit;
298
+ font-size: var(--lp-fs-xs);
299
+ }
300
+ .cg-find-view input::placeholder {
301
+ color: var(--lp-muted);
302
+ }
303
+ .cg-find-view input:focus-visible {
304
+ outline: 2px solid var(--lp-purple-deep);
305
+ outline-offset: -1px;
306
+ }
307
+ .cg-views-scroll {
308
+ flex: 1 1 auto;
309
+ min-height: 0;
310
+ overflow-y: auto;
311
+ }
312
+
313
+ /* Owner item 8 β€” the create pane's field-type picker: a find box over an icon listbox.
314
+ The rows reuse the type marks the column headers already wear (FieldTypeIcon). */
315
+ .cg-type-block { display: flex; flex-direction: column; gap: 4px; }
316
+ .cg-type-title { font-size: var(--lp-fs-2xs); color: var(--lp-muted); }
317
+ .cg-type-picker {
318
+ border: 1px solid var(--lp-line);
319
+ border-radius: var(--lp-r-md);
320
+ overflow: hidden;
321
+ background: var(--lp-surface);
322
+ }
323
+ .cg-type-search {
324
+ position: relative;
325
+ border-bottom: 1px solid var(--lp-line);
326
+ }
327
+ .cg-type-search svg {
328
+ position: absolute;
329
+ left: 8px;
330
+ top: 50%;
331
+ transform: translateY(-50%);
332
+ color: var(--lp-muted);
333
+ pointer-events: none;
334
+ }
335
+ .cg-type-search input {
336
+ width: 100%;
337
+ height: 30px;
338
+ border: none;
339
+ background: transparent;
340
+ padding: 0 8px 0 27px;
341
+ font: inherit;
342
+ font-size: var(--lp-fs-xs);
343
+ color: var(--lp-ink);
344
+ }
345
+ .cg-type-search input::placeholder { color: var(--lp-muted); }
346
+ .cg-type-search input:focus-visible {
347
+ outline: 2px solid var(--lp-purple-deep);
348
+ outline-offset: -2px;
349
+ border-radius: var(--lp-r-sm);
350
+ }
351
+ .cg-type-list {
352
+ max-height: 218px;
353
+ overflow-y: auto;
354
+ padding: 4px;
355
+ display: flex;
356
+ flex-direction: column;
357
+ gap: 1px;
358
+ }
359
+ .cg-type-row {
360
+ display: flex;
361
+ align-items: center;
362
+ gap: 8px;
363
+ width: 100%;
364
+ min-height: 30px;
365
+ padding: 4px 8px;
366
+ border: none;
367
+ border-radius: var(--lp-r-sm);
368
+ background: transparent;
369
+ color: var(--lp-ink);
370
+ font: inherit;
371
+ font-size: var(--lp-fs-xs);
372
+ text-align: left;
373
+ cursor: pointer;
374
+ }
375
+ .cg-type-row:hover { background: var(--lp-surface-2); }
376
+ .cg-type-row.is-on { background: var(--lp-blue-tint); font-weight: 600; }
377
+ .cg-type-row:focus-visible {
378
+ outline: 2px solid var(--lp-blue-deep);
379
+ outline-offset: -2px;
380
+ }
381
+ .cg-type-row .cg-type-icon { color: var(--lp-muted); flex: 0 0 auto; }
382
+ .cg-type-row.is-on .cg-type-icon { color: var(--lp-blue-deep); }
383
+ .cg-type-label {
384
+ flex: 1 1 auto;
385
+ overflow: hidden;
386
+ text-overflow: ellipsis;
387
+ white-space: nowrap;
388
  }
389
+ .cg-type-check { color: var(--lp-blue-deep); flex: 0 0 auto; }
390
  .cg-views-head {
391
  min-height: 44px;
392
  display: flex;
 
441
  border-radius: var(--lp-r-md);
442
  }
443
  .cg-view-row:hover {
444
+ background: var(--lp-surface-2);
445
  }
446
  .cg-view-row.is-active {
447
  background: var(--lp-blue-tint);
 
2256
  .shell-side {
2257
  display: flex;
2258
  flex-direction: column;
2259
+ width: var(--lp-rail-w);
2260
+ flex: 0 0 var(--lp-rail-w);
2261
  background: var(--lp-surface);
2262
  border-right: 1px solid var(--lp-line);
2263
  overflow-y: auto;
2264
+ overflow-x: hidden;
2265
+ }
2266
+
2267
+ /* Owner item 11 β€” the minimize control lives in the rail head, beside the brand. Collapsed,
2268
+ the rail is the slim strip: mark, toggle, icons, avatar β€” labels and badges go, widths come
2269
+ from the shared tokens so both rails fold identically. */
2270
+ .shell-side-head {
2271
+ display: flex;
2272
+ align-items: center;
2273
+ justify-content: space-between;
2274
+ padding-right: 10px;
2275
+ }
2276
+ .shell-rail-toggle {
2277
+ width: 28px;
2278
+ height: 28px;
2279
+ flex: 0 0 28px;
2280
+ display: inline-flex;
2281
+ align-items: center;
2282
+ justify-content: center;
2283
+ border: none;
2284
+ border-radius: var(--lp-r-md);
2285
+ background: transparent;
2286
+ color: var(--lp-muted);
2287
+ cursor: pointer;
2288
+ }
2289
+ .shell-rail-toggle:hover {
2290
+ background: var(--lp-surface-2);
2291
+ color: var(--lp-ink);
2292
+ }
2293
+ .shell-rail-toggle:focus-visible {
2294
+ outline: 2px solid var(--lp-blue-deep);
2295
+ outline-offset: 1px;
2296
+ }
2297
+ .shell-side.is-collapsed {
2298
+ width: var(--lp-rail-min);
2299
+ flex-basis: var(--lp-rail-min);
2300
+ }
2301
+ .shell-side.is-collapsed .shell-side-head {
2302
+ flex-direction: column;
2303
+ gap: 6px;
2304
+ padding: 12px 0 4px;
2305
+ }
2306
+ .shell-side.is-collapsed .shell-brand { padding: 0; }
2307
+ .shell-side.is-collapsed .lp-wordmark,
2308
+ .shell-side.is-collapsed .shell-nav-label,
2309
+ .shell-side.is-collapsed .shell-nav-badge,
2310
+ .shell-side.is-collapsed .shell-nav-open,
2311
+ .shell-side.is-collapsed .shell-nav-group,
2312
+ .shell-side.is-collapsed .shell-account-who,
2313
+ .shell-side.is-collapsed .shell-account-dots {
2314
+ display: none;
2315
+ }
2316
+ .shell-side.is-collapsed .shell-nav { padding: 0 6px; }
2317
+ .shell-side.is-collapsed .shell-nav-item {
2318
+ justify-content: center;
2319
+ padding: 7px 0;
2320
+ }
2321
+ .shell-side.is-collapsed .shell-nav-item.is-child { padding-left: 0; }
2322
+ .shell-side.is-collapsed .shell-account {
2323
+ justify-content: center;
2324
+ padding: 8px 0;
2325
  }
2326
 
2327
  .shell-brand { display: flex; align-items: center; gap: 9px; padding: 18px 16px 14px; }
 
3627
  /* I14 β€” the two create doors, stacked and LEFT-ALIGNED (the owner's word). `.cg-link-btn`
3628
  is a <button>, so it centres its label by default; every other use of that class sits in
3629
  a centred footer, which is why the alignment is set here and not on the shared class. */
3630
+ /* Owner item 9 (2026-07-31): the create row is a QUIET affordance, not a headline β€” regular
3631
+ weight, flush-left with the search and the view names under it. */
3632
+ .cg-create-new { padding: 2px 2px 0; }
3633
  .cg-create-btn {
3634
  width: 100%;
3635
  text-align: left;
3636
+ padding: 0 8px;
3637
  color: var(--lp-ink);
3638
+ font-weight: 400;
3639
  }
3640
  /* Wave-10 item 13 β€” the trigger stays HELD while its flyout is open. Driven off the
3641
  `aria-expanded` the button already publishes, so the visual state cannot drift from the
web/src/shell/Shell.tsx CHANGED
@@ -34,7 +34,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
34
  import CustomerGrid from "../customer-grid/CustomerGrid";
35
  import { clearCustomersCache } from "../customer-grid/apiBridge";
36
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
37
- import { DATA_ERROR_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
38
  import { PageSurface } from "../pages/PageSurface";
39
  import { SettingsModal } from "../settings/SettingsModal";
40
  import type { SettingsSection } from "../settings/SettingsModal";
@@ -141,6 +141,21 @@ function DotsIcon() {
141
  );
142
  }
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  /**
145
  * The account corner: ONE line (monogram + name + role, the host's
146
  * `_account_css` card) that opens a small menu, instead of three buttons
@@ -276,22 +291,42 @@ export default function Shell() {
276
  // "Users" are one component reached two ways rather than two dialogs.
277
  const [settings, setSettings] = useState<SettingsSection | null>(null);
278
  const route = useHashRoute();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
- // The three signals the data layer raises on the window (apiContract.ts). It
281
- // talks to the frame this way because `customer-grid/**` is host-neutral β€”
282
- // the same tree the Streamlit embed ships β€” and must not import a shell.
283
  useEffect(() => {
284
  const onUnauthorized = () => setSession({ phase: "anon" });
285
  const onDataError = (e: Event) =>
286
  setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
287
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
 
288
  window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
289
  window.addEventListener(DATA_ERROR_EVENT, onDataError);
290
  window.addEventListener(TOAST_EVENT, onToast);
 
291
  return () => {
292
  window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
293
  window.removeEventListener(DATA_ERROR_EVENT, onDataError);
294
  window.removeEventListener(TOAST_EVENT, onToast);
 
295
  };
296
  }, []);
297
 
@@ -401,11 +436,23 @@ export default function Shell() {
401
 
402
  return (
403
  <div className="shell-root">
404
- <aside className="shell-side">
405
  {/* The PRODUCT brand β€” the same mark the Streamlit host paints, from the
406
  same generated file, so the two shells cannot drift. "Royal Imports"
407
  stays on business documents only. */}
408
- <Brand size={26} className="shell-brand" />
 
 
 
 
 
 
 
 
 
 
 
 
409
 
410
  <nav className="shell-nav">
411
  {/* The Analyst slot, above the database list β€” the host's own IA
@@ -474,7 +521,21 @@ export default function Shell() {
474
  ) : null}
475
  </nav>
476
 
477
- <div className="shell-side-bottom">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  <AccountMenu
479
  user={session.user}
480
  utility={utility}
 
34
  import CustomerGrid from "../customer-grid/CustomerGrid";
35
  import { clearCustomersCache } from "../customer-grid/apiBridge";
36
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
37
+ import { DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
38
  import { PageSurface } from "../pages/PageSurface";
39
  import { SettingsModal } from "../settings/SettingsModal";
40
  import type { SettingsSection } from "../settings/SettingsModal";
 
141
  );
142
  }
143
 
144
+ /** Owner item 11 β€” the rail toggle: three horizontal bars. One glyph for both rails (the
145
+ * views rail draws the same geometry), so "minimize a panel" reads as one idea. */
146
+ function RailToggleIcon() {
147
+ return (
148
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
149
+ <path
150
+ d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
151
+ stroke="currentColor"
152
+ strokeWidth="1.35"
153
+ strokeLinecap="round"
154
+ />
155
+ </svg>
156
+ );
157
+ }
158
+
159
  /**
160
  * The account corner: ONE line (monogram + name + role, the host's
161
  * `_account_css` card) that opens a small menu, instead of three buttons
 
291
  // "Users" are one component reached two ways rather than two dialogs.
292
  const [settings, setSettings] = useState<SettingsSection | null>(null);
293
  const route = useHashRoute();
294
+ // Owner items 10/11 β€” the navigation folds to a slim strip: by the toggle in the rail head,
295
+ // or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
296
+ // grid). Remembered per browser; expanding is always one click on the same toggle.
297
+ const [navCollapsed, setNavCollapsed] = useState<boolean>(() => {
298
+ try {
299
+ return localStorage.getItem("aios-nav-collapsed") === "1";
300
+ } catch {
301
+ return false;
302
+ }
303
+ });
304
+ useEffect(() => {
305
+ try {
306
+ localStorage.setItem("aios-nav-collapsed", navCollapsed ? "1" : "0");
307
+ } catch {
308
+ // storage can be blocked; the toggle still works for the session
309
+ }
310
+ }, [navCollapsed]);
311
 
312
+ // The window signals the data layer raises (apiContract.ts). It talks to the
313
+ // frame this way because `customer-grid/**` is host-neutral β€” the same tree
314
+ // the Streamlit embed ships β€” and must not import a shell.
315
  useEffect(() => {
316
  const onUnauthorized = () => setSession({ phase: "anon" });
317
  const onDataError = (e: Event) =>
318
  setDataError(String((e as CustomEvent).detail ?? "") || "The data could not be loaded.");
319
  const onToast = (e: Event) => setToast(String((e as CustomEvent).detail ?? ""));
320
+ const onNavMinimize = () => setNavCollapsed(true);
321
  window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
322
  window.addEventListener(DATA_ERROR_EVENT, onDataError);
323
  window.addEventListener(TOAST_EVENT, onToast);
324
+ window.addEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
325
  return () => {
326
  window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
327
  window.removeEventListener(DATA_ERROR_EVENT, onDataError);
328
  window.removeEventListener(TOAST_EVENT, onToast);
329
+ window.removeEventListener(NAV_MINIMIZE_EVENT, onNavMinimize);
330
  };
331
  }, []);
332
 
 
436
 
437
  return (
438
  <div className="shell-root">
439
+ <aside className={"shell-side" + (navCollapsed ? " is-collapsed" : "")}>
440
  {/* The PRODUCT brand β€” the same mark the Streamlit host paints, from the
441
  same generated file, so the two shells cannot drift. "Royal Imports"
442
  stays on business documents only. */}
443
+ <div className="shell-side-head">
444
+ <Brand size={26} className="shell-brand" />
445
+ <button
446
+ type="button"
447
+ className="shell-rail-toggle"
448
+ aria-label={navCollapsed ? "Expand navigation" : "Minimize navigation"}
449
+ aria-expanded={!navCollapsed}
450
+ title={navCollapsed ? "Expand navigation" : "Minimize navigation"}
451
+ onClick={() => setNavCollapsed((v) => !v)}
452
+ >
453
+ <RailToggleIcon />
454
+ </button>
455
+ </div>
456
 
457
  <nav className="shell-nav">
458
  {/* The Analyst slot, above the database list β€” the host's own IA
 
521
  ) : null}
522
  </nav>
523
 
524
+ <div
525
+ className="shell-side-bottom"
526
+ // Collapsed, the 56px strip cannot hold the account POPOVER β€” so the row's one
527
+ // honest behaviour is "expand me first". Capture-phase, so the menu never opens
528
+ // half-clipped behind the grid.
529
+ onClickCapture={
530
+ navCollapsed
531
+ ? (e) => {
532
+ e.preventDefault();
533
+ e.stopPropagation();
534
+ setNavCollapsed(false);
535
+ }
536
+ : undefined
537
+ }
538
+ >
539
  <AccountMenu
540
  user={session.user}
541
  utility={utility}