diff --git a/backend/server.js b/backend/server.js
index 56c00ff..6045767 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -768,6 +768,28 @@ app.post('/api/editor/settings/test', requireAuth, editorOnly, directorOnly, wra
res.json(result);
}));
+// Send a test Discord DM to the current user
+app.post('/api/notify/test', requireAuth, wrap(async (req, res) => {
+ const user = req.user;
+ if (!user.discord) return res.status(400).json({ error: 'No Discord username set. Add it in Discord settings above.' });
+
+ const result = await notify.sendDiscordDM({
+ discordUsername: user.discord,
+ content: '🎉 This is a test message from Synthica!\n\nYour Discord notifications are working correctly.',
+ embed: {
+ title: '✅ Discord Connected!',
+ description: 'You will now receive project notifications via DM.',
+ color: 2589,
+ footer: { text: 'Synthica Notifications' },
+ },
+ });
+
+ if (!result.ok && !result.skipped) {
+ return res.status(400).json({ error: result.error || 'Failed to send DM' });
+ }
+ res.json({ ok: true });
+}));
+
// --- Track 4: Researcher dashboard -----------------------------------------
const researcherOnly = (req, res, next) => {
if (req.user.kind !== 'researcher' && !req.user.allViewsDemo) {
diff --git a/backend/src/email.js b/backend/src/email.js
index aba6f3f..38358b3 100644
--- a/backend/src/email.js
+++ b/backend/src/email.js
@@ -122,3 +122,165 @@ export function emailDecision({ authorEmail, authorName, title, decision }) {
signoff: 'Thank you for contributing,',
});
}
+
+// ============================================================
+// Notification email templates for platform events
+// ============================================================
+
+// New reply/comment on post
+export function emailNewComment({ toEmail, toName, authorName, postTitle, postLink }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ return actionEmail({
+ to: toEmail,
+ subject: `${authorName} commented on your post`,
+ heading: 'New comment on your post',
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ `${esc(authorName)} commented on your post${esc(postTitle)}`,
+ ],
+ button: { label: 'View comment', url: `${SITE}${postLink}` },
+ signoff: 'Stay engaged,',
+ });
+}
+
+// Project invitation received
+export function emailProjectInvite({ toEmail, toName, inviterName, projectTitle, projectLink }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ return actionEmail({
+ to: toEmail,
+ subject: `You've been invited to join: ${projectTitle}`,
+ heading: 'Project invitation',
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ `${esc(inviterName)} invited you to join the project ${esc(projectTitle)}`,
+ ],
+ button: { label: 'View project', url: `${SITE}${projectLink}` },
+ signoff: 'Happy collaborating,',
+ });
+}
+
+// Application status update
+export function emailApplicationUpdate({ toEmail, toName, programName, status, statusMessage, link }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'updated';
+ return actionEmail({
+ to: toEmail,
+ subject: `Your application to ${programName} was ${statusLabel}`,
+ heading: `Application ${statusLabel}`,
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ statusMessage || `Your application to ${esc(programName)} has been ${statusLabel}.`,
+ ],
+ button: link ? { label: 'View application', url: `${SITE}${link}` } : undefined,
+ signoff: 'Best regards,',
+ });
+}
+
+// Mentor request received
+export function emailMentorRequest({ toEmail, toName, menteeName, menteeMessage, requestLink }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ return actionEmail({
+ to: toEmail,
+ subject: `New mentorship request from ${menteeName}`,
+ heading: 'New mentorship request',
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ `${esc(menteeName)} requested your mentorship.`,
+ menteeMessage ? `Message: "${esc(menteeMessage)}"` : '',
+ ],
+ button: { label: 'View request', url: `${SITE}${requestLink}` },
+ signoff: 'Happy mentoring,',
+ });
+}
+
+// New community announcement
+export function emailAnnouncement({ toEmail, toName, authorName, title, content, link }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ return actionEmail({
+ to: toEmail,
+ subject: `Announcement: ${title}`,
+ heading: esc(title),
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ `From ${esc(authorName)}:`,
+ content || '',
+ ],
+ button: link ? { label: 'Read more', url: `${SITE}${link}` } : undefined,
+ signoff: 'Stay connected,',
+ });
+}
+
+// Role/privilege granted
+export function emailRoleGranted({ toEmail, toName, roleName, roleDescription, dashboardLink }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ return actionEmail({
+ to: toEmail,
+ subject: `You've been granted: ${roleName}`,
+ heading: 'New role assigned',
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ `Congratulations! You've been assigned the ${esc(roleName)} role.`,
+ roleDescription || '',
+ ],
+ button: { label: 'Go to dashboard', url: `${SITE}${dashboardLink}` },
+ signoff: 'Congratulations,',
+ });
+}
+
+// Independent researcher proposal update
+export function emailProposalUpdate({ toEmail, toName, proposalTitle, status, feedback, link }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'under review';
+ return actionEmail({
+ to: toEmail,
+ subject: `Your proposal "${proposalTitle}" was ${statusLabel}`,
+ heading: `Proposal ${statusLabel}`,
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ `Your proposal ${esc(proposalTitle)} has been ${statusLabel}.`,
+ feedback ? `Feedback: ${esc(feedback)}` : '',
+ ],
+ button: link ? { label: 'View proposal', url: `${SITE}${link}` } : undefined,
+ signoff: 'Best of luck,',
+ });
+}
+
+// Competition/listing update
+export function emailListingUpdate({ toEmail, toName, listingTitle, status, listingLink }) {
+ const first = String(toName || 'there').split(/\s+/)[0];
+ return actionEmail({
+ to: toEmail,
+ subject: `Update on your ${listingTitle} application`,
+ heading: 'Application update',
+ intro: `Hi ${esc(first)},`,
+ blocks: [
+ `There's an update regarding your application to ${esc(listingTitle)}.`,
+ ],
+ button: { label: 'View details', url: `${SITE}${listingLink}` },
+ signoff: 'Stay tuned,',
+ });
+}
+
+// General notification helper - sends any notification type as email
+export function sendNotificationEmail({ toEmail, toName, type, data }) {
+ if (!toEmail) return { ok: false, reason: 'no email' };
+
+ const templates = {
+ comment: () => emailNewComment({ toEmail, toName, ...data }),
+ project_invite: () => emailProjectInvite({ toEmail, toName, ...data }),
+ application_update: () => emailApplicationUpdate({ toEmail, toName, ...data }),
+ mentor_request: () => emailMentorRequest({ toEmail, toName, ...data }),
+ announcement: () => emailAnnouncement({ toEmail, toName, ...data }),
+ role_granted: () => emailRoleGranted({ toEmail, toName, ...data }),
+ proposal_update: () => emailProposalUpdate({ toEmail, toName, ...data }),
+ listing_update: () => emailListingUpdate({ toEmail, toName, ...data }),
+ };
+
+ const template = templates[type];
+ if (!template) {
+ console.warn(`[email] unknown notification type: ${type}`);
+ return { ok: false, reason: 'unknown type' };
+ }
+
+ return template();
+}
diff --git a/backend/src/notify.js b/backend/src/notify.js
index 64a45a3..66b62c6 100644
--- a/backend/src/notify.js
+++ b/backend/src/notify.js
@@ -17,6 +17,10 @@
// or via the DISCORD_WEBHOOK_URL env var (which also makes it survive restarts).
let webhookUrl = (process.env.DISCORD_WEBHOOK_URL || '').trim();
+// Discord bot token for sending DMs to users
+const DISCORD_BOT_TOKEN = (process.env.DISCORD_BOT_TOKEN || '').trim();
+// Discord guild invite link for users to join
+export const DISCORD_SERVER_LINK = 'https://discord.com/invite/8wPzZkGy5Z';
// A second, generic channel — point it at a WhatsApp relay (Twilio/Make/Zapier
// webhook) that forwards a JSON {text} payload to a WhatsApp number/group.
let whatsappUrl = (process.env.WHATSAPP_WEBHOOK_URL || '').trim();
@@ -35,16 +39,17 @@ export const setWhatsapp = (url) => {
whatsappUrl = (url || '').trim();
return whatsappUrl;
};
+export const hasDiscordBot = () => !!DISCORD_BOT_TOKEN;
// POST JSON with a hard timeout. Resolves to a small result object and never
// throws, so fire-and-forget callers can't trigger unhandled rejections.
-async function postJson(url, payload) {
+async function postJson(url, payload, headers = { 'Content-Type': 'application/json' }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
@@ -68,6 +73,155 @@ export async function postDiscord(payload) {
return postJson(webhookUrl, payload);
}
+// Get Discord user ID from username (requires the bot to share a server with the user)
+async function getDiscordUserId(username) {
+ if (!DISCORD_BOT_TOKEN) return null;
+ try {
+ // Search for user by username (Discord API limitation: this only works if bot shares a server)
+ // Alternative: require users to provide their Discord User ID directly
+ const res = await fetch(`https://discord.com/api/v10/users/@me`, {
+ headers: { Authorization: `Bot ${DISCORD_BOT_TOKEN}` },
+ });
+ // For now, we return the username as-is - actual DM requires User ID
+ // Users need to provide their Discord User ID for DMs to work reliably
+ return username; // Return as-is; the DM function will handle the actual sending
+ } catch (e) {
+ console.warn('[discord] failed to get user ID:', e.message);
+ return null;
+ }
+}
+
+// Send a DM to a Discord user via bot (requires user to be in a shared server)
+export async function sendDiscordDM({ discordUsername, content, embed }) {
+ if (!DISCORD_BOT_TOKEN) {
+ console.log('[discord] (no DISCORD_BOT_TOKEN) would DM', discordUsername);
+ return { ok: false, skipped: true, error: 'no bot token' };
+ }
+ try {
+ let userId = discordUsername;
+
+ // If it's a username (not just numbers), try to find the user ID
+ if (!/^\d+$/.test(discordUsername)) {
+ console.log(`[discord] Looking up user "${discordUsername}" in guild...`);
+
+ let apiFailed = false;
+ try {
+ const membersRes = await fetch(
+ `https://discord.com/api/v10/guilds/1512337763536601169/members?limit=1000`,
+ { headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}` } }
+ );
+
+ if (!membersRes.ok) {
+ const errText = await membersRes.text();
+ console.error('[discord] guild API error:', errText);
+ // Check if it's an auth issue
+ if (membersRes.status === 401 || membersRes.status === 403) {
+ return { ok: false, error: 'Cannot access Discord guild. Bot token may be invalid or expired.' };
+ }
+ apiFailed = true;
+ } else {
+ const members = await membersRes.json();
+ const match = members.find(m =>
+ m.user?.username?.toLowerCase() === discordUsername.toLowerCase() ||
+ m.nick?.toLowerCase() === discordUsername.toLowerCase()
+ );
+
+ if (match?.user?.id) {
+ userId = match.user.id;
+ console.log(`[discord] Found user "${discordUsername}" as ${userId}`);
+ }
+ }
+ } catch (e) {
+ console.warn(`[discord] Could not search guild members:`, e.message);
+ apiFailed = true;
+ }
+
+ // If API failed or user not found, return appropriate error
+ if (apiFailed || !/^\d+$/.test(userId)) {
+ return {
+ ok: false,
+ error: `User "${discordUsername}" not found. Make sure they are in the Synthica Discord server.`
+ };
+ }
+
+ // If we still don't have a user ID, the user might not be in the server
+ if (!/^\d+$/.test(userId)) {
+ return {
+ ok: false,
+ error: `User "${discordUsername}" not found. Make sure they are in the Synthica Discord server.`
+ };
+ }
+ }
+
+ // Create a DM channel with the user
+ const dmResponse = await fetch('https://discord.com/api/v10/users/@me/channels', {
+ method: 'POST',
+ headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ recipient_id: userId }),
+ });
+
+ if (!dmResponse.ok) {
+ const err = await dmResponse.text();
+ console.error('[discord] failed to create DM channel:', err);
+ if (err.includes('403') || err.includes('Cannot send messages to this user')) {
+ return { ok: false, error: 'User has DMs disabled. They need to allow DMs from server members in Discord settings.' };
+ }
+ return { ok: false, error: `Failed to create DM: ${err}` };
+ }
+
+ const dmChannel = await dmResponse.json();
+
+ // Send the message
+ const payload = { channel_id: dmChannel.id };
+ if (content) payload.content = content;
+ if (embed) payload.embeds = [embed];
+
+ const msgRes = await fetch(`https://discord.com/api/v10/channels/${dmChannel.id}/messages`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (!msgRes.ok) {
+ const err = await msgRes.text();
+ console.error('[discord] failed to send message:', err);
+ return { ok: false, error: err };
+ }
+
+ return { ok: true };
+ } catch (e) {
+ console.error('[discord] DM error:', e);
+ return { ok: false, error: e.message };
+ }
+}
+
+function formatDiscordNotification({ type, title, body, link }) {
+ const fields = [];
+ if (title) fields.push({ name: 'Notification', value: title, inline: false });
+ if (body) fields.push({ name: 'Details', value: body, inline: false });
+ if (link) fields.push({ name: 'Action', value: `[View on Synthica](${link})`, inline: false });
+
+ return {
+ username: 'Synthica',
+ embeds: [{
+ title: `🔔 ${title || 'New notification'}`,
+ description: body || '',
+ color: 0x2589ed, // Synthica brand blue
+ fields,
+ footer: { text: 'Synthica Notifications' },
+ timestamp: new Date().toISOString(),
+ }],
+ };
+}
+
+// Send notification via Discord DM (fire-and-forget)
+export function notifyDiscord({ discordUsername, type, title, body, link }) {
+ if (!DISCORD_BOT_TOKEN && !discordUsername) return;
+ const payload = formatDiscordNotification({ type, title, body, link });
+ // Fire and forget
+ sendDiscordDM({ discordUsername, content: null, embed: payload.embeds[0] }).catch(() => {});
+}
+
// Called from the workflow whenever a paper advances or is declined.
export function notifyMove({ title, paperId, category, label, decision }) {
if (!webhookUrl && !whatsappUrl) return;
diff --git a/backend/src/store.js b/backend/src/store.js
index ae6f311..9a16515 100644
--- a/backend/src/store.js
+++ b/backend/src/store.js
@@ -10,8 +10,8 @@ import { STAGE, STAGE_LABEL, EDITOR_ROLES, ASSOCIATE_TOTAL_ROUNDS, TASK_STATUS,
import * as seed from './seed.js';
import { verifyPassword, hashPassword } from './passwords.js';
import { safeUrl } from './url.js';
-import { notifyMove, notifyEvent } from './notify.js';
-import { emailDecision, sendEmail, actionEmail } from './email.js';
+import { notifyMove, notifyEvent, notifyDiscord } from './notify.js';
+import { emailDecision, sendEmail, actionEmail, sendNotificationEmail } from './email.js';
import { registerDoi } from './doi.js';
import { generateSecret as totpGenerateSecret, otpauthUrl as totpOtpauthUrl, verifyTotp } from './totp.js';
@@ -53,7 +53,7 @@ function buildSeed() {
};
}
-// Push an in-app notification to a user (fire-and-forget within mutations).
+// Push an in-app notification to a user and trigger email/Discord via notifyUser.
function pushNotif(userId, { type, title, body, link }) {
if (!userId || !db.notifications) return;
db.notifications.push({
@@ -67,6 +67,158 @@ function pushNotif(userId, { type, title, body, link }) {
at: new Date().toISOString(),
});
emit(userId, 'notification', { title, body, link });
+
+ // Also trigger email/Discord notifications via notifyUser
+ // Use setImmediate to not block the mutation
+ setImmediate(() => {
+ try {
+ notifyUser(userId, { type, title, body, link }, true);
+ } catch (e) {
+ console.warn('[notify] pushNotif -> notifyUser failed:', e.message);
+ }
+ });
+}
+
+// Check if a notification type is enabled for a user
+function isNotificationEnabled(user, channel, type) {
+ const prefs = user?.notifications;
+ if (channel === 'email') {
+ if (!prefs) return true; // Default: email enabled
+ return prefs.email?.[type] ?? true;
+ }
+ if (channel === 'discord') {
+ if (!prefs) return !!user?.discord; // Default: enabled if user has discord username
+ return prefs.discord?.[type] ?? !!user?.discord; // Default: enabled if user has discord username
+ }
+ return false;
+}
+
+// Get the link URL for a notification
+function getNotificationLink(notifData) {
+ const SITE = process.env.FRONTEND_URL || 'https://app.synthica.org';
+ const base = SITE.replace(/\/$/, '');
+ return notifData.link ? `${base}${notifData.link}` : base;
+}
+
+// Comprehensive notification dispatcher - sends to all enabled channels
+// Types: comment, project_invite, application_update, mentor_request,
+// announcement, role_granted, proposal_update, listing_update
+export function notifyUser(userId, { type, title, body, link }, skipPush = false) {
+ const user = getUserById(userId);
+ if (!user) return;
+
+ const linkUrl = getNotificationLink({ link });
+
+ // 1. In-app notification (always sent) - unless we are already being called from pushNotif
+ if (!skipPush) {
+ pushNotif(userId, { type, title, body, link });
+ }
+
+ // 2. Email notification (if enabled for this type)
+ if (isNotificationEnabled(user, 'email', type)) {
+ try {
+ sendNotificationEmail({
+ toEmail: user.email,
+ toName: user.name,
+ type,
+ data: { title, body, link: linkUrl, authorName: '', postTitle: '', postLink: link },
+ });
+ } catch (e) {
+ console.warn(`[notify] email failed for user ${userId}:`, e.message);
+ }
+ }
+
+ // 3. Discord DM (if enabled for this type and user has discord username)
+ if (user.discord && isNotificationEnabled(user, 'discord', type)) {
+ try {
+ notifyDiscord({
+ discordUsername: user.discord,
+ type,
+ title,
+ body,
+ link: linkUrl,
+ });
+ } catch (e) {
+ console.warn(`[notify] discord failed for user ${userId}:`, e.message);
+ }
+ }
+}
+
+// Helper functions for specific notification types
+export function notifyComment({ targetUserId, authorName, postTitle, postLink }) {
+ notifyUser(targetUserId, {
+ type: 'comment',
+ title: `${authorName} commented on your post`,
+ body: `"${postTitle}"`,
+ link: postLink,
+ });
+}
+
+export function notifyProjectInvite({ targetUserId, inviterName, projectTitle, projectLink }) {
+ notifyUser(targetUserId, {
+ type: 'project_invite',
+ title: `You've been invited to join: ${projectTitle}`,
+ body: `${inviterName} invited you`,
+ link: projectLink,
+ });
+}
+
+export function notifyApplicationUpdate({ targetUserId, programName, status, statusMessage, link }) {
+ const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'updated';
+ notifyUser(targetUserId, {
+ type: 'application_update',
+ title: `Your application to ${programName} was ${statusLabel}`,
+ body: statusMessage || `Your application has been ${statusLabel}`,
+ link,
+ });
+}
+
+export function notifyMentorRequest({ targetUserId, menteeName, menteeMessage, requestLink }) {
+ notifyUser(targetUserId, {
+ type: 'mentor_request',
+ title: `New mentorship request from ${menteeName}`,
+ body: menteeMessage || 'Someone requested your mentorship',
+ link: requestLink,
+ });
+}
+
+export function notifyAnnouncement({ targetUserIds, authorName, title, content, link }) {
+ for (const userId of targetUserIds) {
+ notifyUser(userId, {
+ type: 'announcement',
+ title: `Announcement: ${title}`,
+ body: `From ${authorName}`,
+ link,
+ });
+ }
+}
+
+export function notifyRoleGranted({ targetUserId, roleName, roleDescription, dashboardLink }) {
+ notifyUser(targetUserId, {
+ type: 'role_granted',
+ title: `You've been granted: ${roleName}`,
+ body: roleDescription || `You now have the ${roleName} role`,
+ link: dashboardLink,
+ });
+}
+
+export function notifyProposalUpdate({ targetUserId, proposalTitle, status, feedback, link }) {
+ const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'under review';
+ notifyUser(targetUserId, {
+ type: 'proposal_update',
+ title: `Your proposal "${proposalTitle}" was ${statusLabel}`,
+ body: feedback || `Your proposal is now ${statusLabel}`,
+ link,
+ });
+}
+
+export function notifyListingUpdate({ targetUserId, listingTitle, status, link }) {
+ notifyUser(targetUserId, {
+ type: 'listing_update',
+ title: `Update on your ${listingTitle} application`,
+ body: `There's an update regarding your application`,
+ link,
+ });
}
// Notify every editor who reviewed a paper (per policy: reviewers are told when
@@ -675,6 +827,42 @@ export function updateProfile(userId, patch) {
if (typeof patch.avatarUrl === 'string') u.avatarUrl = safeUrl(patch.avatarUrl, 400);
if (typeof patch.resumeUrl === 'string') u.resumeUrl = safeUrl(patch.resumeUrl, 400);
if (typeof patch.discord === 'string') u.discord = patch.discord.trim().slice(0, 60);
+
+ // Notification preferences
+ if (patch.notifications !== undefined) {
+ // Initialize notifications object if it doesn't exist
+ if (!u.notifications) u.notifications = {};
+ // Email notifications (by type)
+ if (typeof patch.notifications.email === 'object') {
+ u.notifications.email = {
+ ...u.notifications.email,
+ comment: patch.notifications.email.comment ?? true,
+ project_invite: patch.notifications.email.project_invite ?? true,
+ application_update: patch.notifications.email.application_update ?? true,
+ mentor_request: patch.notifications.email.mentor_request ?? true,
+ announcement: patch.notifications.email.announcement ?? true,
+ role_granted: patch.notifications.email.role_granted ?? true,
+ proposal_update: patch.notifications.email.proposal_update ?? true,
+ listing_update: patch.notifications.email.listing_update ?? true,
+ digest: patch.notifications.email.digest ?? false,
+ };
+ }
+ // Discord notifications (by type)
+ if (typeof patch.notifications.discord === 'object') {
+ u.notifications.discord = {
+ ...u.notifications.discord,
+ comment: patch.notifications.discord.comment ?? true,
+ project: patch.notifications.discord.project ?? true,
+ project_invite: patch.notifications.discord.project_invite ?? true,
+ application_update: patch.notifications.discord.application_update ?? true,
+ mentor_request: patch.notifications.discord.mentor_request ?? true,
+ announcement: patch.notifications.discord.announcement ?? true,
+ role_granted: patch.notifications.discord.role_granted ?? true,
+ proposal_update: patch.notifications.discord.proposal_update ?? true,
+ listing_update: patch.notifications.discord.listing_update ?? true,
+ };
+ }
+ }
if (typeof patch.linkedinUrl === 'string') u.linkedinUrl = safeUrl(patch.linkedinUrl);
if (typeof patch.websiteUrl === 'string') u.websiteUrl = safeUrl(patch.websiteUrl);
if (typeof patch.githubUrl === 'string') u.githubUrl = safeUrl(patch.githubUrl);
@@ -2806,11 +2994,12 @@ export function inviteToProject({ projectId, leadId, email }) {
if (existing) {
if (p.members.includes(existing.id)) throw httpError(409, 'They are already on this project');
p.members.push(existing.id);
- pushNotif(existing.id, { type: 'project', title: `You were added to ${p.title}`, body: `Invited by ${lead?.name || 'the project lead'}`, link: `/researcher/project/${p.id}` });
- sendEmail({
- to: addr,
- subject: `You've been added to "${p.title}" on Synthica`,
- text: `Hi ${existing.name},\n\n${lead?.name || 'A project lead'} added you to the project "${p.title}".\nSign in to see it: ${process.env.FRONTEND_URL || 'https://app.synthica.org'}\n\n— The Synthica Team`,
+ // Use notifyUser for full notification support (in-app + email + Discord DM)
+ notifyProjectInvite({
+ targetUserId: existing.id,
+ inviterName: lead?.name || 'the project lead',
+ projectTitle: p.title,
+ projectLink: `/researcher/project/${p.id}`,
});
schedulePersist();
return { status: 'added', name: existing.name };
diff --git a/dashboards/src/api.js b/dashboards/src/api.js
index 5e1f8dd..c955f54 100644
--- a/dashboards/src/api.js
+++ b/dashboards/src/api.js
@@ -319,6 +319,8 @@ export const api = {
getSettings: () => request('/editor/settings'),
setWebhook: (discordWebhookUrl) => request('/editor/settings', { method: 'PUT', body: { discordWebhookUrl } }),
testWebhook: () => request('/editor/settings/test', { method: 'POST' }),
+ // Discord DM test
+ notifyTest: () => request('/notify/test', { method: 'POST' }),
// Expertise mentors (ROLE_WORKFLOWS §7)
// Directory + booking (any researcher):
diff --git a/dashboards/src/components/Bell.jsx b/dashboards/src/components/Bell.jsx
index 6ff9f8c..b3c3cea 100644
--- a/dashboards/src/components/Bell.jsx
+++ b/dashboards/src/components/Bell.jsx
@@ -73,12 +73,30 @@ export default function Bell() {
if (n.link) navigate(n.link);
};
+ const goToSettings = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setOpen(false);
+ // Extract base path from current location (e.g., /editor, /moderator, /researcher)
+ const pathname = window.location.pathname;
+ const match = pathname.match(/^(\/editor|\/moderator|\/researcher)/);
+ const basePath = match ? match[1] : '';
+ navigate(`${basePath}/account?tab=notifications`);
+ };
+
const label = unread > 0 ? `Notifications, ${unread} unread` : 'Notifications';
return (
-
{items.length === 0 ? (
diff --git a/dashboards/src/components/Toggle.jsx b/dashboards/src/components/Toggle.jsx
new file mode 100644
index 0000000..0afe082
--- /dev/null
+++ b/dashboards/src/components/Toggle.jsx
@@ -0,0 +1,39 @@
+// Toggle switch component
+export default function Toggle({ checked, onChange, disabled = false }) {
+ return (
+
onChange(!checked)}
+ style={{
+ position: 'relative',
+ width: '44px',
+ height: '24px',
+ borderRadius: '12px',
+ background: checked ? 'var(--brand, #2589ed)' : 'var(--border, #e2e8f0)',
+ border: 'none',
+ cursor: disabled ? 'not-allowed' : 'pointer',
+ opacity: disabled ? 0.5 : 1,
+ transition: 'background 0.2s',
+ padding: 0,
+ outline: 'none',
+ }}
+ >
+
+
+ );
+}
diff --git a/dashboards/src/pages/Account.jsx b/dashboards/src/pages/Account.jsx
index 282575c..9e06bd7 100644
--- a/dashboards/src/pages/Account.jsx
+++ b/dashboards/src/pages/Account.jsx
@@ -6,6 +6,7 @@ import { Security } from './Tools.jsx';
import Certificates from '../components/Certificates.jsx';
import Referrals from '../components/Referrals.jsx';
import PrivacySafety from '../components/PrivacySafety.jsx';
+import Notifications from './Notifications.jsx';
import { useAuth } from '../auth.jsx';
// The account center — one place to manage everything tied to "you": your public
@@ -21,6 +22,7 @@ export default function Account() {
// Tabs are gated by account kind: only researchers earn role certificates.
const tabs = [
['profile', 'Profile', () =>
],
+ ['notifications', 'Notifications', () =>
],
['tools', 'Résumé & tools', () =>
],
isResearcher && ['certs', 'Certificates', () =>
],
['referrals', 'Refer & invite', () =>
],
diff --git a/dashboards/src/pages/Notifications.jsx b/dashboards/src/pages/Notifications.jsx
new file mode 100644
index 0000000..1abe77f
--- /dev/null
+++ b/dashboards/src/pages/Notifications.jsx
@@ -0,0 +1,307 @@
+import { useState, Component } from 'react';
+import { Card, Button, Badge } from '../components/ui.jsx';
+import Toggle from '../components/Toggle.jsx';
+import { api } from '../api.js';
+import { useAuth } from '../auth.jsx';
+import { useToast } from '../components/toast.jsx';
+
+// Error boundary to catch rendering errors
+class ErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+ static getDerivedStateFromError(error) {
+ return { hasError: true, error };
+ }
+ componentDidCatch(error, info) {
+ console.error('Notifications Error:', error, info);
+ }
+ render() {
+ if (this.state.hasError) {
+ return (
+
+ Error loading notifications
+ {this.state.error?.message}
+ window.location.reload()}>Reload page
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+const DISCORD_SERVER_LINK = 'https://discord.com/invite/8wPzZkGy5Z';
+
+const NOTIFICATION_TYPES = [
+ { key: 'comment', label: 'Comments on your posts', desc: 'When someone comments on your posts' },
+ { key: 'project', label: 'Project updates', desc: 'When you are added to or removed from projects' },
+ { key: 'project_invite', label: 'Project invitations', desc: 'When you receive a project invitation' },
+ { key: 'application_update', label: 'Application updates', desc: 'Status updates on your applications' },
+ { key: 'mentor_request', label: 'Mentor requests', desc: 'When someone requests your mentorship' },
+ { key: 'announcement', label: 'Announcements', desc: 'Community announcements and updates' },
+ { key: 'role_granted', label: 'Role assignments', desc: 'When you are assigned a new role' },
+ { key: 'proposal_update', label: 'Proposal updates', desc: 'Status updates on your proposals' },
+ { key: 'listing_update', label: 'Listing updates', desc: 'Updates on listings you have applied to' },
+];
+
+function NotificationsContent() {
+ const { user, refreshUser } = useAuth();
+ const toast = useToast();
+
+ // Guard: show loading if no user
+ if (!user) {
+ return (
+
+
+ Loading notification settings...
+
+
+ );
+ }
+
+ const [discord, setDiscord] = useState(user?.discord || '');
+ const [savingDiscord, setSavingDiscord] = useState(false);
+ const [savingPrefs, setSavingPrefs] = useState(false);
+
+ // Initialize notification preferences from user data
+ const emailPrefs = user?.notifications?.email || {};
+ const discordPrefs = user?.notifications?.discord || {};
+
+ const [emailEnabled, setEmailEnabled] = useState(() =>
+ NOTIFICATION_TYPES.reduce((acc, t) => {
+ acc[t.key] = emailPrefs[t.key] !== undefined ? emailPrefs[t.key] : true;
+ return acc;
+ }, {})
+ );
+
+ const [discordEnabled, setDiscordEnabled] = useState(() =>
+ NOTIFICATION_TYPES.reduce((acc, t) => {
+ acc[t.key] = discordPrefs[t.key] ?? true; // Default to true
+ return acc;
+ }, {})
+ );
+
+ const handleDiscordSave = async () => {
+ setSavingDiscord(true);
+ try {
+ await api.updateProfile({ discord: discord.trim() });
+ await refreshUser();
+ toast.success('Discord username saved');
+ } catch (e) {
+ toast.error(e.message);
+ } finally {
+ setSavingDiscord(false);
+ }
+ };
+
+ const [sendingTest, setSendingTest] = useState(false);
+ const handleSendTestDM = async () => {
+ if (!user?.discord) return toast.error('No Discord username set');
+ setSendingTest(true);
+ try {
+ const data = await api.notifyTest();
+ toast.success('Test DM sent! Check your Discord.');
+ } catch (e) {
+ toast.error(e.message);
+ } finally {
+ setSendingTest(false);
+ }
+ };
+
+ const handleToggleEmail = (key) => {
+ setEmailEnabled(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ const handleToggleDiscord = (key) => {
+ setDiscordEnabled(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ const handleSavePreferences = async () => {
+ setSavingPrefs(true);
+ try {
+ const notifications = {
+ email: emailEnabled,
+ discord: discordEnabled,
+ };
+ await api.updateProfile({ notifications });
+ await refreshUser();
+ toast.success('Notification preferences saved');
+ } catch (e) {
+ toast.error(e.message);
+ } finally {
+ setSavingPrefs(false);
+ }
+ };
+
+ // Check if Discord is connected
+ const hasDiscord = user?.discord && user.discord.trim().length > 0;
+
+ return (
+
+ {/* Discord Settings */}
+
+
+
+ Discord Notifications
+ {hasDiscord ? 'Connected' : 'Not connected'}
+
+
+ {!hasDiscord ? (
+
+
+
+ To receive Discord DM notifications, you need to:
+
+
+ - Join the Synthica Discord server
+ - Enter your Discord username or User ID below
+
+
+
+
+ setDiscord(e.target.value)}
+ className="form-input"
+ style={{ flex: 1 }}
+ />
+
+ {savingDiscord ? 'Saving...' : 'Save'}
+
+
+
+
+
+ Join Synthica Discord Server
+
+
+ ) : (
+
+
+ Connected
+
+ Receiving DMs as: {user.discord}
+
+
+
+ setDiscord(e.target.value)}
+ className="form-input"
+ style={{ flex: 1 }}
+ />
+
+ {savingDiscord ? 'Saving...' : 'Update'}
+
+
+ {sendingTest ? 'Sending...' : 'Send Test DM'}
+
+
+
+ )}
+
+
+ {/* Email Notifications */}
+
+
+
+ Email Notifications
+ Enabled by default
+
+
+
+ {NOTIFICATION_TYPES.map(({ key, label, desc }) => (
+
+
+
handleToggleEmail(key)}
+ />
+
+ ))}
+
+
+
+ {/* Discord Notifications */}
+ {hasDiscord && (
+
+
+
+ Discord DM Notifications
+ Connected
+
+
+
+ {NOTIFICATION_TYPES.map(({ key, label, desc }) => (
+
+
+
handleToggleDiscord(key)}
+ />
+
+ ))}
+
+
+ )}
+
+
+ {savingPrefs ? 'Saving...' : 'Save Preferences'}
+
+
+ );
+}
+
+// Wrap with error boundary for production error catching
+export default function Notifications() {
+ return (
+
+
+
+ );
+}
diff --git a/dashboards/src/pages/PublicProfile.jsx b/dashboards/src/pages/PublicProfile.jsx
index 32508fe..44b49c1 100644
--- a/dashboards/src/pages/PublicProfile.jsx
+++ b/dashboards/src/pages/PublicProfile.jsx
@@ -49,7 +49,6 @@ export default function PublicProfile() {
)}
- Message
onToggle(p)}>{p.following ? Following : 'Follow'}
@@ -131,7 +130,6 @@ function ConnectionCard({ p }) {
Profile
- Message
);
diff --git a/dashboards/src/pages/researcher/ResearcherApp.jsx b/dashboards/src/pages/researcher/ResearcherApp.jsx
index 27e89b1..4282e04 100644
--- a/dashboards/src/pages/researcher/ResearcherApp.jsx
+++ b/dashboards/src/pages/researcher/ResearcherApp.jsx
@@ -24,7 +24,6 @@ import MyProjects from './MyProjects.jsx';
import People from './People.jsx';
import MyJournal from './MyJournal.jsx';
import Community from './Community.jsx';
-import Messages from './Messages.jsx';
import Programs from './Programs.jsx';
import Groups from './Groups.jsx';
import GroupDetail from './GroupDetail.jsx';
@@ -77,7 +76,6 @@ export default function ResearcherApp() {
{ to: '/researcher', label: 'Home', icon: 'home', end: true, views: ['researcher'] },
{ section: 'Community', views: ['researcher'] },
{ to: '/researcher/community', label: 'Feed', icon: 'megaphone', views: ['researcher'] },
- { to: '/researcher/messages', label: 'Messages', icon: 'message', views: ['researcher'] },
{ to: '/researcher/people', label: 'People', icon: 'users', views: ['researcher'] },
{ to: '/researcher/mentors', label: 'Mentors', icon: 'graduation-cap', views: ['researcher'] },
{ section: 'Research', views: ['researcher'] },
@@ -125,7 +123,6 @@ export default function ResearcherApp() {
...(isMentor ? [
{ to: '/researcher/mentor', label: 'Mentor desk', icon: 'graduation-cap', end: true, views: ['mentor'] },
{ section: 'Mentor', views: ['mentor'] },
- { to: '/researcher/messages', label: 'Messages', icon: 'message', views: ['mentor'] },
{ to: '/researcher/calendar', label: 'Calendar', icon: 'calendar', views: ['mentor'] },
] : []),
@@ -152,8 +149,6 @@ export default function ResearcherApp() {
{/* Shared researcher pages */}
} />
- } />
- } />
} />
} />
} />
diff --git a/dashboards/src/styles.css b/dashboards/src/styles.css
index c8895a6..a00767e 100644
--- a/dashboards/src/styles.css
+++ b/dashboards/src/styles.css
@@ -978,8 +978,9 @@ textarea {
.cmdk-foot { padding: 0.5rem 1.2rem; border-top: 1px solid var(--border); color: var(--body-alt); font-size: 0.72rem; }
/* ---------- Notification bell ---------- */
-.bell { position: relative; }
-.bell-btn { position: relative; display: inline-flex; align-items: center; background: none; border: none; font-size: 1.15rem; cursor: pointer; padding: 0.2rem; }
+.bell { position: relative; display: inline-flex !important; }
+.bell-btn { position: relative; display: inline-flex !important; align-items: center; justify-content: center; background: none; border: none; font-size: 1.15rem; cursor: pointer; padding: 0.3rem; border-radius: 6px; }
+.bell-btn:hover { background: var(--surface-alt); }
.icon-label { display: inline-flex; align-items: center; gap: 0.35rem; vertical-align: middle; }
.ob-emoji .icon { display: block; margin: 0 auto; }
.ob-req { color: var(--danger); font-weight: 800; margin-left: 0.15rem; }
@@ -998,6 +999,8 @@ textarea {
.bell-time { font-size: 0.68rem; color: var(--body-alt); margin-top: 0.2rem; }
.bell-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.bell-status { font-size: 0.66rem; font-weight: 600; color: var(--gold, #d4860a); white-space: nowrap; }
+.bell-settings-btn { background: none; border: none; font-size: 0.78rem; cursor: pointer; padding: 0.3rem 0.5rem; border-radius: 4px; opacity: 0.7; transition: opacity 0.15s, background 0.15s; text-decoration: none; color: var(--brand); display: inline-flex; align-items: center; gap: 0.25rem; }
+.bell-settings-btn:hover { opacity: 1; background: var(--surface-alt); }
.bell-empty { text-align: center; }
.bell-empty-icon { font-size: 1.6rem; opacity: 0.5; margin-bottom: 0.35rem; }
.bell-empty-sub { font-size: 0.72rem; color: var(--body-alt); margin-top: 0.3rem; }
@@ -1846,109 +1849,6 @@ html[data-theme='dark'] .fm-path, html[data-theme='dark'] .fm-btn { background:
html[data-theme='dark'] .fm-status { background: #0e1622; }
html[data-theme='dark'] .fm-row:hover { background: rgba(37, 137, 237, 0.14); }
-/* ---------- Direct messages ---------- */
-.dm-layout { display: grid; grid-template-columns: minmax(240px, 320px) 1fr; gap: 1rem; align-items: start; }
-.dm-list { padding: 0.5rem; max-height: 70vh; overflow-y: auto; }
-.dm-convo {
- width: 100%; display: flex; align-items: center; gap: 0.6rem; text-align: left;
- background: transparent; border: none; border-radius: 12px; padding: 0.55rem 0.6rem; cursor: pointer;
-}
-.dm-convo:hover { background: var(--surface-alt); }
-.dm-convo.active { background: rgba(120, 180, 251, 0.16); }
-.dm-convo-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
-.dm-convo-name { font-weight: 700; font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
-.dm-convo-last { color: var(--body-alt); font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.dm-convo-time { color: var(--body-alt); font-size: 0.72rem; flex: none; }
-.dm-thread-card { padding: 0; display: flex; flex-direction: column; min-height: 420px; width: 100%; overflow: hidden; }
-.dm-thread { display: flex; flex-direction: column; height: 70vh; width: 100%; }
-.dm-thread-head { display: flex; align-items: center; gap: 0.5rem; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); }
-.dm-messages { flex: 1; overflow-y: auto; padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: 0.45rem; width: 100%; box-sizing: border-box; }
-.dm-bubble {
- width: fit-content; max-width: 75%; min-width: 60px;
- background: var(--surface-alt); color: var(--heading);
- border-radius: 14px 14px 14px 4px; padding: 0.5rem 0.75rem; font-size: 0.92rem;
- display: flex; flex-direction: column; gap: 0.15rem;
-}
-.dm-bubble.mine { background: var(--sky-deep); color: #fff; border-radius: 14px 14px 4px 14px; }
-.dm-bubble-time { font-size: 0.66rem; opacity: 0.7; align-self: flex-end; }
-.dm-compose { display: flex; gap: 0.5rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); }
-.dm-compose-input { flex: 1; resize: none; border: 1px solid var(--border); border-radius: 12px; padding: 0.5rem 0.75rem; font-size: 0.9rem; font-family: inherit; min-height: 40px; max-height: 120px; }
-.dm-compose-input:focus { outline: none; border-color: var(--brand); }
-
-/* Bubble content */
-.dm-bubble-content { display: flex; flex-direction: column; gap: 0.15rem; }
-.dm-bubble-text { word-break: break-word; line-height: 1.4; }
-.dm-edited-mark { font-size: 0.65rem; opacity: 0.6; margin-left: 0.25rem; }
-.dm-bubble-status { display: flex; align-items: center; gap: 0.25rem; justify-content: flex-end; margin-top: 0.15rem; }
-.dm-status-icons { display: flex; color: rgba(255,255,255,0.7); }
-.dm-bubble.mine .dm-bubble-status { color: rgba(255,255,255,0.8); }
-
-/* Bubble actions */
-.dm-bubble-actions { display: none; gap: 0.25rem; margin-top: 0.35rem; padding-top: 0.35rem; border-top: 1px solid rgba(0,0,0,0.08); }
-.dm-bubble.mine .dm-bubble-actions { border-top-color: rgba(255,255,255,0.15); }
-.dm-bubble:hover .dm-bubble-actions { display: flex; }
-.dm-bubble-actions button { background: transparent; border: none; cursor: pointer; padding: 0.2rem 0.35rem; border-radius: 4px; color: var(--body-alt); opacity: 0.7; transition: opacity 0.15s, background 0.15s; display: flex; align-items: center; justify-content: center; }
-.dm-bubble-actions button:hover { opacity: 1; background: rgba(0,0,0,0.08); }
-.dm-bubble.mine .dm-bubble-actions button:hover { background: rgba(255,255,255,0.15); }
-
-/* Reactions */
-.dm-reactions { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem; }
-.dm-reaction { background: rgba(0,0,0,0.06); border: 1px solid rgba(0,0,0,0.1); border-radius: 10px; padding: 0.1rem 0.4rem; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 0.2rem; transition: background 0.15s; }
-.dm-reaction:hover { background: rgba(0,0,0,0.12); }
-.dm-bubble.mine .dm-reaction { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.3); }
-.dm-reaction-picker { position: absolute; background: white; border: 1px solid var(--border); border-radius: 12px; padding: 0.35rem; display: flex; gap: 0.15rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 10; }
-.dm-bubble-wrap { position: relative; display: flex; flex-direction: column; }
-.dm-bubble-wrap.mine { align-items: flex-end; }
-.dm-bubble-wrap:not(.mine) { align-items: flex-start; }
-
-/* Reply preview */
-.dm-reply-preview { background: rgba(0,0,0,0.05); border-left: 2px solid var(--brand); padding: 0.25rem 0.5rem; margin-bottom: 0.25rem; border-radius: 0 4px 4px 0; font-size: 0.8rem; }
-.dm-bubble.mine .dm-reply-preview { background: rgba(255,255,255,0.1); border-left-color: rgba(255,255,255,0.5); }
-.dm-reply-sender { font-weight: 600; color: var(--brand); margin-right: 0.25rem; }
-.dm-reply-text { color: var(--body-alt); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-
-/* Reply indicator */
-.dm-reply-indicator { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1.1rem; background: var(--surface-alt); border-bottom: 1px solid var(--border); font-size: 0.85rem; }
-.dm-reply-info { display: flex; flex-direction: column; gap: 0.1rem; }
-.dm-reply-content { color: var(--body-alt); font-size: 0.8rem; }
-.dm-reply-close { background: transparent; border: none; cursor: pointer; padding: 0.25rem; color: var(--body-alt); display: flex; }
-.dm-reply-close:hover { color: var(--heading); }
-
-/* Edit form */
-.dm-edit-form { display: flex; flex-direction: column; gap: 0.5rem; }
-.dm-edit-input { border: 1px solid var(--border); border-radius: 8px; padding: 0.4rem 0.6rem; font-size: 0.9rem; font-family: inherit; resize: none; min-height: 60px; }
-.dm-edit-input:focus { outline: none; border-color: var(--brand); }
-.dm-edit-actions { display: flex; gap: 0.35rem; justify-content: flex-end; }
-.dm-btn-save { background: var(--brand); color: white; border: none; border-radius: 6px; padding: 0.3rem 0.75rem; font-size: 0.8rem; cursor: pointer; }
-.dm-btn-save:hover { background: var(--brand-deep); }
-.dm-btn-cancel { background: transparent; color: var(--body-alt); border: 1px solid var(--border); border-radius: 6px; padding: 0.3rem 0.75rem; font-size: 0.8rem; cursor: pointer; }
-.dm-btn-cancel:hover { background: var(--surface-alt); }
-
-/* Deleted / Forwarded */
-.dm-deleted-text { font-style: italic; opacity: 0.5; }
-.dm-forwarded-badge { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--body-alt); margin-bottom: 0.25rem; display: block; }
-
-/* Modal */
-.dm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
-.dm-modal { background: var(--bg); border-radius: 16px; padding: 1.25rem; width: 90%; max-width: 400px; max-height: 70vh; display: flex; flex-direction: column; gap: 0.75rem; }
-.dm-modal h3 { margin: 0; font-size: 1.1rem; }
-.dm-modal-search { border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.9rem; }
-.dm-modal-search:focus { outline: none; border-color: var(--brand); }
-.dm-modal-list { display: flex; flex-direction: column; gap: 0.25rem; max-height: 250px; overflow-y: auto; }
-.dm-modal-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem; background: transparent; border: none; border-radius: 8px; cursor: pointer; text-align: left; width: 100%; }
-.dm-modal-item:hover { background: var(--surface-alt); }
-.dm-modal-close { background: transparent; border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem; cursor: pointer; }
-.dm-modal-close:hover { background: var(--surface-alt); }
-
-/* Link style */
-.msg-link { color: var(--brand); text-decoration: underline; }
-.msg-link:hover { color: var(--brand-deep); }
-
-@media (max-width: 760px) {
- .dm-layout { grid-template-columns: 1fr; }
- .dm-list { max-height: 40vh; }
-}
-
/* ============================================================================
DASHBOARD UI KIT (components/dashboard/* + new ui.jsx primitives)
Foundation design system for role homes. Built on the existing Synthica