Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,17 +205,18 @@ baseline). Three techniques (the third added 2026-07-08):

| File | Current lines | Priority |
|---|---|---|
| `loki-layout/src/para.rs` | 1626 | High |
| `loki-layout/src/flow.rs` | 1362 | High |
| `loki-ooxml/src/docx/write/document.rs` | 1073 | High |
| `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1047 | High |
| `loki-ooxml/src/docx/reader/document.rs` | 1004 | High |
| `loki-odf/src/odt/reader/styles.rs` | 892 | Med |
| `loki-layout/src/resolve.rs` | 865 | Med |
| … 22 more — see `scripts/file-ceiling-baseline.txt` (29 entries after the 2026-07-08 pass) | | |

*(Sizes above are from `scripts/file-ceiling-baseline.txt`, refreshed 2026-07-08;
the earlier numbers were stale — several files grew since first baselined.)*
| `loki-layout/src/para.rs` | 1447 | High |
| `loki-layout/src/flow.rs` | 1202 | High |
| `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1014 | High |
| `loki-ooxml/src/docx/write/document.rs` | 902 | High |
| `loki-layout/src/resolve.rs` | 858 | Med |
| `loki-text/src/routes/editor/editor_inner.rs` | 800 | Med |
| `loki-odf/src/odt/reader/styles.rs` | 764 | Med |
| … 22 more — see `scripts/file-ceiling-baseline.txt` (29 entries) | | |

*(Sizes above are from `scripts/file-ceiling-baseline.txt`, refreshed 2026-07-11;
the deferred-features tail pass also ratcheted `loki-vello/src/scene.rs`
727 → 613 by splitting caret painting into `scene_cursor.rs`.)*

(`odt/mapper/document.rs` (1094 lines) was split into the `odt/mapper/document/`
directory on 2026-06-26 — each module is now under the ceiling.)
Expand Down
4 changes: 4 additions & 0 deletions appthere-ui/src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod confirm_dialog;
pub mod document_tab;
pub mod home_tab;
pub mod icons;
pub mod overlay;
pub mod panel_host;
pub mod platform;
pub mod ribbon;
Expand All @@ -20,6 +21,9 @@ pub mod zoom;
pub use confirm_dialog::{AtConfirmDialog, AtConfirmDialogProps};
pub use document_tab::{AtDocumentTab, AtDocumentTabProps};
pub use home_tab::{AtHomeTab, AtHomeTabProps, BuiltinTemplate, RecentDocument};
pub use overlay::{
use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost, BACKDROP_Z_INDEX,
};
pub use panel_host::{AtPanelHost, AtPanelHostProps, PanelPosture};
pub use platform::Platform;
pub use ribbon::{AtRibbon, AtRibbonGroup, AtRibbonGroupProps, RibbonTabDesc, RibbonTabIndex};
Expand Down
93 changes: 93 additions & 0 deletions appthere-ui/src/components/overlay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: Apache-2.0

//! Window-level dismiss backdrop: [`use_provide_backdrop`] + [`AtBackdropHost`]
//! + [`use_backdrop`].
//!
//! `position: fixed` collapses to `absolute` in the current Blitz stack, so a
//! component deep in the tree (e.g. the ribbon's overflow menu) cannot render
//! its own full-viewport click-catcher — an absolute backdrop only spans its
//! nearest positioned ancestor. Instead the app root provides this context and
//! mounts [`AtBackdropHost`] inside its **positioned** root container; any
//! descendant can then request a transparent viewport-spanning backdrop whose
//! click dismisses whatever the requester has open.
//!
//! The popup itself stays wherever the requester rendered it (anchored to its
//! trigger) — only the backdrop is hosted at the window level. The popup must
//! carry a `z-index` above [`BACKDROP_Z_INDEX`] to stay clickable.

use dioxus::prelude::*;

/// z-index of the backdrop click-catcher. Popups that use the backdrop must
/// render above this (the ribbon overflow menu uses 41).
pub const BACKDROP_Z_INDEX: i32 = 40;

/// Context handle for the window-level dismiss backdrop.
#[derive(Clone, Copy)]
pub struct AtBackdropContext {
/// `Some(on_dismiss)` while a requester has the backdrop up.
dismiss: Signal<Option<Callback<()>>>,
}

impl AtBackdropContext {
/// Raises the backdrop; a click anywhere on it calls `on_dismiss` (the
/// requester closes its own popup state) and lowers the backdrop.
/// A second `show` replaces the previous requester's callback.
pub fn show(mut self, on_dismiss: Callback<()>) {
self.dismiss.set(Some(on_dismiss));
}

/// Lowers the backdrop without invoking the dismiss callback (the popup
/// closed through its own affordance).
pub fn hide(mut self) {
if self.dismiss.peek().is_some() {
self.dismiss.set(None);
}
}
}

