zero-query 0.9.6 → 0.9.8

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 (59) hide show
  1. package/README.md +36 -8
  2. package/cli/commands/build.js +50 -3
  3. package/cli/commands/create.js +22 -9
  4. package/cli/help.js +2 -0
  5. package/cli/scaffold/default/app/app.js +211 -0
  6. package/cli/scaffold/default/app/components/about.js +201 -0
  7. package/cli/scaffold/default/app/components/api-demo.js +143 -0
  8. package/cli/scaffold/default/app/components/contact-card.js +231 -0
  9. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -0
  10. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -0
  11. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -0
  12. package/cli/scaffold/default/app/components/counter.js +127 -0
  13. package/cli/scaffold/default/app/components/home.js +249 -0
  14. package/cli/scaffold/{app → default/app}/components/not-found.js +2 -2
  15. package/cli/scaffold/default/app/components/playground/playground.css +116 -0
  16. package/cli/scaffold/default/app/components/playground/playground.html +162 -0
  17. package/cli/scaffold/default/app/components/playground/playground.js +117 -0
  18. package/cli/scaffold/default/app/components/todos.js +225 -0
  19. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -0
  20. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -0
  21. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -0
  22. package/cli/scaffold/default/app/routes.js +15 -0
  23. package/cli/scaffold/{app → default/app}/store.js +15 -10
  24. package/cli/scaffold/{global.css → default/global.css} +238 -252
  25. package/cli/scaffold/{index.html → default/index.html} +35 -0
  26. package/cli/scaffold/{app → minimal/app}/app.js +37 -39
  27. package/cli/scaffold/minimal/app/components/about.js +68 -0
  28. package/cli/scaffold/minimal/app/components/counter.js +122 -0
  29. package/cli/scaffold/minimal/app/components/home.js +68 -0
  30. package/cli/scaffold/minimal/app/components/not-found.js +16 -0
  31. package/cli/scaffold/minimal/app/routes.js +9 -0
  32. package/cli/scaffold/minimal/app/store.js +36 -0
  33. package/cli/scaffold/minimal/assets/.gitkeep +0 -0
  34. package/cli/scaffold/minimal/global.css +291 -0
  35. package/cli/scaffold/minimal/index.html +44 -0
  36. package/dist/zquery.dist.zip +0 -0
  37. package/dist/zquery.js +1949 -1894
  38. package/dist/zquery.min.js +2 -2
  39. package/index.d.ts +10 -1
  40. package/index.js +5 -3
  41. package/package.json +1 -1
  42. package/src/component.js +6 -3
  43. package/src/diff.js +15 -2
  44. package/src/http.js +37 -0
  45. package/tests/cli.test.js +304 -0
  46. package/tests/http.test.js +200 -0
  47. package/types/http.d.ts +15 -4
  48. package/cli/scaffold/app/components/about.js +0 -131
  49. package/cli/scaffold/app/components/api-demo.js +0 -103
  50. package/cli/scaffold/app/components/contacts/contacts.css +0 -246
  51. package/cli/scaffold/app/components/contacts/contacts.html +0 -140
  52. package/cli/scaffold/app/components/contacts/contacts.js +0 -153
  53. package/cli/scaffold/app/components/counter.js +0 -85
  54. package/cli/scaffold/app/components/home.js +0 -137
  55. package/cli/scaffold/app/components/todos.js +0 -131
  56. package/cli/scaffold/app/routes.js +0 -13
  57. /package/cli/scaffold/{LICENSE → default/LICENSE} +0 -0
  58. /package/cli/scaffold/{assets → default/assets}/.gitkeep +0 -0
  59. /package/cli/scaffold/{favicon.ico → default/favicon.ico} +0 -0
