@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,68 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ // Top chrome strip. Three slots — left (crumbs / brand / back arrow),
4
+ // center (page-specific affordance like a tab switcher), right
5
+ // (actions). Height is exposed as --ck-breadcrumb-height (default
6
+ // 64 px) so the right-side panel, command palette, and other fixed-
7
+ // position UI can offset themselves correctly.
8
+
9
+ export interface TopbarProps {
10
+ // Usually <Breadcrumb /> on desktop, page title on mobile.
11
+ left?: ReactNode;
12
+ // Optional centred slot. Hidden if not supplied.
13
+ center?: ReactNode;
14
+ // Right cluster — theme toggle, profile chip, etc.
15
+ right?: ReactNode;
16
+ // Height in px. Default 64. Stamps --ck-breadcrumb-height so
17
+ // sticky elements below can align.
18
+ height?: number;
19
+ className?: string;
20
+ }
21
+
22
+ export function Topbar({
23
+ left,
24
+ center,
25
+ right,
26
+ height = 64,
27
+ className,
28
+ }: TopbarProps) {
29
+ return (
30
+ <header
31
+ data-ck-topbar
32
+ className={className}
33
+ style={
34
+ {
35
+ height,
36
+ padding: "0 24px",
37
+ display: "flex",
38
+ alignItems: "center",
39
+ gap: 16,
40
+ background: "var(--ck-bg-surface)",
41
+ borderBottom: "1px solid var(--ck-border-subtle)",
42
+ ["--ck-breadcrumb-height" as string]: `${height}px`,
43
+ } as React.CSSProperties
44
+ }
45
+ >
46
+ <div style={{ flex: "1 1 auto", minWidth: 0 }}>{left}</div>
47
+ {center && (
48
+ <div
49
+ style={{
50
+ position: "absolute",
51
+ left: "50%",
52
+ transform: "translateX(-50%)",
53
+ display: "flex",
54
+ alignItems: "center",
55
+ gap: 8,
56
+ }}
57
+ >
58
+ {center}
59
+ </div>
60
+ )}
61
+ {right && (
62
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flex: "none" }}>
63
+ {right}
64
+ </div>
65
+ )}
66
+ </header>
67
+ );
68
+ }
@@ -0,0 +1,22 @@
1
+ export { Shell } from "./Shell";
2
+ export type { ShellProps } from "./Shell";
3
+ export { Topbar } from "./Topbar";
4
+ export type { TopbarProps } from "./Topbar";
5
+ export { Breadcrumb } from "./Breadcrumb";
6
+ export type { BreadcrumbProps, CrumbItem } from "./Breadcrumb";
7
+ export { LeftNavRail } from "./LeftNavRail";
8
+ export type { LeftNavRailProps } from "./LeftNavRail";
9
+ export { useNavRailState } from "./useNavRailState";
10
+ export type { NavRailState, UseNavRailStateOpts } from "./useNavRailState";
11
+ export { MobileTabBar } from "./MobileTabBar";
12
+ export type { MobileTabBarProps, MobileTab } from "./MobileTabBar";
13
+ export { RightSidebarPanel } from "./RightSidebarPanel";
14
+ export type { RightSidebarPanelProps } from "./RightSidebarPanel";
15
+ export { StickyBanner } from "./StickyBanner";
16
+ export type { StickyBannerProps } from "./StickyBanner";
17
+ export { NavSection } from "./NavSection";
18
+ export type { NavSectionProps } from "./NavSection";
19
+ export { NavItem } from "./NavItem";
20
+ export type { NavItemProps } from "./NavItem";
21
+ export { NavSearchTrigger } from "./NavSearchTrigger";
22
+ export type { NavSearchTriggerProps } from "./NavSearchTrigger";
@@ -0,0 +1,69 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { useViewport } from "../hooks/useViewport";
3
+
4
+ // Manages the collapse state of <LeftNavRail>. Two layers:
5
+ //
6
+ // 1. User preference — persisted in localStorage under
7
+ // `storageKey`. The user toggles via a button in the rail.
8
+ // 2. Viewport rule — below `forceCollapseBelow` the rail is
9
+ // always collapsed regardless of user preference.
10
+ //
11
+ // The hook returns the EFFECTIVE collapsed state (combining the
12
+ // two) plus a setter that only mutates the user preference (the
13
+ // viewport rule continues to apply on top).
14
+
15
+ export interface UseNavRailStateOpts {
16
+ storageKey?: string;
17
+ // Viewport width below which the rail force-collapses, ignoring
18
+ // user preference. Default 1100 px.
19
+ forceCollapseBelow?: number;
20
+ // Initial preference if nothing is in localStorage. Default false
21
+ // (expanded).
22
+ defaultCollapsed?: boolean;
23
+ }
24
+
25
+ export interface NavRailState {
26
+ collapsed: boolean;
27
+ // The viewport is currently overriding the user preference.
28
+ // Toggle button should be disabled when this is true.
29
+ forcedByViewport: boolean;
30
+ toggle: () => void;
31
+ setCollapsed: (next: boolean) => void;
32
+ }
33
+
34
+ const DEFAULT_KEY = "ck-leftnav-collapsed";
35
+
36
+ export function useNavRailState({
37
+ storageKey = DEFAULT_KEY,
38
+ forceCollapseBelow = 1100,
39
+ defaultCollapsed = false,
40
+ }: UseNavRailStateOpts = {}): NavRailState {
41
+ const [userPref, setUserPref] = useState<boolean>(() => {
42
+ if (typeof window === "undefined") return defaultCollapsed;
43
+ const stored = window.localStorage.getItem(storageKey);
44
+ if (stored === "1") return true;
45
+ if (stored === "0") return false;
46
+ return defaultCollapsed;
47
+ });
48
+ const vp = useViewport({ narrow: forceCollapseBelow });
49
+ const forcedByViewport = vp.isNarrow;
50
+ const collapsed = forcedByViewport || userPref;
51
+
52
+ const setCollapsed = useCallback(
53
+ (next: boolean) => {
54
+ setUserPref(next);
55
+ },
56
+ [],
57
+ );
58
+
59
+ useEffect(() => {
60
+ window.localStorage.setItem(storageKey, userPref ? "1" : "0");
61
+ }, [userPref, storageKey]);
62
+
63
+ const toggle = useCallback(() => {
64
+ if (forcedByViewport) return; // no-op — viewport rules
65
+ setUserPref((v) => !v);
66
+ }, [forcedByViewport]);
67
+
68
+ return { collapsed, forcedByViewport, toggle, setCollapsed };
69
+ }
package/src/lib/cn.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Lightweight className merge. Filters falsy values and joins with a
2
+ // single space. Sufficient for component composition; if a consumer
3
+ // needs tailwind-merge / clsx semantics they can swap implementations
4
+ // without touching call sites.
5
+ export function cn(...inputs: Array<string | false | null | undefined>): string {
6
+ return inputs.filter(Boolean).join(" ");
7
+ }
@@ -0,0 +1,44 @@
1
+ // Time formatting helpers.
2
+
3
+ // "3m ago" / "2h ago" / "5d ago", falls back to a locale date past
4
+ // 30 days. Use for activity rows, comment timestamps, anywhere a
5
+ // short relative cue beats a full timestamp.
6
+ export function relativeTime(ts: number): string {
7
+ const diff = Date.now() - ts;
8
+ const sec = Math.round(diff / 1000);
9
+ if (sec < 60) return `${sec}s ago`;
10
+ const min = Math.round(sec / 60);
11
+ if (min < 60) return `${min}m ago`;
12
+ const hr = Math.round(min / 60);
13
+ if (hr < 24) return `${hr}h ago`;
14
+ const day = Math.round(hr / 24);
15
+ if (day < 30) return `${day}d ago`;
16
+ return new Date(ts).toLocaleDateString();
17
+ }
18
+
19
+ // Null-safe locale date formatter — accepts ms timestamps, ISO
20
+ // strings, null, undefined, or non-finite values. Empty inputs
21
+ // render as an em-dash placeholder ("—"), which most list rows
22
+ // were doing inline with a ternary before.
23
+ export function formatDate(ms: number | string | null | undefined): string {
24
+ const n = coerceMs(ms);
25
+ if (n === null) return "—";
26
+ return new Date(n).toLocaleDateString();
27
+ }
28
+
29
+ // Full date + time formatter with the same null-safe semantics.
30
+ export function formatTimestamp(
31
+ ms: number | string | null | undefined,
32
+ opts?: Intl.DateTimeFormatOptions,
33
+ ): string {
34
+ const n = coerceMs(ms);
35
+ if (n === null) return "—";
36
+ return opts ? new Date(n).toLocaleString(undefined, opts) : new Date(n).toLocaleString();
37
+ }
38
+
39
+ function coerceMs(ms: number | string | null | undefined): number | null {
40
+ if (ms == null) return null;
41
+ const n = typeof ms === "string" ? Number(ms) : ms;
42
+ if (!Number.isFinite(n) || n <= 0) return null;
43
+ return n;
44
+ }
@@ -0,0 +1,81 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ // Looping marquee strip — the signature neobrutalism section
4
+ // divider. "NEOBRUTALISM · NEOBRUTALISM · NEOBRUTALISM…" in
5
+ // chunky 800-weight type, scrolling horizontally. Decoration,
6
+ // not nav.
7
+ //
8
+ // Pure CSS animation — no rAF loops, no JS. The repeated text
9
+ // inside is doubled so when the first set scrolls past the edge,
10
+ // the second is already in position to seamlessly continue.
11
+
12
+ export interface MarqueeProps {
13
+ text: string;
14
+ // Pixels per second. Default 60 — comfortable reading speed.
15
+ speed?: number;
16
+ // Visual variant. "default" = accent background; "inverted" =
17
+ // pure black background with white text.
18
+ variant?: "default" | "inverted";
19
+ // px height. Default 56.
20
+ height?: number;
21
+ // Slight skew angle (deg). Default 0; try -2 for a punk feel.
22
+ skew?: number;
23
+ className?: string;
24
+ style?: CSSProperties;
25
+ }
26
+
27
+ export function Marquee({
28
+ text,
29
+ speed = 60,
30
+ variant = "default",
31
+ height = 56,
32
+ skew = 0,
33
+ className,
34
+ style,
35
+ }: MarqueeProps) {
36
+ // Each phrase is ~80 px wide for a typical 20-char fragment;
37
+ // 10 copies gives ~800 px content — wide enough that the loop
38
+ // is always seamless on any viewport. Real measurement happens
39
+ // visually; for now the count + duration combo just need to be
40
+ // multiples that produce a continuous loop.
41
+ const phrases = new Array(10).fill(text).join(" • ");
42
+ const duration = `${(phrases.length * 8) / speed}s`;
43
+
44
+ return (
45
+ <div
46
+ className={className}
47
+ style={{
48
+ overflow: "hidden",
49
+ height,
50
+ background:
51
+ variant === "inverted" ? "#000000" : "var(--ck-accent, #88AAEE)",
52
+ color: variant === "inverted" ? "#FFFFFF" : "#000000",
53
+ border: "2px solid var(--neobrut-ink, #000)",
54
+ borderRadius: 4,
55
+ transform: skew ? `skewY(${skew}deg)` : undefined,
56
+ position: "relative",
57
+ ...style,
58
+ }}
59
+ >
60
+ <div
61
+ style={{
62
+ position: "absolute",
63
+ inset: 0,
64
+ display: "flex",
65
+ alignItems: "center",
66
+ gap: 40,
67
+ whiteSpace: "nowrap",
68
+ font: "800 22px/1 var(--ck-font-display, var(--ck-font-sans))",
69
+ letterSpacing: "0.04em",
70
+ animation: `ck-neobrut-marquee ${duration} linear infinite`,
71
+ willChange: "transform",
72
+ }}
73
+ >
74
+ {/* Doubled for seamless loop. CSS animation translates by
75
+ -50% so the second copy moves into the first's position. */}
76
+ <span>{phrases}</span>
77
+ <span aria-hidden>{phrases}</span>
78
+ </div>
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,71 @@
1
+ import type { ReactNode, CSSProperties } from "react";
2
+
3
+ // Rotated badge — "NEW!", "FREE!", "v2.0!" style. Sits in the
4
+ // corner of featured cards or banners. Filled with an accent
5
+ // colour, thick border, hard shadow, slight tilt to read as a
6
+ // physical sticker pinned on the page.
7
+
8
+ export interface StickerProps {
9
+ children: ReactNode;
10
+ // Tilt angle in degrees. Default -8 (slight left lean —
11
+ // pleasing eye-tilt without going crazy).
12
+ rotate?: number;
13
+ // Fill tone. "accent" / "warning" / "success" / "critical"
14
+ // pick from token palette; pass a hex string to override.
15
+ tone?: "accent" | "warning" | "success" | "critical" | string;
16
+ // px diameter — sticker is square so width == height.
17
+ size?: number;
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ }
21
+
22
+ function toneToBg(tone: NonNullable<StickerProps["tone"]>) {
23
+ switch (tone) {
24
+ case "accent":
25
+ return "var(--ck-accent, #88AAEE)";
26
+ case "warning":
27
+ return "var(--ck-warning, #FFD23F)";
28
+ case "success":
29
+ return "var(--ck-success, #A3E636)";
30
+ case "critical":
31
+ return "var(--ck-critical, #FF6B6B)";
32
+ default:
33
+ return tone;
34
+ }
35
+ }
36
+
37
+ export function Sticker({
38
+ children,
39
+ rotate = -8,
40
+ tone = "warning",
41
+ size = 80,
42
+ className,
43
+ style,
44
+ }: StickerProps) {
45
+ return (
46
+ <span
47
+ className={className}
48
+ style={{
49
+ display: "inline-flex",
50
+ alignItems: "center",
51
+ justifyContent: "center",
52
+ textAlign: "center",
53
+ width: size,
54
+ height: size,
55
+ padding: 6,
56
+ background: toneToBg(tone),
57
+ color: "#000",
58
+ border: "2px solid var(--neobrut-ink, #000)",
59
+ borderRadius: "50%",
60
+ boxShadow: "3px 3px 0 0 var(--neobrut-ink, #000)",
61
+ transform: `rotate(${rotate}deg)`,
62
+ font: "800 14px/1.1 var(--ck-font-display, var(--ck-font-sans))",
63
+ letterSpacing: "0.02em",
64
+ textTransform: "uppercase",
65
+ ...style,
66
+ }}
67
+ >
68
+ {children}
69
+ </span>
70
+ );
71
+ }
@@ -0,0 +1,4 @@
1
+ export { Marquee } from "./Marquee";
2
+ export type { MarqueeProps } from "./Marquee";
3
+ export { Sticker } from "./Sticker";
4
+ export type { StickerProps } from "./Sticker";
@@ -0,0 +1,53 @@
1
+ import { cn } from "../lib/cn";
2
+
3
+ // Circular avatar — image with initials fallback. Use the `name`
4
+ // prop to derive initials (first letter of first two whitespace-
5
+ // separated tokens). Size scales the whole thing including font.
6
+
7
+ export interface AvatarProps {
8
+ // Display name — used for initials fallback and as alt text.
9
+ name: string;
10
+ // Image src. If omitted or fails to load, falls back to initials.
11
+ src?: string | null;
12
+ size?: number; // px, default 32
13
+ className?: string;
14
+ }
15
+
16
+ function initials(name: string): string {
17
+ const parts = name.trim().split(/\s+/).slice(0, 2);
18
+ return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
19
+ }
20
+
21
+ export function Avatar({ name, src, size = 32, className }: AvatarProps) {
22
+ return (
23
+ <span
24
+ className={cn("ck-avatar", className)}
25
+ style={{
26
+ display: "inline-flex",
27
+ alignItems: "center",
28
+ justifyContent: "center",
29
+ width: size,
30
+ height: size,
31
+ borderRadius: "50%",
32
+ background: "var(--ck-bg-muted)",
33
+ color: "var(--ck-text-secondary)",
34
+ font: `500 ${Math.round(size * 0.4)}px/1 var(--ck-font-sans)`,
35
+ overflow: "hidden",
36
+ flexShrink: 0,
37
+ }}
38
+ aria-label={name}
39
+ >
40
+ {src ? (
41
+ <img
42
+ src={src}
43
+ alt={name}
44
+ width={size}
45
+ height={size}
46
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
47
+ />
48
+ ) : (
49
+ initials(name)
50
+ )}
51
+ </span>
52
+ );
53
+ }
@@ -0,0 +1,30 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ // Minimal Phase-0 stub so the docs app has something concrete to
5
+ // import — proves the workspace bridge + hot-reload. Phase 3
6
+ // replaces this with the full variant + size + loading-state
7
+ // implementation backed by the .rd-btn styles.
8
+
9
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "icon";
10
+
11
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
12
+ variant?: ButtonVariant;
13
+ children?: ReactNode;
14
+ }
15
+
16
+ export function Button({
17
+ variant = "primary",
18
+ className,
19
+ children,
20
+ ...rest
21
+ }: ButtonProps) {
22
+ return (
23
+ <button
24
+ {...rest}
25
+ className={cn("ck-btn", `ck-btn--${variant}`, className)}
26
+ >
27
+ {children}
28
+ </button>
29
+ );
30
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode, CSSProperties } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export interface CardProps {
5
+ // Eyebrow + title bar at the top. Pass any nodes; common pattern
6
+ // is <span className="ck-eyebrow">...</span> + <h3>...</h3>.
7
+ header?: ReactNode;
8
+ // Right-aligned actions next to the header (e.g. action buttons).
9
+ headerActions?: ReactNode;
10
+ // Bottom strip (typically buttons or meta info).
11
+ footer?: ReactNode;
12
+ children?: ReactNode;
13
+ // Disable the default body padding (when the children manage
14
+ // their own — e.g. a list with full-bleed rows).
15
+ noPad?: boolean;
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ }
19
+
20
+ export function Card({
21
+ header,
22
+ headerActions,
23
+ footer,
24
+ children,
25
+ noPad,
26
+ className,
27
+ style,
28
+ }: CardProps) {
29
+ return (
30
+ <section className={cn("ck-card", className)} style={style}>
31
+ {(header || headerActions) && (
32
+ <header className="ck-card__head">
33
+ <div style={{ flex: 1, minWidth: 0 }}>{header}</div>
34
+ {headerActions}
35
+ </header>
36
+ )}
37
+ <div className={noPad ? undefined : "ck-card__body"}>{children}</div>
38
+ {footer && <footer className="ck-card__foot">{footer}</footer>}
39
+ </section>
40
+ );
41
+ }
@@ -0,0 +1,78 @@
1
+ import { cn } from "../lib/cn";
2
+
3
+ // Custom-styled checkbox — accent-bordered square that becomes filled
4
+ // accent + white tick when checked. The native <input type="checkbox">
5
+ // renders as a giant white card on dark backgrounds in most browsers,
6
+ // which is exactly the bug that prompted this primitive in the parent
7
+ // project. Use this anywhere you'd reach for a checkbox.
8
+
9
+ export interface CheckboxProps {
10
+ checked: boolean;
11
+ onChange: (next: boolean) => void;
12
+ label?: React.ReactNode;
13
+ suffix?: React.ReactNode;
14
+ disabled?: boolean;
15
+ className?: string;
16
+ }
17
+
18
+ export function Checkbox({
19
+ checked,
20
+ onChange,
21
+ label,
22
+ suffix,
23
+ disabled,
24
+ className,
25
+ }: CheckboxProps) {
26
+ return (
27
+ <label
28
+ className={cn("ck-checkbox", className)}
29
+ style={{
30
+ display: "inline-flex",
31
+ alignItems: "center",
32
+ gap: 8,
33
+ font: "400 13px/1 var(--ck-font-sans)",
34
+ color: "var(--ck-text-secondary)",
35
+ cursor: disabled ? "not-allowed" : "pointer",
36
+ opacity: disabled ? 0.5 : 1,
37
+ userSelect: "none",
38
+ }}
39
+ >
40
+ <button
41
+ type="button"
42
+ role="checkbox"
43
+ aria-checked={checked}
44
+ disabled={disabled}
45
+ onClick={() => !disabled && onChange(!checked)}
46
+ style={{
47
+ width: 16,
48
+ height: 16,
49
+ borderRadius: 4,
50
+ border: "1.5px solid var(--ck-accent)",
51
+ background: checked ? "var(--ck-accent)" : "transparent",
52
+ display: "inline-flex",
53
+ alignItems: "center",
54
+ justifyContent: "center",
55
+ padding: 0,
56
+ cursor: disabled ? "not-allowed" : "pointer",
57
+ flexShrink: 0,
58
+ }}
59
+ >
60
+ {checked && (
61
+ <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
62
+ <path
63
+ d="M2.5 6.5L5 9L9.5 3.5"
64
+ stroke="#FFFFFF"
65
+ strokeWidth="2"
66
+ strokeLinecap="round"
67
+ strokeLinejoin="round"
68
+ />
69
+ </svg>
70
+ )}
71
+ </button>
72
+ {label}
73
+ {suffix && (
74
+ <span style={{ color: "var(--ck-text-tertiary)" }}>{suffix}</span>
75
+ )}
76
+ </label>
77
+ );
78
+ }
@@ -0,0 +1,50 @@
1
+ import { cn } from "../lib/cn";
2
+
3
+ // Small filled pill showing an integer count. Caps at 99+ to keep
4
+ // the visual mass tight. Returns null at 0 — call sites can
5
+ // unconditionally render it inside a label without an `if (n > 0)`.
6
+
7
+ export interface CountBadgeProps {
8
+ count: number;
9
+ size?: "sm" | "md";
10
+ // Tone — default accent. Use "critical" for notifications.
11
+ tone?: "accent" | "critical" | "neutral";
12
+ className?: string;
13
+ }
14
+
15
+ const TONE_BG: Record<NonNullable<CountBadgeProps["tone"]>, string> = {
16
+ accent: "var(--ck-accent)",
17
+ critical: "var(--ck-critical)",
18
+ neutral: "var(--ck-text-tertiary)",
19
+ };
20
+
21
+ export function CountBadge({
22
+ count,
23
+ size = "md",
24
+ tone = "accent",
25
+ className,
26
+ }: CountBadgeProps) {
27
+ if (count <= 0) return null;
28
+ const display = count > 99 ? "99+" : String(count);
29
+ const isSm = size === "sm";
30
+ return (
31
+ <span
32
+ className={cn("ck-count-badge", className)}
33
+ style={{
34
+ display: "inline-flex",
35
+ alignItems: "center",
36
+ justifyContent: "center",
37
+ minWidth: isSm ? 14 : 18,
38
+ height: isSm ? 14 : 18,
39
+ padding: "0 5px",
40
+ background: TONE_BG[tone],
41
+ color: "var(--ck-text-inverse)",
42
+ font: `500 ${isSm ? 10 : 11}px/1 var(--ck-font-mono)`,
43
+ borderRadius: 999,
44
+ fontVariantNumeric: "tabular-nums",
45
+ }}
46
+ >
47
+ {display}
48
+ </span>
49
+ );
50
+ }