@drawnagency/primitives 0.1.111 → 0.1.112
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/dist/{chunk-3GAG6ZXB.js → chunk-3SZNUCXI.js} +8 -4
- package/dist/chunk-4I5OO5UB.js +785 -0
- package/dist/{chunk-ALMEC3XN.js → chunk-5KNXTMUC.js} +22 -18
- package/dist/{chunk-OUYB2GCW.js → chunk-BR4MIPTM.js} +1 -57
- package/dist/chunk-CXZ5YJG4.js +85 -0
- package/dist/{chunk-RA5IOFPY.js → chunk-DNY72JZN.js} +16 -38
- package/dist/{chunk-7MI3HCOF.js → chunk-EPWBPIV6.js} +10 -6
- package/dist/chunk-FV7JQRCN.js +94 -0
- package/dist/{chunk-UVIYI4J6.js → chunk-HSXMOS5Q.js} +15 -2
- package/dist/chunk-IIDKW7AV.js +41 -0
- package/dist/chunk-IINIZBQB.js +204 -0
- package/dist/chunk-MM7KZS5C.js +30 -0
- package/dist/{chunk-VCCNKTFD.js → chunk-OGGZ5AXT.js} +3 -31
- package/dist/{chunk-XQMDWXD6.js → chunk-P3ZNCHW7.js} +1 -1
- package/dist/chunk-QQ3S3P5P.js +38 -0
- package/dist/{chunk-ELQ4C677.js → chunk-TALRVOGO.js} +9 -44
- package/dist/chunk-VAYZJKVR.js +209 -0
- package/dist/{chunk-OTDA6KWL.js → chunk-VLPELTPI.js} +1 -1
- package/dist/{chunk-7WQJ57XG.js → chunk-VT3PYGVW.js} +68 -260
- package/dist/chunk-WUJ3XNFI.js +62 -0
- package/dist/{chunk-NXGIHSL5.js → chunk-Z2LO4PSQ.js} +11 -91
- package/dist/collab/rebase.js +1 -1
- package/dist/components/chat/ChatComposer.d.ts +25 -0
- package/dist/components/chat/ChatComposer.d.ts.map +1 -0
- package/dist/components/chat/ChatPanel.d.ts +5 -4
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/markdown.d.ts +14 -0
- package/dist/components/chat/markdown.d.ts.map +1 -0
- package/dist/components/editor/index.js +16 -11
- package/dist/components/primitives/EditablePlainText.d.ts.map +1 -1
- package/dist/components/primitives/index.js +15 -10
- package/dist/components/sections/brand-guide.js +11 -9
- package/dist/components/sections/register-schemas.js +19 -14
- package/dist/components/sections/register.js +19 -14
- package/dist/components/shell/AudienceChips.d.ts +10 -0
- package/dist/components/shell/AudienceChips.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsDecks.d.ts +13 -0
- package/dist/components/shell/SiteSettingsDecks.d.ts.map +1 -0
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/deck/DeckComponentView.d.ts +13 -0
- package/dist/deck/DeckComponentView.d.ts.map +1 -0
- package/dist/deck/DeckPlayer.d.ts +26 -0
- package/dist/deck/DeckPlayer.d.ts.map +1 -0
- package/dist/deck/DeckSlideView.d.ts +17 -0
- package/dist/deck/DeckSlideView.d.ts.map +1 -0
- package/dist/deck/DeckStage.d.ts +16 -0
- package/dist/deck/DeckStage.d.ts.map +1 -0
- package/dist/deck/components/DeckImage.d.ts +14 -0
- package/dist/deck/components/DeckImage.d.ts.map +1 -0
- package/dist/deck/components/DeckImageGrid.d.ts +27 -0
- package/dist/deck/components/DeckImageGrid.d.ts.map +1 -0
- package/dist/deck/components/DeckRichText.d.ts +22 -0
- package/dist/deck/components/DeckRichText.d.ts.map +1 -0
- package/dist/deck/components/DeckVideo.d.ts +15 -0
- package/dist/deck/components/DeckVideo.d.ts.map +1 -0
- package/dist/deck/editing.d.ts +50 -0
- package/dist/deck/editing.d.ts.map +1 -0
- package/dist/deck/flip.d.ts +11 -0
- package/dist/deck/flip.d.ts.map +1 -0
- package/dist/deck/index.d.ts +24 -0
- package/dist/deck/index.d.ts.map +1 -0
- package/dist/deck/index.js +53 -0
- package/dist/deck/layouts/CenteredTextLayout.d.ts +12 -0
- package/dist/deck/layouts/CenteredTextLayout.d.ts.map +1 -0
- package/dist/deck/layouts/ColumnSplitLayout.d.ts +14 -0
- package/dist/deck/layouts/ColumnSplitLayout.d.ts.map +1 -0
- package/dist/deck/layouts/FullBleedImageLayout.d.ts +14 -0
- package/dist/deck/layouts/FullBleedImageLayout.d.ts.map +1 -0
- package/dist/deck/media.d.ts +33 -0
- package/dist/deck/media.d.ts.map +1 -0
- package/dist/deck/tokens.d.ts +22 -0
- package/dist/deck/tokens.d.ts.map +1 -0
- package/dist/deck/useStageScale.d.ts +10 -0
- package/dist/deck/useStageScale.d.ts.map +1 -0
- package/dist/deck-builder/DeckBuilderApp.d.ts +30 -0
- package/dist/deck-builder/DeckBuilderApp.d.ts.map +1 -0
- package/dist/deck-builder/DeckBuilderTopBar.d.ts +24 -0
- package/dist/deck-builder/DeckBuilderTopBar.d.ts.map +1 -0
- package/dist/deck-builder/DeckChatPanel.d.ts +45 -0
- package/dist/deck-builder/DeckChatPanel.d.ts.map +1 -0
- package/dist/deck-builder/DeckEditor.d.ts +37 -0
- package/dist/deck-builder/DeckEditor.d.ts.map +1 -0
- package/dist/deck-builder/DeckLibrary.d.ts +22 -0
- package/dist/deck-builder/DeckLibrary.d.ts.map +1 -0
- package/dist/deck-builder/DeckSlideSurface.d.ts +31 -0
- package/dist/deck-builder/DeckSlideSurface.d.ts.map +1 -0
- package/dist/deck-builder/deck-api.d.ts +76 -0
- package/dist/deck-builder/deck-api.d.ts.map +1 -0
- package/dist/deck-builder/deck-chat-client.d.ts +44 -0
- package/dist/deck-builder/deck-chat-client.d.ts.map +1 -0
- package/dist/deck-builder/deck-chat-history.d.ts +10 -0
- package/dist/deck-builder/deck-chat-history.d.ts.map +1 -0
- package/dist/deck-builder/index.d.ts +13 -0
- package/dist/deck-builder/index.d.ts.map +1 -0
- package/dist/deck-builder/index.js +1505 -0
- package/dist/deck-builder/starter-deck.d.ts +23 -0
- package/dist/deck-builder/starter-deck.d.ts.map +1 -0
- package/dist/deck-builder/types.d.ts +29 -0
- package/dist/deck-builder/types.d.ts.map +1 -0
- package/dist/deck-builder/useSortableSlide.d.ts +29 -0
- package/dist/deck-builder/useSortableSlide.d.ts.map +1 -0
- package/dist/index.js +70 -63
- package/dist/lib/deck-access.d.ts +86 -0
- package/dist/lib/deck-access.d.ts.map +1 -0
- package/dist/lib/deck-access.js +58 -0
- package/dist/lib/deck-dexie.d.ts +88 -0
- package/dist/lib/deck-dexie.d.ts.map +1 -0
- package/dist/lib/deck-dexie.js +22 -0
- package/dist/lib/deck-edit.d.ts +67 -0
- package/dist/lib/deck-edit.d.ts.map +1 -0
- package/dist/lib/deck-media-assets.d.ts +27 -0
- package/dist/lib/deck-media-assets.d.ts.map +1 -0
- package/dist/lib/deck-media-assets.js +25 -0
- package/dist/lib/deck-media-refs.d.ts +35 -0
- package/dist/lib/deck-media-refs.d.ts.map +1 -0
- package/dist/lib/deck-media-refs.js +36 -0
- package/dist/lib/deck-tool.d.ts +25 -0
- package/dist/lib/deck-tool.d.ts.map +1 -0
- package/dist/lib/deck-tool.js +58 -0
- package/dist/lib/index-ops.js +2 -2
- package/dist/lib/index.js +17 -12
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/media/index.js +3 -3
- package/dist/schemas/deck-meta.d.ts +85 -0
- package/dist/schemas/deck-meta.d.ts.map +1 -0
- package/dist/schemas/deck-meta.js +64 -0
- package/dist/schemas/deck.d.ts +954 -0
- package/dist/schemas/deck.d.ts.map +1 -0
- package/dist/schemas/deck.js +65 -0
- package/dist/schemas/index.js +39 -37
- package/dist/schemas/site-config.d.ts +13 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +37 -1
- package/src/components/chat/ChatComposer.tsx +100 -0
- package/src/components/chat/ChatPanel.tsx +20 -69
- package/src/components/chat/markdown.ts +32 -0
- package/src/components/primitives/EditablePlainText.tsx +11 -1
- package/src/components/shell/AudienceChips.tsx +30 -0
- package/src/components/shell/EditorShell.tsx +12 -0
- package/src/components/shell/SiteSettingsChatbot.tsx +1 -18
- package/src/components/shell/SiteSettingsDecks.tsx +41 -0
- package/src/components/shell/SiteSettingsModal.tsx +11 -0
- package/src/deck/DeckComponentView.tsx +28 -0
- package/src/deck/DeckPlayer.tsx +166 -0
- package/src/deck/DeckSlideView.tsx +38 -0
- package/src/deck/DeckStage.tsx +39 -0
- package/src/deck/components/DeckImage.tsx +38 -0
- package/src/deck/components/DeckImageGrid.tsx +254 -0
- package/src/deck/components/DeckRichText.tsx +111 -0
- package/src/deck/components/DeckVideo.tsx +81 -0
- package/src/deck/editing.tsx +201 -0
- package/src/deck/flip.ts +70 -0
- package/src/deck/index.ts +30 -0
- package/src/deck/layouts/CenteredTextLayout.tsx +29 -0
- package/src/deck/layouts/ColumnSplitLayout.tsx +69 -0
- package/src/deck/layouts/FullBleedImageLayout.tsx +58 -0
- package/src/deck/media.tsx +46 -0
- package/src/deck/tokens.ts +57 -0
- package/src/deck/useStageScale.ts +44 -0
- package/src/deck-builder/DeckBuilderApp.tsx +572 -0
- package/src/deck-builder/DeckBuilderTopBar.tsx +62 -0
- package/src/deck-builder/DeckChatPanel.tsx +334 -0
- package/src/deck-builder/DeckEditor.tsx +258 -0
- package/src/deck-builder/DeckLibrary.tsx +195 -0
- package/src/deck-builder/DeckSlideSurface.tsx +55 -0
- package/src/deck-builder/deck-api.ts +177 -0
- package/src/deck-builder/deck-chat-client.ts +129 -0
- package/src/deck-builder/deck-chat-history.ts +62 -0
- package/src/deck-builder/index.ts +12 -0
- package/src/deck-builder/starter-deck.ts +86 -0
- package/src/deck-builder/types.ts +24 -0
- package/src/deck-builder/useSortableSlide.ts +102 -0
- package/src/lib/deck-access.ts +132 -0
- package/src/lib/deck-dexie.ts +190 -0
- package/src/lib/deck-edit.ts +196 -0
- package/src/lib/deck-media-assets.ts +57 -0
- package/src/lib/deck-media-refs.ts +67 -0
- package/src/lib/deck-tool.ts +95 -0
- package/src/lib/nav.ts +21 -1
- package/src/schemas/deck-meta.ts +88 -0
- package/src/schemas/deck.ts +478 -0
- package/src/schemas/index.ts +4 -0
- package/src/schemas/site-config.ts +24 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { AudienceChips } from "./AudienceChips";
|
|
2
|
+
import type { SiteConfig } from "../../schemas/site-config";
|
|
3
|
+
import type { Audience } from "../../auth/types";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
siteConfig: SiteConfig;
|
|
7
|
+
onSiteConfigChange: (config: SiteConfig) => void;
|
|
8
|
+
audiences: Audience[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Deck Builder settings. One control today — the creation opt-in — because
|
|
12
|
+
* that is the only `deckBuilder` key that exists; public sharing (#37) is the
|
|
13
|
+
* next one and belongs on this tab. */
|
|
14
|
+
export function SiteSettingsDecks({ siteConfig, onSiteConfigChange, audiences }: Props) {
|
|
15
|
+
const selected = siteConfig.deckBuilder.audiences;
|
|
16
|
+
|
|
17
|
+
function toggle(name: string) {
|
|
18
|
+
const next = selected.includes(name) ? selected.filter((n) => n !== name) : [...selected, name];
|
|
19
|
+
onSiteConfigChange({ ...siteConfig, deckBuilder: { ...siteConfig.deckBuilder, audiences: next } });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="space-y-3">
|
|
24
|
+
<div>
|
|
25
|
+
<h3 className="mb-1 text-sm font-medium text-base-contrast">Who can build decks</h3>
|
|
26
|
+
<p className="mb-2 text-xs text-base-contrast-light">
|
|
27
|
+
Members of the selected audiences get the Decks button and can create and publish their own presentations.
|
|
28
|
+
None selected means no viewer can start a new one. Editors always can.
|
|
29
|
+
</p>
|
|
30
|
+
<AudienceChips audiences={audiences} selected={selected} onToggle={toggle} />
|
|
31
|
+
</div>
|
|
32
|
+
<p className="text-xs text-base-contrast-light">
|
|
33
|
+
Removing an audience here stops its members starting new decks. Decks they already own stay theirs — they can
|
|
34
|
+
still open, edit and delete them — so remove the deck itself if that is what you want gone.
|
|
35
|
+
</p>
|
|
36
|
+
<p className="text-xs text-base-contrast-light">
|
|
37
|
+
A deck is private to the person who made it. Sharing it with an audience, or by public link, comes later.
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -6,6 +6,7 @@ import { SiteSettingsViewerAccess } from "./SiteSettingsViewerAccess";
|
|
|
6
6
|
import { SiteSettingsDisplay } from "./SiteSettingsDisplay";
|
|
7
7
|
import { SiteSettingsUsers } from "./SiteSettingsUsers";
|
|
8
8
|
import { SiteSettingsChatbot } from "./SiteSettingsChatbot";
|
|
9
|
+
import { SiteSettingsDecks } from "./SiteSettingsDecks";
|
|
9
10
|
import type { SiteConfig } from "../../schemas/site-config";
|
|
10
11
|
import type { Audience } from "../../auth/types";
|
|
11
12
|
|
|
@@ -81,6 +82,16 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
|
|
|
81
82
|
</div>
|
|
82
83
|
),
|
|
83
84
|
},
|
|
85
|
+
{
|
|
86
|
+
id: "decks",
|
|
87
|
+
label: "Decks",
|
|
88
|
+
show: true,
|
|
89
|
+
content: (
|
|
90
|
+
<div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
|
|
91
|
+
<SiteSettingsDecks siteConfig={siteConfig} onSiteConfigChange={onSiteConfigChange} audiences={audiences} />
|
|
92
|
+
</div>
|
|
93
|
+
),
|
|
94
|
+
},
|
|
84
95
|
{
|
|
85
96
|
id: "display",
|
|
86
97
|
label: "Display",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DeckTextSlot } from "../lib/deck-edit";
|
|
2
|
+
import type { DeckComponent } from "../schemas/deck";
|
|
3
|
+
import { DeckImage } from "./components/DeckImage";
|
|
4
|
+
import { DeckImageGrid } from "./components/DeckImageGrid";
|
|
5
|
+
import { DeckRichText } from "./components/DeckRichText";
|
|
6
|
+
import { DeckVideo } from "./components/DeckVideo";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
component: DeckComponent;
|
|
10
|
+
/** The Layout slot this component fills — carried only so text leaves can
|
|
11
|
+
* address themselves for inline editing (#39). Optional: a caller that omits
|
|
12
|
+
* it gets the read-only render. */
|
|
13
|
+
slot?: DeckTextSlot;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Dispatches a Deck Component onto its runtime implementation. */
|
|
17
|
+
export function DeckComponentView({ component, slot }: Props) {
|
|
18
|
+
switch (component.type) {
|
|
19
|
+
case "richText":
|
|
20
|
+
return <DeckRichText content={component} slot={slot} />;
|
|
21
|
+
case "image":
|
|
22
|
+
return <DeckImage content={component} />;
|
|
23
|
+
case "imageGrid":
|
|
24
|
+
return <DeckImageGrid content={component} />;
|
|
25
|
+
case "video":
|
|
26
|
+
return <DeckVideo content={component} />;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import type { Deck, DeckMediaAsset, DeckMediaResolver } from "../schemas/deck";
|
|
5
|
+
import { DeckMediaProvider } from "./media";
|
|
6
|
+
import { DeckStage } from "./DeckStage";
|
|
7
|
+
import { DeckSlideView } from "./DeckSlideView";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
deck: Deck;
|
|
11
|
+
/** Slide to open on. Out-of-range values clamp into the deck. */
|
|
12
|
+
initialSlide?: number;
|
|
13
|
+
/** Window-level arrow/space/Home/End navigation. Off for thumbnails. */
|
|
14
|
+
keyboard?: boolean;
|
|
15
|
+
/** Turns media references into bytes. The Deck Builder injects the real one. */
|
|
16
|
+
resolveMedia?: DeckMediaResolver;
|
|
17
|
+
/**
|
|
18
|
+
* Pre-resolved portal media, keyed by media id. The serializable form of the
|
|
19
|
+
* same seam, for callers that cross an Astro island boundary — a function
|
|
20
|
+
* prop cannot survive that trip. Ignored when `resolveMedia` is given.
|
|
21
|
+
*/
|
|
22
|
+
mediaAssets?: Record<string, DeckMediaAsset>;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clamp(index: number, length: number): number {
|
|
27
|
+
return Math.min(Math.max(index, 0), length - 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Arrow keys must not steal typing from a chat panel docked beside the stage. */
|
|
31
|
+
function isTypingTarget(target: EventTarget | null): boolean {
|
|
32
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
33
|
+
return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A native button activates on Space *keyup*; the keydown only marks it active
|
|
38
|
+
* and suppresses scrolling. Calling preventDefault on that keydown cancels the
|
|
39
|
+
* activation — so Space on a focused "Previous slide" used to swallow the click
|
|
40
|
+
* and step FORWARD instead. Enter needs no such guard: it has no case below, so
|
|
41
|
+
* it never reaches preventDefault.
|
|
42
|
+
*/
|
|
43
|
+
function isActivatableTarget(target: EventTarget | null): boolean {
|
|
44
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
45
|
+
if (target.tagName === "BUTTON" || target.tagName === "SUMMARY") return true;
|
|
46
|
+
if (target.tagName === "A" && target.hasAttribute("href")) return true;
|
|
47
|
+
return target.getAttribute("role") === "button";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NAV_BUTTON =
|
|
51
|
+
"flex size-[38px] items-center justify-center rounded-[var(--radius-outer)] border border-base-200 text-base-contrast transition-colors hover:bg-base-accent disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:bg-transparent";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The deck runtime's presentation surface: one 16:9 stage plus slide
|
|
55
|
+
* navigation. Deliberately self-contained — the Deck Builder, the in-portal
|
|
56
|
+
* deck view and the Public Link view all mount this same component, so slide
|
|
57
|
+
* state lives here rather than in any one of those shells.
|
|
58
|
+
*/
|
|
59
|
+
export function DeckPlayer({ deck, initialSlide = 0, keyboard = true, resolveMedia, mediaAssets, className }: Props) {
|
|
60
|
+
const total = deck.slides.length;
|
|
61
|
+
const [index, setIndex] = useState(() => clamp(initialSlide, total));
|
|
62
|
+
// `Object.hasOwn`, not indexing: a media id is author-supplied, and a plain
|
|
63
|
+
// object answers `"constructor"` and `"toString"` with inherited members that
|
|
64
|
+
// are not assets at all. The schema's charset rules those ids out too — this
|
|
65
|
+
// is the second lock on the same door. The `kind` check keeps the map form
|
|
66
|
+
// honest the day a second media kind lands: it resolves portal ids, and says
|
|
67
|
+
// so by refusing everything else rather than looking the id up anyway.
|
|
68
|
+
const resolve = useMemo<DeckMediaResolver>(
|
|
69
|
+
() =>
|
|
70
|
+
resolveMedia ??
|
|
71
|
+
((ref) =>
|
|
72
|
+
ref.kind === "portal" && mediaAssets !== undefined && Object.hasOwn(mediaAssets, ref.id)
|
|
73
|
+
? mediaAssets[ref.id]
|
|
74
|
+
: null),
|
|
75
|
+
[resolveMedia, mediaAssets],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const current = clamp(index, total);
|
|
79
|
+
const slide = deck.slides[current];
|
|
80
|
+
|
|
81
|
+
const goTo = useCallback((next: number) => setIndex(clamp(next, total)), [total]);
|
|
82
|
+
// `prev` is clamped BEFORE the delta: the builder live-replaces the deck every
|
|
83
|
+
// LLM turn, so a shrunk deck can leave the stored index past the end. Without
|
|
84
|
+
// the inner clamp the first Previous press would land back on the slide
|
|
85
|
+
// already showing — a visible no-op.
|
|
86
|
+
const step = useCallback(
|
|
87
|
+
(delta: number) => setIndex((prev) => clamp(clamp(prev, total) + delta, total)),
|
|
88
|
+
[total],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!keyboard) return;
|
|
93
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
94
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
95
|
+
if (isTypingTarget(event.target)) return;
|
|
96
|
+
switch (event.key) {
|
|
97
|
+
case " ":
|
|
98
|
+
if (isActivatableTarget(event.target)) return;
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
step(1);
|
|
101
|
+
break;
|
|
102
|
+
case "ArrowRight":
|
|
103
|
+
case "PageDown":
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
step(1);
|
|
106
|
+
break;
|
|
107
|
+
case "ArrowLeft":
|
|
108
|
+
case "PageUp":
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
step(-1);
|
|
111
|
+
break;
|
|
112
|
+
case "Home":
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
setIndex(0);
|
|
115
|
+
break;
|
|
116
|
+
case "End":
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
setIndex(total - 1);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
window.addEventListener("keydown", onKeyDown);
|
|
123
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
124
|
+
}, [keyboard, step, total]);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div data-deck-player={deck.id} className={cn("flex h-full min-h-0 w-full flex-col gap-4 bg-base p-4", className)}>
|
|
128
|
+
<DeckStage className="flex-1">
|
|
129
|
+
<DeckMediaProvider resolve={resolve}>
|
|
130
|
+
<DeckSlideView slide={slide} />
|
|
131
|
+
</DeckMediaProvider>
|
|
132
|
+
</DeckStage>
|
|
133
|
+
|
|
134
|
+
<nav aria-label={`${deck.title} slides`} className="flex shrink-0 items-center justify-center gap-4">
|
|
135
|
+
<button type="button" className={NAV_BUTTON} onClick={() => step(-1)} disabled={current === 0} aria-label="Previous slide">
|
|
136
|
+
<ChevronLeft className="size-5" aria-hidden="true" />
|
|
137
|
+
</button>
|
|
138
|
+
|
|
139
|
+
<ol className="flex items-center gap-2">
|
|
140
|
+
{deck.slides.map((s, i) => (
|
|
141
|
+
<li key={s.id}>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() => goTo(i)}
|
|
145
|
+
aria-current={i === current ? "true" : undefined}
|
|
146
|
+
aria-label={s.title ? `Slide ${i + 1}: ${s.title}` : `Slide ${i + 1}`}
|
|
147
|
+
className={cn(
|
|
148
|
+
"size-[10px] rounded-full transition-colors",
|
|
149
|
+
i === current ? "bg-primary" : "bg-base-300 hover:bg-base-contrast-light",
|
|
150
|
+
)}
|
|
151
|
+
/>
|
|
152
|
+
</li>
|
|
153
|
+
))}
|
|
154
|
+
</ol>
|
|
155
|
+
|
|
156
|
+
<button type="button" className={NAV_BUTTON} onClick={() => step(1)} disabled={current === total - 1} aria-label="Next slide">
|
|
157
|
+
<ChevronRight className="size-5" aria-hidden="true" />
|
|
158
|
+
</button>
|
|
159
|
+
|
|
160
|
+
<p aria-live="polite" className="ml-2 text-sm tabular-nums text-base-contrast-light">
|
|
161
|
+
{current + 1} / {total}
|
|
162
|
+
</p>
|
|
163
|
+
</nav>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DeckSlide } from "../schemas/deck";
|
|
2
|
+
import { CenteredTextLayout } from "./layouts/CenteredTextLayout";
|
|
3
|
+
import { ColumnSplitLayout } from "./layouts/ColumnSplitLayout";
|
|
4
|
+
import { FullBleedImageLayout } from "./layouts/FullBleedImageLayout";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
slide: DeckSlide;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function LayoutView({ slide }: Props) {
|
|
11
|
+
const { layout } = slide;
|
|
12
|
+
switch (layout.type) {
|
|
13
|
+
case "centeredText":
|
|
14
|
+
return <CenteredTextLayout layout={layout} />;
|
|
15
|
+
case "fullBleedImage":
|
|
16
|
+
return <FullBleedImageLayout layout={layout} />;
|
|
17
|
+
case "columnSplit":
|
|
18
|
+
return <ColumnSplitLayout layout={layout} />;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dispatches a slide onto its Layout. One slide uses exactly one Layout.
|
|
24
|
+
*
|
|
25
|
+
* Keyed by slide id, and that key is load-bearing rather than a lint nicety:
|
|
26
|
+
* two slides sharing a Layout shape reconcile onto the SAME component
|
|
27
|
+
* instances, so an expanded image-grid tile or a playing video on slide 1 was
|
|
28
|
+
* inherited by slide 2 the moment you pressed the arrow key. The key forces the
|
|
29
|
+
* subtree to remount, which is the only thing that makes a slide's interaction
|
|
30
|
+
* state belong to that slide.
|
|
31
|
+
*/
|
|
32
|
+
export function DeckSlideView({ slide }: Props) {
|
|
33
|
+
return (
|
|
34
|
+
<div key={slide.id} data-deck-slide={slide.id} className="size-full">
|
|
35
|
+
<LayoutView slide={slide} />
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useRef, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { DECK_STAGE_HEIGHT, DECK_STAGE_WIDTH } from "../schemas/deck";
|
|
4
|
+
import { useStageScale } from "./useStageScale";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Applied to the fit box, which is what the stage is scaled to fill. */
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The 16:9 stage: a fixed logical canvas centered in — and scaled to fit — its
|
|
14
|
+
* container, so slide contents are authored in px against one coordinate system
|
|
15
|
+
* regardless of the viewport. Scaling by transform (rather than by a font-size
|
|
16
|
+
* cascade) keeps every measurement, including line breaks, identical at every
|
|
17
|
+
* size, which is what makes a screenshared deck look the same as its preview.
|
|
18
|
+
*/
|
|
19
|
+
export function DeckStage({ children, className }: Props) {
|
|
20
|
+
const fitRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
const scale = useStageScale(fitRef);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div ref={fitRef} className={cn("relative min-h-0 w-full overflow-hidden", className)} data-deck-fit="">
|
|
25
|
+
<div
|
|
26
|
+
data-deck-stage=""
|
|
27
|
+
data-deck-scale={scale === null ? undefined : scale.toFixed(4)}
|
|
28
|
+
className="absolute top-1/2 left-1/2 origin-center overflow-hidden rounded-[var(--radius-outer)] font-body"
|
|
29
|
+
style={{
|
|
30
|
+
width: DECK_STAGE_WIDTH,
|
|
31
|
+
height: DECK_STAGE_HEIGHT,
|
|
32
|
+
transform: scale === null ? "translate(-50%, -50%)" : `translate(-50%, -50%) scale(${scale})`,
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DeckImage as DeckImageContent } from "../../schemas/deck";
|
|
2
|
+
import { DeckMissingMedia, useDeckMedia } from "../media";
|
|
3
|
+
import { surfaceInk } from "../tokens";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
content: DeckImageContent;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Deck Component — image.
|
|
11
|
+
*
|
|
12
|
+
* Fills its slot and crops. The caption is a solid brand block sitting on the
|
|
13
|
+
* image's bottom edge rather than a gradient scrim: a scrim needs a literal
|
|
14
|
+
* color to darken toward, and legibility here comes from the block.
|
|
15
|
+
*/
|
|
16
|
+
export function DeckImage({ content }: Props) {
|
|
17
|
+
const asset = useDeckMedia(content.media);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<figure data-deck-component="image" className="relative size-full overflow-hidden bg-base-200">
|
|
21
|
+
{asset === null ? (
|
|
22
|
+
<DeckMissingMedia />
|
|
23
|
+
) : (
|
|
24
|
+
<img src={asset.src} alt={asset.alt} className="size-full object-cover" />
|
|
25
|
+
)}
|
|
26
|
+
|
|
27
|
+
{content.caption !== undefined && (
|
|
28
|
+
<figcaption
|
|
29
|
+
data-deck-surface="brand"
|
|
30
|
+
style={surfaceInk("brand")}
|
|
31
|
+
className="absolute right-0 bottom-0 left-0 bg-primary px-[28px] py-[18px] text-[18px] tracking-[0.06em] text-[var(--deck-ink)] [text-transform:var(--nav-text-transform)]"
|
|
32
|
+
>
|
|
33
|
+
{content.caption}
|
|
34
|
+
</figcaption>
|
|
35
|
+
)}
|
|
36
|
+
</figure>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import type { DeckImageGrid as DeckImageGridContent, DeckImageGridItem, DeckMediaAsset } from "../../schemas/deck";
|
|
4
|
+
import { captureTileRects, playTileFlip, type TileRects } from "../flip";
|
|
5
|
+
import { DeckMissingMedia, useDeckMediaResolver } from "../media";
|
|
6
|
+
import { DECK_ELEVATION, surfaceInk } from "../tokens";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
content: DeckImageGridContent;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Two columns for a small set, three once there are enough tiles to fill them.
|
|
14
|
+
* Deliberately not a schema field: it is presentation, and one fewer knob for
|
|
15
|
+
* an author to get wrong.
|
|
16
|
+
*/
|
|
17
|
+
function columnsFor(count: number): number {
|
|
18
|
+
return count <= 4 ? 2 : 3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A tile's height as a multiple of the column width. Falls back to a square
|
|
23
|
+
* when the resolver reports no dimensions — the packing still has to be
|
|
24
|
+
* deterministic, and a square is the least-wrong guess.
|
|
25
|
+
*/
|
|
26
|
+
function tileRatio(asset: DeckMediaAsset | null): number {
|
|
27
|
+
if (asset?.width === undefined || asset.height === undefined || asset.width <= 0) return 1;
|
|
28
|
+
return asset.height / asset.width;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Greedy shortest-column packing: each tile joins whichever column is currently
|
|
33
|
+
* least tall, ties going left. With uniform aspect ratios that degenerates to
|
|
34
|
+
* round-robin, which is what makes the simple cases look deliberate.
|
|
35
|
+
*/
|
|
36
|
+
export function packMasonry(ratios: number[], columns: number): number[][] {
|
|
37
|
+
const packed: number[][] = Array.from({ length: columns }, () => []);
|
|
38
|
+
const filled = new Array<number>(columns).fill(0);
|
|
39
|
+
ratios.forEach((ratio, index) => {
|
|
40
|
+
let target = 0;
|
|
41
|
+
for (let column = 1; column < columns; column++) {
|
|
42
|
+
if (filled[column] < filled[target]) target = column;
|
|
43
|
+
}
|
|
44
|
+
packed[target].push(index);
|
|
45
|
+
filled[target] += ratio;
|
|
46
|
+
});
|
|
47
|
+
return packed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TILE_BASE =
|
|
51
|
+
"group relative block shrink-0 overflow-hidden bg-base-200 will-change-transform transition-[transform,box-shadow,opacity] duration-[380ms] ease-[cubic-bezier(0.2,0.75,0.2,1)] focus-visible:outline-2 focus-visible:outline-offset-[-6px] focus-visible:outline-[var(--color-primary)]";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Deck Component — image grid, and the runtime's signature interaction.
|
|
55
|
+
*
|
|
56
|
+
* Masonry columns: every image keeps its own aspect ratio, the column width
|
|
57
|
+
* decides its height, and a stack that runs past the bottom of the slot is
|
|
58
|
+
* clipped there. That clip is the point — on a fixed 16:9 stage a collage that
|
|
59
|
+
* runs off the bottom edge reads better than one that crops every frame to a
|
|
60
|
+
* uniform cell, and nothing may spill past the slot onto the rest of the slide.
|
|
61
|
+
*
|
|
62
|
+
* Hover lifts a tile; activating it expands that tile to fill the whole grid
|
|
63
|
+
* region as a lightbox contained inside the slide (never a full-screen overlay
|
|
64
|
+
* — the deck stays the deck). Activating again, the close affordance, or Escape
|
|
65
|
+
* returns it. Tiles are real buttons, so keyboard activation is the browser's.
|
|
66
|
+
*/
|
|
67
|
+
export function DeckImageGrid({ content }: Props) {
|
|
68
|
+
const [active, setActive] = useState<number | null>(null);
|
|
69
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
70
|
+
const pendingRects = useRef<TileRects | null>(null);
|
|
71
|
+
|
|
72
|
+
const expanded = active !== null;
|
|
73
|
+
const resolve = useDeckMediaResolver();
|
|
74
|
+
const count = content.items.length;
|
|
75
|
+
|
|
76
|
+
const assets = useMemo(() => content.items.map((item) => resolve(item.media)), [content.items, resolve]);
|
|
77
|
+
const columns = columnsFor(count);
|
|
78
|
+
const packed = useMemo(() => packMasonry(assets.map(tileRatio), columns), [assets, columns]);
|
|
79
|
+
|
|
80
|
+
// Measure BEFORE the state change: React moves the DOM, and FLIP needs the
|
|
81
|
+
// pre-move rects, which no longer exist by the time the effect runs.
|
|
82
|
+
const transitionTo = useCallback((next: number | null) => {
|
|
83
|
+
const grid = gridRef.current;
|
|
84
|
+
if (grid) pendingRects.current = captureTileRects(grid);
|
|
85
|
+
setActive(next);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
useLayoutEffect(() => {
|
|
89
|
+
const grid = gridRef.current;
|
|
90
|
+
const before = pendingRects.current;
|
|
91
|
+
pendingRects.current = null;
|
|
92
|
+
if (grid && before) playTileFlip(grid, before);
|
|
93
|
+
}, [active]);
|
|
94
|
+
|
|
95
|
+
// The Deck Builder replaces the whole deck every LLM turn, so a grid can lose
|
|
96
|
+
// tiles under an open lightbox. Left alone, the expanded index would point
|
|
97
|
+
// past the end and every tile would render hidden — a blank slot with no way
|
|
98
|
+
// out but Escape.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
setActive((prev) => (prev === null || prev < count ? prev : null));
|
|
101
|
+
}, [count]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!expanded) return;
|
|
105
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
106
|
+
if (event.key !== "Escape") return;
|
|
107
|
+
// NOTE: this stops the event travelling to an ancestor, which for a
|
|
108
|
+
// window listener is nothing at all — it does NOT stop other listeners on
|
|
109
|
+
// window from also seeing this Escape. A future shell-level Escape (exit
|
|
110
|
+
// presentation, close the builder) has to coordinate with the grid
|
|
111
|
+
// explicitly; it cannot rely on this call to have swallowed the key.
|
|
112
|
+
event.stopPropagation();
|
|
113
|
+
transitionTo(null);
|
|
114
|
+
};
|
|
115
|
+
window.addEventListener("keydown", onKeyDown);
|
|
116
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
117
|
+
}, [expanded, transitionTo]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Close returns focus to the tile that was expanded. Without it the close
|
|
121
|
+
* button unmounts under the caret and focus falls to <body>, which strands a
|
|
122
|
+
* keyboard user outside the grid entirely. Focus first, then re-render, so
|
|
123
|
+
* the unmounting button is no longer the active element when it goes.
|
|
124
|
+
*/
|
|
125
|
+
const close = useCallback(() => {
|
|
126
|
+
gridRef.current?.querySelector<HTMLElement>(`[data-deck-tile="${active}"]`)?.focus({ preventScroll: true });
|
|
127
|
+
transitionTo(null);
|
|
128
|
+
}, [active, transitionTo]);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
ref={gridRef}
|
|
133
|
+
data-deck-component="imageGrid"
|
|
134
|
+
data-deck-grid-expanded={expanded ? "true" : "false"}
|
|
135
|
+
data-deck-grid-columns={columns}
|
|
136
|
+
style={DECK_ELEVATION}
|
|
137
|
+
// Clipped, not scrolled: a deck slide has no scrollbars, and a stack that
|
|
138
|
+
// runs long is meant to run off the bottom of the slot.
|
|
139
|
+
className="relative flex size-full gap-[14px] overflow-hidden"
|
|
140
|
+
>
|
|
141
|
+
{packed.map((indices, column) => (
|
|
142
|
+
// `flex-1` rather than a width fraction: the columns have to share the
|
|
143
|
+
// gap between them, and equal flex bases divide what is left over.
|
|
144
|
+
<div key={column} data-deck-grid-column={column} className="flex min-w-0 flex-1 flex-col gap-[14px]">
|
|
145
|
+
{indices.map((index) => (
|
|
146
|
+
<Tile
|
|
147
|
+
key={index}
|
|
148
|
+
item={content.items[index]}
|
|
149
|
+
asset={assets[index]}
|
|
150
|
+
index={index}
|
|
151
|
+
expanded={expanded}
|
|
152
|
+
active={index === active}
|
|
153
|
+
onActivate={() => transitionTo(index === active ? null : index)}
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
|
|
159
|
+
{expanded && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
data-deck-grid-close=""
|
|
163
|
+
onClick={close}
|
|
164
|
+
data-deck-surface="brand"
|
|
165
|
+
style={surfaceInk("brand")}
|
|
166
|
+
className="absolute top-[14px] right-[14px] z-[4] bg-primary px-[18px] py-[10px] text-[15px] font-semibold tracking-[0.12em] text-[var(--deck-ink)] uppercase"
|
|
167
|
+
>
|
|
168
|
+
Close
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface TileProps {
|
|
176
|
+
item: DeckImageGridItem;
|
|
177
|
+
asset: DeckMediaAsset | null;
|
|
178
|
+
index: number;
|
|
179
|
+
expanded: boolean;
|
|
180
|
+
active: boolean;
|
|
181
|
+
onActivate: () => void;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function Tile({ item, asset, index, expanded, active, onActivate }: TileProps) {
|
|
185
|
+
const label = item.caption ?? asset?.alt ?? `Image ${index + 1}`;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
data-deck-tile={index}
|
|
191
|
+
data-deck-tile-active={active ? "true" : "false"}
|
|
192
|
+
aria-expanded={active}
|
|
193
|
+
aria-label={active ? `Close ${label}` : `Expand ${label}`}
|
|
194
|
+
tabIndex={expanded && !active ? -1 : 0}
|
|
195
|
+
onClick={onActivate}
|
|
196
|
+
className={cn(
|
|
197
|
+
TILE_BASE,
|
|
198
|
+
// Every tile leaves the column flow when one is expanded, not only the
|
|
199
|
+
// active one: leaving the siblings in place would let the columns
|
|
200
|
+
// re-pack around the gap, which both reflows the tiles the lightbox is
|
|
201
|
+
// covering and moves the ones the FLIP is animating.
|
|
202
|
+
expanded ? "absolute inset-0 flex items-center justify-center" : "w-full",
|
|
203
|
+
expanded && !active && "pointer-events-none opacity-0",
|
|
204
|
+
// Transparent, not the tile's loading plate: an expanded frame is
|
|
205
|
+
// letterboxed inside the slot, and the field around it should read as
|
|
206
|
+
// the slide showing through rather than as a grey box drawn on top.
|
|
207
|
+
expanded && active && "z-[3] bg-transparent opacity-100",
|
|
208
|
+
!expanded && "cursor-pointer hover:z-[2] hover:scale-[1.035] hover:shadow-[var(--deck-lift)]",
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
{asset === null ? (
|
|
212
|
+
<DeckMissingMedia className={active ? "size-full" : "aspect-square h-auto w-full"} />
|
|
213
|
+
) : (
|
|
214
|
+
<img
|
|
215
|
+
src={asset.src}
|
|
216
|
+
alt=""
|
|
217
|
+
// The intrinsic size, when the resolver knows it, so the column
|
|
218
|
+
// reserves the right height before a byte arrives — otherwise the
|
|
219
|
+
// masonry re-packs visibly as each image lands.
|
|
220
|
+
width={asset.width}
|
|
221
|
+
height={asset.height}
|
|
222
|
+
// The elevation moves onto the image when expanded: the button box is
|
|
223
|
+
// the whole slot now, and a shadow on THAT would outline the
|
|
224
|
+
// letterbox instead of the frame the viewer is looking at.
|
|
225
|
+
className={
|
|
226
|
+
active ? "max-h-full max-w-full object-contain shadow-[var(--deck-lift-strong)]" : "block h-auto w-full"
|
|
227
|
+
}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
<span
|
|
232
|
+
aria-hidden="true"
|
|
233
|
+
data-deck-surface="brand"
|
|
234
|
+
style={surfaceInk("brand")}
|
|
235
|
+
className="absolute top-0 left-0 bg-primary px-[12px] py-[6px] text-[15px] font-bold tracking-[0.1em] text-[var(--deck-ink)] tabular-nums"
|
|
236
|
+
>
|
|
237
|
+
{String(index + 1).padStart(2, "0")}
|
|
238
|
+
</span>
|
|
239
|
+
|
|
240
|
+
{item.caption !== undefined && (
|
|
241
|
+
<span
|
|
242
|
+
data-deck-surface="brand"
|
|
243
|
+
style={surfaceInk("brand")}
|
|
244
|
+
className={cn(
|
|
245
|
+
"absolute right-0 bottom-0 left-0 bg-primary text-left tracking-[0.04em] text-[var(--deck-ink)] transition-opacity duration-200",
|
|
246
|
+
active ? "px-[30px] py-[22px] text-[24px] opacity-100" : "px-[18px] py-[14px] text-[18px] opacity-0 group-hover:opacity-100",
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
{item.caption}
|
|
250
|
+
</span>
|
|
251
|
+
)}
|
|
252
|
+
</button>
|
|
253
|
+
);
|
|
254
|
+
}
|