fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)

* fix(web): harden, polish and optimize the web UI per the September 2026 audit

Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20).

Implementation integrity (P0)
- app.css now defines every utility class the templates and JS use,
  including .hidden, so the ~145 JS show/hide toggles work. Button reset,
  and base component rules (.btn, .form-control) wrapped in :where() so
  utility classes on the same element win. New static-audit test fails
  when a used utility class has no rule.

Accessibility
- Focus rings render (the old ring rule referenced undefined variables);
  one :focus-visible outline everywhere; skip link; labelled nav landmarks.
- Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap,
  Escape, focus return, applied to every modal.
- Named icon-only buttons and labelled ~70 form fields.
- Toasts announced once; errors persist >= 10s; one showNotification.
- Captive WiFi page: live region, timeouts, dark mode, 16px inputs.

Performance (Pi Zero 2 W)
- SSE streams and tab timers pause when hidden or off-tab; the display
  stream only runs while a preview is visible. app-shell.js deferred.
- Widget scripts served as one versioned bundle (/assets/widgets.js):
  52 -> 21 script tags, 66 -> 35 requests on first load.
- Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS
  1358 KB -> 291 KB on the wire. SSE untouched.

Theming and responsive
- File managers, form fields and Fonts upload on theme tokens; bare
  inputs themed in dark mode; no more white surfaces.
- No horizontal overflow at 375px on any tab; 44px touch targets on
  coarse pointers; reduced-motion respected; header title truncates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings on #568

- json-file-manager: focus-trap releases kept in a Map (no dynamic
  property access or delete; no value-returning forEach callback)
- notification / schedule-picker: style and day-label lookups via Map
- app.js: move the pending-queue assignment out of the expression
- diff_viewer / error_handler: named function declarations instead of
  arrow consts

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: check the OAuth widget ships in the widget bundle

base.html no longer tags widget scripts one by one; they load through
/assets/widgets.js. Assert the page requests the bundle and the bundle
contains google-oauth.js, which is what the test was protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): address review feedback on #568

- widget bundle version fingerprints every file (name, mtime_ns, size)
- gzip fallback appends Accept-Encoding to an existing Vary header
- dialog helper: releasing a non-top dialog no longer moves focus out of
  the dialog the user is in
- labels: file-upload targets its file input; fallback config fields get
  label for/id pairs; native color input has a fallback name
- utility audit also reads class names inside bound :class expressions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): give the native color-picker input an accessible name

CodeRabbit flagged this on PR #568 as an outside-diff finding (never
posted inline, so it was missed in the round of fixes that addressed
the other 6 review comments). The <input type="color"> only carried a
title attribute; screen readers don't reliably announce title, and
there's no other label naming the control when showHexInput is false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings in app-shell.js

- drop the unused catch binding on the SSE JSON parse
- move the pending-notification queue assignment out of the expression

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): contain plugin widgets/ dir and bound style-editor retries

From CodeRabbit review on #568 (code that arrived with the main merge):
- serve_plugin_widget resolves widgets/ with resolve_under before
  resolving the manifest script under it, so a symlinked widgets
  directory can't become the containment base (CWE-22). New test.
- style-editor init stops polling after ~10s when the widget never
  registers and leaves the plain fallback fields in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 09:42:24 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 69d408b321
