@crossworks/share-ui 0.230.70 → 0.232.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/share-ui",
3
- "version": "0.230.70",
3
+ "version": "0.232.0",
4
4
  "description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
5
5
  "exports": {
6
6
  "./app-presenter": "./src/app-presenter.tsx",
@@ -19,6 +19,7 @@
19
19
  "./ui/button": "./src/ui/button.tsx",
20
20
  "./ui/input": "./src/ui/input.tsx",
21
21
  "./ui/label": "./src/ui/label.tsx",
22
+ "./lib/presenter-chrome": "./src/lib/presenter-chrome.ts",
22
23
  "./lib/utils": "./src/lib/utils.ts",
23
24
  "./page-outline": "./src/page-outline.tsx",
24
25
  "./nav-items": "./src/nav-items.ts",
@@ -31,8 +32,8 @@
31
32
  "./styles/app.css": "./styles/app.css"
32
33
  },
33
34
  "dependencies": {
34
- "@mantle/client-types": "npm:@crossworks/client-types@0.230.70",
35
- "@mantle/content-core": "npm:@crossworks/content-core@0.230.70",
35
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.0",
36
+ "@mantle/content-core": "npm:@crossworks/content-core@0.232.0",
36
37
  "@radix-ui/react-label": "^2.1.12",
37
38
  "@radix-ui/react-slot": "^1.3.0",
38
39
  "class-variance-authority": "^0.7.1",
@@ -1,3 +1,5 @@
1
+ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
2
+
1
3
  /**
2
4
  * Public draw render — the committed SVG snapshot, static, no JS.
3
5
  *
@@ -14,14 +16,19 @@
14
16
  export function DrawPresenter({
15
17
  view,
16
18
  src,
19
+ chrome,
17
20
  }: {
18
21
  view: { title: string; hasSvg: boolean };
19
22
  /** URL of the snapshot image (`/s/<token>/draw`). */
20
23
  src: string;
24
+ chrome?: PresenterChrome;
21
25
  }) {
26
+ const embedded = isEmbedded(chrome);
22
27
  return (
23
- <article className="mx-auto max-w-5xl px-6 py-12 md:py-16">
24
- <h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
28
+ <article className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-5xl px-6 py-12 md:py-16'}>
29
+ {!embedded && (
30
+ <h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
31
+ )}
25
32
  {view.hasSvg ? (
26
33
  <div className="overflow-hidden rounded-lg border border-border bg-white p-3">
27
34
  {/* eslint-disable-next-line @next/next/no-img-element -- a drawing
@@ -3,6 +3,8 @@ import remarkGfm from 'remark-gfm';
3
3
  import { CalendarDays, Clock, Download, MapPin } from 'lucide-react';
4
4
  import { formatDateTime } from '@mantle/client-types/lib/format-datetime';
5
5
  import { buildIcsHref } from '@mantle/client-types/lib/event-time';
6
+ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
7
+ import { cn } from './lib/utils';
6
8
 
7
9
  type EventView = {
8
10
  title: string;
@@ -12,18 +14,29 @@ type EventView = {
12
14
  location: string | null;
13
15
  };
14
16
 
15
- /** Public event render — a clean card with time, location, and an .ics link. */
16
- export function EventPresenter({ view }: { view: EventView }) {
17
+ /**
18
+ * Public event render — time, location, and an .ics link.
19
+ *
20
+ * On the standalone page this is a centred card: the card border is what tells
21
+ * a reader where the event begins and ends on an otherwise empty page. Embedded
22
+ * in a pane that already has a header rule and a border of its own, that same
23
+ * card is the "floating box in the middle" — so embedded drops the frame and
24
+ * the title, and lays the detail out on the pane. See `PresenterChrome`.
25
+ */
26
+ export function EventPresenter({ view, chrome }: { view: EventView; chrome?: PresenterChrome }) {
17
27
  const ics = buildIcsHref(view);
28
+ const embedded = isEmbedded(chrome);
18
29
  return (
19
- <div className="mx-auto max-w-2xl px-6 py-12 md:py-16">
20
- <div className="rounded-xl border border-border bg-card p-6">
21
- <div className="flex items-start gap-3">
22
- <CalendarDays className="mt-1 size-6 shrink-0 text-primary-ink" aria-hidden />
23
- <h1 className="text-2xl font-bold tracking-tight text-balance">{view.title}</h1>
24
- </div>
30
+ <div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-2xl px-6 py-12 md:py-16'}>
31
+ <div className={cn(!embedded && 'rounded-xl border border-border bg-card p-6')}>
32
+ {!embedded && (
33
+ <div className="flex items-start gap-3">
34
+ <CalendarDays className="mt-1 size-6 shrink-0 text-primary-ink" aria-hidden />
35
+ <h1 className="text-2xl font-bold tracking-tight text-balance">{view.title}</h1>
36
+ </div>
37
+ )}
25
38
 
26
- <dl className="mt-5 space-y-2 text-sm">
39
+ <dl className={cn('space-y-2 text-sm', !embedded && 'mt-5')}>
27
40
  {view.startsAt && (
28
41
  <div className="flex items-center gap-2 text-muted-foreground">
29
42
  <Clock className="size-4 shrink-0" aria-hidden />
@@ -1,17 +1,26 @@
1
1
  import { Download, FileText } from 'lucide-react';
2
2
  import { formatBytes } from '@mantle/client-types/lib/format-bytes';
3
+ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
3
4
 
4
5
  /**
5
6
  * Public file render with a media-appropriate viewer: images, PDFs, video, and
6
7
  * audio play inline (served from the scoped asset route); everything else gets
7
- * a clean download card. Centered.
8
+ * a download card.
9
+ *
10
+ * Embedded spans the pane. This is the presenter Jason was describing — a
11
+ * non-previewable file drew a `max-w-md` card, which is a phone-width box
12
+ * marooned in the middle of a 2000px pane. Every branch here gets better with
13
+ * width: a PDF, an image and a video all want the space, and the download row
14
+ * wants to look like a row rather than an island. See `PresenterChrome`.
8
15
  */
9
16
  export function FilePresenter({
10
17
  view,
11
18
  assetUrl,
19
+ chrome,
12
20
  }: {
13
21
  view: { fileId: string; filename: string; mimeType: string; size: number };
14
22
  assetUrl: (fileId: string) => string;
23
+ chrome?: PresenterChrome;
15
24
  }) {
16
25
  const src = assetUrl(view.fileId);
17
26
  const mime = view.mimeType || '';
@@ -19,12 +28,20 @@ export function FilePresenter({
19
28
  const isPdf = mime === 'application/pdf';
20
29
  const isVideo = mime.startsWith('video/');
21
30
  const isAudio = mime.startsWith('audio/');
31
+ const embedded = isEmbedded(chrome);
22
32
 
23
33
  return (
24
- <div className="mx-auto max-w-5xl px-6 py-12">
25
- <header className="mb-6 text-center">
26
- <h1 className="text-xl font-semibold tracking-tight">{view.filename}</h1>
27
- <p className="mt-1 text-xs text-muted-foreground">
34
+ <div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-5xl px-6 py-12'}>
35
+ {/* Embedded keeps the type/size line — the pane header carries only the
36
+ filename, so this is the one place a member learns what they are
37
+ looking at — but drops the hero title above it. */}
38
+ <header className={embedded ? 'mb-4' : 'mb-6 text-center'}>
39
+ {!embedded && <h1 className="text-xl font-semibold tracking-tight">{view.filename}</h1>}
40
+ <p
41
+ className={
42
+ embedded ? 'text-xs text-muted-foreground' : 'mt-1 text-xs text-muted-foreground'
43
+ }
44
+ >
28
45
  {mime || 'file'} · {formatBytes(view.size)}
29
46
  </p>
30
47
  </header>
@@ -43,7 +60,11 @@ export function FilePresenter({
43
60
  <iframe
44
61
  src={src}
45
62
  title={view.filename}
46
- className="h-[82vh] w-full rounded-lg border border-border"
63
+ className={
64
+ embedded
65
+ ? 'h-[calc(100dvh-14rem)] min-h-96 w-full rounded-lg border border-border'
66
+ : 'h-[82vh] w-full rounded-lg border border-border'
67
+ }
47
68
  />
48
69
  ) : isVideo ? (
49
70
  <video
@@ -52,11 +73,23 @@ export function FilePresenter({
52
73
  className="mx-auto max-h-[80vh] w-full rounded-lg border border-border bg-black"
53
74
  />
54
75
  ) : isAudio ? (
55
- <div className="mx-auto max-w-xl rounded-lg border border-border bg-card p-4">
76
+ <div
77
+ className={
78
+ embedded
79
+ ? 'rounded-lg border border-border bg-card p-4'
80
+ : 'mx-auto max-w-xl rounded-lg border border-border bg-card p-4'
81
+ }
82
+ >
56
83
  <audio src={src} controls className="w-full" />
57
84
  </div>
58
85
  ) : (
59
- <div className="mx-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-card p-4">
86
+ <div
87
+ className={
88
+ embedded
89
+ ? 'flex items-center gap-3 rounded-xl border border-border bg-card p-4'
90
+ : 'mx-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-card p-4'
91
+ }
92
+ >
60
93
  <FileText className="size-8 shrink-0 text-muted-foreground" aria-hidden />
61
94
  <div className="min-w-0 flex-1">
62
95
  <p className="truncate text-sm font-medium">{view.filename}</p>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Which shell a presenter is drawing inside.
3
+ *
4
+ * Every presenter here was written for ONE surface: the anonymous public `/s`
5
+ * page, where the presenter *is* the page. A centred column with a hero title
6
+ * and generous top padding is exactly right there — nothing else is on screen.
7
+ *
8
+ * `/team` then reused the same components inside a master-detail pane, and both
9
+ * of those choices became wrong at once. The pane already draws the title in
10
+ * its own header, so the hero title was the second of three on screen; and the
11
+ * centred cap meant dragging the divider only grew the empty margins while the
12
+ * content stayed a fixed narrow column. Members read that as "the drag is
13
+ * broken". The handle was fine — the content was ignoring it.
14
+ *
15
+ * So a presenter now says which shell it is in:
16
+ *
17
+ * - `'share'` (the default) — the standalone page. Unchanged, and it stays the
18
+ * default deliberately: the public page must not change because an embedder
19
+ * forgot a prop.
20
+ * - `'embedded'` — inside a shell that already owns the title and the padding.
21
+ * Drop the hero title, tighten the vertical rhythm, and let the content take
22
+ * the width it is given.
23
+ *
24
+ * ⚠ `'embedded'` is NOT a synonym for full-bleed. It means *the shell owns the
25
+ * chrome*; what to do with the width is still the content's call. A table, a
26
+ * file listing and a media viewer all get better as they get wider, so they
27
+ * span the pane. Prose does not — a 2000px line is unreadable no matter whose
28
+ * pane it is in — so a note keeps its measure and simply stops being centred
29
+ * under a title it no longer draws. Anything that reads as "a floating box in
30
+ * the middle of a big screen" is the bug; a comfortable reading measure is not.
31
+ */
32
+ export type PresenterChrome = 'share' | 'embedded';
33
+
34
+ /** Reads better at the call sites than comparing the string in each presenter. */
35
+ export function isEmbedded(chrome?: PresenterChrome): boolean {
36
+ return chrome === 'embedded';
37
+ }
@@ -1,11 +1,27 @@
1
1
  import ReactMarkdown from 'react-markdown';
2
2
  import remarkGfm from 'remark-gfm';
3
+ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
3
4
 
4
- /** Public note render — markdown (GFM) in a centered prose column. */
5
- export function NotePresenter({ view }: { view: { title: string; content: string } }) {
5
+ /**
6
+ * Public note render — markdown (GFM) in a prose column.
7
+ *
8
+ * Embedded keeps the measure. A note is prose, and prose wants a line length
9
+ * whoever is reading it; what it drops is the hero title the shell already
10
+ * draws, and the standalone page's tall top padding. See `PresenterChrome`.
11
+ */
12
+ export function NotePresenter({
13
+ view,
14
+ chrome,
15
+ }: {
16
+ view: { title: string; content: string };
17
+ chrome?: PresenterChrome;
18
+ }) {
19
+ const embedded = isEmbedded(chrome);
6
20
  return (
7
- <article className="mx-auto max-w-3xl px-6 py-12 md:py-16">
8
- <h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
21
+ <article className={embedded ? 'max-w-3xl px-6 py-6' : 'mx-auto max-w-3xl px-6 py-12 md:py-16'}>
22
+ {!embedded && (
23
+ <h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
24
+ )}
9
25
  <div className="prose dark:prose-invert max-w-none prose-accent prose-document">
10
26
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{view.content}</ReactMarkdown>
11
27
  </div>
@@ -1,36 +1,120 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { Loader2, Table2 } from 'lucide-react';
5
- import { Button } from './ui/button';
4
+ import {
5
+ ArrowUpRight,
6
+ Calendar,
7
+ CalendarClock,
8
+ DollarSign,
9
+ Hash,
10
+ Link as LinkIcon,
11
+ List,
12
+ Loader2,
13
+ Percent,
14
+ SquareCheck,
15
+ Tags,
16
+ Type,
17
+ Variable,
18
+ type LucideIcon,
19
+ } from 'lucide-react';
20
+ import { AGGREGATE_KINDS, type AggregateKind } from '@mantle/content-core/table-model';
6
21
  import { cn } from './lib/utils';
22
+ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
7
23
 
8
24
  type CellValue = string | number | boolean | string[] | null;
9
25
  type PublicRow = { id: string; cells: Record<string, CellValue> };
10
26
  type PublicColumn = { id: string; name: string; type: string };
11
- type PublicTab = { id: string; name: string; rowCount: number; columns: PublicColumn[] };
27
+ type PublicTab = {
28
+ id: string;
29
+ name: string;
30
+ rowCount: number;
31
+ columns: PublicColumn[];
32
+ aggregates?: Record<string, AggregateKind>;
33
+ aggregateValues?: Record<string, number | null>;
34
+ };
12
35
 
13
36
  const PAGE = 200;
14
37
 
38
+ /** Header glyphs, the owner grid's map (table-grid.tsx) — the two surfaces
39
+ * should not disagree about what a currency column looks like. */
40
+ const TYPE_ICON: Record<string, LucideIcon> = {
41
+ text: Type,
42
+ number: Hash,
43
+ currency: DollarSign,
44
+ percent: Percent,
45
+ date: Calendar,
46
+ datetime: CalendarClock,
47
+ checkbox: SquareCheck,
48
+ select: List,
49
+ multiselect: Tags,
50
+ url: LinkIcon,
51
+ formula: Variable,
52
+ reference: ArrowUpRight,
53
+ };
54
+
55
+ const AGG_LABEL: Record<AggregateKind, string> = {
56
+ none: 'None',
57
+ sum: 'Sum',
58
+ avg: 'Average',
59
+ count: 'Count',
60
+ min: 'Min',
61
+ max: 'Max',
62
+ empty: 'Empty',
63
+ filled: 'Filled',
64
+ };
65
+
66
+ const NUMERIC_TYPES = new Set(['number', 'currency', 'percent', 'formula']);
67
+
68
+ /** Aggregate values are plain numbers by the time they reach here. An average
69
+ * keeps two decimals; everything else renders as a grouped integer-ish number,
70
+ * which is what a total of a currency or a count wants. */
71
+ function formatAggregate(value: number, kind: AggregateKind): string {
72
+ if (kind === 'avg') return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
73
+ return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
74
+ }
75
+
15
76
  /**
16
- * Public read-only grid for a shared table. File-backed workbooks page their
17
- * PUBLISHED rows through GET /s/[token]/rows (offset windows, "Load more");
18
- * legacy JSONB tables arrive whole in the share view and render inline.
19
- * Raw fetch on purpose: apiFetch is the app shell's authenticated wrapper.
20
- * No edit affordances of any kind — the server surface is read-only anyway;
21
- * this component simply has nothing to strip.
77
+ * Read-only grid for a shared table.
78
+ *
79
+ * File-backed workbooks page their PUBLISHED rows through GET /s/[token]/rows;
80
+ * legacy JSONB tables arrive whole in the share view and render inline. Raw
81
+ * fetch on purpose: apiFetch is the app shell's authenticated wrapper. No edit
82
+ * affordances of any kind — the server surface is read-only anyway; this
83
+ * component simply has nothing to strip.
84
+ *
85
+ * ── Totals, and the one thing worth being careful about ────────────────────
86
+ *
87
+ * A footer total must never be computed from the rows this component happens to
88
+ * be holding. It pages 200 at a time, so a client-side sum over a partial
89
+ * window is not a smaller number — it is a WRONG one that looks exactly as
90
+ * authoritative as a right one. So for a file-backed tab every total comes from
91
+ * the server: the owner's presets ride in the share view already computed, and
92
+ * a total the READER picks is fetched from /s/[token]/aggregate, which runs it
93
+ * in SQL over every row.
94
+ *
95
+ * A legacy table is the exception, and only because it genuinely arrives whole:
96
+ * there, `computeAggregate` over the doc in hand is the complete answer.
97
+ *
98
+ * A reader's own choice is view-local and never persisted. Nothing here writes;
99
+ * reloading restores whatever the owner set.
22
100
  */
23
101
  export function TablePresenter({
24
102
  view,
25
103
  token,
104
+ chrome,
26
105
  }: {
27
106
  view: {
28
107
  title: string;
29
108
  icon: string | null;
30
109
  tabs: PublicTab[] | null;
31
- legacyDoc: { columns: PublicColumn[]; rows: PublicRow[] } | null;
110
+ legacyDoc: {
111
+ columns: PublicColumn[];
112
+ rows: PublicRow[];
113
+ aggregates?: Record<string, AggregateKind>;
114
+ } | null;
32
115
  };
33
116
  token: string;
117
+ chrome?: PresenterChrome;
34
118
  }) {
35
119
  const tabs = view.tabs ?? [];
36
120
  const [tabId, setTabId] = useState<string | null>(tabs[0]?.id ?? null);
@@ -46,6 +130,12 @@ export function TablePresenter({
46
130
  // Tab-switch race guard: a stale page for the previous tab must not land.
47
131
  const reqSeq = useRef(0);
48
132
 
133
+ /** The reader's own picks, per column — view-local, never sent anywhere that
134
+ * writes. Keyed per tab so switching tabs does not carry a choice across to
135
+ * a column that merely shares an id position. */
136
+ const [readerAggs, setReaderAggs] = useState<Record<string, AggregateKind>>({});
137
+ const [readerVals, setReaderVals] = useState<Record<string, number | null>>({});
138
+
49
139
  const fetchPage = useCallback(
50
140
  async (offset: number, forTab: string) => {
51
141
  const seq = ++reqSeq.current;
@@ -74,6 +164,8 @@ export function TablePresenter({
74
164
  if (tab) {
75
165
  setRows([]);
76
166
  setTotal(tab.rowCount);
167
+ setReaderAggs({});
168
+ setReaderVals({});
77
169
  void fetchPage(0, tab.id);
78
170
  }
79
171
  // Deliberately narrower than exhaustive-deps: refetch when the SELECTED TAB
@@ -95,110 +187,302 @@ export function TablePresenter({
95
187
  const hasMore = !view.legacyDoc && shownRows.length < totalRows;
96
188
 
97
189
  const numericCols = useMemo(
98
- () =>
99
- new Set(columns.filter((c) => c.type === 'number' || c.type === 'currency').map((c) => c.id)),
190
+ () => new Set(columns.filter((c) => NUMERIC_TYPES.has(c.type)).map((c) => c.id)),
100
191
  [columns],
101
192
  );
102
193
 
103
- return (
104
- <div className="mx-auto max-w-6xl px-6 py-12">
105
- <header className="mb-6 text-center">
194
+ /** The owner's presets for whatever is on screen. */
195
+ const ownerAggs = view.legacyDoc ? (view.legacyDoc.aggregates ?? {}) : (tab?.aggregates ?? {});
196
+
197
+ const kindFor = (colId: string): AggregateKind => readerAggs[colId] ?? ownerAggs[colId] ?? 'none';
198
+
199
+ /**
200
+ * The total to draw under a column, or null for "nothing to show".
201
+ *
202
+ * Three sources, and which one applies is entirely about whether the rows in
203
+ * hand are the whole story:
204
+ * - legacy → compute locally; the doc IS complete.
205
+ * - a reader's pick on a file-backed tab → the value fetched for it.
206
+ * - otherwise → the owner's preset, computed server-side in the share view.
207
+ */
208
+ const valueFor = (colId: string): number | null => {
209
+ const kind = kindFor(colId);
210
+ if (kind === 'none') return null;
211
+ if (view.legacyDoc) return legacyAggregate(view.legacyDoc.rows, colId, kind);
212
+ if (colId in readerAggs) return readerVals[colId] ?? null;
213
+ return tab?.aggregateValues?.[colId] ?? null;
214
+ };
215
+
216
+ const pickAggregate = async (colId: string, kind: AggregateKind) => {
217
+ setReaderAggs((prev) => ({ ...prev, [colId]: kind }));
218
+ if (kind === 'none' || view.legacyDoc || !tab) return;
219
+ setReaderVals((prev) => ({ ...prev, [colId]: null }));
220
+ try {
221
+ const qs = new URLSearchParams({ tab: tab.id, col: colId, kind });
222
+ const r = await fetch(`/s/${token}/aggregate?${qs.toString()}`, { cache: 'no-store' });
223
+ if (!r.ok) return;
224
+ const d = (await r.json()) as { value: number | null };
225
+ setReaderVals((prev) => ({ ...prev, [colId]: d.value }));
226
+ } catch {
227
+ // A failed total stays blank. There is no honest fallback — the rows in
228
+ // hand are a window, and a number from them would be wrong.
229
+ }
230
+ };
231
+
232
+ const embedded = isEmbedded(chrome);
233
+ const anyTotal = columns.some((c) => kindFor(c.id) !== 'none');
234
+
235
+ const header = (
236
+ <header className={embedded ? 'mb-3 shrink-0' : 'mb-6 shrink-0 text-center'}>
237
+ {!embedded && (
106
238
  <h1 className="text-xl font-semibold tracking-tight">
107
239
  {view.icon ? `${view.icon} ` : ''}
108
240
  {view.title}
109
241
  </h1>
110
- <p className="mt-1 text-xs text-muted-foreground">
111
- {totalRows} row{totalRows === 1 ? '' : 's'}
112
- </p>
113
- </header>
114
-
115
- {tabs.length > 1 && (
116
- <div className="mb-3 flex flex-wrap gap-1">
117
- {tabs.map((t) => (
118
- <button
119
- key={t.id}
120
- type="button"
121
- onClick={() => setTabId(t.id)}
122
- className={cn(
123
- 'rounded-md px-3 py-1.5 text-sm transition-colors',
124
- t.id === (tab?.id ?? null)
125
- ? 'bg-primary text-primary-foreground'
126
- : 'text-muted-foreground hover:bg-muted hover:text-foreground',
127
- )}
128
- >
129
- {t.name}
130
- </button>
131
- ))}
132
- </div>
133
242
  )}
243
+ <p className={cn('text-xs text-muted-foreground', !embedded && 'mt-1')}>
244
+ {view.legacyDoc || !hasMore
245
+ ? `${totalRows} row${totalRows === 1 ? '' : 's'}`
246
+ : `${shownRows.length} of ${totalRows} rows`}
247
+ </p>
248
+ </header>
249
+ );
134
250
 
135
- <div className="overflow-x-auto rounded-lg border border-border">
136
- <table className="w-full border-collapse text-sm">
137
- <thead>
138
- <tr className="border-b border-border bg-muted/50">
139
- {columns.map((c) => (
140
- <th
141
- key={c.id}
251
+ const tabStrip = tabs.length > 1 && (
252
+ <div className="mb-3 flex shrink-0 flex-wrap gap-1">
253
+ {tabs.map((t) => (
254
+ <button
255
+ key={t.id}
256
+ type="button"
257
+ onClick={() => setTabId(t.id)}
258
+ className={cn(
259
+ 'rounded-md px-3 py-1.5 text-sm transition-colors',
260
+ t.id === (tab?.id ?? null)
261
+ ? 'bg-primary text-primary-foreground'
262
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
263
+ )}
264
+ >
265
+ {t.name}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ );
270
+
271
+ const grid = (
272
+ <table className="w-full border-collapse text-sm">
273
+ {/* Sticky against the scroll box below, not the page: a grid whose
274
+ headers leave the screen at row 40 is a grid of unlabelled numbers. */}
275
+ <thead className="sticky top-0 z-10">
276
+ <tr className="border-b border-border bg-muted">
277
+ {columns.map((c) => {
278
+ const Icon = TYPE_ICON[c.type] ?? Type;
279
+ return (
280
+ <th
281
+ key={c.id}
282
+ className={cn(
283
+ 'whitespace-nowrap px-3 py-2 text-left font-medium text-muted-foreground',
284
+ numericCols.has(c.id) && 'text-right',
285
+ )}
286
+ >
287
+ <span
142
288
  className={cn(
143
- 'whitespace-nowrap px-3 py-2 text-left font-medium text-muted-foreground',
144
- numericCols.has(c.id) && 'text-right',
289
+ 'inline-flex items-center gap-1.5',
290
+ numericCols.has(c.id) && 'flex-row-reverse',
145
291
  )}
146
292
  >
293
+ <Icon className="size-3.5 shrink-0 opacity-70" aria-hidden />
147
294
  {c.name}
148
- </th>
149
- ))}
150
- </tr>
151
- </thead>
152
- <tbody>
153
- {shownRows.map((r) => (
154
- <tr key={r.id} className="border-b border-border/60 last:border-0 hover:bg-muted/30">
155
- {columns.map((c) => (
156
- <td
157
- key={c.id}
295
+ </span>
296
+ </th>
297
+ );
298
+ })}
299
+ </tr>
300
+ </thead>
301
+ <tbody>
302
+ {shownRows.map((r) => (
303
+ <tr key={r.id} className="border-b border-border/60 last:border-0 hover:bg-muted/30">
304
+ {columns.map((c) => (
305
+ <td
306
+ key={c.id}
307
+ className={cn(
308
+ 'max-w-96 truncate px-3 py-1.5',
309
+ numericCols.has(c.id) && 'text-right tabular-nums',
310
+ )}
311
+ >
312
+ <Cell value={r.cells[c.id] ?? null} type={c.type} />
313
+ </td>
314
+ ))}
315
+ </tr>
316
+ ))}
317
+ {shownRows.length === 0 && !loading && (
318
+ <tr>
319
+ <td
320
+ colSpan={Math.max(columns.length, 1)}
321
+ className="px-3 py-8 text-center text-muted-foreground"
322
+ >
323
+ {failed ? 'Could not load rows.' : 'No rows.'}
324
+ </td>
325
+ </tr>
326
+ )}
327
+ </tbody>
328
+ {/* Always rendered, even with nothing set: the row IS the affordance.
329
+ A member who wants a total needs somewhere to ask for one, and a
330
+ footer that appears only once the owner has already chosen leaves
331
+ them no door. `bg-background` because a transparent sticky row shows
332
+ the scrolling cells through itself. */}
333
+ <tfoot className="sticky bottom-0 z-10">
334
+ <tr className={cn('border-t-2 border-border bg-muted', !anyTotal && 'text-xs')}>
335
+ {columns.map((c) => {
336
+ const kind = kindFor(c.id);
337
+ const value = valueFor(c.id);
338
+ return (
339
+ <td
340
+ key={c.id}
341
+ className={cn(
342
+ 'border-l border-border/60 px-1.5 py-1 text-xs first:border-l-0',
343
+ numericCols.has(c.id) && 'text-right',
344
+ )}
345
+ >
346
+ <label className="flex items-center justify-end gap-1.5">
347
+ <span className="sr-only">Total for {c.name}</span>
348
+ {kind !== 'none' && (
349
+ <span className="min-w-0 truncate font-medium tabular-nums">
350
+ {value === null ? (
351
+ // null is a real answer — "this column cannot be
352
+ // totalled that way". A 0 here would be a lie.
353
+ <span className="text-muted-foreground/50">—</span>
354
+ ) : (
355
+ formatAggregate(value, kind)
356
+ )}
357
+ </span>
358
+ )}
359
+ <select
360
+ value={kind}
361
+ onChange={(e) => void pickAggregate(c.id, e.target.value as AggregateKind)}
158
362
  className={cn(
159
- 'max-w-96 truncate px-3 py-1.5',
160
- numericCols.has(c.id) && 'text-right tabular-nums',
363
+ 'cursor-pointer rounded-sm border-0 bg-transparent py-0 text-xs outline-none',
364
+ kind === 'none'
365
+ ? 'text-muted-foreground/60 hover:text-foreground'
366
+ : 'text-muted-foreground',
161
367
  )}
162
368
  >
163
- <Cell value={r.cells[c.id] ?? null} type={c.type} />
164
- </td>
165
- ))}
166
- </tr>
167
- ))}
168
- {shownRows.length === 0 && !loading && (
169
- <tr>
170
- <td
171
- colSpan={Math.max(columns.length, 1)}
172
- className="px-3 py-8 text-center text-muted-foreground"
173
- >
174
- {failed ? 'Could not load rows.' : 'No rows.'}
175
- </td>
176
- </tr>
177
- )}
178
- </tbody>
179
- </table>
180
- </div>
369
+ {AGGREGATE_KINDS.map((k) => (
370
+ <option key={k} value={k}>
371
+ {k === 'none' ? 'Σ' : AGG_LABEL[k]}
372
+ </option>
373
+ ))}
374
+ </select>
375
+ </label>
376
+ </td>
377
+ );
378
+ })}
379
+ </tr>
380
+ </tfoot>
381
+ </table>
382
+ );
181
383
 
182
- <div className="mt-3 flex items-center justify-center gap-3">
183
- {loading && (
184
- <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
185
- <Loader2 className="size-3 animate-spin" aria-hidden /> Loading…
186
- </span>
187
- )}
188
- {!loading && hasMore && tab && (
189
- <Button
190
- variant="outline"
191
- size="sm"
192
- onClick={() => void fetchPage(shownRows.length, tab.id)}
193
- >
194
- <Table2 /> Load more ({shownRows.length} of {totalRows})
195
- </Button>
196
- )}
384
+ const foot = (
385
+ <>
386
+ {loading && (
387
+ <div className="flex shrink-0 items-center justify-center gap-1.5 py-2 text-xs text-muted-foreground">
388
+ <Loader2 className="size-3 animate-spin" aria-hidden /> Loading…
389
+ </div>
390
+ )}
391
+ {/* Auto-fetch rather than a "Load more" button: scrolling toward the end
392
+ is already the gesture that means "show me the rest". */}
393
+ {!loading && hasMore && tab && (
394
+ <MoreSentinel onReveal={() => void fetchPage(shownRows.length, tab.id)} />
395
+ )}
396
+ </>
397
+ );
398
+
399
+ // Embedded, the grid OWNS the height it was given: the header and tab strip
400
+ // are fixed, and the table scrolls inside a bounded box so its sticky header
401
+ // and footer have something to stick to. On the standalone /s page there is
402
+ // no height to own — the page scrolls, the table simply grows, and sticky
403
+ // has nothing to do.
404
+ if (embedded) {
405
+ return (
406
+ <div className="flex h-full min-h-0 w-full flex-col px-6 py-6">
407
+ {header}
408
+ {tabStrip}
409
+ <div className="min-h-0 flex-1 overflow-auto rounded-lg border border-border scrollbar-thin">
410
+ {grid}
411
+ {foot}
412
+ </div>
197
413
  </div>
414
+ );
415
+ }
416
+
417
+ return (
418
+ <div className="mx-auto max-w-6xl px-6 py-12">
419
+ {header}
420
+ {tabStrip}
421
+ <div className="overflow-x-auto rounded-lg border border-border">{grid}</div>
422
+ {foot}
198
423
  </div>
199
424
  );
200
425
  }
201
426
 
427
+ /**
428
+ * The legacy path's total. Safe ONLY here: a legacy table's rows arrive whole
429
+ * in the share view, so this is the complete set, not a window. Mirrors
430
+ * computeAggregate's numeric semantics — non-numeric cells are ignored by the
431
+ * numeric kinds rather than counted as zero.
432
+ */
433
+ function legacyAggregate(rows: PublicRow[], colId: string, kind: AggregateKind): number | null {
434
+ const isEmptyCell = (v: CellValue) =>
435
+ v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
436
+ if (kind === 'count') return rows.length;
437
+ if (kind === 'filled') return rows.filter((r) => !isEmptyCell(r.cells[colId] ?? null)).length;
438
+ if (kind === 'empty') return rows.filter((r) => isEmptyCell(r.cells[colId] ?? null)).length;
439
+ const nums = rows
440
+ .map((r) => {
441
+ const v = r.cells[colId];
442
+ if (typeof v === 'number') return v;
443
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
444
+ if (typeof v === 'boolean') return v ? 1 : 0;
445
+ return null;
446
+ })
447
+ .filter((n): n is number => n !== null);
448
+ if (nums.length === 0) return null;
449
+ switch (kind) {
450
+ case 'sum':
451
+ return nums.reduce((a, b) => a + b, 0);
452
+ case 'avg':
453
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
454
+ case 'min':
455
+ return Math.min(...nums);
456
+ case 'max':
457
+ return Math.max(...nums);
458
+ default:
459
+ return null;
460
+ }
461
+ }
462
+
463
+ /** Fetches the next page when it scrolls into view. */
464
+ function MoreSentinel({ onReveal }: { onReveal: () => void }) {
465
+ const ref = useRef<HTMLDivElement>(null);
466
+ // The callback changes identity every render, so the observer reads it from a
467
+ // ref — re-creating the observer each render would disconnect it mid-scroll
468
+ // and the grid would stop growing until the reader scrolled again.
469
+ const reveal = useRef(onReveal);
470
+ reveal.current = onReveal;
471
+
472
+ useEffect(() => {
473
+ const el = ref.current;
474
+ if (!el) return;
475
+ const io = new IntersectionObserver(
476
+ (entries) => entries[0]?.isIntersecting && reveal.current(),
477
+ { rootMargin: '400px' },
478
+ );
479
+ io.observe(el);
480
+ return () => io.disconnect();
481
+ }, []);
482
+
483
+ return <div ref={ref} className="h-8 shrink-0" aria-hidden />;
484
+ }
485
+
202
486
  function Cell({ value, type }: { value: CellValue; type: string }) {
203
487
  if (value === null || value === undefined || value === '') {
204
488
  return <span className="text-muted-foreground/50">—</span>;
@@ -2,6 +2,8 @@ import ReactMarkdown from 'react-markdown';
2
2
  import remarkGfm from 'remark-gfm';
3
3
  import { Check, Circle } from 'lucide-react';
4
4
  import { formatDateTime } from '@mantle/client-types/lib/format-datetime';
5
+ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
6
+ import { cn } from './lib/utils';
5
7
 
6
8
  /** Human label per lifecycle status (raw value falls through for forward
7
9
  * compatibility — never render a snake_case token to a reader). */
@@ -12,9 +14,19 @@ const STATUS_LABEL: Record<string, string> = {
12
14
  done: 'Done',
13
15
  };
14
16
 
15
- /** Public task render — a clean status card. */
17
+ /**
18
+ * Public task render — a status card standalone, laid out on the pane when
19
+ * embedded.
20
+ *
21
+ * The status/priority/due chips survive both shells: they are the task's
22
+ * metadata, not decoration, and the pane header carries only the title. What
23
+ * embedded drops is the card frame, the hero title, and the done-tick beside it
24
+ * (the "Done" chip already says the same thing without a 24px glyph claiming
25
+ * the leftmost column). See `PresenterChrome`.
26
+ */
16
27
  export function TaskPresenter({
17
28
  view,
29
+ chrome,
18
30
  }: {
19
31
  view: {
20
32
  title: string;
@@ -24,48 +36,58 @@ export function TaskPresenter({
24
36
  dueAt: string | null;
25
37
  todos?: { text: string; done: boolean }[];
26
38
  };
39
+ chrome?: PresenterChrome;
27
40
  }) {
28
41
  const done = view.status === 'done';
29
42
  const todos = view.todos ?? [];
43
+ const embedded = isEmbedded(chrome);
44
+ const chips = (
45
+ <div className={cn('flex flex-wrap items-center gap-2 text-xs', !embedded && 'mt-3')}>
46
+ <span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
47
+ {STATUS_LABEL[view.status] ?? view.status.replace(/_/g, ' ')}
48
+ </span>
49
+ <span className="rounded-full border border-border px-2 py-0.5 capitalize text-muted-foreground">
50
+ {view.priority} priority
51
+ </span>
52
+ {view.dueAt && (
53
+ <span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
54
+ Due {formatDateTime(view.dueAt)}
55
+ </span>
56
+ )}
57
+ </div>
58
+ );
59
+
30
60
  return (
31
- <div className="mx-auto max-w-2xl px-6 py-12 md:py-16">
32
- <div className="rounded-xl border border-border bg-card p-6">
33
- <div className="flex items-start gap-3">
34
- <span
35
- className={
36
- 'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border ' +
37
- (done
38
- ? 'border-primary bg-primary text-primary-foreground'
39
- : 'border-border text-transparent')
40
- }
41
- aria-hidden
42
- >
43
- {done ? <Check className="size-4" /> : <Circle className="size-3" />}
44
- </span>
45
- <div className="min-w-0 flex-1">
46
- <h1
61
+ <div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-2xl px-6 py-12 md:py-16'}>
62
+ <div className={cn(!embedded && 'rounded-xl border border-border bg-card p-6')}>
63
+ {embedded ? (
64
+ chips
65
+ ) : (
66
+ <div className="flex items-start gap-3">
67
+ <span
47
68
  className={
48
- 'text-2xl font-bold tracking-tight ' +
49
- (done ? 'text-muted-foreground line-through' : '')
69
+ 'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border ' +
70
+ (done
71
+ ? 'border-primary bg-primary text-primary-foreground'
72
+ : 'border-border text-transparent')
50
73
  }
74
+ aria-hidden
51
75
  >
52
- {view.title}
53
- </h1>
54
- <div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
55
- <span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
56
- {STATUS_LABEL[view.status] ?? view.status.replace(/_/g, ' ')}
57
- </span>
58
- <span className="rounded-full border border-border px-2 py-0.5 capitalize text-muted-foreground">
59
- {view.priority} priority
60
- </span>
61
- {view.dueAt && (
62
- <span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
63
- Due {formatDateTime(view.dueAt)}
64
- </span>
65
- )}
76
+ {done ? <Check className="size-4" /> : <Circle className="size-3" />}
77
+ </span>
78
+ <div className="min-w-0 flex-1">
79
+ <h1
80
+ className={
81
+ 'text-2xl font-bold tracking-tight ' +
82
+ (done ? 'text-muted-foreground line-through' : '')
83
+ }
84
+ >
85
+ {view.title}
86
+ </h1>
87
+ {chips}
66
88
  </div>
67
89
  </div>
68
- </div>
90
+ )}
69
91
  {view.body && (
70
92
  <div className="prose prose-sm dark:prose-invert mt-5 max-w-none border-t border-border pt-5">
71
93
  <ReactMarkdown remarkPlugins={[remarkGfm]}>{view.body}</ReactMarkdown>
@@ -16,13 +16,22 @@ import type { TocEntry } from '@mantle/content-core/page-toc';
16
16
  import type { CoverageGap, FormulaSpec } from '@mantle/content-core/formula-spec';
17
17
  import type { TargetSignature } from '@mantle/content-core/formula-signature';
18
18
  import type { DimensionIssue } from '@mantle/content-core/formula-dimensions';
19
- import type { Column, Row } from '@mantle/content-core/table-model';
19
+ import type { AggregateKind, Column, Row } from '@mantle/content-core/table-model';
20
20
 
21
21
  export type ShareFolderListing = {
22
22
  /** ltree path currently being listed (the shared root or a descendant). */
23
23
  currentPath: string;
24
24
  folders: Array<{ id: string; path: string; slug: string; fileCount: number }>;
25
- files: Array<{ id: string; filename: string; mimeType: string; sizeBytes: number }>;
25
+ files: Array<{
26
+ id: string;
27
+ filename: string;
28
+ mimeType: string;
29
+ sizeBytes: number;
30
+ /** ISO instant of the last write, so an embedded listing can carry a
31
+ * Modified column. Optional because a client pinned to an older server
32
+ * still gets a payload without it. */
33
+ updatedAt?: string;
34
+ }>;
26
35
  };
27
36
 
28
37
  export type ShareViewPayload =
@@ -66,8 +75,29 @@ export type ShareViewPayload =
66
75
  name: string;
67
76
  rowCount: number;
68
77
  columns: Array<{ id: string; name: string; type: string }>;
78
+ /**
79
+ * The owner's footer totals for this tab: colId → kind, and the VALUE
80
+ * computed server-side over the whole tab.
81
+ *
82
+ * The value has to come from the server and this is the whole reason
83
+ * the field exists. A reader holds one 200-row window at a time, so a
84
+ * sum taken over what it happens to have loaded is not a smaller
85
+ * number — it is a WRONG one, and wrong quietly, which is worse than
86
+ * absent. `aggregateWindow` runs it in SQL over every row.
87
+ *
88
+ * Optional: an older server sends neither, and the footer simply does
89
+ * not render.
90
+ */
91
+ aggregates?: Record<string, AggregateKind>;
92
+ aggregateValues?: Record<string, number | null>;
69
93
  }> | null;
70
- legacyDoc: { columns: Column[]; rows: Row[] } | null;
94
+ legacyDoc: {
95
+ columns: Column[];
96
+ rows: Row[];
97
+ /** Legacy tables arrive WHOLE, so the reader can compute these itself
98
+ * with `computeAggregate` and no endpoint is involved. Settings only. */
99
+ aggregates?: Record<string, AggregateKind>;
100
+ } | null;
71
101
  }
72
102
  | {
73
103
  kind: 'formula';