fsanyoto commited on
Commit
07ed0d2
Β·
verified Β·
1 Parent(s): 2690a5a

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "07b629a",
3
  "releases": [
4
  {
5
  "version": "v53",
 
1
  {
2
+ "current": "80b80b9",
3
  "releases": [
4
  {
5
  "version": "v53",
VERSION CHANGED
@@ -1 +1 @@
1
- 07b629a
 
1
+ 80b80b9
platform/aios_grid.py CHANGED
@@ -1395,7 +1395,7 @@ def _clean_rhs(raw, valid_keys):
1395
  #: one lane's fence, so they ship in one change, and whether a mode may be CREATED is a separate
1396
  #: question answered by `CREATABLE_MODES` / `HELD_MODES`.
1397
  DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
1398
- 'timeseries', 'catalog', 'swipe', 'form', 'script'}
1399
 
1400
  #: W37-T20 (C2) β€” the cap on `display.script.id`. Script-view ids are minted server-side as
1401
  #: `'sv_' + secrets.token_urlsafe(9)` (`routes_script_views.py::create_script_view`), so ~15 chars;
 
1395
  #: one lane's fence, so they ship in one change, and whether a mode may be CREATED is a separate
1396
  #: question answered by `CREATABLE_MODES` / `HELD_MODES`.
1397
  DISPLAY_MODES = {'grid', 'list', 'calendar', 'kanban', 'map', 'dashboard', 'chart',
1398
+ 'timeseries', 'catalog', 'swipe', 'form', 'script', 'gallery', 'timeline'}
1399
 
1400
  #: W37-T20 (C2) β€” the cap on `display.script.id`. Script-view ids are minted server-side as
1401
  #: `'sv_' + secrets.token_urlsafe(9)` (`routes_script_views.py::create_script_view`), so ~15 chars;
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -136,6 +136,7 @@ import { MapView } from "./MapView";
136
  import type { RouteSourceView } from "./MapView";
137
  /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */
138
  import { CatalogView } from "./CatalogView";
 
139
  import { CATALOG_CODE_FIELD } from "./catalogData";
140
  import type { CatalogSpec } from "./types";
141
  /* ═══ end W18-C CATALOG ═══ */
@@ -6889,6 +6890,8 @@ function CustomerGridSurface({
6889
  const modeDataRows = useMemo(() => {
6890
  if (
6891
  displayMode !== "calendar" &&
 
 
6892
  displayMode !== "kanban" &&
6893
  displayMode !== "map" &&
6894
  displayMode !== "chart" &&
@@ -8520,6 +8523,30 @@ function CustomerGridSurface({
8520
  This table has no date field to place records by.
8521
  </div>
8522
  ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8523
  {displayMode === "kanban" &&
8524
  (kanbanField ? (
8525
  <KanbanView
 
136
  import type { RouteSourceView } from "./MapView";
137
  /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */
138
  import { CatalogView } from "./CatalogView";
139
+ import { GalleryView, TimelineView } from "./GalleryTimelineViews";
140
  import { CATALOG_CODE_FIELD } from "./catalogData";
141
  import type { CatalogSpec } from "./types";
142
  /* ═══ end W18-C CATALOG ═══ */
 
6890
  const modeDataRows = useMemo(() => {
6891
  if (
6892
  displayMode !== "calendar" &&
6893
+ displayMode !== "gallery" &&
6894
+ displayMode !== "timeline" &&
6895
  displayMode !== "kanban" &&
6896
  displayMode !== "map" &&
6897
  displayMode !== "chart" &&
 
8523
  This table has no date field to place records by.
8524
  </div>
8525
  ))}
8526
+ {displayMode === "gallery" && (
8527
+ <GalleryView
8528
+ rows={modeDataRows}
8529
+ fields={fields}
8530
+ visibleKeys={visibleKeys}
8531
+ titleKey={lockedKey}
8532
+ onOpen={setDetailPid}
8533
+ />
8534
+ )}
8535
+ {displayMode === "timeline" &&
8536
+ (calendarField ? (
8537
+ <TimelineView
8538
+ rows={modeDataRows}
8539
+ fields={fields}
8540
+ visibleKeys={visibleKeys}
8541
+ field={calendarField}
8542
+ titleKey={lockedKey}
8543
+ onOpen={setDetailPid}
8544
+ />
8545
+ ) : (
8546
+ <div className="cg-mode-empty">
8547
+ This table has no date field to place records on a timeline.
8548
+ </div>
8549
+ ))}
8550
  {displayMode === "kanban" &&
8551
  (kanbanField ? (
8552
  <KanbanView
web/src/customer-grid/GalleryTimelineViews.css ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .cg-gallery,
2
+ .cg-timeline {
3
+ padding: 16px;
4
+ }
5
+
6
+ .cg-gallery-bar,
7
+ .cg-timeline-bar {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: 8px;
11
+ min-height: 32px;
12
+ margin-bottom: 12px;
13
+ color: var(--lp-muted);
14
+ font-size: var(--lp-fs-2xs);
15
+ }
16
+
17
+ .cg-gallery-label,
18
+ .cg-timeline-label {
19
+ display: inline-flex;
20
+ align-items: center;
21
+ gap: 6px;
22
+ color: var(--lp-ink);
23
+ font-weight: 600;
24
+ }
25
+
26
+ .cg-gallery-count,
27
+ .cg-timeline-count {
28
+ font-variant-numeric: tabular-nums;
29
+ }
30
+
31
+ .cg-timeline-missing {
32
+ margin-left: auto;
33
+ color: var(--lp-yellow-deep);
34
+ background: var(--lp-yellow-tint);
35
+ border-radius: var(--lp-r-pill);
36
+ padding: 3px 8px;
37
+ font-variant-numeric: tabular-nums;
38
+ }
39
+
40
+ .cg-gallery-grid {
41
+ display: grid;
42
+ grid-template-columns: repeat(auto-fill, minmax(224px, 1fr));
43
+ gap: 10px;
44
+ }
45
+
46
+ .cg-gallery-card,
47
+ .cg-timeline-card {
48
+ border: 0;
49
+ background: transparent;
50
+ color: inherit;
51
+ font: inherit;
52
+ text-align: left;
53
+ cursor: pointer;
54
+ }
55
+
56
+ .cg-gallery-card {
57
+ display: grid;
58
+ grid-template-columns: 36px minmax(0, 1fr);
59
+ gap: 10px;
60
+ align-items: start;
61
+ min-height: 108px;
62
+ padding: 12px;
63
+ border: 1px solid var(--lp-line);
64
+ background: var(--lp-surface);
65
+ color: var(--lp-ink);
66
+ border-radius: var(--lp-r-md);
67
+ transition: transform 150ms ease-out, border-color 150ms ease-out, background 150ms ease-out;
68
+ }
69
+
70
+ .cg-gallery-mark {
71
+ display: grid;
72
+ place-items: center;
73
+ width: 36px;
74
+ height: 36px;
75
+ border-radius: var(--lp-r-sm);
76
+ color: var(--lp-blue-deep);
77
+ background: var(--lp-blue-tint);
78
+ }
79
+
80
+ .cg-gallery-copy,
81
+ .cg-timeline-card {
82
+ display: grid;
83
+ gap: 7px;
84
+ min-width: 0;
85
+ }
86
+
87
+ .cg-gallery-copy > strong,
88
+ .cg-timeline-card > strong {
89
+ overflow: hidden;
90
+ text-overflow: ellipsis;
91
+ white-space: nowrap;
92
+ font-size: var(--lp-fs-sm);
93
+ font-weight: 600;
94
+ }
95
+
96
+ .cg-gallery-detail,
97
+ .cg-timeline-detail {
98
+ display: flex;
99
+ justify-content: space-between;
100
+ gap: 12px;
101
+ color: var(--lp-muted);
102
+ font-size: var(--lp-fs-3xs);
103
+ }
104
+
105
+ .cg-gallery-detail b,
106
+ .cg-timeline-detail b {
107
+ min-width: 0;
108
+ overflow: hidden;
109
+ text-overflow: ellipsis;
110
+ white-space: nowrap;
111
+ color: var(--lp-ink);
112
+ font-weight: 500;
113
+ font-variant-numeric: tabular-nums;
114
+ }
115
+
116
+ .cg-timeline-list {
117
+ display: grid;
118
+ gap: 18px;
119
+ }
120
+
121
+ .cg-timeline-month {
122
+ display: grid;
123
+ gap: 8px;
124
+ }
125
+
126
+ .cg-timeline-month > h3 {
127
+ margin: 0;
128
+ color: var(--lp-muted);
129
+ font-size: var(--lp-fs-2xs);
130
+ font-weight: 600;
131
+ }
132
+
133
+ .cg-timeline-event {
134
+ display: grid;
135
+ grid-template-columns: 72px minmax(0, 1fr);
136
+ gap: 12px;
137
+ align-items: stretch;
138
+ }
139
+
140
+ .cg-timeline-date {
141
+ position: relative;
142
+ padding-top: 12px;
143
+ color: var(--lp-muted);
144
+ font-size: var(--lp-fs-3xs);
145
+ text-align: right;
146
+ }
147
+
148
+ .cg-timeline-date::after {
149
+ position: absolute;
150
+ top: 14px;
151
+ right: -18px;
152
+ width: 8px;
153
+ height: 8px;
154
+ border-radius: var(--lp-r-pill);
155
+ background: var(--lp-yellow-deep);
156
+ content: "";
157
+ }
158
+
159
+ .cg-timeline-card {
160
+ position: relative;
161
+ padding: 11px 12px;
162
+ border: 1px solid var(--lp-line);
163
+ background: var(--lp-surface);
164
+ color: var(--lp-ink);
165
+ border-radius: var(--lp-r-md);
166
+ transition: transform 150ms ease-out, border-color 150ms ease-out, background 150ms ease-out;
167
+ }
168
+
169
+ .cg-timeline-card::before {
170
+ position: absolute;
171
+ top: -9px;
172
+ bottom: -9px;
173
+ left: -17px;
174
+ width: 1px;
175
+ background: var(--lp-line);
176
+ content: "";
177
+ }
178
+
179
+ .cg-gallery-card:focus-visible,
180
+ .cg-timeline-card:focus-visible {
181
+ outline: 2px solid var(--lp-blue-deep);
182
+ outline-offset: 2px;
183
+ }
184
+
185
+ @media (hover: hover) and (pointer: fine) {
186
+ .cg-gallery-card:hover,
187
+ .cg-timeline-card:hover {
188
+ border-color: var(--lp-blue-deep);
189
+ background: var(--lp-surface-2);
190
+ transform: translateY(-1px);
191
+ }
192
+ }
193
+
194
+ @media (prefers-reduced-motion: reduce) {
195
+ .cg-gallery-card,
196
+ .cg-timeline-card {
197
+ transition: none;
198
+ }
199
+ }
200
+
201
+ @media (max-width: 620px) {
202
+ .cg-timeline-event {
203
+ grid-template-columns: 58px minmax(0, 1fr);
204
+ gap: 10px;
205
+ }
206
+ }
web/src/customer-grid/GalleryTimelineViews.tsx ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useMemo } from "react";
2
+
3
+ import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
4
+ import { formatDisplay } from "./display";
5
+ import { ModeIcon } from "./icons";
6
+ import type { Field, Row } from "./types";
7
+ import "./GalleryTimelineViews.css";
8
+
9
+ function visibleDetailFields(fields: Field[], visibleKeys: string[], excluded: string[]) {
10
+ const byKey = new Map(fields.map((field) => [field.key, field]));
11
+ return visibleKeys
12
+ .filter((key) => !excluded.includes(key))
13
+ .map((key) => byKey.get(key))
14
+ .filter((field): field is Field => !!field)
15
+ .slice(0, 3);
16
+ }
17
+
18
+ function titleFor(row: Row, titleKey: string) {
19
+ const value = String(row[titleKey] ?? "").trim();
20
+ return value || "Untitled record";
21
+ }
22
+
23
+ function openRecord(pid: number, detail: number, onOpen: (pid: number) => void) {
24
+ if (detail <= 1) signal(NAV_MINIMIZE_EVENT);
25
+ onOpen(pid);
26
+ }
27
+
28
+ /** A quiet card arrangement over the exact filtered rows the Grid would paint. */
29
+ export const GalleryView = memo(function GalleryView({
30
+ rows,
31
+ fields,
32
+ visibleKeys,
33
+ titleKey,
34
+ onOpen,
35
+ }: {
36
+ rows: Row[];
37
+ fields: Field[];
38
+ visibleKeys: string[];
39
+ titleKey: string;
40
+ onOpen: (pid: number) => void;
41
+ }) {
42
+ const details = useMemo(
43
+ () => visibleDetailFields(fields, visibleKeys, [titleKey]),
44
+ [fields, visibleKeys, titleKey]
45
+ );
46
+
47
+ if (!rows.length) return <div className="cg-mode-empty">No records match this view.</div>;
48
+
49
+ return (
50
+ <section className="cg-gallery" aria-label="Gallery">
51
+ <div className="cg-gallery-bar">
52
+ <span className="cg-gallery-label"><ModeIcon mode="gallery" /> Gallery</span>
53
+ <span className="cg-gallery-count">{rows.length.toLocaleString()} records</span>
54
+ </div>
55
+ <div className="cg-gallery-grid">
56
+ {rows.map((row) => {
57
+ const title = titleFor(row, titleKey);
58
+ return (
59
+ <button
60
+ type="button"
61
+ className="cg-gallery-card"
62
+ key={row.pid}
63
+ onClick={(event) => openRecord(row.pid, event.detail, onOpen)}
64
+ aria-label={`Open ${title}`}
65
+ >
66
+ <span className="cg-gallery-mark" aria-hidden><ModeIcon mode="gallery" /></span>
67
+ <span className="cg-gallery-copy">
68
+ <strong>{title}</strong>
69
+ {details.map((field) => (
70
+ <span className="cg-gallery-detail" key={field.key}>
71
+ <span>{field.label}</span>
72
+ <b>{formatDisplay(field, row[field.key])}</b>
73
+ </span>
74
+ ))}
75
+ </span>
76
+ </button>
77
+ );
78
+ })}
79
+ </div>
80
+ </section>
81
+ );
82
+ });
83
+
84
+ type TimelineEvent = { row: Row; day: string; title: string };
85
+
86
+ function monthLabel(day: string) {
87
+ return new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric", timeZone: "UTC" })
88
+ .format(new Date(`${day.slice(0, 7)}-01T00:00:00Z`));
89
+ }
90
+
91
+ function dayLabel(day: string) {
92
+ return new Intl.DateTimeFormat(undefined, { weekday: "short", day: "numeric", timeZone: "UTC" })
93
+ .format(new Date(`${day}T00:00:00Z`));
94
+ }
95
+
96
+ /** A chronological projection. It never writes dates: editing remains in the normal record drawer. */
97
+ export const TimelineView = memo(function TimelineView({
98
+ rows,
99
+ fields,
100
+ visibleKeys,
101
+ field,
102
+ titleKey,
103
+ onOpen,
104
+ }: {
105
+ rows: Row[];
106
+ fields: Field[];
107
+ visibleKeys: string[];
108
+ field: Field;
109
+ titleKey: string;
110
+ onOpen: (pid: number) => void;
111
+ }) {
112
+ const events = useMemo<TimelineEvent[]>(
113
+ () => rows
114
+ .map((row) => ({ row, day: String(row[field.key] ?? "").slice(0, 10), title: titleFor(row, titleKey) }))
115
+ .filter((event) => /^\d{4}-\d{2}-\d{2}$/.test(event.day))
116
+ .sort((a, b) => a.day.localeCompare(b.day) || a.title.localeCompare(b.title)),
117
+ [rows, field.key, titleKey]
118
+ );
119
+ const details = useMemo(
120
+ () => visibleDetailFields(fields, visibleKeys, [titleKey, field.key]),
121
+ [fields, visibleKeys, titleKey, field.key]
122
+ );
123
+ const missing = rows.length - events.length;
124
+ const months = useMemo(() => {
125
+ const grouped = new Map<string, TimelineEvent[]>();
126
+ for (const event of events) {
127
+ const key = event.day.slice(0, 7);
128
+ grouped.set(key, [...(grouped.get(key) ?? []), event]);
129
+ }
130
+ return [...grouped.entries()];
131
+ }, [events]);
132
+
133
+ if (!events.length) {
134
+ return (
135
+ <div className="cg-mode-empty">
136
+ No records have a valid {field.label} date for this timeline.
137
+ </div>
138
+ );
139
+ }
140
+
141
+ return (
142
+ <section className="cg-timeline" aria-label="Timeline">
143
+ <div className="cg-timeline-bar">
144
+ <span className="cg-timeline-label"><ModeIcon mode="timeline" /> Timeline</span>
145
+ <span className="cg-timeline-count">{events.length.toLocaleString()} dated records Β· by {field.label}</span>
146
+ {missing > 0 && <span className="cg-timeline-missing">{missing.toLocaleString()} without a date</span>}
147
+ </div>
148
+ <div className="cg-timeline-list">
149
+ {months.map(([month, monthEvents]) => (
150
+ <section className="cg-timeline-month" key={month} aria-label={monthLabel(`${month}-01`)}>
151
+ <h3>{monthLabel(`${month}-01`)}</h3>
152
+ {monthEvents.map((event) => (
153
+ <div className="cg-timeline-event" key={event.row.pid}>
154
+ <span className="cg-timeline-date">{dayLabel(event.day)}</span>
155
+ <button
156
+ type="button"
157
+ className="cg-timeline-card"
158
+ onClick={(click) => openRecord(event.row.pid, click.detail, onOpen)}
159
+ aria-label={`Open ${event.title}`}
160
+ >
161
+ <strong>{event.title}</strong>
162
+ {details.map((detail) => (
163
+ <span className="cg-timeline-detail" key={detail.key}>
164
+ <span>{detail.label}</span>
165
+ <b>{formatDisplay(detail, event.row[detail.key])}</b>
166
+ </span>
167
+ ))}
168
+ </button>
169
+ </div>
170
+ ))}
171
+ </section>
172
+ ))}
173
+ </div>
174
+ </section>
175
+ );
176
+ });
web/src/customer-grid/iconShapes.ts CHANGED
@@ -196,6 +196,15 @@ export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
196
  { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
197
  ],
198
  calendar: CALENDAR,
 
 
 
 
 
 
 
 
 
199
  kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
200
  map: [
201
  { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
@@ -416,6 +425,8 @@ export const MODE_LABELS: Record<DisplayMode, string> = {
416
  dashboard: "Chart",
417
  list: "List",
418
  calendar: "Calendar",
 
 
419
  kanban: "Kanban",
420
  map: "Map",
421
  timeseries: "Time series",
@@ -477,8 +488,8 @@ export const MODE_LABELS: Record<DisplayMode, string> = {
477
  // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
478
  // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
479
  // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
480
- // It lands LAST by contract: the owner's seven-mode order above is a product decision and the
481
- // new mode joins the end of it rather than being sorted into it.
482
  // ⭐ Wave-27 C3 (owner item 8) β€” `swipe` is HELD OUT of this list, and the reason corrects a
483
  // mistake this file made an hour earlier.
484
  //
@@ -508,8 +519,7 @@ export const MODE_LABELS: Record<DisplayMode, string> = {
508
  // for its CONSUMER β€” who imports/mounts/registers it β€” excluding the file itself, `_test/`, css
509
  // and comments. `SwipeView` had four hits and three were prose.
510
  // Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
511
- // It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
512
- // mode joins the end of it rather than being sorted into it.
513
  export const CREATABLE_MODES: DisplayMode[] = [
514
  "grid",
515
  "chart",
@@ -547,6 +557,8 @@ export const CREATABLE_MODES: DisplayMode[] = [
547
  // it on a pool scope would mint a view the rail can never show
548
  // ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
549
  "script",
 
 
550
  ];
551
 
552
  /**
@@ -583,6 +595,8 @@ export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
583
  list: "View",
584
  kanban: "View",
585
  calendar: "View",
 
 
586
  chart: "View",
587
  dashboard: "View",
588
  // ⭐ WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
@@ -619,6 +633,8 @@ export const MODE_TONE: Record<DisplayMode, FolderTone> = {
619
  chart: "green",
620
  dashboard: "green",
621
  calendar: "yellow",
 
 
622
  kanban: "yellow",
623
  map: "red",
624
  // Green with `chart`: it is the other analytical mode, and the two belong to one family.
 
196
  { d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
197
  ],
198
  calendar: CALENDAR,
199
+ gallery: [
200
+ { d: "M2.8 3.2h4.1v4.1H2.8zM9.1 3.2h4.1v4.1H9.1zM2.8 9.1h4.1v3.7H2.8zM9.1 9.1h4.1v3.7H9.1z" },
201
+ ],
202
+ timeline: [
203
+ { d: "M4.1 3v10M4.1 5.2h2.1M4.1 8h4.2M4.1 10.8h2.1" },
204
+ { d: circle(4.1, 5.2, 0.8) },
205
+ { d: circle(4.1, 8, 0.8) },
206
+ { d: circle(4.1, 10.8, 0.8) },
207
+ ],
208
  kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
209
  map: [
210
  { d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
 
425
  dashboard: "Chart",
426
  list: "List",
427
  calendar: "Calendar",
428
+ gallery: "Gallery",
429
+ timeline: "Timeline",
430
  kanban: "Kanban",
431
  map: "Map",
432
  timeseries: "Time series",
 
488
  // Wave-18 C6-CATALOG β€” `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
489
  // accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
490
  // "C6 HOST MIRROR APPLIED β€” you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
491
+ // Each new mode joins the end by contract: the owner's order above is a product decision rather
492
+ // than a candidate for alphabetical sorting.
493
  // ⭐ Wave-27 C3 (owner item 8) β€” `swipe` is HELD OUT of this list, and the reason corrects a
494
  // mistake this file made an hour earlier.
495
  //
 
519
  // for its CONSUMER β€” who imports/mounts/registers it β€” excluding the file itself, `_test/`, css
520
  // and comments. `SwipeView` had four hits and three were prose.
521
  // Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
522
+ // Gallery and Timeline later join after Script by that same append-only ordering rule.
 
523
  export const CREATABLE_MODES: DisplayMode[] = [
524
  "grid",
525
  "chart",
 
557
  // it on a pool scope would mint a view the rail can never show
558
  // ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
559
  "script",
560
+ "gallery",
561
+ "timeline",
562
  ];
563
 
564
  /**
 
595
  list: "View",
596
  kanban: "View",
597
  calendar: "View",
598
+ gallery: "View",
599
+ timeline: "View",
600
  chart: "View",
601
  dashboard: "View",
602
  // ⭐ WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
 
633
  chart: "green",
634
  dashboard: "green",
635
  calendar: "yellow",
636
+ gallery: "blue",
637
+ timeline: "yellow",
638
  kanban: "yellow",
639
  map: "red",
640
  // Green with `chart`: it is the other analytical mode, and the two belong to one family.
web/src/customer-grid/types.ts CHANGED
@@ -573,7 +573,7 @@ export type FieldScope = "cohort" | "global";
573
  // stored `script` view draws rather than blanking if the host mirror ever lands.
574
  export const DISPLAY_MODES = [
575
  "grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
576
- "form", "swipe", "script",
577
  ] as const;
578
  export type DisplayMode = (typeof DISPLAY_MODES)[number];
579
 
 
573
  // stored `script` view draws rather than blanking if the host mirror ever lands.
574
  export const DISPLAY_MODES = [
575
  "grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
576
+ "form", "swipe", "script", "gallery", "timeline",
577
  ] as const;
578
  export type DisplayMode = (typeof DISPLAY_MODES)[number];
579
 
web/src/customer-grid/viewModes.tsx CHANGED
@@ -251,10 +251,10 @@ export function ModeSwitch({
251
  date field and a board without a stack field are not modes, so the picker is never
252
  in its placeholder state here unless the view names a field the table has lost β€”
253
  which it now SHOWS rather than silently replacing with the first choice. */}
254
- {mode === "calendar" && dateChoices.length > 0 && (
255
  <FieldSelectButton
256
  className="cg-mode-field"
257
- ariaLabel="Calendar date field"
258
  value={dateField?.key}
259
  onChange={(key) => onPickField("dateField", key)}
260
  fields={dateChoices.map((f) => ({ key: f.key, label: f.label, type: f.type }))}
 
251
  date field and a board without a stack field are not modes, so the picker is never
252
  in its placeholder state here unless the view names a field the table has lost β€”
253
  which it now SHOWS rather than silently replacing with the first choice. */}
254
+ {(mode === "calendar" || mode === "timeline") && dateChoices.length > 0 && (
255
  <FieldSelectButton
256
  className="cg-mode-field"
257
+ ariaLabel={mode === "calendar" ? "Calendar date field" : "Timeline date field"}
258
  value={dateField?.key}
259
  onChange={(key) => onPickField("dateField", key)}
260
  fields={dateChoices.map((f) => ({ key: f.key, label: f.label, type: f.type }))}
web/src/index.css CHANGED
@@ -13471,7 +13471,7 @@ input.conn-odoo-v:disabled { background: var(--lp-surface-2); color: var(--lp-mu
13471
  .cg-date-grid { margin-top: 3px; }
13472
  .cg-date-day { min-height: 27px; border: 0; border-radius: var(--lp-r-sm); background: transparent; color: var(--lp-ink); cursor: pointer; font: inherit; font-size: var(--lp-fs-2xs); }
13473
  .cg-date-day:hover, .cg-date-day:focus-visible { background: var(--lp-surface-2); outline: none; }
13474
- .cg-date-day.is-selected { background: var(--lp-blue-solid); color: var(--lp-on-blue, #fff); }
13475
  .cg-date-recurrence, .cg-date-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--lp-line); font-size: var(--lp-fs-2xs); }
13476
  .cg-date-recurrence .cg-pop-title, .cg-date-preview .cg-pop-title { width: 100%; }
13477
  .cg-date-interval { width: 42px; padding: 3px; }
 
13471
  .cg-date-grid { margin-top: 3px; }
13472
  .cg-date-day { min-height: 27px; border: 0; border-radius: var(--lp-r-sm); background: transparent; color: var(--lp-ink); cursor: pointer; font: inherit; font-size: var(--lp-fs-2xs); }
13473
  .cg-date-day:hover, .cg-date-day:focus-visible { background: var(--lp-surface-2); outline: none; }
13474
+ .cg-date-day.is-selected { background: var(--lp-blue-solid); color: var(--lp-surface); }
13475
  .cg-date-recurrence, .cg-date-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--lp-line); font-size: var(--lp-fs-2xs); }
13476
  .cg-date-recurrence .cg-pop-title, .cg-date-preview .cg-pop-title { width: 100%; }
13477
  .cg-date-interval { width: 42px; padding: 3px; }
web/wiring_rows/wave43_shell_repair.py CHANGED
@@ -17,9 +17,9 @@ ROWS: list[tuple[str, str, str, str, str]] = [
17
  r'onChange=\{\(value\) => patchAndRecord\(datePicker\.pid, \{ \[dateField\.key\]: value \}, "a date"\)\}'),
18
  ("W43C-T02 recurrence is a required DatePicker contract and persists the field definition",
19
  "customer-grid/DatePicker.tsx",
20
- r'onRecurrence: \(recurrence: NonNullable<Field\["recurrence"\]>, nextDate: string \| undefined\) => void;',
21
  "customer-grid/CustomerGrid.tsx",
22
- r'onRecurrence=\{\(recurrence, nextDate\) => saveField\(\{ \.\.\.dateField, recurrence, nextDate \}\)\}'),
23
  ]
24
 
25
  DELETIONS: list[tuple[str, str, str, str]] = []
 
17
  r'onChange=\{\(value\) => patchAndRecord\(datePicker\.pid, \{ \[dateField\.key\]: value \}, "a date"\)\}'),
18
  ("W43C-T02 recurrence is a required DatePicker contract and persists the field definition",
19
  "customer-grid/DatePicker.tsx",
20
+ r'onRecurrence: \(recurrence: DateRecurrence \| null\) => void;',
21
  "customer-grid/CustomerGrid.tsx",
22
+ r'onRecurrence=\{\(recurrence\) => saveField\(\{ \.\.\.dateField, recurrence: recurrence \?\? null \}\)\}'),
23
  ]
24
 
25
  DELETIONS: list[tuple[str, str, str, str]] = []