diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index f5448d68..30c6744c 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -698,6 +698,126 @@ Classifiers wrap scikit-learn or XGBoost models:
The GUI uses Qt's signal/slot mechanism for communication between components.
+##### MessageDialog - Custom Error/Warning/Info Dialogs
+
+JABS provides a custom `MessageDialog` class for displaying errors, warnings, and informational messages throughout the application. This dialog should be **preferred over PySide6's default dialogs** (`QMessageBox`) for consistency and enhanced functionality.
+
+**Location:** `src/jabs/ui/message_dialog.py`
+
+**Why use MessageDialog instead of QMessageBox?**
+
+1. **Consistent branding**: Matches JABS visual design and theming
+2. **Expandable details**: Supports collapsible stack traces and debug information
+3. **Rich text support**: Messages can include HTML formatting
+4. **Custom icons**: Can display JABS-specific icons with tooltips
+
+**Qt-Style API:**
+
+The MessageDialog follows Qt's classmethod pattern (like `QMessageBox.critical()`):
+
+```python
+from jabs.ui import MessageDialog
+
+# Error dialog
+MessageDialog.error(
+ self, # parent widget
+ title="Load Error",
+ message="Failed to load the project file.",
+)
+
+# Error with expandable stack trace
+import traceback
+try:
+ load_project(path)
+except Exception as e:
+ MessageDialog.error(
+ self,
+ title="Project Load Error",
+ message=f"An error occurred: {str(e)}",
+ details=traceback.format_exc(), # Expandable section
+ )
+
+# Warning dialog
+MessageDialog.warning(
+ self,
+ title="Feature Recomputation Required",
+ message="The selected window size has not been used before. Features will be computed during training.",
+)
+
+# Info dialog
+MessageDialog.information(
+ self,
+ title="Success",
+ message="Project loaded successfully!",
+)
+```
+
+**When to use each type:**
+
+- **`MessageDialog.error()`**: Critical errors that prevent operation (file not found, invalid data, exceptions)
+- **`MessageDialog.warning()`**: Non-critical issues that the user should be aware of (performance impacts, missing optional data)
+- **`MessageDialog.information()`**: Successful operations, status updates, helpful tips
+
+**Features:**
+
+1. **Automatic title defaults**: If no title is provided, defaults based on message type ("Error", "Warning", "Information")
+
+2. **Expandable details section**:
+ - Initially collapsed to keep dialog compact
+ - Toggle button: "▶ Show Details" / "▼ Hide Details"
+ - Monospace font suitable for stack traces and logs
+ - Scrollable if content is long
+
+3. **Rich text messages**:
+ ```python
+ MessageDialog.warning(
+ self,
+ message="Window size 60 frames has not been used.
First training may be slow."
+ )
+ ```
+
+4. **Custom icons with tooltips**:
+ - Icons can display tooltips on hover
+ - Configured in `_get_icon_path()` method
+
+**Migration from QMessageBox:**
+
+**Before:**
+```python
+QMessageBox.critical(self, "Error", "Something went wrong")
+QMessageBox.warning(self, "Warning", "Be careful")
+QMessageBox.information(self, "Info", "Success!")
+```
+
+**After:**
+```python
+MessageDialog.error(self, title="Error", message="Something went wrong")
+MessageDialog.warning(self, title="Warning", message="Be careful")
+MessageDialog.information(self, title="Info", message="Success!")
+```
+
+**Best Practices:**
+
+1. **Always include parent widget**: Pass `self` as first parameter for proper dialog centering and modality
+
+2. **Use details for technical info**: Put user-friendly messages in `message`, technical details (stack traces, debug info) in `details`
+
+3. **Be concise in messages**: Keep main message short and actionable; use `details` for verbose information
+
+4. **Provide context in titles**: Use specific titles like "Project Load Error" instead of just "Error"
+
+5. **Include actionable information**: Tell users what went wrong AND what they can do about it
+
+**Testing MessageDialog:**
+
+A test/demo script is available:
+
+```bash
+python -m jabs.ui.message_dialog
+```
+
+This opens a window with buttons to demonstrate all dialog types.
+
### Data Flow
1. **Input**: Video files + Pose estimation HDF5 files
diff --git a/src/jabs/resources/fail_whale.png b/src/jabs/resources/fail_whale.png
new file mode 100644
index 00000000..12c69d3f
Binary files /dev/null and b/src/jabs/resources/fail_whale.png differ
diff --git a/src/jabs/ui/__init__.py b/src/jabs/ui/__init__.py
index 0ae80205..3339ac45 100644
--- a/src/jabs/ui/__init__.py
+++ b/src/jabs/ui/__init__.py
@@ -2,4 +2,6 @@
from .main_window import MainWindow
-__all__ = ["MainWindow"]
+__all__ = [
+ "MainWindow",
+]
diff --git a/src/jabs/ui/message_dialog.py b/src/jabs/ui/message_dialog.py
new file mode 100644
index 00000000..4658b99d
--- /dev/null
+++ b/src/jabs/ui/message_dialog.py
@@ -0,0 +1,413 @@
+"""Custom message dialog for displaying errors, warnings, and information."""
+
+from enum import Enum
+from pathlib import Path
+
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QPixmap
+from PySide6.QtWidgets import (
+ QDialog,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QTextEdit,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class MessageType(Enum):
+ """Enum for message dialog types."""
+
+ ERROR = "error"
+ WARNING = "warning"
+ INFO = "info"
+
+
+class MessageDialog(QDialog):
+ """Custom dialog for displaying messages with optional details.
+
+ Supports error, warning, and informational messages. Can display
+ an expandable details section for additional information like stack traces.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ title: str | None = None,
+ details: str | None = None,
+ message_type: MessageType = MessageType.ERROR,
+ parent=None,
+ ):
+ """Initialize the message dialog.
+
+ Args:
+ message: The main message to display
+ title: Dialog window title (default: determined by message_type)
+ details: Optional detailed information (e.g., stack trace) that can be expanded
+ message_type: Type of message (ERROR, WARNING, or INFO)
+ parent: Parent widget
+ """
+ super().__init__(parent)
+
+ # Set default title based on message type if not provided
+ if title is None:
+ match message_type:
+ case MessageType.ERROR:
+ title = "Error"
+ case MessageType.WARNING:
+ title = "Warning"
+ case MessageType.INFO:
+ title = "Information"
+ case _:
+ title = "Message"
+
+ self.setWindowTitle(title)
+ self.setMinimumWidth(500)
+
+ self._message_type = message_type
+ self._details = details
+ self._details_widget = None
+ self._toggle_button = None
+
+ # Main layout
+ main_layout = QVBoxLayout()
+
+ # Top section with icon and message
+ top_layout = QHBoxLayout()
+
+ # Icon
+ icon_label = QLabel()
+ icon_path, icon_tooltip = self._get_icon_path()
+ if icon_path and icon_path.exists():
+ pixmap = QPixmap(str(icon_path))
+ # Scale the icon to a reasonable size
+ scaled_pixmap = pixmap.scaled(
+ 128,
+ 128,
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+ icon_label.setPixmap(scaled_pixmap)
+ # Set tooltip if provided
+ if icon_tooltip:
+ icon_label.setToolTip(icon_tooltip)
+ else:
+ # Fallback to text emoji if no icon available
+ icon_label.setText(self._get_fallback_icon())
+ icon_label.setStyleSheet("font-size: 48pt;")
+
+ top_layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignTop)
+
+ # Message layout
+ message_layout = QVBoxLayout()
+
+ # Title/heading
+ title_label = QLabel(title)
+ title_label.setStyleSheet("font-weight: bold; font-size: 14pt;")
+ message_layout.addWidget(title_label)
+
+ message_layout.addSpacing(10)
+
+ # Main message
+ message_label = QLabel(message)
+ message_label.setWordWrap(True)
+ message_label.setTextFormat(Qt.TextFormat.RichText)
+ message_layout.addWidget(message_label)
+
+ top_layout.addLayout(message_layout, 1) # Stretch to fill available space
+
+ main_layout.addLayout(top_layout)
+
+ # Details section (collapsible)
+ if details:
+ main_layout.addSpacing(10)
+
+ # Toggle button for details
+ self._toggle_button = QPushButton("▶ Show Details")
+ self._toggle_button.setFlat(True)
+ self._toggle_button.setStyleSheet(
+ """
+ QPushButton {
+ text-align: left;
+ padding: 5px;
+ border: none;
+ background: transparent;
+ }
+ QPushButton:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+ """
+ )
+ self._toggle_button.clicked.connect(self._toggle_details)
+ main_layout.addWidget(self._toggle_button)
+
+ # Details widget (initially hidden)
+ self._details_widget = QWidget()
+ details_layout = QVBoxLayout(self._details_widget)
+ details_layout.setContentsMargins(0, 5, 0, 0)
+
+ details_text = QTextEdit()
+ details_text.setReadOnly(True)
+ details_text.setPlainText(details)
+ details_text.setMinimumHeight(150)
+ details_text.setMaximumHeight(300)
+ details_text.setStyleSheet(
+ """
+ QTextEdit {
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 10pt;
+ border: 1px solid palette(mid);
+ border-radius: 4px;
+ }
+ """
+ )
+ details_layout.addWidget(details_text)
+
+ main_layout.addWidget(self._details_widget)
+ self._details_widget.setVisible(False)
+
+ # Bottom buttons
+ button_layout = QHBoxLayout()
+ button_layout.addStretch()
+
+ close_button = QPushButton("Close")
+ close_button.clicked.connect(self.accept)
+ close_button.setDefault(True)
+ button_layout.addWidget(close_button)
+
+ main_layout.addLayout(button_layout)
+
+ self.setLayout(main_layout)
+
+ def _get_icon_path(self) -> tuple[Path | None, str | None]:
+ """Get the path to the icon and optional tooltip based on message type.
+
+ Returns:
+ Tuple of (Path to icon file or None, tooltip text or None)
+ """
+ resources_dir = Path(__file__).parent.parent / "resources"
+
+ # Map message types to (icon_file, tooltip) tuples
+ # tooltip is optional - use None if no tooltip desired
+ # Note: currently WARNING and INFO use fallback emoji, so no image icon specified
+ icon_map = {
+ MessageType.ERROR: ("fail_whale.png", "Fail Whale"),
+ }
+
+ icon_info = icon_map.get(self._message_type)
+ if icon_info:
+ icon_file, tooltip = icon_info
+ icon_path = resources_dir / icon_file
+ if icon_path.exists():
+ return icon_path, tooltip
+
+ return None, None
+
+ def _get_fallback_icon(self) -> str:
+ """Get fallback emoji icon if image not available.
+
+ Returns:
+ Emoji string for the message type
+ """
+ fallback_map = {
+ MessageType.ERROR: "❌",
+ MessageType.WARNING: "⚠️",
+ MessageType.INFO: "ℹ️", # noqa: RUF001
+ }
+ return fallback_map.get(self._message_type, "❌")
+
+ def _toggle_details(self):
+ """Toggle the visibility of the details section."""
+ if self._details_widget is None:
+ return
+
+ is_visible = self._details_widget.isVisible()
+ self._details_widget.setVisible(not is_visible)
+
+ # Update button text
+ if self._toggle_button:
+ if is_visible:
+ self._toggle_button.setText("▶ Show Details")
+ else:
+ self._toggle_button.setText("▼ Hide Details")
+
+ # Adjust dialog size
+ self.adjustSize()
+
+ @classmethod
+ def error(
+ cls,
+ parent,
+ title: str | None = None,
+ message: str = "",
+ details: str | None = None,
+ ) -> int:
+ """Show an error dialog (Qt-style API).
+
+ Args:
+ parent: Parent widget
+ title: Dialog window title (default: "Error")
+ message: The error message to display
+ details: Optional detailed error information (e.g., stack trace)
+
+ Returns:
+ Dialog result code (QDialog.DialogCode.Accepted or Rejected)
+ """
+ dialog = cls(
+ message=message,
+ title=title,
+ details=details,
+ message_type=MessageType.ERROR,
+ parent=parent,
+ )
+ return dialog.exec()
+
+ @classmethod
+ def warning(
+ cls,
+ parent,
+ title: str | None = None,
+ message: str = "",
+ details: str | None = None,
+ ) -> int:
+ """Show a warning dialog (Qt-style API).
+
+ Args:
+ parent: Parent widget
+ title: Dialog window title (default: "Warning")
+ message: The warning message to display
+ details: Optional detailed warning information
+
+ Returns:
+ Dialog result code (QDialog.DialogCode.Accepted or Rejected)
+ """
+ dialog = cls(
+ message=message,
+ title=title,
+ details=details,
+ message_type=MessageType.WARNING,
+ parent=parent,
+ )
+ return dialog.exec()
+
+ @classmethod
+ def information(
+ cls,
+ parent,
+ title: str | None = None,
+ message: str = "",
+ details: str | None = None,
+ ) -> int:
+ """Show an informational dialog (Qt-style API).
+
+ Args:
+ parent: Parent widget
+ title: Dialog window title (default: "Information")
+ message: The information message to display
+ details: Optional detailed information
+
+ Returns:
+ Dialog result code (QDialog.DialogCode.Accepted or Rejected)
+ """
+ dialog = cls(
+ message=message,
+ title=title,
+ details=details,
+ message_type=MessageType.INFO,
+ parent=parent,
+ )
+ return dialog.exec()
+
+
+def main():
+ """Test the MessageDialog functionality.
+
+ Note: imports that are not needed for the module itself are placed here
+ to avoid unnecessary dependencies when the module is imported elsewhere.
+ """
+ import sys
+ import traceback
+
+ from PySide6.QtWidgets import QApplication
+
+ class TestWindow(QWidget):
+ """Test window to demonstrate MessageDialog."""
+
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("MessageDialog Test")
+ self.setMinimumWidth(300)
+
+ layout = QVBoxLayout()
+
+ # Error dialog button
+ error_button = QPushButton("Show Error Dialog")
+ error_button.clicked.connect(self.show_error)
+ layout.addWidget(error_button)
+
+ # Error with details button
+ error_details_button = QPushButton("Show Error with Details")
+ error_details_button.clicked.connect(self.show_error_with_details)
+ layout.addWidget(error_details_button)
+
+ # Warning dialog button
+ warning_button = QPushButton("Show Warning Dialog")
+ warning_button.clicked.connect(self.show_warning)
+ layout.addWidget(warning_button)
+
+ # Info dialog button
+ info_button = QPushButton("Show Info Dialog")
+ info_button.clicked.connect(self.show_info)
+ layout.addWidget(info_button)
+
+ self.setLayout(layout)
+
+ def show_error(self):
+ """Show a simple error dialog."""
+ MessageDialog.error(
+ self,
+ title="Processing Error",
+ message="An error occurred while processing your request.",
+ )
+
+ def show_error_with_details(self):
+ """Show an error dialog with expandable details."""
+ try:
+ # Simulate an error
+ raise ValueError("Invalid value provided")
+ except ValueError:
+ # Capture the stack trace
+ stack_trace = traceback.format_exc()
+
+ MessageDialog.error(
+ self,
+ title="Project Load Error",
+ message="Failed to load the project file. The file may be corrupted or in an unsupported format.",
+ details=stack_trace,
+ )
+
+ def show_warning(self):
+ """Show a warning dialog."""
+ MessageDialog.warning(
+ self,
+ title="New Window Size",
+ message="The selected window size has not been used before. Features will be computed on the first training run, which may take some time.",
+ )
+
+ def show_info(self):
+ """Show an info dialog."""
+ MessageDialog.information(
+ self,
+ title="Project Loaded",
+ message="JABS has successfully loaded your project. You can now begin labeling behaviors or train classifiers.",
+ )
+
+ app = QApplication(sys.argv)
+ window = TestWindow()
+ window.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()