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