diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ec78b81
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,34 @@
+# Aether Project Rules for Claude Code
+
+You are working on **Project Aether** — a beautiful, fast, native open-source Qt 6 client for the self-hosted Stoat backend.
+
+**Non-negotiable rules (always follow):**
+
+- Every source file (.cpp, .qml, CMakeLists.txt) must start with the exact AGPL-3.0 SPDX header:
+ ```qml
+ // SPDX-License-Identifier: AGPL-3.0-or-later
+ // Copyright (C) 2026 Aether Contributors
+ //
+ // This file is part of Aether.
+ //
+ // Aether is free software: you can redistribute it and/or modify it
+ // under the terms of the GNU Affero General Public License as published
+ // by the Free Software Foundation, either version 3 of the License, or
+ // (at your option) any later version.
+
+Keep the modular QML structure: separate files in src/qml/components/, src/qml/styles/, src/qml/utils/.
+Every QML file must import:
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+ Theme and Constants are singletons — always use Theme.xxx and Constants.xxx. Never use hard-coded colors or sizes.
+ Never use letterSpacing — use font.letterSpacing.
+ Never use modelData directly in Repeater — always use model.modelData or named roles.
+ When fixing errors, change only the necessary lines. Do not rewrite whole files unless asked.
+ Keep code clean, well-commented, and easily changeable later.
+
+This is the permanent rule set for Aether. Follow it strictly in every response.
\ No newline at end of file
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 159b0cd..15d92c3 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -20,11 +20,33 @@ qt_add_executable(aether
main.cpp
)
+# Mark singletons BEFORE qt_add_qml_module so the generated qmldir
+# contains the required "singleton" keyword for each type.
+set_source_files_properties(
+ qml/styles/Theme.qml
+ PROPERTIES QT_QML_SINGLETON_TYPE TRUE
+)
+
+set_source_files_properties(
+ qml/utils/Constants.qml
+ PROPERTIES QT_QML_SINGLETON_TYPE TRUE
+)
+
qt_add_qml_module(aether
URI Aether
VERSION 1.0
QML_FILES
qml/main.qml
+ qml/MainWindow.qml
+ qml/components/GuildList.qml
+ qml/components/ChannelList.qml
+ qml/components/ChatArea.qml
+ qml/components/MessageList.qml
+ qml/components/MessageInput.qml
+ qml/components/MemberList.qml
+ qml/components/VoiceBar.qml
+ qml/styles/Theme.qml
+ qml/utils/Constants.qml
)
target_link_libraries(aether PRIVATE
diff --git a/src/qml/MainWindow.qml b/src/qml/MainWindow.qml
new file mode 100644
index 0000000..40098c8
--- /dev/null
+++ b/src/qml/MainWindow.qml
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Root application window.
+/// Layout: [GuildList] | [ChannelList + VoiceBar] | SplitView([ChatArea] | [MemberList])
+ApplicationWindow {
+ id: root
+
+ title: Constants.appName
+ width: Constants.minWindowWidth
+ height: Constants.minWindowHeight
+ minimumWidth: Constants.minWindowWidth
+ minimumHeight: Constants.minWindowHeight
+ visible: true
+
+ Material.theme: Material.Dark
+ Material.accent: Material.DeepPurple
+ Material.background: Theme.bgBase
+
+ background: Rectangle { color: Theme.bgBase }
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ // ── 1. Guild strip (fixed width, not resizable) ───────────────────────
+ GuildList {
+ Layout.preferredWidth: Constants.guildListWidth
+ Layout.fillHeight: true
+ }
+
+ // ── 2. Channel list + voice bar (fixed width) ─────────────────────────
+ ColumnLayout {
+ Layout.preferredWidth: Constants.channelListWidth
+ Layout.fillHeight: true
+ spacing: 0
+
+ ChannelList {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+
+ VoiceBar {
+ Layout.fillWidth: true
+ Layout.preferredHeight: Constants.voiceBarHeight
+ }
+ }
+
+ // ── 3. Resizable split: chat area + member list ───────────────────────
+ SplitView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ orientation: Qt.Horizontal
+
+ // Minimal 1 px handle that brightens on hover
+ handle: Rectangle {
+ implicitWidth: 1
+ color: SplitHandle.hovered ? Theme.accent : Theme.separator
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ ChatArea {
+ SplitView.fillWidth: true
+ SplitView.minimumWidth: 400
+ }
+
+ MemberList {
+ SplitView.preferredWidth: Constants.memberListWidth
+ SplitView.minimumWidth: 160
+ }
+ }
+ }
+}
diff --git a/src/qml/components/ChannelList.qml b/src/qml/components/ChannelList.qml
new file mode 100644
index 0000000..1025f1c
--- /dev/null
+++ b/src/qml/components/ChannelList.qml
@@ -0,0 +1,298 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Channel list panel: server name header + collapsible Text / Voice sections.
+Rectangle {
+ id: root
+ color: Theme.bgMantle
+
+ property int selectedChannel: 0
+ property string serverName: "Stoat HQ"
+
+ // ── Placeholder channel data ──────────────────────────────────────────────
+ // ListModel avoids JS-array modelData scoping issues; channelId used
+ // instead of id (id is a reserved word in QML ListElement context).
+ ListModel {
+ id: textChannelModel
+ ListElement { channelId: 0; name: "general"; icon: "#"; hasUnread: false }
+ ListElement { channelId: 1; name: "random"; icon: "#"; hasUnread: true }
+ ListElement { channelId: 2; name: "announcements"; icon: "#"; hasUnread: false }
+ ListElement { channelId: 3; name: "dev-talk"; icon: "#"; hasUnread: false }
+ }
+ ListModel {
+ id: voiceChannelModel
+ ListElement { channelId: 10; name: "General"; icon: "🔊"; members: 3 }
+ ListElement { channelId: 11; name: "Gaming"; icon: "🔊"; members: 0 }
+ ListElement { channelId: 12; name: "Music"; icon: "🔊"; members: 1 }
+ }
+
+ property bool textSectionOpen: true
+ property bool voiceSectionOpen: true
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ // ── Server name header ────────────────────────────────────────────────
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: Constants.headerHeight
+ color: Theme.bgMantle
+
+ // Subtle bottom shadow line
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 1
+ color: Theme.separator
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp16
+ anchors.rightMargin: Theme.sp12
+ spacing: 0
+
+ Text {
+ Layout.fillWidth: true
+ text: root.serverName
+ font.pixelSize: Theme.font16
+ font.weight: Font.DemiBold
+ color: Theme.textPrimary
+ elide: Text.ElideRight
+ }
+
+ // Settings / chevron icon placeholder
+ Text {
+ text: "⌄"
+ font.pixelSize: Theme.font16
+ color: Theme.textMuted
+ }
+ }
+ }
+
+ // ── Scrollable channel sections ───────────────────────────────────────
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+
+ ColumnLayout {
+ width: root.width
+ spacing: 0
+
+ Item { height: Theme.sp8; width: 1 }
+
+ // ── TEXT CHANNELS section ─────────────────────────────────────
+ SectionHeader {
+ width: root.width
+ label: "TEXT CHANNELS"
+ isOpen: root.textSectionOpen
+ onToggle: root.textSectionOpen = !root.textSectionOpen
+ }
+
+ Column {
+ width: root.width
+ visible: root.textSectionOpen
+ spacing: 0
+
+ Repeater {
+ model: textChannelModel
+ delegate: Item {
+ // Capture model roles here — plain Item delegates
+ // have full model context; inline components do not.
+ readonly property int _cid: model.channelId
+ readonly property string _name: model.name
+ readonly property string _icon: model.icon
+ readonly property bool _hasUnread: model.hasUnread
+
+ width: root.width
+ height: 34
+
+ ChannelItem {
+ width: root.width
+ channelId: _cid
+ name: _name
+ icon: _icon
+ hasUnread: _hasUnread
+ isSelected: root.selectedChannel === _cid
+ onClicked: root.selectedChannel = _cid
+ }
+ }
+ }
+ }
+
+ Item { height: Theme.sp8; width: 1 }
+
+ // ── VOICE CHANNELS section ────────────────────────────────────
+ SectionHeader {
+ width: root.width
+ label: "VOICE CHANNELS"
+ isOpen: root.voiceSectionOpen
+ onToggle: root.voiceSectionOpen = !root.voiceSectionOpen
+ }
+
+ Column {
+ width: root.width
+ visible: root.voiceSectionOpen
+ spacing: 0
+
+ Repeater {
+ model: voiceChannelModel
+ delegate: Item {
+ readonly property int _cid: model.channelId
+ readonly property string _name: model.name
+ readonly property string _icon: model.icon
+
+ width: root.width
+ height: 34
+
+ ChannelItem {
+ width: root.width
+ channelId: _cid
+ name: _name
+ icon: _icon
+ hasUnread: false
+ isSelected: root.selectedChannel === _cid
+ onClicked: root.selectedChannel = _cid
+ }
+ }
+ }
+ }
+
+ Item { height: Theme.sp24; width: 1 }
+ }
+ }
+ }
+
+ // ── Inline sub-components ─────────────────────────────────────────────────
+
+ // Section collapsible header
+ component SectionHeader: Item {
+ height: 32
+
+ required property string label
+ required property bool isOpen
+ signal toggle
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp8
+ anchors.rightMargin: Theme.sp12
+ spacing: Theme.sp4
+
+ // Chevron
+ Text {
+ text: isOpen ? "▾" : "▸"
+ font.pixelSize: Theme.font10
+ color: Theme.textMuted
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: label
+ font.pixelSize: Theme.font11
+ font.weight: Font.DemiBold
+ color: Theme.textMuted
+ font.letterSpacing: 0.8
+ }
+
+ // Add-channel icon placeholder
+ Text {
+ text: "+"
+ font.pixelSize: Theme.font16
+ color: Theme.textMuted
+ opacity: sectionHover.hovered ? 1.0 : 0.0
+ Behavior on opacity { NumberAnimation { duration: Theme.animFast } }
+ }
+ }
+
+ HoverHandler { id: sectionHover }
+ TapHandler { onTapped: parent.toggle() }
+ }
+
+ // Individual channel row
+ component ChannelItem: Item {
+ id: chItem
+ height: 34
+
+ required property int channelId
+ required property string name
+ required property string icon
+ required property bool hasUnread
+ required property bool isSelected
+ signal clicked
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp8
+ anchors.rightMargin: Theme.sp8
+ radius: Theme.radius4
+ color: chItem.isSelected ? Theme.accentSubtle
+ : chHover.hovered ? Theme.bgHover
+ : "transparent"
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp16
+ anchors.rightMargin: Theme.sp12
+ spacing: Theme.sp6
+
+ Text {
+ text: chItem.icon
+ font.pixelSize: Theme.font14
+ color: chItem.hasUnread ? Theme.textPrimary : Theme.textMuted
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: chItem.name
+ font.pixelSize: Theme.font14
+ font.weight: chItem.hasUnread ? Font.DemiBold : Font.Normal
+ color: chItem.isSelected ? Theme.textPrimary
+ : chItem.hasUnread ? Theme.textPrimary
+ : chHover.hovered ? Theme.textSecondary
+ : Theme.textMuted
+ elide: Text.ElideRight
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ // Unread dot
+ Rectangle {
+ width: 6
+ height: 6
+ radius: 3
+ color: Theme.textPrimary
+ visible: chItem.hasUnread && !chItem.isSelected
+ }
+ }
+
+ HoverHandler { id: chHover }
+ TapHandler { onTapped: chItem.clicked() }
+ }
+}
diff --git a/src/qml/components/ChatArea.qml b/src/qml/components/ChatArea.qml
new file mode 100644
index 0000000..086c4e9
--- /dev/null
+++ b/src/qml/components/ChatArea.qml
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Main chat panel: header + message list + message input.
+Rectangle {
+ id: root
+ color: Theme.bgSurface
+
+ property string channelName: "general"
+ property string channelTopic: "General discussion about Aether and Stoat"
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ // ── Channel header ────────────────────────────────────────────────────
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: Constants.headerHeight
+ color: Theme.bgSurface
+ z: 1 // Render above message list so shadow shows
+
+ // Bottom shadow line
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 1
+ color: Theme.separator
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp16
+ anchors.rightMargin: Theme.sp16
+ spacing: Theme.sp12
+
+ // Channel icon + name
+ Text {
+ text: "#"
+ font.pixelSize: Theme.font20
+ font.weight: Font.Light
+ color: Theme.textMuted
+ }
+
+ Text {
+ text: root.channelName
+ font.pixelSize: Theme.font16
+ font.weight: Font.DemiBold
+ color: Theme.textPrimary
+ }
+
+ // Separator
+ Rectangle {
+ width: 1
+ height: Theme.font16
+ color: Theme.border
+ }
+
+ // Topic
+ Text {
+ Layout.fillWidth: true
+ text: root.channelTopic
+ font.pixelSize: Theme.font13
+ color: Theme.textMuted
+ elide: Text.ElideRight
+ }
+
+ // Header action icons
+ HeaderButton { icon: "🔍"; tooltip: "Search" }
+ HeaderButton { icon: "📌"; tooltip: "Pinned Messages" }
+ HeaderButton { icon: "👥"; tooltip: "Member List" }
+ }
+ }
+
+ // ── Message list ──────────────────────────────────────────────────────
+ MessageList {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+
+ // ── Message input ─────────────────────────────────────────────────────
+ MessageInput {
+ Layout.fillWidth: true
+ Layout.preferredHeight: Constants.inputAreaHeight
+ channelName: root.channelName
+ }
+ }
+
+ // ── Inline header button component ────────────────────────────────────────
+ component HeaderButton: Item {
+ id: hBtn
+ width: 32
+ height: 32
+
+ property string icon
+ property string tooltip
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.radius4
+ color: hBtnHover.hovered ? Theme.bgHover : "transparent"
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: hBtn.icon
+ font.pixelSize: Theme.font16
+ color: hBtnHover.hovered ? Theme.textSecondary : Theme.textMuted
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ HoverHandler { id: hBtnHover }
+
+ ToolTip.visible: hBtnHover.hovered
+ ToolTip.text: hBtn.tooltip
+ ToolTip.delay: 400
+ }
+}
diff --git a/src/qml/components/GuildList.qml b/src/qml/components/GuildList.qml
new file mode 100644
index 0000000..69c829d
--- /dev/null
+++ b/src/qml/components/GuildList.qml
@@ -0,0 +1,171 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Vertical strip of guild/server icons on the far left.
+/// Each guild shows a circular icon with the guild initial letter.
+/// Hovering reveals a pill indicator; the selected guild shows a full pill.
+Rectangle {
+ id: root
+ color: Theme.bgBase
+
+ property int selectedIndex: 0
+
+ // ── Placeholder guild data ────────────────────────────────────────────────
+ ListModel {
+ id: guildsModel
+ ListElement { guildName: "Home"; letter: "H"; iconColor: "#7c6af7" }
+ ListElement { guildName: "Stoat HQ"; letter: "S"; iconColor: "#a6e3a1" }
+ ListElement { guildName: "Gaming"; letter: "G"; iconColor: "#f38ba8" }
+ ListElement { guildName: "Design"; letter: "D"; iconColor: "#fab387" }
+ ListElement { guildName: "Dev Talk"; letter: "V"; iconColor: "#89dceb" }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ // Spacer at top
+ Item { Layout.preferredHeight: Theme.sp8 }
+
+ // Home button separator after first item is handled visually via spacing
+
+ // Guild list
+ ListView {
+ id: listView
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ model: guildsModel
+ clip: true
+ spacing: Theme.sp4
+ ScrollBar.vertical: ScrollBar { policy: ScrollBar.AlwaysOff }
+
+ delegate: Item {
+ id: guildItem
+ width: root.width
+ height: 64
+
+ readonly property bool isSelected: root.selectedIndex === model.index
+ readonly property bool isHovered: hoverHandler.hovered
+
+ // ── Selection pill ────────────────────────────────────────────
+ Rectangle {
+ id: pill
+ width: 4
+ height: guildItem.isSelected ? 40
+ : guildItem.isHovered ? 20
+ : 0
+ radius: 2
+ color: Theme.textPrimary
+ anchors.verticalCenter: parent.verticalCenter
+ x: 0
+
+ Behavior on height {
+ NumberAnimation { duration: Theme.animNormal; easing.type: Easing.OutCubic }
+ }
+ }
+
+ // ── Guild icon ────────────────────────────────────────────────
+ Rectangle {
+ id: iconRect
+ width: Constants.guildIconSize
+ height: Constants.guildIconSize
+ radius: guildItem.isSelected || guildItem.isHovered
+ ? Theme.radius16 : Constants.guildIconSize / 2
+ color: model.iconColor
+ anchors.centerIn: parent
+
+ Behavior on radius {
+ NumberAnimation { duration: Theme.animNormal; easing.type: Easing.OutCubic }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: model.letter
+ font.pixelSize: Theme.font18
+ font.weight: Font.Bold
+ color: "#ffffff"
+ }
+ }
+
+ HoverHandler { id: hoverHandler }
+ TapHandler { onTapped: root.selectedIndex = model.index }
+
+ ToolTip.visible: hoverHandler.hovered
+ ToolTip.text: model.guildName
+ ToolTip.delay: 500
+ }
+
+ // Separator after Home (index 0)
+ footer: Item {
+ width: root.width
+ height: Theme.sp8
+
+ Rectangle {
+ width: root.width * 0.6
+ height: 2
+ radius: 1
+ color: Theme.border
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: Theme.sp4
+ }
+ }
+ }
+
+ // ── Add server button ─────────────────────────────────────────────────
+ Item {
+ Layout.preferredWidth: root.width
+ Layout.preferredHeight: 64
+
+ Rectangle {
+ id: addBtn
+ width: Constants.guildIconSize
+ height: Constants.guildIconSize
+ radius: addHover.hovered ? Theme.radius16 : Constants.guildIconSize / 2
+ color: addHover.hovered ? Theme.accent : Theme.bgMantle
+ anchors.centerIn: parent
+
+ Behavior on radius { NumberAnimation { duration: Theme.animNormal; easing.type: Easing.OutCubic } }
+ Behavior on color { ColorAnimation { duration: Theme.animNormal } }
+
+ Text {
+ anchors.centerIn: parent
+ text: "+"
+ font.pixelSize: Theme.font22
+ font.weight: Font.Light
+ color: addHover.hovered ? "#ffffff" : Theme.accent
+ Behavior on color { ColorAnimation { duration: Theme.animNormal } }
+ }
+ }
+
+ HoverHandler { id: addHover }
+
+ ToolTip.visible: addHover.hovered
+ ToolTip.text: "Add a Server"
+ ToolTip.delay: 500
+ }
+
+ Item { Layout.preferredHeight: Theme.sp8 }
+ }
+}
diff --git a/src/qml/components/MemberList.qml b/src/qml/components/MemberList.qml
new file mode 100644
index 0000000..c5f4963
--- /dev/null
+++ b/src/qml/components/MemberList.qml
@@ -0,0 +1,179 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Right sidebar listing online/offline members.
+Rectangle {
+ id: root
+ color: Theme.bgMantle
+
+ // ── Placeholder member data ───────────────────────────────────────────────
+ ListModel {
+ id: membersModel
+
+ // ONLINE
+ ListElement { username: "alice"; status: "online"; avatarColor: "#7c6af7"; role: "ONLINE" }
+ ListElement { username: "bob"; status: "idle"; avatarColor: "#a6e3a1"; role: "ONLINE" }
+ ListElement { username: "charlie"; status: "dnd"; avatarColor: "#f38ba8"; role: "ONLINE" }
+ ListElement { username: "diana"; status: "online"; avatarColor: "#fab387"; role: "ONLINE" }
+ ListElement { username: "eve"; status: "online"; avatarColor: "#89dceb"; role: "ONLINE" }
+ // OFFLINE
+ ListElement { username: "frank"; status: "offline"; avatarColor: "#585b70"; role: "OFFLINE" }
+ ListElement { username: "grace"; status: "offline"; avatarColor: "#585b70"; role: "OFFLINE" }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ // Header
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: Constants.headerHeight
+ color: Theme.bgMantle
+
+ Rectangle {
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 1
+ color: Theme.separator
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.sp16
+ text: "MEMBERS"
+ font.pixelSize: Theme.font11
+ font.weight: Font.DemiBold
+ color: Theme.textMuted
+ font.letterSpacing: 0.8
+ }
+ }
+
+ // Member list
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+
+ ListView {
+ id: memberListView
+ model: membersModel
+ topMargin: Theme.sp8
+
+ section.property: "role"
+ section.delegate: Item {
+ width: root.width
+ height: 28
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.sp16
+ text: section
+ font.pixelSize: Theme.font11
+ font.weight: Font.DemiBold
+ color: Theme.textMuted
+ font.letterSpacing: 0.8
+ }
+ }
+
+ delegate: Item {
+ id: memberItem
+ width: root.width
+ height: 44
+
+ readonly property color statusColor:
+ model.status === "online" ? Theme.statusOnline :
+ model.status === "idle" ? Theme.statusIdle :
+ model.status === "dnd" ? Theme.statusDnd :
+ Theme.statusOffline
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp8
+ anchors.rightMargin: Theme.sp8
+ radius: Theme.radius4
+ color: memberHover.hovered ? Theme.bgHover : "transparent"
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp12
+ anchors.rightMargin: Theme.sp12
+ spacing: Theme.sp12
+
+ // Avatar + status dot
+ Item {
+ width: Constants.avatarSm
+ height: Constants.avatarSm
+
+ Rectangle {
+ width: Constants.avatarSm
+ height: Constants.avatarSm
+ radius: Constants.avatarSm / 2
+ color: model.avatarColor
+
+ Text {
+ anchors.centerIn: parent
+ text: model.username.charAt(0).toUpperCase()
+ font.pixelSize: Theme.font13
+ font.weight: Font.Bold
+ color: "#ffffff"
+ }
+ }
+
+ // Status dot
+ Rectangle {
+ width: 10
+ height: 10
+ radius: 5
+ color: memberItem.statusColor
+ border.color: Theme.bgMantle
+ border.width: 2
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: model.username
+ font.pixelSize: Theme.font14
+ color: memberHover.hovered
+ ? Theme.textPrimary : Theme.textSecondary
+ elide: Text.ElideRight
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+ }
+
+ HoverHandler { id: memberHover }
+ }
+ }
+ }
+ }
+}
diff --git a/src/qml/components/MessageInput.qml b/src/qml/components/MessageInput.qml
new file mode 100644
index 0000000..b343f53
--- /dev/null
+++ b/src/qml/components/MessageInput.qml
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Message composition bar at the bottom of the chat area.
+Item {
+ id: root
+ height: Constants.inputAreaHeight
+
+ property string channelName: "general"
+ signal messageSent(string text)
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp16
+ anchors.rightMargin: Theme.sp16
+ anchors.topMargin: Theme.sp12
+ anchors.bottomMargin: Theme.sp12
+ radius: Theme.radius8
+ color: Theme.bgOverlay
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp4
+ anchors.rightMargin: Theme.sp4
+ spacing: 0
+
+ // ── Attachment / add button ───────────────────────────────────────
+ IconButton {
+ text: "+"
+ tooltip: "Upload File"
+ }
+
+ // ── Text input ────────────────────────────────────────────────────
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+
+ TextArea {
+ id: textInput
+ placeholderText: "Message #" + root.channelName
+ placeholderTextColor: Theme.textMuted
+ color: Theme.textPrimary
+ font.pixelSize: Theme.font14
+ background: null
+ wrapMode: TextInput.Wrap
+ verticalAlignment: TextEdit.AlignVCenter
+ leftPadding: Theme.sp8
+ rightPadding: Theme.sp8
+ topPadding: Theme.sp12
+ bottomPadding: Theme.sp12
+
+ Keys.onReturnPressed: (event) => {
+ if (!(event.modifiers & Qt.ShiftModifier) && text.trim() !== "") {
+ root.messageSent(text.trim())
+ text = ""
+ event.accepted = true
+ }
+ }
+ }
+ }
+
+ // ── Emoji button ──────────────────────────────────────────────────
+ IconButton {
+ text: "🙂"
+ tooltip: "Emoji"
+ }
+
+ // ── Send button ───────────────────────────────────────────────────
+ IconButton {
+ text: "➤"
+ tooltip: "Send Message"
+ enabled: textInput.text.trim() !== ""
+ opacity: enabled ? 1.0 : 0.4
+ Behavior on opacity { NumberAnimation { duration: Theme.animFast } }
+ onClicked: {
+ if (textInput.text.trim() !== "") {
+ root.messageSent(textInput.text.trim())
+ textInput.text = ""
+ }
+ }
+ }
+ }
+ }
+
+ // ── Inline icon button component ──────────────────────────────────────────
+ component IconButton: Item {
+ id: iconBtn
+ width: 40
+ height: 40
+
+ property string text
+ property string tooltip
+ signal clicked
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: Theme.sp4
+ radius: Theme.radius4
+ color: btnHover.hovered ? Theme.bgHover : "transparent"
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: iconBtn.text
+ font.pixelSize: Theme.font16
+ color: btnHover.hovered ? Theme.textSecondary : Theme.textMuted
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ HoverHandler { id: btnHover }
+ TapHandler { onTapped: iconBtn.clicked() }
+
+ ToolTip.visible: btnHover.hovered
+ ToolTip.text: iconBtn.tooltip
+ ToolTip.delay: 400
+ }
+}
diff --git a/src/qml/components/MessageList.qml b/src/qml/components/MessageList.qml
new file mode 100644
index 0000000..7c758f8
--- /dev/null
+++ b/src/qml/components/MessageList.qml
@@ -0,0 +1,193 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Scrollable list of chat messages.
+/// Groups consecutive messages from the same author (compact display).
+Item {
+ id: root
+
+ // ── Placeholder message data ──────────────────────────────────────────────
+ ListModel {
+ id: messagesModel
+ ListElement {
+ author: "alice"
+ avatarColor: "#7c6af7"
+ content: "Hey everyone! Welcome to the Aether client. 🎉"
+ timestamp: "Today at 09:00"
+ isFirst: true
+ }
+ ListElement {
+ author: "bob"
+ avatarColor: "#a6e3a1"
+ content: "This looks amazing! Really clean interface."
+ timestamp: "Today at 09:02"
+ isFirst: true
+ }
+ ListElement {
+ author: "bob"
+ avatarColor: "#a6e3a1"
+ content: "Can't wait to connect it to a real Stoat server."
+ timestamp: "Today at 09:02"
+ isFirst: false
+ }
+ ListElement {
+ author: "charlie"
+ avatarColor: "#f38ba8"
+ content: "The dark theme is 🔥. Love the purple accent."
+ timestamp: "Today at 09:05"
+ isFirst: true
+ }
+ ListElement {
+ author: "alice"
+ avatarColor: "#7c6af7"
+ content: "Thanks! Built with Qt 6 + QML + Material. Fully native."
+ timestamp: "Today at 09:07"
+ isFirst: true
+ }
+ ListElement {
+ author: "alice"
+ avatarColor: "#7c6af7"
+ content: "AGPL-3.0 licensed, open source, and self-hostable."
+ timestamp: "Today at 09:07"
+ isFirst: false
+ }
+ ListElement {
+ author: "diana"
+ avatarColor: "#fab387"
+ content: "This is exactly what the self-hosting community needed. 💜"
+ timestamp: "Today at 09:10"
+ isFirst: true
+ }
+ }
+
+ ListView {
+ id: listView
+ anchors.fill: parent
+ model: messagesModel
+ clip: true
+ topMargin: Theme.sp16
+ bottomMargin: Theme.sp8
+
+ // Auto-scroll to bottom when new messages arrive
+ onCountChanged: Qt.callLater(() => listView.positionViewAtEnd())
+
+ ScrollBar.vertical: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ }
+
+ delegate: Item {
+ id: msgItem
+ width: listView.width
+ height: msgLayout.implicitHeight + (model.isFirst ? Theme.sp16 : Theme.sp2)
+
+ readonly property bool isHovered: msgHover.hovered
+
+ // Hover highlight
+ Rectangle {
+ anchors.fill: parent
+ color: msgItem.isHovered ? Theme.bgHover : "transparent"
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+ }
+
+ RowLayout {
+ id: msgLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.topMargin: model.isFirst ? Theme.sp16 : Theme.sp2
+ anchors.leftMargin: Theme.sp16
+ anchors.rightMargin: Theme.sp16
+ spacing: Theme.sp12
+
+ // ── Avatar (only on first message in a group) ─────────────────
+ Item {
+ width: Constants.avatarMd
+ height: Constants.avatarMd
+ Layout.alignment: Qt.AlignTop
+
+ Rectangle {
+ anchors.fill: parent
+ radius: Constants.avatarMd / 2
+ color: model.avatarColor
+ visible: model.isFirst
+
+ Text {
+ anchors.centerIn: parent
+ text: model.author.charAt(0).toUpperCase()
+ font.pixelSize: Theme.font16
+ font.weight: Font.Bold
+ color: "#ffffff"
+ }
+ }
+
+ // Hover timestamp for compact messages (no avatar)
+ Text {
+ anchors.centerIn: parent
+ text: model.timestamp.split(" ").slice(-1)[0] // HH:MM
+ font.pixelSize: Theme.font10
+ color: Theme.textMuted
+ visible: !model.isFirst && msgItem.isHovered
+ }
+ }
+
+ // ── Message body ──────────────────────────────────────────────
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Theme.sp2
+
+ // Author + timestamp (first message only)
+ RowLayout {
+ visible: model.isFirst
+ spacing: Theme.sp8
+
+ Text {
+ text: model.author
+ font.pixelSize: Theme.font14
+ font.weight: Font.DemiBold
+ color: Theme.textPrimary
+ }
+
+ Text {
+ text: model.timestamp
+ font.pixelSize: Theme.font11
+ color: Theme.textMuted
+ }
+ }
+
+ // Message content
+ Text {
+ Layout.fillWidth: true
+ text: model.content
+ font.pixelSize: Theme.font14
+ color: Theme.textSecondary
+ wrapMode: Text.Wrap
+ lineHeight: 1.375
+ }
+ }
+ }
+
+ HoverHandler { id: msgHover }
+ }
+ }
+}
diff --git a/src/qml/components/VoiceBar.qml b/src/qml/components/VoiceBar.qml
new file mode 100644
index 0000000..eb95d1b
--- /dev/null
+++ b/src/qml/components/VoiceBar.qml
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
+
+/// Bottom bar of the channel list column.
+/// Shows current voice connection status and quick-action buttons.
+Rectangle {
+ id: root
+ color: Theme.bgBase
+
+ // ── State ─────────────────────────────────────────────────────────────────
+ property bool inVoice: true
+ property bool muted: false
+ property bool deafened: false
+ property string channelName: "General"
+ property string username: "me"
+
+ // Top separator
+ Rectangle {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 1
+ color: Theme.separator
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Theme.sp8
+ anchors.rightMargin: Theme.sp8
+ spacing: Theme.sp4
+
+ // ── User avatar + name + status ───────────────────────────────────────
+ Rectangle {
+ width: 32
+ height: 32
+ radius: 16
+ color: Theme.accent
+
+ Text {
+ anchors.centerIn: parent
+ text: root.username.charAt(0).toUpperCase()
+ font.pixelSize: Theme.font13
+ font.weight: Font.Bold
+ color: "#ffffff"
+ }
+
+ // Online status dot
+ Rectangle {
+ width: 10
+ height: 10
+ radius: 5
+ color: Theme.statusOnline
+ border.color: Theme.bgBase
+ border.width: 2
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ }
+ }
+
+ // Name + voice channel label
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ Text {
+ text: root.username
+ font.pixelSize: Theme.font13
+ font.weight: Font.DemiBold
+ color: Theme.textPrimary
+ elide: Text.ElideRight
+ }
+
+ Text {
+ visible: root.inVoice
+ text: "🔊 " + root.channelName
+ font.pixelSize: Theme.font11
+ color: Theme.statusOnline
+ elide: Text.ElideRight
+ }
+ }
+
+ // ── Mute button ───────────────────────────────────────────────────────
+ VoiceButton {
+ icon: root.muted ? "🚫" : "🎤"
+ active: !root.muted
+ tooltip: root.muted ? "Unmute" : "Mute"
+ onClicked: root.muted = !root.muted
+ }
+
+ // ── Deafen button ─────────────────────────────────────────────────────
+ VoiceButton {
+ icon: root.deafened ? "🔕" : "🎧"
+ active: !root.deafened
+ tooltip: root.deafened ? "Undeafen" : "Deafen"
+ onClicked: root.deafened = !root.deafened
+ }
+
+ // ── Settings button ───────────────────────────────────────────────────
+ VoiceButton {
+ icon: "⚙"
+ active: true
+ tooltip: "User Settings"
+ }
+ }
+
+ // ── Inline button component ───────────────────────────────────────────────
+ component VoiceButton: Rectangle {
+ id: vBtn
+ width: 28
+ height: 28
+ radius: Theme.radius4
+
+ required property string icon
+ required property bool active
+ required property string tooltip
+
+ signal clicked
+
+ color: btnHover.hovered ? Theme.bgHover : "transparent"
+ Behavior on color { ColorAnimation { duration: Theme.animFast } }
+
+ Text {
+ anchors.centerIn: parent
+ text: vBtn.icon
+ font.pixelSize: Theme.font14
+ color: vBtn.active ? Theme.textSecondary : Theme.statusDnd
+ }
+
+ HoverHandler { id: btnHover }
+ TapHandler { onTapped: vBtn.clicked() }
+
+ ToolTip.visible: btnHover.hovered
+ ToolTip.text: vBtn.tooltip
+ ToolTip.delay: 400
+ }
+}
diff --git a/src/qml/main.qml b/src/qml/main.qml
index da7da54..4c38bcc 100644
--- a/src/qml/main.qml
+++ b/src/qml/main.qml
@@ -16,27 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with Aether. If not, see .
-import QtQuick
-import QtQuick.Controls.Material
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
+import QtQuick.Layouts 1.15
+import Aether 1.0
-ApplicationWindow {
- id: root
-
- title: qsTr("Aether")
- width: 1280
- height: 800
- minimumWidth: 1280
- minimumHeight: 800
- visible: true
-
- Material.theme: Material.Dark
- Material.accent: Material.DeepPurple
-
- Text {
- anchors.centerIn: parent
- text: qsTr("Aether is loading…")
- color: "#ffffff"
- font.pixelSize: 24
- font.family: "Segoe UI, SF Pro Display, Inter, sans-serif"
- }
-}
+// Entry point — instantiates the application's main window.
+MainWindow {}
diff --git a/src/qml/styles/Theme.qml b/src/qml/styles/Theme.qml
new file mode 100644
index 0000000..0b31b5e
--- /dev/null
+++ b/src/qml/styles/Theme.qml
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+pragma Singleton
+import QtQuick 2.15
+
+/// Central design-token store for the Aether UI.
+/// Usage: Theme.bgBase, Theme.accent, Theme.font14, etc.
+QtObject {
+
+ // ── Backgrounds ───────────────────────────────────────────────────────────
+ readonly property color bgBase: "#181825" // Guild strip (darkest)
+ readonly property color bgMantle: "#1e1e2e" // Channel list / panels
+ readonly property color bgSurface: "#313244" // Chat area
+ readonly property color bgOverlay: "#45475a" // Input fields, elevated cards
+ readonly property color bgHover: "#2d2d44" // Generic hover
+
+ // ── Accent (deep purple) ──────────────────────────────────────────────────
+ readonly property color accent: "#7c6af7"
+ readonly property color accentHover: "#9d8fff"
+ readonly property color accentPressed: "#6355d6"
+ readonly property color accentSubtle: "#33285c" // Selected row tint
+
+ // ── Text ──────────────────────────────────────────────────────────────────
+ readonly property color textPrimary: "#cdd6f4"
+ readonly property color textSecondary: "#a6adc8"
+ readonly property color textMuted: "#6c7086"
+ readonly property color textHeader: "#bac2de"
+ readonly property color textLink: "#89b4fa"
+
+ // ── Status ────────────────────────────────────────────────────────────────
+ readonly property color statusOnline: "#a6e3a1"
+ readonly property color statusIdle: "#f9e2af"
+ readonly property color statusDnd: "#f38ba8"
+ readonly property color statusOffline: "#585b70"
+
+ // ── Borders / separators ──────────────────────────────────────────────────
+ readonly property color border: "#313244"
+ readonly property color separator: "#1e1e2e"
+
+ // ── Spacing (px) ──────────────────────────────────────────────────────────
+ readonly property int sp2: 2
+ readonly property int sp4: 4
+ readonly property int sp6: 6
+ readonly property int sp8: 8
+ readonly property int sp12: 12
+ readonly property int sp16: 16
+ readonly property int sp20: 20
+ readonly property int sp24: 24
+
+ // ── Border radii ──────────────────────────────────────────────────────────
+ readonly property int radius4: 4
+ readonly property int radius8: 8
+ readonly property int radius12: 12
+ readonly property int radius16: 16
+
+ // ── Font sizes (px) ───────────────────────────────────────────────────────
+ readonly property int font10: 10
+ readonly property int font11: 11
+ readonly property int font12: 12
+ readonly property int font13: 13
+ readonly property int font14: 14
+ readonly property int font16: 16
+ readonly property int font18: 18
+ readonly property int font20: 20
+ readonly property int font22: 22
+
+ // ── Transition durations (ms) ─────────────────────────────────────────────
+ readonly property int animFast: 100
+ readonly property int animNormal: 180
+ readonly property int animSlow: 300
+}
diff --git a/src/qml/utils/Constants.qml b/src/qml/utils/Constants.qml
new file mode 100644
index 0000000..fb36964
--- /dev/null
+++ b/src/qml/utils/Constants.qml
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Copyright (C) 2026 Aether Contributors
+//
+// This file is part of Aether.
+//
+// Aether is free software: you can redistribute it and/or modify it
+// under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Aether is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+// License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with Aether. If not, see .
+
+pragma Singleton
+import QtQuick 2.15
+
+/// App-wide layout dimensions and configuration defaults.
+/// Usage: Constants.guildListWidth, Constants.appName, etc.
+QtObject {
+
+ readonly property string appName: "Aether"
+ readonly property string appVersion: "0.1.0"
+
+ // ── Fixed layout dimensions ───────────────────────────────────────────────
+ readonly property int guildListWidth: 72
+ readonly property int channelListWidth: 240
+ readonly property int memberListWidth: 240
+ readonly property int voiceBarHeight: 52
+ readonly property int headerHeight: 48
+ readonly property int inputAreaHeight: 72
+
+ // ── Window constraints ────────────────────────────────────────────────────
+ readonly property int minWindowWidth: 1280
+ readonly property int minWindowHeight: 800
+
+ // ── Avatar sizes ──────────────────────────────────────────────────────────
+ readonly property int avatarMd: 40
+ readonly property int avatarSm: 32
+ readonly property int guildIconSize: 48
+}