asjs-express 1.7.0 → 1.8.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.
@@ -50,47 +50,86 @@
50
50
 
51
51
  --asjs-progress-gradient: linear-gradient(90deg, var(--accent), #a78bfa);
52
52
  --asjs-progress-shadow: 0 0 14px rgba(var(--accent-rgb), 0.55);
53
+
54
+ /* Bootstrap light bridge — binds BS components to our palette */
55
+ --bs-body-bg: #ffffff;
56
+ --bs-body-bg-rgb: 255,255,255;
57
+ --bs-body-color: #0d0d18;
58
+ --bs-body-color-rgb: 13,13,24;
59
+ --bs-secondary-color: #52526e;
60
+ --bs-border-color: rgba(13,13,50,0.14);
61
+ --bs-border-color-translucent: rgba(13,13,50,0.10);
62
+ --bs-card-bg: #ffffff;
63
+ --bs-card-border-color: rgba(13,13,50,0.10);
64
+ --bs-secondary-bg: #f6f7fb;
65
+ --bs-tertiary-bg: #eceef6;
66
+ --bs-primary: #7c3aed;
67
+ --bs-primary-rgb: 124,58,237;
68
+ --bs-btn-bg: #7c3aed;
69
+ --bs-link-color: #7c3aed;
70
+ --bs-link-color-rgb: 124,58,237;
71
+ --bs-link-hover-color: #6d28d9;
72
+ --bs-focus-ring-color: rgba(124,58,237,0.25);
53
73
  }
54
74
 
55
75
  /* ─── DARK TOKENS ───────────────────────────────────────────── */
56
76
  [data-theme="dark"] {
57
- --bg: #0b0b14;
58
- --bg-2: #13131f;
59
- --bg-3: #1c1c2e;
60
- --surface: #13131f;
77
+ --bg: #0d0d18;
78
+ --bg-2: #161625;
79
+ --bg-3: #1e1e30;
80
+ --surface: #131320;
61
81
 
62
- --border: rgba(255, 255, 255, 0.07);
63
- --border-2: rgba(255, 255, 255, 0.13);
82
+ --border: rgba(255, 255, 255, 0.10);
83
+ --border-2: rgba(255, 255, 255, 0.19);
64
84
 
65
- --text: #f0f0fa;
66
- --text-2: #9898b8;
67
- --text-3: #5c5c7c;
85
+ --text: #ededf8;
86
+ --text-2: #8888ac;
87
+ --text-3: #52527a;
68
88
 
69
89
  --accent: #a78bfa;
70
90
  --accent-h: #c4b5fd;
71
- --accent-soft: #1e1830;
91
+ --accent-soft: #1a1132;
72
92
  --accent-rgb: 167, 139, 250;
73
93
 
74
94
  --sky: #38bdf8;
75
- --sky-soft: #0c1e30;
95
+ --sky-soft: #0a1f30;
76
96
  --sky-rgb: 56, 189, 248;
77
97
 
78
98
  --ok: #34d399;
79
- --ok-bg: #091f18;
80
- --ok-border: rgba(52, 211, 153, 0.2);
99
+ --ok-bg: #071a12;
100
+ --ok-border: rgba(52, 211, 153, 0.22);
81
101
  --err: #f87171;
82
- --err-bg: #1f0909;
83
- --err-border: rgba(248, 113, 113, 0.2);
84
- --warn-bg: #1f1600;
85
- --warn-border:rgba(251, 191, 36, 0.2);
102
+ --err-bg: #1a0707;
103
+ --err-border: rgba(248, 113, 113, 0.22);
104
+ --warn-bg: #1a1300;
105
+ --warn-border:rgba(251, 191, 36, 0.22);
86
106
 
87
- --sh-xs: 0 1px 2px rgba(0,0,0,0.4);
88
- --sh-sm: 0 2px 6px rgba(0,0,0,0.5), 0 6px 20px rgba(0,0,0,0.4);
89
- --sh-md: 0 8px 32px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4);
90
- --sh-accent: 0 6px 24px rgba(var(--accent-rgb), 0.25);
107
+ --sh-xs: 0 1px 3px rgba(0,0,0,0.65);
108
+ --sh-sm: 0 2px 10px rgba(0,0,0,0.75), 0 1px 3px rgba(0,0,0,0.5);
109
+ --sh-md: 0 8px 36px rgba(0,0,0,0.85), 0 2px 8px rgba(0,0,0,0.55);
110
+ --sh-accent: 0 6px 24px rgba(var(--accent-rgb), 0.22);
91
111
 
