@close-by/clay 0.2.1 → 0.2.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@close-by/clay",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "NiceGUI-like ui.* facade for Clay apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,8 +25,10 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@close-by/clay-core": "0.2.1",
29
- "@close-by/clay-components": "0.2.1",
30
- "@close-by/clay-server": "0.2.1"
28
+ "@close-by/clay-core": "0.2.3",
29
+ "@close-by/clay-components": "0.2.3",
30
+ "@close-by/clay-server": "0.2.3",
31
+ "@tailwindcss/cli": "^4.3.3",
32
+ "tailwindcss": "^4.3.3"
31
33
  }
32
34
  }
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  command as commandFactory,
53
53
  resizable as resizableFactory,
54
54
  scrollArea as scrollAreaFactory,
55
+ viewportEnter as viewportEnterFactory,
55
56
  keybind as keybindFactory,
56
57
  kbd as kbdFactory,
57
58
  sheet as sheetFactory,
@@ -66,6 +67,7 @@ import {
66
67
  markdown as markdownFactory,
67
68
  html as htmlFactory,
68
69
  image as imageFactory,
70
+ iframe as iframeFactory,
69
71
  upload as uploadFactory,
70
72
  rating as ratingFactory,
71
73
  colorPicker as colorPickerFactory,
@@ -175,6 +177,7 @@ import {
175
177
  type ResizablePanelProps,
176
178
  type ResizableHandleProps,
177
179
  type ScrollAreaProps,
180
+ type ViewportEnterProps,
178
181
  type KeybindProps,
179
182
  type KbdProps,
180
183
  type BreadcrumbItem,
@@ -200,6 +203,8 @@ import {
200
203
  type MarkdownProps,
201
204
  type HtmlProps,
202
205
  type ImageProps,
206
+ type IframeProps,
207
+ type ViewportEnterProps,
203
208
  type UploadProps,
204
209
  type UploadVariant,
205
210
  type UploadedFile,
@@ -383,8 +388,23 @@ import {
383
388
  type NavFromPagesOptions,
384
389
  } from './pages';
385
390
  import { resolveClayClientDir } from './client-dir';
391
+ import {
392
+ detectClayTailwindContent,
393
+ ensureClayTailwindCss,
394
+ mergeClayCss,
395
+ type ClayTailwindOptions,
396
+ } from './tailwind';
397
+
386
398
 
387
399
  export { resolveClayClientDir } from './client-dir';
400
+ export {
401
+ detectClayTailwindContent,
402
+ ensureClayTailwindCss,
403
+ mergeClayCss,
404
+ type ClayTailwindOptions,
405
+ type ClayTailwindResult,
406
+ } from './tailwind';
407
+
388
408
 
389
409
  export type {
390
410
  ButtonProps,
@@ -459,6 +479,8 @@ export type {
459
479
  MarkdownProps,
460
480
  HtmlProps,
461
481
  ImageProps,
482
+ IframeProps,
483
+ ViewportEnterProps,
462
484
  UploadProps,
463
485
  UploadVariant,
464
486
  UploadedFile,
@@ -759,6 +781,11 @@ export function image(src: string, props?: ImageProps): Element {
759
781
  return imageFactory(src, props);
760
782
  }
761
783
 
784
+ /** Iframe embed. Prefer `ui.iframe`. See {@link IframeProps}. */
785
+ export function iframe(src: string, props?: IframeProps): Element {
786
+ return iframeFactory(src, props);
787
+ }
788
+
762
789
  /** File upload control (`POST /upload`). Prefer `ui.upload`. See {@link UploadProps}. */
763
790
  export function upload(props?: UploadProps): Element {
764
791
  return uploadFactory(props);
@@ -1100,6 +1127,23 @@ export function scrollArea(a: any, b?: any): Element {
1100
1127
  return scrollAreaFactory(a, b);
1101
1128
  }
1102
1129
 
1130
+ /**
1131
+ * Observe when children enter the viewport (IntersectionObserver).
1132
+ * Prefer `ui.viewportEnter`. See {@link ViewportEnterProps}.
1133
+ *
1134
+ * @example
1135
+ * ```ts
1136
+ * ui.viewportEnter({ onEnter: () => void loadMore(), once: true }, () => {
1137
+ * ui.label('Lazy section');
1138
+ * });
1139
+ * ```
1140
+ */
1141
+ export function viewportEnter(fn: () => void, props?: ViewportEnterProps): Element;
1142
+ export function viewportEnter(props: ViewportEnterProps, fn: () => void): Element;
1143
+ export function viewportEnter(a: any, b?: any): Element {
1144
+ return viewportEnterFactory(a, b);
1145
+ }
1146
+
1103
1147
  /**
1104
1148
  * Headless keyboard chord listener. Prefer `ui.keybind`. See {@link KeybindProps}.
1105
1149
  *
@@ -1333,8 +1377,61 @@ export function page(path: string, fn: () => void, options?: PageOptions): void
1333
1377
  export type RunConfig = ClayServerConfig & {
1334
1378
  /** Global dashboard shell; wraps every page unless `ui.page(..., { shell: false })`. */
1335
1379
  app?: AppProps;
1380
+ /**
1381
+ * Auto-build Tailwind utilities from app modules and inject after the client CSS.
1382
+ *
1383
+ * - `undefined` — on when `pages/`, `src/`, or `app/` exists under cwd
1384
+ * - `true` — same auto-detect
1385
+ * - `false` — off
1386
+ * - object — custom `content` paths, `watch`, `appendCss`, …
1387
+ *
1388
+ * Scans `className` / `.classes(...)` strings; no app-level Tailwind config required.
1389
+ */
1390
+ tailwind?: boolean | (Omit<ClayTailwindOptions, 'content'> & { content?: string[] });
1336
1391
  };
1337
1392
 
1393
+ const pathDelimiter = process.platform === 'win32' ? ';' : ':';
1394
+
1395
+ function applyClayTailwind(cfg: RunConfig): RunConfig {
1396
+ if (cfg.tailwind === false || process.env.CLAY_NO_TAILWIND === '1') {
1397
+ return { ...cfg, tailwind: false };
1398
+ }
1399
+
1400
+ const tw = cfg.tailwind;
1401
+ const cwd = process.cwd();
1402
+ const envContent = process.env.CLAY_TAILWIND_CONTENT?.split(pathDelimiter).filter(Boolean) ?? [];
1403
+
1404
+ const opts =
1405
+ tw === undefined || tw === true
1406
+ ? {
1407
+ content: envContent.length ? envContent : detectClayTailwindContent(cwd),
1408
+ watch: process.env.CLAY_TAILWIND_WATCH === '1',
1409
+ }
1410
+ : {
1411
+ content: tw.content?.length
1412
+ ? tw.content
1413
+ : envContent.length
1414
+ ? envContent
1415
+ : detectClayTailwindContent(cwd),
1416
+ watch: tw.watch ?? process.env.CLAY_TAILWIND_WATCH === '1',
1417
+ appendCss: tw.appendCss,
1418
+ outFile: tw.outFile,
1419
+ cwd: tw.cwd ?? cwd,
1420
+ };
1421
+
1422
+ if (!opts.content.length) return cfg;
1423
+
1424
+ const { cssPath } = ensureClayTailwindCss({
1425
+ content: opts.content,
1426
+ cwd: opts.cwd ?? cwd,
1427
+ watch: opts.watch,
1428
+ appendCss: opts.appendCss,
1429
+ outFile: opts.outFile,
1430
+ });
1431
+
1432
+ return { ...cfg, css: mergeClayCss(cfg.css, cssPath) };
1433
+ }
1434
+
1338
1435
  let runCalled = false;
1339
1436
 
1340
1437
  /** Whether `ui.run` has been invoked in this process (used by the `clay` CLI). */
@@ -1374,7 +1471,9 @@ export function run(rootOrConfig: PageFn | RunConfig = {}, config: RunConfig = {
1374
1471
  cfg = { ...cfg, clientDir: resolveClayClientDir() };
1375
1472
  }
1376
1473
 
1377
- const { app: appProps, ...serverConfig } = cfg;
1474
+ cfg = applyClayTailwind(cfg);
1475
+
1476
+ const { app: appProps, tailwind: _tailwind, ...serverConfig } = cfg;
1378
1477
  if (appProps) {
1379
1478
  setPageWrapper((pageFn) => app(appProps, pageFn));
1380
1479
  } else {
@@ -1415,6 +1514,7 @@ export const ui = {
1415
1514
  markdown,
1416
1515
  html,
1417
1516
  image,
1517
+ iframe,
1418
1518
  upload,
1419
1519
  rating,
1420
1520
  colorPicker,
@@ -1463,6 +1563,7 @@ export const ui = {
1463
1563
  command,
1464
1564
  resizable,
1465
1565
  scrollArea,
1566
+ viewportEnter,
1466
1567
  keybind,
1467
1568
  kbd,
1468
1569
  breadcrumb,
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Zero-config Tailwind for Clay apps: scan page modules, emit utilities CSS,
3
+ * inject via `ui.run({ css })` / CLI.
4
+ *
5
+ * Uses `@tailwindcss/cli` (utilities + theme only — client already ships base/preflight).
6
+ */
7
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
8
+ import { createRequire } from 'module';
9
+ import { dirname, isAbsolute, join, resolve } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ export type ClayTailwindOptions = {
13
+ /**
14
+ * Directories/files to scan for `className` / `.classes(...)` strings.
15
+ * Absolute or cwd-relative. Prefer app dirs — never the repo root with `node_modules`.
16
+ */
17
+ content: string[];
18
+ /** Working directory for relative paths. Default `process.cwd()`. */
19
+ cwd?: string;
20
+ /** Output CSS path. Default `<cwd>/.clay/tailwind.css`. */
21
+ outFile?: string;
22
+ /** Keep `@tailwindcss/cli --watch` running after the first build. */
23
+ watch?: boolean;
24
+ /** Extra CSS appended after the generated input (token overrides, keyframes). */
25
+ appendCss?: string;
26
+ };
27
+
28
+ export type ClayTailwindResult = {
29
+ cssPath: string;
30
+ /** Stop the background watch process (if any). */
31
+ stop: () => void;
32
+ };
33
+
34
+ const THEME_SNIPPET = `
35
+ /* Token utilities for app-scanned classes (vars come from the Clay client). */
36
+ @theme inline {
37
+ --color-background: var(--background);
38
+ --color-foreground: var(--foreground);
39
+ --color-card: var(--card);
40
+ --color-card-foreground: var(--card-foreground);
41
+ --color-popover: var(--popover);
42
+ --color-popover-foreground: var(--popover-foreground);
43
+ --color-primary: var(--primary);
44
+ --color-primary-foreground: var(--primary-foreground);
45
+ --color-secondary: var(--secondary);
46
+ --color-secondary-foreground: var(--secondary-foreground);
47
+ --color-muted: var(--muted);
48
+ --color-muted-foreground: var(--muted-foreground);
49
+ --color-accent: var(--accent);
50
+ --color-accent-foreground: var(--accent-foreground);
51
+ --color-destructive: var(--destructive);
52
+ --color-destructive-foreground: var(--destructive-foreground);
53
+ --color-border: var(--border);
54
+ --color-input: var(--input);
55
+ --color-ring: var(--ring);
56
+ --color-chart-1: var(--chart-1);
57
+ --color-chart-2: var(--chart-2);
58
+ --color-chart-3: var(--chart-3);
59
+ --color-chart-4: var(--chart-4);
60
+ --color-chart-5: var(--chart-5);
61
+ --color-sidebar: var(--sidebar);
62
+ --color-sidebar-foreground: var(--sidebar-foreground);
63
+ --color-sidebar-primary: var(--sidebar-primary);
64
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
65
+ --color-sidebar-accent: var(--sidebar-accent);
66
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
67
+ --color-sidebar-border: var(--sidebar-border);
68
+ --color-sidebar-ring: var(--sidebar-ring);
69
+ --radius-sm: calc(var(--radius) - 4px);
70
+ --radius-md: calc(var(--radius) - 2px);
71
+ --radius-lg: var(--radius);
72
+ --radius-xl: calc(var(--radius) + 4px);
73
+ }
74
+ `.trim();
75
+
76
+ function resolveContentPaths(content: string[], cwd: string): string[] {
77
+ const out: string[] = [];
78
+ for (const raw of content) {
79
+ const p = isAbsolute(raw) ? raw : resolve(cwd, raw);
80
+ if (existsSync(p)) out.push(p);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Guess scan roots under `cwd` (`pages/`, `src/`, `app/`). */
86
+ export function detectClayTailwindContent(cwd = process.cwd()): string[] {
87
+ const candidates = ['pages', 'src', 'app'];
88
+ return candidates.map((d) => join(cwd, d)).filter((p) => existsSync(p));
89
+ }
90
+
91
+ function resolveTailwindcssRoot(): string {
92
+ const require = createRequire(import.meta.url);
93
+ const tryPkg = (req: NodeJS.Require): string | null => {
94
+ try {
95
+ return dirname(req.resolve('tailwindcss/package.json'));
96
+ } catch {
97
+ return null;
98
+ }
99
+ };
100
+ const fromUi = tryPkg(require);
101
+ if (fromUi) return fromUi;
102
+ try {
103
+ const rootReq = createRequire(
104
+ join(fileURLToPath(new URL('.', import.meta.url)), '../../../package.json'),
105
+ );
106
+ const fromRoot = tryPkg(rootReq);
107
+ if (fromRoot) return fromRoot;
108
+ } catch {
109
+ // ignore
110
+ }
111
+ throw new Error(
112
+ 'tailwindcss package not found. Install with: bun add tailwindcss @tailwindcss/cli',
113
+ );
114
+ }
115
+
116
+ function writeInputCss(inputFile: string, sources: string[], appendCss?: string): void {
117
+ mkdirSync(dirname(inputFile), { recursive: true });
118
+ const twRoot = resolveTailwindcssRoot();
119
+ // Absolute CSS imports so builds work even when `.clay/` is outside node_modules.
120
+ const themeCss = join(twRoot, 'theme.css');
121
+ const utilitiesCss = join(twRoot, 'utilities.css');
122
+ const sourceLines = sources.map((s) => `@source ${JSON.stringify(s)};`).join('\n');
123
+ const body = [
124
+ '/* Generated by @close-by/clay — do not edit */',
125
+ `@import ${JSON.stringify(themeCss)} layer(theme);`,
126
+ `@import ${JSON.stringify(utilitiesCss)} layer(utilities);`,
127
+ '',
128
+ sourceLines,
129
+ '',
130
+ THEME_SNIPPET,
131
+ appendCss ? `\n${appendCss.trim()}\n` : '',
132
+ ].join('\n');
133
+ writeFileSync(inputFile, body, 'utf8');
134
+ }
135
+
136
+ function resolveTailwindCliEntry(): string {
137
+ const require = createRequire(import.meta.url);
138
+ const tryPkg = (req: NodeJS.Require): string | null => {
139
+ try {
140
+ const pkgJsonPath = req.resolve('@tailwindcss/cli/package.json');
141
+ return join(dirname(pkgJsonPath), 'dist/index.mjs');
142
+ } catch {
143
+ return null;
144
+ }
145
+ };
146
+
147
+ const fromUi = tryPkg(require);
148
+ if (fromUi && existsSync(fromUi)) return fromUi;
149
+
150
+ try {
151
+ const rootReq = createRequire(
152
+ join(fileURLToPath(new URL('.', import.meta.url)), '../../../package.json'),
153
+ );
154
+ const fromRoot = tryPkg(rootReq);
155
+ if (fromRoot && existsSync(fromRoot)) return fromRoot;
156
+ } catch {
157
+ // ignore
158
+ }
159
+
160
+ throw new Error(
161
+ 'Tailwind CLI not found. Install with: bun add tailwindcss @tailwindcss/cli\n' +
162
+ '(shipped with @close-by/clay — ensure the package is installed).',
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Build (and optionally watch) Tailwind CSS for Clay page modules.
168
+ * Returns a stylesheet path suitable for `ui.run({ css })`.
169
+ */
170
+ export function ensureClayTailwindCss(options: ClayTailwindOptions): ClayTailwindResult {
171
+ const cwd = options.cwd ?? process.cwd();
172
+ const sources = resolveContentPaths(options.content, cwd);
173
+ if (sources.length === 0) {
174
+ throw new Error(
175
+ 'Clay Tailwind: no content paths found. Pass content: ["./pages"] or create a pages/ directory.',
176
+ );
177
+ }
178
+
179
+ const outFile = options.outFile
180
+ ? isAbsolute(options.outFile)
181
+ ? options.outFile
182
+ : resolve(cwd, options.outFile)
183
+ : join(cwd, '.clay', 'tailwind.css');
184
+ const inputFile = join(dirname(outFile), 'tailwind.input.css');
185
+ mkdirSync(dirname(outFile), { recursive: true });
186
+ writeInputCss(inputFile, sources, options.appendCss);
187
+
188
+ const cliEntry = resolveTailwindCliEntry();
189
+ const buildArgs = [cliEntry, '-i', inputFile, '-o', outFile];
190
+
191
+ const built = Bun.spawnSync({
192
+ cmd: [process.execPath, ...buildArgs],
193
+ cwd,
194
+ stdout: 'pipe',
195
+ stderr: 'pipe',
196
+ });
197
+ if (built.exitCode !== 0) {
198
+ const err = built.stderr.toString() || built.stdout.toString() || `exit ${built.exitCode}`;
199
+ throw new Error(`Clay Tailwind build failed:\n${err}`);
200
+ }
201
+ if (!existsSync(outFile)) {
202
+ throw new Error(`Clay Tailwind build produced no file at ${outFile}`);
203
+ }
204
+
205
+ let child: ReturnType<typeof Bun.spawn> | null = null;
206
+ if (options.watch) {
207
+ child = Bun.spawn({
208
+ cmd: [process.execPath, ...buildArgs, '--watch'],
209
+ cwd,
210
+ stdout: 'ignore',
211
+ stderr: 'inherit',
212
+ stdin: 'ignore',
213
+ });
214
+ }
215
+
216
+ return {
217
+ cssPath: outFile,
218
+ stop: () => {
219
+ if (!child) return;
220
+ try {
221
+ child.kill();
222
+ } catch {
223
+ // already exited
224
+ }
225
+ child = null;
226
+ },
227
+ };
228
+ }
229
+
230
+ /** Merge a Tailwind stylesheet into existing `css` config (append). */
231
+ export function mergeClayCss(
232
+ existing: string | string[] | undefined,
233
+ extra: string | string[],
234
+ ): string[] {
235
+ const a = existing == null ? [] : Array.isArray(existing) ? existing : [existing];
236
+ const b = Array.isArray(extra) ? extra : [extra];
237
+ return [...a, ...b].map((p) => p.trim()).filter(Boolean);
238
+ }