Revamp UI chrome and add notification sound alerts

- Add desktop-style title bar shell and viewport locking for bank/org UIs
- Redesign notification HUD visuals and behavior (timers, persistence, exposed JS API)
- Register and play a new notification sound via `CfgSounds` on client events
This commit is contained in:
Jacob Schmidt 2026-03-09 20:59:02 -05:00
parent 57267b79b6
commit 6eb6ac79d1
13 changed files with 782 additions and 304 deletions

View File

@ -4,6 +4,9 @@
--bg-surface-hover: #f1f5f9; --bg-surface-hover: #f1f5f9;
--primary: #475569; --primary: #475569;
--primary-hover: #1e293b; --primary-hover: #1e293b;
--window-blue: #12325b;
--window-blue-border: #214978;
--window-blue-highlight: #d7e5f8;
--text-main: #1f2937; --text-main: #1f2937;
--text-muted: #64748b; --text-muted: #64748b;
--text-inverse: #f8fafc; --text-inverse: #f8fafc;
@ -13,6 +16,11 @@
--footer-bg: #1e293b; --footer-bg: #1e293b;
} }
html,
body {
height: 100%;
}
body { body {
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
margin: 0; margin: 0;
@ -20,16 +28,107 @@ body {
background: var(--bg-app); background: var(--bg-app);
color: var(--text-main); color: var(--text-main);
line-height: 1.6; line-height: 1.6;
overflow: hidden;
} }
#app { #app {
min-height: 100vh; height: 100vh;
overflow: hidden;
}
.app-shell {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
} }
main { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; flex: 1 1 auto;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.window-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1.25rem;
background: linear-gradient(180deg, var(--window-blue) 0%, #0d2643 100%);
border-bottom: 1px solid var(--window-blue-border);
color: var(--text-inverse);
box-shadow: 0 10px 24px rgb(18 50 91 / 0.24);
position: sticky;
top: 0;
z-index: 30;
flex-shrink: 0;
}
.window-titlebar-brand {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.window-titlebar-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgb(215 229 248 / 0.78);
}
.window-titlebar-title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-inverse);
}
.window-titlebar-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.window-control-btn {
min-width: 2.5rem;
padding: 0.45rem 0.7rem;
border-radius: 6px;
border: 1px solid rgb(215 229 248 / 0.22);
background: rgb(255 255 255 / 0.08);
color: var(--window-blue-highlight);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.window-control-btn:hover {
background: rgb(255 255 255 / 0.08);
box-shadow: none;
transform: none;
}
.window-control-btn:disabled {
opacity: 0.55;
}
.window-control-btn.is-close {
cursor: pointer;
opacity: 1;
border-color: rgb(255 255 255 / 0.24);
}
.window-control-btn.is-close:hover {
background: rgb(255 255 255 / 0.18);
} }
.container { .container {
@ -102,22 +201,6 @@ main {
font-family: 'Consolas', 'Monaco', monospace; font-family: 'Consolas', 'Monaco', monospace;
} }
.btn-signout {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
padding: 0.5rem 1rem;
font-size: 0.85rem;
&:hover {
background: var(--bg-surface-hover);
color: var(--primary-hover);
border-color: var(--primary);
transform: none;
box-shadow: none;
}
}
.content { .content {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -343,3 +426,15 @@ form {
} }
} }
} }
@media (max-width: 720px) {
.window-titlebar {
flex-direction: column;
align-items: flex-start;
}
.window-titlebar-controls {
width: 100%;
justify-content: flex-end;
}
}

View File