92
112
  --asjs-progress-gradient: linear-gradient(90deg, var(--accent), #c4b5fd);
93
113
  --asjs-progress-shadow: 0 0 14px rgba(var(--accent-rgb), 0.5);
114
+
115
+ /* Bootstrap dark bridge */
116
+ --bs-body-bg: #0d0d18;
117
+ --bs-body-bg-rgb: 13,13,24;
118
+ --bs-body-color: #ededf8;
119
+ --bs-body-color-rgb: 237,237,248;
120
+ --bs-secondary-color: #8888ac;
121
+ --bs-border-color: rgba(255,255,255,0.13);
122
+ --bs-border-color-translucent: rgba(255,255,255,0.09);
123
+ --bs-card-bg: #131320;
124
+ --bs-card-border-color: rgba(255,255,255,0.09);
125
+ --bs-secondary-bg: #1e1e30;
126
+ --bs-tertiary-bg: #26263c;
127
+ --bs-primary: #a78bfa;
128
+ --bs-primary-rgb: 167,139,250;
129
+ --bs-link-color: #a78bfa;
130
+ --bs-link-color-rgb: 167,139,250;
131
+ --bs-link-hover-color: #c4b5fd;
132
+ --bs-focus-ring-color: rgba(167,139,250,0.25);
94
133
  }
95
134
 
96
135
  /* ─── BASE ──────────────────────────────────────────────────── */
@@ -1000,6 +1039,49 @@ code {
1000
1039
  100% { background-position: -200% 0; }
1001
1040
  }
1002
1041
 
