diff --git a/ags-v1/config.js b/ags-v1/config.js
new file mode 100644
index 0000000..e6268ad
--- /dev/null
+++ b/ags-v1/config.js
@@ -0,0 +1,461 @@
+// ── violet-chaton v2 — AGS config ───────────────────────────────────────────
+// Barre + OSD + Launcher + Notifications
+// API: AGS v1.8.2 (.hook/.bind/.poll — no connections)
+
+const audio = await Service.import("audio");
+const battery = await Service.import("battery");
+const network = await Service.import("network");
+const systemtray = await Service.import("systemtray");
+const mpris = await Service.import("mpris");
+const notifications = await Service.import("notifications");
+const applications = await Service.import("applications");
+
+// ── Compositor detection ────────────────────────────────────────────────────
+const compositor = (() => {
+ const session = Utils.exec("bash -c 'echo $XDG_CURRENT_DESKTOP'").trim();
+ if (session.includes("Hyprland")) return "hyprland";
+ if (session.includes("COSMIC")) return "cosmic";
+ return "unknown";
+})();
+
+print(`[violet-chaton] compositor: ${compositor}`);
+
+// ══════════════════════════════════════════════════════════════════════════════
+// BAR
+// ══════════════════════════════════════════════════════════════════════════════
+
+const Separator = () => Widget.Label({ className: "separator", label: "│" });
+
+const Clock = () => Widget.Label({ className: "clock" })
+ .poll(1000, (self) => { self.label = Utils.exec("date +%H:%M"); });
+
+const DateWidget = () => Widget.Label({ className: "date" })
+ .poll(60000, (self) => { self.label = Utils.exec("date '+%a %d %b'"); });
+
+const CPU = () => Widget.Label({ className: "cpu" })
+ .poll(2000, (self) => {
+ const usage = Math.round(
+ Number(Utils.exec(['bash', '-c', "top -bn1 | awk '/^%Cpu/ {print 100-$8}'"]))
+ );
+ self.label = ` ${usage}%`;
+ self.toggleClassName("warning", usage > 70);
+ self.toggleClassName("critical", usage > 90);
+ });
+
+const RAM = () => Widget.Label({ className: "ram" })
+ .poll(2000, (self) => {
+ const used = Utils.exec(['bash', '-c', "free -m | awk '/^Mem:/ {printf \"%.1f\", $3/1024}'"]);
+ const total = Utils.exec(['bash', '-c', "free -m | awk '/^Mem:/ {printf \"%.1f\", $2/1024}'"]);
+ const pct = Math.round((parseFloat(used) / parseFloat(total)) * 100);
+ self.label = ` ${used}G`;
+ self.toggleClassName("warning", pct > 70);
+ self.toggleClassName("critical", pct > 90);
+ });
+
+const Network = () => Widget.Label({ className: "network" })
+ .hook(network, (self) => {
+ if (network.primary === "wifi") {
+ const wifi = network.wifi;
+ self.label = ` ${wifi?.ssid || ""}`;
+ self.toggleClassName("wifi", true);
+ self.toggleClassName("disconnected", false);
+ } else if (network.primary === "wired") {
+ self.label = " Eth";
+ self.toggleClassName("wifi", false);
+ self.toggleClassName("disconnected", false);
+ } else {
+ self.label = " ";
+ self.toggleClassName("disconnected", true);
+ }
+ });
+
+const Volume = () => Widget.Button({
+ className: "volume",
+ onClicked: () => { audio.speaker.isMuted = !audio.speaker.isMuted; },
+ child: Widget.Label()
+ .hook(audio, (self) => {
+ const vol = Math.round((audio.speaker?.volume || 0) * 100);
+ const muted = audio.speaker?.isMuted;
+ const icon = muted ? "" : vol > 66 ? "" : vol > 33 ? "" : "";
+ self.label = `${icon} ${vol}%`;
+ self.parent?.toggleClassName("muted", muted);
+ }, "speaker-changed"),
+});
+
+const Battery = () => Widget.Label({
+ className: "battery",
+ visible: battery.bind("available"),
+}).hook(battery, (self) => {
+ const pct = battery.percent;
+ const charging = battery.charging;
+ const icon = charging ? "" : pct > 80 ? "" : pct > 60 ? "" :
+ pct > 40 ? "" : pct > 20 ? "" : "";
+ self.label = `${icon} ${pct}%`;
+ self.toggleClassName("charging", charging);
+ self.toggleClassName("low", pct <= 20 && !charging);
+ self.toggleClassName("warning", pct <= 30 && !charging);
+});
+
+const Media = () => Widget.Label({ className: "media" })
+ .hook(mpris, (self) => {
+ const player = mpris.players[0];
+ if (!player) {
+ self.visible = false;
+ return;
+ }
+ self.visible = true;
+ const artist = player.trackArtists?.join(", ") || "";
+ const title = player.trackTitle || "";
+ const icon = player.playBackStatus === "Playing" ? " " : " ";
+ self.label = `${icon}${artist ? artist + " — " : ""}${title}`.slice(0, 50);
+ self.toggleClassName("paused", player.playBackStatus !== "Playing");
+ });
+
+const SysTray = () => Widget.Box({
+ className: "systray",
+ children: systemtray.bind("items").as((items) =>
+ items.map((item) =>
+ Widget.Button({
+ child: Widget.Icon({ icon: item.bind("icon"), size: 16 }),
+ tooltipMarkup: item.bind("tooltip-markup"),
+ onPrimaryClick: (_, event) => item.activate(event),
+ onSecondaryClick: (_, event) => item.openMenu(event),
+ })
+ )
+ ),
+});
+
+const LauncherBtn = () => Widget.Button({
+ className: "launcher-btn",
+ label: "",
+ onClicked: () => App.toggleWindow("launcher"),
+});
+
+const PowerBtn = () => Widget.Button({
+ className: "power-btn",
+ label: "⏻",
+ onClicked: () => Utils.exec("bash -c 'systemctl poweroff'"),
+});
+
+const Workspaces = () => {
+ if (compositor !== "hyprland") return Widget.Box({});
+ // Hyprland not running — return empty
+ return Widget.Box({});
+};
+
+const Bar = (monitor) => Widget.Window({
+ name: `bar-${monitor}`,
+ monitor,
+ anchor: ["top", "left", "right"],
+ exclusivity: "exclusive",
+ className: "bar",
+ child: Widget.CenterBox({
+ startWidget: Widget.Box({
+ className: "modules-left",
+ children: [
+ LauncherBtn(),
+ Separator(),
+ Workspaces(),
+ CPU(),
+ RAM(),
+ ],
+ }),
+ centerWidget: Widget.Box({
+ className: "modules-center",
+ children: [
+ Media(),
+ ],
+ }),
+ endWidget: Widget.Box({
+ className: "modules-right",
+ hpack: "end",
+ children: [
+ Network(),
+ Separator(),
+ Volume(),
+ Separator(),
+ Battery(),
+ Separator(),
+ DateWidget(),
+ Clock(),
+ Separator(),
+ SysTray(),
+ Separator(),
+ PowerBtn(),
+ ],
+ }),
+ }),
+});
+
+// ══════════════════════════════════════════════════════════════════════════════
+// OSD
+// ══════════════════════════════════════════════════════════════════════════════
+
+const VolumeOSD = () => {
+ const icon = Widget.Label({ className: "icon" });
+ const progress = Widget.ProgressBar();
+ const label = Widget.Label({ className: "label" });
+
+ return Widget.Box({
+ className: "osd",
+ children: [icon, progress, label],
+ }).hook(audio, (self) => {
+ const vol = audio.speaker?.volume || 0;
+ const muted = audio.speaker?.isMuted;
+ icon.label = muted ? "" : vol > 0.66 ? "" : vol > 0.33 ? "" : "";
+ progress.value = vol;
+ label.label = `${Math.round(vol * 100)}%`;
+ }, "speaker-changed");
+};
+
+const BrightnessOSD = () => {
+ const icon = Widget.Label({ className: "icon", label: "" });
+ const progress = Widget.ProgressBar();
+ const label = Widget.Label({ className: "label" });
+
+ const getBrightness = () => {
+ try {
+ const max = Number(Utils.exec("brightnessctl max"));
+ const cur = Number(Utils.exec("brightnessctl get"));
+ return max > 0 ? cur / max : 0;
+ } catch {
+ return 0;
+ }
+ };
+
+ return Widget.Box({
+ className: "osd",
+ children: [icon, progress, label],
+ }).poll(500, (self) => {
+ const val = getBrightness();
+ progress.value = val;
+ label.label = `${Math.round(val * 100)}%`;
+ });
+};
+
+const OSD = (monitor) => [
+ Widget.Window({
+ name: `osd-volume-${monitor}`,
+ monitor,
+ anchor: ["bottom"],
+ layer: "overlay",
+ visible: false,
+ child: VolumeOSD(),
+ }),
+ Widget.Window({
+ name: `osd-brightness-${monitor}`,
+ monitor,
+ anchor: ["bottom"],
+ layer: "overlay",
+ visible: false,
+ child: BrightnessOSD(),
+ }),
+];
+
+// ══════════════════════════════════════════════════════════════════════════════
+// LAUNCHER
+// ══════════════════════════════════════════════════════════════════════════════
+
+const AppItem = (app) => Widget.Button({
+ className: "app-item",
+ onClicked: () => {
+ app.launch();
+ App.closeWindow("launcher");
+ },
+ child: Widget.Box({
+ children: [
+ Widget.Icon({ icon: app.iconName || "application-x-executable", size: 24 }),
+ Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Label({ label: app.name, xalign: 0, truncate: "end" }),
+ Widget.Label({
+ label: app.description || "",
+ xalign: 0,
+ truncate: "end",
+ css: "color: #716686; font-size: 11px; font-weight: normal;",
+ }),
+ ],
+ }),
+ ],
+ }),
+});
+
+const Launcher = (monitor) => {
+ let apps = applications.list;
+
+ const list = Widget.Box({ vertical: true, spacing: 2 });
+
+ const entry = Widget.Entry({
+ className: "search",
+ placeholderText: "Rechercher...",
+ onAccept: () => {
+ const first = apps[0];
+ if (first) {
+ first.launch();
+ App.closeWindow("launcher");
+ }
+ },
+ onChange: ({ text }) => {
+ apps = applications.query(text || "");
+ list.children = apps.slice(0, 12).map(AppItem);
+ if (apps.length === 0) {
+ list.children = [Widget.Label({
+ className: "no-results",
+ label: "Aucun resultat",
+ })];
+ }
+ },
+ });
+
+ return Widget.Window({
+ name: "launcher",
+ monitor,
+ anchor: ["top"],
+ layer: "overlay",
+ visible: false,
+ keymode: "exclusive",
+ setup: (self) => {
+ self.keybind("Escape", () => App.closeWindow("launcher"));
+ self.hook(App, (_, name, visible) => {
+ if (name === "launcher" && visible) {
+ entry.text = "";
+ apps = applications.list;
+ list.children = apps.slice(0, 12).map(AppItem);
+ entry.grab_focus();
+ }
+ });
+ },
+ child: Widget.Box({
+ className: "launcher",
+ vertical: true,
+ children: [
+ Widget.Scrollable({
+ hscroll: "never",
+ vscroll: "automatic",
+ css: "min-height: 400px;",
+ child: list,
+ }),
+ entry,
+ ],
+ }),
+ });
+};
+
+// ══════════════════════════════════════════════════════════════════════════════
+// NOTIFICATIONS
+// ══════════════════════════════════════════════════════════════════════════════
+
+notifications.popupTimeout = 5000;
+notifications.cacheActions = true;
+
+const NotificationIcon = (notif) => {
+ if (notif.image) {
+ return Widget.Box({
+ css: `
+ min-width: 48px; min-height: 48px;
+ background-image: url("${notif.image}");
+ background-size: cover;
+ background-position: center;
+ border-radius: 8px;
+ margin-right: 10px;
+ `,
+ });
+ }
+ return Widget.Icon({
+ icon: notif.appIcon || notif.appEntry || "dialog-information",
+ size: 36,
+ css: "margin-right: 10px;",
+ });
+};
+
+const Notification = (notif) => Widget.Box({
+ className: `notification ${notif.urgency}`,
+ vertical: true,
+ children: [
+ Widget.Box({
+ children: [
+ NotificationIcon(notif),
+ Widget.Box({
+ vertical: true,
+ hexpand: true,
+ children: [
+ Widget.Box({
+ children: [
+ Widget.Label({
+ className: "title",
+ label: notif.summary,
+ xalign: 0,
+ hexpand: true,
+ truncate: "end",
+ }),
+ Widget.Label({
+ className: "time",
+ label: new Date(notif.time * 1000)
+ .toLocaleTimeString("fr-FR", {
+ hour: "2-digit",
+ minute: "2-digit",
+ }),
+ }),
+ Widget.Button({
+ className: "close-btn",
+ label: "✕",
+ onClicked: () => notif.close(),
+ }),
+ ],
+ }),
+ Widget.Label({
+ className: "app-name",
+ label: notif.appName || "",
+ xalign: 0,
+ }),
+ ],
+ }),
+ ],
+ }),
+ ...(notif.body ? [Widget.Label({
+ className: "body",
+ label: notif.body,
+ xalign: 0,
+ wrap: true,
+ useMarkup: true,
+ })] : []),
+ ...(notif.actions.length > 0 ? [Widget.Box({
+ className: "actions",
+ children: notif.actions.map((action) =>
+ Widget.Button({
+ label: action.label,
+ onClicked: () => notif.invoke(action.id),
+ })
+ ),
+ })] : []),
+ ],
+});
+
+const Notifications = (monitor) => Widget.Window({
+ name: `notifications-${monitor}`,
+ monitor,
+ anchor: ["top", "right"],
+ layer: "overlay",
+ child: Widget.Box({
+ vertical: true,
+ css: "min-width: 350px;",
+ children: notifications.bind("popups").as((popups) =>
+ popups.map(Notification)
+ ),
+ }),
+});
+
+// ══════════════════════════════════════════════════════════════════════════════
+// EXPORT
+// ══════════════════════════════════════════════════════════════════════════════
+
+export default {
+ style: `${App.configDir}/css/style.css`,
+ windows: [
+ Bar(0),
+ ...OSD(0),
+ Launcher(0),
+ Notifications(0),
+ ],
+};
diff --git a/ags-v1/css/style.css b/ags-v1/css/style.css
new file mode 100644
index 0000000..cd1dc8a
--- /dev/null
+++ b/ags-v1/css/style.css
@@ -0,0 +1,385 @@
+/* ── violet-chaton v2 — AGS stylesheet ────────────────────────────────────────
+ *
+ * Palette :
+ * crust #1a0e27
+ * base #261537
+ * mantle #341c4a
+ * surface0 #3d2454
+ * surface1 #493161
+ * surface2 #5a3875
+ * magenta #ff4da6 accent primaire
+ * lilac #c9a0ff accent secondaire
+ * mitsuri #9adba8 vert pastel
+ * lavande #a4b4ff bleu-violet
+ * champagne #e8c87a or chaud
+ * danger #f25c7a
+ * text #f0eaf8
+ * subtext1 #c4b8d4
+ * subtext0 #9a8fad
+ * muted #716686
+ *
+ * ─────────────────────────────────────────────────────────────────────────── */
+
+/* ── Reset ───────────────────────────────────────────────────────────────── */
+
+* {
+ font-family: "Maple Mono NF", "MapleMono Nerd Font", monospace;
+ font-size: 13px;
+ font-weight: bold;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * BAR — 3 pills glassmorphism (island floating)
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+.bar {
+ background: transparent;
+}
+
+.bar .modules-left,
+.bar .modules-center,
+.bar .modules-right {
+ background: alpha(#261537, 0.88);
+ border-radius: 14px;
+ border: 3px solid alpha(#ff4da6, 0.60);
+ margin: 8px 4px;
+ padding: 0 4px;
+}
+
+.bar .modules-left:hover,
+.bar .modules-center:hover,
+.bar .modules-right:hover {
+ border-color: #ff4da6;
+ box-shadow: 0 4px 28px alpha(#c9a0ff, 0.18);
+}
+
+/* ── Launcher button ─────────────────────────────────────────────────────── */
+
+.bar .launcher-btn {
+ color: #ff4da6;
+ font-size: 19px;
+ padding: 0 14px 0 18px;
+ min-width: 0;
+ min-height: 0;
+}
+
+.bar .launcher-btn:hover {
+ color: #c9a0ff;
+}
+
+/* ── Separator ───────────────────────────────────────────────────────────── */
+
+.bar .separator {
+ color: alpha(#f0eaf8, 0.12);
+ font-size: 11px;
+ padding: 0 4px;
+ font-weight: normal;
+}
+
+/* ── Clock ───────────────────────────────────────────────────────────────── */
+
+.bar .clock {
+ color: #ff4da6;
+ font-weight: 900;
+ font-size: 14px;
+ letter-spacing: 0.04em;
+ padding: 0 10px;
+}
+
+.bar .clock:hover {
+ color: #c9a0ff;
+}
+
+/* ── Date ────────────────────────────────────────────────────────────────── */
+
+.bar .date {
+ color: #a4b4ff;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 0 10px 0 2px;
+}
+
+/* ── System modules ──────────────────────────────────────────────────────── */
+
+.bar .cpu { color: #a4b4ff; }
+.bar .cpu.warning { color: #e8c87a; }
+.bar .cpu.critical { color: #f25c7a; }
+
+.bar .ram { color: #ff4da6; }
+.bar .ram.warning { color: #e8c87a; }
+.bar .ram.critical { color: #f25c7a; }
+
+.bar .temp {
+ color: alpha(#a4b4ff, 0.60);
+ font-size: 11px;
+ font-weight: normal;
+}
+
+.bar .temp.warning { color: #e8c87a; }
+.bar .temp.critical { color: #f25c7a; }
+
+/* ── Network ─────────────────────────────────────────────────────────────── */
+
+.bar .network {
+ color: #a4b4ff;
+ font-size: 11px;
+ font-weight: normal;
+}
+
+.bar .network.disconnected { color: #f25c7a; }
+.bar .network.wifi { color: alpha(#a4b4ff, 0.80); }
+
+/* ── Volume ──────────────────────────────────────────────────────────────── */
+
+.bar .volume { color: #ff4da6; }
+.bar .volume.muted { color: alpha(#ff4da6, 0.30); }
+
+/* ── Battery ─────────────────────────────────────────────────────────────── */
+
+.bar .battery { color: #ff4da6; }
+.bar .battery.charging { color: #9adba8; }
+.bar .battery.low { color: #f25c7a; }
+.bar .battery.warning { color: #e8c87a; }
+
+/* ── Media (MPRIS) ───────────────────────────────────────────────────────── */
+
+.bar .media {
+ color: #c9a0ff;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 0 10px;
+}
+
+.bar .media.paused {
+ color: alpha(#c9a0ff, 0.50);
+ font-style: italic;
+}
+
+/* ── Systray ─────────────────────────────────────────────────────────────── */
+
+.bar .systray { padding: 0 8px; }
+.bar .systray .passive { opacity: 0.5; }
+
+/* ── Workspaces (Hyprland only) ──────────────────────────────────────────── */
+
+.bar .workspaces button {
+ background: transparent;
+ color: #716686;
+ min-width: 24px;
+ min-height: 24px;
+ border-radius: 8px;
+ margin: 2px;
+ padding: 0;
+}
+
+.bar .workspaces button.active {
+ background: alpha(#ff4da6, 0.20);
+ color: #ff4da6;
+ border: 1px solid alpha(#ff4da6, 0.40);
+}
+
+.bar .workspaces button.occupied {
+ color: #c9a0ff;
+}
+
+.bar .workspaces button:hover {
+ background: alpha(#c9a0ff, 0.12);
+ color: #c9a0ff;
+}
+
+/* ── Power button ────────────────────────────────────────────────────────── */
+
+.bar .power-btn {
+ color: #f25c7a;
+ font-size: 15px;
+ padding: 0 14px 0 8px;
+ min-width: 0;
+ min-height: 0;
+}
+
+.bar .power-btn:hover { color: #ff4da6; }
+
+/* ── Hover global modules ────────────────────────────────────────────────── */
+
+.bar .cpu:hover,
+.bar .ram:hover,
+.bar .temp:hover,
+.bar .network:hover,
+.bar .volume:hover,
+.bar .battery:hover {
+ color: #c9a0ff;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * OSD — volume / brightness overlay
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+.osd {
+ background: alpha(#261537, 0.92);
+ border-radius: 14px;
+ border: 2px solid alpha(#ff4da6, 0.40);
+ padding: 12px 20px;
+ margin: 0 0 40px 0;
+}
+
+.osd .icon {
+ color: #ff4da6;
+ font-size: 24px;
+ margin-right: 12px;
+}
+
+.osd progressbar trough {
+ background: #3d2454;
+ border-radius: 8px;
+ min-height: 8px;
+ min-width: 200px;
+}
+
+.osd progressbar progress {
+ border-radius: 8px;
+ min-height: 8px;
+ background: linear-gradient(to right, #ff4da6, #c9a0ff, #a4b4ff, #9adba8);
+}
+
+.osd .label {
+ color: #f0eaf8;
+ font-size: 12px;
+ margin-left: 8px;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * LAUNCHER — app search
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+.launcher {
+ background: alpha(#1a0e27, 0.94);
+ border-radius: 14px;
+ border: 2px solid alpha(#ff4da6, 0.38);
+ padding: 10px;
+ min-width: 500px;
+}
+
+.launcher .search {
+ background: alpha(#261537, 0.75);
+ border-radius: 12px;
+ border: 1px solid alpha(#5a3875, 0.50);
+ padding: 9px 14px;
+ color: #f0eaf8;
+ caret-color: #ff4da6;
+ font-size: 14px;
+}
+
+.launcher .search:focus {
+ border-color: alpha(#ff4da6, 0.60);
+}
+
+.launcher .app-item {
+ background: transparent;
+ border-radius: 8px;
+ padding: 7px 10px;
+ color: #f0eaf8;
+}
+
+.launcher .app-item:hover,
+.launcher .app-item:focus {
+ background: alpha(#ff4da6, 0.16);
+ border: 1px solid alpha(#ff4da6, 0.32);
+}
+
+.launcher .app-item:hover label,
+.launcher .app-item:focus label {
+ color: #ff4da6;
+}
+
+.launcher .app-item image {
+ margin-right: 10px;
+}
+
+.launcher .no-results {
+ color: #716686;
+ padding: 20px;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * NOTIFICATIONS
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+.notification {
+ background: alpha(#261537, 0.94);
+ border-radius: 14px;
+ border: 2px solid alpha(#c9a0ff, 0.30);
+ padding: 12px;
+ margin: 8px;
+ min-width: 350px;
+}
+
+.notification .title {
+ color: #ff4da6;
+ font-weight: bold;
+ font-size: 13px;
+}
+
+.notification .body {
+ color: #c4b8d4;
+ font-weight: normal;
+ font-size: 12px;
+}
+
+.notification .app-name {
+ color: #716686;
+ font-size: 11px;
+}
+
+.notification .time {
+ color: #716686;
+ font-size: 10px;
+}
+
+.notification .close-btn {
+ color: #716686;
+ font-size: 14px;
+ min-width: 0;
+ min-height: 0;
+ padding: 2px 6px;
+ border-radius: 6px;
+}
+
+.notification .close-btn:hover {
+ color: #f25c7a;
+ background: alpha(#f25c7a, 0.12);
+}
+
+.notification .actions button {
+ background: alpha(#5a3875, 0.50);
+ color: #c9a0ff;
+ border-radius: 8px;
+ padding: 4px 12px;
+ margin: 4px 4px 0 0;
+}
+
+.notification .actions button:hover {
+ background: alpha(#ff4da6, 0.20);
+ color: #ff4da6;
+}
+
+/* ── Urgency levels ──────────────────────────────────────────────────────── */
+
+.notification.critical {
+ border-color: alpha(#f25c7a, 0.60);
+}
+
+.notification.critical .title {
+ color: #f25c7a;
+}
+
+/* ══════════════════════════════════════════════════════════════════════════
+ * TOOLTIP — shared
+ * ══════════════════════════════════════════════════════════════════════════ */
+
+tooltip {
+ background: alpha(#1a0e27, 0.96);
+ border: 1px solid alpha(#ff4da6, 0.30);
+ border-radius: 10px;
+ color: #f0eaf8;
+ padding: 6px 10px;
+}
diff --git a/ags-v1/widgets/Bar.js b/ags-v1/widgets/Bar.js
new file mode 100644
index 0000000..6a1d6cc
--- /dev/null
+++ b/ags-v1/widgets/Bar.js
@@ -0,0 +1,228 @@
+// ── violet-chaton v2 — Bar widget ───────────────────────────────────────────
+// 3 pills glassmorphism — modulaire selon compositor
+
+const audio = Service.import("audio");
+const battery = Service.import("battery");
+const network = Service.import("network");
+const systemtray = Service.import("systemtray");
+const mpris = Service.import("mpris");
+
+// ── Helpers ─────────────────────────────────────────────────────────────────
+
+const Separator = () => Widget.Label({ className: "separator", label: "│" });
+
+const Clock = () => Widget.Label({
+ className: "clock",
+ connections: [[1000, (self) => {
+ self.label = Utils.exec("date +%H:%M");
+ }]],
+});
+
+const DateWidget = () => Widget.Label({
+ className: "date",
+ connections: [[60000, (self) => {
+ self.label = Utils.exec("date '+%a %d %b'");
+ }]],
+});
+
+// ── System ──────────────────────────────────────────────────────────────────
+
+const CPU = () => Widget.Label({
+ className: "cpu",
+ connections: [[2000, (self) => {
+ const usage = Math.round(
+ Number(Utils.exec(`bash -c "top -bn1 | awk '/^%Cpu/ {print 100-$8}'"`)
+ ));
+ self.label = ` ${usage}%`;
+ self.toggleClassName("warning", usage > 70);
+ self.toggleClassName("critical", usage > 90);
+ }]],
+});
+
+const RAM = () => Widget.Label({
+ className: "ram",
+ connections: [[2000, (self) => {
+ const used = Utils.exec(`bash -c "free -m | awk '/^Mem:/ {printf \"%.1f\", $3/1024}'"`)
+ const total = Utils.exec(`bash -c "free -m | awk '/^Mem:/ {printf \"%.1f\", $2/1024}'"`)
+ const pct = Math.round((parseFloat(used) / parseFloat(total)) * 100);
+ self.label = ` ${used}G`;
+ self.toggleClassName("warning", pct > 70);
+ self.toggleClassName("critical", pct > 90);
+ }]],
+});
+
+// ── Network ─────────────────────────────────────────────────────────────────
+
+const Network = () => Widget.Label({
+ className: "network",
+ connections: [[network, (self) => {
+ if (network.primary === "wifi") {
+ const wifi = network.wifi;
+ self.label = ` ${wifi?.ssid || ""}`;
+ self.toggleClassName("wifi", true);
+ self.toggleClassName("disconnected", false);
+ } else if (network.primary === "wired") {
+ self.label = " Eth";
+ self.toggleClassName("wifi", false);
+ self.toggleClassName("disconnected", false);
+ } else {
+ self.label = " ";
+ self.toggleClassName("disconnected", true);
+ }
+ }]],
+});
+
+// ── Volume ──────────────────────────────────────────────────────────────────
+
+const Volume = () => Widget.Button({
+ className: "volume",
+ onClicked: () => { audio.speaker.isMuted = !audio.speaker.isMuted; },
+ child: Widget.Label({
+ connections: [[audio, (self) => {
+ const vol = Math.round((audio.speaker?.volume || 0) * 100);
+ const muted = audio.speaker?.isMuted;
+ const icon = muted ? "" : vol > 66 ? "" : vol > 33 ? "" : "";
+ self.label = `${icon} ${vol}%`;
+ self.parent?.toggleClassName("muted", muted);
+ }, "speaker-changed"]],
+ }),
+});
+
+// ── Battery ─────────────────────────────────────────────────────────────────
+
+const Battery = () => Widget.Label({
+ className: "battery",
+ visible: battery.bind("available"),
+ connections: [[battery, (self) => {
+ const pct = battery.percent;
+ const charging = battery.charging;
+ const icon = charging ? "" : pct > 80 ? "" : pct > 60 ? "" :
+ pct > 40 ? "" : pct > 20 ? "" : "";
+ self.label = `${icon} ${pct}%`;
+ self.toggleClassName("charging", charging);
+ self.toggleClassName("low", pct <= 20 && !charging);
+ self.toggleClassName("warning", pct <= 30 && !charging);
+ }]],
+});
+
+// ── Media ───────────────────────────────────────────────────────────────────
+
+const Media = () => Widget.Label({
+ className: "media",
+ connections: [[mpris, (self) => {
+ const player = mpris.players[0];
+ if (!player) {
+ self.visible = false;
+ return;
+ }
+ self.visible = true;
+ const artist = player.trackArtists?.join(", ") || "";
+ const title = player.trackTitle || "";
+ const icon = player.playBackStatus === "Playing" ? " " : " ";
+ self.label = `${icon}${artist ? artist + " — " : ""}${title}`.slice(0, 50);
+ self.toggleClassName("paused", player.playBackStatus !== "Playing");
+ }]],
+});
+
+// ── Systray ─────────────────────────────────────────────────────────────────
+
+const SysTray = () => Widget.Box({
+ className: "systray",
+ children: systemtray.bind("items").transform((items) =>
+ items.map((item) =>
+ Widget.Button({
+ child: Widget.Icon({ icon: item.bind("icon"), size: 16 }),
+ tooltipMarkup: item.bind("tooltip-markup"),
+ onPrimaryClick: (_, event) => item.activate(event),
+ onSecondaryClick: (_, event) => item.openMenu(event),
+ })
+ )
+ ),
+});
+
+// ── Launcher button ─────────────────────────────────────────────────────────
+
+const LauncherBtn = () => Widget.Button({
+ className: "launcher-btn",
+ label: "",
+ onClicked: () => App.toggleWindow("launcher"),
+});
+
+// ── Power button ────────────────────────────────────────────────────────────
+
+const PowerBtn = () => Widget.Button({
+ className: "power-btn",
+ label: "⏻",
+ onClicked: () => Utils.exec("bash -c 'systemctl poweroff'"),
+});
+
+// ── Workspaces (Hyprland only) ──────────────────────────────────────────────
+
+const Workspaces = (compositor) => {
+ if (compositor !== "hyprland") return Widget.Box({});
+
+ const hyprland = Service.import("hyprland");
+ return Widget.Box({
+ className: "workspaces",
+ children: hyprland.bind("workspaces").transform((ws) =>
+ ws.sort((a, b) => a.id - b.id)
+ .filter((w) => w.id > 0)
+ .map((w) =>
+ Widget.Button({
+ onClicked: () => hyprland.messageAsync(`dispatch workspace ${w.id}`),
+ child: Widget.Label({ label: `${w.id}` }),
+ className: hyprland.active.workspace.bind("id").transform(
+ (id) => id === w.id ? "active" : w.windows > 0 ? "occupied" : ""
+ ),
+ })
+ )
+ ),
+ });
+};
+
+// ── Bar assembly ────────────────────────────────────────────────────────────
+
+export const Bar = (monitor, compositor) => Widget.Window({
+ name: `bar-${monitor}`,
+ monitor,
+ anchor: ["top", "left", "right"],
+ exclusivity: "exclusive",
+ className: "bar",
+ child: Widget.CenterBox({
+ startWidget: Widget.Box({
+ className: "modules-left",
+ children: [
+ LauncherBtn(),
+ Separator(),
+ Workspaces(compositor),
+ Separator(),
+ CPU(),
+ RAM(),
+ ],
+ }),
+ centerWidget: Widget.Box({
+ className: "modules-center",
+ children: [
+ Media(),
+ ],
+ }),
+ endWidget: Widget.Box({
+ className: "modules-right",
+ hpack: "end",
+ children: [
+ Network(),
+ Separator(),
+ Volume(),
+ Separator(),
+ Battery(),
+ Separator(),
+ DateWidget(),
+ Clock(),
+ Separator(),
+ SysTray(),
+ Separator(),
+ PowerBtn(),
+ ],
+ }),
+ }),
+});
diff --git a/ags-v1/widgets/Launcher.js b/ags-v1/widgets/Launcher.js
new file mode 100644
index 0000000..b683149
--- /dev/null
+++ b/ags-v1/widgets/Launcher.js
@@ -0,0 +1,99 @@
+// ── violet-chaton v2 — Launcher widget ──────────────────────────────────────
+// App launcher avec recherche fuzzy
+
+const applications = Service.import("applications");
+
+const AppItem = (app) => Widget.Button({
+ className: "app-item",
+ onClicked: () => {
+ app.launch();
+ App.closeWindow("launcher");
+ },
+ child: Widget.Box({
+ children: [
+ Widget.Icon({ icon: app.iconName || "application-x-executable", size: 24 }),
+ Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Label({
+ label: app.name,
+ xalign: 0,
+ truncate: "end",
+ }),
+ Widget.Label({
+ label: app.description || "",
+ xalign: 0,
+ truncate: "end",
+ className: "description",
+ css: "color: #716686; font-size: 11px; font-weight: normal;",
+ }),
+ ],
+ }),
+ ],
+ }),
+});
+
+export const Launcher = (monitor) => {
+ let apps = applications.list;
+
+ const list = Widget.Box({
+ vertical: true,
+ spacing: 2,
+ });
+
+ const entry = Widget.Entry({
+ className: "search",
+ placeholderText: "Rechercher...",
+ onAccept: () => {
+ const first = apps[0];
+ if (first) {
+ first.launch();
+ App.closeWindow("launcher");
+ }
+ },
+ onChange: ({ text }) => {
+ apps = applications.query(text || "");
+ list.children = apps.slice(0, 12).map(AppItem);
+
+ if (apps.length === 0) {
+ list.children = [Widget.Label({
+ className: "no-results",
+ label: "Aucun resultat",
+ })];
+ }
+ },
+ });
+
+ return Widget.Window({
+ name: "launcher",
+ monitor,
+ anchor: ["top"],
+ layer: "overlay",
+ visible: false,
+ keymode: "exclusive",
+ setup: (self) => {
+ self.keybind("Escape", () => App.closeWindow("launcher"));
+ self.hook(App, (_, name, visible) => {
+ if (name === "launcher" && visible) {
+ entry.text = "";
+ apps = applications.list;
+ list.children = apps.slice(0, 12).map(AppItem);
+ entry.grab_focus();
+ }
+ });
+ },
+ child: Widget.Box({
+ className: "launcher",
+ vertical: true,
+ children: [
+ Widget.Scrollable({
+ hscroll: "never",
+ vscroll: "automatic",
+ css: "min-height: 400px;",
+ child: list,
+ }),
+ entry,
+ ],
+ }),
+ });
+};
diff --git a/ags-v1/widgets/Notifications.js b/ags-v1/widgets/Notifications.js
new file mode 100644
index 0000000..34670e1
--- /dev/null
+++ b/ags-v1/widgets/Notifications.js
@@ -0,0 +1,105 @@
+// ── violet-chaton v2 — Notifications widget ─────────────────────────────────
+// Popup notifications stylees — urgency-aware
+
+const notifications = Service.import("notifications");
+
+// Ne pas déranger
+notifications.popupTimeout = 5000;
+notifications.cacheActions = true;
+
+const NotificationIcon = (notif) => {
+ if (notif.image) {
+ return Widget.Box({
+ css: `
+ min-width: 48px; min-height: 48px;
+ background-image: url("${notif.image}");
+ background-size: cover;
+ background-position: center;
+ border-radius: 8px;
+ margin-right: 10px;
+ `,
+ });
+ }
+ return Widget.Icon({
+ icon: notif.appIcon || notif.appEntry || "dialog-information",
+ size: 36,
+ css: "margin-right: 10px;",
+ });
+};
+
+const Notification = (notif) => Widget.Box({
+ className: `notification ${notif.urgency}`,
+ vertical: true,
+ children: [
+ Widget.Box({
+ children: [
+ NotificationIcon(notif),
+ Widget.Box({
+ vertical: true,
+ hexpand: true,
+ children: [
+ Widget.Box({
+ children: [
+ Widget.Label({
+ className: "title",
+ label: notif.summary,
+ xalign: 0,
+ hexpand: true,
+ truncate: "end",
+ }),
+ Widget.Label({
+ className: "time",
+ label: new Date(notif.time * 1000)
+ .toLocaleTimeString("fr-FR", {
+ hour: "2-digit",
+ minute: "2-digit",
+ }),
+ }),
+ Widget.Button({
+ className: "close-btn",
+ label: "✕",
+ onClicked: () => notif.close(),
+ }),
+ ],
+ }),
+ Widget.Label({
+ className: "app-name",
+ label: notif.appName || "",
+ xalign: 0,
+ }),
+ ],
+ }),
+ ],
+ }),
+ ...(notif.body ? [Widget.Label({
+ className: "body",
+ label: notif.body,
+ xalign: 0,
+ wrap: true,
+ useMarkup: true,
+ })] : []),
+ ...(notif.actions.length > 0 ? [Widget.Box({
+ className: "actions",
+ children: notif.actions.map((action) =>
+ Widget.Button({
+ label: action.label,
+ onClicked: () => notif.invoke(action.id),
+ })
+ ),
+ })] : []),
+ ],
+});
+
+export const Notifications = (monitor) => Widget.Window({
+ name: `notifications-${monitor}`,
+ monitor,
+ anchor: ["top", "right"],
+ layer: "overlay",
+ child: Widget.Box({
+ vertical: true,
+ css: "min-width: 350px;",
+ children: notifications.bind("popups").transform((popups) =>
+ popups.map(Notification)
+ ),
+ }),
+});
diff --git a/ags-v1/widgets/OSD.js b/ags-v1/widgets/OSD.js
new file mode 100644
index 0000000..0ddc4cc
--- /dev/null
+++ b/ags-v1/widgets/OSD.js
@@ -0,0 +1,95 @@
+// ── violet-chaton v2 — OSD widget ───────────────────────────────────────────
+// Overlay volume / brightness avec gradient Mitsuri
+
+const audio = Service.import("audio");
+
+// ── OSD reveal timer ────────────────────────────────────────────────────────
+
+let osdTimeout = null;
+
+const showOSD = (window) => {
+ window.visible = true;
+ if (osdTimeout) clearTimeout(osdTimeout);
+ osdTimeout = setTimeout(() => {
+ window.visible = false;
+ }, 1500);
+};
+
+// ── Volume OSD ──────────────────────────────────────────────────────────────
+
+const VolumeOSD = () => {
+ const icon = Widget.Label({ className: "icon" });
+ const progress = Widget.ProgressBar();
+ const label = Widget.Label({ className: "label" });
+
+ const box = Widget.Box({
+ className: "osd",
+ children: [icon, progress, label],
+ connections: [[audio, (self) => {
+ const vol = audio.speaker?.volume || 0;
+ const muted = audio.speaker?.isMuted;
+ icon.label = muted ? "" : vol > 0.66 ? "" : vol > 0.33 ? "" : "";
+ progress.value = vol;
+ label.label = `${Math.round(vol * 100)}%`;
+ }, "speaker-changed"]],
+ });
+
+ return box;
+};
+
+// ── Brightness OSD ──────────────────────────────────────────────────────────
+
+const BrightnessOSD = () => {
+ const icon = Widget.Label({ className: "icon", label: "" });
+ const progress = Widget.ProgressBar();
+ const label = Widget.Label({ className: "label" });
+
+ const getBrightness = () => {
+ try {
+ const max = Number(Utils.exec("brightnessctl max"));
+ const cur = Number(Utils.exec("brightnessctl get"));
+ return max > 0 ? cur / max : 0;
+ } catch {
+ return 0;
+ }
+ };
+
+ const box = Widget.Box({
+ className: "osd",
+ children: [icon, progress, label],
+ connections: [[500, (self) => {
+ const val = getBrightness();
+ progress.value = val;
+ label.label = `${Math.round(val * 100)}%`;
+ }]],
+ });
+
+ return box;
+};
+
+// ── OSD windows ─────────────────────────────────────────────────────────────
+
+export const OSD = (monitor) => {
+ const volumeWin = Widget.Window({
+ name: `osd-volume-${monitor}`,
+ monitor,
+ anchor: ["bottom"],
+ layer: "overlay",
+ visible: false,
+ child: VolumeOSD(),
+ });
+
+ const brightnessWin = Widget.Window({
+ name: `osd-brightness-${monitor}`,
+ monitor,
+ anchor: ["bottom"],
+ layer: "overlay",
+ visible: false,
+ child: BrightnessOSD(),
+ });
+
+ // Auto-show on volume change
+ Utils.merge([audio.speaker?.bind("volume")], () => showOSD(volumeWin));
+
+ return [volumeWin, brightnessWin];
+};
diff --git a/ags-v3/.gitignore b/ags-v3/.gitignore
new file mode 100644
index 0000000..298eb4d
--- /dev/null
+++ b/ags-v3/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+@girs/
diff --git a/ags-v3/app.ts b/ags-v3/app.ts
new file mode 100644
index 0000000..81bd64d
--- /dev/null
+++ b/ags-v3/app.ts
@@ -0,0 +1,30 @@
+import app from "ags/gtk3/app"
+import style from "./style.scss"
+import Heartbeat from "./widget/Heartbeat"
+import Bar from "./widget/Bar"
+import BrainPower from "./widget/panels/BrainPower"
+
+app.start({
+ css: style,
+ main() {
+ for (const monitor of app.get_monitors()) {
+ Heartbeat(monitor)
+ Bar(monitor)
+ BrainPower(monitor)
+ }
+ },
+ requestHandler(request: any, res: (response: any) => void) {
+ const cmd = String(request)
+ if (cmd.includes("toggle-brain")) {
+ const win = app.get_window("brain-power")
+ if (win) {
+ win.visible = !win.visible
+ res("toggled")
+ } else {
+ res("window not found")
+ }
+ } else {
+ res(`unknown: '${cmd}'`)
+ }
+ },
+})
diff --git a/ags-v3/env.d.ts b/ags-v3/env.d.ts
new file mode 100644
index 0000000..792ebfd
--- /dev/null
+++ b/ags-v3/env.d.ts
@@ -0,0 +1,21 @@
+declare const SRC: string
+
+declare module "inline:*" {
+ const content: string
+ export default content
+}
+
+declare module "*.scss" {
+ const content: string
+ export default content
+}
+
+declare module "*.blp" {
+ const content: string
+ export default content
+}
+
+declare module "*.css" {
+ const content: string
+ export default content
+}
diff --git a/ags-v3/lib/brain.ts b/ags-v3/lib/brain.ts
new file mode 100644
index 0000000..54060aa
--- /dev/null
+++ b/ags-v3/lib/brain.ts
@@ -0,0 +1,73 @@
+import GLib from "gi://GLib"
+
+const BRAIN_ROOT = "/home/tetardtek/Dev/Cortex-Template"
+
+export interface BrainState {
+ focus: string
+ todos: { open: number; done: number; top3: string[] }
+ session: string | null
+}
+
+function readFile(path: string): string | null {
+ try {
+ const [ok, contents] = GLib.file_get_contents(path)
+ if (ok && contents) {
+ return new TextDecoder().decode(contents)
+ }
+ } catch {}
+ return null
+}
+
+export function getFocus(): string {
+ const content = readFile(`${BRAIN_ROOT}/focus.md`)
+ if (!content) return "no focus"
+ // skip header lines, get the meat
+ const lines = content.split("\n").filter(
+ (l) => !l.startsWith("#") && !l.startsWith(">") && l.trim() !== "" && !l.startsWith("---")
+ )
+ return lines[0]?.trim() || "no focus"
+}
+
+export function getTodos(): { open: number; done: number; top3: string[] } {
+ const content = readFile(`${BRAIN_ROOT}/todo/README.md`)
+ if (!content) return { open: 0, done: 0, top3: [] }
+
+ const lines = content.split("\n")
+ const open: string[] = []
+ let done = 0
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (trimmed.startsWith("- [ ]") || trimmed.startsWith("⬜")) {
+ open.push(trimmed.replace(/^- \[ \] /, "").replace(/^⬜ ?/, ""))
+ } else if (trimmed.startsWith("- [x]") || trimmed.startsWith("✅")) {
+ done++
+ }
+ }
+
+ return {
+ open: open.length,
+ done,
+ top3: open.slice(0, 3),
+ }
+}
+
+export function getSession(): string | null {
+ try {
+ const [ok, contents] = GLib.file_get_contents(
+ GLib.get_home_dir() + "/.claude/session-role"
+ )
+ if (ok && contents) {
+ return new TextDecoder().decode(contents).trim()
+ }
+ } catch {}
+ return null
+}
+
+export function getBrainState(): BrainState {
+ return {
+ focus: getFocus(),
+ todos: getTodos(),
+ session: getSession(),
+ }
+}
diff --git a/ags-v3/package.json b/ags-v3/package.json
new file mode 100644
index 0000000..cbf736c
--- /dev/null
+++ b/ags-v3/package.json
@@ -0,0 +1,10 @@
+{
+ "dependencies": {
+ "ags": "*",
+ "gnim": "*"
+ },
+ "prettier": {
+ "semi": false,
+ "tabWidth": 2
+ }
+}
diff --git a/ags-v3/style.scss b/ags-v3/style.scss
new file mode 100644
index 0000000..31f59ff
--- /dev/null
+++ b/ags-v3/style.scss
@@ -0,0 +1,300 @@
+@use "styles/palette" as *;
+
+* {
+ font-family: $font;
+ font-size: 13px;
+ font-weight: bold;
+}
+
+// ── Layer 0 — Heartbeat ──
+window.Heartbeat {
+ background: transparent;
+
+ .heartbeat-line {
+ background: linear-gradient(
+ to right,
+ rgba(255, 77, 166, 0.0),
+ rgba(255, 77, 166, 0.6),
+ rgba(201, 160, 255, 0.8),
+ rgba(255, 77, 166, 0.6),
+ rgba(255, 77, 166, 0.0)
+ );
+ min-height: 4px;
+ }
+}
+
+// ── Layer 1 — Ghost Bar ──
+window.Bar {
+ background: transparent;
+
+ > centerbox {
+ background: rgba(38, 21, 55, 0.88);
+ border-radius: $radius;
+ border: 3px solid rgba(255, 77, 166, 0.60);
+ margin: 6px 8px;
+ padding: 0 8px;
+ min-height: 38px;
+ }
+
+ .modules-left,
+ .modules-center,
+ .modules-right {
+ padding: 0 4px;
+ }
+
+ .module {
+ padding: 0 8px;
+ color: $text;
+ }
+
+ .prompt-name {
+ color: $magenta;
+ font-size: 14px;
+ padding: 0 0 0 10px;
+ }
+
+ .prompt-cursor {
+ color: $lilac;
+ font-size: 14px;
+ font-weight: 900;
+ }
+
+ .separator {
+ color: rgba(240, 234, 248, 0.12);
+ font-size: 11px;
+ padding: 0 4px;
+ font-weight: normal;
+ }
+
+ // ── clock ──
+ .clock {
+ color: $magenta;
+ font-weight: 900;
+ font-size: 14px;
+ letter-spacing: 0.04em;
+ padding: 0 10px;
+ }
+
+ .date {
+ color: $lavande;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 0 10px 0 2px;
+ }
+
+ // ── system stats ──
+ .cpu {
+ color: $lavande;
+ &.warning { color: $champagne; }
+ &.critical { color: $danger; }
+ }
+
+ .ram {
+ color: $magenta;
+ &.warning { color: $champagne; }
+ &.critical { color: $danger; }
+ }
+
+ // ── network ──
+ .network {
+ color: $lavande;
+ font-size: 12px;
+ padding: 0 8px;
+ &.disconnected { color: $danger; }
+ }
+
+ // ── volume ──
+ .volume {
+ color: $magenta;
+ padding: 0 8px;
+ &.muted { color: rgba(255, 77, 166, 0.30); }
+ }
+
+ // ── battery ──
+ .battery {
+ color: $magenta;
+ padding: 0 8px;
+ &.charging { color: $mitsuri; }
+ &.low { color: $danger; }
+ &.warning { color: $champagne; }
+ }
+
+ // ── hover effects ──
+ .module:hover,
+ .cpu:hover,
+ .ram:hover,
+ .network:hover,
+ .battery:hover {
+ color: $lilac;
+ }
+
+ // ── systray ──
+ .systray {
+ padding: 0 4px;
+ }
+
+ .systray-item {
+ background: transparent;
+ border: none;
+ padding: 0 3px;
+ min-width: 0;
+ min-height: 0;
+
+ &:hover {
+ background: rgba(201, 160, 255, 0.12);
+ border-radius: $radius-sm;
+ }
+ }
+
+ // ── volume interactive ──
+ button.volume,
+ button.muted {
+ background: transparent;
+ border: none;
+ padding: 0 8px;
+ min-width: 0;
+ min-height: 0;
+
+ label { color: $magenta; }
+ &.muted label { color: rgba(255, 77, 166, 0.30); }
+ &:hover label { color: $lilac; }
+ }
+
+ // ── media ──
+ .media-module {
+ padding: 0 4px;
+ }
+
+ .media {
+ padding: 0 4px;
+ }
+
+ .media.paused {
+ .media-text {
+ color: rgba(201, 160, 255, 0.50);
+ font-style: italic;
+ }
+ }
+
+ .media-text {
+ color: $lilac;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 0 6px;
+ }
+
+ .media-prev,
+ .media-play,
+ .media-next {
+ background: transparent;
+ border: none;
+ padding: 0 3px;
+ min-width: 0;
+ min-height: 0;
+
+ label {
+ color: $lilac;
+ font-size: 13px;
+ }
+
+ &:hover label {
+ color: $magenta;
+ }
+ }
+}
+
+// ── Layer 3 — Brain Power Panel ──
+window.BrainPower {
+ background: transparent;
+
+ .brain-panel {
+ background: rgba(26, 14, 39, 0.94); // $crust heavy glass
+ border-radius: 0 $radius $radius 0;
+ border: 2px solid rgba(201, 160, 255, 0.40); // $lilac border
+ border-left: none;
+ min-width: 380px;
+ padding: 16px;
+ }
+
+ .brain-header {
+ padding: 0 0 12px 0;
+ }
+
+ .brain-title {
+ color: $magenta;
+ font-size: 16px;
+ font-weight: 900;
+ letter-spacing: 0.08em;
+ }
+
+ .brain-close {
+ background: transparent;
+ border: none;
+ min-width: 0;
+ min-height: 0;
+ padding: 2px 8px;
+
+ label {
+ color: $muted;
+ font-size: 14px;
+ }
+
+ &:hover label {
+ color: $danger;
+ }
+ }
+
+ .brain-content {
+ padding: 0 4px;
+ }
+
+ .brain-section {
+ padding: 8px 0 4px 0;
+ }
+
+ .brain-section-title {
+ color: $lilac;
+ font-size: 11px;
+ font-weight: 900;
+ letter-spacing: 0.12em;
+ }
+
+ .brain-divider {
+ background: rgba(90, 56, 117, 0.30); // $surface2
+ min-height: 1px;
+ margin: 8px 0;
+ }
+
+ .brain-focus {
+ color: $text;
+ font-size: 13px;
+ padding: 4px 0 4px 16px;
+ }
+
+ .brain-session {
+ color: $champagne;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 4px 0 4px 16px;
+ }
+
+ .brain-todos-count {
+ color: $muted;
+ font-size: 11px;
+ font-weight: normal;
+ }
+
+ .brain-todos-list {
+ color: $subtext1;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 4px 0 4px 16px;
+ }
+
+ .brain-terminal-placeholder {
+ color: $muted;
+ font-size: 11px;
+ font-style: italic;
+ padding: 8px 0 4px 16px;
+ }
+}
diff --git a/ags-v3/styles/_bar.scss b/ags-v3/styles/_bar.scss
new file mode 100644
index 0000000..7fd969a
--- /dev/null
+++ b/ags-v3/styles/_bar.scss
@@ -0,0 +1,133 @@
+@use "palette" as *;
+
+// Layer 1 — Ghost bar (hover reveal)
+// GTK CSS alpha() must be unquoted strings to pass through SCSS compiler
+
+window.Bar {
+ background: transparent;
+
+ > centerbox {
+ background: rgba(38, 21, 55, 0.88); // $base @ 0.88
+ border-radius: $radius;
+ border: 3px solid rgba(255, 77, 166, 0.60); // $magenta @ 0.60
+ margin: 6px 8px;
+ padding: 0 8px;
+ min-height: 38px;
+ }
+
+ .modules-left,
+ .modules-center,
+ .modules-right {
+ padding: 0 4px;
+ }
+
+ .module {
+ padding: 0 8px;
+ color: $text;
+
+ &:hover {
+ color: $lilac;
+ }
+ }
+
+ .separator {
+ color: rgba(240, 234, 248, 0.12); // $text @ 0.12
+ font-size: 11px;
+ padding: 0 4px;
+ font-weight: normal;
+ }
+
+ // ── clock ──
+ .clock {
+ color: $magenta;
+ font-weight: 900;
+ font-size: 14px;
+ letter-spacing: 0.04em;
+ padding: 0 10px;
+
+ &:hover { color: $lilac; }
+ }
+
+ .date {
+ color: $lavande;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 0 10px 0 2px;
+ }
+
+ // ── system ──
+ .cpu { color: $lavande; }
+ .ram { color: $magenta; }
+ .temp {
+ color: rgba(164, 180, 255, 0.60); // $lavande @ 0.60
+ font-size: 11px;
+ font-weight: normal;
+ }
+
+ .cpu, .ram, .temp {
+ &.warning { color: $champagne; }
+ &.critical { color: $danger; }
+ }
+
+ // ── network ──
+ .network {
+ color: $lavande;
+ font-size: 12px;
+ &.disconnected { color: $danger; }
+ }
+
+ // ── volume ──
+ .volume {
+ color: $magenta;
+ &.muted { color: rgba(255, 77, 166, 0.30); }
+ }
+
+ // ── battery ──
+ .battery {
+ color: $magenta;
+ &.charging { color: $mitsuri; }
+ &.low { color: $danger; }
+ &.warning { color: $champagne; }
+ }
+
+ // ── media ──
+ .media {
+ color: $lilac;
+ font-size: 12px;
+ font-weight: normal;
+ padding: 0 10px;
+
+ &.paused {
+ color: rgba(201, 160, 255, 0.50); // $lilac @ 0.50
+ font-style: italic;
+ }
+ }
+
+ // ── systray ──
+ .systray {
+ padding: 0 6px;
+ }
+
+ // ── workspaces ──
+ .workspaces button {
+ background: transparent;
+ color: $muted;
+ min-width: 22px;
+ min-height: 22px;
+ border-radius: $radius-sm;
+ margin: 2px;
+ padding: 0;
+
+ &.active {
+ background: rgba(255, 77, 166, 0.20);
+ color: $magenta;
+ border: 1px solid rgba(255, 77, 166, 0.40);
+ }
+
+ &.occupied { color: $lilac; }
+ &:hover {
+ background: rgba(201, 160, 255, 0.12);
+ color: $lilac;
+ }
+ }
+}
diff --git a/ags-v3/styles/_heartbeat.scss b/ags-v3/styles/_heartbeat.scss
new file mode 100644
index 0000000..62dea0a
--- /dev/null
+++ b/ags-v3/styles/_heartbeat.scss
@@ -0,0 +1,21 @@
+@use "palette" as *;
+
+// Layer 0 — heartbeat line
+// Fin trait magenta en haut de l'écran — "le système est vivant"
+
+window.Heartbeat {
+ background: transparent;
+
+ > box {
+ // gradient uses rgba() which SCSS understands natively
+ background: linear-gradient(
+ to right,
+ rgba(255, 77, 166, 0.0),
+ rgba(255, 77, 166, 0.6),
+ rgba(201, 160, 255, 0.8),
+ rgba(255, 77, 166, 0.6),
+ rgba(255, 77, 166, 0.0)
+ );
+ min-height: 2px;
+ }
+}
diff --git a/ags-v3/styles/_palette.scss b/ags-v3/styles/_palette.scss
new file mode 100644
index 0000000..db9dd8b
--- /dev/null
+++ b/ags-v3/styles/_palette.scss
@@ -0,0 +1,35 @@
+// ── violet-chaton v2 ──────────────────────────────────────────────────────
+// Mitsuri Kanroji inspired — gradient magenta → green
+// Ghost Shell edition
+
+// backgrounds
+$crust: #1a0e27;
+$base: #261537;
+$mantle: #341c4a;
+$surface0: #3d2454;
+$surface1: #493161;
+$surface2: #5a3875;
+
+// accents
+$magenta: #ff4da6;
+$lilac: #c9a0ff;
+$mitsuri: #9adba8;
+$lavande: #a4b4ff;
+$champagne: #e8c87a;
+$danger: #f25c7a;
+
+// text
+$text: #f0eaf8;
+$subtext1: #c4b8d4;
+$subtext0: #9a8fad;
+$muted: #716686;
+
+// shared
+$font: "Maple Mono NF", "MapleMono Nerd Font", monospace;
+$radius: 14px;
+$radius-sm: 8px;
+$transition: 300ms ease-out;
+
+// Note: alpha() is a GTK CSS function, not SCSS.
+// Use it raw in GTK CSS values, not in SCSS variables.
+// For SCSS variables that need transparency, use rgba().
diff --git a/ags-v3/toggle-brain.sh b/ags-v3/toggle-brain.sh
new file mode 100755
index 0000000..17bcd6e
--- /dev/null
+++ b/ags-v3/toggle-brain.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+# Toggle Brain Power panel via AGS IPC
+ags request "toggle-brain" 2>/dev/null
diff --git a/ags-v3/tsconfig.json b/ags-v3/tsconfig.json
new file mode 100644
index 0000000..d20cbbc
--- /dev/null
+++ b/ags-v3/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "strict": true,
+ "module": "ES2022",
+ "target": "ES2020",
+ "lib": ["ES2023"],
+ "moduleResolution": "Bundler",
+ // "checkJs": true,
+ // "allowJs": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "ags/gtk3"
+ }
+}
diff --git a/ags-v3/widget/Bar.tsx b/ags-v3/widget/Bar.tsx
new file mode 100644
index 0000000..acc38c8
--- /dev/null
+++ b/ags-v3/widget/Bar.tsx
@@ -0,0 +1,74 @@
+import app from "ags/gtk3/app"
+import { Astal, Gtk, Gdk } from "ags/gtk3"
+import Clock from "./modules/Clock"
+import Battery from "./modules/Battery"
+import Volume from "./modules/Volume"
+import Network from "./modules/Network"
+import SystemStats from "./modules/SystemStats"
+import Media from "./modules/Media"
+import SysTray from "./modules/SysTray"
+import Prompt from "./modules/Prompt"
+
+export default function Bar(gdkmonitor: Gdk.Monitor) {
+ const { TOP, LEFT, RIGHT } = Astal.WindowAnchor
+
+ let hideTimeout: number | null = null
+
+ function scheduleHide(win: Astal.Window) {
+ if (hideTimeout) clearTimeout(hideTimeout)
+ hideTimeout = setTimeout(() => {
+ win.visible = false
+ hideTimeout = null
+ }, 800)
+ }
+
+ function cancelHide() {
+ if (hideTimeout) {
+ clearTimeout(hideTimeout)
+ hideTimeout = null
+ }
+ }
+
+ return (
+
+ cancelHide()}
+ onHoverLost={(self) => {
+ const win = self.get_toplevel() as Astal.Window
+ scheduleHide(win)
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/ags-v3/widget/Heartbeat.tsx b/ags-v3/widget/Heartbeat.tsx
new file mode 100644
index 0000000..a156a80
--- /dev/null
+++ b/ags-v3/widget/Heartbeat.tsx
@@ -0,0 +1,27 @@
+import app from "ags/gtk3/app"
+import { Astal, Gtk, Gdk } from "ags/gtk3"
+
+export default function Heartbeat(gdkmonitor: Gdk.Monitor) {
+ const { TOP, LEFT, RIGHT } = Astal.WindowAnchor
+
+ return (
+
+ {
+ // reveal the bar
+ const bar = app.get_window("bar")
+ if (bar) bar.visible = true
+ }}
+ >
+
+
+
+ )
+}
diff --git a/ags-v3/widget/modules/Battery.tsx b/ags-v3/widget/modules/Battery.tsx
new file mode 100644
index 0000000..c874541
--- /dev/null
+++ b/ags-v3/widget/modules/Battery.tsx
@@ -0,0 +1,29 @@
+import AstalBattery from "gi://AstalBattery"
+import { createBinding } from "ags"
+
+export default function Battery() {
+ const bat = AstalBattery.get_default()
+
+ const text = createBinding(bat, "percentage")((p: number) => {
+ const pct = Math.round(p * 100)
+ let icon = ""
+ if (bat.charging) icon = ""
+ else if (pct > 90) icon = ""
+ else if (pct > 70) icon = ""
+ else if (pct > 50) icon = ""
+ else if (pct > 30) icon = ""
+ else if (pct > 10) icon = ""
+ else icon = ""
+ return `${icon} ${pct}%`
+ })
+
+ const cls = createBinding(bat, "percentage")((p: number) => {
+ const pct = Math.round(p * 100)
+ if (bat.charging) return "battery charging"
+ if (pct <= 10) return "battery low"
+ if (pct <= 20) return "battery warning"
+ return "battery"
+ })
+
+ return
+}
diff --git a/ags-v3/widget/modules/Clock.tsx b/ags-v3/widget/modules/Clock.tsx
new file mode 100644
index 0000000..292ab60
--- /dev/null
+++ b/ags-v3/widget/modules/Clock.tsx
@@ -0,0 +1,32 @@
+import { createPoll } from "ags/time"
+
+function formatTime(): string {
+ const now = new Date()
+ return now.toLocaleTimeString("fr-FR", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ })
+}
+
+function formatDate(): string {
+ const now = new Date()
+ return now.toLocaleDateString("fr-FR", {
+ weekday: "short",
+ day: "numeric",
+ month: "short",
+ })
+}
+
+export default function Clock() {
+ const time = createPoll("", 1000, () => formatTime())
+ const date = createPoll("", 60000, () => formatDate())
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/ags-v3/widget/modules/Media.tsx b/ags-v3/widget/modules/Media.tsx
new file mode 100644
index 0000000..0afbc47
--- /dev/null
+++ b/ags-v3/widget/modules/Media.tsx
@@ -0,0 +1,60 @@
+import AstalMpris from "gi://AstalMpris"
+import { createBinding, For } from "ags"
+
+export default function Media() {
+ const mpris = AstalMpris.get_default()
+ const players = createBinding(mpris, "players")
+
+ return (
+
+
+ {(player) => {
+ const title = createBinding(player, "title")
+ const artist = createBinding(player, "artist")
+ const status = createBinding(player, "playbackStatus")
+
+ const icon = status((s: AstalMpris.PlaybackStatus) =>
+ s === AstalMpris.PlaybackStatus.PLAYING ? "" : ""
+ )
+
+ const label = title((t: string) => {
+ const a = player.artist
+ const display = a ? `${a} — ${t}` : t
+ // truncate long titles
+ return display.length > 40
+ ? display.substring(0, 37) + "..."
+ : display
+ })
+
+ const cls = status((s: AstalMpris.PlaybackStatus) =>
+ s === AstalMpris.PlaybackStatus.PLAYING ? "media" : "media paused"
+ )
+
+ return (
+
+
+
+
+
+
+ )
+ }}
+
+
+ )
+}
diff --git a/ags-v3/widget/modules/Network.tsx b/ags-v3/widget/modules/Network.tsx
new file mode 100644
index 0000000..d2e1e8c
--- /dev/null
+++ b/ags-v3/widget/modules/Network.tsx
@@ -0,0 +1,31 @@
+import AstalNetwork from "gi://AstalNetwork"
+import { createBinding } from "ags"
+
+export default function Network() {
+ const net = AstalNetwork.get_default()
+
+ const text = createBinding(net, "primary")((type: AstalNetwork.Primary) => {
+ if (type === AstalNetwork.Primary.WIFI) {
+ const wifi = net.wifi
+ if (!wifi) return " offline"
+ const ssid = wifi.ssid || "wifi"
+ const strength = wifi.strength
+ let icon = ""
+ if (strength > 80) icon = ""
+ else if (strength > 60) icon = ""
+ else if (strength > 40) icon = ""
+ else if (strength > 20) icon = ""
+ return `${icon} ${ssid}`
+ }
+ if (type === AstalNetwork.Primary.WIRED) return " eth"
+ return " offline"
+ })
+
+ const cls = createBinding(net, "primary")((type: AstalNetwork.Primary) => {
+ if (type === AstalNetwork.Primary.WIFI) return "network wifi"
+ if (type === AstalNetwork.Primary.WIRED) return "network wired"
+ return "network disconnected"
+ })
+
+ return
+}
diff --git a/ags-v3/widget/modules/Prompt.tsx b/ags-v3/widget/modules/Prompt.tsx
new file mode 100644
index 0000000..9c5eafc
--- /dev/null
+++ b/ags-v3/widget/modules/Prompt.tsx
@@ -0,0 +1,15 @@
+import { createPoll } from "ags/time"
+
+export default function Prompt() {
+ const cursor = createPoll("_", 600, () => {
+ // alternate between _ and empty to create blink
+ return Date.now() % 1200 < 600 ? "_" : " "
+ })
+
+ return (
+
+
+
+
+ )
+}
diff --git a/ags-v3/widget/modules/SysTray.tsx b/ags-v3/widget/modules/SysTray.tsx
new file mode 100644
index 0000000..b19e99a
--- /dev/null
+++ b/ags-v3/widget/modules/SysTray.tsx
@@ -0,0 +1,24 @@
+import AstalTray from "gi://AstalTray"
+import Gtk from "gi://Gtk?version=3.0"
+import { createBinding, For } from "ags"
+
+export default function SysTray() {
+ const tray = AstalTray.get_default()
+ const items = createBinding(tray, "items")
+
+ return (
+
+
+ {(item) => (
+
+ )}
+
+
+ )
+}
diff --git a/ags-v3/widget/modules/SystemStats.tsx b/ags-v3/widget/modules/SystemStats.tsx
new file mode 100644
index 0000000..4fcaf9b
--- /dev/null
+++ b/ags-v3/widget/modules/SystemStats.tsx
@@ -0,0 +1,31 @@
+import { createPoll } from "ags/time"
+import { exec } from "ags/process"
+
+function getCpuUsage(): string {
+ try {
+ const out = exec("bash -c \"top -bn1 | grep 'Cpu(s)' | awk '{print 100 - $8}'\"")
+ return `${Math.round(parseFloat(out))}`
+ } catch {
+ return "?"
+ }
+}
+
+function getRamUsage(): string {
+ try {
+ return exec("bash -c \"free | awk '/Mem:/ {printf \\\"%.0f\\\", $3/$2 * 100}'\"")
+ } catch {
+ return "?"
+ }
+}
+
+export default function SystemStats() {
+ const cpu = createPoll("0", 3000, () => getCpuUsage())
+ const ram = createPoll("0", 5000, () => getRamUsage())
+
+ return (
+
+
+ )
+}
diff --git a/ags-v3/widget/modules/Volume.tsx b/ags-v3/widget/modules/Volume.tsx
new file mode 100644
index 0000000..a7574cf
--- /dev/null
+++ b/ags-v3/widget/modules/Volume.tsx
@@ -0,0 +1,41 @@
+import AstalWp from "gi://AstalWp"
+import { createBinding } from "ags"
+
+export default function Volume() {
+ const speaker = AstalWp.get_default()!.defaultSpeaker!
+
+ const text = createBinding(speaker, "volume")((v: number) => {
+ const pct = Math.round(v * 100)
+ const muted = speaker.mute
+ let icon = ""
+ if (muted) icon = ""
+ else if (v > 0.66) icon = ""
+ else if (v > 0.33) icon = ""
+ else if (v > 0) icon = ""
+ else icon = ""
+ return `${icon} ${pct}%`
+ })
+
+ const cls = createBinding(speaker, "mute")((m: boolean) =>
+ m ? "volume muted" : "volume"
+ )
+
+ return (
+
+ )
+}
diff --git a/ags-v3/widget/panels/BrainPower.tsx b/ags-v3/widget/panels/BrainPower.tsx
new file mode 100644
index 0000000..eeda095
--- /dev/null
+++ b/ags-v3/widget/panels/BrainPower.tsx
@@ -0,0 +1,104 @@
+import app from "ags/gtk3/app"
+import { Astal, Gtk, Gdk } from "ags/gtk3"
+import { createPoll } from "ags/time"
+import { getBrainState } from "../../lib/brain"
+
+function BrainContent() {
+ const state = createPoll("", 10000, () => JSON.stringify(getBrainState()))
+
+ const focus = state((s: string) => {
+ try { return JSON.parse(s).focus } catch { return "..." }
+ })
+
+ const todosLabel = state((s: string) => {
+ try {
+ const t = JSON.parse(s).todos
+ return `${t.open} ouverts / ${t.done} faits`
+ } catch { return "..." }
+ })
+
+ const todosList = state((s: string) => {
+ try {
+ const t = JSON.parse(s).todos.top3
+ return t.map((item: string) => ` ⬜ ${item}`).join("\n") || " rien"
+ } catch { return "..." }
+ })
+
+ const session = state((s: string) => {
+ try {
+ const sess = JSON.parse(s).session
+ return sess || "aucune"
+ } catch { return "..." }
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function BrainPower(gdkmonitor: Gdk.Monitor) {
+ const { TOP, LEFT, BOTTOM } = Astal.WindowAnchor
+
+ return (
+
+ {
+ const win = app.get_window("brain-power")
+ if (win) win.visible = false
+ }}
+ >
+
+
+
+
+
+
+ )
+}
diff --git a/autostart/ghost-shell.desktop b/autostart/ghost-shell.desktop
new file mode 100644
index 0000000..d154899
--- /dev/null
+++ b/autostart/ghost-shell.desktop
@@ -0,0 +1,6 @@
+[Desktop Entry]
+Type=Application
+Name=Ghost Shell
+Comment=violet-chaton v2 AGS statusbar
+Exec=ags run -d /home/tetardtek/.config/ags-v3 -g 3
+X-GNOME-Autostart-enabled=true
diff --git a/palette/violet-chaton-v2.md b/palette/violet-chaton-v2.md
new file mode 100644
index 0000000..6ca36bb
--- /dev/null
+++ b/palette/violet-chaton-v2.md
@@ -0,0 +1,68 @@
+# violet-chaton v2 — Palette Reference
+
+> Mitsuri Kanroji inspired gradient magenta → green
+> Created by Tetardtek — 2026
+
+## Colors
+
+### Backgrounds
+| Name | Hex | Usage |
+|------|-----|-------|
+| crust | `#1a0e27` | Deepest background, panels heavy glass |
+| base | `#261537` | Primary background, bar glass |
+| mantle | `#341c4a` | Elevated surfaces |
+| surface0 | `#3d2454` | Input backgrounds, troughs |
+| surface1 | `#493161` | Hover states, subtle borders |
+| surface2 | `#5a3875` | Active states, dividers |
+
+### Accents
+| Name | Hex | Usage |
+|------|-----|-------|
+| magenta | `#ff4da6` | Primary accent — clock, battery, volume, borders, heartbeat |
+| lilac | `#c9a0ff` | Secondary accent — hover states, media, brain panel |
+| mitsuri | `#9adba8` | Green pastel — charging state, success, health good |
+| lavande | `#a4b4ff` | Blue-violet — date, CPU, network, info states |
+| champagne | `#e8c87a` | Warm gold — warnings, session info |
+| danger | `#f25c7a` | Red-pink — errors, low battery, disconnected |
+
+### Text
+| Name | Hex | Usage |
+|------|-----|-------|
+| text | `#f0eaf8` | Primary text |
+| subtext1 | `#c4b8d4` | Secondary text, todo items |
+| subtext0 | `#9a8fad` | Tertiary text |
+| muted | `#716686` | Disabled, placeholders, empty states |
+
+## Font
+- **Primary**: Maple Mono NF (cursive italics)
+- **Size**: 13px bold (bar), 14px 900 (clock, prompt)
+- **Icons**: Nerd Font glyphs from Maple Mono NF
+
+## Signature Visual Elements
+- Glassmorphism: `rgba(38, 21, 55, 0.88)` (base @ 88%)
+- Heavy glass: `rgba(26, 14, 39, 0.94)` (crust @ 94%)
+- Border accent: `3px solid rgba(255, 77, 166, 0.60)` (magenta @ 60%)
+- Hover glow: `box-shadow: 0 4px 28px rgba(201, 160, 255, 0.18)` (lilac @ 18%)
+- Gradient progress: `linear-gradient(to right, magenta, lilac, lavande, mitsuri)`
+- Heartbeat: gradient `magenta 0% → magenta 60% → lilac 80% → magenta 60% → magenta 0%`
+- Border radius: 14px (pills), 8px (buttons)
+
+## SCSS Variables
+```scss
+$crust: #1a0e27;
+$base: #261537;
+$mantle: #341c4a;
+$surface0: #3d2454;
+$surface1: #493161;
+$surface2: #5a3875;
+$magenta: #ff4da6;
+$lilac: #c9a0ff;
+$mitsuri: #9adba8;
+$lavande: #a4b4ff;
+$champagne: #e8c87a;
+$danger: #f25c7a;
+$text: #f0eaf8;
+$subtext1: #c4b8d4;
+$subtext0: #9a8fad;
+$muted: #716686;
+```