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,13 +70,41 @@ 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')
)
); );
} }
@ -295,12 +323,15 @@ function Footer() {
} }
function App() { function App() {
return h('main', null, return h('div', { className: 'app-shell' },
WindowTitleBar(),
h('main', null,
Navbar(), Navbar(),
h('div', { className: 'container' }, h('div', { className: 'container' },
BankDashboard() BankDashboard()
), ),
Footer() 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,9 +1,8 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forge - Notification System</title> <title>Forge - Notification System</title>
<!-- <link rel="stylesheet" href="styles.css"> --> <!-- <link rel="stylesheet" href="styles.css"> -->
<!-- <!--
@ -13,23 +12,33 @@
--> -->
<script> <script>
Promise.all([ Promise.all([
A3API.RequestFile("forge\\forge_client\\addons\\notifications\\ui\\_site\\styles.css"), A3API.RequestFile(
A3API.RequestFile("forge\\forge_client\\addons\\notifications\\ui\\_site\\script.js") "forge\\forge_client\\addons\\notifications\\ui\\_site\\styles.css",
),
A3API.RequestFile(
"forge\\forge_client\\addons\\notifications\\ui\\_site\\script.js",
),
]).then(([css, js]) => { ]).then(([css, js]) => {
const style = document.createElement('style'); const style = document.createElement("style");
style.textContent = css; style.textContent = css;
document.head.appendChild(style); document.head.appendChild(style);
const script = document.createElement('script'); const script = document.createElement("script");
script.text = js; script.text = js;
document.head.appendChild(script); document.head.appendChild(script);
}); });
</script> </script>
</head> </head>
<body> <body>
<div id="notification-container" class="notification-container" role="region" aria-label="Notifications"></div> <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> --> <!-- <script src="script.js"></script> -->
</body> </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-inner">
<div class="notification-header"> <div class="notification-header">
<div class="notification-title">${notification.title || 'Notification'}</div> <div class="notification-title">${title}</div>
<div class="notification-subtitle">Forge alert</div>
</div> </div>
<div class="notification-message">${notification.message || 'No message'}</div> <div class="notification-body">
${notification.duration > 0 ? '<div class="notification-progress"><div class="notification-progress-bar"></div></div>' : ''} <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>
${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;
&.show { border: 1px solid var(--panel-edge);
transform: translateX(0); border-radius: 12px;
} box-shadow: var(--panel-shadow);
&.hide {
transform: translateX(calc(100% + 24px));
opacity: 0; opacity: 0;
} transform: translateX(28px) scale(0.985);
transition:
opacity 0.18s ease,
transform 0.18s ease,
border-color 0.18s ease;
}
&.success { .notification::before {
background: var(--success-bg); content: "";
border-color: var(--success-border); position: absolute;
border-left-color: var(--success); inset: 0;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08),
transparent 24%
);
pointer-events: none;
}
.notification-title { .notification.show {
color: #065f46; opacity: 1;
} transform: translateX(0) scale(1);
}
.notification-message { .notification.hide {
color: #047857; opacity: 0;
} transform: translateX(32px) scale(0.985);
}
.notification-progress-bar { .notification-inner {
background: var(--success); position: relative;
} display: block;
} padding: 0;
}
&.danger { .notification-body {
background: var(--danger-bg); display: flex;
border-color: var(--danger-border); flex-direction: column;
border-left-color: var(--danger); gap: 7px;
min-width: 0;
padding: 10px 11px 11px;
background: var(--body-bg);
}
.notification-title { .notification-meta,
color: #991b1b; .notification-footer {
} display: flex;
align-items: center;
.notification-message { justify-content: space-between;
color: #b91c1c; gap: 8px;
}
.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,13 +139,18 @@
} }
return h( return h(
"div",
{ className: "app-shell" },
WindowTitleBar({
title: "Global Organization Network",
onClose: closeRegistry,
}),
h(
"main", "main",
null, null,
Navbar({ Navbar({
title: "Global Organization Network", title: "Global Organization Network",
viewLabel, viewLabel,
actionLabel,
onAction: closeRegistry,
}), }),
h( h(
"div", "div",
@ -102,6 +162,7 @@
mainContent, mainContent,
), ),
Footer({ sections: footerSections }), Footer({ sections: footerSections }),
),
); );
}; };
})(); })();

View File

@ -113,7 +113,8 @@ ${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"
? h(
"button", "button",
{ {
type: "button", type: "button",
@ -121,7 +122,8 @@ ${scopeSelector} .app-close-btn:hover {
onClick: onAction, onClick: onAction,
}, },
actionLabel, actionLabel,
), )
: null,
), ),
), ),
); );

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

Binary file not shown.