File size: 105,435 Bytes
dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 a878ebb dcdb685 a878ebb dcdb685 609fb78 dcdb685 a878ebb dcdb685 a878ebb dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 609fb78 dcdb685 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 | import { useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnchoredOverlay } from "./OverlaySurface";
import { EXPORT_FORMATS, EXPORT_LABELS } from "./export";
import type { ExportFormat } from "./export";
import type {
DisplayMode,
FolderIcon,
GridFolder,
SavedView,
ViewEditMode,
ViewPermissions,
Viewer,
} from "./types";
import {
DEFAULT_FOLDER_SHAPE,
DEFAULT_FOLDER_TONE,
FOLDER_SHAPES,
FOLDER_TONES,
MAX_VIEW_USERS,
VIEW_EDIT_BLURBS,
VIEW_EDIT_LABELS,
VIEW_EDIT_MODES,
cleanFolderIcon,
cleanViewPermissions,
isModeFrozen,
isUndeletableView,
mayEditView,
mayToggleViewLock,
viewDisplayMode,
} from "./types";
import {
CREATABLE_MODES,
FOLDER_SHAPE_LABELS,
FOLDER_TONE_LABELS,
MODE_LABELS,
MODE_TONE,
} from "./iconShapes";
import { FOLDER_TONE_PAINT } from "./iconShapes";
import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
import { ROOT_FOLDER_ID } from "./folders";
// β W32-T23 (owner item 17) β THE BELL, from the shared layer rather than redrawn here.
// The alert row wore `cohortAdd` (a LIST with a plus), which names "add to a cohort" on the
// one row that creates an ALERT β a borrowed mark is a small lie a menu repeats every time
// it opens, and `icons.tsx`'s own `upload` header records the last time this bit.
// β `MenuLabel` takes a NODE for exactly this case: a mark that is not part of the grid's
// menu vocabulary. The Inbox module's header wears the SAME component (`ui/icons.BellIcon`),
// so the bell that opens the Inbox and the bell that fills it cannot drift apart.
import { BellIcon } from "../ui/icons";
import { SHARED_FOLDER_ID, groupByFolder, reorderFolderIds } from "./folders";
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β WAVE 27 Β· OWNER ITEM 1 β a MARK on each "Who can edit" row.
The three choices used to be three radio buttons and three sentences, which is a wall of
prose at the exact moment a user is deciding who else can change their work. A mark is the
part you recognise on the second visit, and Airtable's own share step leads every row with
one.
β WHY THE PATHS LIVE HERE AND NOT IN `icons.tsx`, stated so a later wave does not "tidy" it
back: `icons.tsx` renders `MODE_SHAPES` from `iconShapes.ts`, which the glide CANVAS also
paints β geometry with two painters belongs there. These three have exactly ONE call site,
thirty lines below, and no canvas draws them. `LockMark` and `EyeOffIcon` already carry the
same argument at their definitions ("paths are inline⦠no header ever draws this one").
β The wave-27 ownership map puts `iconShapes.ts` in session D's fence and leaves `icons.tsx`
in NOBODY's; a one-call-site component in a file this session owns has zero collision risk,
and the ask to settle `icons.tsx`'s owner is in the mailbox (C-3).
The tone follows the STRENGTH of the grant β neutral for the private default, blue for the
open one, green for the deliberate pick β and the paint comes from the shared pastel table,
never a literal, so these can never be the one place in the product with its own colours.
Outline-only, the register the owner set in 2026-07-29: "it doesn't have a full color, only
the linings". */
const PERM_TONE: Record<ViewEditMode, "neutral" | "blue" | "green"> = {
personal: "neutral",
collaborative: "blue",
users: "green",
};
/** head + shoulders, at `x`. One drawing, three uses β a second hand-drawn person is how two
* marks in one panel end up different heights. */
const person = (x: number) =>
`M${x} 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z` +
`M${x - 3.2} 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1`;
const PERM_MARK: Record<ViewEditMode, string[]> = {
// One person: only you.
personal: [person(8)],
// Two, side by side and overlapping: anyone who can see the table.
collaborative: [person(5.9), person(10.6)],
// One person and a tick: the people you picked.
users: [person(6), "M10.6 9.4l1.5 1.6 2.6-3"],
};
function PermMark({ mode }: { mode: ViewEditMode }) {
const paint = FOLDER_TONE_PAINT[PERM_TONE[mode]];
return (
<svg className="cg-perm-mark" width={16} height={16} viewBox="0 0 16 16" aria-hidden>
{PERM_MARK[mode].map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke={paint.stroke}
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
);
}
/* WAVE 20 item 18 (C-SHARE) β the system folder every view shared WITH you lands in until
you file it somewhere of your own. It is the only folder id this component recognises by
name, and as of wave 21 (item 9) it is DECLARED IN `folders.ts` beside the synthesis that
produces it β a component-local copy of a contract id is a copy that can drift from the
rule, and the rule is the half a gate can run. */
interface ViewSidebarProps {
views: SavedView[];
activeViewId: string;
saveState: "saved" | "saving";
onSelect: (id: string) => void;
/** I14 + I17 β the flyout names the display mode and the prompt names who may edit, so
* creation carries both. `permissions` is always explicit (C4: absent on create means
* personal, so an omission would silently contradict the form). */
onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
onRename: (id: string, name: string) => void;
/** Persist a view's description. "" is a real value β it clears it. */
onNote: (id: string, note: string) => void;
onDuplicate: (id: string) => void;
onDelete: (id: string) => void;
/** Cohorts this user can add to (owner item 6). Empty is fine β the menu then only offers
* "New cohortβ¦", which is how the first cohort gets created. */
lists?: { id: string; name: string }[];
/** Add the customers THIS view currently matches to a cohort. `cohortId` empty = create one
* called `name`. The view need not be the active one β the caller re-runs the engine for it. */
onAddToList?: (viewId: string, cohortId: string, name: string) => void;
/**
* β WAVE 21 item 11 (ruling R10) β open "Select from fileβ¦" for the ACTIVE view.
*
* β NO ARGUMENTS, and that is the contract rather than an omission. The dialog matches
* over the rows the GRID holds, which are the active view's; passing a view id here
* would invite a caller to match one view's list against another's rows and report the
* difference as missing records. The menu switches views first (see its own note) so
* this can stay a verb with one subject.
*/
onSelectFromFile?: () => void;
/** β WAVE-29 T25 (owner item 6) β open the Import dialog. Supplied only where records may be
* added, so a locked database simply has no such row in its menu. */
onImport?: () => void;
/**
* Item 12 (contract C-LOCK) β lock a view to a cohort, or `null` to unlock.
*
* β This menu is the ONLY emitter of `cohortLock` in the client. Optional so the rail still
* compiles and renders wherever the host has not accepted the key β the same posture every
* other optional action here takes, and the reason a half-deployed wave degrades to "the
* action is absent" rather than to "the action silently does nothing".
*/
onCohortLock?: (viewId: string, cohortId: string | null) => void;
/**
* The TENANT'S today (wave-2 item 4) β the payload's `today` string VERBATIM, seeding the
* default cohort name `<view name> Β· <YYYY-MM-DD>`. Never the browser clock: a viewer a day
* ahead of the tenant would stamp a cohort with a date the host's own data denies
* ([[date-window-vocabulary]]). Absent = the name seeds without a date.
*/
today?: string;
/**
* Wave-7 item W2 (contract C2) β export THIS view's current matches (filters + sorts,
* visible fields in display order), generated client-side. Absent on tables where the
* client does not hold the rows (server-windowed), so the entry never lies.
*/
onExport?: (viewId: string, format: ExportFormat) => void;
/**
* Wave-8 I11c (contract C4) β folders. All of it is OPTIONAL: a caller that
* passes no `folders` and no handlers renders exactly the pre-wave-8 flat rail,
* which is what keeps the cohort page (and any future rail) from having to opt
* into a feature it does not want.
*/
folders?: GridFolder[];
/** The folder a view belongs to RIGHT NOW β resolved by the caller through
* `resolveFolderId`, so a pending drag and a dangling ref are already handled
* before the rail sees them. */
folderIdOf?: (viewId: string) => string | null;
/** C5 (I15) β `icon` absent means the caller should send no icon at all (the folder wears
* the default), NOT that it should clear one. */
onFolderCreate?: (name: string, icon?: FolderIcon) => void;
onFolderRename?: (folderId: string, name: string) => void;
onFolderDelete?: (folderId: string) => void;
onFolderDuplicate?: (folderId: string) => void;
onItemMove?: (viewId: string, folderId: string | null) => void;
/**
* WAVE 20 item 19 (C-FOLDER-REORDER) β the folders, in the order the user just dragged
* them into. The FULL list every time, never a delta: a partial order cannot say where an
* unnamed folder went, and the host has to be able to stamp `order` on all of them.
*
* Optional like every other folder handler here: a caller that does not pass it gets a
* rail with no folder drag at all, rather than a grip that lifts a folder and drops it
* back β the posture this whole component takes toward an action nobody can honour.
*/
onFolderReorder?: (order: string[]) => void;
/**
* β WAVE 27 Β· OWNER ITEM 5 (contract C7) β the rail's new VIEW order, whole.
*
* β REQUIRED, AND THE OPTIONAL VERSION OF THIS EXACT PROP IS WHY. `onFolderReorder` above
* shipped optional in wave 20: its absence disabled the entire folder drag SILENTLY, every
* gate stayed green, and folders simply would not move (the note at its call site in
* `CustomerGrid` records it). A required prop fails `tsc` at the unmounted call site, which
* is the only version of this rule a person cannot forget.
*/
onViewReorder: (order: string[]) => void;
/**
* β WAVE 27 Β· OWNER ITEM 21 / RULING R14 β `viewId -> how many records match it RIGHT NOW`,
* for the views that carry an alert. A view with no alert is ABSENT from this map, and that
* absence is the whole display rule ("alert-on = badge-on").
*
* β REQUIRED, and `{}` is the honest empty. An optional prop would make "no alerts on this
* table" and "the caller forgot to compute them" the same state on screen β which is the
* shape wave 20's optional `onFolderReorder` shipped in, and it went unnoticed for a wave.
* β ABSENT vs ZERO are DIFFERENT and both are real: absent = no alert, so no badge; `0` = an
* alert on a view that currently matches nothing, which is exactly the case a user wants to
* see. `!== undefined` rather than a truthiness test, for that reason.
*/
alertCounts: Record<string, number>;
/**
* C4 as AMENDED 2026-07-28 β the folder-level bulk "Add to cohort". The caller
* owns the arithmetic (it holds the engine); the rail owns the confirm.
* `preview` must return the DEDUPED union plus anything it could not compute,
* because the amendment is explicit that a partial union is never silent.
*/
folderAddPreview?: (folderId: string) => {
pids: number[];
counted: number;
skipped: { name: string; why: string }[];
};
onFolderAddToList?: (folderId: string, cohortId: string, name: string) => void;
/**
* Wave-9 contract C3 (I12) β who is looking, so the lock entry appears only for the view's
* creator or an admin. Absent = the entry never appears (fail-closed), which is also what
* every caller that has not wired `onToggleLock` gets.
*/
viewer?: Viewer;
/** C3 β freeze/unfreeze this view's DISPLAY MODE. The host re-checks the actor. */
onToggleLock?: (viewId: string, locked: boolean) => void;
/**
* ββ WAVE 32 Β· T24 (owner item 17, ruling R5, contract C4) β mark / unmark a view IMPORTANT.
*
* β OPTIONAL, matching every other host callback on this rail: the sidebar is host-neutral and
* a host that has not wired it simply does not offer the row (rather than offering one that
* throws). The behaviour is not silently degraded β the row is ABSENT, which a reader can see.
*/
onToggleImportant?: (viewId: string, important: boolean) => void;
/* wave17 R1 / C-LOCKV β `cohorts` and `onSelectCohort` are GONE from this component's
surface. They carried the transient-lock selection for the retired Cohorts section; a
locked view is now an ordinary member of `views` and is selected by `onSelect` like any
other, which is the whole point of R1. */
/** I17 (C4) β the tenant's real account list, for the "Specific users" picker. The HOST
* re-validates every name against core/users.py and drops what it cannot resolve, so this
* list is a convenience, never the authority. */
userOptions?: string[];
}
export default function ViewSidebar({
views,
activeViewId,
saveState,
onSelect,
onCreate,
onRename,
onNote,
onDuplicate,
onDelete,
lists = [],
onAddToList,
onSelectFromFile,
onImport,
onCohortLock,
today,
onExport,
folders,
folderIdOf,
onFolderCreate,
onFolderRename,
onFolderDelete,
onFolderDuplicate,
onItemMove,
onFolderReorder,
onViewReorder,
alertCounts,
folderAddPreview,
onFolderAddToList,
viewer,
onToggleLock,
onToggleImportant,
userOptions = [],
}: ViewSidebarProps) {
/**
* I14 β creating a view now always knows WHICH KIND of view is being created, because the
* flyout is the only door in. `null` = not creating; a mode = the name step for that mode.
*/
const [creating, setCreating] = useState<DisplayMode | null>(null);
/** I14 β the "+ Create newβ¦" flyout's anchor. */
const [createMenu, setCreateMenu] = useState<HTMLButtonElement | null>(null);
const [name, setName] = useState("");
/** I17 (C4) β the create prompt's who-can-edit draft. Seeded PERSONAL, matching the host's
* create-side default, so the form and the store agree before the user touches anything. */
const [permEdit, setPermEdit] = useState<ViewEditMode>("personal");
const [permUsers, setPermUsers] = useState<string[]>([]);
const [menu, setMenu] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [noteFor, setNoteFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const [noteDraft, setNoteDraft] = useState("");
const [addFor, setAddFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
/** Item 12 (C-LOCK) β which view's cohort lock the picker is open for. */
const [lockFor, setLockFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const [newListName, setNewListName] = useState("");
/** W2 β the Export format pane, anchored where the "β¦" menu was. */
const [exportFor, setExportFor] = useState<{
viewId: string;
anchor: HTMLButtonElement;
} | null>(null);
const exportView = exportFor
? views.find((view) => view.id === exportFor.viewId)
: undefined;
const addView = addFor ? views.find((view) => view.id === addFor.viewId) : undefined;
const menuView = menu ? views.find((view) => view.id === menu.viewId) : undefined;
/** C4 β may this viewer change the menu view's IDENTITY? Courtesy only; the host is the
* wall. C3's lock toggle ANDs with this rather than replacing it: the two contracts
* compose (C4 says who may edit, C3 says who may freeze the mode). */
const canEditMenuView = !!menuView && mayEditView(menuView, viewer);
// I11c local UI state. Collapse is deliberately LOCAL (C4: "minimize state
// local") β which folders you have open is a per-person, per-session habit,
// not a property of the shared workspace.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [folderMenu, setFolderMenu] = useState<{ id: string; anchor: HTMLElement } | null>(null);
const [renamingFolder, setRenamingFolder] = useState<string | null>(null);
const [creatingFolder, setCreatingFolder] = useState(false);
/**
* I15 β the folder-create draft. The name became CONTROLLED with an explicit Create button
* for a reason that is not cosmetic: the old input committed on BLUR, and clicking an icon
* swatch blurs the input β so an icon picker beside a commit-on-blur field would create the
* folder the moment you tried to colour it.
*/
const [folderName, setFolderName] = useState("");
const [folderIcon, setFolderIcon] = useState<FolderIcon>({
shape: DEFAULT_FOLDER_SHAPE,
tone: DEFAULT_FOLDER_TONE,
});
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [addTarget, setAddTarget] = useState<string | null>(null);
/** The element the bulk-add pane hangs off. Anchoring to document.body
* put it at the bottom of the DOCUMENT rather than beside the folder β
* painted, and effectively unreachable ([[ui-invisible-to-assertions]]).
* Captured from the folder menu before that menu is dismissed. */
const [addAnchor, setAddAnchor] = useState<HTMLElement | null>(null);
const [addName, setAddName] = useState("");
const [dropTarget, setDropTarget] = useState<string | null>(null);
// Owner item 9 (2026-07-31) β "Find a view". Local and never persisted: a search is a
// moment, not a setting. Filtering runs BEFORE grouping so a folder with no matches
// simply drops out of the rail while the query is live.
const [viewQuery, setViewQuery] = useState("");
// Owner item 11 β this rail is the SECOND navigation bar: it folds to the same slim strip
// the shell nav does, remembered per browser under its own key.
const [railShut, setRailShut] = useState<boolean>(() => {
try {
return localStorage.getItem("aios-views-rail") === "1";
} catch {
return false;
}
});
const toggleRail = () =>
setRailShut((v) => {
const next = !v;
try {
localStorage.setItem("aios-views-rail", next ? "1" : "0");
} catch {
// storage can be blocked; the fold still works for the session
}
return next;
});
const viewNeedle = viewQuery.trim().toLowerCase();
const shownViews = viewNeedle
? views.filter((v) => (v.name || "").toLowerCase().includes(viewNeedle))
: views;
// ββ WAVE 20 item 19 (C-FOLDER-REORDER) β dragging a FOLDER to reorder the rail ββββββ
//
// β A PRIVATE dataTransfer MIME, and it is not decoration. The VIEW drag two blocks down
// carries `text/plain`, and the folder groups already accept that drop ("file this view
// into me"). Sharing one channel would make every folder drag look like a view drop to
// the handler that fires first β so the two drags are told apart by TYPE, the same
// discipline the shell nav's own drag uses, and a drop of this type anywhere
// text-editable types nothing.
const FOLD_DRAG_TYPE = "application/x-loopable-fold";
/** The folder being dragged, and the group its insertion line is currently drawn above. */
const [foldDrag, setFoldDrag] = useState<string | null>(null);
const [foldOver, setFoldOver] = useState<string | null>(null);
const foldsReorderable = !!folders && !!onFolderReorder;
/**
* Land `draggedId` before `beforeId` (null = last) and emit the new order. The arithmetic
* lives in `folders.ts` so a gate can run it under node β the handler around it is DOM.
*
* β The order is computed from the `folders` PROP, never from the rendered groups: a live
* "Find a view" query hides folders with no matches, and an order derived from what is on
* screen would silently drop the hidden ones out of the sequence.
*/
const reorderFolders = (draggedId: string, beforeId: string | null) => {
const next = reorderFolderIds((folders ?? []).map((f) => f.id), draggedId, beforeId);
// `null` is "nothing to say" β an unknown id, or a drop that changed nothing.
if (next) onFolderReorder?.(next);
};
// ββ β WAVE 27 Β· OWNER ITEM 5 (contract C7) β dragging a VIEW to reorder the rail ββββββββ
//
// The owner asked for both lists to be draggable top-to-bottom; folders already were
// (wave 20 item 19) and views could only be FILED INTO a folder, never moved past each
// other. This is that drag, built on the same three pieces so the two gestures cannot drift:
// a private MIME, the shared `reorderFolderIds` arithmetic, and a whole-order emit.
//
// β THE ARITHMETIC IS REUSED, NOT COPIED. `reorderFolderIds` is id-generic β it takes a list
// of ids, a dragged id and a "before" id, and every clause in it (dropped-on-itself, the
// not-found fallback, the no-op return) is about SEQUENCES rather than about folders. It is
// already exercised by `verify_folders.py`, and a second copy here would be a second place
// for the dropped-on-itself bug that function's own comment records ([[one-evaluator-per-question]]).
const VIEW_DRAG_TYPE = "application/x-loopable-view";
const [viewDrag, setViewDrag] = useState<string | null>(null);
const [viewOver, setViewOver] = useState<string | null>(null);
/**
* Land `draggedId` before `beforeId` and emit the FULL order.
*
* β Computed from the `views` PROP, never from the rendered rows β a live "Find a view"
* query hides non-matching rows, and an order derived from what is on screen would silently
* drop every hidden view out of the sequence. (The folder half carries the identical note;
* it is the same trap and this rail has both filters on it.)
*/
const reorderViews = (draggedId: string, beforeId: string | null) => {
const next = reorderFolderIds(views.map((v) => v.id), draggedId, beforeId);
if (next) onViewReorder(next); // `null` = an unknown id, or a drop that changed nothing
};
/**
* One pair of handlers per VIEW ROW, listening for the view MIME only.
*
* β `stopPropagation` IS LOAD-BEARING. A view row sits inside its folder group, whose own
* `onDrop` reads `text/plain` and files the view into that folder. Both types are on this
* drag, so without this a reorder inside a folder would ALSO re-file the view β usually into
* the folder it is already in, i.e. invisible, until the day it is not.
* β WHAT THIS DELIBERATELY DOES NOT DO: change the view's folder. Dropping a view onto a row
* in another folder reorders it and leaves it where it lives; filing stays a drop on the
* folder itself. Two gestures, two meanings, rather than one gesture guessing.
*/
const viewDropHandlers = (viewId: string) => ({
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(VIEW_DRAG_TYPE)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setViewOver(viewId);
},
onDragLeave: () => setViewOver(null),
onDrop: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(VIEW_DRAG_TYPE)) return;
const dragged = e.dataTransfer.getData(VIEW_DRAG_TYPE);
setViewOver(null);
setViewDrag(null);
if (!dragged) return;
e.preventDefault();
e.stopPropagation();
reorderViews(dragged, viewId);
},
});
const viewsReorderable = views.length > 1;
const foldersOn = !!folders && !!folderIdOf && !!onItemMove;
const groups = groupByFolder(
shownViews,
foldersOn ? (folders as GridFolder[]) : [],
(v) => (folderIdOf ? folderIdOf(v.id) : null),
/**
* β WAVE 21 item 9 (R12 / contract C1) β THE CONSUMER of `shared`.
*
* The wire marks each granted view `shared: true` (C1); this is where that mark becomes
* a place on screen. Passed unconditionally β NOT gated on `foldersOn` β because the
* folder machinery is an opt-in feature of the views rail and "who shared this with me"
* is not: a surface with folders switched off must still be able to show you somebody
* else's view under a heading that says whose it is.
*/
(v) => v.shared === true
);
const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
/**
* ONE pair of drop handlers per group, serving TWO drags β a view being filed into this
* folder (`text/plain`) and, since wave 20 item 19, a folder being dropped in front of
* this one (`FOLD_DRAG_TYPE`). They must be one pair rather than two spreads: a second
* `onDragOver`/`onDrop` on the same element replaces the first, silently, and the drag
* that lost would simply stop working.
*
* The type is read BEFORE anything else, so a folder drag never paints the "file a view
* in here" fill and a view drag never draws the insertion rule.
*/
const dropHandlers = (folderId: string | null) =>
foldersOn || foldsReorderable
? {
onDragOver: (e: React.DragEvent) => {
if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
if (!foldsReorderable) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setFoldOver(folderId ?? "__root__");
return;
}
if (!foldersOn) return;
e.preventDefault();
setDropTarget(folderId ?? "__root__");
},
onDragLeave: () => {
setDropTarget(null);
setFoldOver(null);
},
onDrop: (e: React.DragEvent) => {
if (e.dataTransfer.types.includes(FOLD_DRAG_TYPE)) {
const dragged = e.dataTransfer.getData(FOLD_DRAG_TYPE);
setFoldOver(null);
setFoldDrag(null);
if (!foldsReorderable || !dragged) return;
e.preventDefault();
e.stopPropagation();
reorderFolders(dragged, folderId);
return;
}
if (!foldersOn) return;
e.preventDefault();
setDropTarget(null);
const id = e.dataTransfer.getData("text/plain");
// ββ W32-T27 (owner item 20) β DROPPING ON ROOT SENDS A VALUE, NOT `null`.
//
// `null` means "no placement", and `groupByFolder` reads a shared view with no
// placement as one that arrived by grant β so dragging a shared view to the top
// level put it straight back under "Shared with me". `ROOT_FOLDER_ID` is the
// receiver saying they filed it here, which is a different fact and now has a
// different value.
//
// β SENT FOR EVERY ITEM, not only shared ones, and that is deliberate: a rail that
// emits one shape for a shared view and another for your own has two move paths to
// keep in step, and the bug being fixed here is exactly what happens when two
// layers disagree about what absence means. For an unshared view the two are
// equivalent β `groupByFolder` maps the sentinel onto the same root bucket.
if (id) onItemMove?.(id, folderId ?? ROOT_FOLDER_ID);
},
}
: {};
const noteView = noteFor
? views.find((view) => view.id === noteFor.viewId)
: undefined;
const create = () => {
const value = name.trim();
if (!value || !creating) return;
// C4: permissions are sent EXPLICITLY on create. Omitting them means 'personal'
// host-side, so a user who picked "Collaborative" and got a personal view would have no
// way to tell β the form said one thing and the store did the other.
// Cleaned through the same rules the host applies, so an empty "Specific users" grant
// collapses to 'personal' HERE too and the confirmation the user sees is the truth.
onCreate(value, creating, cleanViewPermissions(
{ edit: permEdit, users: permUsers }, "personal", userOptions));
setName("");
setPermEdit("personal");
setPermUsers([]);
setCreating(null);
// WAVE 20 item 20 β the two steps share ONE panel, so the anchor outlives step 1 and
// has to be released here. Left set, the flyout would spring back to the type chooser
// the instant the view was created.
setCreateMenu(null);
};
/** Item 20 β abandon the whole flyout, from either step. Escape, an outside click and
* Cancel all mean the same thing ("I am not creating anything"), so they call one
* function rather than three combinations of setters β and abandoning drops the DRAFT
* too, or the next "+ Create newβ¦" would hand back a half-filled form from a decision
* the user already walked away from. */
const closeCreate = () => {
setCreating(null);
setCreateMenu(null);
setName("");
setPermEdit("personal");
setPermUsers([]);
};
const createFolder = () => {
const value = folderName.trim();
if (!value) return;
// cleanFolderIcon returns undefined for the all-defaults pair, so a folder the user
// never restyled stays byte-identical to a pre-wave-9 one (the no-churn rule).
onFolderCreate?.(value, cleanFolderIcon(folderIcon));
setFolderName("");
setFolderIcon({ shape: DEFAULT_FOLDER_SHAPE, tone: DEFAULT_FOLDER_TONE });
setCreatingFolder(false);
};
/**
* WAVE 20 item 20 β the view door KEEPS the anchor, because the name/permissions step
* now renders in the SAME anchored box the type chooser used. The old comment ("close
* the flyout first, or the menu sits on top of the input") described the bug the owner
* reported from the other side: the two steps of one action were drawn in two different
* places, one beside the rail and one inside it, so step 2 read as an unrelated form.
* One panel, two contents, one geometry.
*
* The FOLDER door still closes it: that path is a single step and has no second panel
* to disagree with. Moving it too would be a change the ruling does not ask for.
*/
const startView = (mode: DisplayMode) => {
setCreatingFolder(false);
setCreating(mode);
};
const startFolder = () => {
setCreateMenu(null);
setCreating(null);
setCreatingFolder(true);
};
/**
* WAVE 20 items 18/23/26 (R10, C-SHARE) β open the access editor for a view or a
* folder.
*
* β A WINDOW EVENT, NOT A PROP, and not for convenience. The dialog is the SHELL's
* (one editor for views, folders and databases β R10 asks for one vocabulary), and
* this tree is host-neutral: `customer-grid/**` must not import a shell. The frame
* already listens on this channel for toasts and staleness; this is one more note on
* it. The literal is spelled once here and once in `shell/shareModel.ts`, which is
* where it becomes a shared constant when S3 publishes it in `apiContract.ts`
* (amendment A-S4-4).
*
* β It degrades to NOTHING when no shell is listening (the Streamlit-era bare grid):
* a dispatched event nobody hears opens no dialog and throws no error β which is the
* right failure for a frame-level surface reached from a rail.
*/
const openShare = (kind: "view" | "folder", id: string, label: string) => {
window.dispatchEvent(
new CustomEvent("aios:share-open", { detail: { kind, id, label } })
);
};
const onMenuKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const items = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
];
if (!items.length) return;
event.preventDefault();
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const next =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: event.key === "ArrowDown"
? (current + 1) % items.length
: (current - 1 + items.length) % items.length;
items[next]?.focus();
};
return (
<aside
className={"cg-views" + (railShut ? " is-collapsed" : "")}
aria-label="Customer views"
>
{/* Owner item 11 β the rail's own minimize control: the same three-bars glyph as the
shell nav's, because the two rails are one idea ("a navigation bar folds"). */}
<div className="cg-views-top">
<button
type="button"
className="cg-rail-toggle"
aria-label={railShut ? "Expand views" : "Minimize views"}
aria-expanded={!railShut}
title={railShut ? "Expand views" : "Minimize views"}
onClick={toggleRail}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/*
I13 β the "Views / All changes saved" header block is GONE and everything moved up.
The save state survives as a ZERO-PIXEL live region. That header was the only place
this surface ever announced "Saving changesβ¦", and `saveState` has exactly one
consumer (CustomerGrid:2074 β here), so deleting the markup outright would take a
real signal away from a screen-reader user in order to satisfy a layout request. The
item asked for the pixels back; this gives back every pixel and keeps the
announcement. It is also what keeps `saveState` a used prop under `noUnusedLocals`.
*/}
<div className="cg-sr-only" aria-live="polite">
{saveState === "saving" ? "Saving changesβ¦" : "All changes saved"}
</div>
{/* I14 β "+ Create newβ¦", left-aligned, opening a flyout to the RIGHT of the rail that
lists every creatable view type AND "Folder".
Wave-10 item 12: the second door β a separate "+ New folder" link that used to sit
directly under this one β is DELETED. It was a duplicate of the flyout's own Folder
row, so the rail offered two controls that did the same thing. `startFolder()` still
has exactly one caller (the flyout row); only the redundant entry point is gone.
β wave17 R1 β the reason `.cg-fold-new` was kept has EXPIRED. It survived wave 10
because `CohortSidebar.tsx` rendered its own "+ New folder" under that class and had
no create-flyout to fold it into; that file is deleted this wave, so the class now has
no consumer in this tree. Left in index.css rather than swept blind: a CSS rule with
no consumer is inert, and hunting one down mid-wave is how a rule something else still
reads gets removed. Named here so the sweep is a decision, not a discovery. */}
<div className="cg-create-new">
<button
type="button"
className="cg-link-btn cg-create-btn"
aria-haspopup="menu"
aria-expanded={!!createMenu}
onClick={(event) => {
// β THE ANCHOR IS HOISTED OUT OF THE UPDATER β the same line that took the whole
// app white from the view row's "β¦" (see the long note at that button). React
// nulls `event.currentTarget` when the handler returns and may re-invoke a
// functional updater afterwards, so `setCreateMenu(cur => β¦ event.currentTarget)`
// can store null. Item 20 made this panel outlive a single click β it now carries
// the name step too β so a randomly-nulled anchor would close a form mid-typing
// rather than merely mis-place a menu.
const anchor = event.currentTarget;
if (createMenu) {
closeCreate();
return;
}
// Every open starts at the type chooser: `creating` left set by an abandoned
// flyout would re-enter at step 2 for a kind nobody just picked.
setCreating(null);
setCreateMenu(anchor);
}}
>
<span className="cg-create-icon" aria-hidden="true">+</span>
<span className="cg-create-label">Create newβ¦</span>
</button>
</div>
{/* Owner item 9 β "Find a view". Borderless on the white rail (no box until it is
being used); focus paints the brand-purple outline. Clearing the query restores
the full rail β filtering never persists. */}
<div className="cg-find-view">
<svg
className="cg-find-view-icon"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="7" cy="7" r="4.4" stroke="currentColor" strokeWidth="1.35" />
<path
d="m10.4 10.4 3.1 3.1"
stroke="currentColor"
strokeWidth="1.35"
strokeLinecap="round"
/>
</svg>
<input
type="text"
value={viewQuery}
placeholder="Find a view"
aria-label="Find a view"
onChange={(event) => setViewQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setViewQuery("");
}}
/>
</div>
{/* β WAVE 20 item 20 β THE CREATE FORM NO LONGER RENDERS HERE. It is the flyout's
second step, inside the same `AnchoredOverlay` (below, next to the type list it
follows). This comment stands in for the moved markup because the move IS the
item: two steps of one action that used to appear in two different places. */}
{/* I15 β the folder form: name + the icon the folder will wear. */}
{foldersOn && onFolderCreate && creatingFolder && (
<div className="cg-view-create cg-create-form cg-fold-form">
<label htmlFor="cg-new-folder">New folder</label>
<input
id="cg-new-folder"
className="cg-input"
autoFocus
value={folderName}
placeholder="Folder name"
onChange={(event) => setFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") createFolder();
if (event.key === "Escape") setCreatingFolder(false);
}}
/>
<div className="cg-fold-pick" role="group" aria-label="Folder icon">
<div className="cg-fold-pick-row">
{FOLDER_SHAPES.map((shape) => (
<button
key={shape}
type="button"
className={
"cg-fold-swatch" + (folderIcon.shape === shape ? " is-on" : "")
}
aria-pressed={folderIcon.shape === shape}
aria-label={FOLDER_SHAPE_LABELS[shape]}
title={FOLDER_SHAPE_LABELS[shape]}
onClick={() => setFolderIcon((cur) => ({ ...cur, shape }))}
>
<FolderMark icon={{ shape, tone: folderIcon.tone }} size={15} />
</button>
))}
</div>
<div className="cg-fold-pick-row">
{FOLDER_TONES.map((tone) => (
<button
key={tone}
type="button"
className={"cg-fold-swatch" + (folderIcon.tone === tone ? " is-on" : "")}
aria-pressed={folderIcon.tone === tone}
aria-label={FOLDER_TONE_LABELS[tone]}
title={FOLDER_TONE_LABELS[tone]}
onClick={() => setFolderIcon((cur) => ({ ...cur, tone }))}
>
<FolderMark icon={{ shape: folderIcon.shape, tone }} size={15} />
</button>
))}
</div>
</div>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
onClick={createFolder}
disabled={!folderName.trim()}
>
Create
</button>
<button
type="button"
className="cg-btn"
onClick={() => setCreatingFolder(false)}
>
Cancel
</button>
</div>
</div>
)}
{/* I14 β the flyout. To the RIGHT (placement right-start), every creatable view type
with its own pastel mark, and Folder LAST behind a separator: it is not a view, and
the owner put it at the bottom of the list rather than among them. */}
{createMenu && (
<AnchoredOverlay
anchor={createMenu}
className="cg-view-menu cg-create-flyout"
placement="right-start"
// WAVE 20 item 20 β ONE panel, two contents. A menu of choices and a form are
// different KINDS of thing to a screen reader even when they are one box to the
// eye, so the role and the label follow the step rather than being frozen at the
// panel's first purpose.
role={creating ? "dialog" : "menu"}
ariaLabel={
creating ? `New ${MODE_LABELS[creating].toLowerCase()} view` : "Create new"
}
onDismiss={closeCreate}
// β THE ARROW-KEY HANDLER IS STEP 1's ONLY. `onMenuKeyDown` swallows
// ArrowUp/ArrowDown to walk `[role=menuitem]`; over a text input that is the
// cursor keys refusing to move through what you just typed.
{...(creating ? {} : { onKeyDown: onMenuKeyDown })}
dataKind={creating ? "create-new-name" : "create-new"}
>
{creating ? (
<div className="cg-view-create cg-create-form">
<label htmlFor="cg-new-view">
{/* The prompt NAMES the kind picked one step ago β the panel replaced its
own contents, so without this the box gives no sign that "Calendar" was
ever chosen. */}
New {MODE_LABELS[creating].toLowerCase()} view
</label>
<input
id="cg-new-view"
className="cg-input"
autoFocus
// The overlay's own initial-focus effect fires on MOUNT, and this panel does
// not re-mount between the two steps β so the input carries `autoFocus` (React
// focuses it when it mounts) and the marker the other in-panel forms use.
data-overlay-autofocus
value={name}
placeholder="e.g. Florida at risk"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") create();
// Escape is handled by the overlay layer too; both mean "abandon", and
// calling the same closer twice is idempotent.
if (event.key === "Escape") closeCreate();
}}
/>
{/* I17 (C4) β the owner's prompt ORDER: type (picked in step 1, named above)
β who can edit β the user picker, and the picker only when it applies. */}
<div className="cg-perm" role="radiogroup" aria-label="Who can edit this view">
<span className="cg-perm-title">Who can edit</span>
{VIEW_EDIT_MODES.map((m) => (
<label key={m} className="cg-radio-row cg-perm-row">
<input
type="radio"
name="cg-view-perm"
checked={permEdit === m}
onChange={() => setPermEdit(m)}
/>
{/* β ITEM 1 β the mark is its OWN COLUMN, between the radio and the words.
β Not inside `.cg-perm-label`: that element is a flex COLUMN (title over
blurb), so a mark placed in it becomes a THIRD ROW under the sentence
rather than a leading glyph β the wrong-parent trap
([[wrong-parent-not-broken-control]]) in its cheapest form. */}
<PermMark mode={m} />
<span className="cg-perm-label">
{VIEW_EDIT_LABELS[m]}
<span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
</span>
</label>
))}
{permEdit === "users" && (
<div className="cg-perm-users">
{userOptions.length === 0 ? (
// Never a silent empty box: an empty grant collapses to Personal
// host-side, so say that rather than letting the user think they
// shared it.
<span className="cg-perm-empty">
No other accounts to pick β this will save as Personal.
</span>
) : (
userOptions.map((u) => (
<label key={u} className="cg-perm-user">
<input
type="checkbox"
checked={permUsers.includes(u)}
onChange={(e) =>
setPermUsers((cur) =>
e.target.checked
? [...cur, u].slice(0, MAX_VIEW_USERS)
: cur.filter((x) => x !== u)
)
}
/>
<span>{u}</span>
</label>
))
)}
{userOptions.length > 0 && permUsers.length === 0 && (
<span className="cg-perm-empty">
Pick at least one person, or this saves as Personal.
</span>
)}
</div>
)}
</div>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
onClick={create}
disabled={!name.trim()}
>
Create
</button>
<button type="button" className="cg-btn" onClick={closeCreate}>
Cancel
</button>
</div>
</div>
) : (
<>
{CREATABLE_MODES.map((mode) => (
<button
key={mode}
type="button"
role="menuitem"
className="cg-create-row"
onClick={() => startView(mode)}
>
{/* size 16 β item 13's "bigger icons", and the SAME 16 the view rows use,
so a Calendar is one mark at one size on this whole surface. */}
<ToneModeIcon mode={mode} tone={MODE_TONE[mode]} size={16} />
<span>{MODE_LABELS[mode]}</span>
</button>
))}
{foldersOn && onFolderCreate && (
<>
<div className="cg-menu-sep" role="separator" aria-hidden />
<button
type="button"
role="menuitem"
className="cg-create-row"
onClick={() => startFolder()}
>
<FolderMark size={16} />
<span>Folder</span>
</button>
</>
)}
</>
)}
</AnchoredOverlay>
)}
<div className="cg-view-list cg-views-scroll">
{groups.map((group) => {
const gid = group.folder?.id ?? null;
// Item 9 β while a search is live, a folder with no matches is noise, not a target.
if (viewNeedle && group.folder && group.items.length === 0) return null;
const isRoot = gid === null;
/**
* β WAVE 21 item 9 (R12/C1) β "Shared with me" IS NOT A STORED FOLDER, and every
* folder affordance below has to know it.
*
* The group is synthesised from `shared: true` views (`folders.ts`), so there is no
* record behind it: a drop would emit `item_move(viewId, "__shared__")` and persist
* a folderId naming a folder that does not exist, and a folder-drag would send an
* order containing an id `reorderFolderIds` was never given. Both write nonsense the
* server would store. The row menu was already suppressed for this id in wave 20;
* the DRAG surfaces were not, because nothing ever constructed the group.
*/
const isSynthetic = gid === SHARED_FOLDER_ID;
const shut = gid != null && collapsed.has(gid);
return (
<div
key={gid ?? "__root__"}
className={
(isRoot ? "cg-fold-root" : "cg-fold") +
(dropTarget === (gid ?? "__root__") ? " is-drop" : "") +
// Item 19 β the insertion rule reads "the folder you are dragging lands HERE":
// above this folder, or above the ungrouped section, which is after them all.
(foldOver === (gid ?? "__root__") && foldDrag && foldDrag !== gid
? " is-drop-above"
: "") +
// β `foldDrag !== null` FIRST. `gid` is null for the UNGROUPED section and
// `foldDrag` idles at null, so a bare `foldDrag === gid` is `null === null` β TRUE
// from first paint, with no drag anywhere. The ungrouped section (which is where
// most people's views live) rendered at `opacity: .45` permanently, and the symptom
// is not "a folder looks dragged" but "my view NAMES are faded, as if hidden" β
// which is why it read as a colour bug rather than a drag-state bug.
//
// The `is-drop-above` line above got this right (`&& foldDrag &&`), which is the
// tell: two conditions written minutes apart, one guarded and one not.
(foldDrag !== null && foldDrag === gid ? " is-folddrag" : "")
}
{/* No drop handlers on the synthetic group β see `isSynthetic`. Spreading `{}`
rather than branching the element keeps ONE render path for every group. */
...(isSynthetic ? {} : dropHandlers(gid))}
>
{group.folder && (
<div
className="cg-fold-head"
// ββ Item 19 β the folder itself is the drag handle βββββββββββββββββββββ
// Draggable only while it is renameable-idle: a `draggable` ancestor eats
// the text selection inside an input, so dragging would win over editing
// the name you just opened.
draggable={foldsReorderable && !isSynthetic && renamingFolder !== group.folder.id}
onDragStart={(e) => {
e.dataTransfer.setData(FOLD_DRAG_TYPE, group.folder!.id);
e.dataTransfer.effectAllowed = "move";
setFoldDrag(group.folder!.id);
}}
onDragEnd={() => {
setFoldDrag(null);
setFoldOver(null);
}}
>
{/* β NO DRAG GRIP, and that is a decision rather than an omission. A grip in
flow shifts the folder's name to the right, and this rail's indent is
MEASURED to the pixel (index.css: "root view name 41.00px / folder header
48.36px" β the whole point of the wave-10 item-2 fix); the gutter it would
otherwise hide in is already occupied by the disclosure chevron, whose
open/shut state is the only one this row has. The view rows above have been
draggable with no handle since wave 8, so a folder that drags the same way
is the vocabulary this list already teaches, not a hidden feature. */}
<button
type="button"
className="cg-fold-toggle"
aria-expanded={!shut}
onClick={() =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(group.folder!.id)) next.delete(group.folder!.id);
else next.add(group.folder!.id);
return next;
})
}
>
<span className={"cg-fold-chev" + (shut ? " is-shut" : "")} aria-hidden>
βΎ
</span>
{/* I14/I15 β the folder wears its chosen mark; a folder that never chose one
(every pre-wave-9 folder) renders the default grey folder shape, which is
precisely "existing folders get the folder icon". */}
{/* size 16 β one mark box across the whole rail (view rows, flyout rows and
now folder headers), which is what lets item 2's text alignment be exact
rather than approximately right. */}
<FolderMark icon={group.folder.icon} size={16} />
{renamingFolder === group.folder.id ? (
<input
className="cg-input cg-fold-rename"
autoFocus
defaultValue={group.folder.name}
onClick={(e) => e.stopPropagation()}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== group.folder!.name) onFolderRename?.(group.folder!.id, v);
setRenamingFolder(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") setRenamingFolder(null);
}}
/>
) : (
<span className="cg-fold-name">{group.folder.name}</span>
)}
{/* β WAVE 20 item 21 β THE VIEW COUNT IS DELETED, and the item is not
really about the number. `.cg-view-more` (the "β¦" beside this button)
ships `opacity: 0` and is revealed by `.cg-view-row:hover`; a folder
head is not a view row, so NO rule ever revealed it β the folder's
actions button was painted at zero opacity in every state but keyboard
focus. The badge is what the owner saw sitting where the control should
have been. Deleting it and making the "β¦" unconditionally visible (one
rule in this wave's S4 region) are the two halves of one fix.
β `.cg-fold-count` in index.css now has no consumer in this tree. Left
in place rather than swept blind β same posture as `.cg-fold-new` above
β and booked in the S4 mailbox so the sweep is a decision, not a
discovery. The NAV rail's own count stays: that rail reveals its "β¦" on
hover already, so it never had this defect and item 21 never named it. */}
</button>
{/* β THE SYSTEM FOLDER HAS NO ACTIONS (item 18 / C-SHARE). "Shared with
me" is not the reader's folder: it cannot be renamed into something
else, duplicated into a second copy of other people's work, or
deleted β the contract calls it undeletable, and a menu whose every
row the server would refuse is three fake affordances rather than
one. Views MOVE OUT of it normally (that is per-receiver placement),
which is the only thing anyone needs to do to it. */}
{group.folder.id === SHARED_FOLDER_ID ? null : (
<button
type="button"
className="cg-view-more"
aria-label={`Actions for folder ${group.folder.name}`}
aria-haspopup="menu"
onClick={(e) => {
// β HOISTED OUT OF THE UPDATER β see the view menu's twin below. React
// nulls `currentTarget` when the handler returns, and a functional
// updater can be re-invoked AFTER that.
const anchor = e.currentTarget;
setFolderMenu((cur) =>
cur?.id === group.folder!.id ? null : { id: group.folder!.id, anchor }
);
}}
>
Β·Β·Β·
</button>
)}
</div>
)}
{/* A collapsed folder hides its rows but stays a DROP TARGET, so you can
file something into a folder you have tidied away. */}
{!shut &&
group.items.map((view) => {
const active = view.id === activeViewId;
const renaming = renamingId === view.id;
const mode = viewDisplayMode(view);
return (
<div
// Item 12 (C-LOCK) β `cg-view-lockset` marks a view locked to a cohort. The
// rail is where someone chooses which table to open, and a locked view shows a
// different row set than its siblings for a reason no filter states; the mark
// is what makes that visible before the click rather than after it.
className={
"cg-view-row" +
(active ? " is-active" : "") +
(view.config?.cohortLock ? " cg-view-lockset" : "") +
// β ITEM 5 (C7) β the same two marks the folder drag uses: the row being
// carried fades, and the row it would land ABOVE draws an insertion rule.
(viewDrag === view.id ? " is-viewdrag" : "") +
(viewOver === view.id && viewDrag && viewDrag !== view.id
? " is-drop-above"
: "")
}
key={view.id}
draggable={(foldersOn || viewsReorderable) && !renaming
&& mayEditView(view, viewer)}
onDragStart={(e) => {
// β TWO TYPES ON ONE DRAG, and that is the discrimination C7 asks for.
// `text/plain` is the FILE-INTO-A-FOLDER channel the folder groups already
// listen on and must keep working; `VIEW_DRAG_TYPE` is what a view ROW listens
// for. Sharing one channel would make a reorder look like a file-into to
// whichever handler fired first β the same collision `FOLD_DRAG_TYPE` exists to
// prevent one level up.
e.dataTransfer.setData("text/plain", view.id);
e.dataTransfer.setData(VIEW_DRAG_TYPE, view.id);
e.dataTransfer.effectAllowed = "move";
setViewDrag(view.id);
}}
// β A DRAG THAT ENDS ANYWHERE clears the marks. Without this, releasing over a
// non-drop target leaves the row faded and an insertion rule painted, and the
// rail looks stuck mid-gesture until the next render happens to clear it.
onDragEnd={() => {
setViewDrag(null);
setViewOver(null);
}}
{...viewDropHandlers(view.id)}
>
{renaming ? (
<input
className="cg-view-rename cg-input"
autoFocus
defaultValue={view.name}
onBlur={(event) => {
const next = event.target.value.trim();
if (next && next !== view.name) onRename(view.id, next);
setRenamingId(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") setRenamingId(null);
}}
/>
) : (
<button
type="button"
className="cg-view-main"
onClick={() => onSelect(view.id)}
// Item 9 kept this tooltip rather than dropping it as redundant: BOTH lines
// ellipsis at 188px, so the hover is now the only way to read either one in
// full. It used to show the note INSTEAD of the name, which meant a
// described view could not have its own truncated name revealed at all.
title={view.note ? `${view.name} β ${view.note}` : view.name}
>
{/* I12 β the row wears the view's CURRENT display mode, not a kind dot.
Same geometry the mode switcher and the create flyout use, so "what
Calendar looks like" is one drawing everywhere on this surface.
Wave-10 item 3 β and now the same PAINT, not just the same geometry.
The flyout has always drawn these marks in pastel (`ToneModeIcon`);
the list drew the bare `ModeIcon` and index.css forced `--lp-muted`
over it, so a view created as a blue Grid or a yellow Calendar landed
in the rail GREY. There is no per-view "creation colour" to restore β
`MODE_TONE` is a static modeβtone map, never a persisted field
(amendment A-1) β so painting the current mode's tone here reproduces
what the flyout showed, by construction. */}
<ToneModeIcon mode={mode} tone={MODE_TONE[mode]} size={16} />
{/* Wave-10 item 9 β name, then the description as a quiet second line.
Wrapped in one column so the icon and the lock stay on the ROW axis
while the text stacks; without the wrapper the note would become a
third flex sibling and sit beside the name. Both lines ellipsis rather
than wrap: a rail this narrow turns a wrapped sentence into a
four-line row and the list stops being scannable. */}
<span className="cg-view-text">
<span className="cg-view-name">{view.name}</span>
{view.note && (
<span className="cg-view-desc">{view.note}</span>
)}
</span>
{/* β WAVE 27 Β· OWNER ITEM 21 / RULING R14 β ALERT ON = BADGE ON.
The LIVE number of records matching this view's filter, in the red
accent, shown for exactly the views that carry an alert. Deleting the
alert removes it, because the badge is not stored anywhere β it is a
rendering of `alertCounts`, which is keyed by the alerts the server
lists.
β OUTSIDE `.cg-view-text`, deliberately. That element is the ellipsising
COLUMN holding the name over its description; a badge inside it becomes
part of the run that gets cut off, so the one number the user asked to
always see is the first thing to disappear on a long name. On the row
axis it takes its width first and the NAME ellipsises around it β which
is the correct priority and what the 188px budget is for. */}
{alertCounts[view.id] !== undefined && (
<span
className="cg-view-count"
// β WAVE 32 Β· T24 (R5) β ONE badge, TWO reasons it can be here, and the
// hover has to say WHICH. A view marked important shows the identical red
// pill as an alerted one (deliberately β R5 asks for the same number in the
// same place), so a single sentence about alerting would tell a reader who
// marked a view that they had created an alert they did not create. The
// number is the same fact; the promise attached to it is not.
title={
view.config?.important
? `${alertCounts[view.id].toLocaleString()} records match this view `
+ `β you marked it important`
: `${alertCounts[view.id].toLocaleString()} records match this `
+ `view β you are alerted when a new one arrives`
}
>
{alertCounts[view.id].toLocaleString()}
</span>
)}
{/* β wave17 R1 / C-LOCKV β ONE mark, TWO meanings, and they are genuinely
different things that both read as "locked":
Β· `kind === "locked"` β the view's ROWS are a hand-curated set. This is
what the retired Cohorts section used to say, and it is the more
consequential of the two: it explains why this view shows fewer
records than its siblings, which no filter chip accounts for.
Β· `isModeFrozen` β the view's DISPLAY MODE is frozen (the legacy
`locked` flag). It says nothing about which rows are here.
A locked view does NOT get the legacy flag set (the host's projection says
so explicitly), so most rows have exactly one reason. When a row has both,
the row-set meaning leads and the title carries the other β never two
padlocks side by side, which would read as a bug. */}
{/* β WAVE 20 item 23 (C-SHARE) β THE SECOND MARK, beside the padlock
and never instead of it. They answer different questions: the lock
says what this view may become, this says how it got to you. A
view someone shared with you is one you can be looking at without
having made it β the single most useful thing the rail can tell you
before the click.
β WAVE 21 (item 9, R12/C1): the narrowed cast this used to need is
gone β `shared`, `sharedRole` and `owner` are declared on `SavedView`
and the server now projects them. The mark also says WHICH GRANT: a
read-only share and an editable one look identical in the rail
otherwise, and the reader's first question on finding a menu with no
Rename in it is why. */}
{view.shared ? (
<span
className="cg-view-shared"
role="img"
aria-label={
view.owner
? `${view.name}, shared with you by ${view.owner}`
: `${view.name}, shared with you`
}
title={
(view.owner
? `Shared with you by ${view.owner}.`
: "Shared with you β someone gave you access to this view.") +
(view.sharedRole === "edit"
? " You can edit it."
: " You can view it, not change it.")
}
>
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
<circle cx="5.6" cy="5.6" r="2.2" fill="none" stroke="currentColor"
strokeWidth="1.3" />
<path d="M1.9 12.6c0-2 1.7-3.2 3.7-3.2s3.7 1.2 3.7 3.2"
fill="none" stroke="currentColor" strokeWidth="1.3"
strokeLinecap="round" />
<path d="M10.6 4.1a2.2 2.2 0 0 1 0 4.2M11.4 9.7c1.6.3 2.7 1.4 2.7 2.9"
fill="none" stroke="currentColor" strokeWidth="1.3"
strokeLinecap="round" />
</svg>
</span>
) : null}
{(view.kind === "locked" || isModeFrozen(view)) && (
<span
className="cg-view-lock"
// Not aria-hidden: "this view is frozen" is information, and the row's
// accessible name is the only place a non-sighted user can receive it.
role="img"
aria-label={
view.kind === "locked"
? `${view.name}, a locked view`
: `${MODE_LABELS[mode]} view, locked`
}
title={
view.kind === "locked"
? "Locked β this view shows only the records locked into it. " +
"Filters, sorts and columns still narrow within them." +
(isModeFrozen(view)
? ` It also stays a ${MODE_LABELS[mode].toLowerCase()}.`
: "")
: `Locked β this view stays a ${MODE_LABELS[
mode
].toLowerCase()}. Filters, sorts and columns are still editable.`
}
>
<LockMark />
</span>
)}
</button>
)}
<button
type="button"
className="cg-view-more"
aria-label={`Actions for ${view.name}`}
aria-haspopup="menu"
aria-expanded={menu?.viewId === view.id}
onClick={(event) => {
/**
* β THE ANCHOR IS READ HERE, NOT INSIDE THE UPDATER β and that one line
* is the difference between this rail working and the whole app going
* white. Found by _qa_live_rail.py on 2026-08-04, reproduced on the
* shipped build.
*
* React sets `event.currentTarget = null` the moment this handler
* returns (`executeDispatch`'s finally). A FUNCTIONAL updater is not
* guaranteed to run inside the handler: the eager-state path evaluates
* it once, synchronously, while `currentTarget` is still the button β
* but React re-invokes it when it processes the queue for real, and by
* then it is null. The menu therefore opened correctly and only died
* later, on a re-render triggered by something else entirely β which is
* why a white screen appeared one interaction AFTER the click that
* caused it. `AnchoredOverlay` then did `"getBoundingClientRect" in
* null` and took the tree down with it.
*/
const anchor = event.currentTarget;
setMenu((current) =>
current?.viewId === view.id ? null : { viewId: view.id, anchor }
);
}}
>
Β·Β·Β·
</button>
{/* Wave-10 item 9 inverted this: the description now renders HERE, on the
row, and the across-the-table banner in CustomerGrid is gone. Still one
place, not two β and now it is the place that names the view, and EVERY
view shows its own, not just the active one. */}
</div>
);
})}
{group.folder && !shut && group.items.length === 0 && (
<div className="cg-fold-empty">Empty β drag a view here.</div>
)}
</div>
);
})}
{/* β wave17 R1 / C-LOCKV β THE "COHORTS" SECTION IS GONE, and nothing replaced it here.
Wave 15 projected each cohort as a transient row under its own heading, which made a
locked view a second-class object: it could not be renamed, filed into a folder,
reordered, sorted or shared, because it was not a view. R1: it IS a view. The host
projects each one into the SAME list above (`locked_view_projection` β id preserved,
`config.cohortLock` naming its own id), so every one of those capabilities arrives for
free and this section has nothing left to render.
The lock MARK moved onto the ordinary view row, where `kind === "locked"` drives it. */}
</div>
{/* I11c β the folder "β¦" menu. Rename Β· Add to cohort Β· Duplicate Β· Delete.
WAVE 20 item 22 β every row now carries the SAME icon vocabulary the view row's
menu uses (`MenuLabel` over `MENU_ICONS`), and Delete is red AT REST rather than
only once armed. Two menus that do the same kind of thing to two kinds of object
were drawn in two registers: one with icons and a red delete, one with neither.
β NO NEW CSS FOR THE COLOUR: this overlay already wears `cg-view-menu`, so adding
`is-danger` to the button reuses `.cg-view-menu button.is-danger` β the exact rule
that paints the view row's Delete, which is what "matching" has to mean. The
`cg-menu-item--danger` class stays for its armed background. */}
{folderMenu && folderMenuF && (
<AnchoredOverlay
anchor={folderMenu.anchor}
className="cg-view-menu"
placement="bottom-end"
role="menu"
ariaLabel={`Actions for folder ${folderMenuF.name}`}
onDismiss={() => {
setFolderMenu(null);
setConfirmDelete(null);
}}
dataKind="folder-menu"
>
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
setRenamingFolder(folderMenuF.id);
setFolderMenu(null);
}}
>
<MenuLabel icon="rename" text="Rename" />
</button>
{folderAddPreview && onFolderAddToList && (
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
setAddTarget(folderMenuF.id);
setAddAnchor(folderMenu.anchor);
setAddName(today ? `${folderMenuF.name} Β· ${today}` : folderMenuF.name);
setFolderMenu(null);
}}
>
<MenuLabel icon="cohortAdd" text="Add to cohort" />
</button>
)}
{onFolderDuplicate && (
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
onFolderDuplicate(folderMenuF.id);
setFolderMenu(null);
}}
>
<MenuLabel icon="duplicate" text="Duplicate folder and its views" />
</button>
)}
{/* WAVE 20 item 18 (R10) β a folder shares, and its views ride along
(the server's rule, stated in C-SHARE; this row only opens the editor). */}
<button
type="button"
role="menuitem"
className="cg-menu-item"
onClick={() => {
openShare("folder", folderMenuF.id, folderMenuF.name);
setFolderMenu(null);
}}
>
<MenuLabel icon="permissions" text="Share folder" />
</button>
{onFolderDelete && (
<button
type="button"
role="menuitem"
className={
"cg-menu-item cg-menu-item--danger is-danger" +
(confirmDelete === folderMenuF.id ? " is-armed" : "")
}
onClick={() => {
if (confirmDelete !== folderMenuF.id) {
setConfirmDelete(folderMenuF.id);
return;
}
onFolderDelete(folderMenuF.id);
setConfirmDelete(null);
setFolderMenu(null);
}}
>
{/* The ARMED label is a whole sentence and the menu row is `nowrap` with a
260px ceiling, so it used to run out of the panel. It wraps while armed
(one rule in the S4 region) rather than being ellipsised: a confirmation
the reader cannot finish reading is not a confirmation. */}
<MenuLabel
icon="trash"
text={
confirmDelete === folderMenuF.id
? "Delete folder? Its views move to the top level."
: "Delete folder"
}
/>
</button>
)}
</AnchoredOverlay>
)}
{/* Item 12 (C-LOCK) β the lock picker. Lists the cohorts this reader can see, marks the
current one, and offers Unlock when there is one: a lock removable only from
somewhere other than where it was applied is a trap, and the owner asked for a
feature, not a one-way door. */}
{lockFor && onCohortLock && (() => {
const target = views.find((v) => v.id === lockFor.viewId);
const current = target?.config?.cohortLock;
return (
<AnchoredOverlay
anchor={lockFor.anchor}
className="cg-pop cg-lock-pop"
placement="bottom-start"
role="dialog"
ariaLabel="Lock this view to a locked view"
onDismiss={() => setLockFor(null)}
dataKind="view-cohort-lock"
>
<div className="cg-pop-title">Lock to a locked view</div>
<div className="cg-pop-note">
This view can then only ever show the records locked into the view you pick. Filters still narrow
within it, and the lock cannot be removed as a condition.
</div>
{(lists ?? []).map((l) => (
<button
type="button"
key={l.id}
className={"cg-pick-row" + (l.id === current ? " is-on" : "")}
onClick={() => {
onCohortLock(lockFor.viewId, l.id);
setLockFor(null);
}}
>
{l.name}
</button>
))}
{current && (
<button
type="button"
className="cg-pick-row cg-lock-clear"
onClick={() => {
onCohortLock(lockFor.viewId, null);
setLockFor(null);
}}
>
Unlock β show the whole table again
</button>
)}
</AnchoredOverlay>
);
})()}
{/* C4 as amended β the folder-level bulk add. The confirm pane states the
DEDUPED union and names every view it could not compute, because a
partial union that looks complete is the failure this pane exists to
prevent ([[no-unverifiable-aggregates]]). */}
{addTarget && addAnchor && addPreview && onFolderAddToList && (
<AnchoredOverlay
anchor={addAnchor ?? undefined}
className="cg-pop cg-fold-addpop"
placement="bottom-start"
role="dialog"
ariaLabel="Add this folder's customers to a locked view"
onDismiss={() => setAddTarget(null)}
dataKind="folder-add-to-list"
>
<div className="cg-pop-title">Add to cohort</div>
<div className="cg-pop-note">
{addPreview.pids.length.toLocaleString()} customer
{addPreview.pids.length === 1 ? "" : "s"} from {addPreview.counted.toLocaleString()}{" "}
view{addPreview.counted === 1 ? "" : "s"} in this folder, counted once each.
</div>
{addPreview.skipped.length > 0 && (
<div className="cg-pop-note cg-fold-skipped">
Not included:
{addPreview.skipped.map((sk) => (
<span key={sk.name} className="cg-fold-skip">
{sk.name} β {sk.why}
</span>
))}
</div>
)}
{(lists ?? []).map((l) => (
<button
type="button"
key={l.id}
className="cg-pick-row"
onClick={() => {
onFolderAddToList(addTarget, l.id, l.name);
setAddTarget(null);
}}
>
{l.name}
</button>
))}
<div className="cg-view-create">
<label htmlFor="cg-fold-new-list">Lock records into a new view</label>
<input
id="cg-fold-new-list"
className="cg-input"
data-overlay-autofocus
value={addName}
onChange={(e) => setAddName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && addName.trim()) {
onFolderAddToList(addTarget, "", addName.trim());
setAddTarget(null);
}
}}
/>
<button
type="button"
className="cg-btn cg-btn--primary"
disabled={!addName.trim() || addPreview.pids.length === 0}
onClick={() => {
onFolderAddToList(addTarget, "", addName.trim());
setAddTarget(null);
}}
>
Create and add
</button>
</div>
</AnchoredOverlay>
)}
{menu && menuView && (
<AnchoredOverlay
anchor={menu.anchor}
className="cg-view-menu"
placement="bottom-end"
role="menu"
ariaLabel={`Actions for ${menuView.name}`}
onDismiss={() => setMenu(null)}
onKeyDown={onMenuKeyDown}
dataKind="saved-view-menu"
>
{/* W2 (C2): Export sits ABOVE the rename/delete group, thin divider between. */}
{(onExport || onImport) && (
<>
{/* β WAVE-29 T32 (owner item 16 / R10) β the DATA group's header.
The owner's complaint was that Export "looks like an orphan": it is already the
FIRST row of this menu and the divider under it is the only one, but every row
is styled identically, so the divider reads as a stray line rather than as the
end of a group. A header names the group instead.
β NOT A TOOLBAR BUTTON (R10) β a standalone Export was deliberately removed on
2026-08-03 so this menu is the ONE door; item 6's Import joins it here.
β INLINE STYLE, and it is a fence decision rather than a preference: this menu
has no group-header class anywhere in the stylesheet, and `index.css` belongs to
another session this wave. Tokens only, so it cannot invent a colour. */}
<div
role="presentation"
style={{
padding: "6px 10px 2px",
fontSize: "10px",
fontWeight: 600,
letterSpacing: "0.02em",
color: "var(--lp-muted)",
}}
>
Data
</div>
{onExport && (
<button
type="button"
role="menuitem"
onClick={() => {
setExportFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel icon="download" text="Export" />
</button>
)}
{/* β WAVE-29 T25 (owner item 6) β IMPORT, beside Export, because they are the same
question in two directions and this menu is the ONE door for both (a standalone
Export button was deliberately removed on 2026-08-03; a second Import entry
anywhere else would re-open that).
β Supplied only on an EDITABLE database β the host passes `undefined` when
records are not mutable, which is what keeps the row off a locked one rather
than a check repeated here.
β `cohortAdd` is the nearest glyph the registry has; there is no upload mark
and `icons.tsx` is not this session's to extend. */}
{onImport && (
<button
type="button"
role="menuitem"
onClick={() => {
onImport();
setMenu(null);
}}
>
<MenuLabel icon="upload" text="Import" />
</button>
)}
<div className="cg-menu-sep" role="separator" aria-hidden />
{/* β¦and the VIEW group's own header, so the divider now sits BETWEEN two named
groups rather than under one lonely row. R10 asks for both names. */}
<div
role="presentation"
style={{
padding: "2px 10px 2px",
fontSize: "10px",
fontWeight: 600,
letterSpacing: "0.02em",
color: "var(--lp-muted)",
}}
>
View
</div>
</>
)}
{/* I17 (C4) β the view's IDENTITY (name, description, folder, deletion) is what
the view permission governs. Duplicate is deliberately NOT gated: making your
own copy changes nothing about this view, and it is how someone without edit
rights gets a version they can work in. */}
{canEditMenuView && (
<button
type="button"
role="menuitem"
onClick={() => {
setRenamingId(menuView.id);
setMenu(null);
}}
>
<MenuLabel icon="rename" text="Rename" />
</button>
)}
{canEditMenuView && (
<button
type="button"
role="menuitem"
onClick={() => {
setNoteDraft(menuView.note ?? "");
setNoteFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel
icon="description"
text={menuView.note ? "Edit description" : "Add description"}
/>
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
onDuplicate(menuView.id);
setMenu(null);
}}
>
<MenuLabel icon="duplicate" text="Duplicate" />
</button>
{/* WAVE 20 items 23/26 (R10) β "who else can reach this view", the same
editor a folder and a database open. Deliberately NOT gated on
`canEditMenuView`: the server decides who may administer (owner or
admin) and says so in the dialog, and hiding the row from everyone else
would hide the ANSWER too β "who has this?" is a fair question for
anyone the view was shared with. */}
<button
type="button"
role="menuitem"
onClick={() => {
openShare("view", menuView.id, menuView.name);
setMenu(null);
}}
>
<MenuLabel icon="permissions" text="Share view" />
</button>
{/* WAVE 20 item 25 (C-ALERT) β the door that MAKES an alert, on the view it
watches. It belongs here and nowhere else: an alert IS "tell me when a
record enters THIS view", so the only place the question has an obvious
subject is the view's own menu.
β The frame answers, because this rail does not know its own topic (it
holds views; the scope key belongs to the route) β and because the server
refuses a view with no active filter, which is a message the frame is
already in the business of showing. */}
{/* ββ WAVE 32 Β· T24 (owner item 17, ruling R5, contract C4) β "Mark important".
β ABOVE the alert row, which is the done-when's own word and not a nicety: the two
rows produce THE SAME red pill in the rail, and the cheap, private, instantly
reversible one has to be reachable before the one that creates a stored server-side
alert. Offered first, a user who only wanted to keep an eye on a number never has
to make an alert to get one.
β ONE MARK. R5 is explicit: no second severity, no colour variants β
`verify_icons` asserts the absence of the word so a second one cannot arrive by
copy-paste from this very row. The label FLIPS rather than the state being implied,
because "Mark important" on an already-marked view reads as a label for the view. */}
{onToggleImportant && (
<button
type="button"
role="menuitem"
onClick={() => {
onToggleImportant(menuView.id, !menuView.config?.important);
setMenu(null);
}}
>
<MenuLabel
icon={menuView.config?.important ? "unlock" : "permissions"}
text={menuView.config?.important ? "Unmark important" : "Mark important"}
/>
</button>
)}
<button
type="button"
role="menuitem"
onClick={() => {
window.dispatchEvent(
new CustomEvent("aios:alert-create", {
detail: { viewId: menuView.id, label: menuView.name },
})
);
setMenu(null);
}}
>
<MenuLabel icon={<BellIcon size={16} />} text="Alert me about new records" />
</button>
{/* β WAVE 21 item 11 (ruling R10) β "paste a list of names and tick those
records". It belongs in the VIEW's menu because the view is what decides
which records are on the table to be found, and the result says so.
β IT SELECTS THE VIEW FIRST when this is not the active one. The dialog
matches over the rows the grid currently holds, so offering it on a view
nobody is looking at would tick records in a DIFFERENT view's row set and
report misses against the wrong table. Switching first makes the menu's
subject and the dialog's subject the same object β and it is what clicking
the row would have done anyway. */}
{onSelectFromFile && (
<button
type="button"
role="menuitem"
onClick={() => {
if (menuView.id !== activeViewId) onSelect(menuView.id);
onSelectFromFile();
setMenu(null);
}}
>
<MenuLabel icon="cohortAdd" text="Select from file" />
</button>
)}
{onAddToList && (
<button
type="button"
role="menuitem"
onClick={() => {
// Item 4: `<view name> Β· <today>`, today from the PAYLOAD verbatim. No date
// when the host sent none β never the browser clock.
setNewListName(today ? `${menuView.name} Β· ${today}` : menuView.name);
setAddFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel icon="cohortAdd" text="Add to locked view" />
</button>
)}
{/* Item 12 (C-LOCK) β LOCK the view to a cohort. This menu is the ONLY emitter of
`cohortLock`, which is why it lands after RECORD's engine: an emitter for an
engine that cannot yet read the key writes a config that silently does nothing.
Deliberately NOT beside "Add to locked view" in meaning, though it sits beside it
in the menu: that one COPIES today's matches into a set; this one makes the set the
view's permanent boundary. The submenu lists the sets and offers Unlock when
one is already set, because a lock that cannot be removed from the same place it
was applied is a trap.
β wave17 R1 β HIDDEN ON A LOCKED VIEW ITSELF, and this is not tidiness. A
projected locked view's lock IS its identity: `aios_grid.views_from_defs` re-stamps
`config.cohortLock = view.id` on EVERY READ, unconditionally, so a lock chosen here
would be accepted by the UI, sent, and then silently overwritten by the host on the
next payload. The rail would show no change and nothing would go red β the
present-and-silently-inert affordance this file's own posture forbids. An action
the server will not honour must be ABSENT, not merely ineffective. */}
{onCohortLock && lists.length > 0 && canEditMenuView &&
menuView.kind !== "locked" && (
<button
type="button"
role="menuitem"
onClick={() => {
setLockFor({ viewId: menuView.id, anchor: menu.anchor });
setMenu(null);
}}
>
<MenuLabel
icon="permissions"
text={menuView.config?.cohortLock ? "Change the lock" : "Lock to a locked view"}
/>
</button>
)}
{/* I12 (C3) β freeze the DISPLAY MODE. Offered only to the creator or an admin,
and only a courtesy: the host enforces the same rule, because a hidden control
is still a reachable event. */}
{onToggleLock && menuView.kind !== "system" && canEditMenuView &&
mayToggleViewLock(menuView, viewer) && (
<button
type="button"
role="menuitem"
onClick={() => {
onToggleLock(menuView.id, !isModeFrozen(menuView));
setMenu(null);
}}
>
{/* The FROZEN action wears the mode's own mark rather than a second padlock:
"Lock as Kanban" beside the kanban glyph says what gets frozen, which a
padlock cannot. Unlocking is the undo, so it takes the open shackle. */}
<MenuLabel
icon={
isModeFrozen(menuView)
? "unlock"
: <ModeIcon mode={viewDisplayMode(menuView)} size={16} />
}
text={
isModeFrozen(menuView)
? "Unlock view"
: `Lock as ${MODE_LABELS[viewDisplayMode(menuView)]}`
}
/>
</button>
)}
{/* β Delete is gated on the HOST's actual rule, NOT on `locked`. C3 widens `locked`
to every view and defines it as mode-frozen only; leaving Delete on it would hide
the entry for a user-frozen Kanban that the host would happily delete. */}
{!isUndeletableView(menuView) && canEditMenuView && (
<button
type="button"
role="menuitem"
className="is-danger"
onClick={() => {
onDelete(menuView.id);
setMenu(null);
}}
>
<MenuLabel icon="trash" text="Delete" />
</button>
)}
</AnchoredOverlay>
)}
{/* W2 (C2) β the format pane: CSV Β· Excel Β· PDF Β· JSON, keyboard-navigable like
the menu it came from. Rows/columns are the view's CURRENT matches and visible
fields; generation is client-side (the grid already holds the answer). */}
{exportFor && exportView && onExport && (
<AnchoredOverlay
anchor={exportFor.anchor}
className="cg-view-menu"
placement="bottom-end"
role="menu"
ariaLabel={`Export ${exportView.name}`}
onDismiss={() => setExportFor(null)}
onKeyDown={onMenuKeyDown}
dataKind="view-export"
>
<div className="cg-pop-title cg-export-title">Export {exportView.name}</div>
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
role="menuitem"
onClick={() => {
onExport(exportView.id, format);
setExportFor(null);
}}
>
{EXPORT_LABELS[format]}
</button>
))}
</AnchoredOverlay>
)}
{/* Add to cohort β the view's CURRENT matches become fixed members of a cohort. Worded
as a one-time copy ("Add ... to") rather than a link, because that is what it is:
the cohort does not track the view afterwards, which is the entire difference
between a cohort and a saved view. */}
{addFor && addView && onAddToList && (
<AnchoredOverlay
anchor={addFor.anchor}
className="cg-pop cg-add-list-pop"
placement="bottom-end"
role="dialog"
ariaLabel={`Add ${addView.name} to a locked view`}
onDismiss={() => setAddFor(null)}
dataKind="add-to-list"
>
<div className="cg-pop-title">Add to cohort</div>
<div className="cg-pop-note">
Adds the customers <strong>{addView.name}</strong> matches right now. The cohort
stays fixed as the data changes.
</div>
{lists.map((l) => (
<button
type="button"
key={l.id}
className="cg-pick-row"
onClick={() => {
onAddToList(addView.id, l.id, l.name);
setAddFor(null);
}}
>
{l.name}
</button>
))}
<div className="cg-view-create">
<label htmlFor="cg-new-list">Lock records into a new view</label>
<input
id="cg-new-list"
className="cg-input"
data-overlay-autofocus
value={newListName}
placeholder="e.g. Q3 call plan"
onChange={(event) => setNewListName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && newListName.trim()) {
onAddToList(addView.id, "", newListName.trim());
setAddFor(null);
}
}}
/>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
disabled={!newListName.trim()}
onClick={() => {
onAddToList(addView.id, "", newListName.trim());
setAddFor(null);
}}
>
Create and add
</button>
</div>
</div>
</AnchoredOverlay>
)}
{/* Description editor β mirrors the column menu's field-note interaction so the
two "explain this thing" affordances behave identically. */}
{noteFor && noteView && (
<AnchoredOverlay
anchor={noteFor.anchor}
className="cg-pop cg-view-note-pop"
placement="bottom-end"
role="dialog"
ariaLabel={`Description for ${noteView.name}`}
onDismiss={() => setNoteFor(null)}
dataKind="saved-view-note"
>
<div className="cg-pop-title">Description</div>
<label className="cg-column-note">
{/* Wave-10 item 9 moved the description off the grid and onto the view's row,
so this sentence had to move with it β it described a banner that no longer
exists. */}
<span>Shown under this view's name in the list.</span>
<textarea
data-overlay-autofocus
value={noteDraft}
maxLength={2000}
placeholder="What is this list for?β¦"
onChange={(event) => setNoteDraft(event.target.value)}
/>
</label>
<div className="cg-form-actions">
<button
type="button"
className="cg-btn cg-btn--primary"
// Enabled whenever the text CHANGED β including changing it to empty,
// which is how a description is removed. Never compared against the
// template seed, only against what is currently stored.
disabled={noteDraft === (noteView.note ?? "")}
onClick={() => {
onNote(noteView.id, noteDraft);
setNoteFor(null);
}}
>
Save description
</button>
<button
type="button"
className="cg-btn"
onClick={() => setNoteFor(null)}
>
Cancel
</button>
</div>
</AnchoredOverlay>
)}
</aside>
);
}
|