/// Provides the backdrop context at the application root. Call once, before
/// any component that may request a backdrop; mount [`AtBackdropHost`] inside
/// the app's positioned root container to actually render it.
pub fn use_provide_backdrop() -> AtBackdropContext {
let dismiss = use_signal(|| None);
let ctx = AtBackdropContext { dismiss };
provide_context(ctx);
ctx
}

/// The backdrop requester handle, or `None` when the app has not wired
/// [`use_provide_backdrop`] (callers degrade to their local dismissal).
#[must_use]
pub fn use_backdrop() -> Option<AtBackdropContext> {
try_consume_context::<AtBackdropContext>()
}

/// Renders the active backdrop as a transparent, viewport-spanning
/// click-catcher. Mount once, inside the app's `position: relative` root
/// container (typically after the router, so the backdrop paints above the
/// regular content and below the requesting popup's higher `z-index`).
///
/// # Touch target
///
/// The backdrop is itself the (viewport-sized) target; the 44 × 44 px minimum
/// (WCAG 2.5.8) is trivially met while it is shown.
#[component]
pub fn AtBackdropHost() -> Element {
let ctx = use_context::<AtBackdropContext>();
let mut dismiss = ctx.dismiss;
let Some(cb) = *dismiss.read() else {
return rsx! {};
};
rsx! {
div {
style: format!(
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; \
z-index: {BACKDROP_Z_INDEX};"
),
onclick: move |_| {
cb.call(());
dismiss.set(None);
},
}
}
}
44 changes: 33 additions & 11 deletions appthere-ui/src/components/ribbon/groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use dioxus::prelude::*;
use super::button::AtRibbonIconButton;
use super::group::AtRibbonGroup;
use crate::components::icons::{AtIcon, LUCIDE_MORE_HORIZONTAL};
use crate::components::overlay::use_backdrop;
use crate::responsive::{use_ribbon_cascade, GroupCollapse, GroupMetrics};
use crate::tokens;

