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;
--primary: #475569;
--primary-hover: #1e293b;
--window-blue: #12325b;
--window-blue-border: #214978;
--window-blue-highlight: #d7e5f8;
--text-main: #1f2937;
--text-muted: #64748b;
--text-inverse: #f8fafc;
@ -13,6 +16,11 @@
--footer-bg: #1e293b;
}
html,
body {
height: 100%;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
margin: 0;
@ -20,16 +28,107 @@ body {
background: var(--bg-app);
color: var(--text-main);
line-height: 1.6;
overflow: hidden;
}
#app {
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
.app-shell {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
main {
display: flex;
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 {
@ -102,22 +201,6 @@ main {
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 {
display: grid;
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('span', { className: 'profile-label' }, 'Account'),
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() {
const state = store.getState();
const transactions = state.transactions || [];
@ -295,12 +323,15 @@ function Footer() {
}
function App() {
return h('main', null,
Navbar(),
h('div', { className: 'container' },
BankDashboard()
),
Footer()
return h('div', { className: 'app-shell' },
WindowTitleBar(),
h('main', null,
Navbar(),
h('div', { className: 'container' },
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), {
params [["_type", "", [""]], ["_title", "", [""]], ["_content", "", [""]], ["_duration", 4000, [4000]]];
playSound QGVAR(notify);
GVAR(NotificationClass) call ["create", [_type, _title, _content, _duration]];
}] call CFUNC(addEventHandler);

View File

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

Binary file not shown.

View File

@ -1,35 +1,44 @@
<!DOCTYPE html>
<!doctype html>
<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>
<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);
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>
const script = document.createElement("script");
script.text = js;
document.head.appendChild(script);
});
</script>
</head>
<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>

View File

@ -3,10 +3,10 @@
//=============================================================================
const NotificationActionTypes = {
ADD_NOTIFICATION: 'ADD_NOTIFICATION',
REMOVE_NOTIFICATION: 'REMOVE_NOTIFICATION',
CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS',
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION'
ADD_NOTIFICATION: "ADD_NOTIFICATION",
REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION",
CLEAR_NOTIFICATIONS: "CLEAR_NOTIFICATIONS",
UPDATE_NOTIFICATION: "UPDATE_NOTIFICATION",
};
const notificationActions = {
@ -15,25 +15,25 @@ const notificationActions = {
payload: {
id: Date.now() + Math.random(),
timestamp: Date.now(),
type: 'info',
title: 'Notification',
message: 'Default message',
type: "info",
title: "Notification",
message: "Default message",
duration: 0,
status: 'showing',
...notification
}
status: "showing",
...notification,
},
}),
removeNotification: (id) => ({
type: NotificationActionTypes.REMOVE_NOTIFICATION,
payload: { id }
payload: { id },
}),
clearNotifications: () => ({
type: NotificationActionTypes.CLEAR_NOTIFICATIONS
type: NotificationActionTypes.CLEAR_NOTIFICATIONS,
}),
updateNotification: (id, updates) => ({
type: NotificationActionTypes.UPDATE_NOTIFICATION,
payload: { id, updates }
})
payload: { id, updates },
}),
};
//=============================================================================
@ -42,7 +42,7 @@ const notificationActions = {
const notificationInitialState = {
notifications: [],
maxNotifications: 5
maxNotifications: 3,
};
function notificationReducer(state = notificationInitialState, action = {}) {
@ -53,24 +53,36 @@ function notificationReducer(state = notificationInitialState, action = {}) {
if (newNotifications.length >= state.maxNotifications) {
newNotifications = newNotifications.slice(1);
}
return { ...state, notifications: [...newNotifications, action.payload] };
return {
...state,
notifications: [...newNotifications, action.payload],
};
}
case NotificationActionTypes.REMOVE_NOTIFICATION: {
if (!action.payload || !action.payload.id) return state;
return {
...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:
return { ...state, notifications: [] };
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 {
...state,
notifications: state.notifications.map(n =>
n.id === action.payload.id ? { ...n, ...action.payload.updates } : n
)
notifications: state.notifications.map((n) =>
n.id === action.payload.id
? { ...n, ...action.payload.updates }
: n,
),
};
}
default:
@ -95,19 +107,22 @@ class Store {
dispatch(action) {
this.state = this.reducer(this.state, action);
this.listeners.forEach(listener => listener(this.state));
this.listeners.forEach((listener) => listener(this.state));
return action;
}
subscribe(listener) {
this.listeners.push(listener);
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
@ -115,7 +130,7 @@ const notificationStore = new Store(notificationReducer, notificationInitialStat
const notificationSelectors = {
getNotifications: (state) => state.notifications,
getMaxNotifications: (state) => state.maxNotifications
getMaxNotifications: (state) => state.maxNotifications,
};
//=============================================================================
@ -126,13 +141,14 @@ class NotificationUI {
constructor(store) {
this.store = store;
this.unsubscribe = null;
this.container = document.getElementById('notification-container');
this.container = document.getElementById("notification-container");
this.renderedNotifications = new Map();
this.dismissTimers = new Map();
}
init() {
if (!this.container) {
console.error('Notification container not found');
console.error("Notification container not found");
return;
}
this.unsubscribe = this.store.subscribe((state) => this.render(state));
@ -141,7 +157,13 @@ class NotificationUI {
destroy() {
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);
});
this.renderedNotifications.clear();
@ -151,16 +173,17 @@ class NotificationUI {
const notifications = notificationSelectors.getNotifications(state);
// 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()) {
if (!currentIds.has(id)) {
this.clearDismissTimers(id);
if (el.parentNode) el.parentNode.removeChild(el);
this.renderedNotifications.delete(id);
}
}
// Add or update notifications
notifications.forEach(notification => {
notifications.forEach((notification) => {
if (!notification || !notification.id) return;
if (!this.renderedNotifications.has(notification.id)) {
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) {
const el = document.createElement('div');
el.className = `notification ${notification.type || 'info'}`;
const type = this.normalizeType(notification.type);
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.innerHTML = `
<div class="notification-header">
<div class="notification-title">${notification.title || 'Notification'}</div>
<div class="notification-inner">
<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 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.renderedNotifications.set(notification.id, el);
setTimeout(() => el.classList.add('show'), 10);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add("show"));
});
// Set progress bar animation duration
if (notification.duration > 0) {
const progressBar = el.querySelector('.notification-progress-bar');
const progressBar = el.querySelector(".notification-progress-bar");
if (progressBar) {
progressBar.style.transition = `width ${notification.duration}ms linear`;
progressBar.style.width = '100%';
setTimeout(() => {
progressBar.style.width = '0%';
}, 20);
progressBar.style.transitionDuration = `${notification.duration}ms`;
const progressTimer = setTimeout(() => {
progressBar.style.transform = "scaleX(0)";
}, 30);
this.dismissTimers.set(notification.id, { progressTimer });
}
setTimeout(() => {
notificationStore.dispatch(notificationActions.updateNotification(notification.id, { status: 'hiding' }));
setTimeout(() => {
notificationStore.dispatch(notificationActions.removeNotification(notification.id));
}, 300);
const hideTimer = setTimeout(() => {
notificationStore.dispatch(
notificationActions.updateNotification(notification.id, {
status: "hiding",
}),
);
}, 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) {
const el = this.renderedNotifications.get(notification.id);
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;
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" }, "*");
}
if (typeof A3API !== "undefined" && typeof A3API.SendAlert === "function") {
@ -230,34 +345,44 @@ function notifyArmaNotificationReady() {
function initializeNotifications() {
if (notificationUIInitialized) {
console.log('Notification system already initialized, skipping...');
console.log("Notification system already initialized, skipping...");
return;
}
notificationUI = new NotificationUI(notificationStore);
notificationUI.init();
notificationUIInitialized = true;
console.log('Notification system is ready!');
console.log("Notification system is ready!");
notifyArmaNotificationReady();
}
// Expose global notification API
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 = () => {
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)
window.addEventListener('forge:notify', function (e) {
window.addEventListener("forge:notify", function (e) {
if (!e || !e.detail) return;
const { type, title, message, duration } = e.detail;
showNotification(type, title, message, duration);
});
// Auto-initialize if DOM is already loaded when script executes
if (document.readyState !== 'loading') {
if (document.readyState !== "loading") {
initializeNotifications();
} else {
document.addEventListener('DOMContentLoaded', initializeNotifications, { once: true });
}
document.addEventListener("DOMContentLoaded", initializeNotifications, {
once: true,
});
}

View File

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

View File

@ -5,6 +5,60 @@
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() {
const Navbar = window.SharedUI.componentFns.Navbar;
const Header = window.SharedUI.componentFns.Header;
@ -23,7 +77,6 @@
: view === "portal"
? "Organization Portal"
: "Entry Hub";
const actionLabel = view === "portal" ? "Sign Out" : "Close";
const footerSections = [
{
title: "Registry Resources",
@ -65,12 +118,14 @@
if (view === "portal" && PortalApp) {
return h(
"div",
null,
{ className: "app-shell" },
WindowTitleBar({
title: "Global Organization Network",
onClose: closeRegistry,
}),
Navbar({
title: "Global Organization Network",
viewLabel,
actionLabel,
onAction: closeRegistry,
}),
PortalApp(),
);
@ -84,24 +139,30 @@
}
return h(
"main",
null,
Navbar({
"div",
{ className: "app-shell" },
WindowTitleBar({
title: "Global Organization Network",
viewLabel,
actionLabel,
onAction: closeRegistry,
onClose: closeRegistry,
}),
h(
"div",
{ className: "container" },
Header({
"main",
null,
Navbar({
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",
{ className: "app-navbar-actions" },
h("span", { className: "app-navbar-view" }, viewLabel),
h(
"button",
{
type: "button",
className: "app-close-btn",
onClick: onAction,
},
actionLabel,
),
actionLabel && typeof onAction === "function"
? h(
"button",
{
type: "button",
className: "app-close-btn",
onClick: onAction,
},
actionLabel,
)
: null,
),
),
);

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

Binary file not shown.