From db232e5794daa3cf09dd3343be97f4a116e42e76 Mon Sep 17 00:00:00 2001 From: Niclas Overby Date: Sat, 8 Aug 2026 17:10:16 +0200 Subject: [PATCH] feat(web/wiki): Read a PDF here by default, and offer it first Most of what this wiki carries a PDF for is read on a phone at a meeting, and there the browser's viewer is a fixed page in a scrolling box: pinching at six-point type, no reflow, and the page's own find blind to it. This app's renderer reflows, works with find, keeps the page marks, and is the one that can go on improving. So it is the default and the first of the two offered, and an unreadable stored preference now lands on it rather than on the browser. The browser's viewer stays one tap away and unchanged, for a reader who wants the page exactly as laid out, or wants to print it. A device that already chose the browser keeps that choice. Sections of a screen are not cards either: the folder's lists, the amendments and polls under a policy, a ballot, the member and profile sections, the editor, admin and feedback panels, and the loading skeleton that stands in for content while it arrives. Still cards: the home screen's places, the canvases in the picker, a social post, and the notices (error, not found, redirect), which are one of several or a message rather than a surface. Signed-off-by: Niclas Overby --- src/components/admin.rs | 8 ++++---- src/components/editor.rs | 4 ++-- src/components/feedback_app.rs | 4 ++-- src/components/file.rs | 30 ++++++++++++++++-------------- src/components/folder.rs | 4 ++-- src/components/loader.rs | 2 +- src/components/member.rs | 4 ++-- src/components/profile.rs | 16 ++++++++-------- src/components/screen.rs | 4 ++-- src/components/social.rs | 4 ++-- src/components/speak.rs | 2 +- src/components/vote/policy.rs | 6 +++--- src/components/vote/poll.rs | 2 +- src/components/vote/position.rs | 2 +- 14 files changed, 47 insertions(+), 45 deletions(-) diff --git a/src/components/admin.rs b/src/components/admin.rs index c3ce63f6..9c182965 100644 --- a/src/components/admin.rs +++ b/src/components/admin.rs @@ -169,7 +169,7 @@ pub fn AdminApp(node: NodeWithChildren) -> Element { let sel = console_tab(tab(), wide); let agenda_pane = rsx! { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar", {icon_el("app/program")} } h3 { class: "title-medium", "{t(\"console.agenda\")}" } @@ -231,7 +231,7 @@ pub fn AdminApp(node: NodeWithChildren) -> Element { // something is projected; when the item has no sections, the card stays // (with a note) rather than silently vanishing. if can_manage && active_id.is_some() { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar", span { class: "material-icons", "center_focus_strong" } } h3 { class: "title-medium", "{t(\"console.focusSection\")}" } @@ -333,7 +333,7 @@ pub fn AdminApp(node: NodeWithChildren) -> Element { } // ── Polls ─────────────────────────────────────────────────────── if sel == 2 { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar", {icon_el("vote/poll")} } h3 { class: "title-medium", "{t(\"admin.results\")}" } @@ -360,7 +360,7 @@ pub fn AdminApp(node: NodeWithChildren) -> Element { // What has landed in this context while the meeting ran: an amendment // posted from the floor shows up here without the chair going looking. if sel == 3 { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "view_agenda" } } h3 { class: "title-medium", "{t(\"layout.feed\")}" } diff --git a/src/components/editor.rs b/src/components/editor.rs index ae97c46d..a4233a07 100644 --- a/src/components/editor.rs +++ b/src/components/editor.rs @@ -816,7 +816,7 @@ pub fn EditorApp(node: NodeWithChildren) -> Element { if !is_auth { // DESIGN: an expressive locked-barrier state instead of a plain card. return rsx! { - div { class: "card", + div { class: "card app-card", div { class: "empty-state empty-state-sm", div { class: "empty-state-orb empty-state-orb-sm", span { class: "material-icons", "lock" } @@ -831,7 +831,7 @@ pub fn EditorApp(node: NodeWithChildren) -> Element { let cmd_seed = initial_html.clone(); rsx! { - div { class: "card", + div { class: "card app-card", div { class: "card-content", // Title field. maxlength caps the node name length (#111). div { class: "text-field mb-2", diff --git a/src/components/feedback_app.rs b/src/components/feedback_app.rs index 3d6388f7..f7afd18f 100644 --- a/src/components/feedback_app.rs +++ b/src/components/feedback_app.rs @@ -210,7 +210,7 @@ pub fn FeedbackApp() -> Element { if !is_auth { return rsx! { - div { class: "card", + div { class: "card app-card", div { class: "empty-state empty-state-sm", div { class: "empty-state-orb empty-state-orb-sm", span { class: "material-icons", "lock" } @@ -235,7 +235,7 @@ pub fn FeedbackApp() -> Element { }; rsx! { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar small", {super::loader::feedback_icon_el()} } h3 { class: "title-medium", diff --git a/src/components/file.rs b/src/components/file.rs index 8ff80367..df25de36 100644 --- a/src/components/file.rs +++ b/src/components/file.rs @@ -258,18 +258,21 @@ fn is_presentation(mime: &str) -> bool { /// /// A different question from [`OfficeViewer`], which is about which THIRD PARTY /// sees the document. A PDF already goes to nobody: every browser has a viewer -/// built in and it renders locally. So this is fidelity against readability, and -/// the browser keeps the default, because it is exact, it prints, and it is what -/// a reader already expects a PDF to look like. +/// built in and it renders locally. So this is fidelity against readability. /// -/// The native one is for the case the browser's viewer is bad at: a long -/// appendix on a phone, where a fixed page in a scrolling box means pinching at -/// six-point type. It reflows, and the browser's own find works on it. +/// Readability leads. Most of what this wiki carries a PDF for is read on a +/// phone at a meeting, and there the browser's viewer is a fixed page in a +/// scrolling box: pinching at six-point type, no reflow, and the page's own find +/// blind to it. The native one reflows, works with find, keeps the page marks, +/// and is the one this app can go on improving. +/// +/// The browser's stays one tap away and unchanged, for when a reader wants the +/// page exactly as it was laid out, or wants to print it. #[derive(Clone, Copy, PartialEq, Debug)] pub enum PdfViewer { - /// The browser's built-in viewer, in an iframe. The default. + /// The browser's built-in viewer, in an iframe. Browser, - /// Rendered here, from the file's own bytes, as flowing text. + /// Rendered here, from the file's own bytes, as flowing text. The default. Native, } @@ -288,12 +291,11 @@ impl PdfViewer { } } - /// Anything unrecognised is the browser: a preference that cannot be read - /// must not silently move a reader to the renderer that reflows. + /// Anything unrecognised is the default, which is this app's own renderer. fn from_key(key: &str) -> Self { match key { - "native" => PdfViewer::Native, - _ => PdfViewer::Browser, + "browser" => PdfViewer::Browser, + _ => PdfViewer::Native, } } } @@ -304,7 +306,7 @@ pub static PDF_VIEWER: GlobalSignal = Signal::global(|| { .and_then(|w| w.local_storage().ok().flatten()) .and_then(|s| s.get_item("wiki_pdf_viewer").ok().flatten()) .map(|v| PdfViewer::from_key(&v)) - .unwrap_or(PdfViewer::Browser) + .unwrap_or(PdfViewer::Native) }); /// Choose a PDF viewer, and remember it. @@ -1224,7 +1226,7 @@ pub fn FileApp(node: NodeWithChildren) -> Element { if file_mime == "application/pdf" { super::widgets::SheetGroup { div { class: "sheet-label", "{t(\"file.renderedBy\")}" } - for viewer in [PdfViewer::Browser, PdfViewer::Native] { + for viewer in [PdfViewer::Native, PdfViewer::Browser] { button { key: "{viewer.label_key()}", class: if PDF_VIEWER() == viewer { "sheet-action selected" } else { "sheet-action" }, diff --git a/src/components/folder.rs b/src/components/folder.rs index 1dd290ed..37a9a1fd 100644 --- a/src/components/folder.rs +++ b/src/components/folder.rs @@ -807,7 +807,7 @@ pub fn FolderApp( // The folder's contents in a separate card, so the content card's header // stays simple (identity + tools). The item count and the list/grid toggle // live here, with the children they control. - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "folder_open" } @@ -898,7 +898,7 @@ pub fn FolderApp( // What this group is credited on, below its own contents. if is_group { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "history_edu" } } // The neutral heading, not the first-person one: these are the diff --git a/src/components/loader.rs b/src/components/loader.rs index 5e2d8163..281a2852 100644 --- a/src/components/loader.rs +++ b/src/components/loader.rs @@ -234,7 +234,7 @@ fn PathResolver(segments: Vec, app: Option) -> Element { #[component] fn ContentSkeleton() -> Element { rsx! { - div { class: "card", + div { class: "card app-card", div { class: "skeleton-card", div { class: "skeleton-row", div { class: "skeleton skeleton-avatar" } diff --git a/src/components/member.rs b/src/components/member.rs index d5070acf..a5b90c8b 100644 --- a/src/components/member.rs +++ b/src/components/member.rs @@ -114,7 +114,7 @@ pub fn MemberApp(node: NodeWithChildren) -> Element { super::widgets::SupportingPaneLayout { primary: rsx! { if show_link_nudge && !nudge_dismissed() { - div { class: "card mb-1", + div { class: "card app-card mb-1", div { class: "card-content stack stack-h", span { class: "material-icons", "cloud_off" } @@ -144,7 +144,7 @@ pub fn MemberApp(node: NodeWithChildren) -> Element { } } } - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar", span { class: "material-icons", "group" } } div { diff --git a/src/components/profile.rs b/src/components/profile.rs index f746609a..b1b36b58 100644 --- a/src/components/profile.rs +++ b/src/components/profile.rs @@ -75,7 +75,7 @@ pub fn ProfileApp() -> Element { let Some(user) = user else { return rsx! { - div { class: "card", + div { class: "card app-card", div { class: "card-content", p { class: "body-large", "{t(\"node.maybeLoginForAccess\")}" } Link { to: Route::Login {}, class: "btn btn-primary", "{t(\"common.logIn\")}" } @@ -89,7 +89,7 @@ pub fn ProfileApp() -> Element { let show_linked = link.linked && !*just_unlinked.read(); rsx! { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar", {user_avatar(&user.avatar_url, icon_el("app/profile"))} } div { @@ -114,7 +114,7 @@ pub fn ProfileApp() -> Element { // action; otherwise hand off to the backend OAuth flow with the handle + // current NHost access token (it redirects back to APP_ORIGIN with // ?linked=bluesky|error, surfaced in a snackbar by App on load). - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar small", {crate::components::loader::bsky_logo()} } h3 { class: "title-medium", @@ -228,7 +228,7 @@ pub fn ProfileApp() -> Element { // Background Web Push: opt this device in/out of notifications that arrive // even when the app is closed (e.g. a vote opening in a group you're in). if crate::pwa::push_supported() { - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "notifications" } } h3 { class: "title-medium", "{t(\"profile.notifications\")}" } @@ -279,7 +279,7 @@ pub fn ProfileApp() -> Element { } // Latest contributions the user authored, each linking to the item. - div { class: "card", + div { class: "card app-card", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "history_edu" } } h3 { class: "title-medium", "{t(\"profile.contributions\")}" } @@ -478,7 +478,7 @@ pub fn UserProfile(id: String) -> Element { let contrib_state = contributions.read().clone(); rsx! { - div { class: "card", + div { class: "card app-card", div { class: "profile-hero", div { class: "profile-hero-avatar", {user_avatar(&avatar_url, icon_el("wiki/user"))} @@ -503,7 +503,7 @@ pub fn UserProfile(id: String) -> Element { // What they have written, first: it is why you opened someone's profile. // The same card your own profile carries, but titled without the "Your", // since this is someone else's page. - div { class: "card mt-1", + div { class: "card app-card mt-1", div { class: "card-header", div { class: "avatar small", span { class: "material-icons", "history_edu" } } h3 { class: "title-medium", "{t(\"profile.contributionsOther\")}" } @@ -518,7 +518,7 @@ pub fn UserProfile(id: String) -> Element { }} } // Where you overlap, below it: context for the reader, not the point. - div { class: "card mt-1", + div { class: "card app-card mt-1", div { class: "card-header", // join_inner, the set-intersection mark: this card is precisely // the overlap between their memberships and yours, and it does not diff --git a/src/components/screen.rs b/src/components/screen.rs index 9d2c2b7e..7eb0d7c2 100644 --- a/src/components/screen.rs +++ b/src/components/screen.rs @@ -194,7 +194,7 @@ pub fn ScreenApp(node: NodeWithChildren) -> Element { // the chair asked for this one, and the active node is often // just whatever was last discussed. _ if show_feed => rsx! { - div { class: "card projector-feed", + div { class: "card app-card projector-feed", div { class: "card-header", div { class: "avatar", span { class: "material-icons", "view_agenda" } } h3 { class: "title-medium", "{crate::i18n::t(\"layout.feed\")}" } @@ -208,7 +208,7 @@ pub fn ScreenApp(node: NodeWithChildren) -> Element { // The LARGE orb on purpose: this is the projector, read // from across a room, and is the one place a full-size // empty state belongs outside a page-level state. - div { class: "card", + div { class: "card app-card", div { class: "empty-state", div { class: "empty-state-orb", span { class: "material-icons", "cast" } diff --git a/src/components/social.rs b/src/components/social.rs index 83abce98..4c431fa2 100644 --- a/src/components/social.rs +++ b/src/components/social.rs @@ -61,7 +61,7 @@ pub fn SocialApp(node: NodeWithChildren) -> Element { } }, Some(Ok(_)) => rsx! { - div { class: "card", + div { class: "card app-card", super::widgets::EmptyState { icon: "search_off".to_string(), message: t("social.empty"), @@ -73,7 +73,7 @@ pub fn SocialApp(node: NodeWithChildren) -> Element { // Log the detail; the UI shows a friendly state, never raw error text. crate::errors::log_handled("bluesky wall fetch", e); rsx! { - div { class: "card accent-error", + div { class: "card app-card accent-error", super::widgets::ErrorState { title: t("error.somethingWentWrong"), small: true, diff --git a/src/components/speak.rs b/src/components/speak.rs index d3639d08..c3be0cda 100644 --- a/src/components/speak.rs +++ b/src/components/speak.rs @@ -632,7 +632,7 @@ fn SpeakList( // Owner admin panel (#6): open/close the list, clear it, run the timer. if is_owner && !screen { - div { class: "card", + div { class: "card app-card", div { class: "card-header", h3 { class: "title-medium", "{t(\"speak.manageSpeakerList\")}" } } diff --git a/src/components/vote/policy.rs b/src/components/vote/policy.rs index 083c60cc..c61df689 100644 --- a/src/components/vote/policy.rs +++ b/src/components/vote/policy.rs @@ -66,7 +66,7 @@ pub fn PolicyApp(node: NodeWithChildren, path: Vec) -> Element { // Amendments — always shown so its create action (in the header) has a // home; the body shows an empty state until the first amendment lands. - div { class: "card mt-1", + div { class: "card app-card mt-1", div { class: "card-header", div { class: "avatar small", {icon_el("vote/change")} } h3 { class: "title-medium", "{t(\"vote.amendments\")}" } @@ -174,7 +174,7 @@ pub fn PolicyApp(node: NodeWithChildren, path: Vec) -> Element { // Polls if !polls.is_empty() { - div { class: "card mt-1", + div { class: "card app-card mt-1", div { class: "card-header", div { class: "avatar small", {icon_el("vote/poll")} } h3 { class: "title-medium", "{t(\"mime.vote\")}" } @@ -213,7 +213,7 @@ pub fn PolicyApp(node: NodeWithChildren, path: Vec) -> Element { // Other children (comments, questions) if !comments.is_empty() { - div { class: "card mt-1", + div { class: "card app-card mt-1", div { class: "list", for child in comments.iter() { { diff --git a/src/components/vote/poll.rs b/src/components/vote/poll.rs index 34ba4ff9..af71d96f 100644 --- a/src/components/vote/poll.rs +++ b/src/components/vote/poll.rs @@ -864,7 +864,7 @@ pub(super) fn StartPollButton(node: NodeWithChildren, path: Vec) -> Elem let range_label = t("poll.voteRange"); rsx! { - div { class: "card mt-1", + div { class: "card app-card mt-1", div { class: "card-header", // `small`, like every other section card on the pages this sits // among (candidates, questions, amendments, polls): a full-size diff --git a/src/components/vote/position.rs b/src/components/vote/position.rs index af040ee2..68dd2403 100644 --- a/src/components/vote/position.rs +++ b/src/components/vote/position.rs @@ -190,7 +190,7 @@ pub fn PositionApp(node: NodeWithChildren, path: Vec) -> Element { // Polls opened on this position. if !polls.is_empty() { - div { class: "card mt-1", + div { class: "card app-card mt-1", div { class: "card-header", div { class: "avatar small", {icon_el("vote/poll")} } h3 { class: "title-medium", "{t(\"mime.vote\")}" } -- 2.51.2