@madojs/mado 0.5.0 → 0.6.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 (122) hide show
  1. package/AGENTS.md +49 -1
  2. package/CHANGELOG.md +188 -0
  3. package/MADO_V1_PLAN.md +179 -0
  4. package/README.md +53 -14
  5. package/ROADMAP.md +36 -5
  6. package/TODO.md +72 -0
  7. package/dist/src/forms.d.ts +41 -7
  8. package/dist/src/forms.js +334 -59
  9. package/dist/src/forms.js.map +1 -1
  10. package/dist/src/html/bindings.d.ts +41 -0
  11. package/dist/src/html/bindings.js +163 -6
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/html.d.ts +2 -0
  14. package/dist/src/html.js +1 -0
  15. package/dist/src/html.js.map +1 -1
  16. package/dist/src/index.d.ts +6 -6
  17. package/dist/src/index.js +2 -2
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/page.d.ts +56 -0
  20. package/dist/src/page.js +17 -0
  21. package/dist/src/page.js.map +1 -1
  22. package/dist/src/router/manifest.d.ts +16 -1
  23. package/dist/src/router/manifest.js +181 -38
  24. package/dist/src/router/manifest.js.map +1 -1
  25. package/dist/src/router/match.d.ts +7 -2
  26. package/dist/src/router/match.js +14 -4
  27. package/dist/src/router/match.js.map +1 -1
  28. package/dist/src/router/navigation.d.ts +10 -0
  29. package/dist/src/router/navigation.js +73 -12
  30. package/dist/src/router/navigation.js.map +1 -1
  31. package/dist/src/signal.d.ts +15 -1
  32. package/dist/src/signal.js +112 -16
  33. package/dist/src/signal.js.map +1 -1
  34. package/docs/en/02-project-layout.md +99 -40
  35. package/docs/en/05-why-mado.md +1 -1
  36. package/docs/en/06-for-backenders.md +1 -1
  37. package/docs/en/07-llm-pitfalls.md +1 -1
  38. package/docs/en/09-shadow-vs-light-dom.md +60 -0
  39. package/docs/en/10-app-architecture.md +141 -0
  40. package/docs/en/11-layouts.md +115 -0
  41. package/docs/en/12-auth-and-api.md +217 -0
  42. package/docs/en/13-deployment.md +192 -0
  43. package/docs/en/14-testing.md +82 -0
  44. package/docs/en/15-error-handling.md +100 -0
  45. package/docs/en/16-bake-cookbook.md +93 -0
  46. package/docs/en/README.md +7 -0
  47. package/docs/fr/05-why-mado.md +1 -1
  48. package/docs/fr/06-for-backenders.md +1 -1
  49. package/docs/fr/07-llm-pitfalls.md +1 -1
  50. package/docs/fr/09-shadow-vs-light-dom.md +63 -0
  51. package/docs/fr/10-app-architecture.md +61 -0
  52. package/docs/fr/11-layouts.md +35 -0
  53. package/docs/fr/12-auth-and-api.md +35 -0
  54. package/docs/fr/13-deployment.md +39 -0
  55. package/docs/fr/14-testing.md +41 -0
  56. package/docs/fr/15-error-handling.md +50 -0
  57. package/docs/fr/16-bake-cookbook.md +35 -0
  58. package/docs/fr/README.md +7 -0
  59. package/docs/ru/05-why-mado.md +2 -2
  60. package/docs/ru/06-for-backenders.md +1 -1
  61. package/docs/ru/09-shadow-vs-light-dom.md +60 -0
  62. package/docs/ru/10-app-architecture.md +100 -0
  63. package/docs/ru/11-layouts.md +47 -0
  64. package/docs/ru/12-auth-and-api.md +53 -0
  65. package/docs/ru/13-deployment.md +60 -0
  66. package/docs/ru/14-testing.md +50 -0
  67. package/docs/ru/15-error-handling.md +56 -0
  68. package/docs/ru/16-bake-cookbook.md +55 -0
  69. package/docs/ru/README.md +7 -0
  70. package/docs/uk/06-for-backenders.md +2 -2
  71. package/docs/uk/09-shadow-vs-light-dom.md +91 -24
  72. package/docs/uk/10-app-architecture.md +56 -0
  73. package/docs/uk/11-layouts.md +34 -0
  74. package/docs/uk/12-auth-and-api.md +34 -0
  75. package/docs/uk/13-deployment.md +39 -0
  76. package/docs/uk/14-testing.md +34 -0
  77. package/docs/uk/15-error-handling.md +32 -0
  78. package/docs/uk/16-bake-cookbook.md +36 -0
  79. package/docs/uk/README.md +7 -0
  80. package/llms.txt +24 -1
  81. package/package.json +3 -1
  82. package/scripts/_config.mjs +224 -0
  83. package/scripts/bake.mjs +217 -120
  84. package/scripts/bundle.mjs +110 -67
  85. package/scripts/cli.mjs +127 -16
  86. package/scripts/preview.mjs +22 -12
  87. package/server/serve.mjs +101 -11
  88. package/starters/admin/README.md +63 -0
  89. package/starters/admin/index.html +21 -0
  90. package/starters/admin/mado.config.json +22 -0
  91. package/starters/admin/package.json +22 -0
  92. package/starters/admin/public/favicon.svg +4 -0
  93. package/starters/admin/src/components/x-button.ts +55 -0
  94. package/starters/admin/src/components/x-input.ts +74 -0
  95. package/starters/admin/src/layouts/app.ts +101 -0
  96. package/starters/admin/src/layouts/auth.ts +41 -0
  97. package/starters/admin/src/lib/api.ts +133 -0
  98. package/starters/admin/src/lib/auth.ts +83 -0
  99. package/starters/admin/src/main.ts +15 -0
  100. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  101. package/starters/admin/src/pages/admin/order-detail.ts +78 -0
  102. package/starters/admin/src/pages/admin/orders.ts +117 -0
  103. package/starters/admin/src/pages/home.ts +25 -0
  104. package/starters/admin/src/pages/login.ts +70 -0
  105. package/starters/admin/src/pages/not-found.ts +12 -0
  106. package/starters/admin/src/routes.ts +40 -0
  107. package/starters/admin/src/styles/global.ts +86 -0
  108. package/starters/admin/tsconfig.json +15 -0
  109. package/starters/crud/README.md +14 -2
  110. package/starters/crud/mado.config.json +20 -0
  111. package/starters/crud/package.json +9 -4
  112. package/starters/crud/src/components/app-shell.ts +13 -8
  113. package/starters/crud/src/main.ts +1 -4
  114. package/starters/crud/src/pages/ticket-detail.ts +1 -0
  115. package/starters/crud/src/pages/ticket-new.ts +1 -0
  116. package/starters/crud/src/pages/tickets.ts +1 -0
  117. package/starters/crud/src/routes.ts +4 -2
  118. package/starters/minimal/README.md +4 -2
  119. package/starters/minimal/mado.config.json +20 -0
  120. package/starters/minimal/package.json +8 -3
  121. package/starters/minimal/src/components/app-counter.ts +1 -1
  122. package/starters/minimal/src/routes.ts +4 -2