1042
+ /* ─── DARK COMPONENT OVERRIDES ──────────────────────────────── */
1043
+ [data-theme="dark"] .brand-mark {
1044
+ background: linear-gradient(135deg, var(--accent) 0%, var(--sky) 100%);
1045
+ }
1046
+ [data-theme="dark"] .section-card,
1047
+ [data-theme="dark"] .hero-copy,
1048
+ [data-theme="dark"] .hero-aside {
1049
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.05) inset,
1050
+ 0 4px 28px rgba(0,0,0,0.75);
1051
+ }
1052
+ [data-theme="dark"] .render-summary-grid,
1053
+ [data-theme="dark"] .card-grid,
1054
+ [data-theme="dark"] .stats-grid,
1055
+ [data-theme="dark"] .metric-grid {
1056
+ background: rgba(255,255,255,0.06);
1057
+ }
1058
+ [data-theme="dark"] .info-card,
1059
+ [data-theme="dark"] .stat-card,
1060
+ [data-theme="dark"] .metric-panel,
1061
+ [data-theme="dark"] .render-item {
1062
+ background: #161625;
1063
+ }
1064
+ [data-theme="dark"] .info-card:hover,
1065
+ [data-theme="dark"] .render-item:hover { background: #1e1e30; }
1066
+ [data-theme="dark"] .check-list li {
1067
+ background: #1e1e30;
1068
+ border-color: rgba(255,255,255,0.12);
1069
+ }
1070
+ [data-theme="dark"] .button-secondary {
1071
+ background: #1e1e30;
1072
+ border-color: rgba(255,255,255,0.16);
1073
+ }
1074
+ [data-theme="dark"] .button-secondary:hover {
1075
+ background: #26263c;
1076
+ border-color: var(--accent);
1077
+ color: var(--accent);
1078
+ }
1079
+ [data-theme="dark"] .route-loader-card { background: rgba(19,19,34,0.97); }
1080
+ [data-theme="dark"] .theme-toggle { background: #1e1e30; }
1081
+ [data-theme="dark"] .section-label,
1082
+ [data-theme="dark"] .hero-badge { background: #1a1132; }
1083
+ [data-theme="dark"] .render-band { background: #161625; }
1084
+
1003
1085
  /* ─── TABLET (≤ 1024px) ──────────────────────────────────────── */
1004
1086
  @media (max-width: 1024px) {
1005
1087
  .header-main { grid-template-columns: auto 1fr; }
@@ -1043,4 +1125,4 @@ code {
1043
1125
  .theme-toggle { bottom: 16px; left: 16px; width: 38px; height: 38px; }
1044
1126
 
1045
1127
  .footer { flex-direction: column; align-items: flex-start; }
1046
- }
1128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asjs-express",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Lightweight Express view engine with EJS-like templates, layouts, async page rendering, form enhancement, and a built-in client router.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -92,18 +92,25 @@ app.post('/form', function(req, res) {
92
92
  if (!subject) errors.subject = 'Please select a subject.';
93
93
  if (message.length < 10) errors.message = 'Message must be at least 10 characters.';
94
94
 
95
+ var formData = {
96
+ title: 'Contact — __APP_TITLE__',
97
+ headline: 'Get in touch',
98
+ description: 'Have a question or want to work together? Send us a message.',
99
+ values: body,
100
+ errors: errors,
101
+ submitted: false
102
+ };
103
+
95
104
  if (Object.keys(errors).length > 0) {
96
- return asjs.render(res, 'form', {
97
- title: 'Contact — __APP_TITLE__',
98
- headline: 'Get in touch',
99
- description: 'Have a question or want to work together? Send us a message.',
100
- submitted: false,
101
- values: body,
102
- errors: errors
103
- });
105
+ return asjs.render(res, 'form', formData);
104
106
  }
105
107
 
106
- res.redirect('/form?sent=1');
108
+ return asjs.render(res, 'form', Object.assign({}, formData, {
109
+ headline: 'Message sent!',
110
+ submitted: true,
111
+ values: {},
112
+ errors: {}
113
+ }));
107
114
  });
108
115
 
109
116
  app.use(asjs.errors());
@@ -1,76 +1,111 @@
1
- <div class="page-intro">
2
- <span class="section-label">Contact</span>
3
- <h1><%= headline %></h1>
4
- <p><%= description %></p>
5
- </div>
6
-
7
- <% if (submitted) { %>
8
-
9
- <div class="status-panel status-panel--success">
10
- <h3>Message received!</h3>
11
- <p>Thanks for reaching out. We typically respond within one business day.</p>
12
- <div class="hero-actions" style="margin-top: 20px;">
13
- <a href="/form" class="button button-primary" data-asjs-transition="fade">Send another</a>
14
- <a href="/" class="button button-secondary" data-asjs-transition="fade">Back to home</a>
15
- </div>
16
- </div>
17
-
18
- <% } else { %>
19
-
20
- <% if (Object.keys(errors).length > 0) { %>
21
- <div class="status-panel status-panel--error" style="margin-bottom: 0;">
22
- <h3>Please fix the errors below</h3>
23
- <p>Some fields need your attention before the form can be submitted.</p>
24
- </div>
25
- <% } %>
26
-
27
- <div class="section-card form-panel">
28
- <span class="section-label">Send a message</span>
29
- <h2>We'd love to hear from you</h2>
30
- <p>Fill in the form and we'll get back to you as soon as possible.</p>
31
-
32
- <form action="/form" method="POST" class="asjs-form" style="margin-top: 28px;">
33
-
34
- <div class="field-grid">
35
- <div class="field-group<% if (errors.name) { %> has-error<% } %>">
36
- <label class="field-label" for="f-name">Full name</label>
37
- <input id="f-name" type="text" name="name" value="<%= values.name || '' %>" placeholder="Your full name" autocomplete="name" required>
38
- <% if (errors.name) { %><span class="field-error"><%= errors.name %></span><% } %>
39
- </div>
40
- <div class="field-group<% if (errors.email) { %> has-error<% } %>">
41
- <label class="field-label" for="f-email">Email address</label>
42
- <input id="f-email" type="email" name="email" value="<%= values.email || '' %>" placeholder="you@example.com" autocomplete="email" required>
43
- <% if (errors.email) { %><span class="field-error"><%= errors.email %></span><% } %>
44
- </div>
45
- </div>
46
-
47
- <div class="field-group<% if (errors.subject) { %> has-error<% } %>">
48
- <label class="field-label" for="f-subject">Subject</label>
49
- <select id="f-subject" name="subject" class="field-select">
50
- <option value="">Select a topic…</option>
51
- <option value="general" <%= values.subject === 'general' ? 'selected' : '' %>>General enquiry</option>
52
- <option value="project" <%= values.subject === 'project' ? 'selected' : '' %>>Project collaboration</option>
53
- <option value="support" <%= values.subject === 'support' ? 'selected' : '' %>>Technical support</option>
54
- <option value="other" <%= values.subject === 'other' ? 'selected' : '' %>>Other</option>
55
- </select>
56
- <% if (errors.subject) { %><span class="field-error"><%= errors.subject %></span><% } %>
57
- </div>
58
-
59
- <div class="field-group<% if (errors.message) { %> has-error<% } %>">
60
- <label class="field-label" for="f-message">Message</label>
61
- <textarea id="f-message" name="message" rows="6" placeholder="Write your message here…" required><%= values.message || '' %></textarea>
62
- <% if (errors.message) { %><span class="field-error"><%= errors.message %></span><% } %>
63
- </div>
64
-
65
- <div class="form-actions">
66
- <span class="form-hint">We typically respond within 24 hours.</span>
67
- <button type="submit" class="button button-primary">
68
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/></svg>
69
- Send message
70
- </button>
71
- </div>
72
-
73
- </form>
74
- </div>
75
-
1
+ <div class="page-intro">
2
+ <span class="section-label">Contact</span>
3
+ <h1><%= headline %></h1>
4
+ <p><%= description %></p>
5
+ </div>
6
+
7
+ <% if (submitted) { %>
8
+
9
+ <div class="card shadow-sm" style="border-radius:var(--r-xl);border-color:var(--ok-border);background:var(--ok-bg);">
10
+ <div class="card-body p-4 p-md-5">
11
+ <div class="d-flex align-items-start gap-4">
12
+ <div style="width:52px;height:52px;flex-shrink:0;border-radius:50%;background:rgba(52,211,153,0.15);border:1px solid var(--ok-border);display:flex;align-items:center;justify-content:center;">
13
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--ok)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
14
+ </div>
15
+ <div>
16
+ <h5 class="fw-bold mb-1" style="color:var(--ok);">Message received!</h5>
17
+ <p class="mb-3" style="color:var(--text-2);font-size:0.92rem;">Thanks for reaching out. We typically respond within one business day.</p>
18
+ <a href="/form" class="button button-primary" data-asjs-transition="fade">Send another message</a>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ </div>
23
+
24
+ <% } else { %>
25
+
26
+ <% if (Object.keys(errors).length > 0) { %>
27
+ <div class="alert alert-danger d-flex align-items-start gap-3 mb-3" role="alert" style="border-radius:var(--r-lg);">
28
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;margin-top:2px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
29
+ <div>
30
+ <strong>Please fix the errors below</strong>
31
+ <div class="mt-1" style="font-size:0.85rem;">Some fields need attention before the form can be submitted.</div>
32
+ </div>
33
+ </div>
34
+ <% } %>
35
+
36
+ <div class="card shadow-sm" style="border-radius:var(--r-xl);">
37
+ <div class="card-body p-4 p-lg-5">
38
+
39
+ <div class="d-flex align-items-center gap-3 mb-4 pb-3" style="border-bottom:1px solid var(--border);">
40
+ <div style="width:40px;height:40px;border-radius:var(--r-md);background:var(--accent-soft);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
41
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
42
+ </div>
43
+ <div>
44
+ <h5 class="mb-0 fw-bold" style="letter-spacing:-0.02em;color:var(--text);">Send us a message</h5>
45
+ <p class="mb-0" style="font-size:0.8rem;color:var(--text-3);">We respond to every message within 24 hours</p>
46
+ </div>
47
+ </div>
48
+
49
+ <form action="/form" method="POST" data-asjs-form>
50
+ <div class="row g-4">
51
+
52
+ <div class="col-md-6">
53
+ <label class="form-label fw-semibold" for="f-name" style="font-size:0.84rem;color:var(--text-2);">Full name</label>
54
+ <input type="text" id="f-name" name="name"
55
+ class="form-control form-control-lg<% if(errors.name){ %> is-invalid<% } %>"
56
+ value="<%= values.name||'' %>"
57
+ placeholder="Jane Smith"
58
+ autocomplete="name" required>
59
+ <% if(errors.name){ %><div class="invalid-feedback fw-medium"><%= errors.name %></div><% } %>
60
+ </div>
61
+
62
+ <div class="col-md-6">
63
+ <label class="form-label fw-semibold" for="f-email" style="font-size:0.84rem;color:var(--text-2);">Email address</label>
64
+ <input type="email" id="f-email" name="email"
65
+ class="form-control form-control-lg<% if(errors.email){ %> is-invalid<% } %>"
66
+ value="<%= values.email||'' %>"
67
+ placeholder="jane@example.com"
68
+ autocomplete="email" required>
69
+ <% if(errors.email){ %><div class="invalid-feedback fw-medium"><%= errors.email %></div><% } %>
70
+ </div>
71
+
72
+ <div class="col-12">
73
+ <label class="form-label fw-semibold" for="f-subject" style="font-size:0.84rem;color:var(--text-2);">Subject</label>
74
+ <select id="f-subject" name="subject"
75
+ class="form-select form-select-lg<% if(errors.subject){ %> is-invalid<% } %>">
76
+ <option value="">Select a topic…</option>
77
+ <option value="general" <%= values.subject==='general' ? 'selected' : '' %>>General enquiry</option>
78
+ <option value="project" <%= values.subject==='project' ? 'selected' : '' %>>Project collaboration</option>
79
+ <option value="support" <%= values.subject==='support' ? 'selected' : '' %>>Technical support</option>
80
+ <option value="other" <%= values.subject==='other' ? 'selected' : '' %>>Other</option>
81
+ </select>
82
+ <% if(errors.subject){ %><div class="invalid-feedback fw-medium"><%= errors.subject %></div><% } %>
83
+ </div>
84
+
85
+ <div class="col-12">
86
+ <label class="form-label fw-semibold" for="f-message" style="font-size:0.84rem;color:var(--text-2);">Message</label>
87
+ <textarea id="f-message" name="message" rows="6"
88
+ class="form-control form-control-lg<% if(errors.message){ %> is-invalid<% } %>"
89
+ placeholder="Tell us what&#39;s on your mind…" required><%= values.message||'' %></textarea>
90
+ <% if(errors.message){ %><div class="invalid-feedback fw-medium"><%= errors.message %></div><% } %>
91
+ </div>
92
+
93
+ <div class="col-12">
94
+ <div class="d-flex align-items-center justify-content-between gap-3 flex-wrap pt-1" style="border-top:1px solid var(--border);padding-top:20px!important;">
95
+ <div style="font-size:0.82rem;color:var(--text-3);display:flex;align-items:center;gap:7px;">
96
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
97
+ Your data is never shared with third parties.
98
+ </div>
99
+ <button type="submit" class="button button-primary" style="height:44px;padding:0 24px;font-size:0.9rem;">
100
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/></svg>
101
+ Send message
102
+ </button>
103
+ </div>
104
+ </div>
105
+
106
+ </div>
107
+ </form>
108
+ </div>
109
+ </div>
110
+
76
111
  <% } %>
@@ -1,48 +1,51 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-theme="light">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title><%= title %></title>
7
- <%- asjs.clientTags({ preload: true, theme: true }) %>
8
- <script>(function(){var t=localStorage.getItem('asjs-theme')||(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
9
- </head>
10
- <body<%- asjs.bodyAttrs() %>>
11
- <%- asjs.progressMarkup() %>
12
- <div class="page-shell">
13
- <%- asjs.header() %>
14
- <main class="view-frame"<%- asjs.viewAttrs() %>>
15
- <%- body %>
16
- </main>
17
- <footer class="footer">
18
- <span>Built with <strong>ASJS __ASJS_VERSION__</strong> + Express</span>
19
- <span>&copy; __YEAR__ __APP_TITLE__</span>
20
- </footer>
21
- </div>
22
- <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme" title="Toggle light / dark">
23
- <span class="toggle-icon toggle-icon--light">
24
- <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
25
- </span>
26
- <span class="toggle-icon toggle-icon--dark">
27
- <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
28
- </span>
29
- </button>
30
- <script>
31
- (function(){
32
- var btn=document.getElementById('theme-toggle');
33
- if(!btn)return;
34
- function sync(t){
35
- document.documentElement.setAttribute('data-theme',t);
36
- btn.querySelector('.toggle-icon--light').style.display=t==='dark'?'flex':'none';
37
- btn.querySelector('.toggle-icon--dark').style.display=t==='dark'?'none':'flex';
38
- }
39
- sync(document.documentElement.getAttribute('data-theme')||'light');
40
- btn.addEventListener('click',function(){
41
- var next=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';
42
- localStorage.setItem('asjs-theme',next);
43
- sync(next);
44
- });
45
- })();
46
- </script>
47
- </body>
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light" data-bs-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= title %></title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" crossorigin="anonymous">
8
+ <%- asjs.clientTags({ preload: true, theme: true }) %>
9
+ <script>(function(){var t=localStorage.getItem('asjs-theme')||(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);document.documentElement.setAttribute('data-bs-theme',t);})();</script>
10
+ </head>
11
+ <body<%- asjs.bodyAttrs() %>>
12
+ <%- asjs.progressMarkup() %>
13
+ <div class="page-shell">
14
+ <%- asjs.header() %>
15
+ <main class="view-frame"<%- asjs.viewAttrs() %>>
16
+ <%- body %>
17
+ </main>
18
+ <footer class="footer">
19
+ <span>Built with <strong>ASJS __ASJS_VERSION__</strong> + Express</span>
20
+ <span>&copy; __YEAR__ __APP_TITLE__</span>
21
+ </footer>
22
+ </div>
23
+ <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme" title="Toggle light / dark">
24
+ <span class="toggle-icon toggle-icon--light" style="display:none">
25
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
26
+ </span>
27
+ <span class="toggle-icon toggle-icon--dark" style="display:none">
28
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
29
+ </span>
30
+ </button>
31
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
32
+ <script>
33
+ (function(){
34
+ var btn = document.getElementById('theme-toggle');
35
+ if (!btn) return;
36
+ function sync(t) {
37
+ document.documentElement.setAttribute('data-theme', t);
38
+ document.documentElement.setAttribute('data-bs-theme', t);
39
+ btn.querySelector('.toggle-icon--light').style.display = t === 'dark' ? 'flex' : 'none';
40
+ btn.querySelector('.toggle-icon--dark').style.display = t === 'light' ? 'flex' : 'none';
41
+ }
42
+ sync(document.documentElement.getAttribute('data-theme') || 'light');
43
+ btn.addEventListener('click', function() {
44
+ var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
45
+ localStorage.setItem('asjs-theme', next);
46
+ sync(next);
47
+ });
48
+ })();
49
+ </script>
50
+ </body>
48
51
  </html>