@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.
Files changed (109) hide show
  1. package/package.json +38 -0
  2. package/src/actionbar/ActionBar.tsx +436 -0
  3. package/src/actionbar/ActionBarButton.tsx +110 -0
  4. package/src/actionbar/ActionBarMenuGroup.tsx +106 -0
  5. package/src/actionbar/ActionBarProvider.tsx +76 -0
  6. package/src/actionbar/actionbar-context.ts +23 -0
  7. package/src/actionbar/index.ts +13 -0
  8. package/src/actionbar/types.ts +50 -0
  9. package/src/actionbar/useActionBarItems.ts +47 -0
  10. package/src/ambient/AmbientBackdrop.tsx +74 -0
  11. package/src/ambient/CommandInput.tsx +107 -0
  12. package/src/ambient/SuperbarStrip.tsx +36 -0
  13. package/src/ambient/index.ts +6 -0
  14. package/src/bento/BentoCell.tsx +66 -0
  15. package/src/bento/BentoGrid.tsx +42 -0
  16. package/src/bento/index.ts +2 -0
  17. package/src/command/CommandPalette.tsx +277 -0
  18. package/src/command/CommandProvider.tsx +57 -0
  19. package/src/command/command-context.ts +12 -0
  20. package/src/command/index.ts +6 -0
  21. package/src/command/rank.ts +45 -0
  22. package/src/command/types.ts +26 -0
  23. package/src/command/useCommandSource.ts +37 -0
  24. package/src/dialogs/DialogsProvider.tsx +216 -0
  25. package/src/dialogs/Modal.tsx +204 -0
  26. package/src/dialogs/Toast.tsx +85 -0
  27. package/src/dialogs/dialogs-context.ts +6 -0
  28. package/src/dialogs/index.ts +10 -0
  29. package/src/dialogs/types.ts +37 -0
  30. package/src/dialogs/useDialogs.ts +8 -0
  31. package/src/editorial/EditorialSpotlight.tsx +63 -0
  32. package/src/editorial/Folio.tsx +52 -0
  33. package/src/editorial/PlateMarker.tsx +33 -0
  34. package/src/editorial/RomanSection.tsx +65 -0
  35. package/src/editorial/RunningMarginalia.tsx +65 -0
  36. package/src/editorial/index.ts +10 -0
  37. package/src/frutiger/GlossyOrb.tsx +79 -0
  38. package/src/frutiger/SkyBackdrop.tsx +114 -0
  39. package/src/frutiger/index.ts +2 -0
  40. package/src/hooks/index.ts +5 -0
  41. package/src/hooks/useKeyboardHotkey.ts +80 -0
  42. package/src/hooks/useReducedMotion.ts +20 -0
  43. package/src/hooks/useViewport.ts +61 -0
  44. package/src/index.ts +26 -0
  45. package/src/layout/Breadcrumb.tsx +74 -0
  46. package/src/layout/LeftNavRail.tsx +126 -0
  47. package/src/layout/MobileTabBar.tsx +101 -0
  48. package/src/layout/NavItem.tsx +128 -0
  49. package/src/layout/NavSearchTrigger.tsx +88 -0
  50. package/src/layout/NavSection.tsx +40 -0
  51. package/src/layout/RightSidebarPanel.tsx +111 -0
  52. package/src/layout/Shell.tsx +91 -0
  53. package/src/layout/StickyBanner.tsx +83 -0
  54. package/src/layout/Topbar.tsx +68 -0
  55. package/src/layout/index.ts +22 -0
  56. package/src/layout/useNavRailState.ts +69 -0
  57. package/src/lib/cn.ts +7 -0
  58. package/src/lib/time-utils.ts +44 -0
  59. package/src/neobrutalism/Marquee.tsx +81 -0
  60. package/src/neobrutalism/Sticker.tsx +71 -0
  61. package/src/neobrutalism/index.ts +4 -0
  62. package/src/primitives/Avatar.tsx +53 -0
  63. package/src/primitives/Button.tsx +30 -0
  64. package/src/primitives/Card.tsx +41 -0
  65. package/src/primitives/Checkbox.tsx +78 -0
  66. package/src/primitives/CountBadge.tsx +50 -0
  67. package/src/primitives/Input.tsx +71 -0
  68. package/src/primitives/Kbd.tsx +45 -0
  69. package/src/primitives/PageHeader.tsx +77 -0
  70. package/src/primitives/Tag.tsx +56 -0
  71. package/src/primitives/Textarea.tsx +62 -0
  72. package/src/primitives/ToggleSwitch.tsx +79 -0
  73. package/src/primitives/Tooltip.tsx +171 -0
  74. package/src/primitives/index.ts +24 -0
  75. package/src/pwa/InstallPromptBanner.tsx +132 -0
  76. package/src/pwa/index.ts +4 -0
  77. package/src/pwa/manifest.template.json +20 -0
  78. package/src/pwa/registerSW.ts +55 -0
  79. package/src/riso/Halftone.tsx +85 -0
  80. package/src/riso/Misregister.tsx +63 -0
  81. package/src/riso/RisoStamp.tsx +76 -0
  82. package/src/riso/index.ts +3 -0
  83. package/src/sketch/HandUnderline.tsx +53 -0
  84. package/src/sketch/RoughArrow.tsx +91 -0
  85. package/src/sketch/RoughBox.tsx +73 -0
  86. package/src/sketch/StickyNote.tsx +56 -0
  87. package/src/sketch/index.ts +4 -0
  88. package/src/styles/base.css +80 -0
  89. package/src/styles/chrome-ambient.css +222 -0
  90. package/src/styles/chrome-bento.css +184 -0
  91. package/src/styles/chrome-editorial.css +145 -0
  92. package/src/styles/chrome-frutiger.css +364 -0
  93. package/src/styles/chrome-neobrutalism.css +315 -0
  94. package/src/styles/chrome-riso.css +328 -0
  95. package/src/styles/chrome-sketch.css +351 -0
  96. package/src/styles/chrome-swiss.css +232 -0
  97. package/src/styles/chrome-terminal.css +235 -0
  98. package/src/styles/fonts.css +22 -0
  99. package/src/styles/index.css +198 -0
  100. package/src/styles/tokens.css +976 -0
  101. package/src/terminal/AsciiBox.tsx +65 -0
  102. package/src/terminal/BrailleSpinner.tsx +46 -0
  103. package/src/terminal/index.ts +4 -0
  104. package/src/theme/ThemeProvider.tsx +93 -0
  105. package/src/theme/index.ts +5 -0
  106. package/src/theme/inline-script.ts +36 -0
  107. package/src/theme/theme-context.ts +7 -0
  108. package/src/theme/types.ts +22 -0
  109. 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,8 @@
1
+ import { useContext } from "react";
2
+ import { DialogsContext } from "./dialogs-context";
3
+
4
+ export function useDialogs() {
5
+ const ctx = useContext(DialogsContext);
6
+ if (!ctx) throw new Error("useDialogs must be used within <DialogsProvider>");
7
+ return ctx;
8
+ }
@@ -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";