estreui 1.3.0 → 1.4.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.
package/index.html CHANGED
@@ -64,6 +64,7 @@
64
64
  <script defer type="text/javascript" src="./scripts/estreUi-core.js"></script>
65
65
  <script defer type="text/javascript" src="./scripts/estreUi-dialog.js"></script>
66
66
  <script defer type="text/javascript" src="./scripts/estreUi-notation.js"></script>
67
+ <script defer type="text/javascript" src="./scripts/estreUi-notification.js"></script>
67
68
  <script defer type="text/javascript" src="./scripts/estreUi-pageModel.js"></script>
68
69
  <script defer type="text/javascript" src="./scripts/estreUi-pageManager.js"></script>
69
70
  <script defer type="text/javascript" src="./scripts/estreUi-handles.js"></script>
@@ -199,12 +199,13 @@
199
199
  <div class="container top" data-container-id="noti" data-static="1" data-on-top="1">
200
200
  <article class="" data-article-id="noti" data-static="" data-multi-instance="1" data-instance-origin="">
201
201
  <div class="h_icon_set post_block" data-bind-attr="interactive@data-interactive" data-bind-style="bgColor@--bg-color">
202
- <div class="icon_place"><img data-bind-attr="iconSrc@src" /></div>
202
+ <div class="icon_place"><img data-bind-attr="largeIconSrc@src" /></div>
203
203
  <div class="content_place">
204
204
  <div class="title_line"><span data-bind="contentTitle"></span></div>
205
+ <div class="subtitle_line"><span data-bind="subtitle"></span></div>
205
206
  <div class="h_icon_Set content_area">
206
207
  <div class="content_place" data-bind="content" data-bind-style="textColor@--color textSize@--size textWeight@--weight"></div>
207
- <div class="icon_place"><img data-bind-attr="subIconSrc@src" /></div>
208
+ <div class="icon_place"><img data-bind-attr="iconSrc@src" /></div>
208
209
  </div>
209
210
  </div>
210
211
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "estreui",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "EstreUI Core Library - A comprehensive UI framework for web applications",
5
5
  "main": "scripts/estreUi.js",
