create-middag-ui 0.4.0 → 0.4.2

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 (3) hide show
  1. package/cli.js +14 -9
  2. package/lib/scaffold.js +644 -106
  3. package/package.json +1 -1
package/cli.js CHANGED
@@ -28,8 +28,10 @@ import {
28
28
  scaffoldPackageJson,
29
29
  scaffoldTsconfig,
30
30
  scaffoldViteConfig,
31
+ scaffoldIndexHtml,
31
32
  scaffoldDemoFiles,
32
- scaffoldMockFiles,
33
+ scaffoldPageExamples,
34
+ scaffoldAppFiles,
33
35
  } from "./lib/scaffold.js";
34
36
  import { runNpmInstall } from "./lib/install.js";
35
37
  import { log, success, heading, blank, info } from "./lib/ui.js";
@@ -112,6 +114,7 @@ heading(5, TOTAL_STEPS, "Scaffolding config files");
112
114
  scaffoldPackageJson(targetDir, host, cwd);
113
115
  scaffoldTsconfig(targetDir);
114
116
  scaffoldViteConfig(targetDir, host);
117
+ scaffoldIndexHtml(targetDir);
115
118
 
116
119
  // ── Step 6: Scaffold ~/.npmrc (GitHub path only) ─────────────────────────
117
120
 
@@ -130,11 +133,12 @@ heading(7, TOTAL_STEPS, "Creating demo files");
130
133
 
131
134
  scaffoldDemoFiles(targetDir);
132
135
 
133
- // ── Step 8: Scaffold mock files ──────────────────────────────────────────
136
+ // ── Step 8: Scaffold app files + page examples ─────────────────────────
134
137
 
135
- heading(8, TOTAL_STEPS, "Creating mock environment");
138
+ heading(8, TOTAL_STEPS, "Creating app and page examples");
136
139
 
137
- scaffoldMockFiles(targetDir);
140
+ scaffoldAppFiles(targetDir);
141
+ scaffoldPageExamples(targetDir);
138
142
 
139
143
  // ── Step 9: npm install ──────────────────────────────────────────────────
140
144
 
@@ -156,21 +160,22 @@ if (installOk) {
156
160
  log(`MIDDAG React UI ready in ${dirName}/ (${elapsed}s)\n`);
157
161
  console.log(" Start developing:");
158
162
  console.log(` cd ${dirName}`);
159
- console.log(` npm run dev:mock \u2192 mock server at http://localhost:${host.port}`);
163
+ console.log(` npm run dev \u2192 dev server at http://localhost:${host.port}`);
160
164
  } else {
161
165
  log(`Scaffold complete in ${dirName}/ (${elapsed}s) \u2014 install failed\n`);
162
166
  console.log(" To retry install:");
163
167
  console.log(` cd ${dirName}`);
164
168
  console.log(" npm install");
165
- console.log(` npm run dev:mock \u2192 mock server at http://localhost:${host.port}`);
169
+ console.log(` npm run dev \u2192 dev server at http://localhost:${host.port}`);
166
170
  }
167
171
 
168
172
  blank();
169
173
  console.log(" Your scaffold includes:");
174
+ console.log(" src/pages/dashboard.ts \u2190 starter: metric_card + dense_table");
175
+ console.log(" src/pages/connectors.ts \u2190 intermediate: card_grid + status_strip");
176
+ console.log(" src/pages/settings.ts \u2190 advanced: tabbed_panel + form_panel");
170
177
  console.log(" src/blocks/hello-block.tsx \u2190 custom block example (rename me!)");
171
- console.log(" src/components/greeting.tsx \u2190 standalone component (rename me!)");
172
- console.log(" src/contracts.ts \u2190 PageContract type re-export");
173
- console.log(" mock/hello-contract.ts \u2190 example PageContract with data");
178
+ console.log(" src/app.tsx \u2190 hash-based page router");
174
179
 
175
180
  blank();
176
181
  console.log(` Integrate with your ${host.name} plugin:`);
package/lib/scaffold.js CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * scaffold.js — File creation for all scaffolded files.
3
3
  *
4
- * Creates directory structure, config files, demo files, and mock files.
4
+ * Creates directory structure, config files, page examples, app entry,
5
+ * and Inertia mock adapters. Everything lives under src/.
6
+ *
5
7
  * Every I/O operation is wrapped with error handling.
6
8
  */
7
9
 