Expand Down Expand Up @@ -54,6 +55,31 @@ pub fn AtRibbonGroups(
let cascade = use_ribbon_cascade(metrics);
let mut menu_open = use_signal(|| false);

// Outside-click dismissal: while the menu is open, the app's window-level
// backdrop host (when wired — `use_provide_backdrop` + `AtBackdropHost`)
// shows a viewport-spanning click-catcher that closes it. Degrades to
// toggle-only dismissal in an app without the host.
let backdrop = use_backdrop();
let mut set_menu_open = move |open: bool| {
menu_open.set(open);
if let Some(b) = backdrop {
if open {
b.show(Callback::new(move |()| menu_open.set(false)));
} else {
b.hide();
}
}
};
// Never leave a stale backdrop if this strip unmounts with the menu open
// (e.g. a ribbon tab switch).
use_drop(move || {
if let Some(b) = backdrop {
if *menu_open.peek() {
b.hide();
}
}
});

// Partition into in-strip groups (with their state) and overflowed groups.
let overflowed: Vec<&RibbonGroupSpec> = groups
.iter()
Expand All @@ -66,7 +92,7 @@ pub fn AtRibbonGroups(
// stale-open menu — its "More" button is gone, so it could never be toggled
// shut. Reconcile in-render; it converges in one frame.
if !cascade.overflow && *menu_open.peek() {
menu_open.set(false);
set_menu_open(false);
}

rsx! {
Expand Down Expand Up @@ -96,7 +122,7 @@ pub fn AtRibbonGroups(
is_disabled: false,
on_click: move |_| {
let open = menu_open();
menu_open.set(!open);
set_menu_open(!open);
},
AtIcon { path_d: LUCIDE_MORE_HORIZONTAL.to_string() }
}
Expand All @@ -106,15 +132,11 @@ pub fn AtRibbonGroups(
// anchored to the More button. `position: absolute` (block-level)
// is confirmed working in the current Blitz stack (see CLAUDE.md).
//
// Dismissal: the More button toggles it shut, and a resize that
// removes the overflow auto-closes it (above). True
// outside-click-to-dismiss needs a full-viewport backdrop, which
// must be hosted at a positioned window-level ancestor (like the
// editor-root overlay the spell panel uses) — `position: fixed`
// collapses to `absolute` in stylo_taffy, so a backdrop rendered
// here would only cover this small wrapper. TODO(ribbon): host the
// overflow menu in a shared window-level overlay so a backdrop can
// span the viewport.
// Dismissal: outside-click via the window-level backdrop host
// (raised in `set_menu_open`; the menu's z-index 41 sits above
// `BACKDROP_Z_INDEX` 40 so its own controls stay clickable),
// plus the More-button toggle and the auto-close on a widen
// that removes the overflow.
div {
style: format!(
"position: absolute; bottom: 100%; right: 0; z-index: 41; \
Expand Down
13 changes: 7 additions & 6 deletions appthere-ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,17 @@ pub use components::ribbon::{
RibbonTabDesc, RibbonTabIndex,
};
pub use components::{
next_zoom, AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData,
AtDocumentTabProps, AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar,
AtStatusBarProps, AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate,
PanelPosture, Platform, RecentDocument,
next_zoom, use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost,
AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps,
AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps,
AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform,
RecentDocument, BACKDROP_Z_INDEX,
};
pub use responsive::{
estimate_group_metrics, group_layout, page_fits, required_page_width, resolve_cascade,
resolve_page_fit, use_breakpoint, use_provide_responsive, use_responsive, use_ribbon_cascade,
use_viewport, AtResponsiveContext, Breakpoint, GroupCollapse, GroupLayout, GroupMetrics,
PageFit, RibbonCascade, Viewport, DEFAULT_DPI,
use_viewport, AtResponsiveContext, AtViewportWidthSensor, Breakpoint, GroupCollapse,
GroupLayout, GroupMetrics, PageFit, RibbonCascade, Viewport, DEFAULT_DPI,
};
pub use safe_area::{set_safe_area_insets, update_safe_area_insets, use_safe_area, SafeAreaInsets};
pub use theme::{use_theme, AtThemeContext, ThemeVariant};
11 changes: 7 additions & 4 deletions appthere-ui/src/responsive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod breakpoint;
mod page_fit;
mod ribbon_collapse;
mod viewport;
mod width_sensor;

pub use breakpoint::Breakpoint;
pub use page_fit::{page_fits, required_page_width, resolve_page_fit, PageFit};
Expand All @@ -39,6 +40,7 @@ pub use ribbon_collapse::{
GroupMetrics, RibbonCascade,
};
pub use viewport::{Viewport, DEFAULT_DPI};
pub use width_sensor::AtViewportWidthSensor;

use dioxus::prelude::*;

Expand Down Expand Up @@ -108,10 +110,11 @@ pub fn use_breakpoint() -> Breakpoint {
/// avoid thrash — see [`resolve_cascade`]).
///
/// **Resilient:** like [`use_breakpoint`], if no responsive context is present
/// (an app that has not wired [`use_provide_responsive`], e.g. Presentation /
/// Spreadsheet) the available width is treated as unbounded, so every group
/// stays [`GroupCollapse::Full`] — a sane full-chrome ribbon rather than a
/// broken one.
/// (an app that has not wired [`use_provide_responsive`]) the available width
/// is treated as unbounded, so every group stays [`GroupCollapse::Full`] — a
/// sane full-chrome ribbon rather than a broken one. (All three suite apps now
/// wire the context; Presentation/Spreadsheet measure via
/// [`AtViewportWidthSensor`].)
///
/// The hysteresis state (the resolved collapse `level`) lives in a hook-local
/// signal, so call this once per ribbon content strip.
Expand Down
56 changes: 56 additions & 0 deletions appthere-ui/src/responsive/width_sensor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 AppThere Loki contributors

//! [`AtViewportWidthSensor`] — pushes the root width into the responsive
//! context for apps without another measured width source (audit F7a).

use dioxus::prelude::*;

use super::{use_responsive, Viewport};

/// Invisible, zero-height, full-width scroll container that measures its own
/// width into the shared responsive context: once at mount, and again on every
/// shell `resync_scroll_geometry` tick (the blitz shell re-emits `onscroll` to
/// every scroll container after a window resize), so [`super::use_breakpoint`]
/// tracks the live window width without a dedicated resize event.
///
/// Mount once at the app root in apps that have no other measured width source
/// (Presentation / Spreadsheet — loki-text funnels its editor scroll-container
/// width instead; audit F7a). Requires [`super::use_provide_responsive`] in an
/// ancestor.
///
/// Not an interactive element (zero-size, no pointer targets), so the 44 px
/// touch-target convention does not apply.
#[component]
pub fn AtViewportWidthSensor() -> Element {
let responsive = use_responsive();
let mut mounted = use_signal(|| Option::<MountedEvent>::None);
let measure = move || {
let Some(evt) = mounted.peek().clone() else {
return;
};
let mut viewport = responsive.viewport;
spawn(async move {
if let Ok(rect) = evt.get_client_rect().await {
let width = rect.size.width as f32;
let prev = *viewport.peek();
if width > 0.0 && (prev.inner_width_px - width).abs() > 0.5 {
viewport.set(Viewport {
inner_width_px: width,
..prev
});
}
}
});
};
rsx! {
div {
style: "width: 100%; height: 0px; overflow: auto;",
onmounted: move |e| {
mounted.set(Some(e));
measure();
},
onscroll: move |_| measure(),
}
}
}
8 changes: 4 additions & 4 deletions docs/deferred-features-audit-2026-07-04.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,21 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe
| Topic | file:line(s) | Defers what |
|---|---|---|
| `3b-3` (partial) | `navigation.rs` (many) | ~~Left/right at page edges + `page_index` recompute after split/merge~~ **fixed 2026-07-05** (plan 4b.1: cross-page Left/Right + `page_locate::recompute_page_index` after every mutation/move). Remaining: double-Enter list-exit heuristic (`clear_para_props`) |
| `loro-bridge` | `loro_bridge/decode.rs` (borders), `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). Remaining: non-Rgb *border* colors (format migration), `Cite` metadata, structural-table CRDT semantics |
| `loro-bridge` | `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). ~~Non-Rgb *border* colors~~ **fixed 2026-07-11** (v2 border codec, color field last so the total color codec fits; v1 strings still decode). Remaining: `Cite` metadata, structural-table CRDT semantics |
| `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | ~~Compact the CRDT oplog~~ **fixed 2026-07-04** (plan Phase 1.5): `loro_bridge::compact` + save-point wiring in loki-text; bench asserts the flattened curve. On-device validation pending (BM-14) |
| `omml` | `docx/omml/mod.rs:20` | OMML↔MathML for delimiters, n-ary, matrices, accents |
| `link-click` | `resolve.rs:689`, `items.rs:125`, `para.rs:203`, `scene.rs:519` | Interactive hyperlink hit-testing (only a visual hint today) |
| `link-click` | — | ~~Interactive hyperlink hit-testing~~ **fixed 2026-07-09/11** (5.11: hint + hit-test + Ctrl/Cmd+click open in paginated *and* reflow modes; all TODO notes retired) |
| `shadow` | `para_emit.rs:187`, `para.rs:196`, `scene.rs:93` | True soft text shadow (Vello blur) — hard grey offset copy today |
| `partial-render` | `scene.rs:148`, `editor_pointer.rs:139` | Viewport clipping / direct `node.scroll_offset` |
| `inline-image-flow` | `resolve.rs:706`, `flow_para.rs:213` | Parley inline image boxes (images prepended block-level today) |
| `floating-image` | `resolve.rs:705` | Detect "floating" class for inline images (gap #12) |
| `underline-style` / `strikethrough-style` | `para.rs:164,170` | Double/Dotted/Dash/Wave underline; double strikethrough (all render Single) |
| `super-sub` (partial) | `para.rs:177,870` | Native Parley `BaselineShift` (effect already applied — see S-3) |
| `split-optimise` | `para.rs:409` | Y-range item filter to avoid GPU clipping (Option B; Option A shipped) |
| `split-optimise` | | ~~Y-range item filter (Option B)~~ **fixed 2026-07-11** (`ParagraphLayout::items_in_y_range`; each split fragment carries only its own y-range's items, clip retained) |
| `tab-default` | `para.rs:648` | Honour `DocumentSettings.default_tab_stop_pt` (hardcoded 36pt) |
| `spell-baseline` | `para.rs:1619` | Tighten squiggle to run underline offset |
| `list-picture-bullet` | `para.rs:1795` | Picture bullets (falls back to `•`) |
| `rotated-cell-editing` | `flow.rs:1676` | Editing data for rotated table cells (read-only today) |
| `rotated-cell-editing` | | ~~Editing data for rotated table cells~~ **fixed 2026-07-08/11** (hit-test + tilted caret paint + rotation-aware arrow navigation; 4b.5 complete) |
| `pdf-rotate` | `pdf/src/page.rs:83` | Rotation transform in PDF export |
| `odf-master-page` | `odf/reader/styles.rs:200` | ODF master-page transitions |
| `odt-fidelity` | `editor_load.rs:84,88` | Tracked DOCX/ODT import gaps |
Expand Down
Loading
Loading