create-middag-ui 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/cli.js +14 -9
  2. package/lib/scaffold.js +638 -119
  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,10 +85,8 @@ 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",
@@ -128,42 +128,89 @@ export function scaffoldTsconfig(targetDir) {
128
128
  noUnusedLocals: true,
129
129
  noUnusedParameters: true,
130
130
  skipLibCheck: true,
131
- paths: { "@/*": ["./src/*"], "@mock/*": ["./mock/*"] },
131
+ paths: { "@/*": ["./src/*"] },
132
132
  baseUrl: ".",
133
133
  },
134
- include: ["src", "mock"],
134
+ include: ["src"],
135
135
  };
136
136
 
137
137
  writeFile(filePath, JSON.stringify(tsconfig, null, 2) + "\n", "tsconfig.json");
138
138
  }
139
139
 
140
140
  /**
141
- * Scaffold vite.mock.config.ts.
141
+ * Scaffold vite.config.ts.
142
142
  */
143
143
  export function scaffoldViteConfig(targetDir, host) {
144
- const filePath = join(targetDir, "vite.mock.config.ts");
145
- if (skipIfExists(filePath, "vite.mock.config.ts")) return;
144
+ const filePath = join(targetDir, "vite.config.ts");
145
+ if (skipIfExists(filePath, "vite.config.ts")) return;
146
146
 
147
- 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";
148
156
  import react from "@vitejs/plugin-react";
149
157
  import { resolve } from "path";
150
158
 
151
159
  export default defineConfig({
152
160
  plugins: [react()],
153
- root: "mock",
154
161
  server: { port: ${host.port} },
155
162
  resolve: {
156
163
  alias: {
164
+ // Path alias \u2014 import from "@/components/..." resolves to src/
157
165
  "@/": resolve(__dirname, "src") + "/",
158
- "@mock/": resolve(__dirname, "mock") + "/",
159
- "@inertiajs/react": resolve(__dirname, "mock/adapters/inertia-react.ts"),
160
- "@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"),
161
169
  },
162
170
  },
163
171
  });
164
172
  `;
165
173
 
166
- 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
+ );
167
214
  }
168
215
 
169
216
  // ── Demo files in src/ ──────────────────────────────────────────────────
@@ -270,70 +317,197 @@ export type { PageContract, BlockDescriptor, SharedProps } from "@middag-io/reac
270
317
  "src/contracts.ts",
271
318
  );
272
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;
273
353
  }
354
+ */
274
355
 
275
- // ── 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
+ */
276
364
 
277
- /**
278
- * 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)
279
402
  */
280
- export function scaffoldMockFiles(targetDir) {
281
- const mockDir = join(targetDir, "mock");
282
- ensureDir(mockDir);
283
403
 
284
- // mock/hello-contract.ts
285
- const helloContractPath = join(mockDir, "hello-contract.ts");
286
- if (!skipIfExists(helloContractPath, "mock/hello-contract.ts")) {
287
- writeFile(
288
- helloContractPath,
289
- `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 ──────────────────────────────────────────────
290
420
 
291
421
  /**
292
- * 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)
293
438
  *
294
- * This is what your backend will send via Inertia.
295
- * Replace this with real data from your server.
439
+ * Layout regions used: metrics, content
296
440
  */
