@crossworks/share-ui 0.230.70 → 0.231.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.231.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.231.0",
36
+ "@mantle/content-core": "npm:@crossworks/content-core@0.231.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>
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Loader2, Table2 } from 'lucide-react';
5
5
  import { Button } from './ui/button';
6
6
  import { cn } from './lib/utils';
7
+ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
7
8
 
8
9
  type CellValue = string | number | boolean | string[] | null;
9
10
  type PublicRow = { id: string; cells: Record<string, CellValue> };
@@ -23,6 +24,7 @@ const PAGE = 200;
23
24
  export function TablePresenter({
24
25
  view,
25
26
  token,
27
+ chrome,
26
28
  }: {
27
29
  view: {
28
30
  title: string;
@@ -31,6 +33,7 @@ export function TablePresenter({
31
33
  legacyDoc: { columns: PublicColumn[]; rows: PublicRow[] } | null;
32
34
  };
33
35
  token: string;
36
+ chrome?: PresenterChrome;
34
37
  }) {
35
38
  const tabs = view.tabs ?? [];
36
39
  const [tabId, setTabId] = useState<string | null>(tabs[0]?.id ?? null);
@@ -100,14 +103,21 @@ export function TablePresenter({
100
103
  [columns],
101
104
  );
102
105
 
106
+ const embedded = isEmbedded(chrome);
107
+
103
108
  return (
104
- <div className="mx-auto max-w-6xl px-6 py-12">
105
- <header className="mb-6 text-center">
106
- <h1 className="text-xl font-semibold tracking-tight">
107
- {view.icon ? `${view.icon} ` : ''}
108
- {view.title}
109
- </h1>
110
- <p className="mt-1 text-xs text-muted-foreground">
109
+ // A table is the clearest case for the embedded shell: every extra pixel is
110
+ // another column a member can read without scrolling sideways, and the
111
+ // centred 6xl cap was throwing that width away.
112
+ <div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-6xl px-6 py-12'}>
113
+ <header className={embedded ? 'mb-3' : 'mb-6 text-center'}>
114
+ {!embedded && (
115
+ <h1 className="text-xl font-semibold tracking-tight">
116
+ {view.icon ? `${view.icon} ` : ''}
117
+ {view.title}
118
+ </h1>
119
+ )}
120
+ <p className={cn('text-xs text-muted-foreground', !embedded && 'mt-1')}>
111
121
  {totalRows} row{totalRows === 1 ? '' : 's'}
112
122
  </p>
113
123
  </header>
@@ -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>
@@ -22,7 +22,16 @@ 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 =