@@ -0,0 +1,200 @@
1
+ <!--
2
+ contacts.html — external template for contacts-page component
3
+
4
+ This template is fetched via templateUrl and uses {{expression}} syntax
5
+ for data binding. All zQuery directives work here: z-if, z-else,
6
+ z-for, z-show, z-bind, z-class, z-text, z-model, z-ref,
7
+ z-cloak, @click, @submit.prevent, @click.outside, @keydown.escape, etc.
8
+ -->
9
+ <div class="page-header" z-cloak>
10
+ <h1>Contacts</h1>
11
+ <p class="subtitle">
12
+ External template &amp; styles via <code>templateUrl</code> / <code>styleUrl</code>.
13
+ Click a contact card to open the detail modal — dismiss with <code>Esc</code> or <code>@click.outside</code>.
14
+ </p>
15
+ </div>
16
+
17
+ <!-- Toolbar -->
18
+ <div class="ct-bar">
19
+ <div class="ct-bar-left">
20
+ <div class="ct-badges" z-if="contacts.length > 0">
21
+ <span class="ct-badge"><strong z-text="contacts.length"></strong> total</span>
22
+ <span class="ct-badge ct-badge-accent"><strong z-text="favoriteCount"></strong> ★</span>
23
+ </div>
24
+ </div>
25
+ <div class="ct-bar-right">
26
+ <input type="text" z-model="filterText" z-debounce="200" placeholder="Search…" class="ct-search" @keydown.escape="filterText = ''" />
27
+ <button class="ct-btn ct-btn-accent ct-btn-add" @click="openAddModal">+ Add Contact</button>
28
+ </div>
29
+ </div>
30
+
31
+ <!-- Role filter chips -->
32
+ <div class="ct-chips" z-show="contacts.length > 0">
33
+ <button class="ct-chip {{filterRole === '' ? 'active' : ''}}" @click="setFilter('')">All</button>
34
+ <button class="ct-chip {{filterRole === 'Developer' ? 'active' : ''}}" @click="setFilter('Developer')">Developer</button>
35
+ <button class="ct-chip {{filterRole === 'Designer' ? 'active' : ''}}" @click="setFilter('Designer')">Designer</button>
36
+ <button class="ct-chip {{filterRole === 'Manager' ? 'active' : ''}}" @click="setFilter('Manager')">Manager</button>
37
+ <button class="ct-chip {{filterRole === 'QA' ? 'active' : ''}}" @click="setFilter('QA')">QA</button>
38
+ </div>
39
+
40
+ <!-- Contact grid -->
41
+ <div class="ct-grid" z-show="filteredContacts.length > 0">
42
+ <div
43
+ z-for="contact in filteredContacts"
44
+ z-key="{{contact.id}}"
45
+ class="ct-card {{contact.favorite ? 'ct-card-fav' : ''}}"
46
+ @click="openModal({{contact.id}})"
47
+ >
48
+ <!-- Status bar -->
49
+ <div class="ct-card-status ct-card-status-{{contact.status}}"></div>
50
+
51
+ <!-- Avatar -->
52
+ <div class="ct-card-avatar" :style="'background: hsl(' + (contact.name.charCodeAt(0) * 7 % 360) + ', 55%, 42%)'">
53
+ {{contact.name.charAt(0).toUpperCase()}}
54
+ </div>
55
+
56
+ <!-- Info -->
57
+ <div class="ct-card-info">
58
+ <div class="ct-card-name">{{contact.name}}</div>
59
+ <div class="ct-card-email">{{contact.email}}</div>
60
+ <span class="ct-card-role ct-role-{{contact.role.toLowerCase()}}">{{contact.role}}</span>
61
+ </div>
62
+
63
+ <!-- Fav -->
64
+ <button
65
+ class="ct-card-fav-btn {{contact.favorite ? 'is-fav' : ''}}"
66
+ @click.stop="toggleFavorite({{contact.id}})"
67
+ >{{contact.favorite ? '★' : '☆'}}</button>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Empty -->
72
+ <div class="ct-empty-card" z-show="filteredContacts.length === 0">
73
+ <div class="ct-empty-icon">
74
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor" style="width:48px;height:48px;opacity:.3;"><path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z"/></svg>
75
+ </div>
76
+ <p z-if="filterText || filterRole">No contacts match your filter.</p>
77
+ <p z-else>No contacts yet — hit <strong>+ Add Contact</strong> to create one!</p>
78
+ </div>
79
+
80
+ <!-- ═══ Add Contact Modal ═══ -->
81
+ <div class="ct-overlay" z-show="showAddModal" @click.self="closeAddModal">
82
+ <div class="ct-modal ct-modal-form" z-show="showAddModal">
83
+ <div class="ct-modal-strip ct-modal-strip-accent"></div>
84
+ <button class="ct-modal-close" @click="closeAddModal">✕</button>
85
+
86
+ <div class="ct-modal-heading">
87
+ <h2>New Contact</h2>
88
+ <p class="muted">Fill in the required fields to add a contact.</p>
89
+ </div>
90
+
91
+ <form class="ct-form" @submit.prevent="addContact">
92
+ <div class="ct-form-row">
93
+ <div class="ct-form-field ct-form-grow">
94
+ <label>Name <span class="ct-req">*</span></label>
95
+ <input type="text" z-model="newName" z-trim placeholder="Full name" class="ct-input" @blur="validateName" />
96
+ <small class="ct-err" z-show="nameError" z-text="nameError"></small>
97
+ </div>
98
+ <div class="ct-form-field ct-form-grow">
99
+ <label>Email <span class="ct-req">*</span></label>
100
+ <input type="email" z-model="newEmail" z-trim z-lowercase placeholder="Email address" class="ct-input" @blur="validateEmail" />
101
+ <small class="ct-err" z-show="emailError" z-text="emailError"></small>
102
+ </div>
103
+ </div>
104
+ <div class="ct-form-row">
105
+ <div class="ct-form-field">
106
+ <label>Role</label>
107
+ <select z-model="newRole" class="ct-input">
108
+ <option value="Developer">Developer</option>
109
+ <option value="Designer">Designer</option>
110
+ <option value="Manager">Manager</option>
111
+ <option value="QA">QA</option>
112
+ </select>
113
+ </div>
114
+ <div class="ct-form-field ct-form-grow">
115
+ <label>Phone <span class="ct-opt">optional</span></label>
116
+ <input type="text" z-model="newPhone" z-trim placeholder="+1 (555) 000-0000" class="ct-input" />
117
+ </div>
118
+ </div>
119
+ <div class="ct-form-field">
120
+ <label>Bio <span class="ct-opt">optional</span></label>
121
+ <input type="text" z-model="newBio" z-trim placeholder="A short bio…" class="ct-input" />
122
+ </div>
123
+ <div class="ct-form-actions">
124
+ <button type="button" class="ct-btn ct-btn-ghost" @click="closeAddModal">Cancel</button>
125
+ <button type="submit" class="ct-btn ct-btn-accent">Save Contact</button>
126
+ </div>
127
+ </form>
128
+ </div>
129
+ </div>
130
+
131
+ <!-- ═══ Contact Detail Modal ═══ -->
132
+ <div class="ct-overlay" z-show="modalId !== null" @click.self="closeModal">
133
+ <div class="ct-modal" z-show="modalContact">
134
+ <!-- Status strip -->
135
+ <div class="ct-modal-strip ct-modal-strip-{{modalContact.status}}"></div>
136
+
137
+ <button class="ct-modal-close" @click="closeModal">✕</button>
138
+
139
+ <!-- Profile header -->
140
+ <div class="ct-modal-profile">
141
+ <div class="ct-modal-avatar" :style="'background: hsl(' + (modalContact.name.charCodeAt(0) * 7 % 360) + ', 55%, 42%)'">
142
+ {{modalContact.name.charAt(0).toUpperCase()}}
143
+ </div>
144
+ <div class="ct-modal-status-dot ct-dot-{{modalContact.status}}"></div>
145
+ <h2>{{modalContact.name}}</h2>
146
+ <span class="ct-modal-role ct-role-{{modalContact.role.toLowerCase()}}">{{modalContact.role}}</span>
147
+ </div>
148
+
149
+ <!-- Details grid -->
150
+ <div class="ct-modal-details">
151
+ <div class="ct-modal-field">
152
+ <span class="ct-modal-label">Email</span>
153
+ <span class="ct-modal-value">{{modalContact.email}}</span>
154
+ </div>
155
+ <div class="ct-modal-field" z-if="modalContact.phone">
156
+ <span class="ct-modal-label">Phone</span>
157
+ <span class="ct-modal-value">{{modalContact.phone}}</span>
158
+ </div>
159
+ <div class="ct-modal-field" z-if="modalContact.location">
160
+ <span class="ct-modal-label">Location</span>
161
+ <span class="ct-modal-value">{{modalContact.location}}</span>
162
+ </div>
163
+ <div class="ct-modal-field" z-if="modalContact.joined">
164
+ <span class="ct-modal-label">Joined</span>
165
+ <span class="ct-modal-value">{{modalContact.joined}}</span>
166
+ </div>
167
+ <div class="ct-modal-field" z-if="modalContact.status">
168
+ <span class="ct-modal-label">Status</span>
169
+ <span class="ct-modal-value ct-modal-status-text">{{modalContact.status}}</span>
170
+ </div>
171
+ </div>
172
+
173
+ <div class="ct-modal-bio" z-if="modalContact.bio">
174
+ <span class="ct-modal-label">Bio</span>
175
+ <p>{{modalContact.bio}}</p>
176
+ </div>
177
+
178
+ <!-- Actions -->
179
+ <div class="ct-modal-actions">
180
+ <button class="ct-btn ct-btn-ghost ct-btn-sm" @click="toggleFavorite({{modalContact.id}})">
181
+ {{modalContact.favorite ? '★ Favorited' : '☆ Favorite'}}
182
+ </button>
183
+ <button class="ct-btn ct-btn-ghost ct-btn-sm" @click="cycleStatus({{modalContact.id}})">
184
+ Cycle Status
185
+ </button>
186
+
187
+ <button
188
+ class="ct-btn ct-btn-danger ct-btn-sm"
189
+ z-if="confirmDeleteId !== modalContact.id"
190
+ @click.stop="confirmDelete({{modalContact.id}})"
191
+ >Delete</button>
192
+
193
+ <span z-else class="ct-confirm-group">
194
+ <span class="ct-confirm-text">Sure?</span>
195
+ <button class="ct-btn ct-btn-danger ct-btn-sm" @click.stop="deleteContact({{modalContact.id}})">Yes</button>
196
+ <button class="ct-btn ct-btn-ghost ct-btn-sm" @click.stop="cancelDelete">No</button>
197
+ </span>
198
+ </div>
199
+ </div>
200
+ </div>
@@ -0,0 +1,196 @@
1
+ // contacts.js — Contact book page
2
+ //
3
+ // Features used:
4
+ // templateUrl / styleUrl — external template & scoped styles
5
+ // z-if / z-show / z-for — conditional & list rendering
6
+ // z-model / z-ref — form bindings
7
+ // @click / @submit.prevent — event handling
8
+ // @keydown.escape — keyboard modifier
9
+ // $.getStore / dispatch — store integration
10
+ // $.bus.emit('toast') — notifications
11
+
12
+ $.component('contacts-page', {
13
+ templateUrl: 'contacts.html',
14
+ styleUrl: 'contacts.css',
15
+
16
+ state: () => ({
17
+ contacts: [],
18
+ showAddModal: false,
19
+ newName: '',
20
+ newEmail: '',
21
+ newRole: 'Developer',
22
+ newPhone: '',
23
+ newBio: '',
24
+ nameError: '',
25
+ emailError: '',
26
+ modalId: null,
27
+ confirmDeleteId: null,
28
+ totalAdded: 0,
29
+ favoriteCount: 0,
30
+ filterText: '',
31
+ filterRole: '',
32
+ // Derived state (not computed — external templates resolve state only)
33
+ filteredContacts: [],
34
+ modalContact: null,
35
+ }),
36
+
37
+ watch: {
38
+ filterText() { this._recompute(); },
39
+ filterRole() { this._recompute(); },
40
+ modalId() { this._recompute(); },
41
+ },
42
+
43
+ mounted() {
44
+ const store = $.getStore('main');
45
+ this._syncFromStore(store);
46
+ this._unsub = store.subscribe(() => this._syncFromStore(store));
47
+
48
+ // Global Escape handler — template @keydown.escape on overlays is
49
+ // unreliable when no child element has focus (detail modal has no inputs).
50
+ this._onEscape = (e) => {
51
+ if (e.key !== 'Escape') return;
52
+ if (this.state.showAddModal) { this.closeAddModal(); e.stopPropagation(); }
53
+ else if (this.state.modalId != null) { this.closeModal(); e.stopPropagation(); }
54
+ };
55
+ document.addEventListener('keydown', this._onEscape);
56
+ },
57
+
58
+ destroyed() {
59
+ if (this._unsub) this._unsub();
60
+ if (this._onEscape) document.removeEventListener('keydown', this._onEscape);
61
+ },
62
+
63
+ _syncFromStore(store) {
64
+ // Shallow-clone each contact so the framework detects new references
65
+ // (store actions mutate objects in place — same refs won't trigger re-render)
66
+ this.state.contacts = store.state.contacts.map(c => ({ ...c }));
67
+ this.state.totalAdded = store.state.contactsAdded;
68
+ this.state.favoriteCount = store.getters.favoriteCount;
69
+ this._recompute();
70
+ },
71
+
72
+ /** Recalculate derived state from current contacts + filter values. */
73
+ _recompute() {
74
+ let list = this.state.contacts;
75
+ const q = this.state.filterText.toLowerCase();
76
+ if (q) list = list.filter(c => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q));
77
+ if (this.state.filterRole) list = list.filter(c => c.role === this.state.filterRole);
78
+ this.state.filteredContacts = list;
79
+
80
+ this.state.modalContact = this.state.modalId != null
81
+ ? this.state.contacts.find(c => c.id === this.state.modalId) || null
82
+ : null;
83
+ },
84
+
85
+ // -- Add-contact modal --
86
+
87
+ openAddModal() {
88
+ this.state.showAddModal = true;
89
+ },
90
+
91
+ closeAddModal() {
92
+ this.state.showAddModal = false;
93
+ this._clearForm();
94
+ },
95
+
96
+ _validateName(name) {
97
+ if (!name) return 'Name is required.';
98
+ if (name.length < 2) return 'At least 2 characters.';
99
+ return '';
100
+ },
101
+
102
+ _validateEmail(email) {
103
+ if (!email) return 'Email is required.';
104
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Enter a valid email.';
105
+ const store = $.getStore('main');
106
+ if (store.state.contacts.some(c => c.email.toLowerCase() === email.toLowerCase())) {
107
+ return 'Email already exists.';
108
+ }
109
+ return '';
110
+ },
111
+
112
+ validateName() {
113
+ this.state.nameError = this._validateName(this.state.newName.trim());
114
+ },
115
+
116
+ validateEmail() {
117
+ this.state.emailError = this._validateEmail(this.state.newEmail.trim());
118
+ },
119
+
120
+ addContact() {
121
+ const name = this.state.newName.trim();
122
+ const email = this.state.newEmail.trim();
123
+
124
+ const nameError = this._validateName(name);
125
+ const emailError = this._validateEmail(email);
126
+ this.state.nameError = nameError;
127
+ this.state.emailError = emailError;
128
+ if (nameError || emailError) return;
129
+
130
+ $.getStore('main').dispatch('addContact', {
131
+ name,
132
+ email,
133
+ role: this.state.newRole,
134
+ phone: this.state.newPhone.trim(),
135
+ bio: this.state.newBio.trim(),
136
+ });
137
+
138
+ this._clearForm();
139
+ this.state.showAddModal = false;
140
+ $.bus.emit('toast', { message: `${name} added!`, type: 'success' });
141
+ },
142
+
143
+ // -- Detail modal --
144
+
145
+ openModal(id) {
146
+ this.state.modalId = Number(id);
147
+ this.state.confirmDeleteId = null;
148
+ },
149
+
150
+ closeModal() {
151
+ this.state.modalId = null;
152
+ this.state.confirmDeleteId = null;
153
+ },
154
+
155
+ // -- Actions --
156
+
157
+ toggleFavorite(id) {
158
+ $.getStore('main').dispatch('toggleFavorite', Number(id));
159
+ },
160
+
161
+ confirmDelete(id) {
162
+ this.state.confirmDeleteId = Number(id);
163
+ },
164
+
165
+ cancelDelete() {
166
+ this.state.confirmDeleteId = null;
167
+ },
168
+
169
+ deleteContact(id) {
170
+ const numId = Number(id);
171
+ const store = $.getStore('main');
172
+ const c = store.state.contacts.find(c => c.id === numId);
173
+ store.dispatch('deleteContact', numId);
174
+ this.state.modalId = null;
175
+ this.state.confirmDeleteId = null;
176
+ $.bus.emit('toast', { message: `${c ? c.name : 'Contact'} removed`, type: 'error' });
177
+ },
178
+
179
+ cycleStatus(id) {
180
+ $.getStore('main').dispatch('cycleContactStatus', Number(id));
181
+ },
182
+
183
+ setFilter(role) {
184
+ this.state.filterRole = this.state.filterRole === role ? '' : role;
185
+ },
186
+
187
+ _clearForm() {
188
+ this.state.newName = '';
189
+ this.state.newEmail = '';
190
+ this.state.newRole = 'Developer';
191
+ this.state.newPhone = '';
192
+ this.state.newBio = '';
193
+ this.state.nameError = '';
194
+ this.state.emailError = '';
195
+ },
196
+ });
@@ -0,0 +1,127 @@
1
+ // counter.js — Interactive counter
2
+ //
3
+ // Features used:
4
+ // state / computed / watch — reactive data flow
5
+ // @click — event binding
6
+ // z-model + z-number — two-way binding with type coercion
7
+ // z-class / z-if / z-for — conditional & list rendering
8
+ // $.bus.emit('toast', …) — notifications via event bus
9
+
10
+ $.component('counter-page', {
11
+ styles: `
12
+ .ctr-display { padding: 2rem 0 1.25rem; text-align: center; }
13
+ .ctr-num { font-size: 4rem; font-weight: 800; font-variant-numeric: tabular-nums;
14
+ color: var(--accent); transition: color .2s;
15
+ letter-spacing: -0.02em; line-height: 1; }
16
+ .ctr-num.negative { color: var(--danger); }
17
+ .ctr-label { font-size: .82rem; color: var(--text-muted); margin-top: .35rem; }
18
+ .ctr-actions { display: flex; justify-content: center; gap: .65rem;
19
+ margin-bottom: 1.5rem; }
20
+ .ctr-actions .btn { min-width: 120px; justify-content: center; }
21
+ .ctr-config { display: flex; align-items: center; justify-content: center; gap: 1.25rem;
22
+ padding-top: 1.15rem; border-top: 1px solid var(--border); }
23
+ .ctr-config label { display: flex; align-items: center; gap: .5rem;
24
+ color: var(--text-muted); font-size: .88rem; }
25
+ .ctr-config .input-sm { width: 65px; text-align: center; }
26
+
27
+ .ctr-hist { display: flex; flex-wrap: wrap; gap: .4rem; }
28
+ .ctr-hist-item { display: inline-flex; align-items: center; gap: .3rem;
29
+ padding: .3rem .65rem; border-radius: var(--radius);
30
+ background: var(--bg-hover); border: 1px solid var(--border);
31
+ font-size: .82rem; font-variant-numeric: tabular-nums;
32
+ transition: border-color .15s; }
33
+ .ctr-hist-item:last-child { border-color: var(--accent); background: rgba(88,166,255,.06); }
34
+ .ctr-hist-op { color: var(--text-muted); font-weight: 500; }
35
+ .ctr-hist-val { color: var(--accent); font-weight: 600; }
36
+
37
+ @media (max-width: 768px) {
38
+ .ctr-num { font-size: 2.75rem; }
39
+ .ctr-actions { gap: .5rem; }
40
+ .ctr-actions .btn { min-width: 100px; }
41
+ .ctr-config { flex-wrap: wrap; gap: .75rem; justify-content: center; }
42
+ }
43
+ @media (max-width: 480px) {
44
+ .ctr-num { font-size: 2.25rem; }
45
+ .ctr-actions .btn { min-width: 0; flex: 1; }
46
+ }
47
+ `,
48
+
49
+ state: () => ({
50
+ count: 0,
51
+ step: 1,
52
+ history: [],
53
+ }),
54
+
55
+ computed: {
56
+ isNegative: (state) => state.count < 0,
57
+ historyCount: (state) => state.history.length,
58
+ lastAction: (state) => state.history.length ? state.history[state.history.length - 1] : null,
59
+ },
60
+
61
+ watch: {
62
+ count(val) {
63
+ if (val === 100) $.bus.emit('toast', { message: 'Century! 🎉', type: 'success' });
64
+ if (val === -100) $.bus.emit('toast', { message: 'Negative century!', type: 'error' });
65
+ },
66
+ },
67
+
68
+ increment() {
69
+ this.state.count += this.state.step;
70
+ this._pushHistory('+', this.state.step, this.state.count);
71
+ },
72
+
73
+ decrement() {
74
+ this.state.count -= this.state.step;
75
+ this._pushHistory('−', this.state.step, this.state.count);
76
+ },
77
+
78
+ _pushHistory(action, value, result) {
79
+ const raw = this.state.history.__raw || this.state.history;
80
+ const next = [...raw, { id: Date.now(), action, value, result }];
81
+ this.state.history = next.length > 8 ? next.slice(-8) : next;
82
+ },
83
+
84
+ reset() {
85
+ this.state.count = 0;
86
+ this.state.history = [];
87
+ $.bus.emit('toast', { message: 'Counter reset!', type: 'info' });
88
+ },
89
+
90
+ render() {
91
+ return `
92
+ <div class="page-header">
93
+ <h1>Counter</h1>
94
+ <p class="subtitle"><code>computed</code>, <code>watch</code>, <code>@click</code>, <code>z-model</code>, <code>z-class</code>, and <code>z-for</code> with <code>z-key</code>.</p>
95
+ </div>
96
+
97
+ <div class="card">
98
+ <div class="ctr-display">
99
+ <div class="ctr-num" z-class="{'negative': count < 0}">${this.state.count}</div>
100
+ <div class="ctr-label">current value${this.state.step !== 1 ? ` · step ${this.state.step}` : ''}</div>
101
+ </div>
102
+
103
+ <div class="ctr-actions">
104
+ <button class="btn btn-outline" @click="decrement">− Subtract</button>
105
+ <button class="btn btn-primary" @click="increment">+ Add</button>
106
+ </div>
107
+
108
+ <div class="ctr-config">
109
+ <label>Step size
110
+ <input type="number" z-model="step" z-number min="1" max="100" class="input input-sm" />
111
+ </label>
112
+ <button class="btn btn-ghost btn-sm" @click="reset">Reset</button>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="card" z-if="history.length > 0">
117
+ <h3>History <small style="color:var(--text-muted);font-weight:400;font-size:.85rem;">${this.computed.historyCount} entries</small></h3>
118
+ <div class="ctr-hist">
119
+ <span z-for="e in history" z-key="{{e.id}}" class="ctr-hist-item">
120
+ <span class="ctr-hist-op">{{e.action}}{{e.value}}</span>
121
+ <span class="ctr-hist-val">→ {{e.result}}</span>
122
+ </span>
123
+ </div>
124
+ </div>
125
+ `;
126
+ }
127
+ });