@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,132 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Button } from "../primitives/Button";
|
|
3
|
+
|
|
4
|
+
// Surfaces the browser's PWA install affordance — `beforeinstallprompt`
|
|
5
|
+
// is the Chromium-side hook that fires when a site qualifies for
|
|
6
|
+
// install. Without intervention browsers eventually suppress it; we
|
|
7
|
+
// catch the event, render a dismissable banner, and trigger the
|
|
8
|
+
// prompt only when the user clicks Install.
|
|
9
|
+
//
|
|
10
|
+
// Dismissal persists in localStorage so the banner doesn't reappear
|
|
11
|
+
// on every page load after the user said no thanks.
|
|
12
|
+
//
|
|
13
|
+
// iOS Safari doesn't fire beforeinstallprompt at all — for that
|
|
14
|
+
// platform you need to show your own "Tap share → Add to Home
|
|
15
|
+
// Screen" instructions. Detect via `iosSafari` prop or your own
|
|
16
|
+
// heuristic and render a different banner if needed.
|
|
17
|
+
|
|
18
|
+
interface BeforeInstallPromptEvent extends Event {
|
|
19
|
+
prompt: () => Promise<void>;
|
|
20
|
+
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface InstallPromptBannerProps {
|
|
24
|
+
// Heading text.
|
|
25
|
+
title?: string;
|
|
26
|
+
// Body text below the heading.
|
|
27
|
+
message?: string;
|
|
28
|
+
installLabel?: string;
|
|
29
|
+
dismissLabel?: string;
|
|
30
|
+
// localStorage key for the dismissal flag.
|
|
31
|
+
storageKey?: string;
|
|
32
|
+
// Called after the user installs (accepted) or dismisses.
|
|
33
|
+
onResolve?: (outcome: "accepted" | "dismissed") => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function InstallPromptBanner({
|
|
37
|
+
title = "Install this app",
|
|
38
|
+
message = "Add it to your home screen for a faster, full-screen experience.",
|
|
39
|
+
installLabel = "Install",
|
|
40
|
+
dismissLabel = "Not now",
|
|
41
|
+
storageKey = "ck-install-prompt-dismissed",
|
|
42
|
+
onResolve,
|
|
43
|
+
}: InstallPromptBannerProps) {
|
|
44
|
+
const [evt, setEvt] = useState<BeforeInstallPromptEvent | null>(null);
|
|
45
|
+
const [dismissed, setDismissed] = useState(() => {
|
|
46
|
+
if (typeof window === "undefined") return true;
|
|
47
|
+
return window.localStorage.getItem(storageKey) === "1";
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (dismissed) return;
|
|
52
|
+
const onBefore = (e: Event) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
setEvt(e as BeforeInstallPromptEvent);
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener("beforeinstallprompt", onBefore);
|
|
57
|
+
return () => window.removeEventListener("beforeinstallprompt", onBefore);
|
|
58
|
+
}, [dismissed]);
|
|
59
|
+
|
|
60
|
+
const dismiss = () => {
|
|
61
|
+
window.localStorage.setItem(storageKey, "1");
|
|
62
|
+
setDismissed(true);
|
|
63
|
+
setEvt(null);
|
|
64
|
+
onResolve?.("dismissed");
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const install = async () => {
|
|
68
|
+
if (!evt) return;
|
|
69
|
+
await evt.prompt();
|
|
70
|
+
const choice = await evt.userChoice;
|
|
71
|
+
setEvt(null);
|
|
72
|
+
if (choice.outcome === "accepted") {
|
|
73
|
+
window.localStorage.setItem(storageKey, "1");
|
|
74
|
+
setDismissed(true);
|
|
75
|
+
}
|
|
76
|
+
onResolve?.(choice.outcome);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (dismissed || !evt) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
role="region"
|
|
84
|
+
aria-label="Install app"
|
|
85
|
+
style={{
|
|
86
|
+
position: "fixed",
|
|
87
|
+
left: 16,
|
|
88
|
+
right: 16,
|
|
89
|
+
bottom: `calc(16px + var(--ck-tabbar-height, 0px))`,
|
|
90
|
+
zIndex: 90,
|
|
91
|
+
maxWidth: 480,
|
|
92
|
+
marginLeft: "auto",
|
|
93
|
+
marginRight: "auto",
|
|
94
|
+
background: "var(--ck-bg-surface)",
|
|
95
|
+
border: "1px solid var(--ck-border-subtle)",
|
|
96
|
+
borderRadius: "var(--ck-radius-md)",
|
|
97
|
+
boxShadow: "var(--ck-shadow-2)",
|
|
98
|
+
padding: "14px 16px",
|
|
99
|
+
display: "flex",
|
|
100
|
+
gap: 12,
|
|
101
|
+
alignItems: "center",
|
|
102
|
+
fontFamily: "var(--ck-font-sans)",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
106
|
+
<div
|
|
107
|
+
style={{
|
|
108
|
+
font: "500 14px/1.3 var(--ck-font-sans)",
|
|
109
|
+
color: "var(--ck-text-primary)",
|
|
110
|
+
marginBottom: 2,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{title}
|
|
114
|
+
</div>
|
|
115
|
+
<div
|
|
116
|
+
style={{
|
|
117
|
+
font: "400 12px/1.4 var(--ck-font-sans)",
|
|
118
|
+
color: "var(--ck-text-tertiary)",
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{message}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<Button variant="ghost" onClick={dismiss}>
|
|
125
|
+
{dismissLabel}
|
|
126
|
+
</Button>
|
|
127
|
+
<Button variant="primary" onClick={install}>
|
|
128
|
+
{installLabel}
|
|
129
|
+
</Button>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
package/src/pwa/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Your App Name",
|
|
3
|
+
"short_name": "AppName",
|
|
4
|
+
"description": "One-line app description.",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"orientation": "portrait",
|
|
8
|
+
"theme_color": "#0B0D12",
|
|
9
|
+
"background_color": "#0B0D12",
|
|
10
|
+
"icons": [
|
|
11
|
+
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
|
12
|
+
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
|
13
|
+
{
|
|
14
|
+
"src": "/icon-512-maskable.png",
|
|
15
|
+
"sizes": "512x512",
|
|
16
|
+
"type": "image/png",
|
|
17
|
+
"purpose": "maskable"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Thin wrapper around vite-plugin-pwa's `virtual:pwa-register`. Use
|
|
2
|
+
// from your app's entry point (main.tsx) so the service worker is
|
|
3
|
+
// registered on first paint. Behaviour:
|
|
4
|
+
//
|
|
5
|
+
// - new SW detected → optional onNeedRefresh callback (typically
|
|
6
|
+
// toast prompting reload)
|
|
7
|
+
// - SW activated for the first time → onOfflineReady callback
|
|
8
|
+
// (typically toast announcing "Ready offline")
|
|
9
|
+
//
|
|
10
|
+
// The kit doesn't pull in vite-plugin-pwa as a dep — the consumer
|
|
11
|
+
// app installs and configures it. This wrapper just centralises
|
|
12
|
+
// the registration boilerplate so consumers don't repeat it.
|
|
13
|
+
|
|
14
|
+
export interface RegisterSWOptions {
|
|
15
|
+
onNeedRefresh?: () => void;
|
|
16
|
+
onOfflineReady?: () => void;
|
|
17
|
+
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void;
|
|
18
|
+
onRegisterError?: (error: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Specifier is constructed from a runtime expression so Vite's
|
|
22
|
+
// static import analyser can't see "virtual:pwa-register" as a
|
|
23
|
+
// literal — without this, every consumer app would fail to boot
|
|
24
|
+
// unless they had vite-plugin-pwa installed AND configured (since
|
|
25
|
+
// transform happens before the try/catch runs at runtime).
|
|
26
|
+
//
|
|
27
|
+
// The eval is the simplest indirection that's also obviously a
|
|
28
|
+
// no-op to readers. /* @vite-ignore */ on its own isn't enough
|
|
29
|
+
// because Vite still attempts resolution when the specifier is a
|
|
30
|
+
// constant string literal.
|
|
31
|
+
const PWA_REGISTER_SPECIFIER = ["virtual", "pwa-register"].join(":");
|
|
32
|
+
|
|
33
|
+
export async function registerSW(opts: RegisterSWOptions = {}) {
|
|
34
|
+
if (typeof window === "undefined") return;
|
|
35
|
+
try {
|
|
36
|
+
const mod = await import(/* @vite-ignore */ PWA_REGISTER_SPECIFIER);
|
|
37
|
+
if (typeof mod.registerSW !== "function") {
|
|
38
|
+
console.warn("virtual:pwa-register found but registerSW() is not a function");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
mod.registerSW({
|
|
42
|
+
immediate: true,
|
|
43
|
+
onNeedRefresh: opts.onNeedRefresh,
|
|
44
|
+
onOfflineReady: opts.onOfflineReady,
|
|
45
|
+
onRegisteredSW: (_url: string, reg: ServiceWorkerRegistration | undefined) => {
|
|
46
|
+
opts.onRegistered?.(reg);
|
|
47
|
+
},
|
|
48
|
+
onRegisterError: opts.onRegisterError,
|
|
49
|
+
});
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// vite-plugin-pwa not installed / configured. Silent in dev,
|
|
52
|
+
// surface to caller if they care.
|
|
53
|
+
opts.onRegisterError?.(err);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Halftone-tinted image / fill block. Renders a solid ink-color
|
|
4
|
+
// background with a halftone dot pattern overlay; if `src` is
|
|
5
|
+
// provided, the image sits underneath in `mix-blend-mode: multiply`
|
|
6
|
+
// for a duotone-on-paper feel.
|
|
7
|
+
//
|
|
8
|
+
// Use for hero blocks, empty-state illustrations, photo treatments.
|
|
9
|
+
|
|
10
|
+
export interface HalftoneProps {
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
// Optional image source. The image is duotoned via blend modes.
|
|
13
|
+
src?: string;
|
|
14
|
+
alt?: string;
|
|
15
|
+
// Ink color of the dot fill. Defaults to riso pink.
|
|
16
|
+
ink?: string;
|
|
17
|
+
// Dot size in px. Defaults to 1.6.
|
|
18
|
+
dotSize?: number;
|
|
19
|
+
// Dot grid spacing in px. Defaults to 7.
|
|
20
|
+
dotSpacing?: number;
|
|
21
|
+
width?: number | string;
|
|
22
|
+
height?: number | string;
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Halftone({
|
|
28
|
+
children,
|
|
29
|
+
src,
|
|
30
|
+
alt = "",
|
|
31
|
+
ink = "var(--ck-riso-pink, #FF48B0)",
|
|
32
|
+
dotSize = 1.6,
|
|
33
|
+
dotSpacing = 7,
|
|
34
|
+
width = "100%",
|
|
35
|
+
height = 200,
|
|
36
|
+
className,
|
|
37
|
+
style,
|
|
38
|
+
}: HalftoneProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className={className}
|
|
42
|
+
style={{
|
|
43
|
+
position: "relative",
|
|
44
|
+
width,
|
|
45
|
+
height,
|
|
46
|
+
background: ink,
|
|
47
|
+
overflow: "hidden",
|
|
48
|
+
borderRadius: 4,
|
|
49
|
+
...style,
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{src && (
|
|
53
|
+
<img
|
|
54
|
+
src={src}
|
|
55
|
+
alt={alt}
|
|
56
|
+
style={{
|
|
57
|
+
position: "absolute",
|
|
58
|
+
inset: 0,
|
|
59
|
+
width: "100%",
|
|
60
|
+
height: "100%",
|
|
61
|
+
objectFit: "cover",
|
|
62
|
+
mixBlendMode: "multiply",
|
|
63
|
+
filter: "grayscale(1) contrast(1.15)",
|
|
64
|
+
pointerEvents: "none",
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
<div
|
|
69
|
+
aria-hidden
|
|
70
|
+
style={{
|
|
71
|
+
position: "absolute",
|
|
72
|
+
inset: 0,
|
|
73
|
+
pointerEvents: "none",
|
|
74
|
+
backgroundImage: `radial-gradient(circle, rgba(26,26,26,0.85) ${dotSize}px, transparent ${dotSize + 0.2}px)`,
|
|
75
|
+
backgroundSize: `${dotSpacing}px ${dotSpacing}px`,
|
|
76
|
+
mixBlendMode: "multiply",
|
|
77
|
+
opacity: 0.55,
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
{children && (
|
|
81
|
+
<div style={{ position: "relative", zIndex: 2, color: "#1A1A1A" }}>{children}</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Misregistration — render text (or any child) twice, with a small
|
|
4
|
+
// offset between an "under" duplicate in a second ink color and the
|
|
5
|
+
// "top" pass in the primary ink. The signature riso effect.
|
|
6
|
+
//
|
|
7
|
+
// The under copy is set aria-hidden so screen readers don't double-
|
|
8
|
+
// read the content.
|
|
9
|
+
|
|
10
|
+
export interface MisregisterProps {
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
// Ink color of the foreground (top) pass.
|
|
13
|
+
ink?: string;
|
|
14
|
+
// Ink color of the misregistered duplicate. Defaults to riso pink.
|
|
15
|
+
offsetInk?: string;
|
|
16
|
+
// Offset of the duplicate, in px. Defaults to {x:3, y:3}.
|
|
17
|
+
offsetX?: number;
|
|
18
|
+
offsetY?: number;
|
|
19
|
+
// Wrap in inline-block? Defaults true. Pass false for block use.
|
|
20
|
+
inline?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
style?: CSSProperties;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Misregister({
|
|
26
|
+
children,
|
|
27
|
+
ink,
|
|
28
|
+
offsetInk = "var(--ck-riso-pink, #FF48B0)",
|
|
29
|
+
offsetX = 3,
|
|
30
|
+
offsetY = 3,
|
|
31
|
+
inline = true,
|
|
32
|
+
className,
|
|
33
|
+
style,
|
|
34
|
+
}: MisregisterProps) {
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
className={className}
|
|
38
|
+
style={{
|
|
39
|
+
display: inline ? "inline-block" : "block",
|
|
40
|
+
position: "relative",
|
|
41
|
+
color: ink ?? "var(--ck-text-primary)",
|
|
42
|
+
...style,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<span
|
|
46
|
+
aria-hidden
|
|
47
|
+
style={{
|
|
48
|
+
position: "absolute",
|
|
49
|
+
top: offsetY,
|
|
50
|
+
left: offsetX,
|
|
51
|
+
color: offsetInk,
|
|
52
|
+
mixBlendMode: "multiply",
|
|
53
|
+
pointerEvents: "none",
|
|
54
|
+
width: "100%",
|
|
55
|
+
height: "100%",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</span>
|
|
60
|
+
<span style={{ position: "relative" }}>{children}</span>
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Hand-stamped label — slightly rotated, bordered, with an offset
|
|
4
|
+
// ink block "shadow." The riso equivalent of a Tag but louder.
|
|
5
|
+
// Use for "NEW", "LIMITED", "ED. 01", "OUT NOW" annotations
|
|
6
|
+
// layered over photos / hero blocks.
|
|
7
|
+
|
|
8
|
+
export interface RisoStampProps {
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
// Ink color of the stamp face. Defaults to pink.
|
|
11
|
+
ink?: "pink" | "blue" | "yellow" | "orange" | "teal" | "violet" | "red" | "green";
|
|
12
|
+
// Ink color of the offset block behind. Defaults to ink-black.
|
|
13
|
+
offset?: "pink" | "blue" | "yellow" | "orange" | "teal" | "violet" | "red" | "green" | "ink";
|
|
14
|
+
// Rotation in degrees. Defaults to -4.
|
|
15
|
+
rotate?: number;
|
|
16
|
+
// Larger / smaller sizing presets.
|
|
17
|
+
size?: "sm" | "md" | "lg";
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const INKS: Record<string, string> = {
|
|
23
|
+
pink: "var(--ck-riso-pink, #FF48B0)",
|
|
24
|
+
blue: "var(--ck-riso-blue, #3D5AFE)",
|
|
25
|
+
yellow: "var(--ck-riso-yellow, #FFE100)",
|
|
26
|
+
orange: "var(--ck-riso-orange, #FF6F3C)",
|
|
27
|
+
teal: "var(--ck-riso-teal, #00A99D)",
|
|
28
|
+
violet: "var(--ck-riso-violet, #765BA7)",
|
|
29
|
+
red: "var(--ck-riso-red, #F02D2D)",
|
|
30
|
+
green: "var(--ck-riso-green, #00A95C)",
|
|
31
|
+
ink: "var(--ck-text-primary, #1A1A1A)",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SIZES = {
|
|
35
|
+
sm: { font: 10, padX: 6, padY: 3, offset: 2 },
|
|
36
|
+
md: { font: 12, padX: 10, padY: 5, offset: 3 },
|
|
37
|
+
lg: { font: 16, padX: 14, padY: 7, offset: 4 },
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export function RisoStamp({
|
|
41
|
+
children,
|
|
42
|
+
ink = "pink",
|
|
43
|
+
offset = "ink",
|
|
44
|
+
rotate = -4,
|
|
45
|
+
size = "md",
|
|
46
|
+
className,
|
|
47
|
+
style,
|
|
48
|
+
}: RisoStampProps) {
|
|
49
|
+
const inkColor = INKS[ink] ?? INKS.pink;
|
|
50
|
+
const offsetColor = INKS[offset] ?? INKS.ink;
|
|
51
|
+
const s = SIZES[size];
|
|
52
|
+
// Yellow / lime ink reads better with dark text.
|
|
53
|
+
const lightOnDark = ink === "yellow" || ink === "orange" || ink === "green" || ink === "pink";
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
className={className}
|
|
57
|
+
style={{
|
|
58
|
+
display: "inline-block",
|
|
59
|
+
background: inkColor,
|
|
60
|
+
color: lightOnDark ? "#1A1A1A" : "#F8F1DE",
|
|
61
|
+
border: "2px solid #1A1A1A",
|
|
62
|
+
borderRadius: 4,
|
|
63
|
+
padding: `${s.padY}px ${s.padX}px`,
|
|
64
|
+
font: `800 ${s.font}px/1 "Helvetica Neue", "Inter", system-ui, sans-serif`,
|
|
65
|
+
letterSpacing: "0.08em",
|
|
66
|
+
textTransform: "uppercase",
|
|
67
|
+
boxShadow: `${s.offset}px ${s.offset}px 0 ${offsetColor}`,
|
|
68
|
+
transform: `rotate(${rotate}deg)`,
|
|
69
|
+
whiteSpace: "nowrap",
|
|
70
|
+
...style,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Wraps a string of text with a hand-drawn underline. The
|
|
4
|
+
// underline is a CSS-only wobble using the same SVG-filter trick
|
|
5
|
+
// the chrome uses on hr / .ck-card head strokes. Falls back to a
|
|
6
|
+
// solid 2px line in browsers that ignore the data-URL filter.
|
|
7
|
+
|
|
8
|
+
export interface HandUnderlineProps {
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
// Underline color. Defaults to the current text color.
|
|
11
|
+
ink?: string;
|
|
12
|
+
// Thickness in px. Defaults to 2.
|
|
13
|
+
thickness?: number;
|
|
14
|
+
className?: string;
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function HandUnderline({
|
|
19
|
+
children,
|
|
20
|
+
ink = "currentColor",
|
|
21
|
+
thickness = 2,
|
|
22
|
+
className,
|
|
23
|
+
style,
|
|
24
|
+
}: HandUnderlineProps) {
|
|
25
|
+
return (
|
|
26
|
+
<span
|
|
27
|
+
className={className}
|
|
28
|
+
style={{
|
|
29
|
+
position: "relative",
|
|
30
|
+
display: "inline-block",
|
|
31
|
+
paddingBottom: 2,
|
|
32
|
+
...style,
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
<span
|
|
37
|
+
aria-hidden
|
|
38
|
+
style={{
|
|
39
|
+
position: "absolute",
|
|
40
|
+
left: 0,
|
|
41
|
+
right: 0,
|
|
42
|
+
bottom: -1,
|
|
43
|
+
height: thickness,
|
|
44
|
+
background: ink,
|
|
45
|
+
borderRadius: thickness,
|
|
46
|
+
filter: "var(--ck-sketch-wobble)",
|
|
47
|
+
opacity: 0.85,
|
|
48
|
+
pointerEvents: "none",
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
</span>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
// Inline SVG hand-drawn arrow. Used between elements on a canvas
|
|
4
|
+
// to indicate connection / flow, or as an annotation pointer.
|
|
5
|
+
// The path is hand-tuned to feel drawn, not generated — small
|
|
6
|
+
// kinks, asymmetric arrowhead. Stroke color + width controllable.
|
|
7
|
+
//
|
|
8
|
+
// The arrow draws on via stroke-dasharray animation on mount when
|
|
9
|
+
// `animated` is true.
|
|
10
|
+
|
|
11
|
+
export interface RoughArrowProps {
|
|
12
|
+
// Endpoints in px (relative to the SVG's own coordinate space).
|
|
13
|
+
from?: [number, number];
|
|
14
|
+
to?: [number, number];
|
|
15
|
+
// Stroke color. Defaults to ink.
|
|
16
|
+
ink?: string;
|
|
17
|
+
// Stroke thickness. Defaults to 2.
|
|
18
|
+
thickness?: number;
|
|
19
|
+
// Curve amount — 0 is a straight wobble, 1 is a strong arc.
|
|
20
|
+
curve?: number;
|
|
21
|
+
// Animate the draw-on. Defaults to false (instant).
|
|
22
|
+
animated?: boolean;
|
|
23
|
+
width?: number | string;
|
|
24
|
+
height?: number | string;
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function RoughArrow({
|
|
30
|
+
from = [10, 40],
|
|
31
|
+
to = [220, 40],
|
|
32
|
+
ink = "var(--ck-text-primary, #1A1A1A)",
|
|
33
|
+
thickness = 2,
|
|
34
|
+
curve = 0.4,
|
|
35
|
+
animated = false,
|
|
36
|
+
width = 240,
|
|
37
|
+
height = 80,
|
|
38
|
+
className,
|
|
39
|
+
style,
|
|
40
|
+
}: RoughArrowProps) {
|
|
41
|
+
const [x1, y1] = from;
|
|
42
|
+
const [x2, y2] = to;
|
|
43
|
+
const dx = x2 - x1;
|
|
44
|
+
const dy = y2 - y1;
|
|
45
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
46
|
+
// Perpendicular for the control offsets — produces a gentle arc.
|
|
47
|
+
const px = -dy / len;
|
|
48
|
+
const py = dx / len;
|
|
49
|
+
// Two control points with tiny irregular wobble.
|
|
50
|
+
const cx1 = x1 + dx * 0.3 + px * len * curve * 0.4;
|
|
51
|
+
const cy1 = y1 + dy * 0.3 + py * len * curve * 0.4;
|
|
52
|
+
const cx2 = x1 + dx * 0.7 + px * len * curve * 0.5 + 2;
|
|
53
|
+
const cy2 = y1 + dy * 0.7 + py * len * curve * 0.5 - 1;
|
|
54
|
+
|
|
55
|
+
const path = `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
|
|
56
|
+
|
|
57
|
+
// Arrowhead — two strokes meeting at (x2,y2). Length and angle
|
|
58
|
+
// pre-computed from the (cx2 → x2) direction.
|
|
59
|
+
const ahLen = 14;
|
|
60
|
+
const angle = Math.atan2(y2 - cy2, x2 - cx2);
|
|
61
|
+
const aOpen = 0.45;
|
|
62
|
+
const ax1 = x2 - Math.cos(angle - aOpen) * ahLen;
|
|
63
|
+
const ay1 = y2 - Math.sin(angle - aOpen) * ahLen;
|
|
64
|
+
const ax2 = x2 - Math.cos(angle + aOpen) * ahLen;
|
|
65
|
+
const ay2 = y2 - Math.sin(angle + aOpen) * ahLen;
|
|
66
|
+
|
|
67
|
+
// Pen-press effect — slightly thicker stroke at start/end via
|
|
68
|
+
// overlaid short paths. Keeps SVG light, no shaders.
|
|
69
|
+
return (
|
|
70
|
+
<svg
|
|
71
|
+
className={className}
|
|
72
|
+
width={width}
|
|
73
|
+
height={height}
|
|
74
|
+
viewBox={`0 0 ${typeof width === "number" ? width : 240} ${typeof height === "number" ? height : 80}`}
|
|
75
|
+
style={{ overflow: "visible", display: "block", ...style }}
|
|
76
|
+
aria-hidden
|
|
77
|
+
>
|
|
78
|
+
<g
|
|
79
|
+
fill="none"
|
|
80
|
+
stroke={ink}
|
|
81
|
+
strokeLinecap="round"
|
|
82
|
+
strokeLinejoin="round"
|
|
83
|
+
strokeWidth={thickness}
|
|
84
|
+
>
|
|
85
|
+
<path d={path} className={animated ? "ck-sketch-drawon" : undefined} style={animated ? { ["--ck-sketch-len" as never]: len + 30 } : undefined} />
|
|
86
|
+
<path d={`M ${x2} ${y2} L ${ax1} ${ay1}`} />
|
|
87
|
+
<path d={`M ${x2} ${y2} L ${ax2} ${ay2}`} />
|
|
88
|
+
</g>
|
|
89
|
+
</svg>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// A drawn rectangle — used to wrap any content in a "sketched"
|
|
4
|
+
// container. CSS-only: asymmetric border-radius + 2px ink stroke
|
|
5
|
+
// + offset second stroke shadow. Pair with `tilt` and an optional
|
|
6
|
+
// `fill` (transparent | hatch | marker color) for emphasis.
|
|
7
|
+
//
|
|
8
|
+
// Why a primitive: this is the canonical "drawn rectangle" pattern
|
|
9
|
+
// — buttons, cards, sticky notes, tool tips all inherit from it.
|
|
10
|
+
// Wrapping it once keeps the wobble math + tilt presets DRY.
|
|
11
|
+
|
|
12
|
+
export interface RoughBoxProps {
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
// Tilt in degrees. Defaults to a small -1.
|
|
15
|
+
tilt?: number;
|
|
16
|
+
// Fill style. Defaults to "paper". "hatch" lays down diagonal
|
|
17
|
+
// marker strokes; "marker" floods a translucent marker color.
|
|
18
|
+
fill?: "paper" | "hatch" | "marker" | "transparent";
|
|
19
|
+
// Marker color when fill="hatch" or fill="marker". Accepts any
|
|
20
|
+
// CSS color, including the --ck-sketch-* vars.
|
|
21
|
+
ink?: string;
|
|
22
|
+
// Override radius. Defaults to a chrome-aware asymmetric value.
|
|
23
|
+
radius?: string;
|
|
24
|
+
// Drawing thickness, in px. Defaults to 2.
|
|
25
|
+
stroke?: number;
|
|
26
|
+
// Padding inside the box. Defaults to 16.
|
|
27
|
+
padding?: number | string;
|
|
28
|
+
// Set to true to skip the offset second-stroke shadow.
|
|
29
|
+
flat?: boolean;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function RoughBox({
|
|
35
|
+
children,
|
|
36
|
+
tilt = -1,
|
|
37
|
+
fill = "paper",
|
|
38
|
+
ink = "var(--ck-sketch-yellow, #F2C94C)",
|
|
39
|
+
radius = "10px 14px 12px 16px / 14px 10px 16px 12px",
|
|
40
|
+
stroke = 2,
|
|
41
|
+
padding = 16,
|
|
42
|
+
flat = false,
|
|
43
|
+
className,
|
|
44
|
+
style,
|
|
45
|
+
}: RoughBoxProps) {
|
|
46
|
+
const inkColor = "var(--ck-text-primary, #1A1A1A)";
|
|
47
|
+
let background: string;
|
|
48
|
+
if (fill === "hatch") {
|
|
49
|
+
background = `repeating-linear-gradient(45deg, ${ink} 0 3px, transparent 3px 9px), var(--ck-bg-surface, #FFFEF9)`;
|
|
50
|
+
} else if (fill === "marker") {
|
|
51
|
+
background = `color-mix(in oklab, ${ink} 22%, transparent), var(--ck-bg-surface, #FFFEF9)`;
|
|
52
|
+
} else if (fill === "transparent") {
|
|
53
|
+
background = "transparent";
|
|
54
|
+
} else {
|
|
55
|
+
background = "var(--ck-bg-surface, #FFFEF9)";
|
|
56
|
+
}
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className={className}
|
|
60
|
+
style={{
|
|
61
|
+
background,
|
|
62
|
+
border: `${stroke}px solid ${inkColor}`,
|
|
63
|
+
borderRadius: radius,
|
|
64
|
+
boxShadow: flat ? "none" : `3px 4px 0 ${inkColor}`,
|
|
65
|
+
padding,
|
|
66
|
+
transform: `rotate(${tilt}deg)`,
|
|
67
|
+
...style,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|