diff --git a/.agent/memory/INDEX.md b/.agent/memory/INDEX.md
index d23ba68..b15ee54 100644
--- a/.agent/memory/INDEX.md
+++ b/.agent/memory/INDEX.md
@@ -18,6 +18,7 @@ Auto-grown list of memory entries. Agents append after each task that produced n
- [2026-05-21-claude-code-stats-cache-stale](facts/2026-05-21-claude-code-stats-cache-stale.md) — `stats-cache.json` is a stale periodic rollup; live token data lives in per-session JSONL files under `projects/`
- [2026-05-21-claude-usage-display-format](facts/2026-05-21-claude-usage-display-format.md) — Exact fields, computation logic, and date-range strategy of `claude /usage`; total tokens = input+output only (cache excluded)
- [2026-09-23-gpui-keymap-dispatch](facts/2026-09-23-gpui-keymap-dispatch.md) — GPUI bindings need a focused root element; same-node contexts tie on depth so insertion order decides; `NoAction` masks lower contexts
+- [2026-09-24-state-json-shared-writers](facts/2026-09-24-state-json-shared-writers.md) — `state.json` has several writers (tray loop, startup, modal, CLI); update one field read-modify-write; app-recorded facts go there, not config
## patterns/
diff --git a/.agent/memory/facts/2026-09-24-state-json-shared-writers.md b/.agent/memory/facts/2026-09-24-state-json-shared-writers.md
new file mode 100644
index 0000000..6efbe67
--- /dev/null
+++ b/.agent/memory/facts/2026-09-24-state-json-shared-writers.md
@@ -0,0 +1,30 @@
+---
+title: state.json has several writers — update it read-modify-write
+status: current
+version: 0.1.0
+last_updated: 2026-09-24
+last_verified: 2026-09-24
+source_refs:
+ - crates/aura-core/src/state.rs
+ - crates/aura-core/src/sponsor.rs
+ - crates/aura/src/main.rs
+ - crates/aura/src/app.rs
+owner: "@rfluid"
+tags: [memory, fact, state]
+source_task: one-time sponsor nudge (docs/configuration.md#sponsor)
+---
+
+# state.json has several writers
+
+- `AppState` (`~/.local/share/aura/state.json`) is written by the tray loop
+ (`modal_height`, while the modal is open), the tray startup (`first_run`),
+ the modal view (`active_profile`, `sponsor_nudge_done`), and the `aura state`
+ CLI. The view's copy is loaded once per open, so saving it wholesale can undo
+ a write made since. New writers should `AppState::load()`, set their one
+ field, and `save()` — see `main.rs` (modal height, first run) and
+ `AuraView::finish_sponsor_nudge`.
+- App-recorded facts (timestamps, "already answered" flags) belong in
+ state.json; `config.toml` holds only user settings. The update chip's
+ `update.dismissed_version` predates this split.
+- Every new `AppState` field needs `#[serde(default)]`: a state file from an
+ older build must still load (`a_state_file_without_a_modal_height_still_loads`).
diff --git a/.design/components.md b/.design/components.md
index 5b2bdfa..447a7d4 100644
--- a/.design/components.md
+++ b/.design/components.md
@@ -46,6 +46,56 @@ A small clickable pill containing the agent icon + agent name.
- Settings cog `⚙`: `COLOR_TEXT_DIM`, opens config via `xdg-open`/`$EDITOR`.
- Title `Aura ⟳`: `COLOR_ACCENT`. Clickable; triggers `refresh()`.
+## Sponsor nudge
+
+**Renderer**: `AuraView::render_sponsor_nudge` — `app.rs:1722`; dismiss
+handler `AuraView::dismiss_sponsor_nudge` — `app.rs:912`.
+
+One-time card between the header and the selector row, shown a week after the
+first run until the user closes it with its × (gating:
+`aura_core::sponsor`). Warm but calm: a faint accent *wash*, not a filled
+banner, so it reads as information rather than an alert. Every color is
+derived from theme tokens with `Theme::blend`, so custom `theme.toml` palettes
+(light ones included) stay coherent — no literals.
+
+- Strip: `px_4 py_2`, border-bottom `border_1` / `COLOR_BORDER`, `flex_shrink_0`
+- Card: `flex_col`, padding `px_3 py_3`, gap `gap_2`, radius `rounded_md`,
+ `border_1`
+- Header row (`items_center`, `gap_2`): 14px `heart.svg` in `COLOR_ACCENT` ·
+ title `text_sm` / `COLOR_TEXT`, `flex_1` (lexicon `sponsor_nudge_title`) ·
+ trailing `icon_button` with `close.svg` (20×20 hit area, 14px icon)
+- Body: `text_xs` / `COLOR_TEXT_DIM` (lexicon `sponsor_nudge`)
+- Actions row: `flex_wrap`, `gap_2`, `mt_1`. Buttons are `px_2 py_1`,
+ `rounded_md`, `text_xs`, icon 12px with `gap_1p5`
+- Weight: regular (the app uses no font weights — see `tokens.md`); the
+ title's emphasis comes from `text_sm` + `COLOR_TEXT` against the dim body.
+
+| Element | Bg / border (rest) | Text / icon | Hover |
+| ---------------- | ------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------- |
+| Card | bg `blend(ACCENT, BG, 0.9)`, border `blend(ACCENT, BG, 0.7)` | — | — |
+| × (dismiss) | none | icon `COLOR_TEXT_DIM` | bg `blend(ACCENT, BG, 0.8)` |
+| Sponsor on GitHub| bg `blend(ACCENT, BG, 0.15)` | `on_accent_text(ACCENT)`, leading `github.svg` same | bg `COLOR_ACCENT` (brightens — never a downgrade) |
+| Pix (BRL) | none, border `blend(ACCENT, BG, 0.55)` | `COLOR_TEXT`, trailing `arrow_up_right.svg` `TEXT_DIM` | bg `blend(ACCENT, BG, 0.8)`, border `blend(ACCENT, BG, 0.3)` |
+
+| Click target | Opens | Then |
+| ----------------- | ------------------------------ | -------------------------------------- |
+| Sponsor on GitHub | `sponsor::SPONSOR_URL` | card stays up |
+| Pix (BRL) | `sponsor::PIX_URL` | card stays up |
+| × | nothing | `sponsor_nudge_done = true` (persisted)|
+
+Notes:
+
+- The sponsor buttons deliberately leave the card up: someone who paid via Pix
+ may still want to set up a GitHub sponsorship, or vice versa. Only the ×
+ retires it.
+
+- The Pix label is plain text, not the 🇧🇷 flag: regional-indicator flags
+ need a color-emoji font plus ligature shaping, which the monospace stack
+ does not guarantee, so the flag could render as two boxed letters.
+- No `cursor_pointer`: no other click target in the app sets one, so the card
+ follows suit.
+- The body does not quote a spend figure: Aura tracks tokens, not cost.
+
## Period row
**Renderer**: `AuraView::render_period_row` — `app.rs:298-336`.
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..706dc67
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,2 @@
+github: Rfluid
+custom: ["https://livepix.gg/rfluid"]
diff --git a/README.md b/README.md
index 7a8da77..1aa6f79 100644
--- a/README.md
+++ b/README.md
@@ -738,7 +738,13 @@ Later
## Sponsor
-See [SPONSOR.md](./SPONSOR.md) for ways to support Aura.
+See [SPONSOR.md](./SPONSOR.md) for ways to support Aura, or sponsor on
+[GitHub Sponsors](https://github.com/sponsors/Rfluid).
+
+A week after first launch, Aura shows a one-time card in the modal asking you
+to consider sponsoring. The sponsor links (GitHub Sponsors or Pix) leave it
+open; only the card's × retires it for good. To never see it, run
+`aura config set sponsor.nudge false`.
## Contributing
diff --git a/SPONSOR.md b/SPONSOR.md
index 2241620..821b219 100644
--- a/SPONSOR.md
+++ b/SPONSOR.md
@@ -3,6 +3,15 @@
Aura is a hobby project I share freely. If it's useful to you and you'd like
to chip in, here's how — no recurring commitment, no expectations.
+## GitHub Sponsors
+
+Monthly or one-time, by card or PayPal:
+**[github.com/sponsors/Rfluid](https://github.com/sponsors/Rfluid)**
+
+## Pix (BRL)
+
+**[livepix.gg/rfluid](https://livepix.gg/rfluid)**
+
## Bitcoin
```
diff --git a/assets/icons/heart.svg b/assets/icons/heart.svg
new file mode 100644
index 0000000..1a4b50e
--- /dev/null
+++ b/assets/icons/heart.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/aura-core/src/config.rs b/crates/aura-core/src/config.rs
index f87990c..cd6f25d 100644
--- a/crates/aura-core/src/config.rs
+++ b/crates/aura-core/src/config.rs
@@ -462,6 +462,26 @@ impl Default for KeybindingsConfig {
}
}
+// ── Sponsor ──────────────────────────────────────────────────────────────────
+
+/// Controls the one-time "consider sponsoring" card. When it shows and whether
+/// it has been answered are tracked in `state.json` (see [`crate::sponsor`]);
+/// this section only holds the opt-out.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(default)]
+pub struct SponsorConfig {
+ /// Show the sponsor card once, a week after the first run. Default true.
+ /// Set false to never show it.
+ #[serde(default = "default_true")]
+ pub nudge: bool,
+}
+
+impl Default for SponsorConfig {
+ fn default() -> Self {
+ Self { nudge: true }
+ }
+}
+
// ── AppConfig ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -480,6 +500,8 @@ pub struct AppConfig {
pub update: UpdateConfig,
#[serde(default)]
pub keybindings: KeybindingsConfig,
+ #[serde(default)]
+ pub sponsor: SponsorConfig,
}
impl AppConfig {
@@ -550,6 +572,7 @@ impl AppConfig {
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
+ sponsor: SponsorConfig::default(),
}
}
@@ -821,6 +844,7 @@ mod tests {
},
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
+ sponsor: SponsorConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
@@ -841,6 +865,7 @@ mod tests {
},
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
+ sponsor: SponsorConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
@@ -857,6 +882,7 @@ mod tests {
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
+ sponsor: SponsorConfig::default(),
};
cfg.apply_plugin_order();
let names: Vec<&str> = cfg.plugins.iter().map(|p| p.name.as_str()).collect();
@@ -939,6 +965,7 @@ dismiss_all = true
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
+ sponsor: SponsorConfig::default(),
};
let added = cfg.merge_agents(vec![
@@ -989,6 +1016,7 @@ dismiss_all = true
content: ContentConfig::default(),
update: UpdateConfig::default(),
keybindings: KeybindingsConfig::default(),
+ sponsor: SponsorConfig::default(),
};
let added = cfg.merge_agents(vec![AgentConfig {
diff --git a/crates/aura-core/src/config_schema.rs b/crates/aura-core/src/config_schema.rs
index 6a96646..bdbd743 100644
--- a/crates/aura-core/src/config_schema.rs
+++ b/crates/aura-core/src/config_schema.rs
@@ -7,7 +7,7 @@
//! - `aura config wizard`
//!
//! Every settable scalar field under `[window]` / `[tray]` / `[content]` /
-//! `[update]` / `[keybindings]` has a [`FieldDescriptor`] here. A unit test
+//! `[update]` / `[keybindings]` / `[sponsor]` has a [`FieldDescriptor`] here. A unit test
//! (`registry_covers_every_field`) serializes a default config and asserts
//! each leaf key is described, so adding a struct field without documenting
//! it breaks the build.
@@ -60,7 +60,14 @@ pub struct SectionField {
/// The scalar `[section]`s of the config, in template-emission order. The
/// repeatable `[[agents]]` / `[[plugins]]` tables are not here — they are
/// documented by [`agent_fields`] / [`plugin_fields`] and edited elsewhere.
-pub const SECTIONS: &[&str] = &["window", "tray", "content", "update", "keybindings"];
+pub const SECTIONS: &[&str] = &[
+ "window",
+ "tray",
+ "content",
+ "update",
+ "keybindings",
+ "sponsor",
+];
/// All settable scalar fields, in template-emission order ([`SECTIONS`]).
pub fn fields() -> &'static [FieldDescriptor] {
@@ -303,6 +310,19 @@ pub fn fields() -> &'static [FieldDescriptor] {
keybindings.toml.",
example: "true",
},
+ // ── [sponsor] ──
+ FieldDescriptor {
+ key: "sponsor.nudge",
+ type_label: "bool",
+ allowed: &["true", "false"],
+ default: "true",
+ summary: "Show the one-time sponsor card a week after the first run.",
+ description: "Show a small, dismissible \"consider sponsoring\" card in the modal \
+ once, seven days after Aura first ran. The sponsor links leave it open; only its × retires it \
+ for good (recorded in state.json, not here). Default true. Set false to never \
+ show it.",
+ example: "false",
+ },
]
}
@@ -513,6 +533,7 @@ pub fn get_value(cfg: &AppConfig, key: &str) -> Result {
.unwrap_or_else(|| "(unset)".to_string()),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
"keybindings.enabled" => cfg.keybindings.enabled.to_string(),
+ "sponsor.nudge" => cfg.sponsor.nudge.to_string(),
_ => return Err(unknown_key(key)),
};
Ok(v)
@@ -547,6 +568,7 @@ pub fn set_value(cfg: &mut AppConfig, key: &str, raw: &str) -> Result<(), Schema
"update.dismissed_version" => cfg.update.dismissed_version = parse_opt_string(raw),
"update.dismiss_all" => cfg.update.dismiss_all = parse_bool(key, raw)?,
"keybindings.enabled" => cfg.keybindings.enabled = parse_bool(key, raw)?,
+ "sponsor.nudge" => cfg.sponsor.nudge = parse_bool(key, raw)?,
_ => return Err(unknown_key(key)),
}
Ok(())
@@ -722,6 +744,7 @@ fn toml_rhs(cfg: &AppConfig, key: &str) -> Option {
"update.dismissed_version" => return cfg.update.dismissed_version.as_deref().map(quote),
"update.dismiss_all" => cfg.update.dismiss_all.to_string(),
"keybindings.enabled" => cfg.keybindings.enabled.to_string(),
+ "sponsor.nudge" => cfg.sponsor.nudge.to_string(),
_ => return None,
})
}
@@ -782,8 +805,8 @@ fn wrap_text(text: &str, width: usize) -> Vec {
mod tests {
use super::*;
use crate::config::{
- AgentConfig, AgentKind, ContentConfig, KeybindingsConfig, PluginConfig, TrayConfig,
- UpdateConfig, WindowConfig,
+ AgentConfig, AgentKind, ContentConfig, KeybindingsConfig, PluginConfig, SponsorConfig,
+ TrayConfig, UpdateConfig, WindowConfig,
};
/// Walk a serialized default config and assert every leaf key under each
@@ -929,6 +952,7 @@ mod tests {
assert_eq!(parsed.content, cfg.content);
assert_eq!(parsed.update, cfg.update);
assert_eq!(parsed.keybindings, cfg.keybindings);
+ assert_eq!(parsed.sponsor, cfg.sponsor);
}
#[test]
@@ -980,6 +1004,7 @@ mod tests {
dismiss_all: true,
},
keybindings: KeybindingsConfig { enabled: false },
+ sponsor: SponsorConfig { nudge: false },
};
assert_round_trips(&cfg);
}
diff --git a/crates/aura-core/src/lexicon.rs b/crates/aura-core/src/lexicon.rs
index 95d7875..7456a84 100644
--- a/crates/aura-core/src/lexicon.rs
+++ b/crates/aura-core/src/lexicon.rs
@@ -56,6 +56,17 @@ pub struct Lexicon {
/// end is part of the persona — keep or replace as you see fit.
pub update_available_fmt: fn(latest_version: &str) -> String,
+ // ── Sponsor nudge ───────────────────────────────────────────────────────
+ /// Header line of the one-time sponsor card (see `crate::sponsor`).
+ pub sponsor_nudge_title: &'static str,
+ /// Body of the sponsor card.
+ pub sponsor_nudge: &'static str,
+ /// Primary button: opens the GitHub Sponsors page.
+ pub sponsor_nudge_github: &'static str,
+ /// Secondary button: opens the Pix (BRL) tip page. The card's trailing ×
+ /// is an icon, so it has no string.
+ pub sponsor_nudge_pix: &'static str,
+
// ── Period pills ────────────────────────────────────────────────────────
pub period_all: &'static str,
pub period_7d: &'static str,
@@ -126,6 +137,12 @@ pub const POLITE: Lexicon = Lexicon {
update_available_fmt: polite_update_available,
+ sponsor_nudge_title: "Enjoying Aura?",
+ sponsor_nudge:
+ "Aura is free and built by one person. If it's been useful, consider sponsoring.",
+ sponsor_nudge_github: "Sponsor on GitHub",
+ sponsor_nudge_pix: "Pix (BRL)",
+
period_all: "All time",
period_7d: "Last 7 days",
period_30d: "Last 30 days",
@@ -163,6 +180,11 @@ pub const GOBLIN: Lexicon = Lexicon {
update_available_fmt: goblin_update_available,
+ sponsor_nudge_title: "Still here, huh?",
+ sponsor_nudge: "One person built this for free. If it's saved your butt, throw them a coin.",
+ sponsor_nudge_github: "Cough it up on GitHub",
+ sponsor_nudge_pix: "Pix it (BRL)",
+
period_all: "the whole damn time",
period_7d: "last week",
period_30d: "this month-ish",
@@ -240,6 +262,22 @@ mod tests {
GOBLIN.menu_open_config,
),
("menu_themes", POLITE.menu_themes, GOBLIN.menu_themes),
+ (
+ "sponsor_nudge_title",
+ POLITE.sponsor_nudge_title,
+ GOBLIN.sponsor_nudge_title,
+ ),
+ ("sponsor_nudge", POLITE.sponsor_nudge, GOBLIN.sponsor_nudge),
+ (
+ "sponsor_nudge_github",
+ POLITE.sponsor_nudge_github,
+ GOBLIN.sponsor_nudge_github,
+ ),
+ (
+ "sponsor_nudge_pix",
+ POLITE.sponsor_nudge_pix,
+ GOBLIN.sponsor_nudge_pix,
+ ),
("period_all", POLITE.period_all, GOBLIN.period_all),
("period_7d", POLITE.period_7d, GOBLIN.period_7d),
("period_30d", POLITE.period_30d, GOBLIN.period_30d),
diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs
index 99421f7..416b222 100644
--- a/crates/aura-core/src/lib.rs
+++ b/crates/aura-core/src/lib.rs
@@ -7,5 +7,6 @@ pub mod lexicon;
pub mod plugin;
pub mod quota;
pub mod reader;
+pub mod sponsor;
pub mod state;
pub mod theme;
diff --git a/crates/aura-core/src/sponsor.rs b/crates/aura-core/src/sponsor.rs
new file mode 100644
index 0000000..8f6619a
--- /dev/null
+++ b/crates/aura-core/src/sponsor.rs
@@ -0,0 +1,132 @@
+//! The one-time "consider sponsoring" nudge.
+//!
+//! Aura asks exactly once, a week after it first ran, and never again once the
+//! user has closed the card with its ×. Both timestamps live in
+//! [`AppState`] (`state.json`), not `config.toml`: they are facts Aura records
+//! about itself, not settings. The only user-facing knob is the opt-out,
+//! `[sponsor] nudge` in config.
+//!
+//! Everything here takes `now` as an argument so the gating is testable
+//! without a clock.
+
+use chrono::{DateTime, TimeDelta, Utc};
+
+use crate::state::AppState;
+
+/// Where the nudge's primary "Sponsor on GitHub" button goes.
+pub const SPONSOR_URL: &str = "https://github.com/sponsors/Rfluid";
+
+/// Where the nudge's secondary "Pix" button goes — a one-off tip in BRL for
+/// users without a GitHub Sponsors-friendly card.
+pub const PIX_URL: &str = "https://livepix.gg/rfluid";
+
+/// How long after the first run the nudge waits before appearing.
+pub const NUDGE_DELAY: TimeDelta = TimeDelta::days(7);
+
+/// Stamp `state.first_run` with `now` if it has never been set. Returns whether
+/// the state changed, so the caller only writes the file when it has to.
+///
+/// A state file from a build that predates the field has no `first_run`, so an
+/// existing user's first launch after upgrading counts as their first run: they
+/// get the full week too, rather than a nudge on the very first open.
+pub fn record_first_run(state: &mut AppState, now: DateTime) -> bool {
+ if state.first_run.is_some() {
+ return false;
+ }
+ state.first_run = Some(now);
+ true
+}
+
+/// Whether the nudge card should render right now.
+///
+/// False when the user opted out (`enabled` is `[sponsor] nudge`), when the card
+/// was already answered (any of its buttons), and when there is no recorded first run — a
+/// missing timestamp means the clock hasn't started, not that it ran out. A
+/// first run in the future (the system clock moved backwards) also reads as
+/// "not yet".
+pub fn nudge_due(state: &AppState, enabled: bool, now: DateTime) -> bool {
+ if !enabled || state.sponsor_nudge_done {
+ return false;
+ }
+ let Some(first_run) = state.first_run else {
+ return false;
+ };
+ now.signed_duration_since(first_run) >= NUDGE_DELAY
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::TimeZone;
+
+ fn t0() -> DateTime {
+ Utc.with_ymd_and_hms(2026, 9, 1, 12, 0, 0).unwrap()
+ }
+
+ fn started_at(first_run: DateTime) -> AppState {
+ AppState {
+ first_run: Some(first_run),
+ ..AppState::default()
+ }
+ }
+
+ #[test]
+ fn hidden_before_seven_days() {
+ let state = started_at(t0());
+ assert!(!nudge_due(&state, true, t0()));
+ assert!(!nudge_due(&state, true, t0() + TimeDelta::days(6)));
+ let just_short = t0() + NUDGE_DELAY - TimeDelta::seconds(1);
+ assert!(!nudge_due(&state, true, just_short));
+ }
+
+ #[test]
+ fn shown_at_and_after_seven_days() {
+ let state = started_at(t0());
+ assert!(nudge_due(&state, true, t0() + NUDGE_DELAY));
+ assert!(nudge_due(&state, true, t0() + TimeDelta::days(90)));
+ }
+
+ #[test]
+ fn never_shown_once_done() {
+ let state = AppState {
+ sponsor_nudge_done: true,
+ ..started_at(t0())
+ };
+ assert!(!nudge_due(&state, true, t0() + TimeDelta::days(30)));
+ }
+
+ #[test]
+ fn opt_out_is_respected() {
+ let state = started_at(t0());
+ assert!(!nudge_due(&state, false, t0() + TimeDelta::days(30)));
+ }
+
+ #[test]
+ fn clock_moving_backwards_reads_as_not_yet() {
+ let state = started_at(t0());
+ assert!(!nudge_due(&state, true, t0() - TimeDelta::days(30)));
+ }
+
+ #[test]
+ fn missing_first_run_initializes_rather_than_shows() {
+ // An upgraded install: state.json exists but predates the field.
+ let mut state = AppState::default();
+ let now = t0() + TimeDelta::days(365);
+ assert!(!nudge_due(&state, true, now));
+
+ assert!(record_first_run(&mut state, now));
+ assert_eq!(state.first_run, Some(now));
+ // Still not due: the week starts now.
+ assert!(!nudge_due(&state, true, now));
+ assert!(nudge_due(&state, true, now + NUDGE_DELAY));
+ }
+
+ #[test]
+ fn record_first_run_never_overwrites() {
+ let mut state = started_at(t0());
+ assert!(!record_first_run(&mut state, t0() + TimeDelta::days(3)));
+ assert_eq!(state.first_run, Some(t0()));
+ }
+}
diff --git a/crates/aura-core/src/state.rs b/crates/aura-core/src/state.rs
index 181df2c..9535070 100644
--- a/crates/aura-core/src/state.rs
+++ b/crates/aura-core/src/state.rs
@@ -4,6 +4,7 @@ use std::{
};
use anyhow::{Context, Result};
+use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
@@ -25,6 +26,17 @@ pub struct AppState {
/// when the config or theme changes.
#[serde(default)]
pub modal_height: Option,
+ /// When the tray app first ran on this machine. Stamped on startup by
+ /// [`crate::sponsor::record_first_run`]; `None` only until then, including
+ /// for a state file written before the field existed. Starts the clock on
+ /// the one-time sponsor nudge.
+ #[serde(default)]
+ pub first_run: Option>,
+ /// Set once the user closes the sponsor nudge card with its ×. The sponsor
+ /// links leave it unset, so the card stays up after one is opened.
+ /// Never cleared, so the card shows at most once per install.
+ #[serde(default)]
+ pub sponsor_nudge_done: bool,
}
impl AppState {
@@ -80,6 +92,8 @@ mod tests {
let state = AppState {
active_profile: Some("Claude Code (Enterprise)".to_string()),
modal_height: Some(409),
+ first_run: Some("2026-09-01T12:00:00Z".parse().unwrap()),
+ sponsor_nudge_done: true,
};
let json = serde_json::to_string_pretty(&state).unwrap();
let parsed: AppState = serde_json::from_str(&json).unwrap();
@@ -96,6 +110,10 @@ mod tests {
let loaded = AppState::load_from(&path).unwrap();
assert_eq!(loaded.active_profile.as_deref(), Some("Personal"));
assert_eq!(loaded.modal_height, None);
+ // Same for the sponsor-nudge fields: an upgraded install starts with
+ // no first run recorded and the nudge still pending.
+ assert_eq!(loaded.first_run, None);
+ assert!(!loaded.sponsor_nudge_done);
}
#[test]
@@ -116,6 +134,7 @@ mod tests {
let original = AppState {
active_profile: Some("My Profile".to_string()),
modal_height: Some(512),
+ ..AppState::default()
};
original.save_to(&path).unwrap();
diff --git a/crates/aura/src/app.rs b/crates/aura/src/app.rs
index 2b137a3..398643d 100644
--- a/crates/aura/src/app.rs
+++ b/crates/aura/src/app.rs
@@ -10,6 +10,7 @@ use aura_core::{
GeminiQuota, QuotaApi, QuotaSnapshot, QuotaSource, QuotaWindow,
},
reader::{make_reader, Period, UsageSnapshot},
+ sponsor,
state::AppState,
theme::Theme,
};
@@ -893,6 +894,31 @@ impl AuraView {
cx.notify();
}
+ /// Whether the one-time sponsor card should render. Thin wrapper around
+ /// [`sponsor::nudge_due`], which holds the (unit-tested) gating.
+ fn show_sponsor_nudge(&self) -> bool {
+ sponsor::nudge_due(&self.state, self.config.sponsor.nudge, Utc::now())
+ }
+
+ /// Retire the sponsor card for good — only the × lands here. The sponsor
+ /// buttons just open their page and leave the card up, so someone who
+ /// chipped in via Pix can still reach GitHub Sponsors (or vice versa).
+ ///
+ /// Written read-modify-write against the file rather than by saving
+ /// `self.state`: the tray loop records `modal_height` into the same file
+ /// while the modal is open, and this copy predates that write. A failed
+ /// save is only logged — the card is still hidden for this session, and
+ /// at worst it comes back once more on a later open.
+ fn dismiss_sponsor_nudge(&mut self, cx: &mut Context) {
+ self.state.sponsor_nudge_done = true;
+ let mut on_disk = AppState::load().unwrap_or_else(|_| self.state.clone());
+ on_disk.sponsor_nudge_done = true;
+ if let Err(e) = on_disk.save() {
+ eprintln!("aura: could not save the sponsor nudge dismissal: {e}");
+ }
+ cx.notify();
+ }
+
fn toggle_more_modal(&mut self, cx: &mut Context) {
self.show_more_modal = !self.show_more_modal;
cx.notify();
@@ -1279,6 +1305,9 @@ impl Render for AuraView {
))
.text_sm()
.child(self.render_header(cx))
+ .when(self.show_sponsor_nudge(), |d| {
+ d.child(self.render_sponsor_nudge(cx))
+ })
.child(self.render_selector_row(cx))
.when(self.current_section_uses_period(), |d| {
d.child(self.render_period_row(cx))
@@ -1678,6 +1707,134 @@ impl AuraView {
.into_any_element()
}
+ /// The one-time "consider sponsoring" card, a strip under the header.
+ ///
+ /// Warm but calm: the card is a faint accent wash (not a filled banner)
+ /// with an accent-tinted hairline, a heart + title header, the dim body
+ /// copy, and two sponsor buttons — a filled accent primary (GitHub
+ /// Sponsors) and an outlined secondary (Pix). Every color derives from
+ /// theme tokens via [`Theme::blend`], so it holds up under any
+ /// `theme.toml`. The sponsor buttons only open their page; the × is the
+ /// one way to retire the card — see [`Self::dismiss_sponsor_nudge`].
+ fn render_sponsor_nudge(&self, cx: &mut Context) -> AnyElement {
+ let lex = lexicon::pick(self.config.content.goblin_mode);
+ let colors = &self.theme.colors;
+ let accent = colors.accent;
+ let bg = colors.bg;
+
+ let card_bg = Theme::blend(accent, bg, 0.9);
+ let card_border = Theme::blend(accent, bg, 0.7);
+
+ // Primary: a slightly muted accent fill at rest that brightens to the
+ // full accent on hover, so hovering reads as "more", never as a
+ // downgrade. Text uses the same contrast rule as the active period
+ // pill.
+ let primary_bg = Theme::blend(accent, bg, 0.15);
+ let primary_text = self.theme.on_accent_text(accent);
+ let github_btn = div()
+ .id("sponsor-github")
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap_1p5()
+ .px_2()
+ .py_1()
+ .rounded_md()
+ .text_xs()
+ .bg(rgb(primary_bg))
+ .text_color(rgb(primary_text))
+ .hover(move |d| d.bg(rgb(accent)))
+ .child(svg_icon("icons/github.svg", primary_text, 12.0))
+ .child(lex.sponsor_nudge_github)
+ .on_click(|_: &ClickEvent, _, _| open_url(sponsor::SPONSOR_URL));
+
+ // Secondary: outlined ghost on the card's wash; hover fills it with
+ // a slightly stronger wash and firms up the outline.
+ let pix_border = Theme::blend(accent, bg, 0.55);
+ let pix_hover_bg = Theme::blend(accent, bg, 0.8);
+ let pix_hover_border = Theme::blend(accent, bg, 0.3);
+ let pix_btn = div()
+ .id("sponsor-pix")
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap_1p5()
+ .px_2()
+ .py_1()
+ .rounded_md()
+ .border_1()
+ .border_color(rgb(pix_border))
+ .text_xs()
+ .text_color(rgb(colors.text))
+ .hover(move |d| d.bg(rgb(pix_hover_bg)).border_color(rgb(pix_hover_border)))
+ .child(lex.sponsor_nudge_pix)
+ .child(svg_icon("icons/arrow_up_right.svg", colors.text_dim, 12.0))
+ .on_click(|_: &ClickEvent, _, _| open_url(sponsor::PIX_URL));
+
+ // `hover` replaces `icon_button`'s own hover style, so its text
+ // color is restated alongside the wash.
+ let text = colors.text;
+ let dismiss_btn = icon_button("sponsor-dismiss", "icons/close.svg", &self.theme)
+ .rounded_md()
+ .hover(move |d| d.bg(rgb(pix_hover_bg)).text_color(rgb(text)))
+ .on_click(cx.listener(|view, _: &ClickEvent, _, cx| view.dismiss_sponsor_nudge(cx)));
+
+ let header = div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap_2()
+ .child(svg_icon("icons/heart.svg", accent, 14.0))
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .text_sm()
+ .text_color(rgb(colors.text))
+ .child(sel("sponsor-nudge-title", lex.sponsor_nudge_title)),
+ )
+ .child(dismiss_btn);
+
+ let card = div()
+ .flex()
+ .flex_col()
+ .w_full()
+ .gap_2()
+ .px_3()
+ .py_3()
+ .rounded_md()
+ .border_1()
+ .border_color(rgb(card_border))
+ .bg(rgb(card_bg))
+ .child(header)
+ .child(
+ div()
+ .text_xs()
+ .text_color(rgb(colors.text_dim))
+ .child(sel("sponsor-nudge-text", lex.sponsor_nudge)),
+ )
+ .child(
+ div()
+ .flex()
+ .flex_row()
+ .flex_wrap()
+ .items_center()
+ .gap_2()
+ .mt_1()
+ .child(github_btn)
+ .child(pix_btn),
+ );
+
+ div()
+ .flex_shrink_0()
+ .px_4()
+ .py_2()
+ .border_b_1()
+ .border_color(rgb(colors.border))
+ .child(card)
+ .into_any_element()
+ }
+
/// Pill row + agent/plugin mode toggle.
fn render_selector_row(&self, cx: &mut Context) -> AnyElement {
let mut row = div()
diff --git a/crates/aura/src/assets.rs b/crates/aura/src/assets.rs
index 4be2e88..c3d05fc 100644
--- a/crates/aura/src/assets.rs
+++ b/crates/aura/src/assets.rs
@@ -24,6 +24,7 @@ icon_assets! {
(SLIDERS, "sliders.svg"),
(DOWNLOAD, "download.svg"),
(SPARKLE, "sparkle.svg"),
+ (HEART, "heart.svg"),
(CIRCLE_HELP, "circle_help.svg"),
(GITHUB, "github.svg"),
(ARROW_UP_RIGHT, "arrow_up_right.svg"),
diff --git a/crates/aura/src/main.rs b/crates/aura/src/main.rs
index 5ef643f..bab3b27 100644
--- a/crates/aura/src/main.rs
+++ b/crates/aura/src/main.rs
@@ -123,6 +123,20 @@ fn main() -> Result<()> {
let mut persisted_modal_height = AppState::load().ok().and_then(|s| s.modal_height);
runtime::seed_modal_height(persisted_modal_height);
+ // Start the sponsor nudge's one-week clock the first time the tray runs
+ // (including the first launch after upgrading from a build that never
+ // recorded it — see `sponsor::record_first_run`). Only written when the
+ // stamp is missing, and read-modify-write so nothing else is clobbered.
+ // A state file that fails to parse is left alone rather than overwritten
+ // with defaults; the nudge simply waits until it reads again.
+ if let Ok(mut state) = AppState::load() {
+ if aura_core::sponsor::record_first_run(&mut state, chrono::Utc::now()) {
+ if let Err(e) = state.save() {
+ eprintln!("aura: could not record the first run: {e}");
+ }
+ }
+ }
+
// ── Install tray icon ─────────────────────────────────────────────────────
//
// Failure is not fatal, but it *is* serious: the tray icon is Aura's only
diff --git a/docs/cli.md b/docs/cli.md
index 6c3ba90..177b2c2 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -2,8 +2,8 @@
title: CLI reference
status: current
version: 0.1.0
-last_updated: 2026-09-23
-last_verified: 2026-09-23
+last_updated: 2026-09-24
+last_verified: 2026-09-24
source_refs: ["crates/aura/src/cli/"]
owner: "@rfluid"
tags: [cli, docs]
@@ -78,8 +78,8 @@ aura config edit # open in $EDITOR (creates defaults if missing)
aura config validate # parse-check
```
-Keys are dotted paths into the `[window]` / `[tray]` / `[content]` / `[update]`
-tables, e.g. `window.anchor`, `window.max_height`, `update.dismiss_all`. `set`
+Keys are dotted paths into the `[window]` / `[tray]` / `[content]` / `[update]` /
+`[keybindings]` / `[sponsor]` tables, e.g. `window.anchor`, `window.max_height`, `update.dismiss_all`. `set`
validates the value (rejecting bad enums/booleans and suggesting near-miss
keys); pass `none` to clear an optional field. The on-disk `config.toml` is
written with a `#` comment above each key, so the file documents itself. The
diff --git a/docs/configuration.md b/docs/configuration.md
index fd7eadc..e1efeca 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -2,13 +2,14 @@
title: Configuration
status: current
version: 0.2.0
-last_updated: 2026-09-23
-last_verified: 2026-09-23
+last_updated: 2026-09-24
+last_verified: 2026-09-24
source_refs:
- crates/aura-core/src/config.rs
- crates/aura-core/src/config_schema.rs
- crates/aura-core/src/keymap.rs
- crates/aura-core/src/state.rs
+ - crates/aura-core/src/sponsor.rs
- crates/aura/src/cli/config.rs
- crates/aura/src/runtime.rs
- crates/aura/src/main.rs
@@ -69,16 +70,17 @@ for fine tuning.
The generated `config.toml` starts with a link back to this tutorial and then
documents each field above the value it controls. Repeatable `[[agents]]` and
`[[plugins]]` blocks are ordinary TOML arrays of tables; scalar settings live
-under `[window]`, `[tray]`, `[content]`, `[update]`, and `[keybindings]`.
+under `[window]`, `[tray]`, `[content]`, `[update]`, `[keybindings]`, and
+`[sponsor]`.
## File locations
| File | Path | What it holds | Edited by |
|---|---|---|---|
-| Config | `~/.config/aura/config.toml` | Agents, plugins, `[window]`, `[tray]`, `[content]`, `[update]`, `[keybindings]` | You (CLI / editor) |
+| Config | `~/.config/aura/config.toml` | Agents, plugins, `[window]`, `[tray]`, `[content]`, `[update]`, `[keybindings]`, `[sponsor]` | You (CLI / editor) |
| Theme | `~/.config/aura/theme.toml` | Color / font / spinner overrides | You (CLI / editor) |
| Keybindings | `~/.config/aura/keybindings.toml` | Keyboard-shortcut overrides — see [keybindings.md](keybindings.md) | You (CLI / editor) |
-| State | `~/.local/share/aura/state.json` | Active profile selection | Aura (do not hand-edit) |
+| State | `~/.local/share/aura/state.json` | Active profile selection, modal height hint, first-run time, sponsor-nudge status | Aura (do not hand-edit) |
| Plugins dir | `~/.config/aura/plugins/` | Auto-discovered plugin binaries | `aura plugin add` |
Paths follow the XDG base-dir spec via the `dirs` crate, so the exact location
@@ -90,7 +92,7 @@ writes a fully-commented default `config.toml` on first run if none exists.
Config flows through five layers, top (authoring) to bottom (consumption):
1. **Typed structs** — `crates/aura-core/src/config.rs`. `AppConfig` is the
- root (`agents`, `plugins`, `window`, `tray`, `content`, `update`, `keybindings`); each sub-struct derives
+ root (`agents`, `plugins`, `window`, `tray`, `content`, `update`, `keybindings`, `sponsor`); each sub-struct derives
`Serialize`/`Deserialize` and a `Default`, so the whole tree round-trips
through TOML and an empty/partial file still parses (missing fields fall back
to `Default`). This is the **source of truth** — the shape of a config is
@@ -178,8 +180,8 @@ aura config edit # open in $EDITOR (creates defaults if missing)
aura config validate # parse-check
```
-Keys are dotted paths into `[window]` / `[tray]` / `[content]` / `[update]`,
-e.g. `window.anchor`, `window.max_height`, `update.dismiss_all`. A key from an
+Keys are dotted paths into `[window]` / `[tray]` / `[content]` / `[update]` /
+`[keybindings]` / `[sponsor]`, e.g. `window.anchor`, `window.max_height`, `update.dismiss_all`. A key from an
older layout (`display.anchor`) still resolves — `get`, `set` and `describe`
answer with its current name and print a note. `set` rejects bad enums/booleans and
suggests near-miss keys; pass `none` (or empty) to clear an optional field. The
@@ -249,6 +251,21 @@ in `keybindings.toml` — see [keybindings.md](keybindings.md).
|---|---|---|---|---|
| `enabled` | bool | `true` \| `false` | `true` | Install the keymap (vim-style defaults + `keybindings.toml`). `false` leaves the modal mouse-only; Escape still closes it. |
+### `[sponsor]`
+
+Opt-out for the one-time sponsor card. Seven days after Aura first runs, the
+modal shows a small card under the header asking you to consider sponsoring,
+with **Sponsor on GitHub** (opens ), **Pix
+(BRL)** (opens ) and a **×** to dismiss. The sponsor
+links leave the card open, so you can use both; only the × retires it for
+good. The first-run time and the "dismissed"
+flag live in `state.json`, not here — an install upgraded from a
+build without them starts its week on the first launch after the upgrade.
+
+| Key | Type | Allowed | Default | Summary |
+|---|---|---|---|---|
+| `nudge` | bool | `true` \| `false` | `true` | Show the one-time sponsor card a week after the first run. `false` never shows it. |
+
### `[[agents]]` (repeatable)
| Field | Type | Allowed | Summary |
@@ -415,6 +432,10 @@ goblin_mode = false
# Master mute: never render the update button, never call GitHub. Default false.
dismiss_all = false
+
+[sponsor]
+# Show the one-time sponsor card a week after the first run. Default true.
+nudge = true
```
### Modal anchoring (`anchor`)
@@ -614,19 +635,27 @@ peak hour all work normally.
## State file
-Aura writes the active profile selection to
-`~/.local/share/aura/state.json`. This file is managed automatically —
-`toggle_window` reloads it each time the modal opens, so a profile change made
-in one session is visible the next time you click the tray icon. **Do not edit
-by hand**; use `aura state set-profile ` (validated against
+Aura writes the active profile selection — plus a few facts it records about
+itself (the modal's last settled height, when it first ran, and whether the
+sponsor card has been dismissed) — to `~/.local/share/aura/state.json`. This
+file is managed automatically — `toggle_window` reloads it each time the modal
+opens, so a profile change made in one session is visible the next time you
+click the tray icon. **Do not edit by hand**; use `aura state set-profile ` (validated against
`config.agents`).
```json
{
- "active_profile": "Claude Code (Personal)"
+ "active_profile": "Claude Code (Personal)",
+ "modal_height": 409,
+ "first_run": "2026-09-01T12:00:00Z",
+ "sponsor_nudge_done": false
}
```
+`aura state clear` resets only the profile selection; the first-run time and
+sponsor-card flag are kept. To silence the sponsor card, use
+`aura config set sponsor.nudge false` rather than editing this file.
+
## Themes
Aura ships with a built-in dark theme that you can override on a per-token basis
diff --git a/install.sh b/install.sh
index 9d031bd..921d385 100755
--- a/install.sh
+++ b/install.sh
@@ -19,6 +19,8 @@
# Override detection with AURA_INSTALL_MODE=source|release or --mode.
# Build a source checkout from a branch with AURA_BRANCH=name or --branch name.
# Pin a specific release with AURA_VERSION=v1.2.3 or --version v1.2.3.
+# Skip the closing sponsor prompt with AURA_NO_SPONSOR_PROMPT=1 (also skipped
+# when CI is set or no terminal is attached).
set -euo pipefail
@@ -75,6 +77,10 @@ Environment equivalents:
AURA_INSTALL_MODE=source|release
AURA_BRANCH=
AURA_VERSION=v1.2.3
+
+Other environment:
+ AURA_NO_SPONSOR_PROMPT=1 Skip the closing sponsor prompt (also skipped
+ when CI is set or no terminal is attached).
EOF
}
@@ -600,3 +606,46 @@ case "$OS" in
*)
;;
esac
+
+# ── Sponsor prompt ────────────────────────────────────────────────────────────
+#
+# Aura is free and built by one person. Ask once, at the very end, whether the
+# user wants to open the sponsor page — opt-in, default No. Skipped when
+# AURA_NO_SPONSOR_PROMPT is set or under CI. `curl | bash` leaves stdin on the
+# pipe, so the answer is read from /dev/tty; with no terminal we just print the
+# URL and move on.
+
+SPONSOR_URL="https://github.com/sponsors/Rfluid"
+PIX_URL="https://livepix.gg/rfluid"
+
+prompt_sponsor() {
+ [ -z "${AURA_NO_SPONSOR_PROMPT:-}" ] || return 0
+ [ -z "${CI:-}" ] || return 0
+
+ echo ""
+ echo "Aura is free and built by one person. If it helps you, please consider"
+ echo "sponsoring: ${SPONSOR_URL}"
+ echo "Prefer Pix (BRL)? ${PIX_URL}"
+
+ # `[ -r /dev/tty ]` passes even without a controlling terminal, so try to
+ # actually open it (in a subshell, so a failure can't trip `set -e`).
+ ( exec /dev/null || return 0
+
+ local answer=""
+ printf 'Open the sponsor page now? [y/N] ' >/dev/tty
+ read -r answer /dev/null 2>&1; then
+ open "$SPONSOR_URL" >/dev/null 2>&1 &
+ elif [ "$OS" != "Darwin" ] && command -v xdg-open >/dev/null 2>&1; then
+ xdg-open "$SPONSOR_URL" >/dev/null 2>&1 &
+ else
+ echo "Open this link in your browser: ${SPONSOR_URL}"
+ fi
+}
+
+prompt_sponsor