diff --git a/app/application.ml b/app/application.ml index 7c8ae74..2733b48 100644 --- a/app/application.ml +++ b/app/application.ml @@ -1599,9 +1599,9 @@ end let dismiss_toolbar ~test_id ~command dispatch body = let close = V.button - ~role:Cancel + ~style:Plain ~on_press:(bind_action dispatch command) - ~child:(V.text "Close") + ~child:(V.label ~title:(V.text "Close") ~icon:(V.symbol ~name:"xmark" ()) ()) () |> V.with_test_id (Ui.Test_id.string test_id) in @@ -1810,6 +1810,7 @@ let media_label ?(detail = false) state dispatch + ~title ~root child = @@ -1818,6 +1819,7 @@ let media_label ~store ~on_region ~scope + ~title ~root ~on_event:(fun event -> dispatch.send (Media (scope, event))) child @@ -1828,6 +1830,7 @@ let row_media_label ?(on_region = fun _ -> ()) state dispatch + ~title ~root ~image_children child @@ -1837,6 +1840,7 @@ let row_media_label ~store ~on_region ~scope + ~title ~root ~image_children ~on_event:(fun event -> dispatch.send (Media (scope, event))) @@ -2002,7 +2006,10 @@ let favorites_view match favorite.target with | Page id | Block id -> id in - key, V.column ~key:(Ui.Key.string key) [ render_media ~root row ]) + ( key + , V.column + ~key:(Ui.Key.string key) + [ render_media ~title:favorite.title ~root row ] )) rows) in let footer = @@ -2044,6 +2051,16 @@ type composer_assets = ; on_remove : string -> unit } +let composer_can_submit ~capture ~saving ~enabled = + enabled + && (not saving) + && (Journal_capture.can_save capture + || + match Journal_capture.phase capture with + | Failed _ -> true + | _ -> false) +;; + let composer_content ~scope ~placeholder @@ -2057,15 +2074,7 @@ let composer_content ~assets = let ignored = Ui.Event.Handler.create (fun _ -> ()) in - let can_submit = - enabled - && (not saving) - && (Journal_capture.can_save capture - || - match Journal_capture.phase capture with - | Failed _ -> true - | _ -> false) - in + let can_submit = composer_can_submit ~capture ~saving ~enabled in let task_selected = Journal_capture.task_state capture = Journal_model.Todo in let task = (* Circular icon capsule matching the composer actions row — buttons has @@ -2198,8 +2207,13 @@ let composer_page ~error ~assets = + let can_submit = composer_can_submit ~capture ~saving ~enabled in let close = - V.button ~role:Cancel ~on_press:on_close ~child:(V.text "Close") () + V.button + ~style:Plain + ~on_press:on_close + ~child:(V.label ~title:(V.text "Cancel") ~icon:(V.symbol ~name:"xmark" ()) ()) + () |> V.with_test_id (Ui.Test_id.string (scope ^ "-close")) in composer_content @@ -2220,6 +2234,14 @@ let composer_page ~key:(Ui.Key.string "composer-close") ~placement:Cancellation_action close + ; V.Toolbar.item + ~key:(Ui.Key.string "composer-submit") + ~placement:Primary_action + (V.button + ~enabled:can_submit + ~on_press:on_save + ~child:(V.text (if scope = "journal-append" then "Add" else "Send")) + ()) ] ;; @@ -2592,7 +2614,8 @@ let diagnostics_page ~snapshot ~graph ~admission diagnostics dispatch = Presentation.section title (List.map - (fun (label, value) -> Presentation.labeled label (V.text value)) + (fun (label, value) -> + V.column ~spacing:4. ~alignment:Leading [ V.text label; V.text value ]) rows)) groups) |> dismiss_toolbar @@ -2819,6 +2842,7 @@ let detail_page ~detail:true state dispatch + ~title:(Journal_model.source block) ~root:(Journal_model.id block)) (Journal_detail.rows detail) in @@ -2932,19 +2956,46 @@ let manager_page state dispatch = "Diagnostics" "stethoscope" in - let toolbar title actions body = + let toolbar ?(sign_out = false) title actions body = + let account_menu = + V.buttons + ~actions: + [ V.buttons_menu_action + ~label:"Account menu" + ~icon:"ellipsis" + ~on_select: + (Ui.Event.Handler.create (function + | Ui.Event.Payload.Int64 1L -> dispatch.send Open_diagnostics + | Int64 2L when sign_out -> dispatch.send Sign_out + | _ -> ())) + ([ V.Menu.action ~id:1L ~title:"Diagnostics" ~icon:"stethoscope" () ] + @ + if sign_out + then + [ V.Menu.action + ~id:2L + ~title:"Sign out" + ~icon:"rectangle.portrait.and.arrow.right" + () + ] + else []) + ] + () + |> V.with_test_id (Ui.Test_id.string "journal-startup-menu") + in body |> V.Body.toolbar ~items: - (V.Toolbar.item - ~key:(Ui.Key.string "startup-title") - ~placement:Principal - (V.text title) - :: V.Toolbar.item - ~key:(Ui.Key.string "startup-diagnostics") - ~placement:Secondary_action - diagnostics - :: actions) + ((V.Toolbar.item + ~key:(Ui.Key.string "startup-title") + ~placement:Principal + (V.text title) + :: actions) + @ [ V.Toolbar.item + ~key:(Ui.Key.string "startup-menu") + ~placement:Primary_action + account_menu + ]) in let unavailable ~title ~symbol ~message ~actions = Presentation.unavailable ~title ~symbol ~message ~actions @@ -3012,6 +3063,7 @@ let manager_page state dispatch = |> V.Body.with_test_id (Ui.Test_id.string "graph-picker-list") in toolbar + ~sign_out:snapshot.startup.authenticated "Choose a graph" [ V.Toolbar.item ~key:(Ui.Key.string "graph-picker-refresh") @@ -6123,13 +6175,14 @@ let start ~render_source:(render_source state) ~render_media: (media_label ~store:media_store ~on_region:on_view_region state dispatch) - ~render_row_media:(fun ~root ~image_children child -> + ~render_row_media:(fun ~title ~root ~image_children child -> on_view_region "timeline-row"; row_media_label ~store:media_store ~on_region:on_view_region state dispatch + ~title ~root ~image_children child) diff --git a/app/journal_media_view.ml b/app/journal_media_view.ml index fa9260a..cc3dd63 100644 --- a/app/journal_media_view.ml +++ b/app/journal_media_view.ml @@ -157,6 +157,7 @@ let size_text size = let view_content ~store + ?title ?(on_region = fun _ -> ()) ?(observed_roots = []) ?asset_root @@ -526,15 +527,20 @@ let view_content ~equal:( = ) (function | None -> L.column ~width:0 ~height:0 [] - | Some ([ path ], _, false) -> - L.file_preview ~path ~on_dismiss:(fun _ -> close_preview ()) [] - | Some (paths, selected_index, _) -> + | Some (paths, selected_index, image) -> let payload = Yojson.Safe.to_string (`Assoc - [ "paths", `List (List.map (fun path -> `String path) paths) - ; "selected_index", `Int selected_index - ]) + ([ "paths", `List (List.map (fun path -> `String path) paths) + ; "selected_index", `Int selected_index + ] + @ + if image + then [] + else + [ "document", `Bool true + ; "title", `String (Option.value title ~default:"Attachment") + ])) in Journal_lui_native.image_preview ~payload @@ -599,6 +605,7 @@ let reactive_structure store ~roots ~on_region build = let view ~store + ?title ?(on_region = fun _ -> ()) ?(observed_roots = []) ?asset_root @@ -612,6 +619,7 @@ let view reactive_structure store ~roots ~on_region (fun () -> view_content ~store + ?title ~on_region ~observed_roots ?asset_root @@ -627,6 +635,7 @@ let view keep their lease/event owner, even when the parent references the same asset. *) let row_content ~store + ?title ?(on_region = fun _ -> ()) ~scope ~root @@ -717,6 +726,7 @@ let row_content in view_content ~store + ?title ~on_region ~known_images:image_children ~observed_roots:(root :: List.map fst image_children) @@ -728,10 +738,20 @@ let row_content child ;; -let row ~store ?(on_region = fun _ -> ()) ~scope ~root ~image_children ~on_event child = +let row + ~store + ?title + ?(on_region = fun _ -> ()) + ~scope + ~root + ~image_children + ~on_event + child + = reactive_structure store ~roots:(root :: List.map fst image_children) ~on_region - (fun () -> row_content ~store ~on_region ~scope ~root ~image_children ~on_event child) + (fun () -> + row_content ~store ?title ~on_region ~scope ~root ~image_children ~on_event child) ;; diff --git a/app/journal_media_view.mli b/app/journal_media_view.mli index 8995e3d..9d280c4 100644 --- a/app/journal_media_view.mli +++ b/app/journal_media_view.mli @@ -31,6 +31,7 @@ type event = val view : store:Store.t + -> ?title:string -> ?on_region:(string -> unit) -> ?observed_roots:string list -> ?asset_root:(string -> string) @@ -47,6 +48,7 @@ val is_image_type : string -> bool the root itself when it is an image asset. No runtime descriptor is required. *) val row : store:Store.t + -> ?title:string -> ?on_region:(string -> unit) -> scope:string -> root:string diff --git a/app/journal_row.ml b/app/journal_row.ml index d1fb1dc..a027209 100644 --- a/app/journal_row.ml +++ b/app/journal_row.ml @@ -120,12 +120,15 @@ let view else body ~render_source block in let content = V.column ~spacing:8. ~alignment:Leading (body :: metadata block) in - let labels = [ render_media ~root:id ~image_children content ] in + let labels = + [ render_media ~title:(Journal_model.source block) ~root:id ~image_children content ] + in let labels = labels @ List.map (fun (summary : Journal_graph_projection.child_summary) -> render_media + ~title:summary.source ~root:summary.block_id ~image_children:[] (V.text (render_source summary.source))) diff --git a/app/journal_row.mli b/app/journal_row.mli index 0e1d774..f02a95c 100644 --- a/app/journal_row.mli +++ b/app/journal_row.mli @@ -1,7 +1,8 @@ val view : ?render_source:(string -> string) -> render_media: - (root:string + (title:string + -> root:string -> image_children:(string * string) list -> Journal_view.View.t -> Journal_view.View.t) diff --git a/app/journal_timeline.mli b/app/journal_timeline.mli index 5b285fa..9f96e22 100644 --- a/app/journal_timeline.mli +++ b/app/journal_timeline.mli @@ -19,7 +19,11 @@ val view -> ?on_region:(string -> unit) -> render_source:(string -> string) -> render_media: - (root:string -> image_children:(string * string) list -> Ui.View.t -> Ui.View.t) + (title:string + -> root:string + -> image_children:(string * string) list + -> Ui.View.t + -> Ui.View.t) -> state:Journal_timeline_state.t -> day_presentation:(int -> Journal_calendar.date_presentation option) -> on_visible_range:Ui.Event.Handler.t diff --git a/app/journal_view.ml b/app/journal_view.ml index 3f87ee1..3358eac 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -19,6 +19,7 @@ type t = ; test_id : string option ; mount : Lui_elements.t ; label_content : label_content option + ; control_label : string option } module Key = struct @@ -36,7 +37,10 @@ module Test_id = struct let to_string s = s end -let element ?key ?test_id mount = { key; test_id; mount; label_content = None } +let element ?key ?test_id mount = + { key; test_id; mount; label_content = None; control_label = None } +;; + let mount t = t.mount let int_of_float_nan v = int_of_float (Float.round v) @@ -793,15 +797,20 @@ module View = struct | Some _ as variant -> variant | None -> Option.map Button_style.lui_variant style in - element - ?key - (Lui_elements.button - ~disabled:(not enabled) - ?variant - ~autofocus - ~on_press:(fun _ -> invoke on_press Event.Payload.Unit) - (* Leaf controls carry label/icon as properties, not child nodes. *) - [ leaf_label child.label_content ]) + let result = + element + ?key + (Lui_elements.button + ~disabled:(not enabled) + ?variant + ~autofocus + ~on_press:(fun _ -> invoke on_press Event.Payload.Unit) + (* Leaf controls carry label/icon as properties, not child nodes. *) + [ leaf_label child.label_content ]) + in + { result with + control_label = Option.map (fun label -> label.title) child.label_content + } ;; let text_edit_handler ~session_id ~document_revision ~accepted_local_revision ~on_edit = @@ -1150,6 +1159,39 @@ module View = struct mounted) ;; + (* Cancellation glyphs retain the original accessible label and action. + Give the native button a full 44pt target before entering the bar. *) + let mount_cancellation (item : item) : Lui_elements.t = + let child context parent = + let previous = !icon_only + and previous_nodes = !icon_only_collapsed_nodes in + icon_only := true; + icon_only_collapsed_nodes := []; + Fun.protect + ~finally:(fun () -> + icon_only := previous; + icon_only_collapsed_nodes := previous_nodes) + (fun () -> + let node = item.content.mount context parent in + if node_is_standard context node + then ( + Lui_ui.width context node 44; + Lui_ui.height context node 44); + node) + in + Journal_lui_native.mount + ~key:item.item_key + ~payload: + (Yojson.Safe.to_string + (`Assoc + [ "mode", `String "toolbar-control" + ; ( "title" + , `String (Option.value item.content.control_label ~default:"Close") ) + ])) + ~children:[ child ] + Journal_lui_native.chrome_identifier + ;; + let nav_button ~icon ~label ~on_press : Lui_elements.t = (* Icon-only button: the schema rejects empty text + icon without an accessibility label. *) @@ -1207,7 +1249,10 @@ module View = struct else []) @ (if cancellation <> [] then - [ emit_bar "cancellation-action" (List.map mount_icon_only cancellation) ] + [ emit_bar + "cancellation-action" + (List.map mount_cancellation cancellation) + ] else []) @ [ flexible_space ] @ (match principal, !nav_bar with @@ -1893,8 +1938,8 @@ module View = struct ~presented ~on_presented_changed ?(interactive_dismiss = true) - ?sizing:_ - ?detents:_ + ?sizing + ?detents ?(title = "") ~content base @@ -1908,6 +1953,23 @@ module View = struct then [ Lui_elements.sheet ~text:(if String.equal title "" then "Sheet" else title) + ~style_class:"navigation-content navigation-inline-title" + ?sizing: + (Option.bind sizing (function + | Automatic -> None + | Form -> Some "form" + | Fitted -> Some "fitted")) + ?detents: + (Option.map + (fun values -> + String.concat + "," + (List.map + (function + | Medium -> "medium" + | Large -> "large") + values)) + detents) ?on_dismiss: (if interactive_dismiss then diff --git a/docs/agent-guide/implemented/bugfix/2026-10-09-favorites-native-soft-header.md b/docs/agent-guide/implemented/bugfix/2026-10-09-favorites-native-soft-header.md new file mode 100644 index 0000000..4a2c138 --- /dev/null +++ b/docs/agent-guide/implemented/bugfix/2026-10-09-favorites-native-soft-header.md @@ -0,0 +1,43 @@ +# Let Favorites content show through its soft header + +## Problem and ownership + +Favorites and Timeline both requested a soft top scroll edge, but Favorites +placed its title and account controls in a plain safe-area inset with a full-width +bar-material background. Actual iOS 26.1 scrolling showed a distinct background +band covering the Favorites rows, while Timeline showed softened content behind +its pinned date and account controls. + +Application's public route reducer owns selection and Favorites data, not Apple's +scroll-edge rendering. Reducer events and completions cannot reproduce this +visual defect. The regression belongs at the native UI boundary, using normal +XCTest swipes, short drags, screenshots, and menu taps against an offline synthetic +service running the current production Application, renderer and Swift views. + +## Decision + +Use the native safeAreaBar for the Favorites title and controls, and remove the +extra bar-material background. Keep the explicit soft top effect, transparent +retained navigation host, title accessibility, native controls, and minimum header +height. Timeline's absent title leaves the new bar empty. No per-row geometry, +custom scrolling, hard mask, opaque cover, new dependency or deployment change is +needed. + +Removing only the material, or using an ordinary overlay, lets sharp row text +overlap the title. Both candidates were rejected by actual screenshot inspection. +The native bar registers its content with the system scroll-edge treatment. + +## Validation and workflow + +The external UI regression compares changing pixels beneath the top chrome with +the actual Timeline reference, checks that content remains visible and softened, +and verifies fixed readable title geometry and the Account menu after real +scrolling. It also covers AXXXL populated and empty Favorites. Existing public +Application navigation/header and renderer semantics regressions cover their +unchanged state and event owners. Production Swift compilation includes App.swift. +Screenshots, synthetic data, runner, measurements and result bundles remain in the +task's external evidence directory. + +spec-dev-tool is unavailable in this execution environment. No Dune or spec files +are changed. Physical-device installation, real graph/account operations and merge +are outside this task. diff --git a/swift/App.swift b/swift/App.swift index bbd063b..64ab593 100644 --- a/swift/App.swift +++ b/swift/App.swift @@ -84,7 +84,8 @@ private struct JournalHost: View { } .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Close", role: .cancel) { signInPresented = false } + JournalSheetDismissButton { signInPresented = false } + .accessibilityIdentifier("authentication-close") } } } diff --git a/swift/JournalAssetSettings.swift b/swift/JournalAssetSettings.swift index ef30687..a278a62 100644 --- a/swift/JournalAssetSettings.swift +++ b/swift/JournalAssetSettings.swift @@ -32,10 +32,11 @@ import SwiftUI if deliveredDays != days && emit("days:\(days)") { deliveredDays = days } } var body: some SwiftUI.View { - context.content - // The root column's proposal is the full viewport; expand to fill it - // and anchor the page at the top so bar rows don't drift to center. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + ZStack { + context.content + // Settings is mounted without children. EmptyView cannot own a sheet. + Color.clear.frame(width: 0, height: 0) + } .task { deliver() } .onChange(of: days) { _, value in if preferences.save(recentDays: value) { deliver() } @@ -85,8 +86,9 @@ import SwiftUI .formStyle(.grouped) .navigationTitle("Attachment settings") .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { emit("dismissed") } + ToolbarItem(placement: .cancellationAction) { + JournalSheetDismissButton(label: "Done") { emit("dismissed") } + .accessibilityIdentifier("journal-settings-close") } } } diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 912924b..ecfe711 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -1,11 +1,29 @@ import LUIAppleBackend import SwiftUI +/// The form sheet's native card transform still leaves at least a 44pt target. +struct JournalSheetDismissButton: SwiftUI.View { + var label: LocalizedStringKey = "Close" + let action: () -> Void + + var body: some SwiftUI.View { + Button(action: action) { + Image(systemName: "xmark") + .frame(width: 48, height: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .fixedSize() + .accessibilityLabel(Text(label)) + } +} + /// Native chrome layout only. List sections own all date scrolling and pinning. @MainActor enum JournalChrome { private enum Mode: String, Decodable { case feedback, page, header, detail, unlock case bottomControls = "bottom-controls" + case toolbarControl = "toolbar-control" } private struct Properties: Decodable { @@ -51,20 +69,21 @@ import SwiftUI controls.padding(.trailing, 16) } } - .safeAreaInset(edge: .top, spacing: 0) { + .safeAreaBar(edge: .top, spacing: 0) { if let title = properties.title { - ZStack { + HStack(alignment: .center, spacing: 12) { Text(title) .font(.headline) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) .accessibilityAddTraits(.isHeader) .accessibilityIdentifier("favorites-header-title") - HStack { - Spacer() - controls - } + controls } .frame(minHeight: 44) .padding(.horizontal, 16) + // The native bar participates in the soft scroll edge, keeping + // content visible beneath the title without another material. } } } @@ -161,6 +180,13 @@ import SwiftUI } } } + case .toolbarControl: + if context.childIDs.count == 1 { + child(0).fixedSize() + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(properties?.title ?? "Close")) + .accessibilityAddTraits(.isButton) + } case .bottomControls: if context.childIDs.count == 2 { GeometryReader { bounds in diff --git a/swift/JournalImagePreview.swift b/swift/JournalImagePreview.swift index 1bbf067..cdfa848 100644 --- a/swift/JournalImagePreview.swift +++ b/swift/JournalImagePreview.swift @@ -12,6 +12,8 @@ import UIKit struct Properties: Decodable { let paths: [String] let selected_index: Int + let document: Bool? + let title: String? } struct View: SwiftUI.View { @@ -38,10 +40,15 @@ import UIKit Color.clear .frame(width: 0, height: 0) .fullScreenCover(isPresented: $presented, onDismiss: dismissed) { - Pager(urls: urls, selectedIndex: properties.selected_index) { - presented = false + if properties.document == true, let url = urls.first { + Document(url: url, title: properties.title ?? "Attachment") { + presented = false + }.ignoresSafeArea() + } else { + Pager(urls: urls, selectedIndex: properties.selected_index) { + presented = false + }.ignoresSafeArea() } - .ignoresSafeArea() } .onAppear { guard !opened, urls.indices.contains(properties.selected_index) else { return } @@ -67,6 +74,51 @@ import UIKit } #if os(iOS) + private static func closeButton(identifier: String, onClose: @escaping () -> Void) -> UIBarButtonItem { + let button = UIButton(type: .system) + button.setImage(UIImage(systemName: "xmark"), for: .normal) + button.accessibilityLabel = Locale.current.language.languageCode?.identifier == "zh" ? "关闭" : "Close" + button.accessibilityIdentifier = identifier + button.widthAnchor.constraint(equalToConstant: 44).isActive = true + button.heightAnchor.constraint(equalToConstant: 44).isActive = true + button.addAction(UIAction { _ in onClose() }, for: .touchUpInside) + let item = UIBarButtonItem(customView: button) + // The shared glass capsule otherwise clips a custom button to 36pt tall. + item.hidesSharedBackground = true + return item + } + + // Quick Look accepts a display title independently of the immutable cache URL. + struct Document: UIViewControllerRepresentable { + let url: URL + let title: String + let onClose: () -> Void + + final class Item: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + init(url: URL, title: String) { + previewItemURL = url + previewItemTitle = title + } + } + final class Coordinator: NSObject, QLPreviewControllerDataSource { + let item: Item + init(url: URL, title: String) { item = Item(url: url, title: title) } + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item } + } + func makeCoordinator() -> Coordinator { Coordinator(url: url, title: title) } + func makeUIViewController(context: Context) -> UINavigationController { + let preview = QLPreviewController() + preview.dataSource = context.coordinator + preview.navigationItem.leftBarButtonItem = JournalImagePreview.closeButton( + identifier: "journal-document-close", onClose: onClose) + return UINavigationController(rootViewController: preview) + } + func updateUIViewController(_ navigation: UINavigationController, context: Context) {} + } + struct Pager: UIViewControllerRepresentable { let urls: [URL] let selectedIndex: Int @@ -227,8 +279,8 @@ import UIKit override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground - navigationItem.leftBarButtonItem = UIBarButtonItem( - systemItem: .done, primaryAction: UIAction { [weak self] _ in self?.onClose?() }) + navigationItem.leftBarButtonItem = JournalImagePreview.closeButton( + identifier: "journal-image-close", onClose: { [weak self] in self?.onClose?() }) saveButton.accessibilityIdentifier = "journal-save-current-image" navigationItem.rightBarButtonItems = [saveButton, shareButton] save.onBusyChanged = { [weak self] busy in diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 6e10a63..419cda8 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -411,7 +411,7 @@ import Observation } } label: { content - }) + }.listRowSeparator(separatorVisibility(row.separator))) } return AnyView(content) } diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 77bb8a1..4682e72 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -94,7 +94,7 @@ let test_timeline_media_targets () = ignore (Journal_row.view ~show_timestamp:false - ~render_media:(fun ~root ~image_children:_ child -> + ~render_media:(fun ~title:_ ~root ~image_children:_ child -> targets := root :: !targets; child) { Journal_graph_projection.block = block () @@ -724,7 +724,7 @@ let test_long_body_can_expand () = let view = Journal_row.view ~show_timestamp:false - ~render_media:(fun ~root:_ ~image_children:_ child -> child) + ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) { Journal_graph_projection.block = block ~source ~task_state:No_status ~child_count:0 () ; child_summaries = [] @@ -994,7 +994,7 @@ let test_status_and_tags_mount () = let view = Journal_row.view ~show_timestamp:false - ~render_media:(fun ~root:_ ~image_children:_ child -> child) + ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) { Journal_graph_projection.block; child_summaries = [] } in with_mounted view (fun _ ops -> @@ -1024,7 +1024,7 @@ let test_reference_body_and_summary () = Journal_row.view ~render_source ~show_timestamp:false - ~render_media:(fun ~root:_ ~image_children:_ child -> child) + ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) entry in with_mounted view (fun _ ops -> @@ -1102,9 +1102,10 @@ let projected_row ?store ~views ~on_event entry = in Journal_row.view ~show_timestamp:false - ~render_media:(fun ~root ~image_children child -> + ~render_media:(fun ~title ~root ~image_children child -> Journal_media_view.row ~store + ~title ~scope:"direct-child-regression" ~root ~image_children @@ -1772,9 +1773,7 @@ let test_reactive_nonimage_structure_and_presentation () = "PDF card lost actual descriptor metadata"; require_nodes_retained [ body; sibling ] (ops ()); update - (Some - (media_state - [ { pdf with presentation = Failed "Unable to open file" } ])); + (Some (media_state [ { pdf with presentation = Failed "Unable to open file" } ])); require (current_text (ops ()) "Unable to open file" && current_text (ops ()) "Retry") "nonimage failure lost its current message or retry action"; @@ -2064,15 +2063,13 @@ let test_single_image_save_preview_and_document_compatibility () = && Yojson.Safe.Util.member "selected_index" payload = `Int 0) "single image preview payload changed") else ( + let _, payload = + match gallery_preview (ops ()) with + | Some preview -> preview + | None -> fail "document did not keep native file preview" + in require - (List.exists - (function - | Lui_protocol.CreateNode (_, FilePreview) -> true - | _ -> false) - (ops ())) - "document did not keep native file preview"; - require - (gallery_preview (ops ()) = None) + (Yojson.Safe.Util.member "document" payload = `Bool true) "document unexpectedly exposed image Save"))) [ "png", "/tmp/single-image.png"; "pdf", "/tmp/single-document.pdf" ] ;; @@ -2110,8 +2107,213 @@ let test_gallery_disposal_releases_preview_references_once () = require (releases () = 2) "disposed preview subscriptions released twice") ;; +(* Presentation defects cannot be reproduced by reducer transitions: the reducer + already requests the sheet/preview correctly. Exercise the public renderer + and native event contract without duplicating Worker or storage coverage. *) +let test_sheet_has_native_navigation_and_keeps_cancel_delivery () = + let check label = + let closed = ref 0 in + let close = + V.button + ~role:Cancel + ~on_press:(Ui.Event.Handler.create (fun _ -> incr closed)) + ~child:(V.label ~title:(V.text label) ~icon:(V.symbol ~name:"xmark" ()) ()) + () + in + let content = + V.text "Sheet content" + |> V.Body.static + |> V.Body.toolbar + ~items: + [ V.Toolbar.item + ~key:(Ui.Key.string "close") + ~placement:Cancellation_action + close + ] + in + let dismissed = ref false in + let view = + V.Sheet.create + ~title:"Diagnostics" + ~presented:true + ~on_presented_changed: + (Ui.Event.Handler.create (function + | Ui.Event.Payload.Bool false -> dismissed := true + | _ -> ())) + ~sizing:Form + ~detents:[ Large ] + ~content + (V.empty ()) + in + with_mounted view (fun app ops -> + let sheet = + List.find_map + (function + | Lui_protocol.CreateNode (node, Sheet) -> Some node + | _ -> None) + (ops ()) + |> Option.get + in + require + (List.exists + (function + | Lui_protocol.SetProp (node, StyleClass, StringValue style) + when node = sheet -> + List.mem "navigation-content" (String.split_on_char ' ' style) + | _ -> false) + (ops ())) + "sheet has no native navigation host for its Close toolbar"; + let close = + List.find_map + (function + | Lui_protocol.CreateNode (node, Button) -> Some node + | _ -> None) + (ops ()) + |> Option.get + in + let property key = + List.find_map + (function + | Lui_protocol.SetProp (node, actual, value) when node = close && actual = key + -> Some value + | _ -> None) + (List.rev (ops ())) + in + require + (property InlineIconName = Some (StringValue "app:xmark")) + "missing native close glyph"; + require + (property AccessibilityLabel = Some (StringValue label)) + "close lost its accessible name"; + require + (property TextValue <> Some (StringValue label)) + "cancellation still displays text"; + require + (property WidthValue = Some (IntValue 44) + && property HeightValue = Some (IntValue 44)) + "cancellation target is smaller than 44pt"; + ignore (Lui_app.dispatch_event app (Press close)); + ignore (Lui_app.flush app); + require (!closed = 1) "Close action no longer reaches its owner"; + ignore (Lui_app.dispatch_event app (Dismiss sheet)); + ignore (Lui_app.flush app); + require !dismissed "interactive dismissal no longer reaches its owner") + in + List.iter check [ "Close"; "Cancel" ] +;; + +let test_labeled_content_keeps_interactive_value () = + let pressed = ref 0 in + let value = + V.button + ~on_press:(Ui.Event.Handler.create (fun _ -> incr pressed)) + ~child:(V.text "Retry") + () + in + with_mounted + (V.labeled_content ~label:(V.text "Connection") ~value ()) + (fun app ops -> + let button = + List.find_map + (function + | Lui_protocol.CreateNode (node, Button) -> Some node + | _ -> None) + (ops ()) + in + require (Option.is_some button) "interactive labeled value became static text"; + ignore (Lui_app.dispatch_event app (Press (Option.get button))); + ignore (Lui_app.flush app); + require (!pressed = 1) "labeled value lost its action") +;; + +let test_timeline_document_title_follows_its_asset_child () = + let entry, views, _, _ = child_gallery_fixture () in + with_mounted (projected_row ~views ~on_event:ignore entry) (fun app ops -> + let card = mounted_node (ops ()) TextValue (StringValue "PDF attachment") in + ignore (Lui_app.dispatch_event app (Press card)); + ignore (Lui_app.flush app); + let _, payload = + match gallery_preview (ops ()) with + | Some preview -> preview + | None -> fail "document child preview missing" + in + require + (Yojson.Safe.Util.member "title" payload = `String "plan.pdf") + "document child used its parent's prose as the friendly title") +;; + +let test_document_preview_preserves_friendly_title_path_and_lease () = + List.iter + (fun file_type -> + let path = "/tmp/immutable-checksum." ^ file_type in + let title = "旅行计划与会议记录." ^ file_type in + let item = media_item 99 file_type (File path) None in + let events = ref [] in + let store = seeded_media_store [ block_id, media_state [ item ] ] in + with_mounted + (Journal_media_view.view + ~store + ~title + ~scope:"friendly-document-title" + ~root:block_id + ~on_event:(fun event -> events := event :: !events) + (V.text title)) + (fun app ops -> + let node = + mounted_node + (ops ()) + TextValue + (StringValue (String.uppercase_ascii file_type ^ " attachment")) + in + ignore (Lui_app.dispatch_event app (Press node)); + ignore (Lui_app.flush app); + let preview, payload = + match gallery_preview (ops ()) with + | Some preview -> preview + | None -> fail "document preview dropped its friendly graph title" + in + let member = Yojson.Safe.Util.member in + require + (member "document" payload = `Bool true + && member "title" payload = `String title) + "document preview lost its title or exposed image actions"; + require + (member "paths" payload = `List [ `String path ]) + "friendly title must not rename the immutable cached file"; + let values = + Lui_protocol.String_map.empty + |> Lui_protocol.String_map.add "id" (Lui_protocol.IntValue 1) + |> Lui_protocol.String_map.add + "payload" + (Lui_protocol.StringValue {|{"type":"dismiss"}|}) + in + ignore + (Lui_app.dispatch_event + app + (ExtensionEvent (preview, "journal-image-preview", "event", values))); + ignore (Lui_app.flush app); + let count visible = + List.length + (List.filter + (fun (event : Journal_media_view.event) -> + event.action = Preview && event.visible = visible) + !events) + in + require + (count true = 1 && count false = 1) + "document dismissal must release exactly its own retained file lease")) + [ "pdf"; "txt" ] +;; + let tests = - [ ( "single image save preview and document compatibility" + [ ( "timeline asset child preview title" + , test_timeline_document_title_follows_its_asset_child ) + ; ( "native sheet navigation and cancellation" + , test_sheet_has_native_navigation_and_keeps_cancel_delivery ) + ; "interactive labeled value", test_labeled_content_keeps_interactive_value + ; ( "document friendly title and lease" + , test_document_preview_preserves_friendly_title_path_and_lease ) + ; ( "single image save preview and document compatibility" , test_single_image_save_preview_and_document_compatibility ) ; ( "gallery disposal releases once" , test_gallery_disposal_releases_preview_references_once ) diff --git a/test/logseq_db_worker_application_integration_test.ml b/test/logseq_db_worker_application_integration_test.ml index 2f0d998..6898259 100644 --- a/test/logseq_db_worker_application_integration_test.ml +++ b/test/logseq_db_worker_application_integration_test.ml @@ -840,7 +840,7 @@ let test_named_tags_reach_timeline_row () = let view = Journal_row.view ~show_timestamp:false - ~render_media:(fun ~root:_ ~image_children:_ child -> child) + ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) entry in let app =