6
6
  "files": [
@@ -502,10 +502,7 @@ const arrived = function (instanceOrigin) {
502
502
  }
503
503
 
504
504
 
505
-
506
- const noti = function (title, htmlContent, onTakeInteraction = (intent) => {}, mainIconSrc, subIconSrc) {
507
- //<= To do implement
508
- }
505
+ // noti() moved to estreUi-notification.js (roadmap #009).
509
506
 
510
507
 
511
508
  // ======================================================================
@@ -283,6 +283,7 @@ const estreUi = {
283
283
  this.setPanelSwipeHandler();
284
284
  this.scheduleOverwatchPanelClock();
285
285
  this.initOverwatchPanelHandles();
286
+ this.initOverwatchPanelTimeline();
286
287
  this.updateDarkModeToggleWidgets();
287
288
  return this.initStaticPanels(subTerm);
288
289
  }
@@ -729,6 +730,16 @@ const estreUi = {
729
730
  new EstreDynamicSectionBlockHandle($block[0], host).init();
730
731
  },
731
732
 
733
+ // Mounts EstreTimelineView onto overwatchPanel #timeline slot (roadmap #010).
734
+ // Timeline persists entries left by checkOut()ed noti banners.
735
+ initOverwatchPanelTimeline() {
736
+ if (typeof EstreTimelineView === "undefined") return;
737
+ if (this.$overwatchPanel == null || this.$overwatchPanel.length < 1) return;
738
+ const $timeline = this.$overwatchPanel.find("#timeline");
739
+ if ($timeline.length < 1) return;
740
+ this.timelineView = new EstreTimelineView($timeline);
741
+ },
742
+
732
743
  setOverwatchPanelClock() {
733
744
  if (this.$panelClock == null) return;
734
745
  const now = new Date();
@@ -0,0 +1,539 @@
1
+ /*
2
+ EstreUI rimwork — Notification Banner
3
+ Part of the split from estreUi.js (roadmap #002 phase 2; roadmap #009).
4
+
5
+ This file is loaded as a plain <script> tag and shares the global scope
6
+ with the other estreUi-*.js files. Load order matters: see index.html.
7
+ */
8
+
9
+ // MODULE: Notification -- EstreNotificationManager, noti(), push adapters
10
+ // ======================================================================
11
+
12
+ /**
13
+ * iOS-style notification banner queue manager.
14
+ * Mirrors EstreNotationManager (note toast) — one-at-a-time, per-item showTime.
15
+ */
16
+ class EstreNotificationManager {
17
+
18
+ // static
19
+ static #page = "popNoti";
20
+
21
+ static #queue = [];
22
+ static current = null;
23
+
24
+ static postHandle = null;
25
+
26
+ /** Default auto-dismiss ms for banners (longer than note toast). */
27
+ static defaultShowTime = 4500;
28
+
29
+ static get noInteraction() { return (intent) => {}; }
30
+
31
+ /**
32
+ * Enqueue a banner.
33
+ * @param {object} options - Normalized banner options (see EstreNotificationManager#data).
34
+ * @returns {Promise<EstreNotificationManager>|undefined}
35
+ */
36
+ static post(options) {
37
+ if (options != null && typeof options === "object") {
38
+ if (this.#isTimelineVisible()) {
39
+ this.#appendToTimelineDirect(options);
40
+ return Promise.resolve(null);
41
+ }
42
+ return new Promise((resolve) => {
43
+ const it = new EstreNotificationManager(options, resolve);
44
+ this.#queue.push(it);
45
+ if (window.isDebug) console.log(this.#page + " posted: ", it);
46
+ postQueue(_ => this.postHandler());
47
+ });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * True when the overwatchPanel is open and the timeline section is currently showing.
53
+ * In that case banner posts are diverted into the timeline list directly (iOS behavior).
54
+ * @returns {boolean}
55
+ */
56
+ static #isTimelineVisible() {
57
+ if (typeof estreUi === "undefined" || !estreUi.isOpenOverwatchPanel) return false;
58
+ if (typeof EstreTimelineStore === "undefined") return false;
59
+ return estreUi.$overwatchPanel.find('.host_item[data-showing="1"][data-id="timeline"]').length > 0;
60
+ }
61
+
62
+ /**
63
+ * Build a timeline entry directly from noti options and append to the store.
64
+ * Mirrors the shape used in checkOut().
65
+ */
66
+ static #appendToTimelineDirect(options) {
67
+ const ui = options.ui ?? {};
68
+ EstreTimelineStore.append({
69
+ postedAt: Date.now(),
70
+ title: options.title,
71
+ body: options.body,
72
+ subtitle: options.subtitle,
73
+ iconSrc: options.icon,
74
+ largeIconSrc: options.largeIcon,
75
+ url: options.url,
76
+ payload: options.data,
77
+ bgColor: ui.bgColor,
78
+ textColor: ui.textColor,
79
+ });
80
+ }
81
+
82
+ static postHandler() {
83
+ if (window.isDebug) console.log("queue: ", this.#queue);
84
+ if (this.postHandle == null && this.current == null && this.#queue.length > 0) {
85
+ const handle = Date.now();
86
+ this.postHandle = handle;
87
+ const current = this.#queue.splice(0, 1)[0];
88
+ current.data.posted = handle;
89
+ if (window.isDebug) console.log(this.#page + " bring: ", current);
90
+ return pageManager.bringPage("!" + this.#page, current, handle);
91
+ }
92
+ }
93
+
94
+ /** True when at least one banner is waiting to be brought. */
95
+ static get hasQueued() { return this.#queue.length > 0; }
96
+
97
+ /**
98
+ * Release the queue lock at close-start so the next banner can begin its
99
+ * enter animation in parallel with the outgoing banner's exit animation.
100
+ * Does the timeline-append + resolver work; checkOut() becomes a noop
101
+ * afterwards via the `_earlyCheckedOut` flag.
102
+ */
103
+ static beginCheckOut(intent) {
104
+ if (intent == null || intent._earlyCheckedOut) return;
105
+ if (intent.data.posted != null && this.postHandle == intent.data.posted) {
106
+ if (this.current == intent) this.current = null;
107
+ this.postHandle = null;
108
+ if (typeof EstreTimelineStore !== "undefined") {
109
+ const d = intent.data;
110
+ EstreTimelineStore.append({
111
+ postedAt: d.posted,
112
+ title: d.contentTitle,
113
+ body: d.content,
114
+ subtitle: d.subtitle,
115
+ iconSrc: d.iconSrc,
116
+ largeIconSrc: d.largeIconSrc,
117
+ url: d.url,
118
+ payload: d.payload,
119
+ bgColor: d.bgColor,
120
+ textColor: d.textColor,
121
+ });
122
+ }
123
+ intent._earlyCheckedOut = true;
124
+ intent.resolver?.(intent);
125
+ if (window.isDebug) console.log(this.#page + " early checked out: ", intent);
126
+ postQueue(_ => this.postHandler());
127
+ }
128
+ }
129
+
130
+ static checkOut(intent) {
131
+ if (intent?._earlyCheckedOut) return;
132
+ if (intent.data.posted != null && this.postHandle == intent.data.posted) {
133
+ if (this.current == intent) {
134
+ this.current = null;
135
+ }
136
+ this.postHandle = null;
137
+ if (window.isDebug) console.log(this.#page + " checked out: ", intent);
138
+ postQueue(_ => this.postHandler());
139
+ }
140
+ if (typeof EstreTimelineStore !== "undefined") {
141
+ const d = intent.data;
142
+ EstreTimelineStore.append({
143
+ postedAt: d.posted,
144
+ title: d.contentTitle,
145
+ body: d.content,
146
+ subtitle: d.subtitle,
147
+ iconSrc: d.iconSrc,
148
+ largeIconSrc: d.largeIconSrc,
149
+ url: d.url,
150
+ payload: d.payload,
151
+ bgColor: d.bgColor,
152
+ textColor: d.textColor,
153
+ });
154
+ }
155
+ intent.resolver?.(intent);
156
+ }
157
+
158
+
159
+ // instance property
160
+ data = {
161
+ posted: undefined,
162
+ contentTitle: undefined,
163
+ subtitle: undefined,
164
+ content: undefined,
165
+ showTime: undefined,
166
+ interactive: undefined,
167
+
168
+ // icons
169
+ iconSrc: undefined, // small / sub icon
170
+ largeIconSrc: undefined, // large / main icon (subIconSrc naming kept for template)
171
+
172
+ // payload / routing
173
+ buttons: undefined,
174
+ url: undefined,
175
+ payload: undefined,
176
+
177
+ // visual tokens
178
+ textSize: undefined,
179
+ textWeight: undefined,
180
+ textColor: undefined,
181
+ bgColor: undefined,
182
+ };
183
+
184
+ onTakeInteraction = undefined;
185
+ resolver = undefined;
186
+
187
+ /**
188
+ * @param {object} options - Normalized banner options.
189
+ * @param {Function} resolver - Promise resolver injected by post().
190
+ */
191
+ constructor(options, resolver) {
192
+ const d = this.data;
193
+ d.contentTitle = options.title;
194
+ d.content = options.body;
195
+ d.subtitle = options.subtitle;
196
+ d.iconSrc = options.icon;
197
+ d.largeIconSrc = options.largeIcon;
198
+ d.buttons = options.buttons;
199
+ d.url = options.url;
200
+ d.payload = options.data;
201
+
202
+ const ui = options.ui ?? {};
203
+ d.showTime = ui.showTime ?? EstreNotificationManager.defaultShowTime;
204
+ d.textSize = ui.textSize;
205
+ d.textWeight = ui.textWeight;
206
+ d.textColor = ui.textColor;
207
+ d.bgColor = ui.bgColor;
208
+
209
+ this.onTakeInteraction = options.onTakeInteraction ?? EstreNotificationManager.noInteraction;
210
+ const hasInteraction = this.onTakeInteraction !== EstreNotificationManager.noInteraction
211
+ || options.url != null
212
+ || (Array.isArray(options.buttons) && options.buttons.length > 0);
213
+ d.interactive = (ui.interactive ?? hasInteraction) ? "" : undefined;
214
+
215
+ this.resolver = resolver;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Post an iOS-style notification banner.
221
+ *
222
+ * Positional form (frequency-first, matches the original stub):
223
+ * noti(title, body, onTakeInteraction, mainIconSrc, subIconSrc)
224
+ *
225
+ * Object overload — passes through to EstreNotificationManager.post():
226
+ * noti({ title, body, subtitle, icon, largeIcon, data, url, buttons, onTakeInteraction, ui })
227
+ *
228
+ * @param {string|object} title - Title text, or the full options object (overload).
229
+ * @param {string} [body] - Body text (HTML-capable).
230
+ * @param {Function} [onTakeInteraction] - Tap callback. intent is passed.
231
+ * @param {string} [mainIconSrc] - Large/leading icon src (maps to largeIcon).
232
+ * @param {string} [subIconSrc] - Small/trailing icon src (maps to icon).
233
+ * @returns {Promise<EstreNotificationManager>|undefined}
234
+ */
235
+ const noti = function (title, body, onTakeInteraction, mainIconSrc, subIconSrc) {
236
+ if (title != null && typeof title === "object") {
237
+ return EstreNotificationManager.post(title);
238
+ }
239
+ return EstreNotificationManager.post({
240
+ title,
241
+ body,
242
+ onTakeInteraction,
243
+ largeIcon: mainIconSrc,
244
+ icon: subIconSrc,
245
+ });
246
+ }
247
+
248
+ /**
249
+ * FCM payload adapter.
250
+ * Accepts either the full message (`{ notification, data }`) or the `notification` object directly.
251
+ * @param {object} payload
252
+ */
253
+ noti.fromFcm = function (payload) {
254
+ if (payload == null) return;
255
+ const n = payload.notification ?? payload;
256
+ const data = payload.data ?? (payload !== n ? undefined : undefined);
257
+ return noti({
258
+ title: n.title,
259
+ body: n.body,
260
+ icon: n.icon,
261
+ largeIcon: n.image,
262
+ url: n.click_action ?? payload.fcm_options?.link,
263
+ data,
264
+ });
265
+ }
266
+
267
+ /**
268
+ * APNs payload adapter.
269
+ * Accepts the outer aps-bearing object (`{ aps: { alert: ... }, ...custom }`).
270
+ * `alert` may be a string or object.
271
+ * @param {object} payload
272
+ */
273
+ noti.fromApns = function (payload) {
274
+ if (payload == null) return;
275
+ const aps = payload.aps ?? {};
276
+ const alert = typeof aps.alert === "string" ? { body: aps.alert } : (aps.alert ?? {});
277
+ return noti({
278
+ title: alert.title,
279
+ body: alert.body,
280
+ subtitle: alert.subtitle,
281
+ data: payload,
282
+ });
283
+ }
284
+
285
+ /**
286
+ * OneSignal payload adapter.
287
+ * `headings` / `contents` may be locale maps; first value is picked.
288
+ * @param {object} payload
289
+ */
290
+ noti.fromOneSignal = function (payload) {
291
+ if (payload == null) return;
292
+ const pick = (v) => (v != null && typeof v === "object") ? Object.values(v)[0] : v;
293
+ return noti({
294
+ title: pick(payload.headings),
295
+ body: pick(payload.contents),
296
+ subtitle: pick(payload.subtitle),
297
+ largeIcon: payload.big_picture ?? payload.chrome_big_picture,
298
+ icon: payload.small_icon ?? payload.chrome_icon,
299
+ url: payload.url,
300
+ data: payload.data ?? payload.additionalData,
301
+ });
302
+ }
303
+
304
+
305
+ /**
306
+ * Persistent history for dismissed notification banners (roadmap #010).
307
+ * Backed by ECLS (localStorage, JSON). Retains up to maxEntries for ttlMs.
308
+ */
309
+ class EstreTimelineStore {
310
+
311
+ static #key = "timelineEntries";
312
+ static maxEntries = 100;
313
+ static ttlMs = 7 * 24 * 60 * 60 * 1000;
314
+
315
+ static #listeners = new Set();
316
+
317
+ /** @returns {Array<object>} entries newest-first, TTL-pruned. */
318
+ static load() {
319
+ const raw = ECLS.get(this.#key, []);
320
+ if (!Array.isArray(raw)) return [];
321
+ const now = Date.now();
322
+ return raw.filter(it => it != null && (now - (it.postedAt ?? 0)) < this.ttlMs);
323
+ }
324
+
325
+ static save(entries) {
326
+ ECLS.set(this.#key, entries);
327
+ }
328
+
329
+ /**
330
+ * Append an entry (newest-first). Assigns id/postedAt if missing. Enforces cap + TTL.
331
+ * @param {object} entry
332
+ */
333
+ static append(entry) {
334
+ if (entry == null) return;
335
+ const now = Date.now();
336
+ const normalized = { ...entry,
337
+ id: entry.id ?? String(entry.postedAt ?? now),
338
+ postedAt: entry.postedAt ?? now,
339
+ };
340
+ const entries = this.load().filter(it => it.id !== normalized.id);
341
+ entries.unshift(normalized);
342
+ if (entries.length > this.maxEntries) entries.length = this.maxEntries;
343
+ this.save(entries);
344
+ this.#emit();
345
+ }
346
+
347
+ static remove(id) {
348
+ const entries = this.load().filter(it => it.id !== id);
349
+ this.save(entries);
350
+ this.#emit();
351
+ }
352
+
353
+ static clear() {
354
+ this.save([]);
355
+ this.#emit();
356
+ }
357
+
358
+ /**
359
+ * Subscribe to store changes. Callback receives the current entries array.
360
+ * @param {Function} cb
361
+ * @returns {Function} unsubscribe
362
+ */
363
+ static subscribe(cb) {
364
+ this.#listeners.add(cb);
365
+ return () => this.#listeners.delete(cb);
366
+ }
367
+
368
+ static #emit() {
369
+ const entries = this.load();
370
+ for (const cb of this.#listeners) {
371
+ try { cb(entries); } catch (ex) { if (window.isLogging) console.error(ex); }
372
+ }
373
+ }
374
+ }
375
+
376
+
377
+ /**
378
+ * Renders EstreTimelineStore entries into a host element (e.g. overwatchPanel #timeline).
379
+ * Groups by date bucket (Today / Yesterday / Older), item visuals share banner styles.
380
+ */
381
+ class EstreTimelineView {
382
+
383
+ #$host;
384
+ #unsubscribe;
385
+ #lastIds = new Set();
386
+
387
+ /**
388
+ * @param {Element|JQuery} host - Container element for the list.
389
+ */
390
+ constructor(host) {
391
+ this.#$host = host instanceof jQuery ? host : $(host);
392
+ this.#$host.addClass("timeline_host");
393
+ this.render(EstreTimelineStore.load());
394
+ this.#unsubscribe = EstreTimelineStore.subscribe((entries) => this.render(entries));
395
+ }
396
+
397
+ destroy() {
398
+ this.#unsubscribe?.();
399
+ this.#$host.empty();
400
+ this.#$host.removeClass("timeline_host");
401
+ }
402
+
403
+ render(entries) {
404
+ const $host = this.#$host;
405
+ $host.empty();
406
+
407
+ if (!entries || entries.length === 0) {
408
+ this.#lastIds = new Set();
409
+ $host.append('<div class="timeline_empty">No notifications</div>');
410
+ return;
411
+ }
412
+
413
+ const currentIds = new Set(entries.map(e => e.id));
414
+ const lastIds = this.#lastIds;
415
+ const isFirstRender = lastIds.size === 0;
416
+
417
+ const groups = this.#groupByDate(entries);
418
+ for (let gi = 0; gi < groups.length; gi++) {
419
+ const group = groups[gi];
420
+ const $group = $('<div class="timeline_group"></div>');
421
+ const $header = $('<div class="timeline_group_header"></div>');
422
+ $header.append($('<span class="timeline_group_label"></span>').text(group.label));
423
+ if (gi === 0) {
424
+ const $btn = $('<button type="button" class="timeline_clear_all">Clear All</button>');
425
+ $btn.on("click", (e) => {
426
+ e.preventDefault();
427
+ e.stopPropagation();
428
+ this.#clearAllWithCascade();
429
+ });
430
+ $header.append($btn);
431
+ }
432
+ $group.append($header);
433
+ for (const entry of group.entries) {
434
+ const isNew = !isFirstRender && !lastIds.has(entry.id);
435
+ $group.append(this.#buildItem(entry, isNew));
436
+ }
437
+ $host.append($group);
438
+ }
439
+
440
+ this.#lastIds = currentIds;
441
+ }
442
+
443
+ #clearAllWithCascade() {
444
+ const $items = this.#$host.find(".timeline_item");
445
+ if ($items.length === 0) {
446
+ EstreTimelineStore.clear();
447
+ return;
448
+ }
449
+ const stagger = 50;
450
+ const maxDelay = 400;
451
+ $items.each(function (i) {
452
+ const delay = Math.min(i * stagger, maxDelay);
453
+ $(this).css("--exit-delay", delay + "ms").addClass("timeline_item_exit");
454
+ });
455
+ setTimeout(() => EstreTimelineStore.clear(), maxDelay + 300);
456
+ }
457
+
458
+ #groupByDate(entries) {
459
+ const now = new Date();
460
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
461
+ const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
462
+
463
+ const today = [], yesterday = [], older = [];
464
+ for (const entry of entries) {
465
+ const t = entry.postedAt ?? 0;
466
+ if (t >= startOfToday) today.push(entry);
467
+ else if (t >= startOfYesterday) yesterday.push(entry);
468
+ else older.push(entry);
469
+ }
470
+
471
+ const groups = [];
472
+ if (today.length) groups.push({ label: "Today", entries: today });
473
+ if (yesterday.length) groups.push({ label: "Yesterday", entries: yesterday });
474
+ if (older.length) groups.push({ label: "Older", entries: older });
475
+ return groups;
476
+ }
477
+
478
+ #buildItem(entry, isNew = false) {
479
+ const $item = $('<div class="h_icon_set post_block timeline_item"></div>');
480
+ $item.attr("data-id", entry.id);
481
+ if (entry.bgColor) $item.css("--bg-color", entry.bgColor);
482
+ if (isNew) $item.addClass("timeline_item_enter");
483
+
484
+ if (entry.largeIconSrc) {
485
+ const $mainIcon = $('<div class="icon_place"></div>');
486
+ $mainIcon.append($('<img />').attr("src", entry.largeIconSrc));
487
+ $item.append($mainIcon);
488
+ }
489
+
490
+ const $content = $('<div class="content_place"></div>');
491
+ if (entry.title) $content.append($('<div class="title_line"></div>').append($('<span></span>').text(entry.title)));
492
+ if (entry.subtitle) $content.append($('<div class="subtitle_line"></div>').append($('<span></span>').text(entry.subtitle)));
493
+ if (entry.body || entry.iconSrc) {
494
+ const $area = $('<div class="content_area"></div>');
495
+ if (entry.body) {
496
+ const $body = $('<div class="content_place"></div>').html(entry.body);
497
+ if (entry.textColor) $body.css("--color", entry.textColor);
498
+ $area.append($body);
499
+ }
500
+ if (entry.iconSrc) {
501
+ const $subIcon = $('<div class="icon_place"></div>');
502
+ $subIcon.append($('<img />').attr("src", entry.iconSrc));
503
+ $area.append($subIcon);
504
+ }
505
+ $content.append($area);
506
+ }
507
+ $item.append($content);
508
+
509
+ $item.on("click", (e) => {
510
+ e.preventDefault();
511
+ if (entry.url) {
512
+ window.open(entry.url, "_blank", "noopener");
513
+ }
514
+ });
515
+
516
+ // Left→right swipe to delete. Opposite direction to the parent's horizontal
517
+ // scroll-snap (quick panel switch), so the two gestures don't collide.
518
+ if (typeof EstreSwipeHandler !== "undefined") {
519
+ new EstreSwipeHandler($item)
520
+ .setStopPropagation()
521
+ .unuseY()
522
+ .setThresholdX(1)
523
+ .setDropStrayed(false)
524
+ .setResponseBound($item)
525
+ .setOnUp(function (grabX, grabY, handled, canceled, directed) {
526
+ if (!handled) return;
527
+ if (this.handledDirection === "right" && grabX > 80) {
528
+ $item.css("--exit-delay", "0ms").addClass("timeline_item_exit");
529
+ setTimeout(() => EstreTimelineStore.remove(entry.id), 300);
530
+ }
531
+ });
532
+ }
533
+
534
+ return $item;
535
+ }
536
+ }
537
+
538
+
539
+ // ======================================================================
@@ -3723,7 +3723,147 @@ class EstreUiPage {
3723
3723
 
3724
3724
  },
3725
3725
 
3726
- "$i&o=notification#noti@noti^": class extends EstrePageHandler { },
3726
+ "$i&o=notification#noti@noti^": class extends EstrePageHandler {
3727
+ $postBlock;
3728
+ $mainIconPlace;
3729
+ $subIconPlace;
3730
+ $titleLine;
3731
+ $subtitleLine;
3732
+ $contentLine;
3733
+ swipeHandler;
3734
+ #closeTimer;
3735
+ #closing = false;
3736
+
3737
+ #applyIntentToBlock(intent) {
3738
+ const data = intent?.data ?? {};
3739
+ this.$mainIconPlace.toggle(data.largeIconSrc != null && data.largeIconSrc !== "");
3740
+ this.$subIconPlace.toggle(data.iconSrc != null && data.iconSrc !== "");
3741
+ this.$titleLine.toggle(data.contentTitle != null && data.contentTitle !== "");
3742
+ this.$subtitleLine.toggle(data.subtitle != null && data.subtitle !== "");
3743
+ this.$contentLine.toggle(data.content != null && data.content !== "");
3744
+ }
3745
+
3746
+ #resetCloseTimer(handle) {
3747
+ if (this.#closeTimer != null) {
3748
+ clearTimeout(this.#closeTimer);
3749
+ this.#closeTimer = null;
3750
+ }
3751
+ const showTime = handle.intent?.data?.showTime ?? EstreNotificationManager.defaultShowTime;
3752
+ this.#closeTimer = setTimeout(_ => this.#beginClose(handle), showTime);
3753
+ }
3754
+
3755
+ #beginClose(handle) {
3756
+ if (this.#closing) return;
3757
+ this.#closing = true;
3758
+ if (this.#closeTimer != null) {
3759
+ clearTimeout(this.#closeTimer);
3760
+ this.#closeTimer = null;
3761
+ }
3762
+
3763
+ if (!EstreNotificationManager.hasQueued) {
3764
+ handle.close();
3765
+ return;
3766
+ }
3767
+
3768
+ const $block = this.$postBlock;
3769
+ const blockEl = $block[0];
3770
+ if (blockEl == null) { handle.close(); return; }
3771
+ const $article = $block.parent();
3772
+ const articleEl = $article[0];
3773
+ const rect = blockEl.getBoundingClientRect();
3774
+ const articleRect = articleEl.getBoundingClientRect();
3775
+ const $ghost = $block.clone(false).removeClass("banner_incoming");
3776
+ $ghost.addClass("banner_ghost_exit");
3777
+ $ghost.css({
3778
+ position: "absolute",
3779
+ left: (rect.left - articleRect.left) + "px",
3780
+ top: (rect.top - articleRect.top) + "px",
3781
+ width: rect.width + "px",
3782
+ margin: 0,
3783
+ pointerEvents: "none",
3784
+ });
3785
+ $article.append($ghost);
3786
+ setTimeout(() => $ghost.remove(), 550);
3787
+
3788
+ $block.css({ visibility: "hidden", pointerEvents: "none" });
3789
+
3790
+ EstreNotificationManager.beginCheckOut(handle.intent);
3791
+ }
3792
+
3793
+ onBring(handle) {
3794
+ const $host = handle.$host;
3795
+ this.$postBlock = $host.find(".post_block");
3796
+ this.$mainIconPlace = this.$postBlock.children(".icon_place");
3797
+ this.$titleLine = this.$postBlock.find("> .content_place > .title_line");
3798
+ this.$subtitleLine = this.$postBlock.find("> .content_place > .subtitle_line");
3799
+ this.$contentLine = this.$postBlock.find("> .content_place > .content_area > .content_place");
3800
+ this.$subIconPlace = this.$postBlock.find("> .content_place > .content_area > .icon_place");
3801
+
3802
+ this.#applyIntentToBlock(handle.intent);
3803
+
3804
+ EstreNotificationManager.current = handle.intent;
3805
+ if (window.isVerbosely) console.log("pushed", handle.intent);
3806
+ }
3807
+
3808
+ onOpen(handle) {
3809
+ this.#closing = false;
3810
+ this.$postBlock.click((e) => {
3811
+ e.preventDefault();
3812
+
3813
+ if (window.isVerbosely) console.log("clicked: ", handle.intent);
3814
+ handle.intent?.onTakeInteraction?.(handle.intent);
3815
+ this.#beginClose(handle);
3816
+
3817
+ return false;
3818
+ });
3819
+
3820
+ const self = this;
3821
+ this.swipeHandler = new EstreSwipeHandler(this.$postBlock)
3822
+ .setStopPropagation()
3823
+ .setPreventDefault()
3824
+ .unuseX()
3825
+ .setThresholdY(1)
3826
+ .setDropStrayed(false)
3827
+ .setResponseBound(this.$postBlock)
3828
+ .setOnUp(function (grabX, grabY, handled, canceled, directed) {
3829
+ if (!handled) return;
3830
+ if (this.handledDirection === "up" && Math.abs(grabY) > 20) {
3831
+ self.#beginClose(handle);
3832
+ } else if (this.handledDirection === "down" && Math.abs(grabY) > 40) {
3833
+ handle.intent?.onTakeInteraction?.(handle.intent);
3834
+ self.#beginClose(handle);
3835
+ }
3836
+ });
3837
+
3838
+ this.#resetCloseTimer(handle);
3839
+ if (window.isVerbosely) console.log("showing: ", handle.intent);
3840
+ }
3841
+
3842
+ onIntentUpdated(handle, intent) {
3843
+ // Queue-chain handover: previous banner's exit is playing on a
3844
+ // detached ghost clone; this article reuses the same DOM with
3845
+ // fresh content + a restart-triggered enter animation.
3846
+ this.#closing = false;
3847
+ if (intent?.data != null) this.#applyIntentToBlock(intent);
3848
+ EstreNotificationManager.current = handle.intent;
3849
+
3850
+ const $block = this.$postBlock;
3851
+ $block.css({ visibility: "", pointerEvents: "" });
3852
+ $block.removeClass("banner_incoming");
3853
+ void $block[0]?.offsetWidth;
3854
+ $block.addClass("banner_incoming");
3855
+
3856
+ this.#resetCloseTimer(handle);
3857
+ }
3858
+
3859
+ onClose(handle) {
3860
+ if (this.#closeTimer != null) {
3861
+ clearTimeout(this.#closeTimer);
3862
+ this.#closeTimer = null;
3863
+ }
3864
+ EstreNotificationManager.checkOut(handle.intent);
3865
+ }
3866
+ },
3727
3867
  "$i&o=notification#note@note^": class extends EstrePageHandler {
3728
3868
  $postBlock;
3729
3869
 
package/serviceWorker.js CHANGED
@@ -1,4 +1,4 @@
1
- const INSTALLATION_VERSION_NAME = "1.3.0-r20260421";
1
+ const INSTALLATION_VERSION_NAME = "1.4.0-r20260424";
2
2
  // ^^ Use for check new update "Native application(webview) version(or Android/iOS version combo) - PWA release version"
3
3
  // ex) "1.0.1/1.0.0-r20251101k"
4
4
 
@@ -22,7 +22,7 @@ const INSTALLATION_FILE_LIST = [
22
22
 
23
23
 
24
24
  // Common files cache - Be changes some time but, well not changed very often
25
- const CACHE_NAME_COMMON_FILES = "common-files-cache-v1-20260421";
25
+ const CACHE_NAME_COMMON_FILES = "common-files-cache-v1-20260424";
26
26
 
27
27
  const COMMON_FILES_TO_CACHE = [
28
28
  "./",
@@ -51,6 +51,7 @@ const COMMON_FILES_TO_CACHE = [
51
51
  "./scripts/estreUi-core.js",
52
52
  "./scripts/estreUi-dialog.js",
53
53
  "./scripts/estreUi-notation.js",
54
+ "./scripts/estreUi-notification.js",
54
55
  "./scripts/estreUi-pageModel.js",
55
56
  "./scripts/estreUi-pageManager.js",
56
57
  "./scripts/estreUi-handles.js",
@@ -129,7 +129,11 @@ nav#overwatchPanel { --grab-y: 0px; --panel-block-height: calc(100vh - var(--top
129
129
  nav#overwatchPanel > header#panelHeader,
130
130
  nav#overwatchPanel > .dynamic_section_host,
131
131
  nav#overwatchPanel > .dynamic_section_block,
132
- nav#overwatchPanel > section#panelGrabArea { pointer-events: auto; }
132
+ nav#overwatchPanel > section#panelGrabArea { position: relative; pointer-events: auto; }
133
+ nav#overwatchPanel > header#panelHeader { z-index: 4; }
134
+ nav#overwatchPanel > .dynamic_section_host { z-index: 3; }
135
+ nav#overwatchPanel > .dynamic_section_block { z-index: 2; }
136
+ nav#overwatchPanel > section#panelGrabArea { z-index: 1; }
133
137
  nav#overwatchPanel > header#panelHeader { display: flex; flex-direction: row; align-items: baseline; gap: 0.5em; padding: calc(var(--top-pad) + 4px) var(--basic-ui-inset-h) 4px; flex-shrink: 0; background-color: rgba(var(--cabr) / 60%); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); transform: translateY(-100%); transition-duration: 0.3s; }
134
138
  nav#overwatchPanel > header#panelHeader > #panelClock { font-size: 1.125rem; font-weight: 600; }
135
139
  nav#overwatchPanel > header#panelHeader > #panelDate { font-size: 0.875rem; opacity: 0.75; }
@@ -150,13 +154,20 @@ nav#overwatchPanel[data-opened="1"] > section#panelGrabArea { opacity: 1; backgr
150
154
  nav#overwatchPanel[data-on-grab="1"] > header#panelHeader,
151
155
  nav#overwatchPanel[data-on-grab="1"] > .dynamic_section_host,
152
156
  nav#overwatchPanel[data-on-grab="1"] > .dynamic_section_block { transition-delay: 0s; transition-duration: 0s; }
153
- nav#overwatchPanel[data-on-grab="1"]:not([data-opened="1"]) > header#panelHeader { transform: translateY(calc(-100% + max(var(--grab-y), 0px))); }
154
- nav#overwatchPanel[data-on-grab="1"]:not([data-opened="1"]) > .dynamic_section_host { transform: translateY(calc(-100% - var(--panel-block-height) + max(var(--grab-y), 0px))); }
155
- nav#overwatchPanel[data-on-grab="1"]:not([data-opened="1"]) > .dynamic_section_block { transform: translateY(calc(-100vh - var(--panel-block-height) + max(var(--grab-y), 0px))); }
157
+ nav#overwatchPanel[data-on-grab="1"]:not([data-opened="1"]) > header#panelHeader { transform: translateY(calc(-100% + min(max(var(--grab-y), 0px), 100%))); }
158
+ nav#overwatchPanel[data-on-grab="1"]:not([data-opened="1"]) > .dynamic_section_host { transform: translateY(calc(-100% - var(--panel-block-height) + min(max(var(--grab-y), 0px), calc(100% + var(--panel-block-height))))); }
159
+ nav#overwatchPanel[data-on-grab="1"]:not([data-opened="1"]) > .dynamic_section_block { transform: translateY(calc(-100vh - var(--panel-block-height) + min(max(var(--grab-y), 0px), calc(100vh + var(--panel-block-height))))); }
156
160
  nav#overwatchPanel[data-opened="1"][data-on-grab="1"] > header#panelHeader,
157
161
  nav#overwatchPanel[data-opened="1"][data-on-grab="1"] > .dynamic_section_host,
158
162
  nav#overwatchPanel[data-opened="1"][data-on-grab="1"] > .dynamic_section_block { transform: translateY(min(var(--grab-y), 0px)); }
159
163
 
164
+ /* Tablet+ (≥740 wide) — surface both block_items side-by-side instead of tab swipe. */
165
+ @media all and (min-width: 740px) {
166
+ nav#overwatchPanel > .dynamic_section_host { display: none; }
167
+ nav#overwatchPanel > .dynamic_section_block { scroll-snap-type: none; overflow-x: hidden; }
168
+ nav#overwatchPanel > .dynamic_section_block > .block_item { width: auto; flex: 1 1 0; min-width: 0; }
169
+ }
170
+
160
171
  /* Top swipe trigger strip. Sits above fixedTop (z-index 130) so the downward swipe can fire while the panel is closed.
161
172
  Height stays inside the safe area (status bar / notch) plus a small 8px bleed into fixedTop so the strip
162
173
  does not cover tappable fixedTop controls. */
@@ -171,6 +182,37 @@ nav#overwatchPanel[data-opened="1"] ~ section#panelTrigger { pointer-events: non
171
182
  #darkModeToggle[data-dark-mode-state="light"] { background-color: rgba(var(--cabr) / 50%); }
172
183
  #darkModeToggle[data-dark-mode-state="dark"] { background-color: rgba(var(--cadm) / 50%); }
173
184
 
185
+ /* Timeline (overwatchPanel #timeline) — roadmap #010. Reuses .post_block visuals from estreUiCore2.css. */
186
+ nav#overwatchPanel #timeline { --bg-color: rgb(var(--cabr) / 70%); box-sizing: border-box; padding: 8px var(--basic-ui-inset-h) calc(8px + var(--bottom-safe-pad)); }
187
+ nav#overwatchPanel #timeline > .timeline_host { display: flex; flex-flow: column nowrap; gap: 10px; }
188
+ nav#overwatchPanel #timeline .timeline_group { display: flex; flex-flow: column nowrap; gap: 6px; }
189
+ nav#overwatchPanel #timeline .timeline_group_header { display: flex; align-items: center; justify-content: space-between; padding: 4px 4px 2px; font-size: 0.75rem; font-weight: 600; color: rgb(var(--ca) / 70%); letter-spacing: 0.02em; text-transform: uppercase; }
190
+ nav#overwatchPanel #timeline .timeline_group_header > .timeline_group_label { flex: 1 1 auto; min-width: 0; }
191
+ nav#overwatchPanel #timeline .timeline_clear_all { flex: 0 0 auto; margin-left: 8px; padding: 3px 10px; border: none; border-radius: 10px; background-color: rgb(var(--ca) / 10%); color: rgb(var(--ca) / 80%); font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; }
192
+ nav#overwatchPanel #timeline .timeline_clear_all:active { background-color: rgb(var(--ca) / 25%); }
193
+ nav#overwatchPanel #timeline .timeline_item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; box-sizing: border-box; border-radius: 14px; background-color: var(--bg-color); backdrop-filter: var(--basic-backdrop-blur); -webkit-backdrop-filter: var(--basic-backdrop-blur); box-shadow: 1px 2px 4px 1px rgb(var(--ca) / 18%); cursor: pointer; user-select: none; }
194
+ nav#overwatchPanel #timeline .timeline_item > .icon_place { flex: 0 0 auto; width: 38px; height: 38px; border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
195
+ nav#overwatchPanel #timeline .timeline_item > .icon_place > img { width: 100%; height: 100%; object-fit: cover; }
196
+ nav#overwatchPanel #timeline .timeline_item > .content_place { flex: 1 1 auto; min-width: 0; --color: var(--color-text); --size: 0.9375rem; --weight: 400; }
197
+ nav#overwatchPanel #timeline .timeline_item > .content_place > .title_line { font-size: 0.9375rem; font-weight: 600; color: var(--color-text); line-height: 1.25em; }
198
+ nav#overwatchPanel #timeline .timeline_item > .content_place > .subtitle_line { font-size: 0.8125rem; font-weight: 400; color: rgb(var(--ca) / 70%); line-height: 1.25em; margin-top: 1px; }
199
+ nav#overwatchPanel #timeline .timeline_item > .content_place > .content_area { display: flex; gap: 8px; align-items: flex-start; margin-top: 3px; }
200
+ nav#overwatchPanel #timeline .timeline_item > .content_place > .content_area > .content_place { flex: 1 1 auto; min-width: 0; color: var(--color); font-size: var(--size); font-weight: var(--weight); line-height: 1.3em; word-break: break-word; }
201
+ nav#overwatchPanel #timeline .timeline_item > .content_place > .content_area > .icon_place { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 4px; overflow: hidden; }
202
+ nav#overwatchPanel #timeline .timeline_item > .content_place > .content_area > .icon_place > img { width: 100%; height: 100%; object-fit: cover; }
203
+ nav#overwatchPanel #timeline .timeline_empty { padding: 32px 16px; text-align: center; font-size: 0.875rem; color: rgb(var(--ca) / 60%); }
204
+ nav#overwatchPanel #timeline .timeline_item.timeline_item_enter { animation: timeline-item-enter 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both; transform-origin: top center; }
205
+ @keyframes timeline-item-enter {
206
+ 0% { opacity: 0; transform: translateY(-14px) scale(0.96); }
207
+ 60% { opacity: 1; }
208
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
209
+ }
210
+ nav#overwatchPanel #timeline .timeline_item.timeline_item_exit { animation: timeline-item-exit 0.3s ease-in both; animation-delay: var(--exit-delay, 0ms); pointer-events: none; }
211
+ @keyframes timeline-item-exit {
212
+ 0% { opacity: 1; transform: translateX(0); }
213
+ 100% { opacity: 0; transform: translateX(60px); }
214
+ }
215
+
174
216
  /* root tabs (bottom) */
175
217
  footer#fixedBottom { position: fixed; display: flex; flex-direction: row; flex-wrap: nowrap; z-index: 110; bottom: 0; left: 0; right: 0; height: var(--rootbar-height); margin: 0; padding-bottom: var(--bottom-safe-pad); justify-content: center; background-color: var(--color-boundary-foggy-o66); backdrop-filter: var(--basic-backdrop-blur); -webkit-backdrop-filter: var(--basic-backdrop-blur); justify-content: center; user-select: none; }
176
218
  footer#fixedBottom nav { position: relative; display: flex; flex-direction: row; flex-wrap: nowrap; height: var(--rootbar-height); flex-grow: 1; flex-shrink: 1; user-select: none; }
@@ -290,7 +290,31 @@ nav#managedOverlay > section#notification > div.container > article { position:
290
290
  nav#managedOverlay > section#notification > div.container > article:not(:is([data-on-top^="1"], [data-on-top^="0"])) { display: none; }
291
291
  nav#managedOverlay > section#notification > div.container > article:not([data-on-top="1"]) { opacity: 0; }
292
292
  nav#managedOverlay > section#notification > div.container[data-container-id="noti"] { top: var(--top-pad); }
293
- nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article { top: 32px; }
293
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article { top: 0; transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); transition-duration: 0.45s; will-change: transform, opacity; }
294
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article[data-on-top="1"] { top: 12px; transform: translateY(0); }
295
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article[data-on-top="0"] { top: 12px; transform: translateY(calc(-100% - var(--top-pad) - 12px)); }
296
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block { display: flex; align-items: center; gap: 10px; padding: 10px 14px; max-width: calc(100vw - var(--left-pad) - var(--right-pad) - 16px); min-width: min(320px, calc(100vw - var(--left-pad) - var(--right-pad) - 16px)); box-sizing: border-box; border-radius: 18px; background-color: var(--bg-color); backdrop-filter: var(--basic-backdrop-blur); -webkit-backdrop-filter: var(--basic-backdrop-blur); box-shadow: 1px 4px 8px 2px rgb(var(--ca) / 25%); cursor: pointer; }
297
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block:not([data-interactive]) { cursor: default; }
298
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .icon_place { flex: 0 0 auto; width: 38px; height: 38px; border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
299
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .icon_place > img { width: 100%; height: 100%; object-fit: cover; }
300
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .content_place { flex: 1 1 auto; min-width: 0; --color: var(--color-text); --size: 0.9375rem; --weight: 400; }
301
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .content_place > .title_line { font-size: 0.9375rem; font-weight: 600; color: var(--color-text); line-height: 1.25em; }
302
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .content_place > .subtitle_line { font-size: 0.8125rem; font-weight: 400; color: rgb(var(--ca) / 70%); line-height: 1.25em; margin-top: 1px; }
303
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .content_place > .content_area { display: flex; gap: 8px; align-items: flex-start; margin-top: 3px; }
304
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .content_place > .content_area > .content_place { flex: 1 1 auto; min-width: 0; color: var(--color); font-size: var(--size); font-weight: var(--weight); line-height: 1.3em; word-break: break-word; }
305
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .content_place > .content_area > .icon_place { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 4px; overflow: hidden; }
306
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block > .content_place > .content_area > .icon_place > img { width: 100%; height: 100%; object-fit: cover; }
307
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block { position: relative; z-index: 1; }
308
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block.banner_incoming { animation: banner-incoming 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
309
+ nav#managedOverlay > section#notification > div.container[data-container-id="noti"] > article > .post_block.banner_ghost_exit { z-index: 0; animation: banner-ghost-exit 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; pointer-events: none; }
310
+ @keyframes banner-incoming {
311
+ 0% { opacity: 0; transform: translateY(calc(-100% - var(--top-pad) - 12px)); }
312
+ 100% { opacity: 1; transform: translateY(0); }
313
+ }
314
+ @keyframes banner-ghost-exit {
315
+ 0% { opacity: 1; transform: translateY(0); }
316
+ 100% { opacity: 0; transform: translateY(calc(-100% - var(--top-pad) - 12px)); }
317
+ }
294
318
  nav#managedOverlay > section#notification > div.container[data-container-id="note"] { bottom: var(--bottom-pad); }
295
319
  nav#managedOverlay > section#notification > div.container[data-container-id="note"] > article { bottom: 0; }
296
320
  nav#managedOverlay > section#notification > div.container[data-container-id="note"] > article:is([data-on-top^="1"], [data-on-top="0"]) { }