package/server/serve.mjs CHANGED
@@ -6,19 +6,36 @@
6
6
  // node server/serve.mjs basic # mount examples/basic/ at /
7
7
  // EXAMPLE=showcase node server/serve.mjs # mount examples/showcase/ at /
8
8
  //
9
- // Without EXAMPLE, / serves examples/index.html (example index).
9
+ // Without EXAMPLE, / serves examples/index.html when running inside the Mado
10
+ // repository, or ./index.html when running inside a generated app.
10
11
  // With EXAMPLE, all extensionless and /index.html requests fall back to
11
12
  // examples/<EXAMPLE>/index.html so the client router works from root, just
12
13
  // like a production SPA deploy.
13
14
 
14
- import { createServer } from "node:http";
15
- import { readFile, readdir, stat } from "node:fs/promises";
16
- import { watch, existsSync } from "node:fs";
15
+ import { createServer, request as httpRequest } from "node:http";
16
+ import { request as httpsRequest } from "node:https";
17
+ import { readFile, readdir, readFile as readFileAsync, stat } from "node:fs/promises";
18
+ import { watch, existsSync, readFileSync } from "node:fs";
17
19
  import { extname, join, resolve, sep } from "node:path";
18
20
  import { createHash } from "node:crypto";
19
21
 
20
22
  const ROOT = resolve(process.cwd());