@@ -83,16 +85,15 @@ export function scaffoldPackageJson(targetDir, host, cwd) {
83
85
  private: true,
84
86
  type: "module",
85
87
  scripts: {
86
- dev: "vite --config vite.mock.config.ts",
87
- "dev:mock": "vite --config vite.mock.config.ts",
88
+ dev: "vite",
88
89
  build: "vite build",
89
- "build:mock": "vite build --config vite.mock.config.ts",
90
90
  typecheck: "tsc --noEmit",
91
91
  lint: "eslint .",
92
92
  "lint:fix": "eslint . --fix",
93
93
  },
94
94
  dependencies: {
95
95
  "@middag-io/react": `^${getLibVersion()}`,
96
+ "@fontsource-variable/figtree": "^5.0.0",
96
97
  },
97
98
  devDependencies: {
98
99
  "@types/react": "^19.0.0",
@@ -104,8 +105,6 @@ export function scaffoldPackageJson(targetDir, host, cwd) {
104
105
  typescript: "^5.7.0",
105
106
  vite: "^6.0.0",
106
107
  "@vitejs/plugin-react": "^4.0.0",
107
- tailwindcss: "^4.0.0",
108
- "@tailwindcss/vite": "^4.0.0",
109
108
  },
110
109
  };
111
110
 
@@ -129,43 +128,89 @@ export function scaffoldTsconfig(targetDir) {
129
128
  noUnusedLocals: true,
130
129
  noUnusedParameters: true,
131
130
  skipLibCheck: true,
132
- paths: { "@/*": ["./src/*"], "@mock/*": ["./mock/*"] },
131
+ paths: { "@/*": ["./src/*"] },
133
132
  baseUrl: ".",
134
133
  },
135
- include: ["src", "mock"],
134
+ include: ["src"],
136
135
  };
137
136
 
138
137
  writeFile(filePath, JSON.stringify(tsconfig, null, 2) + "\n", "tsconfig.json");
139
138
  }
140
139
 
141
140
  /**
142
- * Scaffold vite.mock.config.ts.
141
+ * Scaffold vite.config.ts.
143
142
  */
144
143
  export function scaffoldViteConfig(targetDir, host) {
145
- const filePath = join(targetDir, "vite.mock.config.ts");
146
- if (skipIfExists(filePath, "vite.mock.config.ts")) return;
144
+ const filePath = join(targetDir, "vite.config.ts");
145
+ if (skipIfExists(filePath, "vite.config.ts")) return;
147
146
 
148
- const content = `import { defineConfig } from "vite";
147
+ const content = `/**
148
+ * Vite config \u2014 used by \`npm run dev\` and \`npm run build\`.
149
+ *
150
+ * The Inertia aliases below redirect @inertiajs/* imports to local
151
+ * mock adapters so the dev server works standalone (no Moodle/WP).
152
+ * In production, the real @inertiajs packages handle routing and
153
+ * page resolution \u2014 these aliases have no effect.
154
+ */
155
+ import { defineConfig } from "vite";
149
156
  import react from "@vitejs/plugin-react";
150
- import tailwindcss from "@tailwindcss/vite";
151
157
  import { resolve } from "path";
152
158
 
153
159
  export default defineConfig({
154
- plugins: [react(), tailwindcss()],
155
- root: "mock",
160
+ plugins: [react()],
156
161
  server: { port: ${host.port} },
157
162
  resolve: {
158
163
  alias: {
164
+ // Path alias \u2014 import from "@/components/..." resolves to src/
159
165
  "@/": resolve(__dirname, "src") + "/",
160
- "@mock/": resolve(__dirname, "mock") + "/",
161
- "@inertiajs/react": resolve(__dirname, "mock/adapters/inertia-react.ts"),
162
- "@inertiajs/core": resolve(__dirname, "mock/adapters/inertia-core.ts"),
166
+ // Mock Inertia for standalone dev \u2014 see src/adapters/ for implementation
167
+ "@inertiajs/react": resolve(__dirname, "src/adapters/inertia-react.ts"),
168
+ "@inertiajs/core": resolve(__dirname, "src/adapters/inertia-core.ts"),
163
169
  },
164
170
  },
165
171
  });
166
172
  `;
167
173
 
168
- writeFile(filePath, content, "vite.mock.config.ts");
174
+ writeFile(filePath, content, "vite.config.ts");
175
+ }
176
+
177
+ /**
178
+ * Scaffold index.html at project root.
179
+ */
180
+ export function scaffoldIndexHtml(targetDir) {
181
+ const filePath = join(targetDir, "index.html");
182
+ if (skipIfExists(filePath, "index.html")) return;
183
+
184
+ writeFile(
185
+ filePath,
186
+ `<!doctype html>
187
+ <html lang="en">
188
+ <head>
189
+ <meta charset="UTF-8" />
190
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
191
+ <title>MIDDAG React UI</title>
192
+ </head>
193
+ <body>
194
+ <!--
195
+ DEV ONLY \u2014 This file is used by \`npm run dev\` (Vite dev server).
196
+ In production, your host platform (Moodle/WordPress) serves its own
197
+ HTML and loads the UI via Inertia.js. This file is never deployed.
198
+
199
+ middag-root \u2014 Required class for MIDDAG CSS tokens to apply.
200
+ middag-portals \u2014 Container for floating UI (modals, dropdowns, toasts).
201
+
202
+ To try a theme, add its class to #root:
203
+ class="middag-root theme-ocean"
204
+ See src/theme.css for available themes and how to create your own.
205
+ -->
206
+ <div id="root" class="middag-root"></div>
207
+ <div id="middag-portals" class="middag-root"></div>
208
+ <script type="module" src="/src/main.tsx"></script>
209
+ </body>
210
+ </html>
211
+ `,
212
+ "index.html",
213
+ );
169
214
  }
170
215
 
171
216
  // ── Demo files in src/ ──────────────────────────────────────────────────
@@ -272,68 +317,297 @@ export type { PageContract, BlockDescriptor, SharedProps } from "@middag-io/reac
272
317
  "src/contracts.ts",