297
- export const helloContract: PageContract = {
441
+ import type { PageContract } from "@middag-io/react";
442
+
443
+ export const dashboardContract: PageContract = {
298
444
  version: "1",
299
445
  shell: "product",
300
446
  page: {
301
- key: "hello",
302
- title: "Hello MIDDAG",
303
- breadcrumbs: [
304
- { label: "Home", href: "/" },
305
- { label: "Hello", href: "/hello" },
306
- ],
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: [
455
+ {
456
+ key: "total_users",
457
+ type: "metric_card",
458
+ data: {
459
+ label: "Total Users",
460
+ value: "1,284",
461
+ delta: "+12%",
462
+ deltaDirection: "positive",
463
+ icon: "users",
464
+ },
465
+ },
312
466
  {
313
- key: "welcome_metrics",
467
+ key: "active_sessions",
314
468
  type: "metric_card",
315
469
  data: {
316
- title: "Setup Complete",
317
- value: "1",
318
- subtitle: "@middag-io/react is working",
470
+ label: "Active Sessions",
471
+ value: "342",
472
+ delta: "+5%",
473
+ deltaDirection: "positive",
474
+ icon: "activity",
319
475
  },
320
476
  },
321
477
  {
322
- key: "hello_table",
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",
324
494
  data: {
325
- title: "Example Data",
326
495
  columns: [
327
- { key: "id", label: "ID", sortable: true },
328
- { key: "name", label: "Name", sortable: true },
329
- { key: "status", label: "Status" },
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" },
330
500
  ],
331
501
  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" },
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" } },
335
507
  ],
336
- pagination: { page: 1, totalPages: 1, perPage: 10, totalRows: 3 },
508
+ pagination: { page: 1, perPage: 10, total: 5, lastPage: 1 },
509
+ sort: { column: "date", direction: "desc" },
510
+ filters: { available: [], applied: {} },
337
511
  },
338
512
  },
339
513
  ],
@@ -341,91 +515,425 @@ export const helloContract: PageContract = {
341
515
  },
342
516
  };
343
517
  `,
344
- "mock/hello-contract.ts",
518
+ "src/pages/dashboard.ts",
345
519
  );
346
520
  }
347
521
 
348
- // mock/main.tsx
349
- const mainPath = join(mockDir, "main.tsx");
350
- if (!skipIfExists(mainPath, "mock/main.tsx")) {
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",
561
+ data: {
562
+ variant: "connector",
563
+ columns: [
564
+ { key: "name", label: "Name" },
565
+ { key: "type", label: "Type" },
566
+ { key: "status", label: "Status", kind: "status" },
567
+ ],
568
+ rows: [
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
+ },
610
+ ],
611
+ },
612
+ },
613
+ ],
614
+ },
615
+ },
616
+ };
617
+ `,
618
+ "src/pages/connectors.ts",
619
+ );
620
+ }
621
+
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";
806
+ import "./theme.css";
357
807
  import "@fontsource-variable/figtree";
358
- import { helloContract } from "./hello-contract";
808
+ import { App } from "./app";
359
809
 
360
- // Make contract available to usePage() mock
361
- (window as any).__MIDDAG_MOCK_CONTRACT__ = helloContract;
362
-
363
- // 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.
364
812
  registerDefaults();
365
813
 
366
814
  createRoot(document.getElementById("root")!).render(
367
815
  <StrictMode>
368
- <ContractPage contract={helloContract} />
816
+ <App />
369
817
  </StrictMode>,
370
818
  );
371
819
  `,
372
- "mock/main.tsx",
820
+ "src/main.tsx",
373
821
  );
374
822
  }
375
823
 
376
- // mock/index.html
377
- const indexPath = join(mockDir, "index.html");
378
- 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")) {
379
827
  writeFile(
380
- indexPath,
381
- `<!doctype html>
382
- <html lang="en">
383
- <head>
384
- <meta charset="UTF-8" />
385
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
386
- <title>MIDDAG React UI \u2014 Mock</title>
387
- </head>
388
- <body>
389
- <div id="root" class="middag-root"></div>
390
- <div id="middag-portals" class="middag-root"></div>
391
- <script type="module" src="./main.tsx"></script>
392
- </body>
393
- </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
+ }
394
869
  `,
395
- "mock/index.html",
870
+ "src/app.tsx",
396
871
  );
397
872
  }
398
873
 
399
- // mock/adapters/inertia-react.ts
400
- const adaptersDir = join(mockDir, "adapters");
401
- ensureDir(adaptersDir);
402
-
403
- const inertiaReactPath = join(adaptersDir, "inertia-react.ts");
404
- 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")) {
405
877
  writeFile(
406
878
  inertiaReactPath,
407
879
  `/**
408
- * Mock @inertiajs/react \u2014 standalone adapter for mock dev server.
880
+ * Mock @inertiajs/react \u2014 standalone adapter for dev server.
881
+ *
409
882
  * Vite alias redirects @inertiajs/react imports here.
883
+ * In production, the real Inertia package handles this.
410
884
  */
