feat(rhema-code): add the colors setting with four palettes

The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
This commit is contained in:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

@@ -0,0 +1,73 @@
//! Why a forge call failed.
//!
//! One error type for the whole crate: every backend maps its transport,
//! HTTP, and decoding failures onto it, so callers match one shape no
//! matter which forge answered.
/// Everything that can go wrong between a [`crate::Forge`] call and its
/// answer.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ForgeError {
/// The forge answered with a non-2xx status. `body` is a bounded
/// snippet of the response body — usually the forge's error JSON.
#[error("forge api error (HTTP {status}): {body}")]
Api {
/// The HTTP status the forge answered with.
status: u16,
/// A bounded snippet of the response body.
body: String,
},
/// A response body could not be decoded, or a wire row is missing a
/// field the normalized vocabulary requires. Loud by design: a shape
/// break must surface, not silently drop events.
#[error("malformed forge response: {0}")]
Parse(String),
/// The server kept offering full pages, or offered a page cursor that
/// leads nowhere. Polling stopped rather than looping forever.
#[error("pagination failed: {0}")]
Pagination(String),
/// The request never got an answer (DNS, connect, timeout).
#[error("network error: {0}")]
Network(String),
}
/// The longest body snippet an [`ForgeError::Api`] keeps: enough for any
/// forge's error JSON, small enough that error lines stay bounded.
const BODY_SNIPPET_CAP: usize = 512;
impl ForgeError {
/// Builds [`ForgeError::Api`] from a non-2xx status and the raw body,
/// truncating the body to [`BODY_SNIPPET_CAP`] characters.
pub(crate) fn api(status: reqwest::StatusCode, raw: &str) -> Self {
let body: String = raw.chars().take(BODY_SNIPPET_CAP).collect();
ForgeError::Api {
status: status.as_u16(),
body,
}
}
/// Maps a transport failure onto [`ForgeError::Network`]; reqwest's
/// error is neither `Clone` nor `Eq`, so only its display text travels.
pub(crate) fn network(err: reqwest::Error) -> Self {
ForgeError::Network(err.to_string())
}
}
/// A wire field the normalized vocabulary cannot do without. A missing one
/// is a loud [`ForgeError::Parse`], not a silently skipped row.
pub(crate) fn required<T>(field: Option<T>, what: &str) -> Result<T, ForgeError> {
field.ok_or_else(|| ForgeError::Parse(format!("forge row without {what}")))
}
/// Passes a 2xx response through; turns anything else into
/// [`ForgeError::Api`] with a bounded body snippet.
pub(crate) async fn ensure_success(
response: reqwest::Response,
) -> Result<reqwest::Response, ForgeError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let body = response.text().await.unwrap_or_default();
Err(ForgeError::api(status, &body))
}