@phillipsharring/graspr-framework 0.2.5 → 0.2.7

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.
package/CLAUDE.md ADDED
@@ -0,0 +1,257 @@
1
+ # Graspr Frontend Framework — Claude Code Notes
2
+
3
+ ## Architecture
4
+
5
+ HTMX + Handlebars + Tailwind CSS frontend framework, built with Vite. Installed via npm as `@phillipsharring/graspr-framework`.
6
+
7
+ ## HTMX Boosted Navigation (CRITICAL)
8
+
9
+ Apps use `hx-boost="true"` on `<body>`. Every `<a>` and `<form>` is automatically AJAX-ified.
10
+
11
+ ### How It Works
12
+
13
+ - `<body>` has `hx-target="#app" hx-select="#app" hx-swap="outerHTML"` — all boosted links swap only `<main id="app">`, preserving header/footer/templates.
14
+ - Elements outside `#app` (header widgets) persist across navigation.
15
+ - Elements inside `#app` are fresh DOM nodes after every nav.
16
+ - Cross-layout navigation forces a full page reload (layout mismatch detection).
17
+
18
+ ### The Rule
19
+
20
+ **Every feature must work in BOTH scenarios:**
21
+ 1. Full page load (direct URL, refresh, cross-layout nav)
22
+ 2. Boosted nav (clicking a link within the same layout)
23
+
24
+ Key implications:
25
+ - `DOMContentLoaded` does NOT fire on boosted nav — only `htmx:afterSwap`/`htmx:afterSettle`
26
+ - HTMX lifecycle: `beforeSwap` → DOM swap → `afterSwap` → settle (processes `hx-trigger`) → `afterSettle`
27
+ - Fire custom events in `afterSettle`, NOT `afterSwap` — new elements' `hx-trigger` isn't wired until settle
28
+
29
+ ## Self-Loading Elements
30
+
31
+ Elements that load their own content via HTMX:
32
+
33
+ ```html
34
+ <div hx-get="/api/..." hx-trigger="auth-load, refresh"
35
+ hx-target="this" hx-select="unset" hx-swap="innerHTML"
36
+ handlebars-array-template="..." data-requires-auth>
37
+ ```
38
+
39
+ - `hx-target="this"` + `hx-select="unset"` overrides body's `#app` targeting
40
+ - `data-requires-auth` needed for `auth-load` to fire
41
+ - Do NOT use `hx-disinherit` — breaks boosted nav by blocking `hx-boost` inheritance
42
+ - Do NOT use `hx-push-url="false"` — unnecessary and child links inherit it, breaking URL updates
43
+
44
+ ## Auth System
45
+
46
+ - `auth.js` makes a single `/api/auth/me` call per page load, caches the result
47
+ - `checkAuth()` → `Promise<boolean>`
48
+ - `getAuthData()` → `Promise<{authenticated, username, permissions}>`
49
+ - `refreshAuthData()` → invalidates cache, re-fetches
50
+
51
+ ### Auth-Gated Elements
52
+
53
+ ```html
54
+ <!-- Loads content only when authenticated -->
55
+ <div data-requires-auth hx-trigger="auth-load, refresh" ...></div>
56
+
57
+ <!-- Visible only when logged in -->
58
+ <div data-show-if-auth hidden>...</div>
59
+
60
+ <!-- Visible only when logged out -->
61
+ <div data-hide-if-auth hidden>...</div>
62
+
63
+ <!-- Login/logout links (one of each, both start hidden) -->
64
+ <a data-auth-login hidden>Login</a>
65
+ <a data-auth-logout hidden>Logout</a>
66
+ ```
67
+
68
+ - `data-requires-auth` widgets must use `hx-trigger="auth-load"`, never `"load"` (fires before auth resolves)
69
+ - `data-show-if-auth` / `data-hide-if-auth` must include `hidden` attribute in markup (no flash)
70
+ - `auth-load` fires from `applyAuthState()` in `afterSettle`, NOT `afterSwap`
71
+
72
+ ### Permission Gating
73
+
74
+ ```html
75
+ <div data-requires-permission="admin.access" hidden>Admin only content</div>
76
+ ```
77
+
78
+ Revealed by `applyAuthState()` if user has the permission.
79
+
80
+ ## Dynamic Routes
81
+
82
+ File-based routing with `[id]` or `[slug]` parameters:
83
+ - `pages/things/[id]/index.html` → route `/things/{uuid}/`
84
+ - Both `[id]/index.html` and `[id].html` work
85
+
86
+ ### Pattern
87
+
88
+ ```js
89
+ window.onReady(function() {
90
+ var params = App.getRouteParams('/things/[id]/');
91
+ if (!params || !params.id) return;
92
+ detail.setAttribute('hx-get', '/api/things/' + params.id);
93
+ window.htmx.process(detail);
94
+ window.htmx.trigger(detail, 'refresh');
95
+ });
96
+ ```
97
+
98
+ - Don't use `hx-trigger="auth-load"` with dynamically-set `hx-get` — race condition
99
+ - After changing `hx-get`, call `htmx.process(el)` before triggering
100
+ - CloudFront URL rewrite function must be updated for new dynamic routes in production
101
+
102
+ ## `window.onReady(fn)` (CRITICAL)
103
+
104
+ Defined in each layout's `<head>`. Defers callback to `DOMContentLoaded` unless `readyState` is `'complete'`.
105
+
106
+ ```js
107
+ window.onReady = function(fn) {
108
+ if (document.readyState === 'complete') {
109
+ fn();
110
+ } else {
111
+ document.addEventListener('DOMContentLoaded', fn);
112
+ }
113
+ };
114
+ ```
115
+
116
+ Must check for `'complete'`, NOT `'loading'`. Module scripts (`type="module"`) execute between `'interactive'` and `DOMContentLoaded`. Checking `readyState !== 'loading'` would run the callback immediately at `'interactive'`, before modules have set up `window.App`.
117
+
118
+ ## Handlebars Templates
119
+
120
+ ### Two Template Types
121
+
122
+ - `handlebars-array-template` — renders ONCE with `{ data: rows }`. Template must use `{{#each data}}`.
123
+ - `handlebars-template` (non-array) — spreads data into context. Use `{{data.field}}` for fields that collide with the response envelope (e.g. `status`).
124
+
125
+ ### Template Gotchas
126
+
127
+ - `{{#if}}` as bare HTML attributes inside `<template>` tags gets mangled by the HTML parser. Duplicate the element with `{{#if}}/{{else}}` between tags instead.
128
+ - `{{> partial}}` in templates: `innerHTML` escapes `>` to `&gt;`. Framework handles this centrally.
129
+ - `eq` helper is an expression helper, not a block helper. Use `{{#if (eq status "active")}}`, NOT `{{#eq status "active"}}` (outputs "true"/"false" as text).
130
+ - Other expression helpers: `neq`, `and`, `or`, `notin`, `truncate`, `upper`, `humanize`, `json`, `timeAgo`, `formatDateTime`.
131
+
132
+ ## Modal Form API
133
+
134
+ ```js
135
+ App.ui.openFormModal({
136
+ templateId: 'my-form-template',
137
+ title: 'Edit Thing',
138
+ formUrl: '/api/things/' + id,
139
+ formMethod: 'patch',
140
+ fields: { name: 'current value' },
141
+ size: 'sm', // 'sm' | 'lg' | 'takeover'
142
+ });
143
+ ```
144
+
145
+ - `fields: { name: value }` — populates form inputs by name attribute
146
+ - `formUrl` + `formMethod` must both be specified
147
+
148
+ ### Modal Refresh After Submit
149
+
150
+ On the `<form>` element:
151
+ - `data-refresh-target="#some-element"` — fires HTMX `refresh` trigger on that element
152
+ - `data-refresh-event="event-name"` — dispatches custom event on `document.body`
153
+
154
+ ## Inline Script Rules
155
+
156
+ - Scope event listeners to elements INSIDE `#app` so they're GC'd on boosted nav
157
+ - Exception: `DOMContentLoaded` listeners are fine (fire once per page load)
158
+ - All inline scripts should be wrapped in `window.onReady(function() { ... })`
159
+
160
+ ## Lifecycle Hooks
161
+
162
+ ```js
163
+ import { onPageLoad, onAfterSwap, onAfterSettle } from '@phillipsharring/graspr-framework';
164
+ onPageLoad(function(doc) { ... }); // DOMContentLoaded (safe if already fired)
165
+ onAfterSwap(function(target) { ... }); // #app swap via boosted nav
166
+ onAfterSettle(function(target) { ... }); // after HTMX processes hx-trigger on new elements
167
+ ```
168
+
169
+ ## Namespace Convention
170
+
171
+ Apps define a global namespace (e.g. `window.App`) in their entry point:
172
+
173
+ ```js
174
+ window.App = {
175
+ api: { fetch: apiFetch },
176
+ getRouteParams,
177
+ escapeHtml,
178
+ ui: {
179
+ toast: GrasprToast,
180
+ modal: { open, close, isOpen },
181
+ confirm: GrasprConfirm,
182
+ openFormModal,
183
+ },
184
+ hooks: { onAfterSwap, onAfterSettle, onPageLoad },
185
+ };
186
+ ```
187
+
188
+ The namespace name is app-specific (not framework-defined).
189
+
190
+ ## Tailwind + Framework CSS
191
+
192
+ App's `style.css` must scan framework JS for dynamic class names:
193
+
194
+ ```css
195
+ @source "../../node_modules/@phillipsharring/graspr-framework/src/**/*.js";
196
+ ```
197
+
198
+ Tailwind v4 doesn't detect classes in inline `<script>` tags. Use `@source inline("...")` to safelist dynamic class names built in JS.
199
+
200
+ ## A/B Testing
201
+
202
+ ### HTML Markup
203
+
204
+ ```html
205
+ <!-- Variant A -->
206
+ <div data-ab-test="test-name" data-ab-variant="a" style="display:none">...</div>
207
+ <!-- Variant B -->
208
+ <div data-ab-test="test-name" data-ab-variant="b" style="display:none">...</div>
209
+ ```
210
+
211
+ - Both variants start `display:none`
212
+ - Framework fetches assignments from `/api/ab/assignments`, shows the assigned variant, removes the other
213
+ - Fallback on API failure: shows variant "a"
214
+ - When a test is paused/completed: no assignment returned, both variants removed (hole in page)
215
+ - Intended workflow: run test → pick winner → update HTML to only have winning variant (remove `data-ab-test` attributes)
216
+
217
+ ### Conversion Tracking
218
+
219
+ ```html
220
+ <button data-ab-capture="signup">Sign Up</button>
221
+ ```
222
+
223
+ Or programmatically: `App.ab.capture('event-name')`
224
+
225
+ ## FOUC Prevention
226
+
227
+ For production builds, inject inline CSS that hides the page until the stylesheet loads:
228
+
229
+ ```js
230
+ // In build script (html-compiler)
231
+ '<style>.css-loading{visibility:hidden}.css-loading body{visibility:hidden}</style>' +
232
+ '<link rel="stylesheet" href="..." onload="document.documentElement.classList.remove(\'css-loading\')" />'
233
+ ```
234
+
235
+ Add `css-loading` class to `<html>` in all layouts.
236
+
237
+ ## Common Pitfalls
238
+
239
+ 1. **Forgetting boosted nav**: Works on refresh but not link-click = boosted nav issue.
240
+ 2. **`hx-trigger="load"` for auth widgets**: Fires before auth resolves. Use `auth-load`.
241
+ 3. **`hx-disinherit`**: Breaks boosted nav. Never use it.
242
+ 4. **`hx-push-url="false"` on self-loading elements**: Unnecessary and breaks child link URL updates.
243
+ 5. **Body-level inheritance**: All children inherit `hx-target="#app" hx-select="#app"`. Self-loading elements must set `hx-target="this" hx-select="unset"`.
244
+ 6. **Listener accumulation**: Scope to elements inside `#app`, not `document.body`.
245
+ 7. **`afterSwap` vs `afterSettle`**: Custom events targeting `hx-trigger` must fire in `afterSettle`.
246
+ 8. **`handlebars-template` on elements with links**: Boosted links inherit the template attribute. Framework handles this centrally.
247
+ 9. **Template wrapper divs eat negative margins**: Put bleed classes on the wrapper, not template content.
248
+ 10. **HTMX + json-enc can't send arrays**: `FormData` collapses duplicate keys. Collect in `htmx:configRequest` and `JSON.stringify()`.
249
+ 11. **Module script timing**: `window.onReady` must check `readyState === 'complete'`, not `!== 'loading'`, or callbacks run before modules define globals like `window.App`.
250
+
251
+ ## S3/CloudFront Deployment
252
+
253
+ - `aws s3 sync` flag order matters: `--exclude "*" --include "*.html"` (exclude FIRST).
254
+ - Reversed order excludes everything.
255
+ - HTML files get short cache (`max-age=300`), assets get immutable cache (`max-age=31536000`).
256
+ - CloudFront Function handles URL rewriting for dynamic routes (`/things/{uuid}/` → `/things/[id]/index.html`).
257
+ - Don't use `--delete` on the HTML sync pass — it deletes assets uploaded by the first pass.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phillipsharring/graspr-framework",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "HTMX + Handlebars + Tailwind frontend framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,6 +52,17 @@ function applyAuthState(authData) {
52
52
  loginLink?.removeAttribute('hidden');