@ -70,16 +70,44 @@ function Navbar() {
h('div', { className: 'profile-info' }, h('div', { className: 'profile-info' },
h('span', { className: 'profile-label' }, 'Account'), h('span', { className: 'profile-label' }, 'Account'),
h('span', { className: 'profile-id' }, uid) h('span', { className: 'profile-id' }, uid)
), )
h('button', {
className: 'btn-signout',
onClick: () => sendEvent('bank::close', {})
}, 'Sign Out')
) )
) )
); );
} }
function WindowTitleBar() {
return h('div', { className: 'window-titlebar' },
h('div', { className: 'window-titlebar-brand' },
h('span', { className: 'window-titlebar-kicker' }, 'FDIC Workspace'),
h('span', { className: 'window-titlebar-title' }, 'Global Financial Network')
),
h('div', { className: 'window-titlebar-controls' },
h('button', {
type: 'button',
className: 'window-control-btn',
disabled: true,
title: 'Minimize unavailable',
'aria-label': 'Minimize unavailable'
}, '-'),
h('button', {
type: 'button',
className: 'window-control-btn',
disabled: true,
title: 'Maximize unavailable',
'aria-label': 'Maximize unavailable'
}, '[ ]'),
h('button', {
type: 'button',
className: 'window-control-btn is-close',
onClick: () => sendEvent('bank::close', {}),
title: 'Close',
'aria-label': 'Close banking interface'
}, 'X')
)
);
}
function TransactionHistory() { function TransactionHistory() {
const state = store.getState(); const state = store.getState();
const transactions = state.transactions || []; const transactions = state.transactions || [];
@ -295,12 +323,15 @@ function Footer() {
} }
function App() { function App() {
return h('main', null, return h('div', { className: 'app-shell' },
Navbar(), WindowTitleBar(),
h('div', { className: 'container' }, h('main', null,
BankDashboard() Navbar(),
), h('div', { className: 'container' },
Footer() BankDashboard()
),
Footer()
)
); );
} }

View File

@ -0,0 +1,9 @@
class CfgSounds {
sounds[] += {QGVAR(notify)};
class GVAR(notify) {
name = QGVAR(notify);
sound[] = {QPATHTOF2(sounds\notify.ogg), 1, 1};
titles[] = {};
};
};

View File

@ -11,5 +11,6 @@
[QGVAR(recieveNotification), { [QGVAR(recieveNotification), {
params [["_type", "", [""]], ["_title", "", [""]], ["_content", "", [""]], ["_duration", 4000, [4000]]]; params [["_type", "", [""]], ["_title", "", [""]], ["_content", "", [""]], ["_duration", 4000, [4000]]];
playSound QGVAR(notify);
GVAR(NotificationClass) call ["create", [_type, _title, _content, _duration]]; GVAR(NotificationClass) call ["create", [_type, _title, _content, _duration]];
}] call CFUNC(addEventHandler); }] call CFUNC(addEventHandler);

View File

@ -16,6 +16,7 @@ class CfgPatches {
}; };
}; };
#include "CfgSounds.hpp"
#include "CfgEventHandlers.hpp" #include "CfgEventHandlers.hpp"
#include "ui\RscCommon.hpp" #include "ui\RscCommon.hpp"
#include "ui\RscNotifications.hpp" #include "ui\RscNotifications.hpp"

Binary file not shown.

View File

@ -1,35 +1,44 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forge - Notification System</title>
<!-- <link rel="stylesheet" href="styles.css"> -->
<!--
Dynamic Resource Loading
The following script loads CSS and JavaScript files dynamically using the A3API
This approach is used instead of static HTML imports to work with Arma 3's file system
-->
<script>
Promise.all([
A3API.RequestFile(
"forge\\forge_client\\addons\\notifications\\ui\\_site\\styles.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\notifications\\ui\\_site\\script.js",
),
]).then(([css, js]) => {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
<head> const script = document.createElement("script");
<meta charset="UTF-8"> script.text = js;
<meta name="viewport" content="width=device-width, initial-scale=1.0"> document.head.appendChild(script);
<title>Forge - Notification System</title> });
<!-- <link rel="stylesheet" href="styles.css"> --> </script>
<!-- </head>
Dynamic Resource Loading
The following script loads CSS and JavaScript files dynamically using the A3API
This approach is used instead of static HTML imports to work with Arma 3's file system
-->
<script>
Promise.all([
A3API.RequestFile("forge\\forge_client\\addons\\notifications\\ui\\_site\\styles.css"),
A3API.RequestFile("forge\\forge_client\\addons\\notifications\\ui\\_site\\script.js")
]).then(([css, js]) => {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
const script = document.createElement('script');
script.text = js;
document.head.appendChild(script);
});
</script>
</head>
<body>
<div id="notification-container" class="notification-container" role="region" aria-label="Notifications"></div>
<!-- <script src="script.js"></script> -->
</body>
<body>
<div class="notifications-hud" aria-live="polite" aria-atomic="false">
<div
id="notification-container"
class="notification-container"
role="region"
aria-label="Notifications"
></div>
</div>
<!-- <script src="script.js"></script> -->
</body>
</html> </html>

View File

