tsquare 0.1.0 → 0.2.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.
@@ -1,1057 +0,0 @@
1
- import React, { Children, cloneElement, isValidElement, type CSSProperties, type ReactNode } from "react";
2
- import { paletteFor, TONES, type Palette } from "./colors";
3
- import { iconNode } from "./icons";
4
- import type { ComponentRenderProps } from "@json-render/image";
5
- import {
6
- BOARD_GAP,
7
- BOARD_PADDING,
8
- LABEL_H,
9
- NOTE_WIDTH,
10
- TITLE_H,
11
- screenSize,
12
- theme as t,
13
- } from "./layout";
14
-
15
- /**
16
- * Satori renders a subset of CSS. Rules followed here:
17
- * - every element that has more than one child sets display:flex
18
- * - absolute positioning is relative to the direct parent (Yoga), so overlays
19
- * are lifted out and placed by Screen rather than nested in the body
20
- */
21
-
22
- type Dir = "row" | "column";
23
- type Props<P = Record<string, any>> = ComponentRenderProps<P> & { dir?: Dir; stretch?: boolean; aligned?: boolean; colors?: Palette };
24
-
25
- /** The board's colors, passed to every component by `withPalette`. Grayscale when there's no accent. */
26
- const GRAY = paletteFor();
27
-
28
- /** A registry whose components all receive this board's palette as `colors`. */
29
- export function withPalette(colors: Palette): typeof registry {
30
- return Object.fromEntries(
31
- Object.entries(registry).map(([name, C]) => [name, (props: Props) => (C as any)({ ...props, colors })]),
32
- ) as typeof registry;
33
- }
34
-
35
- const flexAlign = { start: "flex-start", center: "center", end: "flex-end", stretch: "stretch" } as const;
36
- const flexJustify = {
37
- start: "flex-start",
38
- center: "center",
39
- end: "flex-end",
40
- between: "space-between",
41
- around: "space-around",
42
- } as const;
43
-
44
- function Box({ style, children }: { style?: CSSProperties; children?: ReactNode }) {
45
- // Satori chokes on undefined style values, so drop them.
46
- const clean: Record<string, unknown> = { display: "flex" };
47
- for (const [k, v] of Object.entries(style ?? {})) if (v !== undefined && v !== null) clean[k] = v;
48
- return <div style={clean as CSSProperties}>{children}</div>;
49
- }
50
-
51
- /** Tell each child which way its parent lays out, so leaves can avoid stretching. */
52
- /** `aligned`: the parent column sets align=start/center/end, so leaves follow it instead of hugging the left edge. */
53
- function withDir(children: ReactNode, dir: Dir, stretch = false, aligned = false) {
54
- return Children.toArray(children).map((c) => (isValidElement(c) ? cloneElement(c as any, { dir, stretch, aligned }) : c));
55
- }
56
-
57
- /**
58
- * Containers in a row fill its height (sidebars, side-by-side cards) unless the
59
- * row sets `align` explicitly. Leaves keep the row's centering.
60
- */
61
- function fillRow(inRow?: boolean, edge = true): CSSProperties {
62
- if (!inRow) return {};
63
- return { alignSelf: edge ? "stretch" : "flex-start" };
64
- }
65
-
66
- /**
67
- * `grow` fills the parent's leftover space. In a row, growing siblings split it
68
- * equally whatever their content (two growing cards → equal columns); with the
69
- * default flex-basis the one with more content would get more.
70
- */
71
- function grow(on: unknown, dir?: Dir): CSSProperties {
72
- if (!on) return { flexGrow: 0 };
73
- return dir === "row" ? { flexGrow: 1, flexBasis: "0px" } : { flexGrow: 1 };
74
- }
75
-
76
- /** Leaves like buttons shouldn't stretch across a column, unless the column sets its own alignment. */
77
- function hug(dir?: Dir, aligned = false): CSSProperties {
78
- return dir === "row" || aligned ? {} : { alignSelf: "flex-start" };
79
- }
80
-
81
- // ── Icons ───────────────────────────────────────────────────────────────
82
-
83
- export function IconGlyph({ name, size = 20, color = t.ink, strokeWidth = 2 }: {
84
- name?: string | null;
85
- size?: number;
86
- color?: string;
87
- strokeWidth?: number;
88
- }) {
89
- const node = name ? iconNode(name) : null;
90
- return (
91
- <svg
92
- width={size}
93
- height={size}
94
- viewBox="0 0 24 24"
95
- fill="none"
96
- stroke={color}
97
- strokeWidth={strokeWidth}
98
- strokeLinecap="round"
99
- strokeLinejoin="round"
100
- style={{ flexShrink: 0 }}
101
- >
102
- {node
103
- ? node.map(([tag, attrs]: [string, Record<string, string>], i: number) =>
104
- React.createElement(tag, { key: i, ...attrs }),
105
- )
106
- : <circle cx="12" cy="12" r="9" />}
107
- </svg>
108
- );
109
- }
110
-
111
- // ── Canvas ──────────────────────────────────────────────────────────────
112
-
113
- function Board({ element, children }: Props) {
114
- const p = element.props;
115
- const gap = p.gap ?? BOARD_GAP;
116
- const items = Children.toArray(children);
117
- const perRow = p.layout === "grid" ? Math.max(1, p.columns ?? 3) : Math.max(1, items.length);
118
- const rows: ReactNode[][] = [];
119
- for (let i = 0; i < items.length; i += perRow) rows.push(items.slice(i, i + perRow));
120
-
121
- return (
122
- <Box
123
- style={{
124
- flexDirection: "column",
125
- width: "100%",
126
- height: "100%",
127
- padding: p.padding ?? BOARD_PADDING,
128
- backgroundColor: t.board,
129
- fontFamily: t.font,
130
- color: t.ink,
131
- }}
132
- >
133
- {p.title ? (
134
- <Box style={{ height: TITLE_H, fontSize: 28, fontWeight: 600, color: t.ink }}>{p.title}</Box>
135
- ) : null}
136
- <Box style={{ flexDirection: "column", gap }}>
137
- {rows.map((row, i) => (
138
- <Box key={i} style={{ flexDirection: "row", alignItems: "flex-start", gap }}>
139
- {row}
140
- </Box>
141
- ))}
142
- </Box>
143
- </Box>
144
- );
145
- }
146
-
147
- function StatusBar() {
148
- return (
149
- <Box
150
- style={{
151
- height: 34,
152
- flexShrink: 0,
153
- padding: "0 26px",
154
- alignItems: "center",
155
- justifyContent: "space-between",
156
- fontSize: 14,
157
- fontWeight: 600,
158
- }}
159
- >
160
- <Box>9:41</Box>
161
- <Box style={{ gap: 5, alignItems: "center" }}>
162
- <IconGlyph name="signal" size={15} strokeWidth={2.4} />
163
- <IconGlyph name="wifi" size={15} strokeWidth={2.4} />
164
- <IconGlyph name="battery-full" size={17} strokeWidth={2} />
165
- </Box>
166
- </Box>
167
- );
168
- }
169
-
170
- function BrowserBar() {
171
- return (
172
- <Box
173
- style={{
174
- height: 40,
175
- flexShrink: 0,
176
- padding: "0 14px",
177
- gap: 16,
178
- alignItems: "center",
179
- backgroundColor: t.fill,
180
- borderBottom: `1px solid ${t.line}`,
181
- }}
182
- >
183
- <Box style={{ gap: 7 }}>
184
- {[0, 1, 2].map((i) => (
185
- <Box key={i} style={{ width: 11, height: 11, borderRadius: 6, backgroundColor: t.fill2, border: `1px solid ${t.lineStrong}` }} />
186
- ))}
187
- </Box>
188
- <Box style={{ flexGrow: 1, justifyContent: "center" }}>
189
- <Box style={{ width: 420, height: 24, borderRadius: 12, backgroundColor: t.paper, border: `1px solid ${t.line}` }} />
190
- </Box>
191
- <Box style={{ width: 57 }} />
192
- </Box>
193
- );
194
- }
195
-
196
- function Screen({ element, children }: Props) {
197
- const p = element.props;
198
- const s = screenSize(p);
199
- const top: ReactNode[] = [];
200
- const body: ReactNode[] = [];
201
- const bottom: ReactNode[] = [];
202
- const overlays: ReactNode[] = [];
203
- for (const c of Children.toArray(children)) {
204
- const type = isValidElement(c) ? (c.props as any).element?.type : null;
205
- if (type === "NavBar") top.push(c);
206
- else if (type === "TabBar") bottom.push(c);
207
- else if (type === "Modal" || type === "Drawer") overlays.push(c);
208
- else body.push(c);
209
- }
210
-
211
- return (
212
- <Box style={{ flexDirection: "column", flexShrink: 0 }}>
213
- <Box style={{ height: LABEL_H, fontSize: 16, fontWeight: 600, color: t.text }}>{p.name ?? ""}</Box>
214
- <Box
215
- style={{
216
- width: s.width,
217
- height: s.height,
218
- flexDirection: "column",
219
- backgroundColor: t.paper,
220
- border: `1.5px solid ${t.lineStrong}`,
221
- borderRadius: s.radius,
222
- overflow: "hidden",
223
- boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
224
- color: t.ink,
225
- fontSize: 15,
226
- }}
227
- >
228
- {s.chrome && s.device === "phone" ? <StatusBar /> : null}
229
- {s.chrome && s.device === "desktop" ? <BrowserBar /> : null}
230
- <Box style={{ position: "relative", flexGrow: 1, flexDirection: "column", overflow: "hidden" }}>
231
- {top}
232
- <Box
233
- style={{
234
- flexGrow: 1,
235
- flexDirection: "column",
236
- padding: s.padding,
237
- gap: p.gap ?? 14,
238
- overflow: "hidden",
239
- }}
240
- >
241
- {withDir(body, "column")}
242
- </Box>
243
- {bottom}
244
- {overlays}
245
- </Box>
246
- </Box>
247
- </Box>
248
- );
249
- }
250
-
251
- function Note({ element, dir, aligned }: Props) {
252
- const p = element.props;
253
- const onBoard = dir === undefined; // Board children don't get a dir
254
- return (
255
- <Box
256
- style={{
257
- width: p.width ?? (onBoard ? NOTE_WIDTH : undefined),
258
- marginTop: onBoard ? LABEL_H : 0,
259
- flexShrink: 0,
260
- padding: 14,
261
- backgroundColor: t.notes[(p.color ?? "yellow") as keyof typeof t.notes] ?? t.notes.yellow,
262
- boxShadow: "0 2px 6px rgba(0,0,0,0.12)",
263
- fontSize: 14,
264
- lineHeight: 1.5,
265
- color: t.ink,
266
- ...(onBoard ? {} : hug(dir, aligned)),
267
- }}
268
- >
269
- {p.text}
270
- </Box>
271
- );
272
- }
273
-
274
- // ── Layout ──────────────────────────────────────────────────────────────
275
-
276
- const borderSide = (side?: string | null): CSSProperties => {
277
- const b = `1px solid ${t.line}`;
278
- switch (side) {
279
- case "all": return { border: b };
280
- case "left": return { borderLeft: b };
281
- case "right": return { borderRight: b };
282
- case "top": return { borderTop: b };
283
- case "bottom": return { borderBottom: b };
284
- default: return {};
285
- }
286
- };
287
-
288
- function Stack({ element, children, dir: parentDir, stretch }: Props) {
289
- const p = element.props;
290
- const dir: Dir = p.direction ?? "column";
291
- return (
292
- <Box
293
- style={{
294
- flexDirection: dir,
295
- gap: p.gap ?? (dir === "row" ? 12 : 12),
296
- padding: p.padding ?? 0,
297
- alignItems: flexAlign[(p.align ?? (dir === "row" ? "center" : "stretch")) as keyof typeof flexAlign],
298
- justifyContent: flexJustify[(p.justify ?? "start") as keyof typeof flexJustify],
299
- flexWrap: p.wrap ? "wrap" : "nowrap",
300
- ...grow(p.grow, parentDir),
301
- flexShrink: p.width ? 0 : 1,
302
- width: p.width ?? undefined,
303
- minHeight: 0,
304
- backgroundColor: p.fill ? t.fill : undefined,
305
- ...borderSide(p.border),
306
- // stacks with a visible edge (sidebars, panels) fill the row's height; others sit at the top
307
- ...fillRow(stretch, p.width != null || !!p.fill || (!!p.border && p.border !== "none")),
308
- }}
309
- >
310
- {withDir(children, dir, dir === "row" && p.align == null, dir === "column" && p.align != null && p.align !== "stretch")}
311
- </Box>
312
- );
313
- }
314
-
315
- function Grid({ element, children }: Props) {
316
- const p = element.props;
317
- const gap = p.gap ?? 16;
318
- const cols = Math.max(1, p.columns ?? 2);
319
- return (
320
- <Box style={{ padding: p.padding ?? 0, flexDirection: "column" }}>
321
- <Box style={{ flexDirection: "row", flexWrap: "wrap", margin: -gap / 2 }}>
322
- {Children.toArray(children).map((c, i) => (
323
- <Box key={i} style={{ width: `${100 / cols}%`, padding: gap / 2, flexDirection: "column" }}>
324
- {withDir(c, "column")}
325
- </Box>
326
- ))}
327
- </Box>
328
- </Box>
329
- );
330
- }
331
-
332
- function Card({ element, children, dir, stretch }: Props) {
333
- const p = element.props;
334
- const filled = p.variant === "filled";
335
- return (
336
- <Box
337
- style={{
338
- flexDirection: "column",
339
- gap: p.gap ?? 10,
340
- padding: p.padding ?? 16,
341
- borderRadius: 10,
342
- border: filled ? "none" : `1.5px solid ${t.line}`,
343
- backgroundColor: filled ? t.fill : t.paper,
344
- ...grow(p.grow, dir),
345
- width: p.width ?? undefined,
346
- flexShrink: p.width ? 0 : 1,
347
- ...fillRow(stretch),
348
- }}
349
- >
350
- {p.title ? <Box style={{ fontSize: 15, fontWeight: 600 }}>{p.title}</Box> : null}
351
- {withDir(children, "column")}
352
- </Box>
353
- );
354
- }
355
-
356
- function Divider({ element, dir }: Props) {
357
- const vertical = element.props.vertical ?? dir === "row";
358
- return vertical ? (
359
- <Box style={{ width: 1, alignSelf: "stretch", backgroundColor: t.line, flexShrink: 0 }} />
360
- ) : (
361
- <Box style={{ height: 1, alignSelf: "stretch", backgroundColor: t.line, flexShrink: 0 }} />
362
- );
363
- }
364
-
365
- function Spacer({ element }: Props) {
366
- const size = element.props.size;
367
- return size != null ? (
368
- <Box style={{ flexBasis: `${size}px`, flexGrow: 0, flexShrink: 0 }} />
369
- ) : (
370
- <Box style={{ flexGrow: 1 }} />
371
- );
372
- }
373
-
374
- // ── Content ─────────────────────────────────────────────────────────────
375
-
376
- function Heading({ element }: Props) {
377
- const p = element.props;
378
- const size = { 1: 26, 2: 20, 3: 16 }[(p.level ?? 1) as 1 | 2 | 3] ?? 26;
379
- return (
380
- <Box
381
- style={{
382
- fontSize: size,
383
- fontWeight: 600,
384
- lineHeight: 1.25,
385
- color: t.ink,
386
- justifyContent: p.align === "center" ? "center" : p.align === "right" ? "flex-end" : "flex-start",
387
- textAlign: p.align ?? "left",
388
- }}
389
- >
390
- {p.text}
391
- </Box>
392
- );
393
- }
394
-
395
- const LINE_WIDTHS = [100, 94, 97, 89, 95, 91];
396
-
397
- function PlaceholderLines({ count, height = 8, gap = 9, color = t.fill2, last = 62 }: {
398
- count: number;
399
- height?: number;
400
- gap?: number;
401
- color?: string;
402
- last?: number;
403
- }) {
404
- return (
405
- <Box style={{ flexDirection: "column", gap, alignSelf: "stretch" }}>
406
- {Array.from({ length: count }, (_, i) => (
407
- <Box
408
- key={i}
409
- style={{
410
- height,
411
- borderRadius: height / 2,
412
- backgroundColor: color,
413
- width: `${i === count - 1 && count > 1 ? last : LINE_WIDTHS[i % LINE_WIDTHS.length]}%`,
414
- }}
415
- />
416
- ))}
417
- </Box>
418
- );
419
- }
420
-
421
- function Text({ element }: Props) {
422
- const p = element.props;
423
- const size = { sm: 13, md: 15, lg: 18 }[(p.size ?? "md") as "sm" | "md" | "lg"] ?? 15;
424
- if (p.text == null) {
425
- return <PlaceholderLines count={p.lines ?? 2} height={Math.round(size * 0.55)} gap={Math.round(size * 0.6)} />;
426
- }
427
- return (
428
- <Box
429
- style={{
430
- fontSize: size,
431
- lineHeight: 1.45,
432
- color: p.muted ? t.muted : t.text,
433
- fontWeight: p.bold ? 600 : 400,
434
- justifyContent: p.align === "center" ? "center" : p.align === "right" ? "flex-end" : "flex-start",
435
- textAlign: p.align ?? "left",
436
- }}
437
- >
438
- {p.text}
439
- </Box>
440
- );
441
- }
442
-
443
- function Image({ element, dir }: Props) {
444
- const p = element.props;
445
- const h = p.height ?? 160;
446
- const w = p.width ?? (dir === "row" ? h : "100%");
447
- return (
448
- <Box
449
- style={{
450
- position: "relative",
451
- width: w,
452
- height: h,
453
- flexShrink: 0,
454
- backgroundColor: t.fill,
455
- border: `1.5px solid ${t.line}`,
456
- borderRadius: p.rounded ? 12 : 4,
457
- overflow: "hidden",
458
- alignItems: "center",
459
- justifyContent: "center",
460
- }}
461
- >
462
- <svg
463
- width="100%"
464
- height="100%"
465
- viewBox="0 0 100 100"
466
- preserveAspectRatio="none"
467
- style={{ position: "absolute", top: 0, left: 0 }}
468
- >
469
- <line x1="0" y1="0" x2="100" y2="100" stroke={t.line} strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
470
- <line x1="100" y1="0" x2="0" y2="100" stroke={t.line} strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
471
- </svg>
472
- {p.label ? (
473
- <Box
474
- style={{
475
- padding: "3px 8px",
476
- fontSize: 12,
477
- color: t.muted,
478
- backgroundColor: t.fill,
479
- borderRadius: 4,
480
- }}
481
- >
482
- {p.label}
483
- </Box>
484
- ) : null}
485
- </Box>
486
- );
487
- }
488
-
489
- function Icon({ element, dir, aligned }: Props) {
490
- const p = element.props;
491
- return (
492
- <Box style={{ flexShrink: 0, ...hug(dir, aligned) }}>
493
- <IconGlyph name={p.name} size={p.size ?? 20} />
494
- </Box>
495
- );
496
- }
497
-
498
- function AvatarCircle({ initials, size = 40 }: { initials?: string | null; size?: number }) {
499
- return (
500
- <Box
501
- style={{
502
- width: size,
503
- height: size,
504
- borderRadius: size / 2,
505
- backgroundColor: t.fill2,
506
- alignItems: "center",
507
- justifyContent: "center",
508
- flexShrink: 0,
509
- fontSize: Math.round(size * 0.38),
510
- fontWeight: 600,
511
- color: t.text,
512
- overflow: "hidden",
513
- }}
514
- >
515
- {initials ? initials.slice(0, 2).toUpperCase() : <IconGlyph name="user" size={Math.round(size * 0.55)} color={t.muted} />}
516
- </Box>
517
- );
518
- }
519
-
520
- function Avatar({ element, dir, aligned }: Props) {
521
- return (
522
- <Box style={hug(dir, aligned)}>
523
- <AvatarCircle initials={element.props.initials} size={element.props.size ?? 40} />
524
- </Box>
525
- );
526
- }
527
-
528
- function BadgePill({ label, variant, tone, c = GRAY }: { label: string; variant?: string | null; tone?: string | null; c?: Palette }) {
529
- const solid = variant !== "outline";
530
- const toneColor = tone && tone !== "neutral" ? TONES[tone as keyof typeof TONES] : undefined;
531
- return (
532
- <Box
533
- style={{
534
- height: 22,
535
- padding: "0 9px",
536
- borderRadius: 11,
537
- alignItems: "center",
538
- fontSize: 12,
539
- fontWeight: 600,
540
- flexShrink: 0,
541
- backgroundColor: solid ? toneColor ?? c.accent : "transparent",
542
- color: solid ? (toneColor ? "#ffffff" : c.onAccent) : toneColor ?? t.text,
543
- border: solid ? "none" : `1.5px solid ${toneColor ?? t.lineStrong}`,
544
- }}
545
- >
546
- {label}
547
- </Box>
548
- );
549
- }
550
-
551
- function Badge({ element, dir, aligned, colors }: Props) {
552
- return (
553
- <Box style={hug(dir, aligned)}>
554
- <BadgePill label={element.props.label} variant={element.props.variant} tone={element.props.tone} c={colors} />
555
- </Box>
556
- );
557
- }
558
-
559
- // ── Controls ────────────────────────────────────────────────────────────
560
-
561
- function Button({ element, dir, aligned, colors: c = GRAY }: Props) {
562
- const p = element.props;
563
- const variant = p.variant ?? "primary";
564
- const h = { sm: 32, md: 42, lg: 50 }[(p.size ?? "md") as "sm" | "md" | "lg"] ?? 42;
565
- const fg = variant === "primary" ? c.onAccent : variant === "ghost" ? c.accentText : t.ink;
566
- return (
567
- <Box
568
- style={{
569
- height: h,
570
- padding: `0 ${Math.round(h * 0.45)}px`,
571
- gap: 8,
572
- borderRadius: 8,
573
- alignItems: "center",
574
- justifyContent: "center",
575
- flexShrink: 0,
576
- fontSize: h < 36 ? 13 : 15,
577
- fontWeight: 600,
578
- color: fg,
579
- backgroundColor: variant === "primary" ? c.accent : "transparent",
580
- border: variant === "secondary" ? `1.5px solid ${t.lineStrong}` : "none",
581
- ...(p.fullWidth ? { alignSelf: "stretch" } : hug(dir, aligned)),
582
- }}
583
- >
584
- {p.icon ? <IconGlyph name={p.icon} size={h < 36 ? 16 : 18} color={fg} /> : null}
585
- {p.label ? <Box>{p.label}</Box> : null}
586
- </Box>
587
- );
588
- }
589
-
590
- /** Fields fill a column; in a row they take the given width or share the space. */
591
- function fieldWidth(width: unknown, dir?: Dir): CSSProperties {
592
- if (width != null) return { width: width as number, flexShrink: 0 };
593
- return dir === "row" ? { flexGrow: 1, flexBasis: "0px", minWidth: 120 } : {};
594
- }
595
-
596
- function FieldLabel({ text }: { text?: string | null }) {
597
- return text ? <Box style={{ fontSize: 13, fontWeight: 600, color: t.text }}>{text}</Box> : null;
598
- }
599
-
600
- function Field({ children, height = 42, top = false, error = false }: { children?: ReactNode; height?: number; top?: boolean; error?: boolean }) {
601
- return (
602
- <Box
603
- style={{
604
- height,
605
- padding: top ? "10px 12px" : "0 12px",
606
- gap: 8,
607
- alignItems: top ? "flex-start" : "center",
608
- border: `1.5px solid ${error ? TONES.danger : t.lineStrong}`,
609
- borderRadius: 8,
610
- backgroundColor: t.paper,
611
- fontSize: 15,
612
- }}
613
- >
614
- {children}
615
- </Box>
616
- );
617
- }
618
-
619
- function Input({ element, dir }: Props) {
620
- const p = element.props;
621
- const rows = p.multiline ?? 1;
622
- const isPassword = p.type === "password";
623
- const shown = p.value ?? null;
624
- const content =
625
- isPassword && shown ? "•".repeat(Math.min(shown.length, 12)) : shown;
626
- return (
627
- <Box style={{ flexDirection: "column", gap: 6, ...fieldWidth(p.width, dir), ...(p.grow ? { flexGrow: 1 } : {}) }}>
628
- <FieldLabel text={p.label} />
629
- <Field height={rows > 1 ? rows * 22 + 20 : 42} top={rows > 1} error={!!p.error}>
630
- {p.type === "search" ? <IconGlyph name="search" size={18} color={t.muted} /> : null}
631
- <Box style={{ flexGrow: 1, color: content ? t.ink : t.muted }}>
632
- {content ?? p.placeholder ?? (isPassword ? "••••••••" : "")}
633
- </Box>
634
- {isPassword ? <IconGlyph name="eye-off" size={18} color={t.muted} /> : null}
635
- </Field>
636
- {p.helper ? <Box style={{ fontSize: 12, color: p.error ? TONES.danger : t.muted }}>{p.helper}</Box> : null}
637
- </Box>
638
- );
639
- }
640
-
641
- function Select({ element, dir }: Props) {
642
- const p = element.props;
643
- return (
644
- <Box style={{ flexDirection: "column", gap: 6, ...fieldWidth(p.width, dir) }}>
645
- <FieldLabel text={p.label} />
646
- <Field>
647
- <Box style={{ flexGrow: 1, color: p.value ? t.ink : t.muted }}>{p.value ?? p.placeholder ?? "Select…"}</Box>
648
- <IconGlyph name="chevron-down" size={18} color={t.text} />
649
- </Field>
650
- </Box>
651
- );
652
- }
653
-
654
- function CheckMark({ checked, round = false, c = GRAY }: { checked?: boolean | null; round?: boolean; c?: Palette }) {
655
- return (
656
- <Box
657
- style={{
658
- width: 20,
659
- height: 20,
660
- flexShrink: 0,
661
- borderRadius: round ? 10 : 5,
662
- border: `1.5px solid ${checked && !round ? c.accent : t.lineStrong}`,
663
- backgroundColor: checked && !round ? c.accent : t.paper,
664
- alignItems: "center",
665
- justifyContent: "center",
666
- }}
667
- >
668
- {checked ? (
669
- round ? (
670
- <Box style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: c.accent }} />
671
- ) : (
672
- <IconGlyph name="check" size={14} color={c.onAccent} strokeWidth={3} />
673
- )
674
- ) : null}
675
- </Box>
676
- );
677
- }
678
-
679
- function Checkbox({ element, dir, aligned, colors }: Props) {
680
- const p = element.props;
681
- return (
682
- <Box style={{ gap: 10, alignItems: "center", ...hug(dir, aligned) }}>
683
- <CheckMark checked={p.checked} c={colors} />
684
- {p.label ? <Box style={{ color: t.text }}>{p.label}</Box> : null}
685
- </Box>
686
- );
687
- }
688
-
689
- function Radio({ element, dir, aligned, colors }: Props) {
690
- const p = element.props;
691
- return (
692
- <Box style={{ gap: 10, alignItems: "center", ...hug(dir, aligned) }}>
693
- <CheckMark checked={p.checked} round c={colors} />
694
- {p.label ? <Box style={{ color: t.text }}>{p.label}</Box> : null}
695
- </Box>
696
- );
697
- }
698
-
699
- function Switch({ on, c = GRAY }: { on?: boolean | null; c?: Palette }) {
700
- return (
701
- <Box
702
- style={{
703
- width: 42,
704
- height: 24,
705
- borderRadius: 12,
706
- padding: 3,
707
- flexShrink: 0,
708
- backgroundColor: on ? c.accent : t.fill2,
709
- justifyContent: on ? "flex-end" : "flex-start",
710
- }}
711
- >
712
- <Box style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: t.paper }} />
713
- </Box>
714
- );
715
- }
716
-
717
- function Toggle({ element, colors }: Props) {
718
- const p = element.props;
719
- return (
720
- <Box style={{ gap: 12, alignItems: "center", justifyContent: "space-between" }}>
721
- {p.label ? <Box style={{ color: t.text }}>{p.label}</Box> : null}
722
- <Switch on={p.on} c={colors} />
723
- </Box>
724
- );
725
- }
726
-
727
- // ── Navigation & data ───────────────────────────────────────────────────
728
-
729
- const LEADING_ICON: Record<string, string> = { menu: "menu", back: "arrow-left", close: "x" };
730
-
731
- function NavBar({ element }: Props) {
732
- const p = element.props;
733
- const leading =
734
- p.leading === "logo" ? (
735
- <Box style={{ width: 30, height: 30, borderRadius: 8, backgroundColor: t.fill2 }} />
736
- ) : p.leading && p.leading !== "none" ? (
737
- <IconGlyph name={LEADING_ICON[p.leading]} size={22} />
738
- ) : null;
739
- const actions = (
740
- <Box style={{ gap: 18, alignItems: "center", justifyContent: "flex-end" }}>
741
- {(p.actions ?? []).map((a: string, i: number) => (
742
- <IconGlyph key={i} name={a} size={22} />
743
- ))}
744
- </Box>
745
- );
746
- const title = p.title ? <Box style={{ fontSize: 17, fontWeight: 600 }}>{p.title}</Box> : null;
747
- const centered = p.align === "center";
748
- return (
749
- <Box
750
- style={{
751
- height: 56,
752
- flexShrink: 0,
753
- padding: "0 16px",
754
- gap: 14,
755
- alignItems: "center",
756
- borderBottom: `1px solid ${t.line}`,
757
- backgroundColor: t.paper,
758
- }}
759
- >
760
- {centered
761
- ? [
762
- <Box key="l" style={{ width: 90, alignItems: "center" }}>{leading}</Box>,
763
- <Box key="c" style={{ flexGrow: 1, justifyContent: "center" }}>{title}</Box>,
764
- <Box key="r" style={{ width: 90, justifyContent: "flex-end" }}>{actions}</Box>,
765
- ]
766
- : [
767
- leading ? <Box key="l">{leading}</Box> : null,
768
- title ? <Box key="t">{title}</Box> : null,
769
- <Box key="s" style={{ flexGrow: 1 }} />,
770
- <Box key="a">{actions}</Box>,
771
- ]}
772
- </Box>
773
- );
774
- }
775
-
776
- function TabBar({ element, colors: c = GRAY }: Props) {
777
- const p = element.props;
778
- const active = p.active ?? 0;
779
- return (
780
- <Box
781
- style={{
782
- height: 76,
783
- flexShrink: 0,
784
- padding: "8px 0 18px",
785
- borderTop: `1px solid ${t.line}`,
786
- backgroundColor: t.paper,
787
- }}
788
- >
789
- {(p.items ?? []).map((item: { label: string; icon?: string }, i: number) => {
790
- const color = i === active ? c.accentText : t.muted;
791
- return (
792
- <Box
793
- key={i}
794
- style={{ flexGrow: 1, flexBasis: "0px", flexDirection: "column", alignItems: "center", gap: 4, color, fontSize: 11, fontWeight: i === active ? 600 : 400 }}
795
- >
796
- <IconGlyph name={item.icon} size={22} color={color} />
797
- <Box>{item.label}</Box>
798
- </Box>
799
- );
800
- })}
801
- </Box>
802
- );
803
- }
804
-
805
- function Tabs({ element, colors: c = GRAY }: Props) {
806
- const p = element.props;
807
- const active = p.active ?? 0;
808
- return (
809
- <Box style={{ borderBottom: `1px solid ${t.line}`, gap: 4 }}>
810
- {(p.items ?? []).map((label: string, i: number) => (
811
- <Box
812
- key={i}
813
- style={{
814
- padding: "10px 14px",
815
- marginBottom: -1,
816
- fontSize: 14,
817
- fontWeight: i === active ? 600 : 400,
818
- color: i === active ? c.accentText : t.muted,
819
- borderBottom: i === active ? `2.5px solid ${c.accentText}` : "2.5px solid transparent",
820
- }}
821
- >
822
- {label}
823
- </Box>
824
- ))}
825
- </Box>
826
- );
827
- }
828
-
829
- function List({ element, children, dir, stretch }: Props) {
830
- const dividers = element.props.dividers ?? true;
831
- const items = withDir(children, "column");
832
- return (
833
- <Box style={{ flexDirection: "column", ...grow(element.props.grow, dir), ...fillRow(stretch, false) }}>
834
- {items.map((c, i) => (
835
- <Box key={i} style={{ flexDirection: "column" }}>
836
- {i > 0 && dividers ? <Box style={{ height: 1, backgroundColor: t.line }} /> : null}
837
- {c}
838
- </Box>
839
- ))}
840
- </Box>
841
- );
842
- }
843
-
844
- function ListItem({ element, colors }: Props) {
845
- const p = element.props;
846
- const leading = (() => {
847
- switch (p.leading) {
848
- case "icon":
849
- return (
850
- <Box style={{ width: 38, height: 38, borderRadius: 10, backgroundColor: t.fill, alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
851
- <IconGlyph name={p.icon} size={20} />
852
- </Box>
853
- );
854
- case "avatar":
855
- return <AvatarCircle size={40} />;
856
- case "image":
857
- return (
858
- <Box style={{ width: 48, height: 48, borderRadius: 6, backgroundColor: t.fill, border: `1.5px solid ${t.line}`, flexShrink: 0 }} />
859
- );
860
- case "checkbox":
861
- return <CheckMark c={colors} />;
862
- default:
863
- return null;
864
- }
865
- })();
866
- const trailing = (() => {
867
- switch (p.trailing) {
868
- case "chevron":
869
- return <IconGlyph name="chevron-right" size={20} color={t.muted} />;
870
- case "toggle":
871
- return <Switch on c={colors} />;
872
- case "text":
873
- return <Box style={{ fontSize: 13, color: t.muted, flexShrink: 0 }}>{p.trailingText ?? ""}</Box>;
874
- case "badge":
875
- return <BadgePill label={p.trailingText ?? "1"} c={colors} />;
876
- case "icon":
877
- return <IconGlyph name={p.trailingIcon ?? "more-horizontal"} size={20} color={t.muted} />;
878
- default:
879
- return null;
880
- }
881
- })();
882
- return (
883
- <Box style={{ minHeight: 60, padding: "10px 0", gap: 14, alignItems: "center" }}>
884
- {leading}
885
- <Box style={{ flexGrow: 1, flexShrink: 1, flexDirection: "column", gap: 4 }}>
886
- {p.title ? (
887
- <Box style={{ fontSize: 15, fontWeight: 600, color: t.ink }}>{p.title}</Box>
888
- ) : (
889
- <Box style={{ height: 9, width: "55%", borderRadius: 5, backgroundColor: t.fill2 }} />
890
- )}
891
- {p.subtitle ? (
892
- <Box style={{ fontSize: 13, color: t.muted }}>{p.subtitle}</Box>
893
- ) : p.title ? null : (
894
- <Box style={{ height: 7, width: "80%", borderRadius: 4, backgroundColor: t.fill, marginTop: 4 }} />
895
- )}
896
- </Box>
897
- {trailing}
898
- </Box>
899
- );
900
- }
901
-
902
- const CELL_WIDTHS = [70, 45, 60, 38, 52];
903
-
904
- function Table({ element }: Props) {
905
- const p = element.props;
906
- const cols: string[] = p.columns ?? [];
907
- const data: string[][] | null = p.data ?? null;
908
- const rowCount = data ? data.length : p.rows ?? 4;
909
- return (
910
- <Box style={{ flexDirection: "column", border: `1.5px solid ${t.line}`, borderRadius: 8, overflow: "hidden", fontSize: 14 }}>
911
- <Box style={{ backgroundColor: t.fill }}>
912
- {cols.map((c, i) => (
913
- <Box key={i} style={{ flexGrow: 1, flexBasis: "0px", padding: "10px 14px", fontSize: 13, fontWeight: 600, color: t.text }}>
914
- {c}
915
- </Box>
916
- ))}
917
- </Box>
918
- {Array.from({ length: rowCount }, (_, r) => (
919
- <Box key={r} style={{ borderTop: `1px solid ${t.line}`, minHeight: 42, alignItems: "center" }}>
920
- {cols.map((_, c) => (
921
- <Box key={c} style={{ flexGrow: 1, flexBasis: "0px", padding: "10px 14px", color: t.text }}>
922
- {data ? (
923
- data[r]?.[c] ?? ""
924
- ) : (
925
- <Box
926
- style={{
927
- height: 8,
928
- borderRadius: 4,
929
- backgroundColor: c === 0 ? t.fill2 : t.fill,
930
- width: `${CELL_WIDTHS[(r + c * 2) % CELL_WIDTHS.length]}%`,
931
- }}
932
- />
933
- )}
934
- </Box>
935
- ))}
936
- </Box>
937
- ))}
938
- </Box>
939
- );
940
- }
941
-
942
- // ── Overlays ────────────────────────────────────────────────────────────
943
-
944
- function Scrim({ show, children, style }: { show: boolean; children?: ReactNode; style?: CSSProperties }) {
945
- return (
946
- <Box
947
- style={{
948
- position: "absolute",
949
- top: 0,
950
- left: 0,
951
- right: 0,
952
- bottom: 0,
953
- backgroundColor: show ? t.scrim : "transparent",
954
- ...style,
955
- }}
956
- >
957
- {children}
958
- </Box>
959
- );
960
- }
961
-
962
- function Modal({ element, children }: Props) {
963
- const p = element.props;
964
- return (
965
- <Scrim show={p.scrim ?? true} style={{ alignItems: "center", justifyContent: "center", padding: 24 }}>
966
- <Box
967
- style={{
968
- width: p.width ?? 320,
969
- flexDirection: "column",
970
- gap: 14,
971
- padding: 22,
972
- borderRadius: 14,
973
- backgroundColor: t.paper,
974
- boxShadow: "0 8px 30px rgba(0,0,0,0.18)",
975
- }}
976
- >
977
- {p.title ? <Box style={{ fontSize: 18, fontWeight: 600 }}>{p.title}</Box> : null}
978
- {withDir(children, "column")}
979
- </Box>
980
- </Scrim>
981
- );
982
- }
983
-
984
- function Drawer({ element, children }: Props) {
985
- const p = element.props;
986
- const side: "left" | "right" | "bottom" = p.side ?? "left";
987
- const bottom = side === "bottom";
988
- const size = p.size ?? (bottom ? 380 : 300);
989
- const panel: CSSProperties = bottom
990
- ? { left: 0, right: 0, bottom: 0, minHeight: size, maxHeight: "92%", borderRadius: "18px 18px 0 0", boxShadow: "0 -6px 24px rgba(0,0,0,0.14)" }
991
- : {
992
- top: 0,
993
- bottom: 0,
994
- [side]: 0,
995
- width: size,
996
- boxShadow: side === "left" ? "6px 0 24px rgba(0,0,0,0.14)" : "-6px 0 24px rgba(0,0,0,0.14)",
997
- };
998
- return (
999
- <Scrim show={p.scrim ?? true}>
1000
- <Box
1001
- style={{
1002
- position: "absolute",
1003
- flexDirection: "column",
1004
- backgroundColor: t.paper,
1005
- padding: bottom ? "10px 20px 20px" : 20,
1006
- gap: 14,
1007
- overflow: "hidden",
1008
- ...panel,
1009
- }}
1010
- >
1011
- {bottom && (p.handle ?? true) ? (
1012
- <Box style={{ justifyContent: "center", paddingBottom: 4 }}>
1013
- <Box style={{ width: 40, height: 5, borderRadius: 3, backgroundColor: t.fill2 }} />
1014
- </Box>
1015
- ) : null}
1016
- {p.title ? (
1017
- <Box style={{ alignItems: "center", justifyContent: "space-between" }}>
1018
- <Box style={{ fontSize: 18, fontWeight: 600 }}>{p.title}</Box>
1019
- <IconGlyph name="x" size={22} color={t.muted} />
1020
- </Box>
1021
- ) : null}
1022
- {withDir(children, "column")}
1023
- </Box>
1024
- </Scrim>
1025
- );
1026
- }
1027
-
1028
- export const registry = {
1029
- Board,
1030
- Screen,
1031
- Note,
1032
- Stack,
1033
- Grid,
1034
- Card,
1035
- Divider,
1036
- Spacer,
1037
- Heading,
1038
- Text,
1039
- Image,
1040
- Icon,
1041
- Avatar,
1042
- Badge,
1043
- Button,
1044
- Input,
1045
- Checkbox,
1046
- Radio,
1047
- Toggle,
1048
- Select,
1049
- NavBar,
1050
- TabBar,
1051
- Tabs,
1052
- List,
1053
- ListItem,
1054
- Table,
1055
- Modal,
1056
- Drawer,
1057
- };