@crossworks/share-ui 0.232.16 → 0.232.17

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.232.16",
3
+ "version": "0.232.17",
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",
@@ -32,8 +32,8 @@
32
32
  "./styles/app.css": "./styles/app.css"
33
33
  },
34
34
  "dependencies": {
35
- "@mantle/client-types": "npm:@crossworks/client-types@0.232.16",
36
- "@mantle/content-core": "npm:@crossworks/content-core@0.232.16",
35
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.17",
36
+ "@mantle/content-core": "npm:@crossworks/content-core@0.232.17",
37
37
  "@radix-ui/react-label": "^2.1.12",
38
38
  "@radix-ui/react-slot": "^1.3.0",
39
39
  "class-variance-authority": "^0.7.1",
@@ -1,4 +1,5 @@
1
1
  import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
2
+ import { cn } from './lib/utils';
2
3
 
3
4
  /**
4
5
  * Public draw render — the committed SVG snapshot, static, no JS.
@@ -9,28 +10,39 @@ import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
9
10
  * page, and external references don't load. acceptSceneSvg still validates at
10
11
  * commit, but this surface no longer depends on it being exhaustive.
11
12
  *
12
- * The white mat is deliberate — the snapshot is exported light-mode with its
13
- * own background, so it reads as a framed artwork on either page theme rather
14
- * than adapting (and mangling) its colours.
13
+ * Theme: the snapshot is exported light-mode with its own background. On the
14
+ * STANDALONE page the white mat stays on either theme — a framed artwork on a
15
+ * page that leaves the brain. EMBEDDED (the /team reader is a themed app
16
+ * surface), dark mode applies Excalidraw's own theme filter
17
+ * (invert + hue-rotate, the exact transform the editor uses) over the whole
18
+ * mat, so strokes and canvas read dark natively — UNLESS the scene contains a
19
+ * raster image (`hasImage`), which one flat filter would render as a negative;
20
+ * those keep the light mat, same veto the owner previews apply.
15
21
  */
16
22
  export function DrawPresenter({
17
23
  view,
18
24
  src,
19
25
  chrome,
20
26
  }: {
21
- view: { title: string; hasSvg: boolean };
27
+ view: { title: string; hasSvg: boolean; hasImage?: boolean };
22
28
  /** URL of the snapshot image (`/s/<token>/draw`). */
23
29
  src: string;
24
30
  chrome?: PresenterChrome;
25
31
  }) {
26
32
  const embedded = isEmbedded(chrome);
33
+ const invertible = embedded && view.hasImage !== true;
27
34
  return (
28
35
  <article className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-5xl px-6 py-12 md:py-16'}>
29
36
  {!embedded && (
30
37
  <h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
31
38
  )}
32
39
  {view.hasSvg ? (
33
- <div className="overflow-hidden rounded-lg border border-border bg-white p-3">
40
+ <div
41
+ className={cn(
42
+ 'overflow-hidden rounded-lg border border-border bg-white p-3',
43
+ invertible && 'dark:[filter:invert(93%)_hue-rotate(180deg)]',
44
+ )}
45
+ >
34
46
  {/* eslint-disable-next-line @next/next/no-img-element -- a drawing
35
47
  snapshot is not a next/image candidate: it's an owner-generated
36
48
  SVG of unknown dimensions served from our own share route. */}
@@ -1,6 +1,9 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
1
4
  import ReactMarkdown from 'react-markdown';
2
5
  import remarkGfm from 'remark-gfm';
3
- import { CalendarDays, Clock, Download, MapPin } from 'lucide-react';
6
+ import { CalendarCheck, CalendarDays, Clock, Download, MapPin, Repeat, Tag } from 'lucide-react';
4
7
  import { formatDateTime } from '@mantle/client-types/lib/format-datetime';
5
8
  import { buildIcsHref } from '@mantle/client-types/lib/event-time';
6
9
  import { isEmbedded, type PresenterChrome } from './lib/presenter-chrome';
@@ -12,20 +15,96 @@ type EventView = {
12
15
  startsAt: string | null;
13
16
  endsAt: string | null;
14
17
  location: string | null;
18
+ /** Optional — an older brain omits these and the rows simply don't render. */
19
+ recur?: string | null;
20
+ recurUntil?: string | null;
21
+ tags?: string[];
15
22
  };
16
23
 
24
+ /** Live clock, ticking only after mount. On the static /s render (no
25
+ * hydration for the event kind) this stays null and the hero shows the
26
+ * date-anchored snapshot instead — same SSR-safe fallback the owner pane
27
+ * uses. */
28
+ function useNow(): number | null {
29
+ const [now, setNow] = useState<number | null>(null);
30
+ useEffect(() => {
31
+ setNow(Date.now());
32
+ const t = setInterval(() => setNow(Date.now()), 1000);
33
+ return () => clearInterval(t);
34
+ }, []);
35
+ return now;
36
+ }
37
+
38
+ function remainingLabel(ms: number): string {
39
+ const s = Math.max(0, Math.floor(ms / 1000));
40
+ const days = Math.floor(s / 86_400);
41
+ if (days >= 2) return `${days} days`;
42
+ const hours = Math.floor(s / 3600);
43
+ if (hours >= 1) return `${hours} h ${Math.floor((s % 3600) / 60)} min`;
44
+ const min = Math.floor(s / 60);
45
+ return `${min}:${String(s % 60).padStart(2, '0')} min`;
46
+ }
47
+
17
48
  /**
18
- * Public event render — time, location, and an .ics link.
49
+ * The countdown hero — the owner pane's signature, ported for members: a
50
+ * coming event leads with HOW SOON, a running one says so live, a past one
51
+ * closes the loop. Left-aligned like every other row (the member asked "when
52
+ * is this", not for a poster). Degrades to plain dates with no JS.
53
+ */
54
+ function CountdownHero({ startsAt, endsAt }: { startsAt: string; endsAt: string | null }) {
55
+ const now = useNow();
56
+ const start = Date.parse(startsAt);
57
+ const end = endsAt ? Date.parse(endsAt) : start + 60 * 60 * 1000;
58
+ if (now === null) {
59
+ return <p className="text-sm font-medium text-primary-ink">{formatDateTime(startsAt)}</p>;
60
+ }
61
+ if (now < start) {
62
+ return (
63
+ <div>
64
+ <p className="text-3xl font-bold tabular-nums tracking-tight">
65
+ {remainingLabel(start - now)}
66
+ </p>
67
+ <p className="mt-0.5 text-xs text-muted-foreground">until it starts</p>
68
+ </div>
69
+ );
70
+ }
71
+ if (now <= end) {
72
+ return (
73
+ <div className="flex items-center gap-2">
74
+ <span className="relative flex size-2.5">
75
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-75" />
76
+ <span className="relative inline-flex size-2.5 rounded-full bg-success" />
77
+ </span>
78
+ <div>
79
+ <p className="text-lg font-semibold">Happening now</p>
80
+ {endsAt && <p className="text-xs text-muted-foreground">ends {formatDateTime(endsAt)}</p>}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+ return (
86
+ <div className="flex items-center gap-2 text-muted-foreground">
87
+ <CalendarCheck className="size-5 shrink-0" aria-hidden />
88
+ <p className="text-sm">Ended {formatDateTime(endsAt ?? startsAt)}</p>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Public event render — countdown, time, location, recurrence, tags, and an
95
+ * .ics link: the owner pane's details, member-safe (reminders stay private).
19
96
  *
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`.
97
+ * On the standalone page this is a card in a measured column: the card border
98
+ * is what tells a reader where the event begins and ends on an otherwise empty
99
+ * page. Embedded in a pane that already has a header rule and a border of its
100
+ * own, that same card is the "floating box in the middle" — so embedded drops
101
+ * the frame and the title, and lays the detail out on the pane. Everything is
102
+ * left-aligned in both modes. See `PresenterChrome`.
25
103
  */
26
104
  export function EventPresenter({ view, chrome }: { view: EventView; chrome?: PresenterChrome }) {
27
105
  const ics = buildIcsHref(view);
28
106
  const embedded = isEmbedded(chrome);
107
+ const recur = view.recur && view.recur !== 'none' ? view.recur : null;
29
108
  return (
30
109
  <div className={embedded ? 'w-full px-6 py-6' : 'mx-auto max-w-2xl px-6 py-12 md:py-16'}>
31
110
  <div className={cn(!embedded && 'rounded-xl border border-border bg-card p-6')}>
@@ -36,7 +115,13 @@ export function EventPresenter({ view, chrome }: { view: EventView; chrome?: Pre
36
115
  </div>
37
116
  )}
38
117
 
39
- <dl className={cn('space-y-2 text-sm', !embedded && 'mt-5')}>
118
+ {view.startsAt && (
119
+ <div className={cn(!embedded && 'mt-5')}>
120
+ <CountdownHero startsAt={view.startsAt} endsAt={view.endsAt} />
121
+ </div>
122
+ )}
123
+
124
+ <dl className={cn('space-y-2 text-sm', view.startsAt ? 'mt-4' : !embedded && 'mt-5')}>
40
125
  {view.startsAt && (
41
126
  <div className="flex items-center gap-2 text-muted-foreground">
42
127
  <Clock className="size-4 shrink-0" aria-hidden />
@@ -52,6 +137,30 @@ export function EventPresenter({ view, chrome }: { view: EventView; chrome?: Pre
52
137
  <span>{view.location}</span>
53
138
  </div>
54
139
  )}
140
+ {recur && (
141
+ <div className="flex items-center gap-2 text-muted-foreground">
142
+ <Repeat className="size-4 shrink-0" aria-hidden />
143
+ <span className="capitalize">
144
+ {recur}
145
+ {view.recurUntil ? ` · until ${formatDateTime(view.recurUntil)}` : ''}
146
+ </span>
147
+ </div>
148
+ )}
149
+ {(view.tags?.length ?? 0) > 0 && (
150
+ <div className="flex items-center gap-2 text-muted-foreground">
151
+ <Tag className="size-4 shrink-0" aria-hidden />
152
+ <span className="flex flex-wrap gap-1">
153
+ {view.tags!.map((t) => (
154
+ <span
155
+ key={t}
156
+ className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs text-muted-foreground"
157
+ >
158
+ {t}
159
+ </span>
160
+ ))}
161
+ </span>
162
+ </div>
163
+ )}
55
164
  </dl>
56
165
 
57
166
  {view.body && (
@@ -233,7 +233,10 @@ export function TablePresenter({
233
233
  const anyTotal = columns.some((c) => kindFor(c.id) !== 'none');
234
234
 
235
235
  const header = (
236
- <header className={embedded ? 'mb-3 shrink-0' : 'mb-6 shrink-0 text-center'}>
236
+ // Embedded: a thin meta strip flush with the pane — the grid below is
237
+ // full-bleed (owner-grid parity), so the padding lives here, not on a
238
+ // wrapper.
239
+ <header className={embedded ? 'shrink-0 px-3 pb-2 pt-2' : 'mb-6 shrink-0 text-center'}>
237
240
  {!embedded && (
238
241
  <h1 className="text-xl font-semibold tracking-tight">
239
242
  {view.icon ? `${view.icon} ` : ''}
@@ -249,7 +252,7 @@ export function TablePresenter({
249
252
  );
250
253
 
251
254
  const tabStrip = tabs.length > 1 && (
252
- <div className="mb-3 flex shrink-0 flex-wrap gap-1">
255
+ <div className={cn('flex shrink-0 flex-wrap gap-1', embedded ? 'px-3 pb-2' : 'mb-3')}>
253
256
  {tabs.map((t) => (
254
257
  <button
255
258
  key={t.id}
@@ -403,10 +406,14 @@ export function TablePresenter({
403
406
  // has nothing to do.
404
407
  if (embedded) {
405
408
  return (
406
- <div className="flex h-full min-h-0 w-full flex-col px-6 py-6">
409
+ // Full-bleed (owner-grid parity): no wrapper padding, no border box —
410
+ // the pane's edges are the grid's edges and the cells draw the rules.
411
+ // The scroller keeps the bounded height so the sticky header/footer
412
+ // still have something to stick to.
413
+ <div className="flex h-full min-h-0 w-full flex-col">
407
414
  {header}
408
415
  {tabStrip}
409
- <div className="min-h-0 flex-1 overflow-auto rounded-lg border border-border scrollbar-thin">
416
+ <div className="min-h-0 flex-1 overflow-auto border-t border-border scrollbar-thin">
410
417
  {grid}
411
418
  {foot}
412
419
  </div>
@@ -44,7 +44,10 @@ export function TaskPresenter({
44
44
  const chips = (
45
45
  <div className={cn('flex flex-wrap items-center gap-2 text-xs', !embedded && 'mt-3')}>
46
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, ' ')}
47
+ {/* Guarded like `todos` above: a payload from an older brain may omit
48
+ status, and an unguarded deref here took down the whole /team
49
+ workspace (no boundary caught it). */}
50
+ {STATUS_LABEL[view.status ?? 'open'] ?? (view.status ?? 'open').replace(/_/g, ' ')}
48
51
  </span>
49
52
  <span className="rounded-full border border-border px-2 py-0.5 capitalize text-muted-foreground">
50
53
  {view.priority} priority
@@ -62,6 +62,11 @@ export type ShareViewPayload =
62
62
  startsAt: string | null;
63
63
  endsAt: string | null;
64
64
  location: string | null;
65
+ /** Recurrence rule name ('daily'|'weekly'|…) + optional end — optional:
66
+ * an older brain omits them and the presenter simply shows nothing. */
67
+ recur?: string | null;
68
+ recurUntil?: string | null;
69
+ tags?: string[];
65
70
  }
66
71
  | { kind: 'file'; fileId: string; filename: string; mimeType: string; size: number }
67
72
  | { kind: 'app'; appId: string; title: string }