53
53
  }
54
54
 
55
+ // Auth-visibility elements — show or hide based on auth state.
56
+ // Use hidden attribute in markup so they start invisible (no flash).
57
+ // data-show-if-auth: hidden by default, revealed when authenticated
58
+ // data-hide-if-auth: hidden by default, revealed when NOT authenticated
59
+ document.querySelectorAll('[data-show-if-auth]').forEach(el => {
60
+ if (authenticated) el.removeAttribute('hidden');
61
+ });
62
+ document.querySelectorAll('[data-hide-if-auth]').forEach(el => {
63
+ if (!authenticated) el.removeAttribute('hidden');
64
+ });
65
+
55
66
  // Widgets that require an authenticated session.
56
67
  // Track triggered elements in a WeakSet so each element only fires once,
57
68
  // even if multiple afterSwap/afterSettle handlers call applyAuthState while
@@ -235,11 +235,11 @@ async function fetchPendingCount(progressUrl) {
235
235
  function renderProgressUI(total, { progressLabel = 'Processing...', progressItemLabel = 'processed' } = {}) {
236
236
  return `
237
237
  <div class="space-y-4 py-2" data-progress-container>
238
- <p class="text-sm text-slate-700 font-medium">${progressLabel}</p>
239
- <div class="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
240
- <div class="confirm-progress-bar bg-slate-700 h-3 rounded-full" style="width: 0%"></div>
238
+ <p class="text-sm font-medium confirm-message">${progressLabel}</p>
239
+ <div class="w-full rounded-full h-3 overflow-hidden" style="background: var(--graspr-progress-bg)">
240
+ <div class="confirm-progress-bar h-3 rounded-full" style="width: 0%; background: var(--graspr-progress-fill)"></div>
241
241
  </div>
242
- <p class="text-sm text-slate-500" data-progress-text>0 of ${total} ${progressItemLabel}</p>
242
+ <p class="text-sm confirm-subtext" data-progress-text>0 of ${total} ${progressItemLabel}</p>
243
243
  </div>
244
244
  `;
245
245
  }
package/src/ui/toast.js CHANGED
@@ -89,17 +89,17 @@ export const GrasprToast = {
89
89
  export function registerToastHelpers(Handlebars) {
90
90
  Handlebars.registerHelper('toastClass', (status) => {
91
91
  const s = String(status || 'success').toLowerCase();
92
- // Note: accept the user's typo "eror" as error.
93
92
  const normalized = s === 'eror' ? 'error' : s;
94
93
 
94
+ // Use CSS-var-driven classes for themeable toasts
95
95
  switch (normalized) {
96
96
  case 'warning':
97
- return 'bg-amber-100 text-amber-900 border-amber-200';
97
+ return 'graspr-toast-warning';
98
98
  case 'error':
99
- return 'bg-red-600 text-white border-red-700';
99
+ return 'graspr-toast-error';
100
100
  case 'success':
101
101
  default:
102
- return 'bg-green-600 text-white border-green-700';
102
+ return 'graspr-toast-success';
103
103
  }
104
104
  });
105
105
  }
package/styles/base.css CHANGED
@@ -1,6 +1,59 @@
1
1
  /* Graspr Framework — base styles
2
2
  Import this from your app's CSS after @import 'tailwindcss' and @source directives. */
3
3
 
4
+ /* ── Theme variables ──
5
+ Override these in your app's CSS to customize the framework's UI components.
6
+
7
+ Example:
8
+ :root {
9
+ --graspr-text: #1C1C1C;
10
+ --graspr-primary: #A3B8A5;
11
+ --graspr-primary-hover: #8AA38D;
12
+ --graspr-surface: #FEFBF8;
13
+ }
14
+ */
15
+ :root {
16
+ /* Text */
17
+ --graspr-text: rgb(15 23 42); /* slate-900 */
18
+ --graspr-text-secondary: rgb(71 85 105); /* slate-600 */
19
+ --graspr-text-muted: rgb(100 116 139); /* slate-500 */
20
+
21
+ /* Primary action (confirm buttons, active states) */
22
+ --graspr-primary: rgb(15 23 42); /* slate-900 */
23
+ --graspr-primary-hover: rgb(30 41 59); /* slate-800 */
24
+ --graspr-primary-text: white;
25
+
26
+ /* Secondary/cancel */
27
+ --graspr-border: rgb(203 213 225); /* slate-300 */
28
+ --graspr-border-light: rgb(226 232 240); /* slate-200 */
29
+ --graspr-surface: rgb(248 250 252); /* slate-50 */
30
+ --graspr-surface-hover: rgb(241 245 249); /* slate-100 */
31
+
32
+ /* Toast: success */
33
+ --graspr-toast-success-bg: rgb(22 163 74); /* green-600 */
34
+ --graspr-toast-success-text: white;
35
+ --graspr-toast-success-border: rgb(21 128 61); /* green-700 */
36
+
37
+ /* Toast: warning */
38
+ --graspr-toast-warning-bg: rgb(254 243 199); /* amber-100 */
39
+ --graspr-toast-warning-text: rgb(120 53 15); /* amber-900 */
40
+ --graspr-toast-warning-border: rgb(253 230 138); /* amber-200 */
41
+
42
+ /* Toast: error */
43
+ --graspr-toast-error-bg: rgb(220 38 38); /* red-600 */
44
+ --graspr-toast-error-text: white;
45
+ --graspr-toast-error-border: rgb(185 28 28); /* red-700 */
46
+
47
+ /* Modal */
48
+ --graspr-modal-bg: white;
49
+ --graspr-modal-border: var(--graspr-border-light);
50
+ --graspr-modal-backdrop: rgba(0, 0, 0, 0.25);
51
+
52
+ /* Progress bar */
53
+ --graspr-progress-bg: var(--graspr-border-light);
54
+ --graspr-progress-fill: var(--graspr-text-secondary);
55
+ }
56
+
4
57
  /* Override inline FOUC prevention — body becomes visible once this stylesheet loads */
5
58
  body { opacity: 1; }
6
59
 
@@ -56,12 +109,19 @@ button[disabled] {
56
109
  transition: transform 150ms ease-out;
57
110
  transform: translateY(2rem);
58
111
  outline: none;
112
+ background: var(--graspr-modal-bg);
113
+ border-color: var(--graspr-modal-border);
59
114
  }
60
115
 
61
116
  #global-modal.modal-open [role="dialog"] {
62
117
  transform: translateY(0);
63
118
  }
64
119
 
120
+ /* Modal backdrop */
121
+ #global-modal > .absolute.inset-0 {
122
+ background: var(--graspr-modal-backdrop);
123
+ }
124
+
65
125
  /* Darker backdrop when examples layout is active */
