Diff -- /var/www/vhosts/webstls.com/httpdocs/TEMPLATES/webstls_wrapper.html

O Operator
Diff

/var/www/vhosts/webstls.com/httpdocs/TEMPLATES/webstls_wrapper.html

added on WebSTLs (webstls.com) at 2026-07-11 08:13:38

Added
+1152
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to a180120b054e
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!DOCTYPE html>
2<html lang="en" class="$html_class">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
7<meta http-equiv="Pragma" content="no-cache">
8<meta http-equiv="Expires" content="0">
9 <link rel="icon" type="image/svg+xml" href="/assets/images/favicon.svg">
10 <title>$title &middot; $website_title</title>
11$meta_tags
12 <link rel="preconnect" href="https://fonts.googleapis.com">
13 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
14 <link href="https://fonts.googleapis.com/css2?family=Inter:wght\@400;500;600;700&family=Space+Grotesk:wght\@500;600;700&family=JetBrains+Mono:wght\@400;600&display=swap" rel="stylesheet">
15 <link rel="stylesheet" href="$assets_css/site.css?v=2026-07-02-padfix">
16 <style>
17 /* Collapsible sidebar -- narrows to an icon rail at --sidebar-rail-w
18 (48px) instead of collapsing to zero. Ports the ShopCart pattern
19 exactly: section labels become thin divider lines, label spans
20 hide, signout icon hides, brand mark shrinks but stays visible,
21 and hover tooltips show the link's title on each icon. */
22 :root { --sidebar-rail-w: 48px; }
23 .app { transition: grid-template-columns 0.22s ease; }
24 .sidebar { transition: padding 0.22s ease; overflow-x: hidden; }
25 html.sidebar-collapsed .app { grid-template-columns: var(--sidebar-rail-w) 1fr; }
26
27 /* When collapsed: hide text, center icons, shrink horizontal padding. */
28 html.sidebar-collapsed .sidebar { padding-left: 0; padding-right: 0; }
29 html.sidebar-collapsed .sidebar-brand { justify-content: center; padding-left: 4px; padding-right: 4px; }
30 html.sidebar-collapsed .sidebar-brand .brand-mark { width: 32px; height: 32px; font-size: 13px; }
31 html.sidebar-collapsed .brand-text,
32 html.sidebar-collapsed .sidebar-section-label,
33 html.sidebar-collapsed .sidebar-link span,
34 html.sidebar-collapsed .sidebar-link .badge,
35 html.sidebar-collapsed .user-meta,
36 html.sidebar-collapsed .sidebar-foot .icon-btn { display: none; }
37 html.sidebar-collapsed .sidebar-nav { padding-left: 6px; padding-right: 6px; }
38 html.sidebar-collapsed .sidebar-link {
39 justify-content: center;
40 padding: 11px 0;
41 gap: 0;
42 }
43 html.sidebar-collapsed .sidebar-link svg { width: 20px; height: 20px; }
44 /* Pull the accent bar back into the rail's left gutter. */
45 html.sidebar-collapsed .sidebar-link.active::before { left: -6px; }
46 /* Unread dot on Messages: float as a tiny corner indicator. */
47 html.sidebar-collapsed .sidebar-link .menu-dot {
48 position: absolute;
49 top: 6px; right: 6px;
50 margin: 0;
51 }
52 /* Section labels become a thin brand-tinted divider line since we
53 lose the text label that would otherwise separate the groups. */
54 html.sidebar-collapsed .sidebar-section-label {
55 display: block !important;
56 visibility: hidden;
57 height: 1px;
58 padding: 0;
59 margin: 12px 14px 6px;
60 background: rgba(59,130,246,0.20);
61 }
62 html.sidebar-collapsed .sidebar-foot {
63 flex-direction: column;
64 padding: 12px 6px;
65 gap: 8px;
66 }
67 html.sidebar-collapsed .sidebar-foot .user-chip { justify-content: center; }
68 html.sidebar-collapsed .user-avatar { width: 32px; height: 32px; font-size: 12px; }
69
70 /* Tooltip on hover when collapsed -- shows the link's label using
71 its title attribute, anchored to the right of the rail so it
72 doesn't bump up against the content area. */
73 html.sidebar-collapsed .sidebar-link { position: relative; }
74 html.sidebar-collapsed .sidebar-link[title]:hover::after {
75 content: attr(title);
76 position: absolute;
77 left: calc(100% + 14px);
78 top: 50%;
79 transform: translateY(-50%);
80 padding: 6px 10px;
81 background: var(--col-surface-3);
82 border: 1px solid var(--col-border-2);
83 color: var(--col-text);
84 border-radius: 6px;
85 font-size: 12px;
86 font-weight: 600;
87 white-space: nowrap;
88 box-shadow: 0 4px 14px rgba(0,0,0,0.4);
89 z-index: 200;
90 pointer-events: none;
91 }
92
93 /* When collapsed the content area widens -- drop the centered cap. */
94 html.sidebar-collapsed .app-content { max-width: none; padding-left: 24px; padding-right: 24px; }
95 html.sidebar-collapsed .topbar { padding-left: 24px; padding-right: 24px; }
96
97 /* Topbar mini brand. Only visible at the mobile breakpoint where
98 the sidebar fully slides out -- on desktop the icon rail keeps
99 the brand mark on-screen, so this duplicate is hidden. */
100 .topbar-brand {
101 display: none;
102 align-items: center;
103 gap: 10px;
104 text-decoration: none;
105 color: inherit;
106 margin-right: 14px;
107 }
108 .topbar-brand-name {
109 font-family: var(--font-display, inherit);
110 font-size: 15px;
111 font-weight: 700;
112 color: #fff;
113 letter-spacing: 0.3px;
114 }
115
116 /* Admin chat pill -- always-present topbar control with two
117 states: idle (blue, calm) when the queue is empty, and active
118 (green, pulsing) when visitors are waiting. The DOM is the
119 same in both states; JS just swaps cw-idle/cw-active classes
120 so transitions animate cleanly when a new chat lands or gets
121 resolved. */
122 .chat-pill {
123 position: relative;
124 display: inline-flex;
125 align-items: center;
126 gap: 9px;
127 padding: 0 14px;
128 height: 36px;
129 border-radius: 10px;
130 font-size: 12px;
131 font-weight: 700;
132 letter-spacing: 0.5px;
133 text-transform: uppercase;
134 text-decoration: none;
135 transition: background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
136 }
137 .chat-pill .cw-count { font-weight: 800; color: #fff; line-height: 1; }
138 .chat-pill .cw-badge {
139 display: inline-flex;
140 align-items: center;
141 color: #fff;
142 font-size: 12px;
143 font-weight: 800;
144 line-height: 1;
145 margin-left: 4px;
146 }
147 .chat-pill .cw-badge.cw-badge-empty { display: none; }
148 .cw-ping {
149 position: relative;
150 width: 9px; height: 9px;
151 border-radius: 50%;
152 flex-shrink: 0;
153 transition: background 0.18s, box-shadow 0.18s;
154 }
155 .cw-ping::before,
156 .cw-ping::after {
157 content: '';
158 position: absolute;
159 inset: 0;
160 border-radius: 50%;
161 opacity: 0;
162 pointer-events: none;
163 }
164 /* --- Idle: blue, no animation -------------------------------- */
165 .chat-pill.cw-idle {
166 background: linear-gradient(135deg, rgba(59,130,246,0.18) 0%, rgba(37,99,235,0.12) 100%);
167 border: 1px solid rgba(59,130,246,0.45);
168 color: #93c5fd;
169 box-shadow: 0 0 10px rgba(37,99,235,0.18);
170 }
171 .chat-pill.cw-idle:hover {
172 background: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(37,99,235,0.20) 100%);
173 color: #fff;
174 transform: translateY(-1px);
175 box-shadow: 0 0 16px rgba(37,99,235,0.45);
176 }
177 .chat-pill.cw-idle:focus-visible {
178 outline: 2px solid #60a5fa;
179 outline-offset: 2px;
180 }
181 .chat-pill.cw-idle .cw-ping {
182 background: #3b82f6;
183 box-shadow: 0 0 6px rgba(59,130,246,0.7);
184 }
185
186 /* --- Active: green, pulsing dot + count badge ----------------- */
187 .chat-pill.cw-active {
188 background: linear-gradient(135deg, rgba(16,185,129,0.28) 0%, rgba(16,185,129,0.16) 100%);
189 border: 1px solid rgba(16,185,129,0.70);
190 color: #fff;
191 box-shadow: 0 0 18px rgba(16,185,129,0.45);
192 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
193 }
194 /* JS toggles this class on/off every second while active, giving
195 a fade-in/fade-out blink that can't be killed by OS-level
196 prefers-reduced-motion (which silences CSS @keyframes). */
197 .chat-pill.cw-active.cw-blink-off { opacity: 0.65; }
198 .chat-pill.cw-active:hover {
199 background: linear-gradient(135deg, rgba(16,185,129,0.34) 0%, rgba(16,185,129,0.20) 100%);
200 color: #fff;
201 transform: translateY(-1px);
202 box-shadow: 0 0 22px rgba(16,185,129,0.65);
203 }
204 .chat-pill.cw-active:focus-visible {
205 outline: 2px solid #34d399;
206 outline-offset: 2px;
207 }
208 .chat-pill.cw-active .cw-ping {
209 background: #10b981;
210 box-shadow: 0 0 8px rgba(16,185,129,0.9);
211 }
212 .chat-pill.cw-active .cw-ping::before,
213 .chat-pill.cw-active .cw-ping::after {
214 background: #10b981;
215 animation: cw-ping 1.8s cubic-bezier(0.0, 0.0, 0.2, 1) infinite;
216 }
217 .chat-pill.cw-active .cw-ping::after { animation-delay: 0.9s; }
218
219 @keyframes cw-ping {
220 0% { transform: scale(1); opacity: 0.7; }
221 80% { opacity: 0; }
222 100% { transform: scale(3.6); opacity: 0; }
223 }
224 /* Whole-box blink: matches what the pill looks like on hover at
225 its peak, then settles back to idle-active. Animates background,
226 border, AND box-shadow so the entire pill feels alive instead
227 of just a soft outer glow change. */
228 /* Slow opacity fade -- this animates THE single most reliable
229 CSS property (opacity has no transition competition and works
230 on every browser), so the blink is impossible to miss. The
231 pill fades from fully visible to ~40% and back over 2 seconds. */
232 @keyframes cw-box-blink {
233 0%, 100% { opacity: 1; }
234 50% { opacity: 0.40; }
235 }
236 /* JS-triggered bump when the count goes up. Brief scale on top
237 of the box blink so a new chat literally pops into view. */
238 .chat-pill.cw-active.cw-bump { animation: cw-bump 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), cw-box-blink 2.0s ease-in-out infinite; }
239 @keyframes cw-bump {
240 0% { transform: scale(1); }
241 50% { transform: scale(1.16); }
242 100% { transform: scale(1); }
243 }
244
245 /* Messages pill -- mirrors the chat-pill idle style so the three
246 topbar controls (Chat, Messages, Help) read as one consistent
247 row. Unread count gets a red badge inside the pill instead of
248 the old corner dot so the number is visible at a glance. */
249 .msg-pill {
250 position: relative;
251 display: inline-flex;
252 align-items: center;
253 gap: 9px;
254 padding: 0 14px;
255 height: 36px;
256 border-radius: 10px;
257 font-size: 12px;
258 font-weight: 700;
259 letter-spacing: 0.5px;
260 text-transform: uppercase;
261 text-decoration: none;
262 background: linear-gradient(135deg, rgba(59,130,246,0.18) 0%, rgba(37,99,235,0.12) 100%);
263 border: 1px solid rgba(59,130,246,0.45);
264 color: #93c5fd;
265 box-shadow: 0 0 10px rgba(37,99,235,0.18);
266 transition: opacity 0.45s ease-in-out, background 0.18s, color 0.18s, transform 0.12s, box-shadow 0.18s, border-color 0.18s;
267 }
268 /* JS toggles this on/off every 900ms when has-unread is set,
269 giving the same smooth fade as the active chat pill but in
270 blue. Stays static when there's nothing unread. */
271 .msg-pill.has-unread.msg-blink-off { opacity: 0.65; }
272 .msg-pill .msg-badge.msg-badge-empty { display: none; }
273 .msg-pill:hover {
274 background: linear-gradient(135deg, rgba(59,130,246,0.28) 0%, rgba(37,99,235,0.20) 100%);
275 color: #fff;
276 transform: translateY(-1px);
277 box-shadow: 0 0 16px rgba(37,99,235,0.45);
278 }
279 .msg-pill:focus-visible {
280 outline: 2px solid #60a5fa;
281 outline-offset: 2px;
282 }
283 .msg-pill .msg-dot {
284 width: 9px; height: 9px;
285 border-radius: 50%;
286 background: #3b82f6;
287 box-shadow: 0 0 6px rgba(59,130,246,0.7);
288 flex-shrink: 0;
289 }
290 .msg-pill .msg-badge {
291 display: inline-flex;
292 align-items: center;
293 justify-content: center;
294 min-width: 18px; height: 18px;
295 padding: 0 6px;
296 border-radius: 9px;
297 background: #ef4444;
298 color: #fff;
299 font-size: 11px;
300 font-weight: 800;
301 line-height: 1;
302 box-shadow: 0 0 8px rgba(239,68,68,0.55);
303 margin-left: 2px;
304 }
305
306 /* Prominent topbar Help button. More visible than a plain icon-btn
307 so first-time users can find docs / how-to without hunting. Blue
308 chip with icon + label + soft glow. */
309 .help-btn {
310 display: inline-flex;
311 align-items: center;
312 gap: 7px;
313 height: 36px;
314 padding: 0 14px;
315 border-radius: 10px;
316 background: linear-gradient(135deg, rgba(59,130,246,0.22) 0%, rgba(37,99,235,0.18) 100%);
317 border: 1px solid rgba(59,130,246,0.55);
318 color: #93c5fd;
319 font-size: 13px;
320 font-weight: 700;
321 letter-spacing: 0.3px;
322 text-decoration: none;
323 box-shadow: 0 0 14px rgba(37,99,235,0.32);
324 transition: background 0.15s, color 0.15s, transform 0.12s, box-shadow 0.15s;
325 }
326 .help-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
327 .help-btn:hover {
328 background: linear-gradient(135deg, rgba(59,130,246,0.32) 0%, rgba(37,99,235,0.28) 100%);
329 color: #fff;
330 transform: translateY(-1px);
331 box-shadow: 0 0 18px rgba(37,99,235,0.55);
332 }
333 .help-btn:focus-visible {
334 outline: 2px solid #60a5fa;
335 outline-offset: 2px;
336 }
337 /* Chevron next to the Help label hints at the dropdown. Rotates
338 slightly on hover so the open-state reads visually. */
339 .help-btn-chevron {
340 width: 12px !important; height: 12px !important;
341 opacity: 0.85;
342 transition: transform 0.15s, opacity 0.15s;
343 }
344 .help-menu:hover .help-btn-chevron,
345 .help-menu:focus-within .help-btn-chevron {
346 transform: rotate(180deg);
347 opacity: 1;
348 }
349
350 /* Help-menu dropdown -- mirrors the profile-menu pattern so the two
351 feel like one family. Hover the chip to reveal; clicking the chip
352 still navigates to /help.cgi for keyboard / no-hover users. */
353 .help-menu { position: relative; }
354 .help-dropdown {
355 position: absolute;
356 top: calc(100% + 8px);
357 right: 0;
358 min-width: 240px;
359 background: var(--col-surface-2);
360 border: 1px solid var(--col-border);
361 border-radius: 12px;
362 padding: 6px 0 8px;
363 box-shadow: 0 20px 60px rgba(0,0,0,0.5);
364 opacity: 0; visibility: hidden;
365 transform: translateY(-6px);
366 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
367 z-index: 200;
368 }
369 /* Grace pad so cursor can move from button into the dropdown
370 without crossing a gap that would collapse it. */
371 .help-dropdown::before {
372 content: ""; position: absolute;
373 top: -8px; left: 0; right: 0; height: 8px;
374 }
375 .help-menu:hover .help-dropdown,
376 .help-menu:focus-within .help-dropdown {
377 opacity: 1; visibility: visible; transform: translateY(0);
378 }
379 .help-dropdown-section {
380 font-size: 9px;
381 font-weight: 800;
382 letter-spacing: 1.5px;
383 text-transform: uppercase;
384 color: var(--col-text-3);
385 padding: 10px 16px 4px;
386 }
387 .help-dropdown a {
388 display: flex; align-items: center; gap: 10px;
389 padding: 9px 16px;
390 font-size: 13px; font-weight: 600;
391 color: var(--col-text-2);
392 text-decoration: none;
393 transition: color 0.12s, background 0.12s;
394 }
395 .help-dropdown a svg {
396 width: 14px; height: 14px;
397 flex-shrink: 0;
398 color: var(--col-text-3);
399 }
400 .help-dropdown a:hover,
401 .help-dropdown a:focus-visible {
402 color: #93c5fd;
403 background: var(--col-surface-3);
404 outline: none;
405 }
406 .help-dropdown a:hover svg,
407 .help-dropdown a:focus-visible svg {
408 color: #93c5fd;
409 }
410
411 /* Hamburger toggle at the left of the topbar. Bare white icon by
412 default (no fill, no border) -- on hover the dark-purple chip
413 fades in with a thin white border so the affordance only shows
414 when the cursor lands on it. */
415 .sidebar-toggle {
416 flex-shrink: 0;
417 width: 38px; height: 38px;
418 background: transparent;
419 border: 1px solid transparent;
420 border-radius: 10px;
421 color: #fff;
422 display: inline-flex; align-items: center; justify-content: center;
423 cursor: pointer;
424 margin-right: 16px;
425 box-shadow: none;
426 transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.12s;
427 }
428 .sidebar-toggle:hover {
429 border-color: var(--col-violet);
430 }
431 .sidebar-toggle:active { transform: translateY(1px); }
432 .sidebar-toggle:focus-visible {
433 outline: 2px solid #60a5fa;
434 outline-offset: 2px;
435 }
436 .sidebar-toggle svg { width: 18px; height: 18px; }
437 /* Cluster the hamburger + page title on the left of the topbar
438 so .topbar-tools is pushed to the far right (override the
439 base .topbar `justify-content: space-between` distribution). */
440 .topbar-title { margin-right: auto; }
441
442 /* Impersonation banner -- only rendered while an admin is acting
443 as another user. Bright accent so it's impossible to miss. */
444 .impersonation-banner {
445 display: flex; align-items: center; justify-content: space-between;
446 gap: 14px; flex-wrap: wrap;
447 padding: 10px 18px;
448 background: linear-gradient(90deg, rgba(245,158,11,0.20), rgba(239,68,68,0.20));
449 border-bottom: 1px solid rgba(245,158,11,0.45);
450 color: #fde68a; font-size: 13px;
451 }
452 .impersonation-banner-msg { display:flex; align-items:center; gap:10px; }
453 .impersonation-banner-msg svg { flex-shrink: 0; }
454 .impersonation-banner-msg strong { color: #fff; }
455 .impersonation-banner-form { margin: 0; }
456 .impersonation-banner-stop {
457 background: rgba(239,68,68,0.85);
458 border: 1px solid rgba(239,68,68,1);
459 color: #fff; font-weight: 700;
460 padding: 6px 14px; border-radius: 8px;
461 font-size: 12px; cursor: pointer;
462 letter-spacing: 0.5px;
463 }
464 .impersonation-banner-stop:hover { background: #dc2626; }
465
466 /* Top-right profile menu. Hover over the avatar to reveal the
467 dropdown; uses CSS-only hover with a small grace area so the
468 cursor can move from the avatar into the menu without it
469 collapsing. */
470 .profile-menu { position: relative; flex-shrink: 0; }
471 /* Eye-catching avatar -- vibrant gradient + animated halo so first-
472 time users naturally hover to see what's behind it. The halo is
473 a soft repeating glow at low opacity that never gets noisy. */
474 .profile-menu .avatar {
475 position: relative;
476 width: 38px; height: 38px;
477 border-radius: 50%;
478 background: linear-gradient(135deg, #3b82f6 0%, #6366f1 45%, #06b6d4 100%);
479 display: flex; align-items: center; justify-content: center;
480 font-weight: 800; font-size: 14px; color: #fff;
481 cursor: pointer;
482 letter-spacing: 0.5px;
483 user-select: none;
484 box-shadow:
485 0 0 0 2px rgba(59,130,246,0.45),
486 0 0 14px rgba(99,102,241,0.55);
487 transition: transform 0.18s ease, box-shadow 0.18s ease;
488 }
489 .profile-menu .avatar::before {
490 content: '';
491 position: absolute;
492 inset: -6px;
493 border-radius: 50%;
494 background: radial-gradient(circle, rgba(99,102,241,0.45) 0%, rgba(59,130,246,0.0) 70%);
495 animation: avatar-pulse 2.6s ease-in-out infinite;
496 pointer-events: none;
497 z-index: -1;
498 }
499 @keyframes avatar-pulse {
500 0%, 100% { transform: scale(1); opacity: 0.55; }
501 50% { transform: scale(1.25); opacity: 0.95; }
502 }
503 .profile-menu .avatar:hover {
504 transform: scale(1.08);
505 box-shadow:
506 0 0 0 3px rgba(99,102,241,0.65),
507 0 0 22px rgba(59,130,246,0.75);
508 }
509 .profile-menu:focus-within .avatar {
510 transform: scale(1.05);
511 outline: 2px solid #60a5fa;
512 outline-offset: 3px;
513 }
514 /* Respect reduced-motion preferences -- still highlight the avatar
515 but drop the animated halo for users who opted out. */
516 @media (prefers-reduced-motion: reduce) {
517 .profile-menu .avatar::before { animation: none; }
518 }
519
520 /* Inline trigger (avatar + name + subtitle + chevron). Whole row
521 is the hover target -- hovering anywhere on the trigger opens
522 the dropdown the same way ShopCart and TaskForge do. */
523 .profile-trigger {
524 display: flex;
525 align-items: center;
526 gap: 12px;
527 padding: 4px 10px 4px 4px;
528 border-radius: 14px;
529 cursor: pointer;
530 user-select: none;
531 transition: background 0.18s, box-shadow 0.18s;
532 }
533 .profile-trigger:hover {
534 background: rgba(59,130,246,0.10);
535 box-shadow: inset 0 0 0 1px rgba(59,130,246,0.25);
536 }
537 .profile-menu:focus-within .profile-trigger {
538 background: rgba(59,130,246,0.14);
539 box-shadow: inset 0 0 0 1px rgba(59,130,246,0.45);
540 }
541 .profile-trigger-meta {
542 display: flex; flex-direction: column;
543 min-width: 0;
544 line-height: 1.15;
545 max-width: 200px;
546 }
547 .profile-trigger-name {
548 font-size: 13px;
549 font-weight: 700;
550 color: #fff;
551 white-space: nowrap;
552 overflow: hidden;
553 text-overflow: ellipsis;
554 }
555 .profile-trigger-role {
556 font-weight: 500;
557 color: var(--col-accent-bright);
558 opacity: 0.85;
559 }
560 .profile-trigger-sub {
561 margin-top: 2px;
562 font-size: 11px;
563 font-weight: 500;
564 letter-spacing: 0.3px;
565 color: var(--col-text-3);
566 white-space: nowrap;
567 overflow: hidden;
568 text-overflow: ellipsis;
569 }
570 .profile-chevron {
571 width: 14px; height: 14px;
572 color: var(--col-text-3);
573 flex-shrink: 0;
574 transition: transform 0.18s ease, color 0.18s;
575 }
576 .profile-menu:hover .profile-chevron,
577 .profile-menu:focus-within .profile-chevron {
578 transform: rotate(180deg);
579 color: var(--col-accent-bright);
580 }
581 /* Phones: drop the name+chevron -- only the avatar stays interactive. */
582 @media (max-width: 720px) {
583 .profile-trigger-meta, .profile-chevron { display: none; }
584 .profile-trigger { padding: 0; gap: 0; }
585 .profile-trigger:hover, .profile-menu:focus-within .profile-trigger {
586 background: transparent; box-shadow: none;
587 }
588 }
589
590 /* Header badges (role + plan tier) -- two outlined pills. Role gets
591 the brand-blue outline; plan gets a neutral outline. */
592 .profile-dropdown-badges {
593 display: flex; flex-wrap: wrap; gap: 6px;
594 margin-top: 8px;
595 }
596 .profile-badge {
597 display: inline-flex; align-items: center;
598 padding: 3px 9px;
599 border-radius: 999px;
600 font-size: 10px;
601 font-weight: 700;
602 letter-spacing: 0.4px;
603 line-height: 1.4;
604 white-space: nowrap;
605 }
606 .profile-badge-violet {
607 background: transparent;
608 color: var(--col-accent-bright);
609 border: 1px solid var(--col-accent);
610 }
611 .profile-badge-neutral {
612 background: transparent;
613 color: var(--col-text-2);
614 border: 1px solid var(--col-border-2);
615 }
616
617 .profile-dropdown {
618 position: absolute;
619 top: calc(100% + 10px);
620 right: 0;
621 min-width: 300px;
622 background: var(--col-surface-2);
623 border: 1px solid var(--col-border-2);
624 border-radius: 14px;
625 padding: 8px 0 6px;
626 box-shadow: 0 24px 60px rgba(0,0,0,0.55), 0 0 24px rgba(59,130,246,0.12);
627 opacity: 0; visibility: hidden;
628 transform: translateY(-6px);
629 transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
630 z-index: 200;
631 /* Cap height to the viewport (minus the topbar + a small margin)
632 and scroll the contents when the menu is longer than that. */
633 max-height: calc(100vh - 90px);
634 overflow-y: auto;
635 overscroll-behavior: contain;
636 }
637 /* Slim dark scrollbar so it doesn't fight the dropdown chrome. */
638 .profile-dropdown::-webkit-scrollbar { width: 8px; }
639 .profile-dropdown::-webkit-scrollbar-track { background: transparent; }
640 .profile-dropdown::-webkit-scrollbar-thumb {
641 background: rgba(148,163,184,0.25); border-radius: 4px;
642 }
643 .profile-dropdown::-webkit-scrollbar-thumb:hover {
644 background: rgba(148,163,184,0.45);
645 }
646 /* Grace pad so cursor can move from avatar to dropdown without it
647 collapsing in the gap between them. */
648 .profile-dropdown::before {
649 content: ""; position: absolute;
650 top: -8px; left: 0; right: 0; height: 8px;
651 }
652 .profile-menu:hover .profile-dropdown,
653 .profile-menu:focus-within .profile-dropdown {
654 opacity: 1; visibility: visible; transform: translateY(0);
655 }
656 /* Header: stacked name + email + badges. No avatar -- the trigger
657 up top already shows it; duplicating here adds noise. */
658 .profile-dropdown-header {
659 display: block;
660 padding: 14px 18px 12px;
661 }
662 .profile-dropdown-avatar { display: none; }
663 .profile-dropdown-name {
664 margin: 0 0 2px;
665 font-size: 15px; font-weight: 700;
666 color: #fff;
667 line-height: 1.3;
668 }
669 .profile-dropdown-email {
670 margin: 0;
671 font-size: 13px;
672 color: var(--col-text-3);
673 line-height: 1.35;
674 word-break: break-all;
675 }
676 .profile-dropdown-divider {
677 height: 1px;
678 background: var(--col-border);
679 margin: 4px 14px;
680 }
681 /* Section headers in muted small-caps with a divider underneath --
682 matches TaskForge / ShopCart profile dropdown pattern. */
683 .profile-dropdown-section {
684 font-size: 10px;
685 font-weight: 700;
686 letter-spacing: 1.8px;
687 text-transform: uppercase;
688 color: var(--col-text-3);
689 padding: 14px 18px 6px;
690 margin-top: 2px;
691 border-top: 1px solid var(--col-border);
692 }
693 /* First section after the badges doesn't need its own top border. */
694 .profile-dropdown-header + .profile-dropdown-section { border-top: 1px solid var(--col-border); margin-top: 6px; }
695 .profile-dropdown a {
696 display: flex; align-items: center;
697 padding: 9px 18px;
698 font-size: 14px; font-weight: 500;
699 color: var(--col-text);
700 text-decoration: none;
701 transition: color 0.12s, background 0.12s, padding 0.12s;
702 }
703 .profile-dropdown a:hover {
704 color: var(--col-accent-bright);
705 background: rgba(59,130,246,0.10);
706 padding-left: 22px;
707 }
708 .profile-dropdown a.profile-admin { color: var(--col-accent-bright); }
709 .profile-dropdown a.profile-signout { color: #f87171; }
710 .profile-dropdown a.profile-signout:hover { color: #fca5a5; background: rgba(239,68,68,0.08); }
711 /* Hide per-item icon decorations -- text-only items match the
712 ShopCart/TaskForge style. */
713 .profile-dropdown .glyph,
714 .profile-dropdown .dropdown-icon { display: none; }
715 </style>
716 $extra_css
717 <script src="/assets/js/fingerprint.js" defer></script>
718</head>
719<body>
720 <div class="app">
721 <aside class="sidebar">
722 <div class="sidebar-brand">
723 <a href="/dashboard.cgi" style="display:flex;align-items:center;gap:12px;text-decoration:none">
724 <!-- Two-letter brand mark (TaskForge-style). The .brand-mark
725 CSS already centers a single child via grid + handles
726 the bold display font, so a plain text node is enough. -->
727 <div class="brand-mark">WS</div>
728 <div class="brand-text">
729 <span class="brand-name">$brand_name</span>
730 <span class="brand-sub">Creator Platform</span>
731 </div>
732 </a>
733 </div>
734 <nav class="sidebar-nav">
735 $sidebar
736 </nav>
737 <div class="sidebar-foot">
738 <div class="user-chip">
739 <div class="user-avatar">$initials</div>
740 <div class="user-meta">
741 <div class="user-name">$display_name</div>
742 <div class="user-tier">$plan_tier Plan</div>
743 </div>
744 </div>
745 <a href="/logout.cgi" class="icon-btn" title="Sign out" aria-label="Sign out">
746 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
747 </a>
748 </div>
749 </aside>
750
751 <div class="app-main">
752 $impersonation_banner
753 <header class="topbar">
754 <!-- Hamburger toggle. Lives in the topbar (instead of the
755 old edge-tab) so the icon is always in a predictable
756 spot and the sidebar can collapse to an icon-only rail
757 without losing its toggle affordance. The same .sidebar-
758 toggle class drives the JS in this wrapper. -->
759 <button class="sidebar-toggle" type="button" aria-label="Toggle sidebar" title="Collapse / expand sidebar">
760 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
761 </button>
762 <!-- Topbar-only mini brand. Only visible when the sidebar is
763 slid out on mobile so the company logo stays on-screen at
764 narrow viewports. CSS gates this on the mobile breakpoint. -->
765 <a href="/dashboard.cgi" class="topbar-brand" aria-label="WebSTLs">
766 <div class="brand-mark" style="width:34px;height:34px;font-size:14px">WS</div>
767 <span class="topbar-brand-name">$brand_name</span>
768 </a>
769 <div class="topbar-title">$title</div>
770 <div class="topbar-tools">
771 <form class="search-box" method="GET" action="/search.cgi" role="search">
772 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
773 <input type="text" name="q" placeholder="Search models, orders, settings..." autocomplete="off">
774 </form>
775 [if:$is_admin]
776 <a id="chat-waiting-pill" class="chat-pill[if:$has_admin_chats_pending] cw-active[/if][if:!$has_admin_chats_pending] cw-idle[/if]" href="$admin_chats_href" title="Live chat">
777 <span class="cw-ping" aria-hidden="true"></span>
778 <span>Chat</span>
779 <span class="cw-badge[if:!$has_admin_chats_pending] cw-badge-empty[/if]"><span class="cw-count">$admin_chats_pending</span></span>
780 </a>
781 [/if]
782 <a id="msg-pill" class="msg-pill[if:$has_unread_messages] has-unread[/if]" href="$messages_href" title="Messages" aria-label="Messages">
783 <span class="msg-dot" aria-hidden="true"></span>
784 <span>Messages</span>
785 <span class="msg-badge[if:!$has_unread_messages] msg-badge-empty[/if]" data-msg-count>$unread_messages_count</span>
786 </a>
787 <!-- Help-menu: the topbar Help button is the hover anchor for
788 a dropdown that surfaces every help destination. Clicking
789 the button itself still goes to /help.cgi (the resource
790 hub) as a fallback for keyboard / no-hover usage. -->
791 <div class="help-menu" tabindex="0">
792 <a class="help-btn" href="/help.cgi" title="Open the help &amp; resources guide" aria-label="Help" aria-haspopup="menu">
793 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h0"/></svg>
794 <span>Help</span>
795 <svg class="help-btn-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
796 </a>
797 <div class="help-dropdown" role="menu">
798 <div class="help-dropdown-section">Help</div>
799 <a href="/help.cgi" role="menuitem">
800 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h0"/></svg>
801 Help center
802 </a>
803 <a href="/tutorials.cgi" role="menuitem">
804 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
805 Tutorials
806 </a>
807 <a href="/tutorials.cgi#section-optimization" role="menuitem">
808 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
809 A/B &amp; MVT tutorials
810 </a>
811 <a href="/tour.cgi" role="menuitem">
812 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
813 Product tour
814 </a>
815 </div>
816 </div>
817
818 <!-- Profile menu. Hover the trigger (avatar+name+sub+chevron)
819 to reveal the dropdown; a grace-pad above the dropdown
820 keeps it open while the cursor crosses the gap. Ports
821 the ShopCart/TaskForge pattern exactly. -->
822 <div class="profile-menu" tabindex="0">
823 <div class="profile-trigger" title="My account">
824 <div class="avatar">$initials</div>
825 <div class="profile-trigger-meta">
826 <div class="profile-trigger-name">$display_name[if:$is_admin] <span class="profile-trigger-role">(Super Admin)</span>[/if]</div>
827 <div class="profile-trigger-sub">$plan_tier Plan</div>
828 </div>
829 <svg class="profile-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
830 </div>
831 <div class="profile-dropdown" role="menu">
832 <div class="profile-dropdown-header">
833 <div class="profile-dropdown-avatar">$initials</div>
834 <div style="min-width:0">
835 <p class="profile-dropdown-name">$display_name</p>
836 <p class="profile-dropdown-email">$email</p>
837 <div class="profile-dropdown-badges">
838 [if:$is_admin]<span class="profile-badge profile-badge-violet">Super Admin</span>[/if]
839 <span class="profile-badge profile-badge-neutral">$plan_tier</span>
840 </div>
841 </div>
842 </div>
843 <!-- Sections + items auto-generated from @SIDEBAR in
844 MODS::WebSTLs::Wrapper. Never hand-edit these links
845 -- they MUST stay in lockstep with the left sidebar.
846 To add / rename / remove an item, change @SIDEBAR. -->
847 $dropdown_links
848 <div class="profile-dropdown-divider"></div>
849 <a href="/logout.cgi" class="profile-signout"><span class="glyph">&times;</span> Sign Out</a>
850 </div>
851 </div>
852 </div>
853 </header>
854
855 <main class="app-content">
856 $body
857 </main>
858 </div>
859 </div>
860
861 <script src="$assets_js/site.js?v=4"></script>
862 <!-- In-app confirm modal -- replaces native window.confirm() across
863 the dashboard. Auto-binds any form/link/button with
864 data-confirm-title attrs and exposes window.appConfirm() for
865 imperative calls. Loads on every authenticated page so popups
866 feel consistent. -->
867 <script src="$assets_js/confirm.js?v=1"></script>
868 <!-- track.js (visitor tracking + chat widget) is intentionally NOT
869 loaded in the dashboard wrapper. The admin is the one running
870 chat from /admin_chat.cgi -- they should not see the bubble on
871 their own admin pages, and their own dashboard sessions should
872 not pollute the visitor analytics. track.js is loaded by
873 webstls_marketing.html for customer-facing pages. -->
874
875 <script>
876 // Sidebar collapse toggle. The per-user preference is stored in
877 // user_settings.sidebar_collapsed -- Wrapper.pm renders the
878 // current value as a class on <html>, so the next page load is
879 // already in the right state on first paint. We POST the new
880 // value with keepalive:true so the save survives the user
881 // clicking a nav link right after toggling.
882 (function () {
883 var btn = document.querySelector('.sidebar-toggle');
884 if (!btn) return;
885 var html = document.documentElement;
886 // Matches the mobile breakpoint in site.css. Kept in one place via
887 // matchMedia so a future width change only updates the CSS.
888 var mqMobile = window.matchMedia('(max-width: 1200px)');
889
890 btn.addEventListener('click', function (e) {
891 if (mqMobile.matches) {
892 // On mobile the toggle controls the slide-in overlay --
893 // a separate class so it doesn't pollute the per-user
894 // sidebar_collapsed preference used on desktop.
895 html.classList.toggle('mobile-nav-open');
896 e.stopPropagation();
897 return;
898 }
899 var collapsed = html.classList.toggle('sidebar-collapsed');
900 try {
901 fetch('/save_sidebar.cgi', {
902 method: 'POST',
903 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
904 body: 'sidebar_collapsed=' + (collapsed ? '1' : '0'),
905 keepalive: true,
906 credentials: 'same-origin'
907 });
908 } catch (e2) {}
909 });
910
911 // Close the mobile overlay when the user taps outside the sidebar
912 // (e.g. the dim backdrop) or follows a nav link inside it.
913 document.addEventListener('click', function (e) {
914 if (!html.classList.contains('mobile-nav-open')) return;
915 if (e.target.closest('.sidebar-toggle')) return; // handled above
916 if (e.target.closest('.sidebar a')) { // nav link tapped
917 html.classList.remove('mobile-nav-open');
918 return;
919 }
920 if (!e.target.closest('.sidebar')) { // backdrop tap
921 html.classList.remove('mobile-nav-open');
922 }
923 });
924
925 // If the viewport crosses back to desktop (rotation, fold open,
926 // window resize), drop the mobile-open state so we don't strand
927 // it stuck after the slide-in styles stop applying.
928 mqMobile.addEventListener('change', function (ev) {
929 if (!ev.matches) html.classList.remove('mobile-nav-open');
930 });
931 })();
932
933 // Live-poll the topbar chat pill so admins see state change
934 // WITHOUT having to refresh the page. 20s cadence keeps it cheap;
935 // the endpoint is a single indexed COUNT(). Two states:
936 // - idle (blue, "Chat", links to /admin_chat.cgi)
937 // - active (green, "N waiting", links straight to oldest chat)
938 // The pill bumps on every count increase so a brand-new chat is
939 // impossible to miss; a decreasing count never re-alerts.
940 (function () {
941 var pill = document.getElementById('chat-waiting-pill');
942 if (!pill) return;
943 var countEl = pill.querySelector('.cw-count');
944 var badgeEl = pill.querySelector('.cw-badge');
945 var POLL_MS = 20000;
946 var lastN = parseInt(countEl && countEl.textContent, 10) || 0;
947 var bumpTimer;
948 function paint(n, firstId) {
949 n = (typeof n === 'number' && n >= 0) ? n : 0;
950 firstId = (typeof firstId === 'number' && firstId > 0) ? firstId : 0;
951 if (n <= 0) {
952 // Idle: blue pill, no badge, link to chat console.
953 pill.classList.remove('cw-active');
954 pill.classList.remove('cw-bump');
955 pill.classList.add('cw-idle');
956 pill.setAttribute('href', '/admin_chat.cgi');
957 pill.setAttribute('title', 'Live chat');
958 if (badgeEl) badgeEl.classList.add('cw-badge-empty');
959 lastN = 0;
960 return;
961 }
962 // Active: green pulsing pill with red count badge.
963 if (n !== lastN && countEl) countEl.textContent = n;
964 if (badgeEl) badgeEl.classList.remove('cw-badge-empty');
965 pill.classList.remove('cw-idle');
966 pill.classList.add('cw-active');
967 pill.setAttribute(
968 'href',
969 firstId ? ('/admin_chat.cgi?chat=' + firstId) : '/admin_chat.cgi?view=active'
970 );
971 pill.setAttribute('title', n + (n === 1 ? ' visitor' : ' visitors') + ' waiting for a reply');
972 // Bump only when the count INCREASED -- a decreasing count
973 // (admin replied, chat closed) shouldn't visually re-alert.
974 if (n > lastN) {
975 pill.classList.remove('cw-bump');
976 void pill.offsetWidth; // restart animation
977 pill.classList.add('cw-bump');
978 clearTimeout(bumpTimer);
979 bumpTimer = setTimeout(function () {
980 pill.classList.remove('cw-bump');
981 }, 500);
982 }
983 lastN = n;
984 }
985 function tick() {
986 fetch('/admin_chat_pending.cgi', {
987 credentials: 'same-origin',
988 cache: 'no-store'
989 })
990 .then(function (r) { return r.ok ? r.json() : null; })
991 .then(function (d) {
992 if (d && typeof d.n === 'number') paint(d.n, d.first_id || 0);
993 })
994 .catch(function () { /* offline / network blip -- next tick retries */ });
995 }
996 setInterval(tick, POLL_MS);
997 // Also tick once when the tab regains focus so coming back to
998 // a backgrounded tab gives an immediate fresh count.
999 window.addEventListener('focus', tick);
1000
1001 // JS-driven blink while active. CSS @keyframes can be silenced
1002 // by OS-level prefers-reduced-motion / some VM browsers; this
1003 // toggle is a direct DOM class change so it always runs. The
1004 // CSS rule attaches a 0.45s opacity transition so the toggle
1005 // reads as a smooth fade, not a hard flicker. Same tick also
1006 // The chat pill is admin-only. The Messages pill polling +
1007 // blink lives in its OWN IIFE below so non-admin users (who
1008 // don't have the chat pill, and would early-return out of
1009 // this whole block) still get a live-updating Messages pill.
1010 setInterval(function () {
1011 if (pill.classList.contains('cw-active')) {
1012 pill.classList.toggle('cw-blink-off');
1013 } else {
1014 pill.classList.remove('cw-blink-off');
1015 }
1016 }, 900);
1017 })();
1018
1019 // ===== Messages pill: live-poll + blink (admin AND non-admin) =====
1020 // Independent IIFE so non-admins (who don't have the chat pill
1021 // above) still get the Messages pill blinking and live-updating.
1022 (function () {
1023 var msgPill = document.getElementById('msg-pill');
1024 if (!msgPill) return;
1025 var msgBadge = msgPill.querySelector('[data-msg-count]');
1026 // Synced blink tick at 900ms (matches chat pill cadence so
1027 // when both are visible they breathe together).
1028 setInterval(function () {
1029 if (msgPill.classList.contains('has-unread')) {
1030 msgPill.classList.toggle('msg-blink-off');
1031 } else {
1032 msgPill.classList.remove('msg-blink-off');
1033 }
1034 }, 900);
1035 function msgTick() {
1036 fetch('/messages_pending.cgi', { credentials:'same-origin', cache:'no-store' })
1037 .then(function(r){ return r.ok ? r.json() : null; })
1038 .then(function(d){
1039 if (!d || typeof d.n !== 'number') return;
1040 var n = d.n;
1041 if (msgBadge) msgBadge.textContent = n;
1042 if (n > 0) {
1043 msgPill.classList.add('has-unread');
1044 if (msgBadge) msgBadge.classList.remove('msg-badge-empty');
1045 } else {
1046 msgPill.classList.remove('has-unread');
1047 msgPill.classList.remove('msg-blink-off');
1048 if (msgBadge) msgBadge.classList.add('msg-badge-empty');
1049 }
1050 })
1051 .catch(function(){});
1052 }
1053 setInterval(msgTick, 20000);
1054 window.addEventListener('focus', msgTick);
1055 })();
1056
1057 // Preserve sidebar scroll position across page reloads. Some
1058 // pages (Traffic Statistics, Visitors, etc.) auto-refresh every
1059 // 15s; without this, the menu snaps back to the top mid-click
1060 // and the user has to re-scroll. Saved per browser tab so
1061 // different tabs can scroll independently.
1062 (function () {
1063 var nav = document.querySelector('.sidebar-nav');
1064 if (!nav) return;
1065 var KEY = 'webstls_sidebar_scroll';
1066 var saved = sessionStorage.getItem(KEY);
1067 if (saved !== null) {
1068 var n = parseInt(saved, 10);
1069 if (!isNaN(n) && n >= 0) nav.scrollTop = n;
1070 }
1071 var pending;
1072 nav.addEventListener('scroll', function () {
1073 clearTimeout(pending);
1074 pending = setTimeout(function () {
1075 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
1076 }, 120);
1077 }, { passive: true });
1078 // Also save right before unload so a fast click-then-navigate
1079 // captures the latest position even if the debounced save
1080 // hadn't fired yet.
1081 window.addEventListener('beforeunload', function () {
1082 try { sessionStorage.setItem(KEY, nav.scrollTop); } catch (e) {}
1083 });
1084 })();
1085 </script>
1086 $extra_js
1087 <script>
1088 // Timezone pingback. Reads the browser's IANA timezone via the
1089 // Intl API and POSTs it to /_set_timezone.cgi once per session.
1090 // The endpoint updates users.timezone so every reporting query
1091 // returns values in the viewer's local time (MariaDB SET time_zone
1092 // applies via MODS::DBConnect on every fresh connection).
1093 (function(){
1094 try {
1095 var tz = (Intl && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions().timeZone : '';
1096 if (!tz) return;
1097 var sent = '';
1098 try { sent = sessionStorage.getItem('webstls_tz_sent'); } catch(e){}
1099 if (sent === tz) return;
1100 var fd = new FormData();
1101 fd.append('tz', tz);
1102 fetch('/_set_timezone.cgi', { method: 'POST', body: fd, credentials: 'same-origin' })
1103 .then(function(){ try { sessionStorage.setItem('webstls_tz_sent', tz); } catch(e){} })
1104 .catch(function(){});
1105 } catch(e) {}
1106 })();
1107 </script>
1108<!-- Viewer-local timestamp renderer (portfolio-canonical).
1109 Server emits `<span class="ts" data-ts="EPOCH" data-fmt="…">FALLBACK</span>`.
1110 Formats: datetime | date | date-short | time | relative
1111 Original text is the no-JS fallback. Re-run via window.tzLocalize() after AJAX. -->
1112<script>
1113(function(){
1114 function fmt(el){
1115 var e = parseInt(el.getAttribute('data-ts'), 10);
1116 if (!e) return;
1117 var d = new Date(e * 1000);
1118 var kind = el.getAttribute('data-fmt') || 'datetime';
1119 var out;
1120 switch (kind) {
1121 case 'date': out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'}); break;
1122 case 'date-short': out = d.toLocaleDateString(undefined, {month:'short',day:'numeric'}); break;
1123 case 'time': out = d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'}); break;
1124 case 'relative': out = rel(e); break;
1125 default: out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})
1126 + ' ' + d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'});
1127 }
1128 el.textContent = out;
1129 if (!el.title) el.title = d.toString();
1130 }
1131 function rel(e){
1132 var s = Math.floor(Date.now()/1000) - e, future = s < 0; s = Math.abs(s);
1133 var n, u;
1134 if (s < 60) { n = s; u = 'second'; }
1135 else if (s < 3600) { n = Math.floor(s/60); u = 'minute'; }
1136 else if (s < 86400) { n = Math.floor(s/3600); u = 'hour'; }
1137 else if (s < 2592000){ n = Math.floor(s/86400); u = 'day'; }
1138 else if (s < 31536000){n = Math.floor(s/2592000); u = 'month'; }
1139 else { n = Math.floor(s/31536000);u = 'year'; }
1140 var p = n === 1 ? '' : 's';
1141 return future ? ('in ' + n + ' ' + u + p) : (n + ' ' + u + p + ' ago');
1142 }
1143 function localize(root){ (root||document).querySelectorAll('span.ts[data-ts], time[data-ts]').forEach(fmt); }
1144 window.tzLocalize = localize;
1145 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ localize(); });
1146 else localize();
1147 window.addEventListener('ts-refresh', function(){ localize(); });
1148})();
1149</script>
1150
1151</body>
1152</html>