@cosxai/ui 0.1.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 +38 -0
- package/src/actionbar/ActionBar.tsx +436 -0
- package/src/actionbar/ActionBarButton.tsx +110 -0
- package/src/actionbar/ActionBarMenuGroup.tsx +106 -0
- package/src/actionbar/ActionBarProvider.tsx +76 -0
- package/src/actionbar/actionbar-context.ts +23 -0
- package/src/actionbar/index.ts +13 -0
- package/src/actionbar/types.ts +50 -0
- package/src/actionbar/useActionBarItems.ts +47 -0
- package/src/ambient/AmbientBackdrop.tsx +74 -0
- package/src/ambient/CommandInput.tsx +107 -0
- package/src/ambient/SuperbarStrip.tsx +36 -0
- package/src/ambient/index.ts +6 -0
- package/src/bento/BentoCell.tsx +66 -0
- package/src/bento/BentoGrid.tsx +42 -0
- package/src/bento/index.ts +2 -0
- package/src/command/CommandPalette.tsx +277 -0
- package/src/command/CommandProvider.tsx +57 -0
- package/src/command/command-context.ts +12 -0
- package/src/command/index.ts +6 -0
- package/src/command/rank.ts +45 -0
- package/src/command/types.ts +26 -0
- package/src/command/useCommandSource.ts +37 -0
- package/src/dialogs/DialogsProvider.tsx +216 -0
- package/src/dialogs/Modal.tsx +204 -0
- package/src/dialogs/Toast.tsx +85 -0
- package/src/dialogs/dialogs-context.ts +6 -0
- package/src/dialogs/index.ts +10 -0
- package/src/dialogs/types.ts +37 -0
- package/src/dialogs/useDialogs.ts +8 -0
- package/src/editorial/EditorialSpotlight.tsx +63 -0
- package/src/editorial/Folio.tsx +52 -0
- package/src/editorial/PlateMarker.tsx +33 -0
- package/src/editorial/RomanSection.tsx +65 -0
- package/src/editorial/RunningMarginalia.tsx +65 -0
- package/src/editorial/index.ts +10 -0
- package/src/frutiger/GlossyOrb.tsx +79 -0
- package/src/frutiger/SkyBackdrop.tsx +114 -0
- package/src/frutiger/index.ts +2 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useKeyboardHotkey.ts +80 -0
- package/src/hooks/useReducedMotion.ts +20 -0
- package/src/hooks/useViewport.ts +61 -0
- package/src/index.ts +26 -0
- package/src/layout/Breadcrumb.tsx +74 -0
- package/src/layout/LeftNavRail.tsx +126 -0
- package/src/layout/MobileTabBar.tsx +101 -0
- package/src/layout/NavItem.tsx +128 -0
- package/src/layout/NavSearchTrigger.tsx +88 -0
- package/src/layout/NavSection.tsx +40 -0
- package/src/layout/RightSidebarPanel.tsx +111 -0
- package/src/layout/Shell.tsx +91 -0
- package/src/layout/StickyBanner.tsx +83 -0
- package/src/layout/Topbar.tsx +68 -0
- package/src/layout/index.ts +22 -0
- package/src/layout/useNavRailState.ts +69 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/time-utils.ts +44 -0
- package/src/neobrutalism/Marquee.tsx +81 -0
- package/src/neobrutalism/Sticker.tsx +71 -0
- package/src/neobrutalism/index.ts +4 -0
- package/src/primitives/Avatar.tsx +53 -0
- package/src/primitives/Button.tsx +30 -0
- package/src/primitives/Card.tsx +41 -0
- package/src/primitives/Checkbox.tsx +78 -0
- package/src/primitives/CountBadge.tsx +50 -0
- package/src/primitives/Input.tsx +71 -0
- package/src/primitives/Kbd.tsx +45 -0
- package/src/primitives/PageHeader.tsx +77 -0
- package/src/primitives/Tag.tsx +56 -0
- package/src/primitives/Textarea.tsx +62 -0
- package/src/primitives/ToggleSwitch.tsx +79 -0
- package/src/primitives/Tooltip.tsx +171 -0
- package/src/primitives/index.ts +24 -0
- package/src/pwa/InstallPromptBanner.tsx +132 -0
- package/src/pwa/index.ts +4 -0
- package/src/pwa/manifest.template.json +20 -0
- package/src/pwa/registerSW.ts +55 -0
- package/src/riso/Halftone.tsx +85 -0
- package/src/riso/Misregister.tsx +63 -0
- package/src/riso/RisoStamp.tsx +76 -0
- package/src/riso/index.ts +3 -0
- package/src/sketch/HandUnderline.tsx +53 -0
- package/src/sketch/RoughArrow.tsx +91 -0
- package/src/sketch/RoughBox.tsx +73 -0
- package/src/sketch/StickyNote.tsx +56 -0
- package/src/sketch/index.ts +4 -0
- package/src/styles/base.css +80 -0
- package/src/styles/chrome-ambient.css +222 -0
- package/src/styles/chrome-bento.css +184 -0
- package/src/styles/chrome-editorial.css +145 -0
- package/src/styles/chrome-frutiger.css +364 -0
- package/src/styles/chrome-neobrutalism.css +315 -0
- package/src/styles/chrome-riso.css +328 -0
- package/src/styles/chrome-sketch.css +351 -0
- package/src/styles/chrome-swiss.css +232 -0
- package/src/styles/chrome-terminal.css +235 -0
- package/src/styles/fonts.css +22 -0
- package/src/styles/index.css +198 -0
- package/src/styles/tokens.css +976 -0
- package/src/terminal/AsciiBox.tsx +65 -0
- package/src/terminal/BrailleSpinner.tsx +46 -0
- package/src/terminal/index.ts +4 -0
- package/src/theme/ThemeProvider.tsx +93 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/inline-script.ts +36 -0
- package/src/theme/theme-context.ts +7 -0
- package/src/theme/types.ts +22 -0
- package/src/theme/useTheme.ts +8 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useEffect, type ReactNode, type CSSProperties } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
// Modal base. Portals to document.body so it escapes ancestor
|
|
5
|
+
// stacking contexts. Closes on Esc + backdrop click (both opt-out).
|
|
6
|
+
// Caller owns open state and slots in the header/body/footer.
|
|
7
|
+
//
|
|
8
|
+
// Provides three sub-components for compositional layout:
|
|
9
|
+
// - <Modal> — the host (backdrop + card + close binding)
|
|
10
|
+
// - <ModalHeader> — sticky-ish top with title + optional close X
|
|
11
|
+
// - <ModalBody> — scrollable middle region
|
|
12
|
+
// - <ModalFooter> — sticky-ish bottom (buttons)
|
|
13
|
+
|
|
14
|
+
export interface ModalProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
// Disables close on backdrop click + Escape. Useful for blocking
|
|
18
|
+
// dialogs that must be resolved via an explicit button.
|
|
19
|
+
dismissable?: boolean;
|
|
20
|
+
// Width preset OR raw px. Default "md" (440 px).
|
|
21
|
+
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | number;
|
|
22
|
+
// Inline style override for the card.
|
|
23
|
+
cardStyle?: CSSProperties;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
// Optional aria-labelled-by for screen readers (point at the
|
|
26
|
+
// ModalHeader title's id).
|
|
27
|
+
labelledBy?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WIDTHS = { sm: 340, md: 440, lg: 560, xl: 720, "2xl": 880, "3xl": 1024, "4xl": 1200, "5xl": 1440 };
|
|
31
|
+
|
|
32
|
+
export function Modal({
|
|
33
|
+
open,
|
|
34
|
+
onClose,
|
|
35
|
+
dismissable = true,
|
|
36
|
+
maxWidth = "md",
|
|
37
|
+
cardStyle,
|
|
38
|
+
children,
|
|
39
|
+
labelledBy,
|
|
40
|
+
}: ModalProps) {
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open || !dismissable) return;
|
|
43
|
+
const onKey = (e: KeyboardEvent) => {
|
|
44
|
+
if (e.key === "Escape") {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
onClose();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener("keydown", onKey);
|
|
50
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
51
|
+
}, [open, dismissable, onClose]);
|
|
52
|
+
|
|
53
|
+
// Lock body scroll while open so the backdrop doesn't reveal
|
|
54
|
+
// a scrolling page underneath.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!open) return;
|
|
57
|
+
const prev = document.body.style.overflow;
|
|
58
|
+
document.body.style.overflow = "hidden";
|
|
59
|
+
return () => {
|
|
60
|
+
document.body.style.overflow = prev;
|
|
61
|
+
};
|
|
62
|
+
}, [open]);
|
|
63
|
+
|
|
64
|
+
if (!open) return null;
|
|
65
|
+
const width = typeof maxWidth === "number" ? maxWidth : WIDTHS[maxWidth];
|
|
66
|
+
return createPortal(
|
|
67
|
+
<div
|
|
68
|
+
role="dialog"
|
|
69
|
+
aria-modal="true"
|
|
70
|
+
aria-labelledby={labelledBy}
|
|
71
|
+
style={{
|
|
72
|
+
position: "fixed",
|
|
73
|
+
inset: 0,
|
|
74
|
+
background: "rgba(10, 14, 26, 0.6)",
|
|
75
|
+
backdropFilter: "blur(2px)",
|
|
76
|
+
display: "flex",
|
|
77
|
+
alignItems: "center",
|
|
78
|
+
justifyContent: "center",
|
|
79
|
+
padding: 16,
|
|
80
|
+
zIndex: 100,
|
|
81
|
+
}}
|
|
82
|
+
onMouseDown={(e) => {
|
|
83
|
+
// Backdrop click closes; clicks inside the card stopPropagation.
|
|
84
|
+
if (dismissable && e.target === e.currentTarget) onClose();
|
|
85
|
+
}}
|
|
86
|
+
className="ck-anim-fade"
|
|
87
|
+
>
|
|
88
|
+
<div
|
|
89
|
+
style={{
|
|
90
|
+
width: "100%",
|
|
91
|
+
maxWidth: width,
|
|
92
|
+
maxHeight: "calc(100vh - 32px)",
|
|
93
|
+
background: "var(--ck-bg-surface)",
|
|
94
|
+
border: "1px solid var(--ck-border-subtle)",
|
|
95
|
+
borderRadius: "var(--ck-radius-md)",
|
|
96
|
+
boxShadow: "var(--ck-shadow-3)",
|
|
97
|
+
display: "flex",
|
|
98
|
+
flexDirection: "column",
|
|
99
|
+
overflow: "hidden",
|
|
100
|
+
fontFamily: "var(--ck-font-sans)",
|
|
101
|
+
color: "var(--ck-text-primary)",
|
|
102
|
+
...cardStyle,
|
|
103
|
+
}}
|
|
104
|
+
className="ck-anim-popover"
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
</div>
|
|
108
|
+
</div>,
|
|
109
|
+
document.body,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------- Slot components ----------
|
|
114
|
+
|
|
115
|
+
export interface ModalHeaderProps {
|
|
116
|
+
title?: ReactNode;
|
|
117
|
+
subtitle?: ReactNode;
|
|
118
|
+
onClose?: () => void;
|
|
119
|
+
titleId?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function ModalHeader({ title, subtitle, onClose, titleId }: ModalHeaderProps) {
|
|
123
|
+
if (!title && !subtitle && !onClose) return null;
|
|
124
|
+
return (
|
|
125
|
+
<header
|
|
126
|
+
style={{
|
|
127
|
+
padding: "16px 20px",
|
|
128
|
+
borderBottom: "1px solid var(--ck-border-subtle)",
|
|
129
|
+
display: "flex",
|
|
130
|
+
alignItems: "flex-start",
|
|
131
|
+
gap: 12,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
135
|
+
{title && (
|
|
136
|
+
<h2
|
|
137
|
+
id={titleId}
|
|
138
|
+
style={{
|
|
139
|
+
font: "500 16px/1.3 var(--ck-font-sans)",
|
|
140
|
+
margin: 0,
|
|
141
|
+
color: "var(--ck-text-primary)",
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{title}
|
|
145
|
+
</h2>
|
|
146
|
+
)}
|
|
147
|
+
{subtitle && (
|
|
148
|
+
<p
|
|
149
|
+
style={{
|
|
150
|
+
font: "400 12px/1.4 var(--ck-font-sans)",
|
|
151
|
+
color: "var(--ck-text-tertiary)",
|
|
152
|
+
margin: "4px 0 0",
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{subtitle}
|
|
156
|
+
</p>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
{onClose && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={onClose}
|
|
163
|
+
aria-label="Close"
|
|
164
|
+
style={{
|
|
165
|
+
border: "none",
|
|
166
|
+
background: "transparent",
|
|
167
|
+
color: "var(--ck-text-tertiary)",
|
|
168
|
+
cursor: "pointer",
|
|
169
|
+
fontSize: 18,
|
|
170
|
+
lineHeight: 1,
|
|
171
|
+
padding: 2,
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
×
|
|
175
|
+
</button>
|
|
176
|
+
)}
|
|
177
|
+
</header>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function ModalBody({ children }: { children: ReactNode }) {
|
|
182
|
+
return (
|
|
183
|
+
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "20px" }}>
|
|
184
|
+
{children}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function ModalFooter({ children }: { children: ReactNode }) {
|
|
190
|
+
return (
|
|
191
|
+
<footer
|
|
192
|
+
style={{
|
|
193
|
+
padding: "14px 20px",
|
|
194
|
+
borderTop: "1px solid var(--ck-border-subtle)",
|
|
195
|
+
display: "flex",
|
|
196
|
+
justifyContent: "flex-end",
|
|
197
|
+
gap: 8,
|
|
198
|
+
background: "var(--ck-bg-surface-2)",
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{children}
|
|
202
|
+
</footer>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import type { ToastOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
interface ToastItem extends ToastOptions {
|
|
6
|
+
id: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Toast stack — fixed bottom-right corner. Each toast auto-dismisses
|
|
10
|
+
// after durationMs (default 4000). Stack grows upward.
|
|
11
|
+
|
|
12
|
+
const KIND_BG = {
|
|
13
|
+
info: "var(--ck-bg-surface)",
|
|
14
|
+
success: "var(--ck-success-muted)",
|
|
15
|
+
error: "var(--ck-critical-muted)",
|
|
16
|
+
};
|
|
17
|
+
const KIND_BORDER = {
|
|
18
|
+
info: "var(--ck-border-strong)",
|
|
19
|
+
success: "var(--ck-success)",
|
|
20
|
+
error: "var(--ck-critical)",
|
|
21
|
+
};
|
|
22
|
+
const KIND_TEXT = {
|
|
23
|
+
info: "var(--ck-text-primary)",
|
|
24
|
+
success: "var(--ck-success)",
|
|
25
|
+
error: "var(--ck-critical)",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function ToastStack({
|
|
29
|
+
toasts,
|
|
30
|
+
onDismiss,
|
|
31
|
+
}: {
|
|
32
|
+
toasts: ToastItem[];
|
|
33
|
+
onDismiss: (id: number) => void;
|
|
34
|
+
}) {
|
|
35
|
+
return createPortal(
|
|
36
|
+
<div
|
|
37
|
+
style={{
|
|
38
|
+
position: "fixed",
|
|
39
|
+
bottom: 16,
|
|
40
|
+
right: 16,
|
|
41
|
+
zIndex: 110,
|
|
42
|
+
display: "flex",
|
|
43
|
+
flexDirection: "column",
|
|
44
|
+
gap: 8,
|
|
45
|
+
maxWidth: 360,
|
|
46
|
+
pointerEvents: "none",
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{toasts.map((t) => (
|
|
50
|
+
<ToastRow key={t.id} item={t} onDismiss={() => onDismiss(t.id)} />
|
|
51
|
+
))}
|
|
52
|
+
</div>,
|
|
53
|
+
document.body,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ToastRow({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
|
|
58
|
+
const kind = item.kind ?? "info";
|
|
59
|
+
const duration = item.durationMs ?? 4000;
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const timer = setTimeout(onDismiss, duration);
|
|
62
|
+
return () => clearTimeout(timer);
|
|
63
|
+
}, [duration, onDismiss]);
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
className="ck-anim-slide-left"
|
|
67
|
+
style={{
|
|
68
|
+
pointerEvents: "auto",
|
|
69
|
+
padding: "12px 14px",
|
|
70
|
+
background: KIND_BG[kind],
|
|
71
|
+
border: `1px solid ${KIND_BORDER[kind]}`,
|
|
72
|
+
borderRadius: "var(--ck-radius-md)",
|
|
73
|
+
boxShadow: "var(--ck-shadow-2)",
|
|
74
|
+
color: KIND_TEXT[kind],
|
|
75
|
+
font: "400 13px/1.4 var(--ck-font-sans)",
|
|
76
|
+
cursor: "pointer",
|
|
77
|
+
}}
|
|
78
|
+
onClick={onDismiss}
|
|
79
|
+
>
|
|
80
|
+
{item.message}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type { ToastItem };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
import type { DialogsApi } from "./types";
|
|
3
|
+
|
|
4
|
+
// React Fast Refresh requires component modules to export only
|
|
5
|
+
// components — same reason ThemeContext lives in its own file.
|
|
6
|
+
export const DialogsContext = createContext<DialogsApi | null>(null);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Modal, ModalHeader, ModalBody, ModalFooter } from "./Modal";
|
|
2
|
+
export type { ModalProps, ModalHeaderProps } from "./Modal";
|
|
3
|
+
export { DialogsProvider } from "./DialogsProvider";
|
|
4
|
+
export { useDialogs } from "./useDialogs";
|
|
5
|
+
export type {
|
|
6
|
+
ConfirmOptions,
|
|
7
|
+
PromptOptions,
|
|
8
|
+
ToastOptions,
|
|
9
|
+
DialogsApi,
|
|
10
|
+
} from "./types";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ConfirmOptions {
|
|
4
|
+
title: string;
|
|
5
|
+
message?: ReactNode;
|
|
6
|
+
confirmLabel?: string;
|
|
7
|
+
cancelLabel?: string;
|
|
8
|
+
// Renders the confirm button as the critical tone.
|
|
9
|
+
danger?: boolean;
|
|
10
|
+
// When set, the user must type this string into a confirmation
|
|
11
|
+
// field before confirm is enabled. Case-insensitive.
|
|
12
|
+
confirmationText?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PromptOptions {
|
|
16
|
+
title: string;
|
|
17
|
+
message?: ReactNode;
|
|
18
|
+
defaultValue?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
confirmLabel?: string;
|
|
21
|
+
cancelLabel?: string;
|
|
22
|
+
// Returning a string treats it as a validation error message;
|
|
23
|
+
// returning null/undefined accepts the input.
|
|
24
|
+
validate?: (value: string) => string | null | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ToastOptions {
|
|
28
|
+
kind?: "info" | "success" | "error";
|
|
29
|
+
message: ReactNode;
|
|
30
|
+
durationMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DialogsApi {
|
|
34
|
+
confirm: (opts: ConfirmOptions) => Promise<boolean>;
|
|
35
|
+
prompt: (opts: PromptOptions) => Promise<string | null>;
|
|
36
|
+
toast: (opts: ToastOptions) => void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode, CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
// Local "spotlight" inversion for editorial chrome — the
|
|
4
|
+
// dark-canvas-with-cream-cards pattern from the brief, scoped to
|
|
5
|
+
// a single section rather than the whole page.
|
|
6
|
+
//
|
|
7
|
+
// Sets local CSS vars on a wrapper so every descendant component
|
|
8
|
+
// re-reads its tokens from the inverted palette. Pure CSS variable
|
|
9
|
+
// re-binding; no JS or context needed. Used inside any chrome —
|
|
10
|
+
// the kit doesn't require editorial to be active for the wrapper
|
|
11
|
+
// to work — but the visual lineage (cream surface + coral accent)
|
|
12
|
+
// reads most strongly under editorial.
|
|
13
|
+
|
|
14
|
+
export interface EditorialSpotlightProps {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
// Optional inner padding. Default 32 px.
|
|
17
|
+
padding?: number | string;
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function EditorialSpotlight({
|
|
23
|
+
children,
|
|
24
|
+
padding = 32,
|
|
25
|
+
className,
|
|
26
|
+
style,
|
|
27
|
+
}: EditorialSpotlightProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={className}
|
|
31
|
+
data-ck-spotlight
|
|
32
|
+
style={
|
|
33
|
+
{
|
|
34
|
+
// Dark canvas surrounding cream-paper cards.
|
|
35
|
+
background: "#0E0E0E",
|
|
36
|
+
color: "#F4EFE0",
|
|
37
|
+
padding,
|
|
38
|
+
borderRadius: "var(--ck-radius-md)",
|
|
39
|
+
// Rebind tokens so descendant components (Button, Card,
|
|
40
|
+
// Tag, etc.) read inverted values. Card surface stays
|
|
41
|
+
// cream; everything else flips to dark.
|
|
42
|
+
["--ck-bg-canvas"]: "#0E0E0E",
|
|
43
|
+
["--ck-bg-surface"]: "#F4EFE0",
|
|
44
|
+
["--ck-bg-surface-2"]: "#E5DECB",
|
|
45
|
+
["--ck-bg-muted"]: "#1F1F1F",
|
|
46
|
+
["--ck-text-primary"]: "#F4EFE0",
|
|
47
|
+
["--ck-text-secondary"]: "#C9C3B5",
|
|
48
|
+
["--ck-text-tertiary"]: "#8F897D",
|
|
49
|
+
["--ck-text-inverse"]: "#0F0F0F",
|
|
50
|
+
["--ck-border-subtle"]: "rgba(244, 239, 224, 0.18)",
|
|
51
|
+
["--ck-border-strong"]: "rgba(244, 239, 224, 0.32)",
|
|
52
|
+
...style,
|
|
53
|
+
} as CSSProperties
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
{/* Inner wrapper re-binds the text colour back to dark
|
|
57
|
+
ink so anything sitting on the cream cards inside reads
|
|
58
|
+
correctly. <Card> children get this for free via the
|
|
59
|
+
token rebinding on .ck-card. */}
|
|
60
|
+
{children}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Folio bar — thin hairline rule above a row of editorial
|
|
4
|
+
// metadata. Sits at the top of every section in the editorial
|
|
5
|
+
// chrome ("SECTION TITLE · CROSSREF · PLATE 0X" on the left,
|
|
6
|
+
// "00X / 008" page number on the right).
|
|
7
|
+
//
|
|
8
|
+
// Renders fine under any chrome but only feels "right" under
|
|
9
|
+
// editorial — the hairline + tracked metadata pair are the
|
|
10
|
+
// editorial language, neutral in other chromes.
|
|
11
|
+
|
|
12
|
+
export interface FolioProps {
|
|
13
|
+
// Left-aligned metadata fragments. Joined with the editorial
|
|
14
|
+
// separator (" · ").
|
|
15
|
+
left: (string | ReactNode)[];
|
|
16
|
+
// Right-aligned fragment (typically the page number).
|
|
17
|
+
right?: string | ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Folio({ left, right, className }: FolioProps) {
|
|
22
|
+
return (
|
|
23
|
+
<header
|
|
24
|
+
className={className}
|
|
25
|
+
style={{
|
|
26
|
+
display: "flex",
|
|
27
|
+
alignItems: "center",
|
|
28
|
+
gap: 12,
|
|
29
|
+
padding: "10px 0",
|
|
30
|
+
borderTop: "1px solid var(--ck-text-primary)",
|
|
31
|
+
borderBottom: "1px solid var(--ck-text-primary)",
|
|
32
|
+
margin: "0 0 24px",
|
|
33
|
+
font: "500 10px/1.2 var(--ck-font-sans)",
|
|
34
|
+
letterSpacing: "0.16em",
|
|
35
|
+
textTransform: "uppercase",
|
|
36
|
+
color: "var(--ck-text-primary)",
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<div style={{ flex: 1, minWidth: 0, display: "flex", flexWrap: "wrap", gap: "0 8px" }}>
|
|
40
|
+
{left.map((seg, i) => (
|
|
41
|
+
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
|
42
|
+
{i > 0 && <span style={{ color: "var(--ck-text-tertiary)" }}>·</span>}
|
|
43
|
+
<span>{seg}</span>
|
|
44
|
+
</span>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
{right && (
|
|
48
|
+
<div style={{ flex: "none", color: "var(--ck-text-secondary)" }}>{right}</div>
|
|
49
|
+
)}
|
|
50
|
+
</header>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// "N° 01" / "N° 02" plate numbering — sits in the top-left of
|
|
2
|
+
// editorial cards and stat blocks. Uses small caps for the prefix
|
|
3
|
+
// and a tabular numeral so a column of plates aligns visually.
|
|
4
|
+
|
|
5
|
+
export interface PlateMarkerProps {
|
|
6
|
+
// The numeral, 1-indexed. Zero-padded to 2 digits ("01").
|
|
7
|
+
n: number;
|
|
8
|
+
// "N°" by default. Override to "Plate", "Vol.", "Issue", etc.
|
|
9
|
+
prefix?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function PlateMarker({ n, prefix = "N°", className }: PlateMarkerProps) {
|
|
14
|
+
const padded = n < 10 ? `0${n}` : String(n);
|
|
15
|
+
return (
|
|
16
|
+
<span
|
|
17
|
+
className={className}
|
|
18
|
+
style={{
|
|
19
|
+
display: "inline-flex",
|
|
20
|
+
alignItems: "baseline",
|
|
21
|
+
gap: 4,
|
|
22
|
+
font: "500 10px/1 var(--ck-font-sans)",
|
|
23
|
+
letterSpacing: "0.18em",
|
|
24
|
+
textTransform: "uppercase",
|
|
25
|
+
color: "var(--ck-text-tertiary)",
|
|
26
|
+
fontVariantNumeric: "tabular-nums",
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
<span style={{ fontStyle: "italic" }}>{prefix}</span>
|
|
30
|
+
<span style={{ color: "var(--ck-text-primary)" }}>{padded}</span>
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Section break with a Roman-numeral marker. "I.", "II.", ...
|
|
4
|
+
// in oversized serif italic, followed by a thin hairline rule.
|
|
5
|
+
// Use to delimit major content slabs inside an editorial page.
|
|
6
|
+
|
|
7
|
+
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"];
|
|
8
|
+
|
|
9
|
+
export interface RomanSectionProps {
|
|
10
|
+
// 1-indexed numeral. Anything above XII falls back to the
|
|
11
|
+
// arabic numeral so we don't have to ship a full converter.
|
|
12
|
+
n: number;
|
|
13
|
+
title: ReactNode;
|
|
14
|
+
// Optional eyebrow (rendered above the numeral).
|
|
15
|
+
eyebrow?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function RomanSection({ n, title, eyebrow }: RomanSectionProps) {
|
|
19
|
+
const numeral = n >= 1 && n <= ROMAN.length ? ROMAN[n - 1] : String(n);
|
|
20
|
+
return (
|
|
21
|
+
<section style={{ margin: "56px 0 32px" }}>
|
|
22
|
+
{eyebrow && (
|
|
23
|
+
<div
|
|
24
|
+
style={{
|
|
25
|
+
font: "500 10px/1 var(--ck-font-sans)",
|
|
26
|
+
letterSpacing: "0.18em",
|
|
27
|
+
textTransform: "uppercase",
|
|
28
|
+
color: "var(--ck-text-tertiary)",
|
|
29
|
+
marginBottom: 8,
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
— {eyebrow}
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
<div style={{ display: "flex", alignItems: "baseline", gap: 16, marginBottom: 12 }}>
|
|
36
|
+
<span
|
|
37
|
+
style={{
|
|
38
|
+
font: "500 italic 64px/1 var(--ck-font-serif)",
|
|
39
|
+
color: "var(--ck-text-primary)",
|
|
40
|
+
letterSpacing: "-0.02em",
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{numeral}.
|
|
44
|
+
</span>
|
|
45
|
+
<h2
|
|
46
|
+
style={{
|
|
47
|
+
font: "500 28px/1.1 var(--ck-font-display)",
|
|
48
|
+
color: "var(--ck-text-primary)",
|
|
49
|
+
margin: 0,
|
|
50
|
+
letterSpacing: "-0.01em",
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{title}
|
|
54
|
+
</h2>
|
|
55
|
+
</div>
|
|
56
|
+
<hr
|
|
57
|
+
style={{
|
|
58
|
+
border: "none",
|
|
59
|
+
borderTop: "1px solid var(--ck-text-primary)",
|
|
60
|
+
margin: 0,
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
</section>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
// Vertical tracked-uppercase tagline in a far-side gutter — pure
|
|
4
|
+
// decoration that sets the magazine tone. Pin to either "left" or
|
|
5
|
+
// "right" edge of the viewport. Repeats the tagline string so it
|
|
6
|
+
// fills the column regardless of height.
|
|
7
|
+
//
|
|
8
|
+
// Skip on phones (the gutter doesn't exist on a 360-px-wide page).
|
|
9
|
+
|
|
10
|
+
export interface RunningMarginaliaProps {
|
|
11
|
+
// The tagline to repeat. Don't include separators — the
|
|
12
|
+
// component injects " × " between repetitions.
|
|
13
|
+
text: string;
|
|
14
|
+
// Which edge to pin to. Default left.
|
|
15
|
+
side?: "left" | "right";
|
|
16
|
+
// px offset from the edge. Default 24.
|
|
17
|
+
inset?: number;
|
|
18
|
+
// How many repetitions to print. Default 8 — plenty for any
|
|
19
|
+
// viewport at the default font size.
|
|
20
|
+
repeat?: number;
|
|
21
|
+
// Hide below this viewport width (px). Default 1024.
|
|
22
|
+
hideBelow?: number;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function RunningMarginalia({
|
|
27
|
+
text,
|
|
28
|
+
side = "left",
|
|
29
|
+
inset = 24,
|
|
30
|
+
repeat = 8,
|
|
31
|
+
hideBelow = 1024,
|
|
32
|
+
className,
|
|
33
|
+
}: RunningMarginaliaProps) {
|
|
34
|
+
const display: CSSProperties =
|
|
35
|
+
typeof window !== "undefined" && window.innerWidth < hideBelow
|
|
36
|
+
? { display: "none" }
|
|
37
|
+
: {};
|
|
38
|
+
const fragments = new Array(repeat).fill(text).join(" × ");
|
|
39
|
+
return (
|
|
40
|
+
<aside
|
|
41
|
+
aria-hidden
|
|
42
|
+
className={className}
|
|
43
|
+
style={{
|
|
44
|
+
position: "fixed",
|
|
45
|
+
top: "50%",
|
|
46
|
+
[side]: inset,
|
|
47
|
+
transform:
|
|
48
|
+
side === "left"
|
|
49
|
+
? "translateY(-50%) rotate(-90deg)"
|
|
50
|
+
: "translateY(-50%) rotate(90deg)",
|
|
51
|
+
transformOrigin: side === "left" ? "left center" : "right center",
|
|
52
|
+
font: "500 10px/1 var(--ck-font-sans)",
|
|
53
|
+
letterSpacing: "0.36em",
|
|
54
|
+
textTransform: "uppercase",
|
|
55
|
+
color: "var(--ck-text-tertiary)",
|
|
56
|
+
whiteSpace: "nowrap",
|
|
57
|
+
pointerEvents: "none",
|
|
58
|
+
zIndex: 5,
|
|
59
|
+
...display,
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{fragments}
|
|
63
|
+
</aside>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Folio } from "./Folio";
|
|
2
|
+
export type { FolioProps } from "./Folio";
|
|
3
|
+
export { PlateMarker } from "./PlateMarker";
|
|
4
|
+
export type { PlateMarkerProps } from "./PlateMarker";
|
|
5
|
+
export { RunningMarginalia } from "./RunningMarginalia";
|
|
6
|
+
export type { RunningMarginaliaProps } from "./RunningMarginalia";
|
|
7
|
+
export { RomanSection } from "./RomanSection";
|
|
8
|
+
export type { RomanSectionProps } from "./RomanSection";
|
|
9
|
+
export { EditorialSpotlight } from "./EditorialSpotlight";
|
|
10
|
+
export type { EditorialSpotlightProps } from "./EditorialSpotlight";
|