66
126
  body:has(#app[data-layout="examples"]) #global-modal .absolute.inset-0 {
67
127
  background: rgba(255, 255, 255, 0.50);
@@ -128,8 +188,8 @@ body:has(#app[data-layout="examples"]) #global-modal .absolute.inset-0 {
128
188
  }
129
189
 
130
190
  /* Sortable drag-and-drop */
131
- .sortable-ghost { opacity: 0.4; background-color: #dbeafe; }
132
- .sortable-chosen { background-color: #eff6ff; }
191
+ .sortable-ghost { opacity: 0.4; background-color: var(--graspr-surface); }
192
+ .sortable-chosen { background-color: var(--graspr-surface); }
133
193
  .sortable-disabled .drag-handle { visibility: hidden; pointer-events: none; }
134
194
 
135
195
  /* Active nav link — faux-bold via text-shadow to avoid layout shift */
@@ -137,20 +197,37 @@ body:has(#app[data-layout="examples"]) #global-modal .absolute.inset-0 {
137
197
  text-shadow: 0 0 0.01px currentColor, 0 0 0.01px currentColor;
138
198
  }
139
199
 
140
- /* ---- Confirm dialog: default (admin/base) ---- */
141
- .confirm-message { color: rgb(15 23 42); } /* slate-900 */
142
- .confirm-subtext { color: rgb(71 85 105); } /* slate-600 */
143
- .confirm-checkbox { color: rgb(51 65 85); } /* slate-700 */
200
+ /* ── Confirm dialog ── */
201
+ .confirm-message { color: var(--graspr-text); }
202
+ .confirm-subtext { color: var(--graspr-text-secondary); }
203
+ .confirm-checkbox { color: var(--graspr-text); }
144
204
  .confirm-cancel-btn {
145
- border-color: rgb(203 213 225); /* slate-300 */
146
- color: rgb(51 65 85);
205
+ border-color: var(--graspr-border);
206
+ color: var(--graspr-text);
147
207
  }
148
- .confirm-cancel-btn:hover { background: rgb(248 250 252); } /* slate-50 */
208
+ .confirm-cancel-btn:hover { background: var(--graspr-surface); }
149
209
  .confirm-ok-btn {
150
- background: rgb(15 23 42); /* slate-900 */
151
- color: white;
210
+ background: var(--graspr-primary);
211
+ color: var(--graspr-primary-text);
212
+ }
213
+ .confirm-ok-btn:hover { background: var(--graspr-primary-hover); }
214
+
215
+ /* ── Toast variants ── */
216
+ .graspr-toast-success {
217
+ background: var(--graspr-toast-success-bg);
218
+ color: var(--graspr-toast-success-text);
219
+ border-color: var(--graspr-toast-success-border);
220
+ }
221
+ .graspr-toast-warning {
222
+ background: var(--graspr-toast-warning-bg);
223
+ color: var(--graspr-toast-warning-text);
224
+ border-color: var(--graspr-toast-warning-border);
225
+ }
226
+ .graspr-toast-error {
227
+ background: var(--graspr-toast-error-bg);
228
+ color: var(--graspr-toast-error-text);
229
+ border-color: var(--graspr-toast-error-border);
152
230
  }
153
- .confirm-ok-btn:hover { background: rgb(30 41 59); } /* slate-800 */
154
231
 
155
232
  /* Table column sort headers */
156
233
  th[data-sort] { cursor: pointer; user-select: none; }