411
- import { createElement, forwardRef, useEffect, type ReactNode, type AnchorHTMLAttributes } from "react";
885
+ import React from "react";
412
886
  import { router } from "./inertia-core";
413
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
+
414
936
  const mockSharedProps = {
415
- navigation: {
416
- sections: [
417
- {
418
- key: "home",
419
- label: "Home",
420
- icon: "house",
421
- group: "main",
422
- items: [
423
- { key: "home.dashboard", label: "Dashboard", href: "/", active: true, children: [] },
424
- ],
425
- },
426
- ],
427
- activeKey: "home.dashboard",
428
- },
429
937
  auth: { id: 1, name: "Dev User", email: "dev@localhost", capabilities: [] },
430
938
  theme: { appearance: "light" as const },
431
939
  flash: {},
@@ -435,15 +943,24 @@ const mockSharedProps = {
435
943
 
436
944
  export function usePage<T = Record<string, unknown>>(): { props: T; url: string } {
437
945
  const contract = typeof window !== "undefined" ? (window as any).__MIDDAG_MOCK_CONTRACT__ : undefined;
438
- return { props: { ...mockSharedProps, contract } as T, url: window.location.pathname };
946
+ return {
947
+ props: { ...mockSharedProps, navigation: buildNavigation(), contract } as T,
948
+ url: window.location.pathname,
949
+ };
439
950
  }
440
951
 
441
- export function Head({ title, children }: { title?: string; children?: ReactNode }) {
442
- useEffect(() => { if (title) document.title = title; }, [title]);
443
- 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;
444
959
  }
445
960
 
446
- 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"> {
447
964
  href?: string;
448
965
  method?: string;
449
966
  preserveScroll?: boolean;
@@ -451,7 +968,7 @@ interface MockLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "h
451
968
  as?: string;
452
969
  }
453
970
 
454
- export const Link = forwardRef<HTMLAnchorElement, MockLinkProps>(function MockLink(
971
+ export const Link = React.forwardRef<HTMLAnchorElement, MockLinkProps>(function MockLink(
455
972
  { href, onClick, children, as: _as, method: _m, preserveScroll: _ps, preserveState: _pst, ...rest },
456
973
  ref,
457
974
  ) {
@@ -461,23 +978,25 @@ export const Link = forwardRef<HTMLAnchorElement, MockLinkProps>(function MockLi
461
978
  e.preventDefault();
462
979
  if (href) window.location.hash = href;
463
980
  };
464
- return createElement("a", { ...rest, href: href ?? "#", ref, onClick: handleClick }, children);
981
+ return React.createElement("a", { ...rest, href: href ?? "#", ref, onClick: handleClick }, children);
465
982
  });
466
983
 
467
984
  export { router };
468
985
  `,
469
- "mock/adapters/inertia-react.ts",
986
+ "src/adapters/inertia-react.ts",
470
987
  );
471
988
  }
472
989
 
473
- // mock/adapters/inertia-core.ts
474
- const inertiaCorePathFile = join(adaptersDir, "inertia-core.ts");
475
- 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")) {
476
993
  writeFile(
477
- inertiaCorePathFile,
994
+ inertiaCorePath,
478
995
  `/**
479
- * Mock @inertiajs/core \u2014 standalone adapter for mock dev server.
996
+ * Mock @inertiajs/core \u2014 standalone adapter for dev server.
997
+ *
480
998
  * Vite alias redirects @inertiajs/core imports here.
999
+ * In production, the real Inertia package handles this.
481
1000
  */
482
1001
  export const router = {
483
1002
  get: (url: string) => { window.location.hash = url; },
@@ -490,7 +1009,7 @@ export const router = {
490
1009
  on: () => () => {},
491
1010
  };
492
1011
  `,
493
- "mock/adapters/inertia-core.ts",
1012
+ "src/adapters/inertia-core.ts",
494
1013
  );
495
1014
  }
496
1015
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-middag-ui",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Bootstrap a MIDDAG React UI layer in your Moodle or WordPress plugin",
6
6
  "bin": {