registry.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import { defineRegistry, useBoundProp } from "@json-render/react";
  2. import { catalog } from "./catalog";
  3. // ---------------------------------------------------------------------------
  4. // Shared tokens
  5. // ---------------------------------------------------------------------------
  6. const gapMap = { xs: 4, sm: 8, md: 12, lg: 20 } as const;
  7. const cardToneBg = {
  8. default: "var(--surface-raised)",
  9. accent: "color-mix(in oklab, var(--accent) 8%, var(--surface-raised))",
  10. success: "color-mix(in oklab, var(--success) 8%, var(--surface-raised))",
  11. warn: "color-mix(in oklab, var(--warn) 10%, var(--surface-raised))",
  12. muted: "var(--surface-muted)",
  13. } as const;
  14. const cardToneBorder = {
  15. default: "var(--border)",
  16. accent: "color-mix(in oklab, var(--accent) 45%, var(--border))",
  17. success: "color-mix(in oklab, var(--success) 45%, var(--border))",
  18. warn: "color-mix(in oklab, var(--warn) 45%, var(--border))",
  19. muted: "var(--border)",
  20. } as const;
  21. const badgeTones = {
  22. default: { bg: "var(--surface-muted)", fg: "var(--text)" },
  23. accent: {
  24. bg: "color-mix(in oklab, var(--accent) 15%, transparent)",
  25. fg: "var(--accent)",
  26. },
  27. success: {
  28. bg: "color-mix(in oklab, var(--success) 15%, transparent)",
  29. fg: "var(--success)",
  30. },
  31. warn: {
  32. bg: "color-mix(in oklab, var(--warn) 15%, transparent)",
  33. fg: "var(--warn)",
  34. },
  35. muted: { bg: "var(--surface-muted)", fg: "var(--text-muted)" },
  36. } as const;
  37. // ---------------------------------------------------------------------------
  38. // Registry
  39. // ---------------------------------------------------------------------------
  40. export const { registry } = defineRegistry(catalog, {
  41. components: {
  42. Card: ({ props, children }) => {
  43. const tone = props.tone ?? "default";
  44. return (
  45. <div
  46. style={{
  47. padding: 16,
  48. borderRadius: 12,
  49. border: `1px solid ${cardToneBorder[tone]}`,
  50. background: cardToneBg[tone],
  51. display: "flex",
  52. flexDirection: "column",
  53. gap: 12,
  54. }}
  55. >
  56. {(props.title || props.subtitle) && (
  57. <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
  58. {props.title && (
  59. <div style={{ fontWeight: 600, fontSize: 14 }}>
  60. {props.title}
  61. </div>
  62. )}
  63. {props.subtitle && (
  64. <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
  65. {props.subtitle}
  66. </div>
  67. )}
  68. </div>
  69. )}
  70. {children}
  71. </div>
  72. );
  73. },
  74. Heading: ({ props }) => {
  75. const level = props.level ?? "2";
  76. const sizes = { "1": 22, "2": 16, "3": 13 } as const;
  77. const Tag = `h${level}` as unknown as "h1";
  78. return (
  79. <Tag
  80. style={{
  81. margin: 0,
  82. fontWeight: 600,
  83. fontSize: sizes[level],
  84. lineHeight: 1.3,
  85. }}
  86. >
  87. {props.text}
  88. </Tag>
  89. );
  90. },
  91. Text: ({ props }) => {
  92. const weight =
  93. props.weight === "bold" ? 700 : props.weight === "medium" ? 500 : 400;
  94. return (
  95. <p
  96. style={{
  97. margin: 0,
  98. fontSize: 13,
  99. lineHeight: 1.5,
  100. fontWeight: weight,
  101. color: props.muted ? "var(--text-muted)" : "var(--text)",
  102. }}
  103. >
  104. {props.text}
  105. </p>
  106. );
  107. },
  108. Stack: ({ props, children }) => (
  109. <div
  110. style={{
  111. display: "flex",
  112. flexDirection: "column",
  113. gap: gapMap[props.gap ?? "sm"],
  114. }}
  115. >
  116. {children}
  117. </div>
  118. ),
  119. Row: ({ props, children }) => {
  120. const alignMap = {
  121. start: "flex-start",
  122. center: "center",
  123. end: "flex-end",
  124. between: "space-between",
  125. } as const;
  126. return (
  127. <div
  128. style={{
  129. display: "flex",
  130. flexDirection: "row",
  131. gap: gapMap[props.gap ?? "sm"],
  132. alignItems: "center",
  133. justifyContent: alignMap[props.align ?? "start"],
  134. flexWrap: "wrap",
  135. }}
  136. >
  137. {children}
  138. </div>
  139. );
  140. },
  141. Grid: ({ props, children }) => (
  142. <div
  143. style={{
  144. display: "grid",
  145. gridTemplateColumns: `repeat(${props.columns ?? "2"}, minmax(0, 1fr))`,
  146. gap: gapMap[props.gap ?? "md"],
  147. }}
  148. >
  149. {children}
  150. </div>
  151. ),
  152. Metric: ({ props }) => {
  153. const trendColor =
  154. props.trend === "up"
  155. ? "var(--success)"
  156. : props.trend === "down"
  157. ? "var(--warn)"
  158. : "var(--text-muted)";
  159. const trendGlyph =
  160. props.trend === "up" ? "↑" : props.trend === "down" ? "↓" : "→";
  161. return (
  162. <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
  163. <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
  164. {props.label}
  165. </div>
  166. <div style={{ fontSize: 24, fontWeight: 600, lineHeight: 1.1 }}>
  167. {props.value}
  168. </div>
  169. {props.delta && (
  170. <div style={{ fontSize: 11, color: trendColor }}>
  171. {trendGlyph} {props.delta}
  172. </div>
  173. )}
  174. </div>
  175. );
  176. },
  177. Badge: ({ props }) => {
  178. const tone = badgeTones[props.tone ?? "default"];
  179. return (
  180. <span
  181. style={{
  182. display: "inline-flex",
  183. alignItems: "center",
  184. padding: "2px 8px",
  185. borderRadius: 999,
  186. fontSize: 11,
  187. fontWeight: 500,
  188. background: tone.bg,
  189. color: tone.fg,
  190. }}
  191. >
  192. {props.label}
  193. </span>
  194. );
  195. },
  196. Divider: () => (
  197. <hr
  198. style={{
  199. border: 0,
  200. borderTop: "1px solid var(--border)",
  201. margin: 0,
  202. }}
  203. />
  204. ),
  205. Button: ({ props, emit }) => {
  206. const variant = props.variant ?? "primary";
  207. const size = props.size ?? "md";
  208. const base: React.CSSProperties = {
  209. padding: size === "sm" ? "4px 10px" : "6px 12px",
  210. borderRadius: 8,
  211. fontSize: size === "sm" ? 12 : 13,
  212. fontWeight: 500,
  213. cursor: props.disabled ? "not-allowed" : "pointer",
  214. border: "1px solid transparent",
  215. transition: "background 0.15s",
  216. opacity: props.disabled ? 0.5 : 1,
  217. };
  218. const variants: Record<string, React.CSSProperties> = {
  219. primary: {
  220. background: "var(--accent)",
  221. color: "var(--accent-fg)",
  222. },
  223. secondary: {
  224. background: "var(--surface-muted)",
  225. color: "var(--text)",
  226. borderColor: "var(--border)",
  227. },
  228. ghost: {
  229. background: "transparent",
  230. color: "var(--text)",
  231. },
  232. };
  233. return (
  234. <button
  235. type="button"
  236. disabled={props.disabled ?? false}
  237. onClick={() => emit("press")}
  238. style={{ ...base, ...variants[variant] }}
  239. >
  240. {props.label}
  241. </button>
  242. );
  243. },
  244. TextInput: ({ props, bindings }) => {
  245. const [value, setValue] = useBoundProp<string>(
  246. (props.value ?? "") as string,
  247. bindings?.value,
  248. );
  249. return (
  250. <input
  251. type="text"
  252. value={value ?? ""}
  253. placeholder={props.placeholder ?? ""}
  254. onChange={(e) => setValue(e.target.value)}
  255. style={{
  256. padding: "6px 10px",
  257. borderRadius: 8,
  258. border: "1px solid var(--border)",
  259. background: "var(--surface)",
  260. color: "var(--text)",
  261. fontSize: 13,
  262. outline: "none",
  263. width: "100%",
  264. }}
  265. />
  266. );
  267. },
  268. Checkbox: ({ props, bindings }) => {
  269. const [checked, setChecked] = useBoundProp<boolean>(
  270. props.checked ?? false,
  271. bindings?.checked,
  272. );
  273. return (
  274. <label
  275. style={{
  276. display: "inline-flex",
  277. alignItems: "center",
  278. gap: 8,
  279. fontSize: 13,
  280. cursor: "pointer",
  281. }}
  282. >
  283. <input
  284. type="checkbox"
  285. checked={checked ?? false}
  286. onChange={(e) => setChecked(e.target.checked)}
  287. style={{ accentColor: "var(--accent)" }}
  288. />
  289. {props.label && <span>{props.label}</span>}
  290. </label>
  291. );
  292. },
  293. ProgressBar: ({ props }) => {
  294. const max = props.max ?? 100;
  295. const value = Math.max(0, Math.min(max, props.value));
  296. const pct = (value / max) * 100;
  297. const color =
  298. props.tone === "success"
  299. ? "var(--success)"
  300. : props.tone === "warn"
  301. ? "var(--warn)"
  302. : props.tone === "accent"
  303. ? "var(--accent)"
  304. : "var(--text-muted)";
  305. return (
  306. <div
  307. style={{
  308. width: "100%",
  309. height: 6,
  310. background: "var(--surface-muted)",
  311. borderRadius: 999,
  312. overflow: "hidden",
  313. }}
  314. >
  315. <div
  316. style={{
  317. width: `${pct}%`,
  318. height: "100%",
  319. background: color,
  320. transition: "width 0.3s",
  321. }}
  322. />
  323. </div>
  324. );
  325. },
  326. Callout: ({ props }) => {
  327. const tone = props.tone ?? "info";
  328. const toneBg = {
  329. info: "color-mix(in oklab, var(--accent) 10%, transparent)",
  330. success: "color-mix(in oklab, var(--success) 10%, transparent)",
  331. warn: "color-mix(in oklab, var(--warn) 12%, transparent)",
  332. tip: "color-mix(in oklab, var(--accent) 8%, transparent)",
  333. } as const;
  334. const toneBorder = {
  335. info: "color-mix(in oklab, var(--accent) 30%, var(--border))",
  336. success: "color-mix(in oklab, var(--success) 30%, var(--border))",
  337. warn: "color-mix(in oklab, var(--warn) 35%, var(--border))",
  338. tip: "color-mix(in oklab, var(--accent) 25%, var(--border))",
  339. } as const;
  340. return (
  341. <div
  342. style={{
  343. padding: 12,
  344. borderRadius: 10,
  345. border: `1px solid ${toneBorder[tone]}`,
  346. background: toneBg[tone],
  347. fontSize: 13,
  348. lineHeight: 1.5,
  349. }}
  350. >
  351. {props.title && (
  352. <div style={{ fontWeight: 600, marginBottom: 2 }}>
  353. {props.title}
  354. </div>
  355. )}
  356. <div>{props.text}</div>
  357. </div>
  358. );
  359. },
  360. // List is an unstyled flex-column container on purpose: when repeated
  361. // children are interactive (Row with Checkbox + Button), a framed
  362. // container looks like "a card in a card". ListItem below paints its
  363. // own subtle top border between rows to keep a clear separator.
  364. List: ({ children }) => (
  365. <div
  366. style={{
  367. display: "flex",
  368. flexDirection: "column",
  369. }}
  370. >
  371. {children}
  372. </div>
  373. ),
  374. ListItem: ({ props }) => (
  375. <div
  376. className="jr-list-item"
  377. style={{
  378. padding: "8px 0",
  379. display: "flex",
  380. alignItems: "center",
  381. justifyContent: "space-between",
  382. gap: 12,
  383. }}
  384. >
  385. <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
  386. <div style={{ fontSize: 13, fontWeight: 500 }}>{props.title}</div>
  387. {props.description && (
  388. <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
  389. {props.description}
  390. </div>
  391. )}
  392. </div>
  393. {props.meta && (
  394. <div
  395. style={{
  396. fontSize: 12,
  397. color: "var(--text-muted)",
  398. whiteSpace: "nowrap",
  399. }}
  400. >
  401. {props.meta}
  402. </div>
  403. )}
  404. </div>
  405. ),
  406. Avatar: ({ props }) => {
  407. const tones = {
  408. default: { bg: "var(--surface-muted)", fg: "var(--text)" },
  409. accent: {
  410. bg: "color-mix(in oklab, var(--accent) 20%, transparent)",
  411. fg: "var(--accent)",
  412. },
  413. success: {
  414. bg: "color-mix(in oklab, var(--success) 20%, transparent)",
  415. fg: "var(--success)",
  416. },
  417. warn: {
  418. bg: "color-mix(in oklab, var(--warn) 20%, transparent)",
  419. fg: "var(--warn)",
  420. },
  421. } as const;
  422. const t = tones[props.tone ?? "default"];
  423. return (
  424. <span
  425. style={{
  426. display: "inline-flex",
  427. alignItems: "center",
  428. justifyContent: "center",
  429. width: 32,
  430. height: 32,
  431. borderRadius: 999,
  432. background: t.bg,
  433. color: t.fg,
  434. fontSize: 12,
  435. fontWeight: 600,
  436. }}
  437. >
  438. {props.initials.slice(0, 2).toUpperCase()}
  439. </span>
  440. );
  441. },
  442. },
  443. });