@ -3,10 +3,10 @@
//============================================================================= //=============================================================================
const NotificationActionTypes = { const NotificationActionTypes = {
ADD_NOTIFICATION: 'ADD_NOTIFICATION', ADD_NOTIFICATION: "ADD_NOTIFICATION",
REMOVE_NOTIFICATION: 'REMOVE_NOTIFICATION', REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION",
CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS', CLEAR_NOTIFICATIONS: "CLEAR_NOTIFICATIONS",
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION' UPDATE_NOTIFICATION: "UPDATE_NOTIFICATION",
}; };
const notificationActions = { const notificationActions = {
@ -15,25 +15,25 @@ const notificationActions = {
payload: { payload: {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
timestamp: Date.now(), timestamp: Date.now(),
type: 'info', type: "info",
title: 'Notification', title: "Notification",
message: 'Default message', message: "Default message",
duration: 0, duration: 0,
status: 'showing', status: "showing",
...notification ...notification,
} },
}), }),
removeNotification: (id) => ({ removeNotification: (id) => ({
type: NotificationActionTypes.REMOVE_NOTIFICATION, type: NotificationActionTypes.REMOVE_NOTIFICATION,
payload: { id } payload: { id },
}), }),
clearNotifications: () => ({ clearNotifications: () => ({
type: NotificationActionTypes.CLEAR_NOTIFICATIONS type: NotificationActionTypes.CLEAR_NOTIFICATIONS,
}), }),
updateNotification: (id, updates) => ({ updateNotification: (id, updates) => ({
type: NotificationActionTypes.UPDATE_NOTIFICATION, type: NotificationActionTypes.UPDATE_NOTIFICATION,
payload: { id, updates } payload: { id, updates },
}) }),
}; };
//============================================================================= //=============================================================================
@ -42,7 +42,7 @@ const notificationActions = {
const notificationInitialState = { const notificationInitialState = {
notifications: [], notifications: [],
maxNotifications: 5 maxNotifications: 3,
}; };
function notificationReducer(state = notificationInitialState, action = {}) { function notificationReducer(state = notificationInitialState, action = {}) {
@ -53,24 +53,36 @@ function notificationReducer(state = notificationInitialState, action = {}) {
if (newNotifications.length >= state.maxNotifications) { if (newNotifications.length >= state.maxNotifications) {
newNotifications = newNotifications.slice(1); newNotifications = newNotifications.slice(1);
} }
return { ...state, notifications: [...newNotifications, action.payload] }; return {
...state,
notifications: [...newNotifications, action.payload],
};
} }
case NotificationActionTypes.REMOVE_NOTIFICATION: { case NotificationActionTypes.REMOVE_NOTIFICATION: {
if (!action.payload || !action.payload.id) return state; if (!action.payload || !action.payload.id) return state;
return { return {
...state, ...state,
notifications: state.notifications.filter(n => n.id !== action.payload.id) notifications: state.notifications.filter(
(n) => n.id !== action.payload.id,
),
}; };
} }
case NotificationActionTypes.CLEAR_NOTIFICATIONS: case NotificationActionTypes.CLEAR_NOTIFICATIONS:
return { ...state, notifications: [] }; return { ...state, notifications: [] };
case NotificationActionTypes.UPDATE_NOTIFICATION: { case NotificationActionTypes.UPDATE_NOTIFICATION: {
if (!action.payload || !action.payload.id || !action.payload.updates) return state; if (
!action.payload ||
!action.payload.id ||
!action.payload.updates
)
return state;
return { return {
...state, ...state,
notifications: state.notifications.map(n => notifications: state.notifications.map((n) =>
n.id === action.payload.id ? { ...n, ...action.payload.updates } : n n.id === action.payload.id
) ? { ...n, ...action.payload.updates }
: n,
),
}; };
} }
default: default:
@ -95,19 +107,22 @@ class Store {
dispatch(action) { dispatch(action) {
this.state = this.reducer(this.state, action); this.state = this.reducer(this.state, action);
this.listeners.forEach(listener => listener(this.state)); this.listeners.forEach((listener) => listener(this.state));
return action; return action;
} }
subscribe(listener) { subscribe(listener) {
this.listeners.push(listener); this.listeners.push(listener);
return () => { return () => {
this.listeners = this.listeners.filter(l => l !== listener); this.listeners = this.listeners.filter((l) => l !== listener);
}; };
} }
} }
const notificationStore = new Store(notificationReducer, notificationInitialState); const notificationStore = new Store(
notificationReducer,
notificationInitialState,
);
//============================================================================= //=============================================================================
// #region SELECTORS // #region SELECTORS
@ -115,7 +130,7 @@ const notificationStore = new Store(notificationReducer, notificationInitialStat
const notificationSelectors = { const notificationSelectors = {
getNotifications: (state) => state.notifications, getNotifications: (state) => state.notifications,
getMaxNotifications: (state) => state.maxNotifications getMaxNotifications: (state) => state.maxNotifications,
}; };
//============================================================================= //=============================================================================
@ -126,13 +141,14 @@ class NotificationUI {
constructor(store) { constructor(store) {
this.store = store; this.store = store;
this.unsubscribe = null; this.unsubscribe = null;
this.container = document.getElementById('notification-container'); this.container = document.getElementById("notification-container");
this.renderedNotifications = new Map(); this.renderedNotifications = new Map();
this.dismissTimers = new Map();
} }
init() { init() {
if (!this.container) { if (!this.container) {
console.error('Notification container not found'); console.error("Notification container not found");
return; return;
} }
this.unsubscribe = this.store.subscribe((state) => this.render(state)); this.unsubscribe = this.store.subscribe((state) => this.render(state));
@ -141,7 +157,13 @@ class NotificationUI {
destroy() { destroy() {
if (this.unsubscribe) this.unsubscribe(); if (this.unsubscribe) this.unsubscribe();
this.renderedNotifications.forEach(el => { this.dismissTimers.forEach((timers) => {
clearTimeout(timers.hideTimer);
clearTimeout(timers.removeTimer);
clearTimeout(timers.progressTimer);
});
this.dismissTimers.clear();
this.renderedNotifications.forEach((el) => {
if (el.parentNode) el.parentNode.removeChild(el); if (el.parentNode) el.parentNode.removeChild(el);
}); });
this.renderedNotifications.clear(); this.renderedNotifications.clear();
@ -151,16 +173,17 @@ class NotificationUI {
const notifications = notificationSelectors.getNotifications(state); const notifications = notificationSelectors.getNotifications(state);
// Remove notifications no longer present // Remove notifications no longer present
const currentIds = new Set(notifications.map(n => n.id)); const currentIds = new Set(notifications.map((n) => n.id));
for (const [id, el] of this.renderedNotifications.entries()) { for (const [id, el] of this.renderedNotifications.entries()) {
if (!currentIds.has(id)) { if (!currentIds.has(id)) {
this.clearDismissTimers(id);
if (el.parentNode) el.parentNode.removeChild(el); if (el.parentNode) el.parentNode.removeChild(el);
this.renderedNotifications.delete(id); this.renderedNotifications.delete(id);
} }
} }
// Add or update notifications // Add or update notifications
notifications.forEach(notification => { notifications.forEach((notification) => {
if (!notification || !notification.id) return; if (!notification || !notification.id) return;
if (!this.renderedNotifications.has(notification.id)) { if (!this.renderedNotifications.has(notification.id)) {
this.createNotificationElement(notification); this.createNotificationElement(notification);
@ -170,45 +193,133 @@ class NotificationUI {
}); });
} }
clearDismissTimers(id) {
const timers = this.dismissTimers.get(id);
if (!timers) return;
clearTimeout(timers.hideTimer);
clearTimeout(timers.removeTimer);
clearTimeout(timers.progressTimer);
this.dismissTimers.delete(id);
}
escapeHTML(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
normalizeType(type) {
const supportedTypes = new Set([
"success",
"danger",
"warning",
"info",
]);
return supportedTypes.has(type) ? type : "info";
}
formatTypeLabel(type) {
const labels = {
success: "Success",
danger: "Critical",
warning: "Warning",
info: "Info",
};
return labels[this.normalizeType(type)] || labels.info;
}
getDurationLabel(duration) {
if (!(duration > 0)) return "Pinned";
const seconds = Math.max(1, Math.round(duration / 100) / 10);
return `${seconds.toFixed(1)}s`;
}
getTimestampLabel(timestamp) {
const date = new Date(timestamp || Date.now());
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
createNotificationElement(notification) { createNotificationElement(notification) {
const el = document.createElement('div'); const type = this.normalizeType(notification.type);
el.className = `notification ${notification.type || 'info'}`; const title = this.escapeHTML(notification.title || "Notification");
const message = this.escapeHTML(notification.message || "No message");
const isPersistent = !(notification.duration > 0);
const el = document.createElement("div");
el.className = `notification ${type}${isPersistent ? " is-persistent" : ""}`;
el.dataset.id = notification.id; el.dataset.id = notification.id;
el.innerHTML = ` el.innerHTML = `
<div class="notification-header"> <div class="notification-inner">
<div class="notification-title">${notification.title || 'Notification'}</div> <div class="notification-header">
<div class="notification-title">${title}</div>
<div class="notification-subtitle">Forge alert</div>
</div>
<div class="notification-body">
<div class="notification-meta">
<span class="notification-badge">${this.formatTypeLabel(type)}</span>
<span class="notification-time">${this.getTimestampLabel(notification.timestamp)}</span>
</div>
<div class="notification-message">${message}</div>
<div class="notification-footer">
<span>${isPersistent ? "Persistent signal" : "Auto-dismiss"}</span>
<span>${this.getDurationLabel(notification.duration)}</span>
</div>
</div>
</div> </div>
<div class="notification-message">${notification.message || 'No message'}</div> ${notification.duration > 0 ? '<div class="notification-progress"><div class="notification-progress-bar"></div></div>' : ""}
${notification.duration > 0 ? '<div class="notification-progress"><div class="notification-progress-bar"></div></div>' : ''}
`; `;
this.container.appendChild(el); this.container.appendChild(el);
this.renderedNotifications.set(notification.id, el); this.renderedNotifications.set(notification.id, el);
setTimeout(() => el.classList.add('show'), 10); requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add("show"));
});
// Set progress bar animation duration // Set progress bar animation duration
if (notification.duration > 0) { if (notification.duration > 0) {
const progressBar = el.querySelector('.notification-progress-bar'); const progressBar = el.querySelector(".notification-progress-bar");
if (progressBar) { if (progressBar) {
progressBar.style.transition = `width ${notification.duration}ms linear`; progressBar.style.transitionDuration = `${notification.duration}ms`;
progressBar.style.width = '100%'; const progressTimer = setTimeout(() => {
setTimeout(() => { progressBar.style.transform = "scaleX(0)";
progressBar.style.width = '0%'; }, 30);
}, 20); this.dismissTimers.set(notification.id, { progressTimer });
} }
setTimeout(() => { const hideTimer = setTimeout(() => {
notificationStore.dispatch(notificationActions.updateNotification(notification.id, { status: 'hiding' })); notificationStore.dispatch(
setTimeout(() => { notificationActions.updateNotification(notification.id, {
notificationStore.dispatch(notificationActions.removeNotification(notification.id)); status: "hiding",
}, 300); }),
);
}, notification.duration); }, notification.duration);
const removeTimer = setTimeout(() => {
this.clearDismissTimers(notification.id);
notificationStore.dispatch(
notificationActions.removeNotification(notification.id),
);
}, notification.duration + 260);
const existingTimers =
this.dismissTimers.get(notification.id) || {};
this.dismissTimers.set(notification.id, {
...existingTimers,
hideTimer,
removeTimer,
});
} }
} }
updateNotificationElement(notification) { updateNotificationElement(notification) {
const el = this.renderedNotifications.get(notification.id); const el = this.renderedNotifications.get(notification.id);
if (!el) return; if (!el) return;
if (notification.status === 'hiding') el.classList.add('hide'); if (notification.status === "hiding") {
el.classList.add("hide");
}
} }
} }
@ -220,7 +331,11 @@ let notificationUI = null;
let notificationUIInitialized = false; let notificationUIInitialized = false;
function notifyArmaNotificationReady() { function notifyArmaNotificationReady() {
if (window.parent && window.parent !== window && typeof window.parent.postMessage === "function") { if (
window.parent &&
window.parent !== window &&
typeof window.parent.postMessage === "function"
) {
window.parent.postMessage({ event: "notifications::ready" }, "*"); window.parent.postMessage({ event: "notifications::ready" }, "*");
} }
if (typeof A3API !== "undefined" && typeof A3API.SendAlert === "function") { if (typeof A3API !== "undefined" && typeof A3API.SendAlert === "function") {
@ -230,34 +345,44 @@ function notifyArmaNotificationReady() {
function initializeNotifications() { function initializeNotifications() {
if (notificationUIInitialized) { if (notificationUIInitialized) {
console.log('Notification system already initialized, skipping...'); console.log("Notification system already initialized, skipping...");
return; return;
} }
notificationUI = new NotificationUI(notificationStore); notificationUI = new NotificationUI(notificationStore);
notificationUI.init(); notificationUI.init();
notificationUIInitialized = true; notificationUIInitialized = true;
console.log('Notification system is ready!'); console.log("Notification system is ready!");
notifyArmaNotificationReady(); notifyArmaNotificationReady();
} }
// Expose global notification API // Expose global notification API
const showNotification = (type, title, message, duration) => { const showNotification = (type, title, message, duration) => {
return notificationStore.dispatch(notificationActions.addNotification({ type, title, message, duration })); return notificationStore.dispatch(
notificationActions.addNotification({ type, title, message, duration }),
);
}; };
const clearAllNotifications = () => { const clearAllNotifications = () => {
return notificationStore.dispatch(notificationActions.clearNotifications()); return notificationStore.dispatch(notificationActions.clearNotifications());
}; };
window.showNotification = showNotification;
window.clearAllNotifications = clearAllNotifications;
window.ForgeNotifications = {
show: showNotification,
clear: clearAllNotifications,
};
// Listen for global notification events (for Arma/SQF or other scripts) // Listen for global notification events (for Arma/SQF or other scripts)
window.addEventListener('forge:notify', function (e) { window.addEventListener("forge:notify", function (e) {
if (!e || !e.detail) return; if (!e || !e.detail) return;
const { type, title, message, duration } = e.detail; const { type, title, message, duration } = e.detail;
showNotification(type, title, message, duration); showNotification(type, title, message, duration);
}); });
// Auto-initialize if DOM is already loaded when script executes // Auto-initialize if DOM is already loaded when script executes
if (document.readyState !== 'loading') { if (document.readyState !== "loading") {
initializeNotifications(); initializeNotifications();
} else { } else {
document.addEventListener('DOMContentLoaded', initializeNotifications, { once: true }); document.addEventListener("DOMContentLoaded", initializeNotifications, {
} once: true,
});
}

View File

@ -1,26 +1,27 @@
:root { :root {
--bg-surface: #ffffff; --hud-top: 30px;
--bg-surface-hover: #f1f5f9; --hud-right: 20px;
--primary: #475569; --header-bg: linear-gradient(
--primary-hover: #1e293b; 180deg,
--text-main: #1f2937; rgba(13, 37, 69, 0.98) 0%,
--text-muted: #64748b; rgba(8, 24, 48, 0.98) 100%
--border: #e2e8f0; );
--radius: 8px; --body-bg: rgba(242, 238, 228, 0.97);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --panel-edge: rgba(121, 166, 212, 0.22);
--panel-shadow: 0 14px 24px rgba(0, 0, 0, 0.34);
--success: #10b981; --header-text: rgba(235, 243, 255, 0.98);
--success-bg: #ecfdf5; --header-muted: rgba(166, 189, 221, 0.8);
--success-border: #a7f3d0; --body-text: rgba(31, 45, 64, 0.96);
--danger: #ef4444; --body-muted: rgba(86, 102, 122, 0.82);
--danger-bg: #fef2f2; --body-faint: rgba(111, 126, 144, 0.76);
--danger-border: #fecaca; --success: #6de2b3;
--warning: #f59e0b; --success-soft: rgba(109, 226, 179, 0.12);
--warning-bg: #fffbeb; --danger: #ff7b7b;
--warning-border: #fde68a; --danger-soft: rgba(255, 123, 123, 0.12);
--info: #3b82f6; --warning: #ffd36b;
--info-bg: #eff6ff; --warning-soft: rgba(255, 211, 107, 0.12);
--info-border: #bfdbfe; --info: #78b9ff;
--info-soft: rgba(120, 185, 255, 0.12);
} }
* { * {
@ -29,182 +30,216 @@
box-sizing: border-box; box-sizing: border-box;
} }
html,
body { body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: transparent;
min-height: 100vh; min-height: 100vh;
color: var(--text-main); }
body {
background: transparent;
color: var(--body-text);
font-family: "Bahnschrift", "Segoe UI", Tahoma, sans-serif;
letter-spacing: 0.01em;
overflow: hidden;
pointer-events: none; pointer-events: none;
user-select: none;
}
.notifications-hud {
position: fixed;
top: var(--hud-top);
right: var(--hud-right);
width: 384px;
max-width: calc(100vw - 24px);
z-index: 1000;
} }
.notification-container { .notification-container {
position: fixed; display: grid;
top: 80px; gap: 8px;
right: 24px;
z-index: 1000;
width: 360px;
pointer-events: auto;
} }
.notification { .notification {
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1), 0 2px 4px rgb(0 0 0 / 0.05);
margin-bottom: 12px;
padding: 1rem 1.25rem;
width: 100%;
transform: translateX(calc(100% + 24px));
transition: all 0.2s ease;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: transparent;
border: 1px solid var(--panel-edge);
border-radius: 12px;
box-shadow: var(--panel-shadow);
opacity: 0;
transform: translateX(28px) scale(0.985);
transition:
opacity 0.18s ease,
transform 0.18s ease,
border-color 0.18s ease;
}
&.show { .notification::before {
transform: translateX(0); content: "";
} position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08),
transparent 24%
);
pointer-events: none;
}
&.hide { .notification.show {
transform: translateX(calc(100% + 24px)); opacity: 1;
opacity: 0; transform: translateX(0) scale(1);
} }
&.success { .notification.hide {
background: var(--success-bg); opacity: 0;
border-color: var(--success-border); transform: translateX(32px) scale(0.985);
border-left-color: var(--success); }
.notification-title { .notification-inner {
color: #065f46; position: relative;
} display: block;
padding: 0;
}
.notification-message { .notification-body {
color: #047857; display: flex;
} flex-direction: column;
gap: 7px;
min-width: 0;
padding: 10px 11px 11px;
background: var(--body-bg);
}
.notification-progress-bar { .notification-meta,
background: var(--success); .notification-footer {
} display: flex;
} align-items: center;
justify-content: space-between;
&.danger { gap: 8px;
background: var(--danger-bg);
border-color: var(--danger-border);
border-left-color: var(--danger);
.notification-title {
color: #991b1b;
}
.notification-message {
color: #b91c1c;
}
.notification-progress-bar {
background: var(--danger);
}
}
&.warning {
background: var(--warning-bg);
border-color: var(--warning-border);
border-left-color: var(--warning);
.notification-title {
color: #92400e;
}
.notification-message {
color: #b45309;
}
.notification-progress-bar {
background: var(--warning);
}
}
&.info {
background: var(--info-bg);
border-color: var(--info-border);
border-left-color: var(--info);
.notification-title {
color: #1e40af;
}
.notification-message {
color: #1d4ed8;
}
.notification-progress-bar {
background: var(--info);
}
}
} }
.notification-header { .notification-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 6px;
margin-bottom: 0.375rem; padding: 10px 11px;
background: var(--header-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.notification-meta {
color: var(--body-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.notification-badge {
padding: 3px 6px;
border-radius: 999px;
border: 1px solid currentColor;
background: rgba(34, 51, 74, 0.08);
}
.notification-time {
color: var(--body-faint);
} }
.notification-title { .notification-title {
font-weight: 600; color: var(--header-text);
font-size: 0.875rem; font-size: 14px;
font-weight: 700;
line-height: 1.1;
}
.notification-subtitle {
color: var(--header-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.025em;
flex: 1;
color: var(--primary-hover);
} }
.notification-message { .notification-message {
color: var(--text-muted); color: var(--body-text);
font-size: 0.875rem; font-size: 12px;
line-height: 1.5; line-height: 1.35;
word-wrap: break-word; word-break: break-word;
}
.notification-footer {
color: var(--body-faint);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
} }
.notification-progress { .notification-progress {
position: absolute; height: 4px;
bottom: 0; background: rgba(34, 51, 74, 0.16);
left: 0;
height: 3px;
background: var(--bg-surface-hover);
width: 100%;
border-radius: 0 0 var(--radius) var(--radius);
} }
.notification-progress-bar { .notification-progress-bar {
height: 100%; height: 100%;
width: 0%; width: 100%;
transform-origin: left; background: currentColor;
border-radius: 0 0 var(--radius) var(--radius); opacity: 0.95;
transition: width linear; transform: scaleX(1);
transform-origin: left center;
transition: transform linear;
} }
@media (max-width: 768px) { .notification.is-persistent .notification-progress {
.notification-container { display: none;
left: 24px;
right: 24px;
width: auto;
}
.notification {
transform: translateY(-100%);
&.show {
transform: translateY(0);
}
&.hide {
transform: translateY(-100%);
}
}
} }
@media (max-width: 500px) { .notification.success {
.notification-container { color: var(--success);
width: calc(100vw - 48px); border-color: rgba(109, 226, 179, 0.24);
}
.notification.success .notification-badge {
background-color: var(--success-soft);
}
.notification.danger {
color: var(--danger);
border-color: rgba(255, 123, 123, 0.24);
}
.notification.danger .notification-badge {
background-color: var(--danger-soft);
}
.notification.warning {
color: var(--warning);
border-color: rgba(255, 211, 107, 0.24);
}
.notification.warning .notification-badge {
background-color: var(--warning-soft);
}
.notification.info {
color: var(--info);
border-color: rgba(120, 185, 255, 0.24);
}
.notification.info .notification-badge {
background-color: var(--info-soft);
}
@media (max-width: 720px) {
:root {
--hud-top: 18px;
--hud-right: 12px;
}
.notifications-hud {
width: calc(100vw - 16px);
max-width: calc(100vw - 16px);
} }
} }

View File

@ -4,6 +4,9 @@
--bg-surface-hover: #f1f5f9; --bg-surface-hover: #f1f5f9;
--primary: #475569; --primary: #475569;
--primary-hover: #1e293b; --primary-hover: #1e293b;
--window-blue: #12325b;
--window-blue-border: #214978;
--window-blue-highlight: #d7e5f8;
--text-main: #1f2937; --text-main: #1f2937;
--text-muted: #64748b; --text-muted: #64748b;
--text-inverse: #f8fafc; --text-inverse: #f8fafc;
@ -13,6 +16,11 @@
--footer-bg: #1e293b; --footer-bg: #1e293b;
} }
html,
body {
height: 100%;
}
body { body {
font-family: font-family:
"Inter", "Inter",
@ -24,16 +32,107 @@ body {
background: var(--bg-app); background: var(--bg-app);
color: var(--text-main); color: var(--text-main);
line-height: 1.6; line-height: 1.6;
overflow: hidden;
} }
#app { #app {
min-height: 100vh; height: 100vh;
overflow: hidden;
}
.app-shell {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
} }
main { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; flex: 1 1 auto;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.window-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1.25rem;
background: linear-gradient(180deg, var(--window-blue) 0%, #0d2643 100%);
border-bottom: 1px solid var(--window-blue-border);
color: var(--text-inverse);
box-shadow: 0 10px 24px rgb(18 50 91 / 0.24);
position: sticky;
top: 0;
z-index: 30;
flex-shrink: 0;
}
.window-titlebar-brand {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.window-titlebar-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgb(215 229 248 / 0.78);
}
.window-titlebar-title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-inverse);
}
.window-titlebar-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.window-control-btn {
min-width: 2.5rem;
padding: 0.45rem 0.7rem;
border-radius: 6px;
border: 1px solid rgb(215 229 248 / 0.22);
background: rgb(255 255 255 / 0.08);
color: var(--window-blue-highlight);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.window-control-btn:hover {
background: rgb(255 255 255 / 0.08);
box-shadow: none;
transform: none;
}
.window-control-btn:disabled {
opacity: 0.55;
}
.window-control-btn.is-close {
cursor: pointer;
opacity: 1;
border-color: rgb(255 255 255 / 0.24);
}
.window-control-btn.is-close:hover {
background: rgb(255 255 255 / 0.18);
} }
.container { .container {
@ -156,6 +255,16 @@ button {
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.window-titlebar {
flex-direction: column;
align-items: flex-start;
}
.window-titlebar-controls {
width: 100%;
justify-content: flex-end;
}
.container { .container {
padding: 1.5rem; padding: 1.5rem;
} }

View File

@ -5,6 +5,60 @@
RegistryApp.components = RegistryApp.components || {}; RegistryApp.components = RegistryApp.components || {};
function WindowTitleBar({ title, onClose }) {
return h(
"div",
{ className: "window-titlebar" },
h(
"div",
{ className: "window-titlebar-brand" },
h(
"span",
{ className: "window-titlebar-kicker" },
"ORBIS Workspace",
),
h("span", { className: "window-titlebar-title" }, title),
),
h(
"div",
{ className: "window-titlebar-controls" },
h(
"button",
{
type: "button",
className: "window-control-btn",
disabled: true,
title: "Minimize unavailable",
"aria-label": "Minimize unavailable",
},
"-",
),
h(
"button",
{
type: "button",
className: "window-control-btn",
disabled: true,
title: "Maximize unavailable",
"aria-label": "Maximize unavailable",
},
"[ ]",
),
h(
"button",
{
type: "button",
className: "window-control-btn is-close",
onClick: onClose,
title: "Close",
"aria-label": "Close organization interface",
},
"X",
),
),
);
}
RegistryApp.components.App = function App() { RegistryApp.components.App = function App() {
const Navbar = window.SharedUI.componentFns.Navbar; const Navbar = window.SharedUI.componentFns.Navbar;
const Header = window.SharedUI.componentFns.Header; const Header = window.SharedUI.componentFns.Header;
@ -23,7 +77,6 @@
: view === "portal" : view === "portal"
? "Organization Portal" ? "Organization Portal"
: "Entry Hub"; : "Entry Hub";
const actionLabel = view === "portal" ? "Sign Out" : "Close";
const footerSections = [ const footerSections = [
{ {
title: "Registry Resources", title: "Registry Resources",
@ -65,12 +118,14 @@
if (view === "portal" && PortalApp) { if (view === "portal" && PortalApp) {
return h( return h(
"div", "div",
null, { className: "app-shell" },
WindowTitleBar({
title: "Global Organization Network",
onClose: closeRegistry,
}),
Navbar({ Navbar({
title: "Global Organization Network", title: "Global Organization Network",
viewLabel, viewLabel,
actionLabel,
onAction: closeRegistry,
}), }),
PortalApp(), PortalApp(),
); );
@ -84,24 +139,30 @@
} }
return h( return h(
"main", "div",
null, { className: "app-shell" },
Navbar({ WindowTitleBar({
title: "Global Organization Network", title: "Global Organization Network",
viewLabel, onClose: closeRegistry,
actionLabel,
onAction: closeRegistry,
}), }),
h( h(
"div", "main",
{ className: "container" }, null,
Header({ Navbar({
title: "Global Organization Network", title: "Global Organization Network",
onTitleClick: () => store.setView("home"), viewLabel,
}), }),
mainContent, h(
"div",
{ className: "container" },
Header({
title: "Global Organization Network",
onTitleClick: () => store.setView("home"),
}),
mainContent,
),
Footer({ sections: footerSections }),
), ),
Footer({ sections: footerSections }),
); );
}; };
})(); })();

View File

@ -113,15 +113,17 @@ ${scopeSelector} .app-close-btn:hover {
"div", "div",
{ className: "app-navbar-actions" }, { className: "app-navbar-actions" },
h("span", { className: "app-navbar-view" }, viewLabel), h("span", { className: "app-navbar-view" }, viewLabel),
h( actionLabel && typeof onAction === "function"
"button", ? h(
{ "button",
type: "button", {
className: "app-close-btn", type: "button",
onClick: onAction, className: "app-close-btn",
}, onClick: onAction,
actionLabel, },
), actionLabel,
)
: null,
), ),
), ),
); );

BIN
arma/client/extra/notify.wav vendored Normal file

Binary file not shown.