273
318
  );
274
319
  }
320
+
321
+ // src/theme.css
322
+ const themePath = join(targetDir, "src", "theme.css");
323
+ if (!skipIfExists(themePath, "src/theme.css")) {
324
+ writeFile(
325
+ themePath,
326
+ `/**
327
+ * Theme customization \u2014 override MIDDAG design tokens here.
328
+ *
329
+ * @middag-io/react uses CSS custom properties for all visual tokens.
330
+ * Override them in :root for global changes, or scope them to a class
331
+ * for switchable themes.
332
+ *
333
+ * All colors use OKLCH color space: oklch(lightness chroma hue)
334
+ * Visual picker: https://oklch.com
335
+ *
336
+ * Import order matters \u2014 this file is loaded AFTER @middag-io/react/style.css
337
+ * so overrides here take precedence.
338
+ */
339
+
340
+ /* \u2500\u2500 Global token overrides \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
341
+ *
342
+ * Uncomment to change the default appearance globally.
343
+ * Every MIDDAG component reads from these tokens.
344
+ */
345
+
346
+ /*
347
+ :root {
348
+ --primary: oklch(0.45 0.2 260);
349
+ --primary-foreground: oklch(0.98 0 0);
350
+ --accent: oklch(0.95 0.02 260);
351
+ --accent-foreground: oklch(0.21 0.014 286);
352
+ --radius: 0.5rem;
275
353
  }
354
+ */
276
355
 
277
- // ── Mock files ──────────────────────────────────────────────────────────
356
+ /* \u2500\u2500 Example theme: Ocean \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
357
+ *
358
+ * A complete theme override scoped to a CSS class.
359
+ * Apply with: <div id="root" class="middag-root theme-ocean">
360
+ *
361
+ * This is how MIDDAG themes work \u2014 redefine tokens in a scope.
362
+ * The scoped class overrides :root values for everything inside it.
363
+ */
278
364
 
279
- /**
280
- * Scaffold mock/ files (hello-contract, main.tsx, index.html, tailwind.css).
365
+ .theme-ocean {
366
+ /* Brand */
367
+ --primary: oklch(0.45 0.18 230);
368
+ --primary-foreground: oklch(0.98 0 0);
369
+ --primary-subtle: oklch(0.92 0.04 230);
370
+ --primary-muted: oklch(0.85 0.06 230);
371
+
372
+ /* Accents */
373
+ --accent: oklch(0.94 0.03 230);
374
+ --accent-foreground: oklch(0.2 0.02 230);
375
+ --info: oklch(0.55 0.14 200);
376
+ --info-foreground: oklch(0.98 0 0);
377
+
378
+ /* Sidebar */
379
+ --sidebar: oklch(0.15 0.03 230);
380
+ --sidebar-foreground: oklch(0.75 0.02 230);
381
+ --sidebar-primary: oklch(0.55 0.15 230);
382
+ --sidebar-primary-foreground: oklch(0.98 0 0);
383
+ --sidebar-accent: oklch(0.22 0.04 230);
384
+ --sidebar-accent-foreground: oklch(0.9 0.005 230);
385
+ --sidebar-hover: oklch(0.19 0.025 230);
386
+ --sidebar-border: oklch(0.25 0.03 230);
387
+ }
388
+
389
+ /* \u2500\u2500 Custom project styles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
390
+ *
391
+ * Add project-specific styles below. These can target MIDDAG components
392
+ * or your own custom elements.
393
+ *
394
+ * Available token groups:
395
+ * Colors: var(--primary), var(--foreground), var(--background),
396
+ * var(--muted), var(--card), var(--border), var(--destructive),
397
+ * var(--success), var(--warning), var(--info), var(--accent)
398
+ * Spacing: var(--space-1) through var(--space-20) (4px grid)
399
+ * Radius: var(--radius-sm|md|lg|xl|2xl|full)
400
+ * Shadows: var(--shadow-xs|sm|md|lg|xl|2xl)
401
+ * Motion: var(--duration-fast|normal|moderate|slow)
281
402
  */
282
- export function scaffoldMockFiles(targetDir) {
283
- const mockDir = join(targetDir, "mock");
284
- ensureDir(mockDir);
285
403
 
286
- // mock/hello-contract.ts
287
- const helloContractPath = join(mockDir, "hello-contract.ts");
288
- if (!skipIfExists(helloContractPath, "mock/hello-contract.ts")) {
289
- writeFile(
290
- helloContractPath,
291
- `import type { PageContract } from "@middag-io/react";
404
+ /* Example: branded page header */
405
+ /*
406
+ .my-page-header {
407
+ background: linear-gradient(135deg, var(--primary) 0%, var(--info) 100%);
408
+ color: var(--primary-foreground);
409
+ padding: var(--space-6) var(--space-8);
410
+ border-radius: var(--radius-lg);
411
+ }
412
+ */
413
+ `,
414
+ "src/theme.css",
415
+ );
416
+ }
417
+ }
418
+
419
+ // ── Page contract examples ──────────────────────────────────────────────
292
420
 
293
421
  /**
294
- * Hello World contract \u2014 a minimal PageContract to verify your setup.
422
+ * Scaffold 3 progressive page contract examples in src/pages/.
423
+ */
424
+ export function scaffoldPageExamples(targetDir) {
425
+ ensureDir(join(targetDir, "src", "pages"));
426
+
427
+ // ── Starter: dashboard.ts (metric_card + dense_table) ───────────────
428
+ const dashboardPath = join(targetDir, "src", "pages", "dashboard.ts");
429
+ if (!skipIfExists(dashboardPath, "src/pages/dashboard.ts")) {
430
+ writeFile(
431
+ dashboardPath,
432
+ `/**
433
+ * Dashboard page contract \u2014 STARTER example.
434
+ *
435
+ * Demonstrates the "dashboard" layout with two block types:
436
+ * - metric_card (KPI indicators in the metrics region)
437
+ * - dense_table (data table in the content region)
295
438
  *
296
- * This is what your backend will send via Inertia.
297
- * Replace this with real data from your server.
439
+ * Layout regions used: metrics, content
298
440
  */
299
- export const helloContract: PageContract = {
441
+ import type { PageContract } from "@middag-io/react";
442
+
443
+ export const dashboardContract: PageContract = {
444
+ version: "1",
300
445
  shell: "product",
301
- meta: {
302
- title: "Hello MIDDAG",
303
- breadcrumbs: [
304
- { label: "Home", href: "/" },
305
- { label: "Hello", href: "/hello" },
306
- ],
446
+ page: {
447
+ key: "dashboard",
448
+ title: "Dashboard",
449
+ breadcrumbs: [{ label: "Home", href: "#/" }],
307
450
  },
308
451
  layout: {
309
- template: "stack",
452
+ template: "dashboard",
310
453
  regions: {
311
- content: [
454
+ metrics: [
312
455
  {
313
- key: "welcome_metrics",
456
+ key: "total_users",
314
457
  type: "metric_card",
315
458
  data: {
316
- title: "Setup Complete",
317
- value: "1",
318
- subtitle: "@middag-io/react is working",
459
+ label: "Total Users",
460
+ value: "1,284",
461
+ delta: "+12%",
462
+ deltaDirection: "positive",
463
+ icon: "users",
319
464
  },
320
465
  },
321
466
  {
322
- key: "hello_table",
467
+ key: "active_sessions",
468
+ type: "metric_card",
469
+ data: {
470
+ label: "Active Sessions",
471
+ value: "342",
472
+ delta: "+5%",
473
+ deltaDirection: "positive",
474
+ icon: "activity",
475
+ },
476
+ },
477
+ {
478
+ key: "completion_rate",
479
+ type: "metric_card",
480
+ data: {
481
+ label: "Completion Rate",
482
+ value: "87%",
483
+ delta: "-2%",
484
+ deltaDirection: "negative",
485
+ icon: "chart-line",
486
+ },
487
+ },
488
+ ],
489
+ content: [
490
+ {
491
+ key: "recent_activity",
323
492
  type: "dense_table",
493
+ title: "Recent Activity",
494
+ data: {
495
+ columns: [
496
+ { key: "user", label: "User", sortable: true },
497
+ { key: "action", label: "Action" },
498
+ { key: "date", label: "Date", sortable: true },
499
+ { key: "status", label: "Status", variant: "status" },
500
+ ],
501
+ rows: [
502
+ { id: 1, user: "Alice Johnson", action: "Completed module", date: "2024-01-15", status: { label: "Complete", appearance: "success" } },
503
+ { id: 2, user: "Bob Smith", action: "Started course", date: "2024-01-15", status: { label: "In Progress", appearance: "info" } },
504
+ { id: 3, user: "Carol Davis", action: "Failed quiz", date: "2024-01-14", status: { label: "Failed", appearance: "danger" } },
505
+ { id: 4, user: "Dave Wilson", action: "Enrolled", date: "2024-01-14", status: { label: "New", appearance: "neutral" } },
506
+ { id: 5, user: "Eve Brown", action: "Completed course", date: "2024-01-13", status: { label: "Complete", appearance: "success" } },
507
+ ],
508
+ pagination: { page: 1, perPage: 10, total: 5, lastPage: 1 },
509
+ sort: { column: "date", direction: "desc" },
510
+ filters: { available: [], applied: {} },
511
+ },
512
+ },
513
+ ],
514
+ },
515
+ },
516
+ };
517
+ `,
518
+ "src/pages/dashboard.ts",
519
+ );
520
+ }
521
+
522
+ // ── Intermediate: connectors.ts (card_grid + status_strip + detail_panel)
523
+ const connectorsPath = join(targetDir, "src", "pages", "connectors.ts");
524
+ if (!skipIfExists(connectorsPath, "src/pages/connectors.ts")) {
525
+ writeFile(
526
+ connectorsPath,
527
+ `/**
528
+ * Connectors page contract \u2014 INTERMEDIATE example.
529
+ *
530
+ * Demonstrates the "split" layout with three block types:
531
+ * - card_grid (connector cards in the main region)
532
+ * - status_strip (health indicators in the aside)
533
+ * - detail_panel (metadata in the aside)
534
+ *
535
+ * Layout regions used: main, aside
536
+ */
537
+ import type { PageContract } from "@middag-io/react";
538
+
539
+ export const connectorsContract: PageContract = {
540
+ version: "1",
541
+ shell: "product",
542
+ page: {
543
+ key: "connectors",
544
+ title: "Connectors",
545
+ breadcrumbs: [
546
+ { label: "Home", href: "#/" },
547
+ { label: "Connectors" },
548
+ ],
549
+ actions: [
550
+ { id: "add", label: "Add Connector", intent: "primary", icon: "plus" },
551
+ ],
552
+ },
553
+ layout: {
554
+ template: "split",
555
+ regions: {
556
+ main: [
557
+ {
558
+ key: "connector_grid",
559
+ type: "card_grid",
560
+ title: "Available Connectors",
324
561
  data: {
325
- title: "Example Data",
562
+ variant: "connector",
326
563
  columns: [
327
- { key: "id", label: "ID", sortable: true },
328
- { key: "name", label: "Name", sortable: true },
329
- { key: "status", label: "Status" },
564
+ { key: "name", label: "Name" },
565
+ { key: "type", label: "Type" },
566
+ { key: "status", label: "Status", kind: "status" },
330
567
  ],
331
568
  rows: [
332
- { id: 1, name: "First item", status: "Active" },
333
- { id: 2, name: "Second item", status: "Draft" },
334
- { id: 3, name: "Third item", status: "Active" },
569
+ { id: 1, name: "Moodle LMS", type: "LMS", status: "Active", icon: "graduation-cap", href: "#/connectors" },
570
+ { id: 2, name: "Google Workspace", type: "SSO", status: "Active", icon: "shield", href: "#/connectors" },
571
+ { id: 3, name: "Stripe", type: "Payment", status: "Inactive", icon: "credit-card", href: "#/connectors" },
572
+ { id: 4, name: "Mailchimp", type: "Email", status: "Active", icon: "mail", href: "#/connectors" },
573
+ ],
574
+ },
575
+ },
576
+ ],
577
+ aside: [
578
+ {
579
+ key: "connector_health",
580
+ type: "status_strip",
581
+ title: "Health Overview",
582
+ data: {
583
+ score: 75,
584
+ tone: "success",
585
+ items: [
586
+ { key: "uptime", label: "Uptime", value: "99.9%", appearance: "success" },
587
+ { key: "sync", label: "Last Sync", value: "2 min ago", appearance: "success" },
588
+ { key: "errors", label: "Errors (24h)", value: "3", appearance: "warning" },
589
+ { key: "queue", label: "Queue", value: "0", appearance: "success" },
590
+ ],
591
+ },
592
+ },
593
+ {
594
+ key: "connector_detail",
595
+ type: "detail_panel",
596
+ title: "Selected Connector",
597
+ data: {
598
+ sections: [
599
+ {
600
+ id: "overview",
601
+ title: "Overview",
602
+ fields: [
603
+ { key: "provider", label: "Provider", value: "Moodle LMS" },
604
+ { key: "version", label: "API Version", value: "4.3.2" },
605
+ { key: "connected", label: "Connected Since", value: "2024-01-01", kind: "timestamp" },
606
+ { key: "status", label: "Status", value: "Active", kind: "status" },
607
+ { key: "endpoint", label: "Endpoint", value: "https://moodle.example.com/webservice/rest", kind: "code", copyable: true },
608
+ ],
609
+ },
335
610
  ],
336
- pagination: { page: 1, totalPages: 1, perPage: 10, totalRows: 3 },
337
611
  },
338
612
  },
339
613
  ],
@@ -341,73 +615,325 @@ export const helloContract: PageContract = {
341
615
  },
342
616
  };
343
617
  `,
344
- "mock/hello-contract.ts",
618
+ "src/pages/connectors.ts",
345
619
  );
346
620
  }
347
621
 
348
- // mock/main.tsx
349
- const mainPath = join(mockDir, "main.tsx");
350
- if (!skipIfExists(mainPath, "mock/main.tsx")) {
622
+ // ── Advanced: settings.ts (tabbed_panel + form_panel + link_list) ────
623
+ const settingsPath = join(targetDir, "src", "pages", "settings.ts");
624
+ if (!skipIfExists(settingsPath, "src/pages/settings.ts")) {
625
+ writeFile(
626
+ settingsPath,
627
+ `/**
628
+ * Settings page contract \u2014 ADVANCED example.
629
+ *
630
+ * Demonstrates the "stack" layout with nested block types:
631
+ * - tabbed_panel (tabs that contain other blocks)
632
+ * - form_panel (schema-driven form inside a tab)
633
+ * - link_list (navigation links inside a tab)
634
+ *
635
+ * Layout regions used: content
636
+ */
637
+ import type { PageContract } from "@middag-io/react";
638
+
639
+ export const settingsContract: PageContract = {
640
+ version: "1",
641
+ shell: "product",
642
+ page: {
643
+ key: "settings",
644
+ title: "Settings",
645
+ breadcrumbs: [
646
+ { label: "Home", href: "#/" },
647
+ { label: "Settings" },
648
+ ],
649
+ },
650
+ layout: {
651
+ template: "stack",
652
+ regions: {
653
+ content: [
654
+ {
655
+ key: "settings_tabs",
656
+ type: "tabbed_panel",
657
+ data: {
658
+ defaultTab: "general",
659
+ tabs: [
660
+ {
661
+ key: "general",
662
+ label: "General",
663
+ icon: "settings",
664
+ blocks: [
665
+ {
666
+ key: "general_form",
667
+ type: "form_panel",
668
+ data: {
669
+ action: "/api/settings/general",
670
+ method: "put",
671
+ schema: [
672
+ {
673
+ kind: "section",
674
+ id: "site",
675
+ label: "Site Settings",
676
+ children: [
677
+ {
678
+ kind: "field",
679
+ key: "site_name",
680
+ component: "text",
681
+ props: { label: "Site Name", placeholder: "My Platform", required: true },
682
+ },
683
+ {
684
+ kind: "field",
685
+ key: "site_url",
686
+ component: "url",
687
+ props: { label: "Site URL", placeholder: "https://example.com" },
688
+ },
689
+ {
690
+ kind: "field",
691
+ key: "timezone",
692
+ component: "select",
693
+ props: {
694
+ label: "Timezone",
695
+ options: [
696
+ { value: "UTC", label: "UTC" },
697
+ { value: "America/Sao_Paulo", label: "S\\u00e3o Paulo (BRT)" },
698
+ { value: "America/New_York", label: "New York (EST)" },
699
+ { value: "Europe/London", label: "London (GMT)" },
700
+ ],
701
+ },
702
+ },
703
+ ],
704
+ },
705
+ {
706
+ kind: "section",
707
+ id: "features",
708
+ label: "Features",
709
+ children: [
710
+ {
711
+ kind: "field",
712
+ key: "enable_notifications",
713
+ component: "switch",
714
+ props: { label: "Enable Notifications", helpText: "Send email notifications for important events" },
715
+ },
716
+ {
717
+ kind: "field",
718
+ key: "enable_analytics",
719
+ component: "switch",
720
+ props: { label: "Enable Analytics", helpText: "Track user activity and generate reports" },
721
+ },
722
+ {
723
+ kind: "field",
724
+ key: "maintenance_mode",
725
+ component: "switch",
726
+ props: { label: "Maintenance Mode", helpText: "Show maintenance page to non-admin users" },
727
+ },
728
+ ],
729
+ },
730
+ ],
731
+ values: {
732
+ site_name: "My Platform",
733
+ site_url: "https://example.com",
734
+ timezone: "UTC",
735
+ enable_notifications: true,
736
+ enable_analytics: true,
737
+ maintenance_mode: false,
738
+ },
739
+ errors: {},
740
+ meta: { submitLabel: "Save Changes", cancelHref: "#/" },
741
+ },
742
+ },
743
+ ],
744
+ },
745
+ {
746
+ key: "notifications",
747
+ label: "Notifications",
748
+ icon: "bell",
749
+ blocks: [
750
+ {
751
+ key: "notification_links",
752
+ type: "link_list",
753
+ data: {
754
+ items: [
755
+ { label: "Email Templates", href: "#/settings", icon: "mail", description: "Customize email notification templates" },
756
+ { label: "Webhook Endpoints", href: "#/settings", icon: "webhook", description: "Configure webhook delivery endpoints" },
757
+ { label: "Notification Rules", href: "#/settings", icon: "filter", description: "Set up conditional notification routing" },
758
+ ],
759
+ },
760
+ },
761
+ ],
762
+ },
763
+ ],
764
+ },
765
+ },
766
+ ],
767
+ },
768
+ },
769
+ };
770
+ `,
771
+ "src/pages/settings.ts",
772
+ );
773
+ }
774
+ }
775
+
776
+ // ── App files (entry point + router + adapters) ─────────────────────────
777
+
778
+ /**
779
+ * Scaffold src/main.tsx, src/app.tsx, and src/adapters/.
780
+ */
781
+ export function scaffoldAppFiles(targetDir) {
782
+ ensureDir(join(targetDir, "src"));
783
+ ensureDir(join(targetDir, "src", "adapters"));
784
+
785
+ // src/main.tsx — entry point
786
+ const mainPath = join(targetDir, "src", "main.tsx");
787
+ if (!skipIfExists(mainPath, "src/main.tsx")) {
351
788
  writeFile(
352
789
  mainPath,
353
- `import { StrictMode } from "react";
790
+ `/**
791
+ * Entry point \u2014 loaded by index.html during development.
792
+ *
793
+ * In production, your host platform (Moodle/WordPress) loads the UI
794
+ * via Inertia.js and provides its own entry point. This file is
795
+ * only used by the standalone dev server (\`npm run dev\`).
796
+ *
797
+ * Import order matters for CSS:
798
+ * 1. @middag-io/react/style.css \u2014 base tokens + component styles
799
+ * 2. ./theme.css \u2014 your token overrides (wins by cascade)
800
+ * 3. @fontsource-variable/figtree \u2014 font face declarations
801
+ */
802
+ import { StrictMode } from "react";
354
803
  import { createRoot } from "react-dom/client";
355
- import { ContractPage, registerDefaults } from "@middag-io/react";
804
+ import { registerDefaults } from "@middag-io/react";
356
805
  import "@middag-io/react/style.css";
357
- import { helloContract } from "./hello-contract";
806
+ import "./theme.css";
807
+ import "@fontsource-variable/figtree";
808
+ import { App } from "./app";
358
809
 
359
- // Register all default shells, layouts, and blocks
810
+ // Register all default shells, layouts, and blocks.
811
+ // In production, your host entry point calls this same function.
360
812
  registerDefaults();
361
813
 
362
814
  createRoot(document.getElementById("root")!).render(
363
815
  <StrictMode>
364
- <ContractPage contract={helloContract} />
816
+ <App />
365
817
  </StrictMode>,
366
818
  );
367
819
  `,
368
- "mock/main.tsx",
820
+ "src/main.tsx",
369
821
  );
370
822
  }
371
823
 
372
- // mock/index.html
373
- const indexPath = join(mockDir, "index.html");
374
- if (!skipIfExists(indexPath, "mock/index.html")) {
824
+ // src/app.tsx — hash-based page router
825
+ const appPath = join(targetDir, "src", "app.tsx");
826
+ if (!skipIfExists(appPath, "src/app.tsx")) {
375
827
  writeFile(
376
- indexPath,
377
- `<!doctype html>
378
- <html lang="en">
379
- <head>
380
- <meta charset="UTF-8" />
381
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
382
- <title>MIDDAG React UI \u2014 Mock</title>
383
- </head>
384
- <body>
385
- <div id="root"></div>
386
- <script type="module" src="./main.tsx"></script>
387
- </body>
388
- </html>
828
+ appPath,
829
+ `import { useState, useEffect } from "react";
830
+ import { ContractPage } from "@middag-io/react";
831
+ import type { PageContract } from "@middag-io/react";
832
+ import { dashboardContract } from "./pages/dashboard";
833
+ import { connectorsContract } from "./pages/connectors";
834
+ import { settingsContract } from "./pages/settings";
835
+
836
+ /**
837
+ * Route map \u2014 hash fragment to PageContract.
838
+ *
839
+ * In production, Inertia handles routing. This hash-based approach
840
+ * is only for the standalone dev server. Add more pages here as
841
+ * you build them.
842
+ */
843
+ const routes: Record<string, PageContract> = {
844
+ "/": dashboardContract,
845
+ "/connectors": connectorsContract,
846
+ "/settings": settingsContract,
847
+ };
848
+
849
+ function getRoute(): string {
850
+ return window.location.hash.replace("#", "") || "/";
851
+ }
852
+
853
+ export function App() {
854
+ const [route, setRoute] = useState(getRoute);
855
+
856
+ useEffect(() => {
857
+ const onHashChange = () => setRoute(getRoute());
858
+ window.addEventListener("hashchange", onHashChange);
859
+ return () => window.removeEventListener("hashchange", onHashChange);
860
+ }, []);
861
+
862
+ const contract = routes[route] || dashboardContract;
863
+
864
+ // Expose contract for usePage() mock adapter
865
+ (window as any).__MIDDAG_MOCK_CONTRACT__ = contract;
866
+
867
+ return <ContractPage contract={contract} />;
868
+ }
389
869
  `,
390
- "mock/index.html",
870
+ "src/app.tsx",
391
871
  );
392
872
  }
393
873
 
394
- // mock/adapters/inertia-react.ts
395
- const adaptersDir = join(mockDir, "adapters");
396
- ensureDir(adaptersDir);
397
-
398
- const inertiaReactPath = join(adaptersDir, "inertia-react.ts");
399
- if (!skipIfExists(inertiaReactPath, "mock/adapters/inertia-react.ts")) {
874
+ // src/adapters/inertia-react.ts
875
+ const inertiaReactPath = join(targetDir, "src", "adapters", "inertia-react.ts");
876
+ if (!skipIfExists(inertiaReactPath, "src/adapters/inertia-react.ts")) {
400
877
  writeFile(
401
878
  inertiaReactPath,
402
879
  `/**
403
- * Mock @inertiajs/react \u2014 standalone adapter for mock dev server.
880
+ * Mock @inertiajs/react \u2014 standalone adapter for dev server.
881
+ *
404
882
  * Vite alias redirects @inertiajs/react imports here.
883
+ * In production, the real Inertia package handles this.
405
884
  */
406
- import { createElement, forwardRef, useEffect, type ReactNode, type AnchorHTMLAttributes } from "react";
885
+ import React from "react";
407
886
  import { router } from "./inertia-core";
408
887
 
888
+ // \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
889
+
890
+ function getActiveKey(): string {
891
+ const hash = window.location.hash.replace("#", "") || "/";
892
+ const map: Record<string, string> = {
893
+ "/": "overview.dashboard",
894
+ "/connectors": "integration.connectors",
895
+ "/settings": "system.settings",
896
+ };
897
+ return map[hash] || "overview.dashboard";
898
+ }
899
+
900
+ function buildNavigation() {
901
+ const activeKey = getActiveKey();
902
+ const sections = [
903
+ {
904
+ key: "overview",
905
+ label: "Overview",
906
+ icon: "house",
907
+ group: "main" as const,
908
+ items: [
909
+ { key: "overview.dashboard", label: "Dashboard", href: "#/", active: activeKey === "overview.dashboard", children: [] },
910
+ ],
911
+ },
912
+ {
913
+ key: "integration",
914
+ label: "Integration",
915
+ icon: "plug",
916
+ group: "main" as const,
917
+ items: [
918
+ { key: "integration.connectors", label: "Connectors", href: "#/connectors", active: activeKey === "integration.connectors", children: [] },
919
+ ],
920
+ },
921
+ {
922
+ key: "system",
923
+ label: "System",
924
+ icon: "settings",
925
+ group: "system" as const,
926
+ items: [
927
+ { key: "system.settings", label: "Settings", href: "#/settings", active: activeKey === "system.settings", children: [] },
928
+ ],
929
+ },
930
+ ];
931
+ return { sections, activeKey };
932
+ }
933
+
934
+ // \u2500\u2500 usePage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
935
+
409
936
  const mockSharedProps = {
410
- navigation: { sections: [], activeKey: "" },
411
937
  auth: { id: 1, name: "Dev User", email: "dev@localhost", capabilities: [] },
412
938
  theme: { appearance: "light" as const },
413
939
  flash: {},
@@ -416,15 +942,25 @@ const mockSharedProps = {
416
942
  };
417
943
 
418
944
  export function usePage<T = Record<string, unknown>>(): { props: T; url: string } {
419
- return { props: mockSharedProps as T, url: window.location.pathname };
945
+ const contract = typeof window !== "undefined" ? (window as any).__MIDDAG_MOCK_CONTRACT__ : undefined;
946
+ return {
947
+ props: { ...mockSharedProps, navigation: buildNavigation(), contract } as T,
948
+ url: window.location.pathname,
949
+ };
420
950
  }
421
951
 
422
- export function Head({ title, children }: { title?: string; children?: ReactNode }) {
423
- useEffect(() => { if (title) document.title = title; }, [title]);
424
- return children ? createElement("span", { style: { display: "none" } }, children) : null;
952
+ // \u2500\u2500 Head \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
953
+
954
+ export function Head({ title, children }: { title?: string; children?: React.ReactNode }) {
955
+ React.useEffect(() => {
956
+ if (title) document.title = title;
957
+ }, [title]);
958
+ return children ? React.createElement("span", { style: { display: "none" } }, children) : null;
425
959
  }
426
960
 
427
- interface MockLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
961
+ // \u2500\u2500 Link \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
962
+
963
+ interface MockLinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
428
964
  href?: string;
429
965
  method?: string;
430
966
  preserveScroll?: boolean;
@@ -432,7 +968,7 @@ interface MockLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "h
432
968
  as?: string;
433
969
  }
434
970
 
435
- export const Link = forwardRef<HTMLAnchorElement, MockLinkProps>(function MockLink(
971
+ export const Link = React.forwardRef<HTMLAnchorElement, MockLinkProps>(function MockLink(
436
972
  { href, onClick, children, as: _as, method: _m, preserveScroll: _ps, preserveState: _pst, ...rest },
437
973
  ref,
438
974
  ) {
@@ -442,23 +978,25 @@ export const Link = forwardRef<HTMLAnchorElement, MockLinkProps>(function MockLi
442
978
  e.preventDefault();
443
979
  if (href) window.location.hash = href;
444
980
  };
445
- return createElement("a", { ...rest, href: href ?? "#", ref, onClick: handleClick }, children);
981
+ return React.createElement("a", { ...rest, href: href ?? "#", ref, onClick: handleClick }, children);
446
982
  });
447
983
 
448
984
  export { router };
449
985
  `,
450
- "mock/adapters/inertia-react.ts",
986
+ "src/adapters/inertia-react.ts",
451
987
  );
452
988
  }
453
989
 
454
- // mock/adapters/inertia-core.ts
455
- const inertiaCorePathFile = join(adaptersDir, "inertia-core.ts");
456
- if (!skipIfExists(inertiaCorePathFile, "mock/adapters/inertia-core.ts")) {
990
+ // src/adapters/inertia-core.ts
991
+ const inertiaCorePath = join(targetDir, "src", "adapters", "inertia-core.ts");
992
+ if (!skipIfExists(inertiaCorePath, "src/adapters/inertia-core.ts")) {
457
993
  writeFile(
458
- inertiaCorePathFile,
994
+ inertiaCorePath,
459
995
  `/**
460
- * Mock @inertiajs/core \u2014 standalone adapter for mock dev server.
996
+ * Mock @inertiajs/core \u2014 standalone adapter for dev server.
997
+ *
461
998
  * Vite alias redirects @inertiajs/core imports here.
999
+ * In production, the real Inertia package handles this.
462
1000
  */
463
1001
  export const router = {
464
1002
  get: (url: string) => { window.location.hash = url; },
@@ -471,7 +1009,7 @@ export const router = {
471
1009
  on: () => () => {},
472
1010
  };
473
1011
  `,
474
- "mock/adapters/inertia-core.ts",
1012
+ "src/adapters/inertia-core.ts",
475
1013
  );
476
1014
  }
477
1015
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-middag-ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "Bootstrap a MIDDAG React UI layer in your Moodle or WordPress plugin",
6
6
  "bin": {