From 166494c408e55e4708b2e80e9f8ebacc41848340 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 20 Aug 2026 21:24:50 +0530 Subject: [PATCH] Add gate_overlay primitive + icon_text_button for auth-gated chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TokitoAI/tokito #523: the chat panel needs a designed sign-in gate instead of a bare inline error line when the AI gateway returns 401. - icon_text_button: text_button with a leading Phosphor glyph, sharing the same fill/ink/border visuals via a new button_visuals() helper (text_button refactored onto the same helper, no behavior change). - gate_overlay: a full-bleed gated/locked state — heavy scrim (egui has no real backdrop blur, so a near-opaque base + a soft two-ring accent glow approximates depth), a centred brand tile with a small corner badge glyph, a display-weight heading, a muted subline, and a primary CTA. Generalized: icon/heading/body/button label are caller-supplied and it returns whether the CTA was clicked, so any future gated/empty chrome state can reuse it — not just the Tokito Cloud sign-in gate. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 7 +- README.md | 2 + src/components.rs | 201 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 186 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 27d347d..3ae8153 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,13 +25,14 @@ src/ ``` **Components** (`components.rs`): `card`, `new_tile`, `icon_button`, -`text_button`, `link`, `badge`, `menu_button`, `menu_item`, `list_row`, -`text_input`, `search_field`, `secret_input`, `toggle`, `modal`, +`text_button`, `icon_text_button`, `link`, `badge`, `menu_button`, `menu_item`, +`list_row`, `text_input`, `search_field`, `secret_input`, `toggle`, `modal`, `page_header`, `section_header`, `nav_item`, `checkbox`, `segmented`, `select`, `select_option`, `banner`, `collapsing`, `cad_tool_button` (+ `paint_phosphor_glyph`), `data_table` (+ `SortState`, `sortable_header`), `toast_overlay` (+ `ToastStack`), `chip`, -`content_card`, `inspector_row`, `list_section_label`, `empty_state`. +`content_card`, `inspector_row`, `list_section_label`, `empty_state`, +`gate_overlay`. ## Rules — keep these true diff --git a/README.md b/README.md index 62fdecd..215d9b6 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ All live in `tokito_ui::components` (aliased `c` above). Each takes | `new_tile` | `new_tile(ui, t, label, sublabel, size) -> Response` | A dashed "create new …" tile. | | `icon_button` | `icon_button(ui, t, glyph, side) -> Response` | A square, frameless icon button. | | `text_button` | `text_button(ui, t, kind, label, height) -> Response` | A text button — `ButtonKind::Primary` / `Secondary`. | +| `icon_text_button` | `icon_text_button(ui, t, kind, icon, label, height) -> Response` | `text_button` with a leading Phosphor glyph. | | `link` | `link(ui, t, label) -> Response` | An inline accent-coloured text link. | | `badge` | `badge(ui, t, text) -> Response` | A small bordered count / status pill. | | `list_row` | `list_row(ui, t, job, selected) -> Response` | A full-width, left-aligned, hover-highlighted row. | @@ -133,6 +134,7 @@ All live in `tokito_ui::components` (aliased `c` above). Each takes | `inspector_row` | `inspector_row(ui, t, label, value)` | Label-on-left, value-on-right key/value row for inspector / property panels. | | `list_section_label` | `list_section_label(ui, t, label, count)` | "Symbols (24)" style group label for dense side-panel lists. | | `empty_state` | `empty_state(ui, t, message)` | Centred "nothing here" placeholder card. | +| `gate_overlay` | `gate_overlay(ui, t, badge_icon, heading, body, button_icon, button_label) -> bool` | Full-bleed gated/locked state — heavy scrim, brand tile + corner badge, display heading, subline, primary CTA. Returns `true` when the CTA is clicked. | | `chat_activity` | `chat_activity(ui, t, id_source, label, detail)` | Assistant loading/activity bubble with subtle dots and status copy for slow background work. | ### Icons diff --git a/src/components.rs b/src/components.rs index 4278438..54101ba 100644 --- a/src/components.rs +++ b/src/components.rs @@ -158,26 +158,11 @@ pub enum ButtonKind { Secondary, } -/// A text button. `height` fixes the row height; width fits the label. -pub fn text_button( - ui: &mut Ui, - t: &Tokens, - kind: ButtonKind, - label: &str, - height: f32, -) -> Response { - // PLACEHOLDER so the galley has no baked colour — `Painter::galley`'s - // fallback colour then applies, letting us colour by hover state. - let galley = ui.painter().layout_no_wrap( - label.to_owned(), - TextStyle::Button.resolve(ui.style()), - Color32::PLACEHOLDER, - ); - let width = galley.size().x + t.space_4 * 2.0; - let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click()); - let hv = hover_t(ui, response.id, response.hovered()); - - let (fill, ink, border) = match kind { +/// Fill / ink / border for a [`ButtonKind`] at hover factor `hv`. Shared by +/// [`text_button`] and [`icon_text_button`] so the two stay visually +/// identical — only the content laid out inside the rect differs. +fn button_visuals(t: &Tokens, kind: ButtonKind, hv: f32) -> (Color32, Color32, Option) { + match kind { ButtonKind::Primary => ( lerp_color(t.accent, lighten(t.accent, 0.12), hv), t.accent_ink, @@ -188,7 +173,13 @@ pub fn text_button( lerp_color(t.text_2, t.text, hv), Some(lerp_color(t.border, t.border_strong, hv)), ), - }; + } +} + +/// Paint a button's rounded fill + optional border. Shared tail of +/// [`text_button`] / [`icon_text_button`] after each lays out its own +/// content (label-only vs. icon-plus-label). +fn paint_button_chrome(ui: &Ui, rect: Rect, t: &Tokens, fill: Color32, border: Option) { ui.painter().rect_filled(rect, t.rounding_sm(), fill); if let Some(b) = border { ui.painter().rect_stroke( @@ -198,11 +189,78 @@ pub fn text_button( egui::StrokeKind::Outside, ); } +} + +/// A text button. `height` fixes the row height; width fits the label. +pub fn text_button( + ui: &mut Ui, + t: &Tokens, + kind: ButtonKind, + label: &str, + height: f32, +) -> Response { + // PLACEHOLDER so the galley has no baked colour — `Painter::galley`'s + // fallback colour then applies, letting us colour by hover state. + let galley = ui.painter().layout_no_wrap( + label.to_owned(), + TextStyle::Button.resolve(ui.style()), + Color32::PLACEHOLDER, + ); + let width = galley.size().x + t.space_4 * 2.0; + let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + let hv = hover_t(ui, response.id, response.hovered()); + + let (fill, ink, border) = button_visuals(t, kind, hv); + paint_button_chrome(ui, rect, t, fill, border); ui.painter() .galley(rect.center() - galley.size() / 2.0, galley, ink); response } +/// A [`text_button`] with a leading Phosphor glyph (`icon`, an +/// [`icons::ph`] constant). Same visual weights and hover animation as +/// [`text_button`] — use this when the action reads better with an icon +/// ("Sign in to Tokito Cloud", "Retry"), and plain `text_button` otherwise. +pub fn icon_text_button( + ui: &mut Ui, + t: &Tokens, + kind: ButtonKind, + icon: &str, + label: &str, + height: f32, +) -> Response { + let icon_size = height * 0.46; + let gap = t.space_2; + let galley = ui.painter().layout_no_wrap( + label.to_owned(), + TextStyle::Button.resolve(ui.style()), + Color32::PLACEHOLDER, + ); + let width = icon_size + gap + galley.size().x + t.space_4 * 2.0; + let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + let hv = hover_t(ui, response.id, response.hovered()); + + let (fill, ink, border) = button_visuals(t, kind, hv); + paint_button_chrome(ui, rect, t, fill, border); + + let content_w = icon_size + gap + galley.size().x; + let start_x = rect.center().x - content_w / 2.0; + let icon_pos = pos2(start_x, rect.center().y); + ui.painter().text( + icon_pos, + egui::Align2::LEFT_CENTER, + icon, + icons::font(icon_size), + ink, + ); + let label_pos = pos2( + start_x + icon_size + gap, + rect.center().y - galley.size().y / 2.0, + ); + ui.painter().galley(label_pos, galley, ink); + response +} + /// An inline text link in the accent colour. pub fn link(ui: &mut Ui, t: &Tokens, label: &str) -> Response { let resp = ui.add( @@ -1625,6 +1683,107 @@ pub fn empty_state(ui: &mut Ui, t: &Tokens, message: &str) { }); } +// --------------------------------------------------------------------------- +// gate_overlay +// --------------------------------------------------------------------------- + +/// A full-bleed gated / locked state: a heavy tinted scrim filling `ui`'s +/// current available rect, with a centred vertical stack — a rounded +/// brand-tile visual carrying a small corner badge glyph, a display-weight +/// heading, one muted subline, and a primary CTA with a leading icon. +/// +/// egui has no real backdrop blur, so the scrim is approximated with a +/// high-opacity base fill plus a soft two-layer accent glow behind the +/// card for depth, rather than a translucent wash over live content — +/// give this its own `Ui` scoped to exactly the region that should read as +/// gated (e.g. a `CentralPanel` spanning a message list *and* its +/// composer), not a thin strip over just part of it. +/// +/// Generalized on purpose: only the icon, heading, body copy, and button +/// icon/label are baked in here — what the button *does* is entirely the +/// caller's concern. Today's only consumer is the chat panel's Tokito +/// Cloud sign-in gate, but any future gated/empty chrome state (a locked +/// feature, an unconfigured integration, …) can reuse this unchanged. +/// +/// Returns `true` on the frame the CTA is clicked. +#[allow(clippy::too_many_arguments)] +pub fn gate_overlay( + ui: &mut Ui, + t: &Tokens, + badge_icon: &str, + heading: &str, + body: &str, + button_icon: &str, + button_label: &str, +) -> bool { + let rect = ui.available_rect_before_wrap(); + // Claim the whole area so nothing behind it is clickable while gated. + ui.allocate_rect(rect, Sense::hover()); + + // Layer 1: a near-opaque base — the closest egui gets to a heavy + // backdrop scrim without real blur support. + let scrim = if t.dark { + t.bg.gamma_multiply(0.97) + } else { + t.bg + }; + ui.painter().rect_filled(rect, 0.0, scrim); + + // Layer 2: a soft two-ring accent glow centred behind the card, for + // depth — flat fills, not a real blur, but reads as intentional rather + // than a plain empty-state grey box. + let glow_r = (rect.width().min(rect.height()) * 0.42).max(120.0); + ui.painter() + .circle_filled(rect.center(), glow_r, t.accent_soft); + ui.painter() + .circle_filled(rect.center(), glow_r * 0.6, t.accent_2_soft); + + let mut clicked = false; + ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| { + ui.allocate_ui_with_layout(rect.size(), Layout::top_down(Align::Center), |ui| { + ui.add_space((rect.height() * 0.5 - 150.0).max(rect.height() * 0.12)); + + // Visual: the brand tile with a small lock badge overlapped at + // its bottom-right corner (falls back to a plain rounded tile + // if the caller ever needs a badge-less variant — not needed + // today, so not parameterised yet). + let tile_side = 72.0; + let tile_resp = crate::brand::brand_tile(ui, tile_side); + let badge_d = 30.0; + let badge_c = tile_resp.rect.right_bottom() - Vec2::splat(badge_d * 0.32); + // Separation ring so the badge reads as sitting *on* the tile + // rather than merging into it. + ui.painter() + .circle_filled(badge_c, badge_d * 0.5 + 3.0, scrim); + ui.painter().circle_filled(badge_c, badge_d * 0.5, t.accent); + ui.painter().text( + badge_c, + egui::Align2::CENTER_CENTER, + badge_icon, + icons::font(badge_d * 0.56), + t.accent_ink, + ); + + ui.add_space(t.space_5); + ui.label( + RichText::new(heading) + .text_style(TextStyle::Heading) + .strong() + .color(t.text), + ); + ui.add_space(t.space_2); + ui.label(RichText::new(body).size(14.0).color(t.text_2)); + ui.add_space(t.space_5); + if icon_text_button(ui, t, ButtonKind::Primary, button_icon, button_label, 38.0) + .clicked() + { + clicked = true; + } + }); + }); + clicked +} + // --------------------------------------------------------------------------- // app_header // ---------------------------------------------------------------------------