Breadlock UI update: motion system, depth polish, and new animations

Adds a full motion system to the lock screen (staggered entrance, dot pop
+ caret, wrong-password shake, success flash, clock crossfade), depth
polish (gradient veil, pill shadow + hairline border, "Enter password"
hint, responsive typography), and a second wave of animations (idle
breathing, success dot cascade, status slide-in, parallax unlock drift,
animated checking ellipsis, opt-in Ken Burns wallpaper pan). Includes a
dev-only breadlock-preview harness that renders every state to PNGs with
no Wayland involved, and a design/sketch.html prototype.

Fixes text alpha being dropped by cosmic-text's glyph-mask path (all text
fades now work) and double-drifting that made the date/status overlap the
clock/pill during unlock.
This commit is contained in:
Breadway 2026-08-21 12:28:41 +08:00
parent 647a211a93
commit f0b66cd791
14 changed files with 1771 additions and 84 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/
# breadlock-preview PNG output (dev-only animation harness)
preview/

View file

@ -9,9 +9,13 @@
mode = "color"
path = ""
blur = false
# (Ken Burns is a breadlock-only background effect; not used by the greeter.)
ken_burns = false
[clock]
format = "%H:%M"
# strftime format for the date line under the clock; empty string hides it
date_format = "%A · %b %d"
[font]
family = "Varela Round"

View file

@ -26,6 +26,10 @@ pub struct Background {
/// v2 feature flag — no-op (with a warning) in v1, which only supports a
/// static color or image background.
pub blur: bool,
/// Slow Ken Burns pan on image backgrounds (a gentle drift + zoom instead
/// of a static image). CPU cost: the background redraws continuously at a
/// low frame rate while locked, so this is opt-in.
pub ken_burns: bool,
}
impl Default for Background {
@ -34,6 +38,7 @@ impl Default for Background {
mode: BackgroundMode::Color,
path: String::new(),
blur: false,
ken_burns: false,
}
}
}
@ -42,12 +47,16 @@ impl Default for Background {
#[serde(default)]
pub struct Clock {
pub format: String,
/// strftime format for the date line under the clock. Empty string hides
/// the date. `%A` = full weekday, `%b` = abbreviated month, `%d` = day.
pub date_format: String,
}
impl Default for Clock {
fn default() -> Self {
Self {
format: "%H:%M".to_string(),
date_format: "%A · %b %d".to_string(),
}
}
}
@ -89,7 +98,9 @@ mod tests {
fn defaults_match_design_system() {
let a = Appearance::default();
assert_eq!(a.background.mode, BackgroundMode::Color);
assert!(!a.background.ken_burns, "Ken Burns must be opt-in (CPU cost)");
assert_eq!(a.clock.format, "%H:%M");
assert_eq!(a.clock.date_format, "%A · %b %d");
assert_eq!(a.font.family, "Varela Round");
}

View file

@ -6,6 +6,7 @@
pub use bread_theme::tokens;
use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, SwashCache};
use std::collections::HashMap;
use tiny_skia::{Path, PathBuilder, Pixmap, PremultipliedColorU8};
/// Builds a rounded-rectangle path. `radius` is clamped so it never exceeds
@ -32,6 +33,10 @@ pub fn rounded_rect(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<Path>
pub struct TextRenderer {
font_system: FontSystem,
swash_cache: SwashCache,
/// Exact glyph-pixel span `(top, height)` per unique `(text, family,
/// size)` — see [`Self::measure_box`]. Keyed by size in centipixels so
/// fractional sizes don't thrash the cache.
boxes: HashMap<(String, String, u32), (f32, f32)>,
}
impl Default for TextRenderer {
@ -45,6 +50,7 @@ impl TextRenderer {
Self {
font_system: FontSystem::new(),
swash_cache: SwashCache::new(),
boxes: HashMap::new(),
}
}
@ -58,6 +64,47 @@ impl TextRenderer {
buffer
}
/// Exact vertical span `(top, height)` of the glyph pixels a line drawn
/// with [`Self::draw_line`] at `(0, 0)` would occupy: `top` is the
/// distance from the draw origin down to the highest glyph pixel.
/// `draw_line`'s `origin_y` anchors the *top* of the text (not the
/// baseline), so centering a line of height `h` in a box spanning
/// `[y0, y1]` needs `origin_y = y0 + (h - height) / 2 - top`.
///
/// Measured exactly by rendering the line once into a tiny offscreen
/// pixmap and scanning it, then cached — lock-screen text changes rarely
/// (clock per minute, date per day, static hints once), so the one-off
/// cost is negligible and the result is correct for any font.
pub fn measure_box(&mut self, text: &str, family: &str, size_px: f32) -> (f32, f32) {
let key = (text.to_string(), family.to_string(), (size_px * 100.0) as u32);
if let Some(b) = self.boxes.get(&key) {
return *b;
}
let w = self.measure_line(text, family, size_px).ceil().max(1.0) as u32;
let h = (size_px * 1.5).ceil().max(1.0) as u32;
let mut probe = match Pixmap::new(w, h) {
Some(p) => p,
None => return (0.0, size_px),
};
self.draw_line(&mut probe, text, family, size_px, tiny_skia::Color::WHITE, 0.0, 0.0);
let (mut top, mut bottom) = (h as f32, 0.0f32);
for y in 0..h {
for x in 0..w {
if probe.pixel(x, y).is_some_and(|p| p.alpha() > 0) {
top = top.min(y as f32);
bottom = bottom.max(y as f32);
}
}
}
let boxed = if bottom >= top {
(top, bottom - top + 1.0)
} else {
(0.0, size_px)
};
self.boxes.insert(key, boxed);
boxed
}
/// Width in pixels `text` would occupy if drawn via [`Self::draw_line`]
/// with the same `family`/`size_px` — use to center text before drawing.
pub fn measure_line(&mut self, text: &str, family: &str, size_px: f32) -> f32 {
@ -85,7 +132,13 @@ impl TextRenderer {
let buffer = self.shape_line(text, family, size_px, pixmap.width() as f32);
let c8 = color.to_color_u8();
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), c8.alpha());
// cosmic-text's glyph-Mask rendering drops the base color's alpha
// entirely — its swash `with_pixels` uses the glyph coverage as the
// output alpha (see the "TODO: blend base alpha?" in its source), so
// a translucent text color would render fully opaque. Fold the
// requested alpha back in at blend time below; RGB stays straight.
let base_alpha = c8.alpha();
let text_color = cosmic_text::Color::rgba(c8.red(), c8.green(), c8.blue(), base_alpha);
let (width, height) = (pixmap.width() as i32, pixmap.height() as i32);
let ox = origin_x as i32;
@ -103,6 +156,12 @@ impl TextRenderer {
if a == 0 {
return;
}
// `a` is the glyph coverage; combine it with the requested
// color alpha for the true source alpha.
let a = (a as u32 * base_alpha as u32 / 255) as u8;
if a == 0 {
return;
}
blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a);
},
);
@ -168,4 +227,37 @@ mod tests {
// exact glyph coverage depends on whatever fonts are installed on the CI host.
assert!(pixmap.pixels().iter().all(|p| p.alpha() == 255));
}
#[test]
fn draw_line_respects_color_alpha() {
// Regression: cosmic-text's glyph-Mask path drops the base color's
// alpha (coverage becomes the only alpha), so translucent text used to
// render fully opaque — which broke every text fade on the lock screen
// (clock/date/hint/status never faded during appear/unlock).
let mut renderer = TextRenderer::new();
let mut full = Pixmap::new(200, 40).unwrap();
full.fill(tiny_skia::Color::BLACK);
renderer.draw_line(
&mut full,
"12:34",
"sans-serif",
24.0,
tiny_skia::Color::WHITE,
0.0,
0.0,
);
let full_max = full.pixels().iter().map(|p| p.red()).max().unwrap();
assert!(full_max > 200, "full-alpha text should render bright, got {full_max}");
let faint = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.1).unwrap();
let mut low = Pixmap::new(200, 40).unwrap();
low.fill(tiny_skia::Color::BLACK);
renderer.draw_line(&mut low, "12:34", "sans-serif", 24.0, faint, 0.0, 0.0);
let low_max = low.pixels().iter().map(|p| p.red()).max().unwrap();
assert!(
low_max < 100,
"10%-alpha text must not render near-white, got {low_max}"
);
}
}

View file

@ -8,10 +8,16 @@ path = ""
# v2 feature — accepted but currently just logs a warning and shows the
# background unblurred (needs a wlr-screencopy capture, not implemented yet).
blur = false
# Slow Ken Burns pan on image backgrounds (gentle drift + zoom). Opt-in: the
# background redraws continuously at a low frame rate while locked.
ken_burns = false
[clock]
# strftime format
format = "%H:%M"
# strftime format for the date line under the clock; empty string hides it
# (e.g. %A · %b %d → "Friday · Aug 21")
date_format = "%A · %b %d"
[font]
family = "Varela Round"
@ -20,3 +26,7 @@ family = "Varela Round"
# How long the "wrong password" state (red pill) shows before input
# re-enables, in milliseconds.
fail_timeout_ms = 800
[animation]
# Subtle glow pulse on the password pill every few seconds while idle.
breathe = true

View file

@ -17,6 +17,13 @@ path = "src/main.rs"
name = "breadlock-auth-check"
path = "src/bin/breadlock-auth-check.rs"
# Dev-only harness: renders the lock-screen motion system (render.rs) to a
# folder of PNGs with no Wayland involved, for eyeballing animations without
# locking a session. Not installed by the package.
[[bin]]
name = "breadlock-preview"
path = "src/bin/breadlock-preview.rs"
[dependencies]
breadlock-ui = { path = "../breadlock-ui", features = ["paint"] }
bread-utils = { workspace = true, features = ["bread-client"] }

View file