commit d1e821c625
45 changed files with 2869 additions and 870 deletions
@@ -415,7 +415,8 @@
removeBtn.type = 'button';
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
removeBtn.setAttribute('aria-label', 'Remove row');
removeBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
actionsCell.appendChild(removeBtn);
if (hasAdvanced) {
@@ -424,7 +425,8 @@
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
editBtn.title = 'Edit advanced properties (layout, style…)';
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
editBtn.setAttribute('aria-label', 'Edit advanced properties');
editBtn.innerHTML = '<i class="fas fa-sliders-h" aria-hidden="true"></i>';
actionsCell.appendChild(editBtn);
}
@@ -446,9 +448,8 @@
if (!advancedCell) return;
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
// Close any existing modal
const existing = document.getElementById('array-row-editor-modal');
if (existing) existing.remove();
// Close any existing modal (also releases its focus trap)
window.closeArrayTableRowEditor();
const overlay = document.createElement('div');
overlay.id = 'array-row-editor-modal';
@@ -463,9 +464,10 @@
// Header
safeSetHTML(dialog, `
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
<h3 id="array-row-editor-title" class="text-base font-semibold text-gray-900">Advanced Properties</h3>
<button type="button" onclick="window.closeArrayTableRowEditor()"
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
aria-label="Close advanced properties"
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times" aria-hidden="true"></i></button>
</div>`);
const body = document.createElement('div');
@@ -558,11 +560,27 @@
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
if (window.LEDDialog) {
rowEditorRelease = window.LEDDialog.trap(dialog, {
labelledBy: 'array-row-editor-title',
initialFocus: '[data-modal-prop]',
onEscape: window.closeArrayTableRowEditor
});
}
};
// Focus-trap release for the open row editor (see utils/dialog.js).
let rowEditorRelease = null;
window.closeArrayTableRowEditor = function() {
const modal = document.getElementById('array-row-editor-modal');
if (modal) modal.remove();
if (rowEditorRelease) {
const release = rowEditorRelease;
rowEditorRelease = null;
release();
}
};
/**
@@ -107,6 +107,8 @@
html += `
<input type="color"
id="${fieldId}_color"
title="Choose color"
aria-label="Choose color"
value="${currentValue}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onColorChange('${fieldId}')"
@@ -122,6 +124,7 @@
id="${fieldId}_hex"
value="${currentValue.substring(1)}"
maxlength="6"
aria-label="Hex color code"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onHexChange('${fieldId}')"
oninput="window.LEDMatrixWidgets.getHandlers('color-picker').onHexInput('${fieldId}')"
@@ -161,7 +164,8 @@
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
style="background-color: ${escapeHtml(normalized)};"
title="${escapeHtml(normalized)}">
title="${escapeHtml(normalized)}"
aria-label="Use color ${escapeHtml(normalized)}">
</button>
`;
}
@@ -85,7 +85,7 @@
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-calendar-alt text-gray-400"></i>
<i class="fas fa-calendar-alt text-gray-400" aria-hidden="true"></i>
</div>
</div>
`;
@@ -96,8 +96,8 @@
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('date-picker').onClear('${fieldId}')"
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
title="Clear" aria-label="Clear date">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
`;
}
@@ -102,8 +102,8 @@
</div>`;
html += `<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
<i class="fas fa-times"></i>
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image" aria-label="Remove image">
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
html += '</div>';
@@ -840,7 +840,7 @@
<!-- Schedule Mode -->
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<select id="schedule_mode_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
@@ -855,8 +855,8 @@
<!-- Time Range Mode -->
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
id="schedule_start_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
@@ -865,8 +865,8 @@
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
id="schedule_end_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
@@ -900,6 +900,7 @@
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time"
id="day_${day}_start_${sanitizedId}"
aria-label="${day} start time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
@@ -909,6 +910,7 @@
${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time"
id="day_${day}_end_${sanitizedId}"
aria-label="${day} end time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
@@ -57,6 +57,8 @@
this._editFile = null;
this._deleteFile = null;
this._keyHandler = this._onKey.bind(this);
// Focus-trap release functions for open modals (utils/dialog.js)
this._releases = new Map();
this._inject();
this._bind();
@@ -67,9 +69,30 @@
_destroy() {
document.removeEventListener('keydown', this._keyHandler);
for (const key of Array.from(this._releases.keys())) this._release(key);
this.el._jfmInstance = null;
}
// Open a modal's focus trap. Escape is routed here, so _onKey no
// longer handles it.
_trap(key, modal, titleId, initialFocus, onClose) {
if (!modal || !window.LEDDialog) return;
const box = modal.querySelector('.jfm-modal-box');
this._releases.set(key, window.LEDDialog.trap(box, {
labelledBy: titleId,
initialFocus: initialFocus || null,
onEscape: onClose
}));
}
_release(key) {
const release = this._releases.get(key);
if (release) {
this._releases.delete(key);
release();
}
}
// ── DOM Injection ────────────────────────────────────────────────────
_inject() {
@@ -108,21 +131,22 @@
</div>` : ''}
<!-- ── Edit modal ─────────────────────────────────────── -->
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal" id="${u}-edit-modal" hidden>
<div class="jfm-modal-box jfm-modal-wide">
<div class="jfm-modal-head">
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
<div class="jfm-modal-tools">
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">&times;</button>
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close editor">&times;</button>
</div>
</div>
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
<div id="${u}-edit-err" class="jfm-err-bar" role="alert" hidden></div>
<textarea id="${u}-editor" class="jfm-editor"
spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="off"
aria-label="JSON editor"></textarea>
aria-label="JSON editor" aria-describedby="${u}-editor-hint"></textarea>
<span id="${u}-editor-hint" hidden>Tab inserts two spaces. Shift+Tab moves focus back to the toolbar. Ctrl+S saves.</span>
<div class="jfm-modal-foot">
<span id="${u}-charcount" class="jfm-stat"></span>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
@@ -133,10 +157,10 @@
<!-- ── Delete modal ───────────────────────────────────── -->
${hasDelete ? `
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal" id="${u}-del-modal" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Delete file</span>
<span id="${u}-del-title" class="jfm-modal-title">Delete file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
@@ -144,7 +168,7 @@
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del" id="${u}-del-cancel">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
</div>
</div>
@@ -152,10 +176,10 @@
<!-- ── Create modal ───────────────────────────────────── -->
${hasCreate ? `
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal" id="${u}-create-modal" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Create new file</span>
<span id="${u}-create-title" class="jfm-modal-title">Create new file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
@@ -189,93 +213,106 @@
}
_css(u) {
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"])
// with the original light values as fallbacks for pages without app.css.
// Shades the tokens don't cover are local variables with a dark override.
return `<style>
#${u}{font-family:inherit;color:#111827;}
#${u}{--jfm-border-strong:#d1d5db;--jfm-border-hover:#9ca3af;--jfm-subtle:#f3f4f6;--jfm-accent-soft:#93c5fd;--jfm-danger-border:#fecaca;--jfm-danger-text:#991b1b;--jfm-text-muted:#6b7280;}
[data-theme="dark"] #${u}{--jfm-border-strong:#4b5563;--jfm-border-hover:#6b7280;--jfm-subtle:#374151;--jfm-accent-soft:#1e40af;--jfm-danger-border:#991b1b;--jfm-danger-text:#fecaca;--jfm-text-muted:#9ca3af;}
#${u}{font-family:inherit;color:var(--color-text-primary,#111827);}
#${u} *{box-sizing:border-box;}
/* Header */
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:var(--color-text-primary,#111827);}
#${u} .jfm-dir{font-size:.75rem;color:var(--jfm-text-muted);background:var(--jfm-subtle);padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
/* Buttons */
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid var(--jfm-border-strong);background:var(--color-surface,#fff);color:var(--color-text-secondary,#374151);font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
#${u} .jfm-btn:hover:not(:disabled){background:var(--color-background,#f9fafb);border-color:var(--jfm-border-hover);}
#${u} .jfm-btn:focus-visible,#${u} .jfm-close-btn:focus-visible{outline:2px solid var(--color-primary,#3b82f6);outline-offset:1px;}
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
#${u} .jfm-btn-primary{background:var(--color-primary,#3b82f6);border-color:var(--color-primary,#3b82f6);color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:var(--color-primary-hover,#2563eb);border-color:var(--color-primary-hover,#2563eb);}
#${u} .jfm-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#b91c1c;border-color:#b91c1c;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:var(--jfm-text-muted);}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:var(--jfm-text-muted);font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
#${u} .jfm-close-btn:hover{background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
/* File list */
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--jfm-text-muted);font-size:.875rem;}
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:var(--jfm-text-muted);}
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-empty-title{font-weight:600;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
/* File cards */
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
#${u} .jfm-card{border:1px solid var(--color-border,#e5e7eb);border-radius:.5rem;padding:.875rem;background:var(--color-surface,#fff);display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
#${u} .jfm-card:hover{border-color:var(--jfm-accent-soft);box-shadow:var(--shadow-md,0 2px 8px rgba(59,130,246,.1));}
#${u} .jfm-card.jfm-off{opacity:.6;}
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:var(--color-text-primary,#111827);flex:1;}
#${u} .jfm-card-meta{font-size:.75rem;color:var(--jfm-text-muted);display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid var(--color-border-light,#f3f4f6);margin-top:.125rem;}
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
/* Toggle */
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:var(--jfm-text-muted);white-space:nowrap;flex-shrink:0;}
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
/* Upload zone */
#${u} .jfm-upload-wrap{margin-top:.25rem;}
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
#${u} .jfm-dropzone{border:2px dashed var(--jfm-border-strong);border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:var(--color-background,#f9fafb);user-select:none;}
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:var(--color-primary,#3b82f6);background:var(--color-info-bg,#eff6ff);border-style:solid;outline:none;}
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
#${u} .jfm-drop-primary{font-size:.875rem;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:var(--jfm-text-muted);margin:0;}
/* Modals */
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
#${u} .jfm-modal[hidden]{display:none;}
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
#${u} .jfm-modal-box{background:var(--color-surface,#fff);color:var(--color-text-primary,#111827);border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
#${u} .jfm-modal-box:focus{outline:none;}
#${u} .jfm-modal-wide{max-width:880px;}
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid var(--color-border,#e5e7eb);flex-shrink:0;gap:.5rem;}
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:var(--color-text-primary,#111827);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid var(--color-border,#e5e7eb);flex-shrink:0;background:var(--color-background,#f9fafb);border-radius:0 0 .5rem .5rem;}
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:var(--jfm-text-muted);font-variant-numeric:tabular-nums;}
/* JSON editor */
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:var(--color-text-primary,#1e293b);background:var(--color-background,#fafafa);tab-size:2;}
#${u} .jfm-editor:focus-visible{box-shadow:inset 0 0 0 2px var(--color-primary,#3b82f6);}
#${u} .jfm-err-bar{background:var(--color-error-bg,#fef2f2);border-bottom:1px solid var(--jfm-danger-border);color:var(--jfm-danger-text);font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
#${u} .jfm-err-bar[hidden]{display:none;}
/* Create form */
#${u} .jfm-field{margin-bottom:.875rem;}
#${u} .jfm-field:last-child{margin-bottom:0;}
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:var(--color-text-secondary,#374151);margin-bottom:.3125rem;}
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid var(--jfm-border-strong);border-radius:.375rem;font-size:.875rem;color:var(--color-text-primary,#111827);background:var(--color-surface,#fff);}
#${u} .jfm-field input:focus{outline:none;border-color:var(--color-primary,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,.25);}
#${u} .jfm-hint{display:block;font-size:.75rem;color:var(--jfm-text-muted);margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:var(--jfm-text-muted);margin-top:.375rem;}
/* Spinner */
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid var(--color-border,#e5e7eb);border-top-color:var(--color-primary,#3b82f6);border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
@media (prefers-reduced-motion: reduce){
#${u} .jfm-btn,#${u} .jfm-card,#${u} .jfm-dropzone{transition:none;}
#${u} .jfm-spin{animation-duration:1.5s;}
#${u} .jfm-modal{backdrop-filter:none;}
}
</style>`;
}
@@ -319,7 +356,9 @@
if (this._editorEl) {
this._editorEl.addEventListener('input', () => this._updateStat());
this._editorEl.addEventListener('keydown', e => {
if (e.key === 'Tab') {
// Plain Tab indents; Shift+Tab is left alone so keyboard
// users can always move focus out of the editor.
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
const s = this._editorEl.selectionStart;
const end = this._editorEl.selectionEnd;
@@ -336,15 +375,8 @@
}
_onKey(e) {
// Escape is handled by the dialog focus trap (see _trap).
const editOpen = this._editModal && !this._editModal.hidden;
const delOpen = this._delModal && !this._delModal.hidden;
const createOpen = this._createModal && !this._createModal.hidden;
if (e.key === 'Escape') {
if (editOpen) { this._closeEdit(); return; }
if (delOpen) { this._closeDel(); return; }
if (createOpen) { this._closeCreate(); return; }
}
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
e.preventDefault();
this._doSave();
@@ -488,6 +520,7 @@
this._editorEl.value = 'Loading…';
this._updateStat();
this._editModal.hidden = false;
this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit());
try {
const data = await this._api('get', { filename });
@@ -498,7 +531,7 @@
this._editorEl.setSelectionRange(0, 0);
this._editorEl.scrollTop = 0;
} catch (err) {
this._showErr('Failed to load file: ' + err.message);
this._showErr(`Couldn't load this file (${err.message}). Close the editor and try again.`);
this._editorEl.value = '';
}
}
@@ -507,6 +540,7 @@
if (this._editModal) this._editModal.hidden = true;
this._editFile = null;
this._clearErr();
this._release('edit');
}
_formatJson() {
@@ -562,12 +596,18 @@
this._deleteFile = filename;
const el = document.getElementById(`${this._uid}-del-name`);
if (el) el.textContent = filename;
if (this._delModal) this._delModal.hidden = false;
if (this._delModal) {
this._delModal.hidden = false;
// Destructive dialog: start on Cancel.
this._trap('del', this._delModal, `${this._uid}-del-title`,
document.getElementById(`${this._uid}-del-cancel`), () => this._closeDel());
}
}
_closeDel() {
if (this._delModal) this._delModal.hidden = true;
this._deleteFile = null;
this._release('del');
}
async _doDelete() {
@@ -597,11 +637,13 @@
});
this._createModal.hidden = false;
const first = this.createFields[0];
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
const firstInput = first ? document.getElementById(`${this._uid}-cf-${first.key}`) : null;
this._trap('create', this._createModal, `${this._uid}-create-title`, firstInput, () => this._closeCreate());
}
_closeCreate() {
if (this._createModal) this._createModal.hidden = true;
this._release('create');
}
async _doCreate() {
@@ -2,7 +2,9 @@
* LEDMatrix Notification Widget
*
* Global notification/toast system for displaying messages to users.
* Consolidates all notification functionality into a single widget.
* This is the single implementation: the early fallbacks in app-shell.js,
* app.js and partials/fonts.html only queue messages until this widget has
* loaded (see window.__pendingNotifications) and then delegate to it.
*
* Usage:
* window.showNotification('Message here', 'success');
@@ -11,6 +13,16 @@
*
* Types: success, error, warning, info (default)
*
* Accessibility:
* - Toasts are announced through two persistent, visually hidden live
* regions: errors use an assertive region (role="alert"), everything else
* a polite one (role="status"). The visible toast is not itself a live
* region, so each message is announced exactly once.
* - Errors stay on screen for at least ERROR_MIN_DURATION and always have a
* dismiss button.
* - Auto-dismiss pauses while the pointer is over a toast or focus is in it.
* - Slide animations are skipped under prefers-reduced-motion.
*
* @module NotificationWidget
*/
@@ -26,35 +38,51 @@
// Configuration
const CONFIG = {
containerId: 'notifications',
politeRegionId: 'notifications-live-polite',
assertiveRegionId: 'notifications-live-assertive',
defaultDuration: 4000,
warningDuration: 6000,
errorMinDuration: 10000,
fadeOutDuration: 300,
maxNotifications: 5,
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
};
// Type-specific styling
// Type-specific styling. `bg` class names are kept for anyone styling
// against them; `color` is applied inline so white text meets 4.5:1
// contrast even where the utility classes are missing or lighter.
const TYPE_STYLES = {
success: {
bg: 'bg-green-500',
color: '#047857',
icon: 'fa-check-circle',
label: 'Success'
},
error: {
bg: 'bg-red-500',
color: '#b91c1c',
icon: 'fa-exclamation-circle',
label: 'Error'
},
warning: {
bg: 'bg-yellow-500',
color: '#b45309',
icon: 'fa-exclamation-triangle',
label: 'Warning'
},
info: {
bg: 'bg-blue-500',
color: '#1d4ed8',
icon: 'fa-info-circle',
label: 'Info'
}
};
// Lookups go through a Map so an arbitrary type string can't reach
// Object.prototype properties.
const STYLE_BY_TYPE = new Map(Object.entries(TYPE_STYLES));
const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
// Track active notifications
let activeNotifications = [];
@@ -62,8 +90,14 @@
// keyed by notification id (cleaned up on dismiss). A Map rather than a
// plain object so ids can never collide with prototype properties.
const actionCallbacks = new Map();
// Auto-dismiss timers, keyed by notification id: { timer, remaining, startedAt }
const timers = new Map();
let notificationCounter = 0;
function prefersReducedMotion() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
/**
* Get or create the notifications container
* @returns {HTMLElement} Container element
@@ -75,14 +109,55 @@
container = document.createElement('div');
container.id = CONFIG.containerId;
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-label', 'Notifications');
document.body.appendChild(container);
}
if (!container.dataset.ledNotifications) {
container.dataset.ledNotifications = 'true';
// Announcements go through the dedicated live regions below; a
// live container would announce every toast a second time.
container.removeAttribute('aria-live');
container.setAttribute('role', 'region');
container.setAttribute('aria-label', 'Notifications');
// Let clicks pass through the empty parts of the stack even when
// the utility classes are not defined.
container.style.pointerEvents = 'none';
}
getLiveRegion(CONFIG.politeRegionId, 'status', 'polite');
getLiveRegion(CONFIG.assertiveRegionId, 'alert', 'assertive');
return container;
}
function getLiveRegion(id, role, politeness) {
let region = document.getElementById(id);
if (!region) {
region = document.createElement('div');
region.id = id;
region.setAttribute('role', role);
region.setAttribute('aria-live', politeness);
region.setAttribute('aria-atomic', 'true');
region.style.cssText = VISUALLY_HIDDEN;
document.body.appendChild(region);
}
return region;
}
/**
* Announce a message to screen readers once.
*/
function announce(text, assertive) {
const region = getLiveRegion(
assertive ? CONFIG.assertiveRegionId : CONFIG.politeRegionId,
assertive ? 'alert' : 'status',
assertive ? 'assertive' : 'polite'
);
// Clear first so repeating the same message is announced again.
region.textContent = '';
setTimeout(() => { region.textContent = text; }, 50);
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
@@ -94,16 +169,55 @@
return div.innerHTML;
}
function clearTimer(notificationId) {
const t = timers.get(notificationId);
if (t && t.timer) clearTimeout(t.timer);
timers.delete(notificationId);
}
function startTimer(notificationId, ms) {
const t = timers.get(notificationId) || {};
if (t.timer) clearTimeout(t.timer);
t.remaining = ms;
t.startedAt = Date.now();
t.timer = setTimeout(() => removeNotification(notificationId), ms);
timers.set(notificationId, t);
}
function pauseTimer(notificationId) {
const t = timers.get(notificationId);
if (!t || !t.timer) return;
clearTimeout(t.timer);
t.timer = null;
t.remaining = Math.max(0, t.remaining - (Date.now() - t.startedAt));
}
function resumeTimer(notificationId) {
const t = timers.get(notificationId);
if (!t || t.timer) return;
// Give the reader a moment after they move away.
startTimer(notificationId, Math.max(t.remaining, 2000));
}
/**
* Remove a notification by ID
* @param {string} notificationId - Notification ID
* @param {boolean} immediate - Skip fade animation
*/
function removeNotification(notificationId, immediate = false) {
clearTimer(notificationId);
const notification = document.getElementById(notificationId);
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
if (!notification) return;
if (immediate) {
// If focus is inside the toast, don't strand it on a removed node.
const hadFocus = notification.contains(document.activeElement);
if (immediate || prefersReducedMotion()) {
notification.remove();
} else {
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
@@ -115,9 +229,16 @@
}, CONFIG.fadeOutDuration);
}
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
if (hadFocus) {
const next = activeNotifications.length
? document.querySelector(`#${activeNotifications[activeNotifications.length - 1]} button`)
: null;
if (next) {
next.focus();
} else if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
}
}
/**
@@ -131,15 +252,26 @@
if (typeof options === 'string') {
options = { type: options };
}
options = options || {};
const type = options.type || 'info';
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration;
const type = STYLE_BY_TYPE.has(options.type) ? options.type : 'info';
const isError = type === 'error';
let duration = options.duration !== undefined
? Number(options.duration)
: (isError ? CONFIG.errorMinDuration
: type === 'warning' ? CONFIG.warningDuration : CONFIG.defaultDuration);
// Errors must stay long enough to read; 0 still means "until dismissed".
if (isError && duration > 0 && duration < CONFIG.errorMinDuration) {
duration = CONFIG.errorMinDuration;
}
const showIcon = options.showIcon !== false;
const dismissible = options.dismissible !== false;
// Errors always get a dismiss button.
const dismissible = isError || duration <= 0 || options.dismissible !== false;
const style = TYPE_STYLES[type] || TYPE_STYLES.info;
const style = STYLE_BY_TYPE.get(type);
const container = getContainer();
const notificationId = `notification_${++notificationCounter}`;
const reduceMotion = prefersReducedMotion();
// Enforce max notifications limit
while (activeNotifications.length >= CONFIG.maxNotifications) {
@@ -150,18 +282,25 @@
const notification = document.createElement('div');
notification.id = notificationId;
notification.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 pointer-events-auto transform transition-all duration-300 ease-out`;
notification.style.opacity = '0';
notification.style.transform = 'translateX(100%)';
notification.setAttribute('role', 'alert');
notification.style.backgroundColor = style.color;
notification.style.color = '#ffffff';
notification.style.pointerEvents = 'auto';
notification.dataset.type = type;
if (reduceMotion) {
notification.style.transition = 'none';
} else {
notification.style.opacity = '0';
notification.style.transform = 'translateX(100%)';
}
// Build content
let html = '';
if (showIcon) {
html += `<i class="fas ${style.icon} flex-shrink-0"></i>`;
html += `<i class="fas ${style.icon} flex-shrink-0" aria-hidden="true"></i>`;
}
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${escapeHtml(message)}</span>`;
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
// The callback is stored by id and invoked via triggerAction, which
@@ -171,7 +310,8 @@
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150"
style="background:rgba(255,255,255,.2);color:inherit;border:0;">
${escapeHtml(options.actionLabel)}
</button>
`;
@@ -182,8 +322,9 @@
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')"
class="flex-shrink-0 ml-2 w-5 h-5 flex items-center justify-center rounded-full opacity-70 hover:opacity-100 hover:bg-white hover:bg-opacity-20 transition-all duration-150"
aria-label="Dismiss notification">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
style="width:1.75rem;height:1.75rem;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:inherit;border:0;border-radius:9999px;cursor:pointer;"
aria-label="Dismiss ${style.label.toLowerCase()} notification">
<svg class="w-3 h-3" width="12" height="12" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
@@ -194,17 +335,29 @@
container.appendChild(notification);
activeNotifications.push(notificationId);
// Trigger animation (need to wait for DOM update)
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateX(0)';
});
announce(`${style.label}: ${String(message)}`, isError);
// Auto-dismiss (0 = no auto-dismiss)
if (!reduceMotion) {
// Trigger animation (need to wait for DOM update)
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateX(0)';
});
}
// Auto-dismiss (0 = no auto-dismiss), paused while hovered or focused
if (duration > 0) {
setTimeout(() => {
removeNotification(notificationId);
}, duration);
startTimer(notificationId, duration);
notification.addEventListener('mouseenter', () => pauseTimer(notificationId));
notification.addEventListener('mouseleave', () => {
if (!notification.contains(document.activeElement)) resumeTimer(notificationId);
});
notification.addEventListener('focusin', () => pauseTimer(notificationId));
notification.addEventListener('focusout', (e) => {
if (!notification.contains(e.relatedTarget) && !notification.matches(':hover')) {
resumeTimer(notificationId);
}
});
}
// Log for debugging
@@ -224,16 +377,17 @@
// Register the widget
window.LEDMatrixWidgets.register('notification', {
name: 'Notification Widget',
version: '1.0.0',
version: '1.1.0',
/**
* Show a notification
* @param {string} message - Message to display
* @param {Object} options - Configuration options
* @param {string} options.type - Notification type: success, error, warning, info
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss).
* Errors are kept for at least 10s.
* @param {boolean} options.showIcon - Show type icon (default: true)
* @param {boolean} options.dismissible - Show dismiss button (default: true)
* @param {boolean} options.dismissible - Show dismiss button (default: true; always true for errors)
* @returns {string} Notification ID
*/
show: showNotification,
@@ -295,15 +449,28 @@
// Global shorthand function (backwards compatible with existing code).
// Accepts either the legacy type string or a full options object
// ({ type, duration, actionLabel, onAction, ... }).
// This always replaces the early fallbacks, whatever order they loaded in.
window.showNotification = function(message, type = 'info') {
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
};
window.showNotification.__ledNotificationWidget = true;
// Initialize container on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', getContainer);
} else {
function flushPending() {
getContainer();
const pending = window.__pendingNotifications;
window.__pendingNotifications = [];
if (Array.isArray(pending)) {
pending.forEach(args => {
try { window.showNotification(args[0], args[1]); } catch (e) { console.error(e); }
});
}
}
// Initialize container (and show anything queued before we loaded)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', flushPending);
} else {
flushPending();
}
console.log('[NotificationWidget] Notification widget registered');
@@ -101,8 +101,9 @@
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onDecrement('${fieldId}')"
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}">
<i class="fas fa-minus text-xs"></i>
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}"
aria-label="Decrease" aria-controls="${fieldId}_input">
<i class="fas fa-minus text-xs" aria-hidden="true"></i>
</button>
`;
}
@@ -128,8 +129,9 @@
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onIncrement('${fieldId}')"
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}">
<i class="fas fa-plus text-xs"></i>
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}"
aria-label="Increase" aria-controls="${fieldId}_input">
<i class="fas fa-plus text-xs" aria-hidden="true"></i>
</button>
`;
}
@@ -142,8 +142,9 @@
id="${fieldId}_toggle"
onclick="window.LEDMatrixWidgets.getHandlers('password-input').onToggle('${fieldId}')"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
title="Show/hide password">
<i id="${fieldId}_icon" class="fas fa-eye"></i>
title="Show/hide password"
aria-label="Show password" aria-controls="${fieldId}_input">
<i id="${fieldId}_icon" class="fas fa-eye" aria-hidden="true"></i>
</button>
`;
}
@@ -297,16 +298,19 @@
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const icon = document.getElementById(`${safeId}_icon`);
const toggleBtn = document.getElementById(`${safeId}_toggle`);
if (input && icon) {
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Hide password');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Show password');
}
}
}
@@ -56,108 +56,141 @@
if (!document.getElementById('pfm-styles')) {
const style = document.createElement('style');
style.id = 'pfm-styles';
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"]),
// with the original light values as fallbacks for pages without app.css.
// The few shades the tokens don't cover are widget-local variables with
// a dark-theme override.
style.textContent = `
.pfm-root { font-family: inherit; }
.pfm-root, .pfm-overlay {
--pfm-border-strong:#d1d5db; --pfm-subtle:#f3f4f6; --pfm-subtle-hover:#e5e7eb;
--pfm-danger-border:#fecaca; --pfm-danger-text:#991b1b; --pfm-today:#fef9c3;
--pfm-text-muted:#6b7280;
}
[data-theme="dark"] .pfm-root, [data-theme="dark"] .pfm-overlay {
--pfm-border-strong:#4b5563; --pfm-subtle:#374151; --pfm-subtle-hover:#4b5563;
--pfm-danger-border:#991b1b; --pfm-danger-text:#fecaca; --pfm-today:#422006;
--pfm-text-muted:#9ca3af;
}
.pfm-root { font-family: inherit; color:var(--color-text-primary,#111827); }
.pfm-header { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.75rem; }
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
.pfm-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
.pfm-dir { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.125rem; }
.pfm-upload { border:2px dashed var(--pfm-border-strong); border-radius:.5rem; padding:1.25rem;
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
.pfm-upload:hover,.pfm-upload.dragover { border-color:var(--color-primary,#3b82f6); background:var(--color-info-bg,#eff6ff); }
.pfm-upload p { font-size:.875rem; color:var(--color-text-tertiary,#4b5563); margin:.25rem 0 0; }
.pfm-upload small { font-size:.75rem; color:var(--pfm-text-muted); }
.pfm-upload-icon { font-size:1.5rem; color:var(--pfm-text-muted); }
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
gap:.75rem; margin-top:.75rem; }
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
background:#fff; transition:box-shadow .15s; }
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
.pfm-card { border:1px solid var(--color-border,#e5e7eb); border-radius:.5rem; padding:.875rem;
background:var(--color-surface,#fff); transition:box-shadow .15s; }
.pfm-card:hover { box-shadow:var(--shadow-md,0 1px 4px rgba(0,0,0,.1)); }
.pfm-card.disabled { opacity:.55; }
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.5rem; }
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
.pfm-card-icon { width:2rem; height:2rem; background:var(--pfm-subtle); border-radius:.375rem;
display:flex; align-items:center; justify-content:center;
color:#6b7280; font-size:1rem; }
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
color:var(--pfm-text-muted); font-size:1rem; }
.pfm-card-name { font-weight:600; color:var(--color-text-primary,#111827); font-size:.875rem; margin:.375rem 0 .125rem; }
.pfm-card-meta { font-size:.75rem; color:var(--pfm-text-muted); line-height:1.5; }
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
border-radius:.375rem; font-size:.8125rem; font-weight:500;
border:none; cursor:pointer; transition:background .15s; }
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
.pfm-btn-primary:hover { background:#1d4ed8; }
.pfm-btn:focus-visible { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
.pfm-btn:disabled { opacity:.5; cursor:not-allowed; }
.pfm-btn-primary { background:var(--color-primary,#2563eb); color:#fff; flex:1; justify-content:center; }
.pfm-btn-primary:hover { background:var(--color-primary-hover,#1d4ed8); }
/* Solid fills with white text: fixed shades read well on both themes. */
.pfm-btn-danger { background:#dc2626; color:#fff; }
.pfm-btn-danger:hover { background:#b91c1c; }
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
.pfm-btn-secondary:hover { background:#e5e7eb; }
.pfm-btn-secondary { background:var(--pfm-subtle); color:var(--color-text-secondary,#374151); border:1px solid var(--pfm-border-strong); }
.pfm-btn-secondary:hover { background:var(--pfm-subtle-hover); }
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
.pfm-btn-create { background:#059669; color:#fff; }
.pfm-btn-create:hover { background:#047857; }
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
.pfm-toggle-label { font-size:.75rem; color:var(--pfm-text-muted); }
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
.pfm-toggle-slider { position:absolute; inset:0; background:var(--pfm-border-strong); border-radius:9999px;
cursor:pointer; transition:background .2s; }
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
left:.1875rem; bottom:.1875rem; background:#fff;
border-radius:50%; transition:transform .2s; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
.pfm-toggle-cb input:focus-visible + .pfm-toggle-slider { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
.pfm-empty { text-align:center; padding:2rem; color:var(--pfm-text-muted); }
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
.pfm-error-text { color:var(--color-error,#dc2626); }
/* Modal */
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
display:flex; align-items:flex-start; justify-content:center;
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
.pfm-modal { background:var(--color-surface,#fff); color:var(--color-text-primary,#111827);
border-radius:.75rem; width:100%; max-width:56rem;
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
.pfm-modal:focus { outline:none; }
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
padding:1rem 1.25rem; border-bottom:1px solid var(--color-border,#e5e7eb); }
.pfm-modal-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
padding:.875rem 1.25rem; border-top:1px solid var(--color-border,#e5e7eb);
background:var(--color-background,#f9fafb); border-radius:0 0 .75rem .75rem; }
/* Entry table */
.pfm-table-wrap { overflow-x:auto; }
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
.pfm-table th { background:var(--color-background,#f9fafb); text-align:left; padding:.5rem .625rem;
font-weight:600; color:var(--color-text-secondary,#374151); border-bottom:1px solid var(--color-border,#e5e7eb);
white-space:nowrap; position:sticky; top:0; }
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid var(--color-border-light,#f3f4f6);
vertical-align:top; }
.pfm-table tr.today-row td { background:#fef9c3; }
.pfm-table tr.today-row td { background:var(--pfm-today); }
.pfm-table td input, .pfm-table td textarea {
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
width:100%; border:1px solid var(--pfm-border-strong); border-radius:.25rem;
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
resize:vertical; background:#fff; }
resize:vertical; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-table td input:focus, .pfm-table td textarea:focus {
outline:none; border-color:#3b82f6; }
outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
color:#6b7280; white-space:nowrap; }
color:var(--pfm-text-muted); white-space:nowrap; }
.pfm-table-info { font-size:.75rem; color:var(--pfm-text-muted); margin-bottom:.375rem; }
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
margin-top:.75rem; font-size:.8125rem; color:var(--pfm-text-muted); }
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
border-radius:.25rem; text-align:center; }
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid var(--pfm-border-strong);
border-radius:.25rem; text-align:center;
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-json-ta { width:100%; font-family:monospace; font-size:.75rem; border:1px solid var(--pfm-border-strong);
border-radius:.375rem; padding:.5rem;
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-json-err { color:var(--color-error,#dc2626); font-size:.75rem; margin-top:.25rem; }
/* Form in create modal */
.pfm-field { margin-bottom:.875rem; }
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
color:#374151; margin-bottom:.25rem; }
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
border-radius:.375rem; font-size:.875rem; }
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
color:var(--color-text-secondary,#374151); margin-bottom:.25rem; }
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid var(--pfm-border-strong);
border-radius:.375rem; font-size:.875rem;
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-field input:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
.pfm-field-hint { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.2rem; }
.pfm-field-error { font-size:.75rem; color:var(--color-error,#dc2626); margin-top:.2rem; }
/* Delete danger box */
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
.pfm-danger-box { background:var(--color-error-bg,#fef2f2); border:1px solid var(--pfm-danger-border);
border-radius:.5rem; padding:.875rem; font-size:.875rem;
color:#991b1b; }
color:var(--pfm-danger-text); }
@media (prefers-reduced-motion: reduce) {
.pfm-upload, .pfm-card, .pfm-btn, .pfm-toggle-slider, .pfm-toggle-slider:before { transition:none; }
}
`;
document.head.appendChild(style);
}
@@ -356,7 +389,8 @@
delBtn.dataset.pfmAction = 'delete';
delBtn.dataset.pfmField = fieldId;
delBtn.dataset.pfmFile = f.filename;
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
delBtn.setAttribute('aria-label', 'Delete ' + f.filename);
delBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>'; // static
actions.appendChild(delBtn);
}
card.appendChild(actions);
@@ -375,18 +409,18 @@
modal.className = 'pfm-modal';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
<i class="fas fa-times"></i>
<span class="pfm-modal-title" id="${escHtml(fieldId)}_modal_title"><i class="fas fa-edit mr-2" aria-hidden="true"></i>${escHtml(filename)}</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
<div class="pfm-empty"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i>Loading…</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<i class="fas fa-save mr-1"></i>Save
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<i class="fas fa-save mr-1" aria-hidden="true"></i>Save
</button>
</div>`);
overlay.appendChild(modal);
@@ -394,11 +428,12 @@
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
trapModal(fieldId, modal, `${fieldId}_modal_title`);
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
const body = document.getElementById(`${fieldId}_edit_body`);
if (!data || data.status !== 'success' || !body) {
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
if (body) safeSetHTML(body, `<div class="pfm-empty pfm-error-text">Couldn't load ${escHtml(filename)}. Close this window and try again.</div>`);
return;
}
@@ -413,10 +448,10 @@
// Textarea path: _editData stays null; save() reads from the <textarea>
st._editData = null;
safeSetHTML(body, `
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
<textarea id="${escHtml(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
aria-label="File contents (JSON)" aria-describedby="${escHtml(fieldId)}_json_err"
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
<div id="${escHtml(fieldId)}_json_err" class="pfm-json-err" role="alert"></div>`);
}
};
@@ -461,7 +496,7 @@
const totalPages = Math.ceil(total / perPage);
safeSetHTML(container, `
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
<div class="pfm-table-info">
${total} entries total
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
@@ -579,9 +614,9 @@
modal.style.maxWidth = '28rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
<i class="fas fa-times"></i>
<span class="pfm-modal-title" id="${escHtml(fieldId)}_del_title"><i class="fas fa-trash mr-2" aria-hidden="true"></i>Delete File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body">
@@ -591,15 +626,18 @@
</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1"></i>Delete
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1" aria-hidden="true"></i>Delete
</button>
</div>`);
overlay.appendChild(modal);
const cancelBtn = modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`);
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
cancelBtn.addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
// Destructive dialog: start on Cancel.
trapModal(fieldId, modal, `${fieldId}_del_title`, cancelBtn);
};
window._pfmConfirmDelete = async function (fieldId, filename) {
@@ -611,7 +649,7 @@
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
notify('Delete failed: ' + (result.message || ''), 'error');
notify("Couldn't delete the file: " + (result.message || 'unknown error') + '. Try again.', 'error');
}
};
@@ -626,13 +664,13 @@
modal.style.maxWidth = '32rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
<i class="fas fa-times"></i>
<span class="pfm-modal-title" id="${escHtml(fieldId)}_cre_title"><i class="fas fa-plus-circle mr-2" aria-hidden="true"></i>Create New File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body">
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" role="alert" style="margin-bottom:.5rem;"></div>
${fields.map(f => `
<div class="pfm-field">
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
@@ -643,16 +681,16 @@
</div>`).join('')}
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
<i class="fas fa-plus mr-1"></i>Create
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
<i class="fas fa-plus mr-1" aria-hidden="true"></i>Create
</button>
</div>
</div>`);
overlay.appendChild(modal);
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
trapModal(fieldId, modal, `${fieldId}_cre_title`, modal.querySelector('.pfm-field input'));
};
window._pfmConfirmCreate = async function (fieldId) {
@@ -739,11 +777,28 @@
return overlay;
}
// Dialog semantics + focus trap for an open modal (utils/dialog.js).
// Every close path goes through _pfmCloseModal, which releases it.
function trapModal(fieldId, modal, titleId, initialFocus) {
if (!window.LEDDialog) return;
const st = getState(fieldId);
st._releaseDialog = window.LEDDialog.trap(modal, {
labelledBy: titleId,
initialFocus: initialFocus || null,
onEscape: () => window._pfmCloseModal(fieldId)
});
}
window._pfmCloseModal = function (fieldId) {
const st = getState(fieldId);
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
st._editData = null;
st._editFilename = null;
if (st._releaseDialog) {
const release = st._releaseDialog;
st._releaseDialog = null;
release();
}
};
// ─── Widget registration ──────────────────────────────────────────────────
@@ -793,7 +848,7 @@
<input type="file" id="${fieldId}_file_input" accept=".json"
style="display:none"
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
<i class="fas fa-cloud-upload-alt pfm-upload-icon" aria-hidden="true"></i>
<p>Drag and drop or click to upload</p>
<small>${escHtml(st.uploadHint)}</small>
</div>` : ''}
@@ -49,6 +49,7 @@
saturday: 'Saturday',
sunday: 'Sunday'
};
const DAY_LABEL_MAP = new Map(Object.entries(DAY_LABELS));
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
@@ -283,17 +284,19 @@
// Render each day row
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day);
const disabled = !dayConfig.enabled;
const disabledClass = disabled ? 'bg-gray-100' : '';
html += `
<tr class="hover:bg-gray-50" id="${fieldId}_row_${day}">
<td class="px-3 py-2 whitespace-nowrap">
<span class="text-sm font-medium text-gray-900">${escapeHtml(DAY_LABELS[day])}</span>
<span class="text-sm font-medium text-gray-900">${dayLabel}</span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
<input type="checkbox"
id="${fieldId}_${day}_enabled"
aria-label="${dayLabel} enabled"
${dayConfig.enabled ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayEnabledChange('${fieldId}', '${day}', this.checked)"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
@@ -301,6 +304,7 @@
<td class="px-3 py-2 whitespace-nowrap">
<input type="time"
id="${fieldId}_${day}_start"
aria-label="${dayLabel} start time"
value="${escapeHtml(dayConfig.start_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
@@ -309,6 +313,7 @@
<td class="px-3 py-2 whitespace-nowrap">
<input type="time"
id="${fieldId}_${day}_end"
aria-label="${dayLabel} end time"
value="${escapeHtml(dayConfig.end_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
@@ -121,8 +121,8 @@
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('text-input').onClear('${fieldId}')"
class="inline-flex items-center px-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
title="Clear" aria-label="Clear">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
`;
}
@@ -90,7 +90,7 @@
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-clock text-gray-400"></i>
<i class="fas fa-clock text-gray-400" aria-hidden="true"></i>
</div>
</div>
`;
@@ -101,8 +101,8 @@
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
title="Clear" aria-label="Clear time">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
`;
}