21
- const PORT = Number(process.env.PORT ?? 5173);
23
+
24
+ // Optional mado.config.json — used for dev.proxy and dev.port. Read with a
25
+ // hand-rolled JSON parse to avoid a circular dep with scripts/_config.mjs
26
+ // (this server is launched from cli.mjs and runs in its own Node process).
27
+ const CONFIG = (() => {
28
+ try {
29
+ const file = join(ROOT, "mado.config.json");
30
+ if (!existsSync(file)) return {};
31
+ return JSON.parse(readFileSync(file, "utf8")) ?? {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ })();
36
+ const PROXY_RULES = Object.entries(CONFIG.dev?.proxy ?? {}); // [["/api", "http://localhost:3000"], ...]
37
+
38
+ const PORT = Number(process.env.PORT ?? CONFIG.dev?.port ?? 5173);
22
39
  const HMR = process.env.NO_HMR !== "1";
23
40
 
24
41
  const EXAMPLE = process.argv[2] ?? process.env.MADO_EXAMPLE ?? process.env.EXAMPLE ?? "";
@@ -26,6 +43,9 @@ const EXAMPLE_DIR = EXAMPLE
26
43
  ? resolve(join(ROOT, "examples", EXAMPLE))
27
44
  : "";
28
45
  const EXAMPLE_INDEX = EXAMPLE ? join(EXAMPLE_DIR, "index.html") : "";
46
+ const EXAMPLES_INDEX = join(ROOT, "examples", "index.html");
47
+ const APP_INDEX = join(ROOT, "index.html");
48
+ const DEFAULT_INDEX = existsSync(EXAMPLES_INDEX) ? EXAMPLES_INDEX : APP_INDEX;
29
49
 
30
50
  if (EXAMPLE) {
31
51
  if (!existsSync(EXAMPLE_INDEX)) {
@@ -114,6 +134,16 @@ const server = createServer(async (req, res) => {
114
134
  const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
115
135
  pathname = decodeURIComponent(url.pathname);
116
136
 
137
+ // Dev proxy: forward matching prefixes to an upstream backend, so the
138
+ // browser can reach the SPA and the API on a single origin without CORS.
139
+ const proxyRule = PROXY_RULES.find(([prefix]) => pathname.startsWith(prefix));
140
+ if (proxyRule) {
141
+ const [prefix, upstream] = proxyRule;
142
+ await proxyForward({ req, res, prefix, upstream, pathname, search: url.search });
143
+ reason = `proxy → ${upstream}`;
144
+ return;
145
+ }
146
+
117
147
  // SSE endpoint for HMR.
118
148
  if (pathname === "/__hmr") {
119
149
  res.writeHead(200, {
@@ -133,9 +163,7 @@ const server = createServer(async (req, res) => {
133
163
 
134
164
  // A mounted example owns root and SPA fallback. Otherwise serve the
135
165
  // examples index page.
136
- const fallbackIndex = EXAMPLE
137
- ? EXAMPLE_INDEX
138
- : join(ROOT, "examples", "index.html");
166
+ const fallbackIndex = EXAMPLE ? EXAMPLE_INDEX : DEFAULT_INDEX;
139
167
 
140
168
  if (pathname === "/") {
141
169
  // Resolved through fallback below.
@@ -260,6 +288,10 @@ async function buildPreloadHints() {
260
288
  }
261
289
  }
262
290
  }
291
+ } else if (!existsSync(EXAMPLES_INDEX)) {
292
+ if (existsSync(join(ROOT, "dist/main.js"))) {
293
+ hrefs.push("/dist/main.js");
294
+ }
263
295
  }
264
296
  cachedPreloadHints = hrefs
265
297
  .map((h) => ` <link rel="modulepreload" href="${h}">`)
@@ -273,17 +305,75 @@ server.on("error", (err) => {
273
305
  process.exit(1);
274
306
  });
275
307
 
308
+ async function proxyForward({ req, res, prefix, upstream, pathname, search }) {
309
+ // Strip the prefix only if the upstream URL itself ends with `/`; otherwise
310
+ // forward the full pathname so the backend sees /api/...
311
+ let upstreamUrl;
312
+ try {
313
+ upstreamUrl = new URL(upstream);
314
+ } catch {
315
+ res.writeHead(502).end(`bad upstream: ${upstream}`);
316
+ return;
317
+ }
318
+ const target = new URL(upstream);
319
+ // Compose path: <upstream.pathname rstrip "/"> + <pathname> + <search>
320
+ const tail = pathname; // keep the original /api/... so backends route normally
321
+ target.pathname = (target.pathname.replace(/\/$/, "")) + tail;
322
+ target.search = search;
323
+
324
+ const lib = target.protocol === "https:" ? httpsRequest : httpRequest;
325
+ const upstreamReq = lib(
326
+ target,
327
+ {
328
+ method: req.method,
329
+ headers: {
330
+ ...req.headers,
331
+ host: target.host,
332
+ },
333
+ },
334
+ (upstreamRes) => {
335
+ // Forward status and headers, then pipe the body.
336
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
337
+ upstreamRes.pipe(res);
338
+ },
339
+ );
340
+ upstreamReq.on("error", (err) => {
341
+ console.error(`[serve] proxy error for ${pathname} → ${target.href}:`, err.message);
342
+ if (!res.headersSent) {
343
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
344
+ res.end(`proxy upstream unavailable: ${target.host}\n${err.message}`);
345
+ } else {
346
+ res.end();
347
+ }
348
+ });
349
+ req.pipe(upstreamReq);
350
+ // Reference unused arg so lint is happy.
351
+ void prefix;
352
+ }
353
+
276
354
  server.listen(PORT, () => {
277
- const distReady = existsSync(join(ROOT, "dist/src/index.js"));
355
+ const distReady = existsSync(join(ROOT, "dist/src/index.js"))
356
+ || existsSync(join(ROOT, "dist/main.js"));
357
+ const mount = EXAMPLE
358
+ ? `examples/${EXAMPLE}/ -> /`
359
+ : existsSync(EXAMPLES_INDEX)
360
+ ? "examples/index.html landing"
361
+ : "index.html app";
278
362
  console.log("");
279
363
  console.log("Mado dev server");
280
364
  console.log(` url: http://localhost:${PORT}/`);
281
365
  console.log(` root: ${ROOT}`);
282
- console.log(` example: ${EXAMPLE ? `examples/${EXAMPLE}/ -> /` : "examples/index.html landing"}`);
366
+ console.log(` mount: ${mount}`);
283
367
  console.log(` hmr: ${HMR ? "on" : "off"}`);
284
368
  console.log(` preload: ${PRELOAD}`);
285
369
  console.log(` dist: ${distReady ? "ready" : "missing (run mado build)"}`);
286
- if (!EXAMPLE) {
370
+ if (PROXY_RULES.length > 0) {
371
+ console.log(" proxy:");
372
+ for (const [prefix, upstream] of PROXY_RULES) {
373
+ console.log(` ${prefix.padEnd(10)} → ${upstream}`);
374
+ }
375
+ }
376
+ if (!EXAMPLE && existsSync(EXAMPLES_INDEX)) {
287
377
  console.log(" try: mado serve basic");
288
378
  console.log(" mado serve showcase");
289
379
  console.log(" mado serve tickets");
@@ -0,0 +1,63 @@
1
+ # __APP_NAME__
2
+
3
+ A starter Mado admin app: nested routes, a guarded admin shell, a blessed API
4
+ client, and a one-shot release pipeline.
5
+
6
+ ## What you get
7
+
8
+ - `src/main.ts` — 8 lines: mount the router into `#app`. Layouts are NOT
9
+ declared here, only in `src/routes.ts`.
10
+ - `src/routes.ts` — nested manifest with three groups:
11
+ - `/` → public landing (bakeable),
12
+ - `/login` → centered `auth` layout,
13
+ - `/admin` → `app` layout, **guarded** by `requireAuth`.
14
+ - `src/layouts/app.ts` — admin shell (top bar + sidebar + content slot).
15
+ - `src/layouts/auth.ts` — centered card for sign-in.
16
+ - `src/lib/api.ts` — `createApiClient(baseUrl)` with bearer token, 401-refresh
17
+ retry, JSON in/out and a typed `ApiError`.
18
+ - `src/lib/auth.ts` — memory-only `accessToken`, `restoreSession()` from an
19
+ HttpOnly refresh cookie, and the `requireAuth` guard.
20
+ - `src/components/` — tiny `x-button` and `x-input` Web Components.
21
+ - `mado.config.json` — one config file. Includes a `dev.proxy` for `/api`.
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ npm run dev # tsc -w + dev server on http://localhost:5173, HMR on
27
+ npm run build # tsc → dist/
28
+ npm run typecheck # tsc --noEmit
29
+ npm run bundle # esbuild → out/assets/
30
+ npm run bake # prerender baked routes → out/baked/
31
+ npm run release # typecheck + build + bundle + bake + copy public/ → out/
32
+ npm run preview # serve out/ locally (production rehearsal)
33
+ ```
34
+
35
+ To deploy, run `npm run release` and upload the entire `out/` directory
36
+ anywhere static (nginx, Cloudflare Pages, S3, Netlify, GitHub Pages, …).
37
+
38
+ ## Backend expectations
39
+
40
+ The blessed `api` client speaks JSON. The auth recipe expects:
41
+
42
+ - `POST /api/auth/login` → `{ accessToken: string }` (sets refresh cookie)
43
+ - `POST /api/auth/refresh` → `{ accessToken: string }` (reads refresh cookie)
44
+ - `POST /api/auth/logout` → 204 (clears refresh cookie)
45
+
46
+ Change `mado.config.json#dev.proxy` to point at your backend in development.
47
+
48
+ ## Where things live
49
+
50
+ | What | Where |
51
+ |---------------------|--------------------------------------|
52
+ | New URL | `src/pages/*.ts` + add to `routes.ts`|
53
+ | New protected URL | inside the `/admin` layout block |
54
+ | New layout | `src/layouts/*.ts` |
55
+ | New reusable widget | `src/components/x-*.ts` |
56
+ | New API call | `src/lib/api.ts` (add a method) |
57
+ | New global signal | `src/lib/<name>.ts` |
58
+ | Static image | `public/<file>` |
59
+
60
+ See the framework docs:
61
+ [`docs/en/11-layouts.md`](https://github.com/madojs/mado/blob/main/docs/en/11-layouts.md),
62
+ [`docs/en/12-auth-and-api.md`](https://github.com/madojs/mado/blob/main/docs/en/12-auth-and-api.md),
63
+ [`docs/en/13-deployment.md`](https://github.com/madojs/mado/blob/main/docs/en/13-deployment.md).
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>__APP_NAME__</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+ <script type="importmap">
9
+ {
10
+ "imports": {
11
+ "@madojs/mado": "./node_modules/@madojs/mado/dist/src/index.js",
12
+ "@madojs/mado/": "./node_modules/@madojs/mado/dist/src/"
13
+ }
14
+ }
15
+ </script>
16
+ </head>
17
+ <body>
18
+ <div id="app"></div>
19
+ <script type="module" src="./dist/main.js"></script>
20
+ </body>
21
+ </html>
@@ -0,0 +1,22 @@
1
+ {
2
+ "dev": {
3
+ "port": 5173,
4
+ "proxy": {
5
+ "/api": "http://localhost:3000"
6
+ }
7
+ },
8
+ "build": {
9
+ "out": "out",
10
+ "dist": "dist",
11
+ "publicDir": "public"
12
+ },
13
+ "bake": {
14
+ "entry": "src/routes.ts",
15
+ "template": "index.html",
16
+ "baseUrl": "https://example.com"
17
+ },
18
+ "bundle": {
19
+ "splitting": true,
20
+ "compress": ["gz", "br"]
21
+ }
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "__PACKAGE_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "mado build",
8
+ "typecheck": "mado typecheck",
9
+ "dev": "mado dev",
10
+ "serve": "mado serve",
11
+ "bundle": "mado bundle",
12
+ "bake": "mado bake",
13
+ "release": "mado release",
14
+ "preview": "mado preview"
15
+ },
16
+ "dependencies": {
17
+ "@madojs/mado": "__MADOJS_VERSION__"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^6.0.3"
21
+ }
22
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="6" fill="#1f6feb"/>
3
+ <text x="16" y="22" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-weight="700" font-size="18" fill="white">M</text>
4
+ </svg>
@@ -0,0 +1,55 @@
1
+ // <x-button variant="primary|ghost|danger" ?disabled>
2
+ //
3
+ // Wraps a native <button> so it can be slotted with text/icon and styled
4
+ // consistently across the app. Click events bubble naturally because Shadow
5
+ // DOM is `mode: open` and composed: true is the default for `click`.
6
+
7
+ import { component, css, html } from "@madojs/mado";
8
+
9
+ component(
10
+ "x-button",
11
+ ({ host }) => () => {
12
+ const variant = host.getAttribute("variant") ?? "primary";
13
+ const disabled = host.hasAttribute("disabled");
14
+ return html`
15
+ <button data-variant=${variant} ?disabled=${disabled}>
16
+ <slot></slot>
17
+ </button>
18
+ `;
19
+ },
20
+ {
21
+ styles: css`
22
+ :host { display: inline-flex; }
23
+ button {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ gap: var(--space-2);
27
+ padding: 8px 14px;
28
+ border-radius: var(--radius-sm);
29
+ border: 1px solid transparent;
30
+ font: inherit;
31
+ cursor: pointer;
32
+ background: var(--accent);
33
+ color: var(--accent-fg);
34
+ transition: filter .12s ease;
35
+ }
36
+ button:hover:not(:disabled) { filter: brightness(1.07); }
37
+ button:active:not(:disabled) { filter: brightness(.95); }
38
+ button:disabled { opacity: .55; cursor: not-allowed; }
39
+
40
+ button[data-variant="ghost"] {
41
+ background: transparent;
42
+ color: var(--fg);
43
+ border-color: var(--border);
44
+ }
45
+ button[data-variant="ghost"]:hover:not(:disabled) {
46
+ background: var(--bg-elevated);
47
+ }
48
+
49
+ button[data-variant="danger"] {
50
+ background: var(--danger);
51
+ color: white;
52
+ }
53
+ `,
54
+ },
55
+ );
@@ -0,0 +1,74 @@
1
+ // <x-input label name type placeholder required value @input @blur>
2
+ //
3
+ // Labeled input that proxies its events. Use inside `useForm()`:
4
+ //
5
+ // <x-input name="email" type="email" required
6
+ // @input=${form.onInput} @blur=${form.onBlur}></x-input>
7
+
8
+ import { component, css, html } from "@madojs/mado";
9
+
10
+ component(
11
+ "x-input",
12
+ ({ host }) => () => {
13
+ const label = host.getAttribute("label") ?? "";
14
+ const name = host.getAttribute("name") ?? "";
15
+ const type = host.getAttribute("type") ?? "text";
16
+ const placeholder = host.getAttribute("placeholder") ?? "";
17
+ const required = host.hasAttribute("required");
18
+ const value = host.getAttribute("value") ?? "";
19
+ const error = host.getAttribute("error");
20
+
21
+ return html`
22
+ <label>
23
+ ${label
24
+ ? html`<span class="lbl">${label}${required ? html`<em>*</em>` : null}</span>`
25
+ : null}
26
+ <input
27
+ name=${name}
28
+ type=${type}
29
+ placeholder=${placeholder}
30
+ ?required=${required}
31
+ .value=${value}
32
+ >
33
+ ${error ? html`<small class="err">${error}</small>` : null}
34
+ </label>
35
+ `;
36
+ },
37
+ {
38
+ styles: css`
39
+ :host { display: block; }
40
+ label { display: block; }
41
+ .lbl {
42
+ display: block;
43
+ font-size: 12px;
44
+ color: var(--fg-muted);
45
+ margin-bottom: var(--space-1);
46
+ }
47
+ .lbl em {
48
+ color: var(--danger);
49
+ font-style: normal;
50
+ margin-left: 2px;
51
+ }
52
+ input {
53
+ width: 100%;
54
+ padding: 8px 10px;
55
+ font: inherit;
56
+ background: var(--bg);
57
+ color: var(--fg);
58
+ border: 1px solid var(--border);
59
+ border-radius: var(--radius-sm);
60
+ }
61
+ input:focus {
62
+ outline: none;
63
+ border-color: var(--accent);
64
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
65
+ }
66
+ .err {
67
+ display: block;
68
+ color: var(--danger);
69
+ font-size: 12px;
70
+ margin-top: var(--space-1);
71
+ }
72
+ `,
73
+ },
74
+ );
@@ -0,0 +1,101 @@
1
+ // Admin shell layout: top bar + sidebar + content slot.
2
+ //
3
+ // A layout is just a `page({ view })` whose view renders `${ctx.child}`. The
4
+ // router wraps every page in the enclosing group with this template, so the
5
+ // shell is ALWAYS the outer frame, never below the page content.
6
+ //
7
+ // Keep the shell stateless if you can. Auth/user signals belong in
8
+ // src/lib/auth.ts; the layout reads them.
9
+
10
+ import { component, css, html, navigate, page } from "@madojs/mado";
11
+ import { accessToken } from "../lib/api.js";
12
+ import { logout } from "../lib/auth.js";
13
+ import "../components/x-button.js";
14
+
15
+ component(
16
+ "x-admin-shell",
17
+ () => () => html`
18
+ <header>
19
+ <a href="/admin" data-link class="brand">__APP_NAME__</a>
20
+ <nav>
21
+ <a href="/admin" data-link>Dashboard</a>
22
+ <a href="/admin/orders" data-link>Orders</a>
23
+ </nav>
24
+ <span class="spacer"></span>
25
+ <span class="user">${() => maskToken(accessToken())}</span>
26
+ <x-button
27
+ variant="ghost"
28
+ @click=${async () => {
29
+ await logout();
30
+ navigate("/login", { replace: true });
31
+ }}
32
+ >Sign out</x-button>
33
+ </header>
34
+ <aside>
35
+ <nav>
36
+ <a href="/admin" data-link>Overview</a>
37
+ <a href="/admin/orders" data-link>Orders</a>
38
+ </nav>
39
+ </aside>
40
+ <main>
41
+ <slot></slot>
42
+ </main>
43
+ `,
44
+ {
45
+ styles: css`
46
+ :host {
47
+ display: grid;
48
+ grid-template-columns: 220px 1fr;
49
+ grid-template-rows: 56px 1fr;
50
+ grid-template-areas:
51
+ "topbar topbar"
52
+ "side main";
53
+ min-height: 100vh;
54
+ }
55
+ header {
56
+ grid-area: topbar;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: var(--space-4);
60
+ padding: 0 var(--space-5);
61
+ background: var(--bg-elevated);
62
+ border-bottom: 1px solid var(--border);
63
+ }
64
+ header .brand { font-weight: 700; color: var(--fg); }
65
+ header nav { display: flex; gap: var(--space-3); }
66
+ header nav a { color: var(--fg-muted); }
67
+ header nav a:hover { color: var(--fg); text-decoration: none; }
68
+ header .user { color: var(--fg-muted); font-variant-numeric: tabular-nums; }
69
+ .spacer { flex: 1; }
70
+ aside {
71
+ grid-area: side;
72
+ border-right: 1px solid var(--border);
73
+ background: var(--bg-elevated);
74
+ padding: var(--space-4);
75
+ }
76
+ aside nav { display: flex; flex-direction: column; gap: var(--space-2); }
77
+ aside nav a {
78
+ padding: var(--space-2) var(--space-3);
79
+ border-radius: var(--radius-sm);
80
+ color: var(--fg-muted);
81
+ }
82
+ aside nav a:hover {
83
+ background: var(--bg);
84
+ color: var(--fg);
85
+ text-decoration: none;
86
+ }
87
+ main { grid-area: main; padding: var(--space-5); }
88
+ `,
89
+ },
90
+ );
91
+
92
+ function maskToken(token: string | null): string {
93
+ if (!token) return "—";
94
+ return `signed in · …${token.slice(-4)}`;
95
+ }
96
+
97
+ export default page({
98
+ view: ({ child }) => html`
99
+ <x-admin-shell>${child}</x-admin-shell>
100
+ `,
101
+ });
@@ -0,0 +1,41 @@
1
+ // Centered card layout for auth pages (login, signup, password reset).
2
+ // Identical shape to layouts/app.ts but a different shell.
3
+
4
+ import { component, css, html, page } from "@madojs/mado";
5
+
6
+ component(
7
+ "x-auth-shell",
8
+ () => () => html`
9
+ <main>
10
+ <div class="card">
11
+ <slot></slot>
12
+ </div>
13
+ </main>
14
+ `,
15
+ {
16
+ styles: css`
17
+ :host {
18
+ display: block;
19
+ min-height: 100vh;
20
+ }
21
+ main {
22
+ min-height: 100vh;
23
+ display: grid;
24
+ place-items: center;
25
+ padding: var(--space-5);
26
+ }
27
+ .card {
28
+ background: var(--bg-elevated);
29
+ border: 1px solid var(--border);
30
+ border-radius: var(--radius);
31
+ box-shadow: var(--shadow-1);
32
+ padding: var(--space-6);
33
+ width: min(360px, 100%);
34
+ }
35
+ `,
36
+ },
37
+ );
38
+
39
+ export default page({
40
+ view: ({ child }) => html`<x-auth-shell>${child}</x-auth-shell>`,
41
+ });