@ -1,14 +1,26 @@
//! Lock-screen background: a solid palette color, or a static image scaled
//! to cover the surface. Live blur-of-desktop (hyprlock-style) is a v2
//! follow-up (see README) — `blur = true` is accepted but only logs a
//! warning in v1.
//! warning in v1. `ken_burns = true` adds a slow, continuous pan+zoom to
//! image backgrounds (opt-in: it keeps the background redrawing at a low
//! frame rate while locked).
use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
use std::f32::consts::TAU;
use tiny_skia::{Pixmap, PixmapPaint, Transform};
/// One full Ken Burns pan+zoom cycle, in seconds. Deliberately slow so the
/// motion reads as a gentle drift rather than a slideshow.
const KENBURNS_PERIOD_S: f32 = 90.0;
/// Extra zoom beyond plain cover-fit — gives the pan room to travel without
/// ever exposing the image edges.
const KENBURNS_ZOOM: f32 = 1.06;
pub enum Background {
Color(tiny_skia::Color),
Image(Pixmap),
/// `(source, ken_burns)` — the flag decides whether `paint` pans over
/// time or draws statically.
Image(Pixmap, bool),
}
impl Background {
@ -31,7 +43,7 @@ impl Background {
return fallback();
}
match Pixmap::load_png(&cfg.path) {
Ok(pixmap) => Background::Image(pixmap),
Ok(pixmap) => Background::Image(pixmap, cfg.ken_burns),
Err(err) => {
tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color");
fallback()
@ -41,25 +53,51 @@ impl Background {
}
}
/// True when this background needs continuous redraws (Ken Burns pan).
pub fn ken_burns(&self) -> bool {
matches!(self, Background::Image(_, true))
}
/// Paints this background into `target`, cover-fit (scaled uniformly to
/// fill the surface, cropping any overflow — never letterboxed).
pub fn paint(&self, target: &mut Pixmap) {
/// fill the surface, cropping any overflow — never letterboxed). `t_secs`
/// is the monotonic clock: with Ken Burns enabled the image slowly pans
/// and zooms along a smooth Lissajous-ish drift, so consecutive frames
/// differ slightly but never jump.
pub fn paint(&self, target: &mut Pixmap, t_secs: f32) {
match self {
Background::Color(c) => target.fill(*c),
Background::Image(source) => {
Background::Image(source, ken_burns) => {
let (tw, th) = (target.width() as f32, target.height() as f32);
let (sw, sh) = (source.width() as f32, source.height() as f32);
if sw <= 0.0 || sh <= 0.0 {
return;
}
let scale = (tw / sw).max(th / sh);
let cover = (tw / sw).max(th / sh);
let (scale, tx, ty) = if *ken_burns {
let scale = cover * KENBURNS_ZOOM;
// Pan range: how far the scaled image overhangs each axis.
let pan_x = (sw * scale - tw).max(0.0);
let pan_y = (sh * scale - th).max(0.0);
let phase = t_secs * TAU / KENBURNS_PERIOD_S;
// Sin/cos offset by a quarter cycle: the pan traces a slow
// ellipse, starting from a corner.
(
scale,
-pan_x * (0.5 + 0.5 * phase.sin()),
-pan_y * (0.5 + 0.5 * phase.cos()),
)
} else {
(cover, 0.0, 0.0)
};
target.fill(tiny_skia::Color::BLACK);
target.draw_pixmap(
0,
0,
source.as_ref(),
&PixmapPaint::default(),
Transform::from_scale(scale, scale),
// scale first (image coords → scaled), then translate into
// the pan position.
Transform::from_translate(tx, ty).pre_concat(Transform::from_scale(scale, scale)),
None,
);
}

View file

@ -0,0 +1,153 @@
//! Dev-only harness: renders the breadlock lock-screen motion system to a
//! folder of PNGs so the new animations can be eyeballed without locking a
//! session (or even touching Wayland). Every scene below pins concrete
//! progress values into `render::FrameInputs` — the same struct the real
//! locker feeds from live timestamps — so what you see here is exactly what
//! `state.rs` computes at runtime.
//!
//! Not installed by the package; run from a build tree with
//! `cargo run --bin breadlock-preview [out-dir]` (default `preview/`).
//! Scenes are written as `NN-<name>.png` in alphabetical-file order, so a
//! file manager or `for f in preview/*.png; do ...` steps through them as a
//! flipbook roughly in timeline order.
use breadlock_ui::painter::TextRenderer;
use breadlock_ui::theme;
use render::{compose, FrameInputs};
// Reuse the real renderer + background code via the same `#[path]` include
// trick as `breadlock-auth-check` (dev bins are separate crates and can't see
// `main.rs`'s modules otherwise). `render.rs` pulls `crate::background::Background`,
// which this crate root provides below. Only `compose`/`FrameInputs` are used
// here; the compositor-side helpers (blit_to_shm, the timing consts) stay
// included so this harness exercises the *real* renderer, so dead-code is
// expected and silenced.
#[allow(dead_code)]
#[path = "../background.rs"]
mod background;
#[allow(dead_code)]
#[path = "../render.rs"]
mod render;
const W: u32 = 960;
const H: u32 = 540;
const FONT: &str = "Varela Round";
struct Scene {
name: &'static str,
clock: &'static str,
date: &'static str,
clock_old: Option<(&'static str, f32)>,
password_len: usize,
failed: bool,
failed_t: f32,
dot_pop_t: f32,
keystroke_age: Option<f32>,
/// Idle caret blink phase driver (`t_secs` in FrameInputs). Only matters
/// for scenes with no keystroke age: phase = (t × 1.8) % 1.0, caret is
/// lit below 0.5.
t_secs: f32,
status: Option<&'static str>,
appear_t: f32,
unlock_t: f32,
breathe_t: f32,
status_t: f32,
}
impl Default for Scene {
fn default() -> Self {
Self {
name: "",
clock: "12:34",
date: "Friday · Aug 21",
clock_old: None,
password_len: 0,
failed: false,
failed_t: 0.0,
dot_pop_t: 1.0,
keystroke_age: None,
t_secs: 0.2,
status: None,
appear_t: 1.0,
unlock_t: 0.0,
breathe_t: 0.0,
status_t: 1.0,
}
}
}
fn main() {
let out_dir = std::env::args()
.nth(1)
.unwrap_or_else(|| "preview".to_string());
std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir");
let palette = theme::load_palette();
let background = background::Background::load(
&breadlock_ui::config::Background::default(),
&palette,
);
let scenes = [
// ---- Staggered entrance: clock leads, pill pops in last (overshoot).
Scene { name: "01-appear-start", appear_t: 0.0, ..Scene::default() },
Scene { name: "02-appear-clock", password_len: 4, appear_t: 0.25, ..Scene::default() },
Scene { name: "03-appear-pill", password_len: 4, appear_t: 0.55, ..Scene::default() },
// ---- Rest pose: empty pill showing the "Enter password" hint.
Scene { name: "04-rest-pose", t_secs: 0.5, ..Scene::default() },
// ---- Idle breath: glow peak on the pill (accent ring + deeper shadow).
Scene { name: "05-breathe-peak", breathe_t: 1.0, ..Scene::default() },
// ---- Typing: newest dot mid-pop, caret solid.
Scene { name: "06-typing-pop", password_len: 6, dot_pop_t: 0.4, keystroke_age: Some(0.2), ..Scene::default() },
// ---- Idle blink: two dots, caret lit (phase 0.36 → visible half-cycle).
Scene { name: "07-idle-blink", password_len: 2, ..Scene::default() },
// ---- Checking: status mid slide-in with the animated ellipsis.
Scene { name: "08-checking", status: Some("Checking…"), status_t: 0.5, ..Scene::default() },
// ---- Wrong password: mid-shake, red pill, red status (settled).
Scene { name: "09-failed-shake", password_len: 6, failed: true, failed_t: 0.35, status: Some("Wrong password"), ..Scene::default() },
// ---- Success: green flash ring, dots cascading accent → white.
Scene { name: "10-success-flash", password_len: 6, unlock_t: 0.12, ..Scene::default() },
// ---- Unlock fade-out: chrome faded, parallax drift (clock furthest).
Scene { name: "11-unlock-fade", password_len: 6, unlock_t: 0.8, ..Scene::default() },
// ---- Minute rollover: old clock fading out above, new fading in below.
Scene { name: "12-clock-crossfade", clock: "12:35", clock_old: Some(("12:34", 0.5)), password_len: 4, ..Scene::default() },
];
let mut text = TextRenderer::new();
let mut count = 0;
for scene in &scenes {
let inputs = FrameInputs {
width: W,
height: H,
background: &background,
palette: &palette,
font_family: FONT,
clock_text: scene.clock,
date_text: scene.date,
clock_old: scene.clock_old,
password_len: scene.password_len,
failed: scene.failed,
failed_t: scene.failed_t,
dot_pop_t: scene.dot_pop_t,
keystroke_age: scene.keystroke_age,
t_secs: scene.t_secs,
breathe_t: scene.breathe_t,
status_t: scene.status_t,
status_text: scene.status,
appear_t: scene.appear_t,
unlock_t: scene.unlock_t,
};
let Some(pixmap) = compose(&mut text, &inputs) else {
eprintln!("compose returned None for scene {}", scene.name);
std::process::exit(1);
};
let path = format!("{}/{}.png", out_dir, scene.name);
pixmap
.save_png(&path)
.unwrap_or_else(|err| panic!("failed to write {path}: {err}"));
count += 1;
println!("wrote {path}");
}
println!("{count} frames → {out_dir}/");
}

View file

@ -8,6 +8,7 @@ pub struct Config {
#[serde(flatten)]
pub appearance: Appearance,
pub input: Input,
pub animation: Animation,
}
#[derive(Debug, Clone, Deserialize)]
@ -25,6 +26,23 @@ impl Default for Input {
}
}
/// Idle animation toggles. Everything here runs on a low-duty-cycle timer so
/// the software-rendered lock screen doesn't burn CPU while idle.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Animation {
/// Subtle glow pulse on the password pill every few seconds — proves the
/// screen is live, not frozen. Runs only during a short active window of
/// each cycle (see `BREATHE_*` in render.rs).
pub breathe: bool,
}
impl Default for Animation {
fn default() -> Self {
Self { breathe: true }
}
}
pub fn load() -> Config {
breadlock_ui::config::load_or_default(&config_path())
}
@ -46,6 +64,11 @@ mod tests {
assert_eq!(Config::default().input.fail_timeout_ms, 800);
}
#[test]
fn default_animation_breathe_is_on() {
assert!(Config::default().animation.breathe);
}
#[test]
fn flattened_appearance_parses_alongside_input() {
let toml = "[clock]\nformat = \"%H:%M:%S\"\n[input]\nfail_timeout_ms = 1200\n";

View file

@ -2,6 +2,7 @@ use smithay_client_toolkit::seat::keyboard::{
KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers,
};
use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState};
use std::time::Instant;
use wayland_client::protocol::{wl_keyboard, wl_seat, wl_surface};
use wayland_client::{Connection, QueueHandle};
use zeroize::Zeroize;
@ -179,6 +180,10 @@ impl AppState {
for ch in text.chars().filter(|c| !c.is_control()) {
self.password.push(ch);
}
// Only keystrokes that *grew* the password re-prime the
// newest-dot pop-in and the caret's solid phase (see the
// `last_keystroke` field doc in state.rs).
self.last_keystroke = Some(Instant::now());
self.clear_failed_state();
}
}
@ -190,6 +195,9 @@ impl AppState {
fn clear_failed_state(&mut self) {
if matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError) {
self.auth_state = AuthState::Idle;
// Drop the red-pill tint and shake offsets; `failed_at` is also
// cleared so `schedule_clear_failed`'s timer is a no-op.
self.failed_at = None;
}
}

View file

@ -177,10 +177,12 @@ fn run_lock() {
"PAM context initialization failed — check /etc/pam.d/breadlock exists and is valid; authentication cannot succeed until this is fixed"
);
state.auth_state = AuthState::ConfigError;
state.failed_at = Some(std::time::Instant::now());
}
auth::AuthError::Authenticate | auth::AuthError::AccountInvalid => {
tracing::warn!(%err, "authentication failed");
state.auth_state = AuthState::Failed;
state.failed_at = Some(std::time::Instant::now());
}
}
state.schedule_clear_failed(auth_result_qh.clone());
@ -218,8 +220,20 @@ fn run_lock() {
password: zeroize::Zeroizing::new(String::with_capacity(128)),
auth_state: AuthState::Idle,
auth_tx,
started: std::time::Instant::now(),
appear_started: None,
unlocking: None,
last_keystroke: None,
failed_at: None,
last_clock_text: String::new(),
clock_anim_started: None,
status_anim_started: None,
last_auth_state: AuthState::Idle,
breathe_started: None,
breathe_next_at: Some(
std::time::Instant::now()
+ std::time::Duration::from_millis(render::BREATHE_INITIAL_DELAY_MS),
),
anim_timer_armed: false,
exit: false,
};

View file

@ -1,6 +1,11 @@
//! Frame composition: paints one full lock-screen frame (background,
//! password pill, clock, status line) into a `tiny_skia::Pixmap`, then
//! copies it into a Wayland `wl_shm` buffer.
//! Frame composition: paints one full lock-screen frame (background, clock,
//! date, password pill, dots/caret, status line) into a `tiny_skia::Pixmap`,
//! then copies it into a Wayland `wl_shm` buffer.
//!
//! Motion: every effect is driven by raw 0..1 progress inputs (see
//! [`FrameInputs`]) that `state.rs` computes from timestamps; this module only
//! turns progress into pixels, so the whole timeline is unit-testable off a
//! compositor (see `breadlock-preview`, which renders frames to PNG).
//!
//! tiny-skia's in-memory pixel format is byte-order RGBA; `wl_shm`'s
//! `Argb8888` format is host-endian `0xAARRGGBB`, i.e. byte-order BGRA on
@ -9,19 +14,88 @@
use crate::background::Background;
use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer};
use breadlock_ui::theme::tiny_skia_color;
use std::f32::consts::PI;
use std::time::Instant;
use tiny_skia::{Color, Paint, Pixmap};
use tiny_skia::{Color, Paint, Pixmap, Rect, Transform};
/// Lock-appear duration: overlay fades in and eases up from below rest.
/// Lock-appear duration: elements ease in on a small stagger (see the
/// `*_DELAY_MS` consts) instead of one uniform fade.
pub const APPEAR_MS: u64 = 450;
/// Unlock-fade duration: overlay fades out with a slight upward drift.
pub const UNLOCK_MS: u64 = 400;
/// Redraw cadence while an animation is in flight (~60 Hz).
/// Total unlock duration: [`FLASH_MS`] of green success flash, then a
/// fade-out with a slight upward drift.
pub const UNLOCK_MS: u64 = 650;
/// Green success-flash phase at the start of the unlock.
pub const FLASH_MS: u64 = 250;
/// Wrong-password shake duration (the red pill stays up until
/// `input.fail_timeout_ms`, which outlives the shake).
pub const SHAKE_MS: u64 = 380;
/// Newest password-dot pop-in duration.
pub const DOT_POP_MS: u64 = 200;
/// Minute-rollover crossfade duration.
pub const CLOCK_CROSSFADE_MS: u64 = 300;
/// Redraw cadence while any fast animation is in flight (~60 Hz).
pub const ANIM_FRAME_MS: u64 = 16;
/// Cadence while only slow effects are running (idle breath, Ken Burns pan).
pub const SLOW_FRAME_MS: u64 = 62;
/// Status-line slide-in duration ("Checking…" / "Wrong password" rise in).
pub const STATUS_SLIDE_MS: u64 = 200;
/// Idle breathing (config `animation.breathe`): a subtle glow pulse on the
/// pill every few seconds. Only the active window redraws (low-duty-cycle
/// timer in state.rs), so idle CPU stays near zero.
pub const BREATHE_PERIOD_MS: u64 = 4000;
pub const BREATHE_ACTIVE_MS: u64 = 1200;
/// How long after the lock appears before the first breath.
pub const BREATHE_INITIAL_DELAY_MS: u64 = 1500;
const APPEAR_SLIDE_PX: f32 = 28.0;
const UNLOCK_DRIFT_PX: f32 = 20.0;
const DIM_ALPHA: f32 = 0.28;
/// Parallax: during the unlock fade each element drifts up at a slightly
/// different speed (clock furthest, status least) for a sense of depth.
const DRIFT_CLOCK: f32 = 1.25;
const DRIFT_DATE: f32 = 1.25;
const DRIFT_PILL: f32 = 1.0;
const DRIFT_STATUS: f32 = 0.8;
/// Peak glow multiplier added to the pill shadow during an idle breath, and
/// the accent-ring alpha at the breath peak (sketch's `breathe` keyframes).
const BREATHE_GLOW: f32 = 0.6;
const BREATHE_RING_ALPHA: f32 = 0.12;
/// How far the status line rises during its slide-in.
const STATUS_SLIDE_PX: f32 = 8.0;
/// Dim veil over the wallpaper: a vertical gradient, darker at the top so
/// the clock (in the upper third) sits on the deepest tone.
const DIM_ALPHA_TOP: f32 = 0.34;
const DIM_ALPHA_BOTTOM: f32 = 0.16;
/// Pill hairline-border alpha (sketch: `1px solid rgba(255,255,255,.08)`).
const PILL_BORDER_ALPHA: f32 = 0.10;
/// Fake drop-shadow layers under the pill (tiny-skia has no blur filter):
/// `(grow_px, black_alpha)` — a few concentric copies at fading alpha read
/// as a soft shadow when drawn under the fill.
const PILL_SHADOW: [(f32, f32); 3] = [(2.5, 0.20), (5.0, 0.11), (7.5, 0.05)];
/// Clock/date scale with the surface, clamped to the sketch's CSS ranges
/// (`clamp(34px, 7.5vw, 60px)` and `clamp(11px, 1.6vw, 14px)`).
const CLOCK_SIZE_MIN: f32 = 34.0;
const CLOCK_SIZE_MAX: f32 = 60.0;
const DATE_SIZE_MIN: f32 = 11.0;
const DATE_SIZE_MAX: f32 = 14.0;
/// Fraction of the shake window over which the pill tints to red (smooth
/// transition instead of an instant color swap).
const SHAKE_RED_FRAC: f32 = 150.0 / SHAKE_MS as f32;
/// Fraction of the unlock window that is the green flash.
const FLASH_FRAC: f32 = FLASH_MS as f32 / UNLOCK_MS as f32;
/// Entrance stagger: each element's appear starts this many ms into the
/// `APPEAR_MS` window, so the clock leads and the status trails.
const CLOCK_DELAY_MS: u64 = 0;
const DATE_DELAY_MS: u64 = 80;
const PILL_DELAY_MS: u64 = 100;
const STATUS_DELAY_MS: u64 = 160;
/// Dot/caret geometry. Dot diameter matches the sketch's 69px range.
const DOT_R: f32 = 4.5;
const DOT_GAP: f32 = 18.0;
const CARET_W: f32 = 2.0;
/// Seconds between caret blinks while idle; solid for the first `CARET_HOLD_S`
/// after a keystroke (terminal-style).
const CARET_BLINK_HZ: f32 = 1.8;
const CARET_HOLD_S: f32 = 0.5;
pub struct FrameInputs<'a> {
pub width: u32,
@ -30,10 +104,27 @@ pub struct FrameInputs<'a> {
pub palette: &'a breadlock_ui::theme::Palette,
pub font_family: &'a str,
pub clock_text: &'a str,
/// Date line under the clock. Empty string hides it.
pub date_text: &'a str,
/// Minute-rollover crossfade: `(previous clock text, raw 0..1 progress)`.
pub clock_old: Option<(&'a str, f32)>,
pub password_len: usize,
/// True while showing a failed-attempt state (red pill). No animated
/// shake in v1 — just a color/status-text indicator.
/// True while showing a failed attempt (red pill + shake + red status).
pub failed: bool,
/// Raw 0..1 progress of the wrong-password shake. 0 when not failed.
pub failed_t: f32,
/// Raw 0..1 progress of the newest dot's pop-in. 1 when no pop is live.
pub dot_pop_t: f32,
/// Seconds since the most recent keystroke (caret solid/blink behavior).
pub keystroke_age: Option<f32>,
/// Monotonic seconds since app start (idle caret blink cadence, Ken Burns
/// pan phase).
pub t_secs: f32,
/// Idle-breathing envelope: 0 when no breath is active, ramping 0..1..0
/// (one sine hump) over the active window. Scales the pill's glow.
pub breathe_t: f32,
/// Status-line slide-in progress (0..1, 1 settled).
pub status_t: f32,
pub status_text: Option<&'a str>,
/// Raw 0..1 lock-appear progress (pre-ease). 1 is rest pose.
pub appear_t: f32,
@ -48,6 +139,23 @@ pub fn ease_out_cubic(t: f32) -> f32 {
1.0 - inv * inv * inv
}
/// Ease-out-back: overshoots past 1 (~5%) then settles — used for the pill's
/// entrance scale so it pops instead of sliding.
pub fn ease_out_back(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
let c1 = 1.70158;
let c3 = c1 + 1.0;
1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
}
/// Horizontal shake offset in px for raw progress `t` in 0..1 — a damped
/// sinusoid that starts and ends at rest.
pub fn damped_shake_x(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
let env = (1.0 - t) * (1.0 - t);
env * 9.0 * (PI * 5.5 * t).sin()
}
/// Linear 0..1 progress since `started` over `duration_ms`.
pub fn unit_progress(started: Instant, duration_ms: u64) -> f32 {
let dur = duration_ms as f32 / 1000.0;
@ -57,7 +165,25 @@ pub fn unit_progress(started: Instant, duration_ms: u64) -> f32 {
(started.elapsed().as_secs_f32() / dur).clamp(0.0, 1.0)
}
/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress.
/// Idle-breath envelope: a single sine hump over the active window (0 at the
/// start and end, 1 at the peak).
pub fn breathe_envelope(t: f32) -> f32 {
(PI * t.clamp(0.0, 1.0)).sin()
}
/// Appear progress for a single staggered element: raw overall progress
/// `appear_t` is spread over the `APPEAR_MS` window; the element only starts
/// moving `delay_ms` in.
fn staggered_t(appear_t: f32, delay_ms: u64) -> f32 {
if APPEAR_MS <= delay_ms {
return appear_t;
}
let window = (APPEAR_MS - delay_ms) as f32;
((appear_t * APPEAR_MS as f32 - delay_ms as f32) / window).clamp(0.0, 1.0)
}
/// Overlay alpha and y-offset (positive is down) from raw 0..1 progress —
/// used for the full-screen dim veil, which fades with the whole chrome.
pub fn overlay_motion(appear_t: f32, unlock_t: f32) -> (f32, f32) {
let appear = ease_out_cubic(appear_t);
let unlock = ease_out_cubic(unlock_t);
@ -71,129 +197,397 @@ fn faded(mut color: Color, alpha: f32) -> Color {
color
}
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let mix = |x: f32, y: f32| x + (y - x) * t;
Color::from_rgba(
mix(a.red(), b.red()),
mix(a.green(), b.green()),
mix(a.blue(), b.blue()),
mix(a.alpha(), b.alpha()),
)
.unwrap_or(a)
}
/// Composes one frame. Returns `None` only if `width`/`height` are degenerate
/// (a `0x0` `configure`, which some compositors send transiently).
pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap> {
let mut pixmap = Pixmap::new(inputs.width, inputs.height)?;
inputs.background.paint(&mut pixmap);
inputs.background.paint(&mut pixmap, inputs.t_secs);
let (alpha, y_off) = overlay_motion(inputs.appear_t, inputs.unlock_t);
if alpha <= 0.0 {
// Overall chrome fade: appear eased in, unlock eased out. The unlock
// `fade` multiplies every element below.
let unlock = ease_out_cubic(inputs.unlock_t);
let fade = 1.0 - unlock;
let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t);
if veil_alpha <= 0.0 {
return Some(pixmap);
}
let (w, h) = (inputs.width as f32, inputs.height as f32);
let surface_color = faded(tiny_skia_color(&inputs.palette.color0), alpha);
let accent_color = faded(tiny_skia_color(&inputs.palette.color4), alpha);
let surface_color = faded(tiny_skia_color(&inputs.palette.color0), veil_alpha);
let accent_color = faded(tiny_skia_color(&inputs.palette.color4), veil_alpha);
let green_color = faded(tiny_skia_color(&inputs.palette.color2), veil_alpha);
let on_surface = faded(
tiny_skia_color(breadlock_ui::theme::ink_on(&inputs.palette.color0)),
alpha,
veil_alpha,
);
let red_color = faded(tiny_skia_color(&inputs.palette.color1), alpha);
let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha);
// Translucent veil over the (static) wallpaper — fades with the chrome.
if let Some(rect) = tiny_skia::Rect::from_xywh(0.0, 0.0, w, h) {
// Translucent veil over the (static) wallpaper — a vertical gradient
// (deeper at the top) that fades in with the chrome.
if let Some(shader) = tiny_skia::LinearGradient::new(
tiny_skia::Point::from_xy(0.0, 0.0),
tiny_skia::Point::from_xy(0.0, h),
vec![
tiny_skia::GradientStop::new(
0.0,
Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_TOP * veil_alpha)
.unwrap_or(Color::TRANSPARENT),
),
tiny_skia::GradientStop::new(
1.0,
Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_BOTTOM * veil_alpha)
.unwrap_or(Color::TRANSPARENT),
),
],
tiny_skia::SpreadMode::Pad,
Transform::identity(),
) {
let mut paint = Paint::default();
paint.set_color(
Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA * alpha).unwrap_or(Color::TRANSPARENT),
);
pixmap.fill_rect(rect, &paint, tiny_skia::Transform::identity(), None);
paint.shader = shader;
if let Some(rect) = Rect::from_xywh(0.0, 0.0, w, h) {
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
}
}
// Clock, large, centered in the upper third.
let clock_size = 64.0;
let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size);
text.draw_line(
&mut pixmap,
inputs.clock_text,
inputs.font_family,
clock_size,
faded(Color::WHITE, alpha),
(w - clock_w) / 2.0,
h * 0.28 + y_off,
);
// Per-element staggered entrance.
let clock_e = ease_out_cubic(staggered_t(inputs.appear_t, CLOCK_DELAY_MS));
let date_e = ease_out_cubic(staggered_t(inputs.appear_t, DATE_DELAY_MS));
let pill_t = staggered_t(inputs.appear_t, PILL_DELAY_MS);
let pill_e = ease_out_cubic(pill_t);
let pill_scale = ease_out_back(pill_t);
let status_e = ease_out_cubic(staggered_t(inputs.appear_t, STATUS_DELAY_MS));
// Per-element vertical motion: the appear part is uniform, the unlock
// drift is scaled per element for parallax.
let elem_y = |e: f32, drift: f32| APPEAR_SLIDE_PX * (1.0 - e) - UNLOCK_DRIFT_PX * unlock * drift;
// Password pill, centered; turns red while showing a failed attempt.
// ---- Clock, large, centered in the upper third (size scales with the
// surface). A minute rollover crossfades old text out (drifting up) while
// the new fades in from below.
let clock_size = (w * 0.075).clamp(CLOCK_SIZE_MIN, CLOCK_SIZE_MAX);
// Rest-pose anchors: every element drifts from its own rest position, so
// the clock+date and pill+status clusters move as units. (Anchoring the
// date/status to the already-drifted clock/pill *and* adding their own
// `elem_y` would drift them twice, sliding them up into their anchors
// during the unlock.)
let clock_y_rest = h * 0.28;
let clock_y = clock_y_rest + elem_y(clock_e, DRIFT_CLOCK);
let clock_alpha = clock_e * fade;
match inputs.clock_old {
Some((old, t)) => {
let t = t.clamp(0.0, 1.0);
let old_w = text.measure_line(old, inputs.font_family, clock_size);
text.draw_line(
&mut pixmap,
old,
inputs.font_family,
clock_size,
faded(Color::WHITE, clock_alpha * (1.0 - t)),
(w - old_w) / 2.0,
clock_y - 6.0 * t,
);
let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size);
text.draw_line(
&mut pixmap,
inputs.clock_text,
inputs.font_family,
clock_size,
faded(Color::WHITE, clock_alpha * t),
(w - new_w) / 2.0,
clock_y + 6.0 * (1.0 - t),
);
}
None => {
let clock_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size);
text.draw_line(
&mut pixmap,
inputs.clock_text,
inputs.font_family,
clock_size,
faded(Color::WHITE, clock_alpha),
(w - clock_w) / 2.0,
clock_y,
);
}
}
// ---- Date line under the clock (hidden when date_text is empty). The
// clock's `origin_y` anchors its *top*, so the date is placed below the
// clock's actual glyph box (exact per font, cached) with a small gap.
if !inputs.date_text.is_empty() {
let date_size = (w * 0.016).clamp(DATE_SIZE_MIN, DATE_SIZE_MAX);
let (clock_top, clock_height) =
text.measure_box(inputs.clock_text, inputs.font_family, clock_size);
let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height
+ tokens::SPACE_SM as f32;
let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size);
text.draw_line(
&mut pixmap,
inputs.date_text,
inputs.font_family,
date_size,
faded(Color::WHITE, date_e * fade * 0.82),
(w - date_w) / 2.0,
date_y,
);
}
// ---- Password pill, centered. Red while failed (tinting in smoothly over
// the first part of the shake), green during the success flash.
let pill_w = 280.0_f32.min(w - tokens::SPACE_XL as f32 * 2.0);
let pill_h = 48.0;
let pill_x = (w - pill_w) / 2.0;
let pill_y = h * 0.5 + y_off;
let pill_y_rest = h * 0.5;
let pill_y = pill_y_rest + elem_y(pill_e, DRIFT_PILL);
let pill_alpha = pill_e * fade;
// Idle breath: glow multiplier on the shadow/border (1 at rest, up to
// 1 + BREATHE_GLOW at the breath peak).
let breathe = 1.0 + BREATHE_GLOW * inputs.breathe_t;
let base_pill = if inputs.failed {
lerp_color(surface_color, red_color, (inputs.failed_t / SHAKE_RED_FRAC).clamp(0.0, 1.0))
} else {
surface_color
};
let pill_color = if inputs.unlock_t > 0.0 {
green_color
} else {
base_pill
};
// The pill scales about its center (ease-out-back overshoot) instead of
// rising like the text; while unlocking it stays at rest scale.
let scale = if inputs.unlock_t > 0.0 { 1.0 } else { pill_scale };
let shake_x = if inputs.failed { damped_shake_x(inputs.failed_t) } else { 0.0 };
let cx = pill_x + pill_w / 2.0;
let cy = pill_y + pill_h / 2.0;
let pill_xf = Transform::from_row(
scale,
0.0,
0.0,
scale,
cx * (1.0 - scale) + shake_x,
cy * (1.0 - scale),
);
if let Some(path) =
rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32)
{
// Soft drop shadow first (under the fill): concentric expanded copies
// offset downward at fading alpha. The idle breath scales the glow.
for (grow, alpha) in PILL_SHADOW {
if let Some(shadow_path) = rounded_rect(
pill_x - grow,
pill_y - grow + 3.0,
pill_w + grow * 2.0,
pill_h + grow * 2.0,
tokens::RADIUS_SECONDARY as f32 + grow,
) {
let mut paint = Paint::default();
paint.set_color(faded(Color::BLACK, alpha * pill_alpha * breathe));
paint.anti_alias = true;
pixmap.fill_path(
&shadow_path,
&paint,
tiny_skia::FillRule::Winding,
pill_xf,
None,
);
}
}
if let Some(path) = rounded_rect(
pill_x,
pill_y,
pill_w,
pill_h,
tokens::RADIUS_SECONDARY as f32,
) {
let mut paint = Paint::default();
paint.set_color(if inputs.failed {
red_color
} else {
surface_color
});
paint.set_color(faded(pill_color, pill_alpha));
paint.anti_alias = true;
pixmap.fill_path(
&path,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::identity(),
pill_xf,
None,
);
// Hairline border for depth — dropped on the wrong/success states
// (the sketch sets `border-color: transparent` there).
if !inputs.failed && inputs.unlock_t == 0.0 {
let mut stroke = tiny_skia::Stroke::default();
stroke.width = 1.0;
let mut paint = Paint::default();
paint.set_color(faded(
Color::WHITE,
PILL_BORDER_ALPHA * pill_alpha * (1.0 + 0.4 * inputs.breathe_t),
));
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
}
// Idle breath: a faint accent ring blooms around the pill at the
// breath peak (matches the sketch's `breathe` keyframes).
if inputs.breathe_t > 0.0 && !inputs.failed && inputs.unlock_t == 0.0 {
let mut stroke = tiny_skia::Stroke::default();
stroke.width = 1.5;
let mut paint = Paint::default();
paint.set_color(faded(accent_color, BREATHE_RING_ALPHA * inputs.breathe_t * pill_alpha));
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
}
// Success flash: expanding accent ring around the pill for the first
// `FLASH_MS` of the unlock.
if inputs.unlock_t > 0.0 && inputs.unlock_t < FLASH_FRAC {
let flash_t = inputs.unlock_t / FLASH_FRAC;
let mut stroke = tiny_skia::Stroke::default();
stroke.width = 2.0 + 16.0 * flash_t;
let mut paint = Paint::default();
paint.set_color(faded(green_color, 0.55 * (1.0 - flash_t) * pill_alpha));
pixmap.stroke_path(&path, &paint, &stroke, pill_xf, None);
}
}
// Password dots — one filled circle per typed character, capped so a
// very long password can't overflow the pill.
let dot_r = 5.0;
let dot_gap = 18.0;
let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / dot_gap)
// ---- Password dots — one filled circle per typed character, capped so a
// very long password can't overflow the pill. The newest dot pops in with
// an overshoot; the rest sit at rest size.
let max_dots = ((pill_w - tokens::SPACE_LG as f32 * 2.0) / DOT_GAP)
.floor()
.max(1.0) as usize;
let shown_dots = inputs.password_len.min(max_dots);
let dot_y = pill_y + pill_h / 2.0;
if shown_dots > 0 {
let dots_w = (shown_dots as f32 - 1.0).max(0.0) * dot_gap;
let start_x = pill_x + (pill_w - dots_w) / 2.0;
let dot_y = pill_y + pill_h / 2.0;
let start_x = start_x_for(shown_dots, pill_x, pill_w);
for i in 0..shown_dots {
let newest = i == shown_dots - 1;
let r = if newest && inputs.dot_pop_t < 1.0 {
(DOT_R * ease_out_back(inputs.dot_pop_t)).max(0.4)
} else {
DOT_R
};
// Success: dots flip accent → white in a quick left-to-right
// cascade over the green flash (each dot finishes (i+1)/n through
// the flash), instead of all flipping at once.
let dot_color = if inputs.failed {
Color::WHITE
} else if inputs.unlock_t > 0.0 {
let flash_t = (inputs.unlock_t / FLASH_FRAC).clamp(0.0, 1.0);
let cascade = (flash_t * shown_dots as f32 - i as f32).clamp(0.0, 1.0);
lerp_color(accent_color, Color::WHITE, cascade)
} else {
accent_color
};
if let Some(path) =
tiny_skia::PathBuilder::from_circle(start_x + i as f32 * dot_gap, dot_y, dot_r)
tiny_skia::PathBuilder::from_circle(start_x + i as f32 * DOT_GAP, dot_y, r)
{
let mut paint = Paint::default();
paint.set_color(if inputs.failed {
faded(Color::WHITE, alpha)
} else {
accent_color
});
paint.set_color(faded(dot_color, pill_alpha));
paint.anti_alias = true;
pixmap.fill_path(
&path,
&paint,
tiny_skia::FillRule::Winding,
tiny_skia::Transform::identity(),
Transform::identity(),
None,
);
}
}
}
// Status line (e.g. "wrong password" / "checking…") below the pill.
// ---- Empty pill: a centered "enter password" hint instead of dots. The
// caret only appears with the first typed character, so the pill reads as
// an input field rather than an empty dark bar. Centered on the exact
// glyph box (origin anchors the text top, not the baseline).
if shown_dots == 0 && !inputs.failed {
let hint = "Enter password";
let hint_size = tokens::FONT_SIZE_BASE as f32;
let hint_w = text.measure_line(hint, inputs.font_family, hint_size);
let (hint_top, hint_height) = text.measure_box(hint, inputs.font_family, hint_size);
let hint_y = pill_y + (pill_h - hint_height) / 2.0 - hint_top;
text.draw_line(
&mut pixmap,
hint,
inputs.font_family,
hint_size,
faded(on_surface, pill_alpha * 0.5),
(w - hint_w) / 2.0,
hint_y,
);
} else if shown_dots > 0 {
// ---- Caret after the last dot: solid for half a second after a
// keystroke, then blinking at ~1.8 Hz.
let caret_x = start_x_for(shown_dots, pill_x, pill_w)
+ (shown_dots - 1) as f32 * DOT_GAP
+ DOT_R
+ 6.0;
let blink = match inputs.keystroke_age {
Some(age) if age < CARET_HOLD_S => 1.0,
Some(age) => ((age - CARET_HOLD_S) * CARET_BLINK_HZ) % 1.0,
None => (inputs.t_secs * CARET_BLINK_HZ) % 1.0,
};
if blink < 0.5 {
let caret_color = if inputs.failed || inputs.unlock_t > 0.0 {
Color::WHITE
} else {
accent_color
};
let caret_h = pill_h * 0.5;
if let Some(path) = rounded_rect(
caret_x,
dot_y - caret_h / 2.0,
CARET_W,
caret_h,
1.0,
) {
let mut paint = Paint::default();
paint.set_color(faded(caret_color, pill_alpha));
paint.anti_alias = true;
pixmap.fill_path(
&path,
&paint,
tiny_skia::FillRule::Winding,
Transform::identity(),
None,
);
}
}
}
// ---- Status line below the pill (e.g. "wrong password" / "checking…").
// Slides up 8px with a fade when it appears (state.rs resets `status_t`
// on every auth-state change).
if let Some(status) = inputs.status_text {
let status_size = tokens::FONT_SIZE_SECONDARY as f32;
let status_w = text.measure_line(status, inputs.font_family, status_size);
let status_anim = ease_out_cubic(inputs.status_t);
let status_alpha = status_e * fade * status_anim;
let color = if inputs.failed { red_color } else { on_surface };
text.draw_line(
&mut pixmap,
status,
inputs.font_family,
status_size,
on_surface,
faded(color, status_alpha),
(w - status_w) / 2.0,
pill_y + pill_h + tokens::SPACE_MD as f32,
pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS)
+ STATUS_SLIDE_PX * (1.0 - status_anim),
);
}
Some(pixmap)
}
/// Recomputes the left edge of the dot row (shared by the dot loop and the
/// caret placement — kept out of `compose` to avoid a long-lived binding).
fn start_x_for(shown_dots: usize, pill_x: f32, pill_w: f32) -> f32 {
let dots_w = (shown_dots as f32 - 1.0).max(0.0) * DOT_GAP;
pill_x + (pill_w - dots_w) / 2.0
}
/// Copies a composed frame into a `wl_shm` `Argb8888` buffer, swizzling
/// tiny-skia's RGBA byte order to the host-endian `0xAARRGGBB` `wl_shm`
/// expects (BGRA bytes on little-endian, which is every target this ships
@ -211,6 +605,41 @@ pub fn blit_to_shm(pixmap: &Pixmap, shm_bytes: &mut [u8]) {
mod tests {
use super::*;
fn inputs<'a>(
bg: &'a Background,
palette: &'a breadlock_ui::theme::Palette,
text: &'a str,
date: &'a str,
password_len: usize,
failed: bool,
failed_t: f32,
dot_pop_t: f32,
appear_t: f32,
unlock_t: f32,
) -> FrameInputs<'a> {
FrameInputs {
width: 400,
height: 300,
background: bg,
palette,
font_family: "sans-serif",
clock_text: text,
date_text: date,
clock_old: None,
password_len,
failed,
failed_t,
dot_pop_t,
keystroke_age: None,
t_secs: 0.0,
breathe_t: 0.0,
status_t: 1.0,
status_text: None,
appear_t,
unlock_t,
}
}
#[test]
fn blit_swizzles_rgba_to_bgra() {
let mut pixmap = Pixmap::new(1, 1).unwrap();
@ -232,8 +661,16 @@ mod tests {
palette: &palette,
font_family: "sans-serif",
clock_text: "12:34",
date_text: "Friday · Aug 21",
clock_old: None,
password_len: 0,
failed: false,
failed_t: 0.0,
dot_pop_t: 1.0,
keystroke_age: None,
t_secs: 0.0,
breathe_t: 0.0,
status_t: 1.0,
status_text: None,
appear_t: 1.0,
unlock_t: 0.0,
@ -242,6 +679,22 @@ mod tests {
assert_eq!((pixmap.width(), pixmap.height()), (400, 300));
}
#[test]
fn compose_renders_failed_and_unlock_states() {
let bg = Background::Color(Color::BLACK);
let palette = breadlock_ui::theme::Palette::default();
let mut text = TextRenderer::new();
// Wrong-password shake mid-flight.
let failed = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 0.4, 1.0, 0.0);
assert!(compose(&mut text, &failed).is_some());
// Success flash phase of the unlock.
let success = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.12);
assert!(compose(&mut text, &success).is_some());
// Fully faded unlock returns just the background.
let done = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 1.0);
assert!(compose(&mut text, &done).is_some());
}
#[test]
fn ease_out_cubic_bounds_and_shape() {
assert_eq!(ease_out_cubic(0.0), 0.0);
@ -252,6 +705,48 @@ mod tests {
assert!(ease_out_cubic(0.5) > 0.5);
}
#[test]
fn ease_out_back_overshoots_past_one() {
assert_eq!(ease_out_back(0.0), 0.0);
assert_eq!(ease_out_back(1.0), 1.0);
assert!(
(0..=20).any(|i| ease_out_back(i as f32 / 20.0) > 1.0),
"ease-out-back must overshoot past 1 somewhere in (0, 1)"
);
}
#[test]
fn damped_shake_starts_and_ends_at_rest_and_stays_bounded() {
assert_eq!(damped_shake_x(0.0), 0.0);
assert_eq!(damped_shake_x(1.0), 0.0);
for i in 0..=40 {
let x = damped_shake_x(i as f32 / 40.0);
assert!(
x.abs() < 9.5,
"shake amplitude must stay bounded, got {x} at t={}",
i as f32 / 40.0
);
}
}
#[test]
fn staggered_t_spreads_elements_across_the_window() {
// Clock (delay 0) starts immediately; pill (delay 100ms of 450ms)
// only begins after ~22% of the window.
assert_eq!(staggered_t(0.0, CLOCK_DELAY_MS), 0.0);
assert_eq!(staggered_t(0.0, PILL_DELAY_MS), 0.0);
assert!(staggered_t(0.1, CLOCK_DELAY_MS) > 0.0);
assert_eq!(staggered_t(0.1, PILL_DELAY_MS), 0.0);
assert_eq!(staggered_t(1.0, PILL_DELAY_MS), 1.0);
// Monotonic: later raw progress never regresses an element.
let mut prev = 0.0f32;
for i in 0..=20 {
let t = staggered_t(i as f32 / 20.0, STATUS_DELAY_MS);
assert!(t >= prev, "staggered progress regressed: {t} < {prev}");
prev = t;
}
}
#[test]
fn overlay_motion_appear_starts_below_and_fades_in() {
let (a0, y0) = overlay_motion(0.0, 0.0);

View file

@ -75,6 +75,8 @@ pub struct AppState {
pub auth_state: AuthState,
pub auth_tx: Sender<AuthResult>,
/// Monotonic clock reference — drives the idle caret blink cadence.
pub started: Instant,
/// First-frame timestamp for the lock-appear animation. `None` until
/// the first non-degenerate redraw so the fade starts when the surface
/// is actually visible, not when the process starts.
@ -83,6 +85,30 @@ pub struct AppState {
/// overlay fades out; compositor `unlock()` happens only after the
/// fade completes. Dying mid-fade leaves the session locked (fail-secure).
pub unlocking: Option<Instant>,
/// Timestamp of the most recent keystroke that grew the password — drives
/// the newest-dot pop-in and the caret's solid-then-blink behavior.
pub last_keystroke: Option<Instant>,
/// When the failed state was entered — drives the wrong-password shake.
/// Cleared (with the failed state) by typing or `fail_timeout_ms`.
pub failed_at: Option<Instant>,
/// Clock text drawn last frame; a change starts a minute-rollover
/// crossfade instead of a hard text swap.
pub last_clock_text: String,
/// When the current clock crossfade started.
pub clock_anim_started: Option<Instant>,
/// When the current status line appeared ("Checking…" / "Wrong password") —
/// drives its slide-in. Reset whenever `auth_state` changes (see
/// `last_auth_state`).
pub status_anim_started: Option<Instant>,
/// The `auth_state` from the last frame — a change resets the status
/// slide-in so a freshly appearing status rises in instead of popping.
pub last_auth_state: AuthState,
/// When the current idle-breath window started (glow pulse). `None`
/// between breaths.
pub breathe_started: Option<Instant>,
/// When the next idle-breath window is due — the 1s clock tick arms the
/// animation timer once it's due, so idle CPU stays near zero.
pub breathe_next_at: Option<Instant>,
/// True while a ~16ms animation timer is registered on the event loop.
pub anim_timer_armed: bool,
@ -108,11 +134,37 @@ impl AppState {
self.appear_started = Some(Instant::now());
}
let now = Instant::now();
let clock_text = chrono::Local::now()
.format(&self.config.appearance.clock.format)
.to_string();
let date_text = chrono::Local::now()
.format(&self.config.appearance.clock.date_format)
.to_string();
// A status line appearing (or changing) resets its slide-in.
if self.auth_state != self.last_auth_state {
self.status_anim_started = Some(now);
self.last_auth_state = self.auth_state;
}
// Idle breath: one sine hump over the active window. When the window
// ends, schedule the next one a full period out (the 1s clock tick
// re-arms the animation timer once it's due).
let breathe_t = if let Some(started) = self.breathe_started {
let p = render::unit_progress(started, render::BREATHE_ACTIVE_MS);
if p >= 1.0 {
self.breathe_started = None;
self.breathe_next_at =
Some(started + Duration::from_millis(render::BREATHE_PERIOD_MS));
0.0
} else {
render::breathe_envelope(p)
}
} else {
0.0
};
// While a PAM check runs, the status dots tick to signal progress.
let status_text = match self.auth_state {
AuthState::Checking => Some("Checking…".to_string()),
AuthState::Checking => Some(format!("Checking{}", checking_dots(now))),
AuthState::Failed => Some("Wrong password".to_string()),
AuthState::ConfigError => Some(
"PAM config error — check logs (breadlock service not set up correctly)"
@ -120,6 +172,22 @@ impl AppState {
),
AuthState::Idle => None,
};
let status_t = self
.status_anim_started
.map(|t| render::unit_progress(t, render::STATUS_SLIDE_MS))
.unwrap_or(1.0);
// Minute rollover: keep the previous clock text for a short crossfade.
let clock_old = if !self.last_clock_text.is_empty() && clock_text != self.last_clock_text {
self.clock_anim_started = Some(now);
Some((self.last_clock_text.clone(), 0.0))
} else {
self.clock_anim_started
.map(|t| render::unit_progress(t, render::CLOCK_CROSSFADE_MS))
.filter(|t| *t < 1.0)
.map(|t| (self.last_clock_text.clone(), t))
};
self.last_clock_text = clock_text.clone();
let appear_t = self
.appear_started
@ -129,6 +197,14 @@ impl AppState {
.unlocking
.map(|t| render::unit_progress(t, render::UNLOCK_MS))
.unwrap_or(0.0);
let failed_t = self
.failed_at
.map(|t| render::unit_progress(t, render::SHAKE_MS))
.unwrap_or(0.0);
let dot_pop_t = self
.last_keystroke
.map(|t| render::unit_progress(t, render::DOT_POP_MS))
.unwrap_or(1.0);
let output_palette = self.palette_for_surface(surface);
let inputs = render::FrameInputs {
@ -138,8 +214,16 @@ impl AppState {
palette: &output_palette,
font_family: &self.config.appearance.font.family,
clock_text: &clock_text,
date_text: &date_text,
clock_old: clock_old.as_ref().map(|(s, t)| (s.as_str(), *t)),
password_len: self.password.len(),
failed: matches!(self.auth_state, AuthState::Failed | AuthState::ConfigError),
failed_t,
dot_pop_t,
keystroke_age: self.last_keystroke.map(|t| t.elapsed().as_secs_f32()),
t_secs: self.started.elapsed().as_secs_f32(),
breathe_t,
status_t,
status_text: status_text.as_deref(),
appear_t,
unlock_t,
@ -217,15 +301,74 @@ impl AppState {
.unwrap_or(false)
}
fn anim_in_progress(&self) -> bool {
self.unlocking.is_some() || self.appear_in_progress()
fn failed_shake_in_progress(&self) -> bool {
self.failed_at
.map(|t| t.elapsed() < Duration::from_millis(render::SHAKE_MS))
.unwrap_or(false)
}
/// Keep requesting frames while appear or unlock-fade is running.
fn dot_pop_in_progress(&self) -> bool {
self.last_keystroke
.map(|t| t.elapsed() < Duration::from_millis(render::DOT_POP_MS))
.unwrap_or(false)
}
fn clock_fade_in_progress(&self) -> bool {
self.clock_anim_started
.map(|t| t.elapsed() < Duration::from_millis(render::CLOCK_CROSSFADE_MS))
.unwrap_or(false)
}
fn status_slide_in_progress(&self) -> bool {
self.status_anim_started
.map(|t| t.elapsed() < Duration::from_millis(render::STATUS_SLIDE_MS))
.unwrap_or(false)
}
fn breathe_in_progress(&self) -> bool {
self.breathe_started.is_some()
}
/// An idle breath is due when the cycle timer says so (and no breath is
/// already running). The 1s clock tick calls `redraw_all`, which arms the
/// animation timer through here — so the screen stays asleep between
/// breaths.
fn breathe_due(&self) -> bool {
if !self.config.animation.breathe || self.breathe_started.is_some() {
return false;
}
self.breathe_next_at
.map(|t| Instant::now() >= t)
.unwrap_or(false)
}
/// Any effect still running that needs the animation timer: the fast ones
/// (entrance, unlock flash+fade, shake, dot pop, clock rollover, status
/// slide, a live PAM check) plus the slow ones (idle breath, Ken Burns
/// pan) which run at a reduced cadence — see `tick_animation`.
fn anim_in_progress(&self) -> bool {
self.unlocking.is_some()
|| self.appear_in_progress()
|| self.failed_shake_in_progress()
|| self.dot_pop_in_progress()
|| self.clock_fade_in_progress()
|| self.status_slide_in_progress()
|| self.breathe_in_progress()
|| self.breathe_due()
|| self.auth_state == AuthState::Checking
|| self.background.ken_burns()
}
/// Keep requesting frames while any effect is running.
fn arm_anim_if_needed(&mut self, qh: &QueueHandle<Self>) {
if self.anim_timer_armed || !self.anim_in_progress() {
return;
}
// A breath that's due starts its window now, so the first ticked
// frame already shows the start of the hump.
if self.breathe_due() {
self.breathe_started = Some(Instant::now());
}
self.anim_timer_armed = true;
let qh = qh.clone();
if self
@ -247,7 +390,20 @@ impl AppState {
self.anim_timer_armed = false;
TimeoutAction::Drop
} else if self.anim_in_progress() {
TimeoutAction::ToDuration(Duration::from_millis(render::ANIM_FRAME_MS))
// Slow effects (idle breath, Ken Burns) don't need 60fps — halve
// the redraw cost for them. Everything else stays at ~60Hz.
let fast = self.appear_in_progress()
|| self.unlock_in_progress()
|| self.failed_shake_in_progress()
|| self.dot_pop_in_progress()
|| self.clock_fade_in_progress()
|| self.status_slide_in_progress()
|| self.auth_state == AuthState::Checking;
TimeoutAction::ToDuration(Duration::from_millis(if fast {
render::ANIM_FRAME_MS
} else {
render::SLOW_FRAME_MS
}))
} else {
self.anim_timer_armed = false;
TimeoutAction::Drop
@ -281,6 +437,7 @@ impl AppState {
.insert_source(Timer::from_duration(timeout), move |_, _, state| {
if matches!(state.auth_state, AuthState::Failed | AuthState::ConfigError) {
state.auth_state = AuthState::Idle;
state.failed_at = None;
state.redraw_all(&qh);
}
TimeoutAction::Drop
@ -288,6 +445,17 @@ impl AppState {
}
}
/// The animated ellipsis for the "Checking" status while a PAM check runs:
/// cycles "", ".", "..", "…" every ~500ms (driven by the monotonic clock).
fn checking_dots(now: Instant) -> &'static str {
match (now.elapsed().as_secs_f32() * 2.0) as usize % 4 {
0 => "",
1 => ".",
2 => "..",
_ => "",
}
}
impl ShmHandler for AppState {
fn shm_state(&mut self) -> &mut Shm {
&mut self.shm

661
design/sketch.html Normal file
View file

@ -0,0 +1,661 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>breadlock × breadgreet — style &amp; motion sketch</title>
<style>
/* ============================================================
Design tokens — mirrors bread-theme (BREAD_DESIGN_SYSTEM.md)
bg/surface/overlay/foreground are the fixed BOS dark base;
color1-6 are pywal-derived accents. Two palettes shown:
"bread" = curated bread-toned defaults (fresh install)
"tokyo" = an example pywal palette (Tokyo Night)
============================================================ */
:root, [data-palette="bread"] {
--bg: #0c0c0c;
--surface: #1a1a1a;
--surface2: #232323;
--overlay: #d8d8d8;
--fg: #e8e8e8;
--red: #b98749; /* color1 */
--green: #cd9450; /* color2 */
--yellow: #e3a85c; /* color3 */
--accent: #eab672; /* color4 */
--pink: #f6c477; /* color5 */
--teal: #eabe82; /* color6 */
--radius: 8px; /* primary */
--radius-sm: 6px; /* secondary (inputs) */
--font: "Varela Round", "Segoe UI", system-ui, -apple-system, sans-serif;
--line: #2b2b2b;
}
[data-palette="tokyo"] {
--red: #f7768e;
--green: #9ece6a;
--yellow: #e0af68;
--accent: #7aa2f7;
--pink: #bb9af7;
--teal: #7dcfff;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
padding: 32px clamp(16px, 4vw, 48px) 80px;
}
header.maxw, main { max-width: 1240px; margin: 0 auto; }
header h1 { font-size: 26px; margin: 0 0 6px; letter-spacing: .2px; }
header p.sub { color: var(--overlay); opacity: .72; margin: 0 0 18px; max-width: 72ch; }
header .meta {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px;
}
.palette-toggle {
display: inline-flex; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; margin-left: auto;
}
.palette-toggle button {
background: transparent; color: var(--overlay); border: 0; padding: 6px 14px;
font: inherit; font-size: 12px; cursor: pointer;
}
.palette-toggle button.active { background: var(--surface2); color: var(--fg); }
.swatches { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.swatch { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--overlay); opacity: .85; }
.swatch i { width: 14px; height: 14px; border-radius: 4px; border: 1px solid rgba(255,255,255,.15); display: inline-block; }
h2 { font-size: 16px; margin: 34px 0 4px; }
h2 small { color: var(--overlay); opacity: .6; font-weight: 400; margin-left: 8px; }
.hint { color: var(--overlay); opacity: .7; font-size: 12px; margin: 0 0 14px; max-width: 90ch; }
/* ---------- stages ---------- */
.stages { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 960px) { .stages { grid-template-columns: 1fr; } }
.stage {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px; display: flex; flex-direction: column; gap: 12px;
}
.stage > .stage-head { display: flex; align-items: baseline; gap: 8px; }
.stage > .stage-head h3 { margin: 0; font-size: 14px; }
.stage > .stage-head span { font-size: 11px; color: var(--overlay); opacity: .6; }
.stage .states { display: flex; flex-wrap: wrap; gap: 6px; }
.stage .states button {
background: var(--surface2); color: var(--overlay); border: 1px solid var(--line);
border-radius: 999px; padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
}
.stage .states button.active { background: var(--accent); color: #0c0c0c; border-color: transparent; font-weight: 700; }
/* the "monitor" */
.screen {
position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: var(--radius-sm);
overflow: hidden; border: 1px solid var(--line); background: var(--bg);
}
.wallpaper, .veil { position: absolute; inset: 0; }
[data-palette="bread"] .wallpaper {
background:
radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
}
[data-palette="tokyo"] .wallpaper {
background:
radial-gradient(120% 90% at 18% 8%, #2b3152 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #1c2338 0%, transparent 60%),
linear-gradient(160deg, #10121c 0%, #0c0c0c 72%);
}
.veil { background: rgba(0, 0, 0, .28); } /* DIM_ALPHA = 0.28, fades with chrome */
/* ---------- breadlock overlay ---------- */
.lockoverlay {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
padding-top: 12%; transition: opacity 400ms ease, transform 400ms ease;
}
.screen[data-state="unlock"] .lockoverlay { opacity: 0; transform: translateY(-20px); }
.lclock {
font-size: clamp(34px, 7.5vw, 60px); font-weight: 700; color: #fff; text-align: center;
animation: riseIn 450ms ease-out both;
}
.ldate {
font-size: clamp(11px, 1.6vw, 14px); color: rgba(255,255,255,.82); margin-top: 6px;
animation: riseIn 450ms ease-out 80ms both;
}
.badge-new {
display: inline-block; vertical-align: middle; margin-left: 6px; padding: 1px 6px;
font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase;
border-radius: 4px; background: var(--accent); color: #0c0c0c;
}
.pill {
position: relative; margin-top: clamp(20px, 5vh, 44px);
width: clamp(190px, 36vw, 280px); height: clamp(34px, 6vw, 48px);
border-radius: var(--radius-sm);
background: var(--surface);
border: 1px solid rgba(255,255,255,.08);
display: flex; align-items: center; justify-content: center; gap: clamp(8px, 1.6vw, 18px);
animation: popIn 380ms cubic-bezier(.34, 1.56, .64, 1) 100ms both;
transition: background-color 150ms ease, border-color 150ms ease;
box-shadow: 0 4px 18px rgba(0,0,0,.45);
}
.dot {
width: clamp(6px, 1.1vw, 9px); height: clamp(6px, 1.1vw, 9px); border-radius: 50%;
background: var(--accent); animation: dotPop 200ms ease-out both;
}
.pill .dot:nth-child(1) { animation-delay: 150ms; }
.pill .dot:nth-child(2) { animation-delay: 200ms; }
.pill .dot:nth-child(3) { animation-delay: 250ms; }
.caret {
width: 2px; height: 1.2em; background: var(--accent); border-radius: 1px;
animation: caretBlink 1.1s steps(1) infinite; opacity: .9;
}
.lstatus { margin-top: 12px; font-size: 12px; color: var(--overlay); min-height: 1em; text-align: center; }
/* wrong password: shake + red, then fade back */
.screen[data-state="wrong"] .pill {
background: var(--red); border-color: transparent;
animation: shake 380ms cubic-bezier(.36,.07,.19,.97);
}
.screen[data-state="wrong"] .dot, .screen[data-state="wrong"] .caret { background: #fff; }
.screen[data-state="wrong"] .lstatus { color: var(--red); font-weight: 700; }
/* success: green flash, then the unlock fade is handled by data-state="unlock" */
.screen[data-state="success"] .pill { background: var(--green); border-color: transparent; animation: successFlash 300ms ease-out; }
.screen[data-state="success"] .dot, .screen[data-state="success"] .caret { background: #fff; }
.screen[data-state="success"] .lstatus { color: var(--green); font-weight: 700; }
.replay {
align-self: flex-start; background: transparent; color: var(--accent); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 4px 12px; font: inherit; font-size: 11px; cursor: pointer;
}
.replay:hover { border-color: var(--accent); }
/* ---------- breadgreet ---------- */
.greetoverlay {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 18px;
}
.gclock { font-size: clamp(28px, 5vw, 44px); font-weight: 700; color: #fff; animation: riseIn 450ms ease-out both; }
.card {
width: clamp(240px, 44vw, 340px); background: var(--surface);
border: 1px solid rgba(255,255,255,.08); border-radius: var(--radius);
padding: 20px; display: flex; flex-direction: column; gap: 10px;
animation: riseIn 450ms ease-out 120ms both;
box-shadow: 0 6px 24px rgba(0,0,0,.5);
}
.gentry {
width: 100%; background: var(--surface2); color: var(--fg);
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 10px 14px; font: inherit; font-size: 14px;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.gentry:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
[data-palette="tokyo"] .gentry:focus { box-shadow: 0 0 0 2px rgba(122,162,247,.28); }
.gstatus { font-size: 12px; color: var(--overlay); opacity: .75; min-height: 1em; text-align: center; transition: opacity 200ms; }
.screen[data-state="error"] .gstatus { color: var(--red); opacity: 1; font-weight: 700; }
.spinner {
margin: 0 auto; width: 18px; height: 18px; border-radius: 50%;
border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
animation: spin .8s linear infinite; display: none;
}
.screen[data-state="checking"] .spinner { display: block; }
.srow {
display: flex; align-items: center; gap: 10px; width: 100%;
background: var(--surface2); border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 8px 12px; font-size: 12px; color: var(--overlay);
}
.srow .icon {
width: 20px; height: 20px; border-radius: 5px; flex: none;
background: linear-gradient(135deg, var(--accent), var(--teal));
}
.srow .label { flex: 1; text-align: left; }
.srow .chev { color: var(--overlay); opacity: .6; }
.screen[data-state="error"] .card { animation: shake 380ms cubic-bezier(.36,.07,.19,.97); }
/* ---------- keyframes ---------- */
@keyframes riseIn { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
@keyframes popIn { from { opacity: 0; transform: scale(.94); } 70% { transform: scale(1.02); } to { opacity: 1; transform: scale(1); } }
@keyframes dotPop { from { transform: scale(0); } 70% { transform: scale(1.35); } to { transform: scale(1); } }
@keyframes caretBlink { 0%, 55% { opacity: .9; } 56%, 100% { opacity: 0; } }
@keyframes shake {
10%, 90% { transform: translateX(-2px); } 20%, 80% { transform: translateX(5px); }
30%, 50%, 70% { transform: translateX(-8px); } 40%, 60% { transform: translateX(8px); }
}
@keyframes successFlash { from { box-shadow: 0 0 0 0 rgba(205,148,80,.55); } to { box-shadow: 0 0 0 22px rgba(205,148,80,0); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes breathe { 0%, 100% { box-shadow: 0 4px 18px rgba(0,0,0,.45); } 50% { box-shadow: 0 4px 26px rgba(0,0,0,.6), 0 0 0 1px rgba(234,182,114,.12); } }
@keyframes clockFlip { 0% { opacity: 1; } 45% { opacity: 0; transform: translateY(6px); } 55% { opacity: 0; transform: translateY(-6px); } 100% { opacity: 1; transform: none; } }
@keyframes kbdPan { 0% { transform: translateX(-2.5%) scale(1.06); } 100% { transform: translateX(2.5%) scale(1.06); } }
/* ---------- motion library ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.tile {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 12px; display: flex; flex-direction: column; gap: 8px;
}
.tile .tname { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
.tile .tag { font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; padding: 1px 6px; border-radius: 4px; background: var(--surface2); color: var(--overlay); }
.tile .tag.S { color: var(--green); } .tile .tag.M { color: var(--yellow); } .tile .tag.L { color: var(--red); }
.tile .tnote { font-size: 11px; color: var(--overlay); opacity: .75; min-height: 3em; }
.tile .tscreen {
position: relative; width: 100%; aspect-ratio: 16 / 7; border-radius: var(--radius-sm);
overflow: hidden; background: var(--bg); border: 1px solid var(--line);
}
/* tile demos */
.tile .tscreen .tclock { position: absolute; top: 22%; left: 0; right: 0; text-align: center; color: #fff; font-weight: 700; font-size: 22px; }
.tile .tscreen .tpill {
position: absolute; top: 52%; left: 50%; transform: translateX(-50%);
width: 120px; height: 26px; border-radius: var(--radius-sm); background: var(--surface);
border: 1px solid rgba(255,255,255,.08); display: flex; align-items: center; justify-content: center; gap: 9px;
}
.tile .tscreen .tpill i { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
.tile .tscreen .tpill .tc { width: 2px; height: 12px; border-radius: 1px; background: var(--accent); animation: caretBlink 1.1s steps(1) infinite; }
.tile[data-tile="stagger"] .tclock, .tile[data-tile="stagger"] .tpill { animation: riseIn 450ms ease-out both; }
.tile[data-tile="stagger"] .tpill { animation-name: popIn; animation-delay: 140ms; }
.tile[data-tile="dotpop"] .tpill i:nth-child(1) { animation: dotPop 200ms ease-out 120ms both; }
.tile[data-tile="dotpop"] .tpill i:nth-child(2) { animation: dotPop 200ms ease-out 180ms both; }
.tile[data-tile="dotpop"] .tpill i:nth-child(3) { animation: dotPop 200ms ease-out 240ms both; }
.tile[data-tile="shake"] .tpill { background: var(--red); animation: shake 380ms cubic-bezier(.36,.07,.19,.97) 200ms both; }
.tile[data-tile="shake"] .tpill i { background: #fff; }
.tile[data-tile="shake"] .tstatus { position: absolute; top: 66%; width: 100%; text-align: center; font-size: 10px; color: var(--red); opacity: 0; animation: fadeIn 200ms ease 380ms both; }
.tile[data-tile="flash"] .tpill { background: var(--green); animation: successFlash 300ms ease-out 200ms both; }
.tile[data-tile="flash"] .tpill i { background: #fff; }
.tile[data-tile="crossfade"] .tclock .old, .tile[data-tile="crossfade"] .tclock .new {
position: absolute; inset: 0; transition: opacity 300ms ease, transform 300ms ease;
}
.tile[data-tile="crossfade"] .tclock .new { opacity: 0; transform: translateY(6px); }
.tile[data-tile="crossfade"].ticked .tclock .old { opacity: 0; transform: translateY(-6px); }
.tile[data-tile="crossfade"].ticked .tclock .new { opacity: 1; transform: none; }
.tile[data-tile="breathe"] .tpill { animation: breathe 3.2s ease-in-out infinite; }
.tile[data-tile="gcard"] .gcard-mini {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 130px; background: var(--surface); border: 1px solid rgba(255,255,255,.08);
border-radius: var(--radius); padding: 10px; animation: riseIn 450ms ease-out both;
}
.tile[data-tile="gcard"] .gcard-mini b { display: block; height: 12px; border-radius: 4px; background: var(--surface2); }
.tile[data-tile="gcard"] .gcard-mini b + b { margin-top: 6px; height: 8px; opacity: .6; }
.tile[data-tile="focus"] .gcard-mini b:first-child { transition: border-color 200ms, box-shadow 200ms; border: 1px solid var(--line); }
.tile[data-tile="focus"].focused .gcard-mini b:first-child { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(234,182,114,.25); }
.tile[data-tile="spinner"] .spin-mini {
position: absolute; top: 50%; left: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px;
border-radius: 50%; border: 2px solid rgba(255,255,255,.15); border-top-color: var(--accent);
animation: spin .8s linear infinite;
}
.tile[data-tile="session"] .rows { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 140px; display: flex; flex-direction: column; gap: 5px; }
.tile[data-tile="session"] .rows div {
display: flex; align-items: center; gap: 7px; background: var(--surface2);
border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 5px 8px; font-size: 10px; color: var(--overlay);
}
.tile[data-tile="session"] .rows div i { width: 12px; height: 12px; border-radius: 3px; background: linear-gradient(135deg, var(--accent), var(--teal)); }
.tile[data-tile="session"] .rows div.sel { border-color: var(--accent); color: var(--fg); }
.tile[data-tile="kenburns"] .tpill { opacity: 0; }
.tile[data-tile="kenburns"] .tscreen.noanim::after { animation: none; }
.tile[data-tile="kenburns"] .tscreen::after {
content: ""; position: absolute; inset: -8%;
background: radial-gradient(120% 90% at 18% 8%, #3b2f1e 0%, transparent 55%),
radial-gradient(100% 100% at 88% 88%, #2c2313 0%, transparent 60%),
linear-gradient(160deg, #17130c 0%, #0c0c0c 72%);
animation: kbdPan 9s ease-in-out infinite alternate;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* ---------- implementation notes ---------- */
.notes { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 960px) { .notes { grid-template-columns: 1fr; } }
.notes .col { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
.notes h4 { margin: 0 0 10px; font-size: 13px; }
.notes ul { margin: 0; padding-left: 18px; font-size: 12px; color: var(--overlay); opacity: .9; }
.notes li { margin-bottom: 8px; }
.notes code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--accent); opacity: .9; }
footer { margin-top: 40px; color: var(--overlay); opacity: .55; font-size: 11px; max-width: 100ch; }
</style>
</head>
<body data-palette="bread">
<header class="maxw">
<div class="meta">
<h1>breadlock × breadgreet — style &amp; motion sketch</h1>
<div class="palette-toggle" id="paletteToggle">
<button data-palette="bread" class="active">Bread default</button>
<button data-palette="tokyo">Pywal (Tokyo Night)</button>
</div>
</div>
<p class="sub">
Live CSS prototype of the lock screen and greeter, grounded in the real
<code>bread-theme</code> tokens (fixed BOS dark base + pywal accents). The locker's
software renderer (tiny-skia) can reproduce every motion here; the greeter uses the
same CSS engine directly (GTK4). <b>Badges</b> mark what already ships vs what's proposed.
</p>
<div class="swatches">
<span class="swatch"><i style="background:#0c0c0c"></i>bg</span>
<span class="swatch"><i style="background:#1a1a1a"></i>surface</span>
<span class="swatch"><i id="swRed" style="background:var(--red)"></i>red</span>
<span class="swatch"><i id="swGreen" style="background:var(--green)"></i>green</span>
<span class="swatch"><i id="swAccent" style="background:var(--accent)"></i>accent</span>
</div>
</header>
<main>
<h2>Live stages <small>click a state chip to replay it</small></h2>
<p class="hint">The two apps should feel like one family: same palette, same radius/spacing tokens, same motion language (ease-out, 300450ms).</p>
<div class="stages">
<!-- ================= breadlock ================= -->
<section class="stage">
<div class="stage-head"><h3>breadlock</h3><span>tiny-skia · 16ms timer loop · <code>render.rs</code></span></div>
<div class="screen" id="lockScreen" data-state="idle">
<div class="wallpaper"></div>
<div class="veil"></div>
<div class="lockoverlay">
<div class="lclock">21:47<span class="badge-new">date</span><div class="ldate">Friday · Aug 21</div></div>
<div class="pill">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="caret" title="proposed"></span>
</div>
<div class="lstatus" id="lockStatus"></div>
</div>
</div>
<div class="states" data-screen="lockScreen">
<button data-state="idle" class="active">Idle</button>
<button data-state="typing">Typing</button>
<button data-state="wrong">Wrong pw</button>
<button data-state="success">Success</button>
<button data-state="unlock">Unlock</button>
</div>
<button class="replay" data-replay="lockScreen">Replay entrance</button>
</section>
<!-- ================= breadgreet ================= -->
<section class="stage">
<div class="stage-head"><h3>breadgreet</h3><span>GTK4 · relm4 · CSS in <code>theme.rs</code></span></div>
<div class="screen" id="greetScreen" data-state="username">
<div class="wallpaper"></div>
<div class="veil"></div>
<div class="greetoverlay">
<div class="gclock">21:47</div>
<div class="card">
<input class="gentry" id="greetEntry" type="text" placeholder="Username" />
<div class="gstatus" id="greetStatus"></div>
<div class="spinner"></div>
<div class="srow">
<span class="icon"></span>
<span class="label">bos — Hyprland <span class="badge-new">icon</span></span>
<span class="chev"></span>
</div>
</div>
</div>
</div>
<div class="states" data-screen="greetScreen">
<button data-state="username" class="active">Username</button>
<button data-state="prompt">Password prompt</button>
<button data-state="checking">Checking…</button>
<button data-state="error">Wrong pw</button>
</div>
<button class="replay" data-replay="greetScreen">Replay entrance</button>
</section>
</div>
<h2>Motion library <small>proposed animations, mapped to where each lands</small></h2>
<p class="hint">Every idea below is prototypeable in CSS first, then ported. Effort: <b style="color:var(--green)">S</b> small ·
<b style="color:var(--yellow)">M</b> medium · <b style="color:var(--red)">L</b> large (protocol/CPU work).</p>
<div class="tiles">
<div class="tile" data-tile="stagger">
<div class="tname">Entrance stagger <span class="tag S">S</span></div>
<div class="tscreen"><div class="tclock">21:47</div><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Clock → pill → status cascade instead of one uniform fade. Pill overshoots ~2% (ease-out-back). Replaces the single overlay motion in <code>render.rs</code>.</div>
<button class="replay" data-replay="tile-stagger">Replay</button>
</div>
<div class="tile" data-tile="dotpop">
<div class="tname">Dot pop + caret <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i><span class="tc"></span></div></div>
<div class="tnote">Newest password dot scales in with overshoot; a blinking caret marks where you're typing. Today dots just appear — <code>render.rs</code> dot loop.</div>
<button class="replay" data-replay="tile-dotpop">Replay</button>
</div>
<div class="tile" data-tile="shake">
<div class="tname">Wrong-password shake <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div><div class="tstatus">Wrong password</div></div>
<div class="tnote">The classic, currently reserved for v2 — failure is just a red pill today. Damped 8px shake, red fill, auto-clear after <code>fail_timeout_ms</code>.</div>
<button class="replay" data-replay="tile-shake">Replay</button>
</div>
<div class="tile" data-tile="flash">
<div class="tname">Success flash → unlock drift <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Correct password: green (<code>color2</code>) flash + glow ring, then the existing 400ms fade-and-drift-up unlock in <code>state.rs</code>.</div>
<button class="replay" data-replay="tile-flash">Replay</button>
</div>
<div class="tile" data-tile="crossfade">
<div class="tname">Clock minute crossfade <span class="tag S">S</span></div>
<div class="tscreen"><div class="tclock"><span class="old">21:47</span><span class="new">21:48</span></div></div>
<div class="tnote">300ms dip-and-swap on the minute tick instead of a hard blink. Locker: crossfade layer in <code>render.rs</code>. Greeter: GTK CSS transition.</div>
<button class="replay" data-replay="tile-crossfade">Tick</button>
</div>
<div class="tile" data-tile="breathe">
<div class="tname">Idle breathing <span class="tag S">S</span></div>
<div class="tscreen"><div class="tpill"><i></i><i></i><i></i></div></div>
<div class="tnote">Very subtle 34s sine on the pill's glow — proof the screen is live, not frozen. Cheap in <code>render.rs</code>; keep amplitude tiny (CPU is software-rendered).</div>
<button class="replay" data-replay="tile-breathe">Replay</button>
</div>
<div class="tile" data-tile="gcard">
<div class="tname">Greeter card entrance <span class="tag S">S</span></div>
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
<div class="tnote">Greeter currently has zero animation. Fade + 18px rise, staggered after the clock — GTK4 CSS <code>@keyframes</code> in <code>breadgreet/theme.rs</code>.</div>
<button class="replay" data-replay="tile-gcard">Replay</button>
</div>
<div class="tile" data-tile="focus">
<div class="tname">Entry focus ring <span class="tag S">S</span></div>
<div class="tscreen"><div class="gcard-mini"><b></b><b></b></div></div>
<div class="tnote">Accent border + soft glow on focus, 200ms transition. Standard GTK CSS <code>:focus</code> — matches the shared stylesheet's "blue on focus" input rule.</div>
<button class="replay" data-replay="tile-focus">Focus</button>
</div>
<div class="tile" data-tile="spinner">
<div class="tname">Auth spinner <span class="tag S">S</span></div>
<div class="tscreen"><div class="spin-mini"></div></div>
<div class="tnote">Real <code>gtk::Spinner</code> during <code>Stage::Working</code> instead of static "Checking…" text. One widget swap in <code>breadgreet/main.rs</code>.</div>
<button class="replay" data-replay="tile-spinner">Replay</button>
</div>
<div class="tile" data-tile="session">
<div class="tname">Session icon rows <span class="tag M">M</span></div>
<div class="tscreen"><div class="rows"><div class="sel"><i></i>bos — Hyprland</div><div><i></i>Hyprland</div></div></div>
<div class="tnote"><code>sessions.rs</code> doesn't parse <code>Icon=</code> today. Custom dropdown rows with per-session icons; falls back to a letter tile.</div>
<button class="replay" data-replay="tile-session">Replay</button>
</div>
<div class="tile" data-tile="kenburns">
<div class="tname">Wallpaper Ken Burns <span class="tag M">M</span></div>
<div class="tscreen"><div class="tpill"><i></i></div></div>
<div class="tnote">Slow pan on image wallpapers — just a drifting <code>Transform</code> in <code>background.rs::paint</code>, no new protocol. Gate behind config (CPU cost).</div>
<button class="replay" data-replay="tile-kenburns">Replay</button>
</div>
</div>
<div class="notes">
<div class="col">
<h4>breadlock — where things land</h4>
<ul>
<li><b>Motion</b>: extend the existing <code>anim_timer</code>/<code>tick_animation</code> loop in <code>state.rs</code>; add per-element progress fields (appear started per element, fail-shake start, dot-pop start).</li>
<li><b>Frame math</b>: all easing lives in <code>render.rs</code> (<code>ease_out_cubic</code>, <code>overlay_motion</code>). Add <code>ease_out_back</code> for the pill overshoot and a damped sinusoid for the shake.</li>
<li><b>Success flash</b>: reuse <code>unlocking: Option&lt;Instant&gt;</code> — flash phase 0250ms, fade 250650ms, then <code>unlock()</code>.</li>
<li><b>Bigger</b>: live blur-of-desktop needs a <code>wlr-screencopy</code> capture (already flagged v2 in README) — software downscale → blur → upscale to keep CPU sane. New <code>[animation]</code> config section (enabled / speed / per-effect toggles).</li>
</ul>
</div>
<div class="col">
<h4>breadgreet — where things land</h4>
<ul>
<li><b>Motion</b>: GTK4 CSS supports <code>@keyframes</code>/<code>animation</code> and transitions — everything goes in <code>breadgreet/theme.rs::load_css</code>, no Rust logic needed for entrance/focus/status.</li>
<li><b>Spinner</b>: swap the status label for a <code>gtk::Spinner</code> during <code>Stage::Working</code> in <code>main.rs</code>.</li>
<li><b>Session icons</b>: parse <code>Icon=</code> in <code>sessions.rs</code> and switch <code>DropDown</code> to custom rows.</li>
<li><b>Unify with the locker</b>: same clock sizing/weight, same radius + spacing tokens; card <code>backdrop-filter: blur()</code> if GTK ≥ 4.12 supports it (README already requires 4.12).</li>
</ul>
</div>
</div>
<footer>
Sketch mirrors <code>breadlock/src/render.rs</code> + <code>state.rs</code>, <code>breadgreet/src/theme.rs</code> + <code>main.rs</code>, and the tokens in
<code>bread-ecosystem/BREAD_DESIGN_SYSTEM.md</code> / <code>bread-theme/src/palette.rs</code>. Palette: fixed BOS dark base
(bg <code>#0c0c0c</code>, surface <code>#1a1a1a</code>, overlay <code>#d8d8d8</code>) with pywal-driven accents (color16).
The "bread" palette's red/green/accent are the curated bread-toned defaults, which is why "wrong password" is brownish until pywal is active.
</footer>
</main>
<script>
/* Palette toggle */
const toggle = document.getElementById("paletteToggle");
toggle.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
document.body.dataset.palette = btn.dataset.palette;
toggle.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b === btn));
});
/* Restart a stylesheet-driven CSS animation: drop the animation via an
inline override, force a reflow, then remove the override so the rule
applies again from its first frame. */
function replayAnim(el) {
el.style.animation = "none";
void el.offsetWidth;
el.style.animation = "";
}
/* ---- breadlock stage ---- */
const lockScreen = document.getElementById("lockScreen");
const lockStatus = document.getElementById("lockStatus");
function setLockState(state) {
// The attribute change is what starts each CSS animation; bounce through
// idle so repeat clicks on the same chip replay it.
if (state === "wrong" || state === "success" || state === "unlock") {
lockScreen.dataset.state = "idle";
void lockScreen.offsetWidth;
}
lockScreen.dataset.state = state;
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
b.classList.toggle("active", b.dataset.state === state));
switch (state) {
case "typing":
lockScreen.querySelectorAll(".dot").forEach(replayAnim);
lockStatus.textContent = "";
break;
case "wrong":
lockStatus.textContent = "Wrong password";
setTimeout(() => { if (lockScreen.dataset.state === "wrong") setLockState("idle"); }, 1200);
break;
case "success":
lockStatus.textContent = "✓ Unlocked";
setTimeout(() => setLockState("unlock"), 600);
break;
case "unlock":
setTimeout(() => { setLockState("idle"); replayLockEntrance(); }, 900);
break;
default:
lockStatus.textContent = "";
}
}
function replayLockEntrance() {
replayAnim(lockScreen.querySelector(".lclock"));
replayAnim(lockScreen.querySelector(".ldate"));
replayAnim(lockScreen.querySelector(".pill"));
}
document.querySelectorAll('[data-screen="lockScreen"] button').forEach((b) =>
b.addEventListener("click", () => setLockState(b.dataset.state)));
document.querySelector('[data-replay="lockScreen"]').addEventListener("click", replayLockEntrance);
/* ---- breadgreet stage ---- */
const greetScreen = document.getElementById("greetScreen");
const greetEntry = document.getElementById("greetEntry");
const greetStatus = document.getElementById("greetStatus");
const greetStates = {
username: { placeholder: "Username", status: "" },
prompt: { placeholder: "Password", status: "Password for breadway" },
checking: { placeholder: "Password", status: "Checking…" },
error: { placeholder: "Password", status: "Wrong password" },
};
function setGreetState(state) {
if (state === "error") {
greetScreen.dataset.state = "username";
void greetScreen.offsetWidth;
}
greetScreen.dataset.state = state;
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
b.classList.toggle("active", b.dataset.state === state));
const s = greetStates[state];
greetEntry.placeholder = s.placeholder;
greetStatus.textContent = s.status;
if (state === "error") {
setTimeout(() => { if (greetScreen.dataset.state === "error") setGreetState("prompt"); }, 1200);
} else if (state === "checking") {
setTimeout(() => { if (greetScreen.dataset.state === "checking") setGreetState("username"); }, 1600);
}
}
document.querySelectorAll('[data-screen="greetScreen"] button').forEach((b) =>
b.addEventListener("click", () => setGreetState(b.dataset.state)));
document.querySelector('[data-replay="greetScreen"]').addEventListener("click", () => {
replayAnim(greetScreen.querySelector(".gclock"));
replayAnim(greetScreen.querySelector(".card"));
});
/* ---- motion library ---- */
const tileActions = {
"tile-stagger": (t) => { replayAnim(t.querySelector(".tclock")); replayAnim(t.querySelector(".tpill")); },
"tile-dotpop": (t) => t.querySelectorAll(".tpill i").forEach(replayAnim),
"tile-shake": (t) => { replayAnim(t.querySelector(".tpill")); replayAnim(t.querySelector(".tstatus")); },
"tile-flash": (t) => replayAnim(t.querySelector(".tpill")),
"tile-crossfade": (t) => t.classList.toggle("ticked"),
"tile-breathe": (t) => replayAnim(t.querySelector(".tpill")),
"tile-gcard": (t) => replayAnim(t.querySelector(".gcard-mini")),
"tile-focus": (t) => t.classList.toggle("focused"),
"tile-spinner": () => {},
"tile-session": (t) => replayAnim(t.querySelector(".rows")),
"tile-kenburns": (t) => {
const sc = t.querySelector(".tscreen");
sc.classList.add("noanim");
void sc.offsetWidth;
sc.classList.remove("noanim");
},
};
document.querySelectorAll(".tile").forEach((tile) => {
const btn = tile.querySelector(".replay");
if (!btn) return;
btn.addEventListener("click", () => tileActions["tile-" + tile.dataset.tile]?.(tile));
});
/* Boot the lock stage with the entrance visible. */
replayLockEntrance();
</script>
</body>
</html>