From ceb45bdf26620e9f8552cfd4917c8715d0a65c7d Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sat, 10 Oct 2026 23:47:14 +0800 Subject: [PATCH 01/11] Refine Detail typography and add scoped subtree Copy --- app/application.ml | 114 ++++++++++------ app/journal_graph_runtime.ml | 21 ++- app/journal_header.ml | 37 +++++- app/journal_header.mli | 6 + app/journal_view.ml | 2 + app/journal_view.mli | 1 + swift/JournalChrome.swift | 73 ++++++++++- swift/JournalList.swift | 28 +++- test/application_view_test.ml | 138 +++++++++++++++++++- test/journal_adaptive_test.ml | 4 +- test/journal_graph_runtime_locality_test.ml | 28 +++- test/journal_semantics_test.ml | 85 +++++++++++- 12 files changed, 467 insertions(+), 70 deletions(-) diff --git a/app/application.ml b/app/application.ml index dac12e9..a706988 100644 --- a/app/application.ml +++ b/app/application.ml @@ -245,6 +245,17 @@ type composer_owner = | Timeline_composer | Detail_composer of string * int64 +type detail_copy_phase = + | Copying + | Copied + | Copy_failed_detail of string + +type detail_copy_feedback = + { copy_scope : string + ; copy_owner : int * int64 + ; copy_phase : detail_copy_phase + } + type state = { favorites : Journal_routes.Favorites.t ; favorites_media_roots : string Rrbvec.t @@ -276,6 +287,7 @@ type state = ; capture_error : capture_failure option ; capture_error_owner : composer_owner option ; timeline_notice : timeline_notice option + ; detail_copy_feedback : detail_copy_feedback option ; write_enabled : bool ; graph_ready : bool ; feed_loaded : bool @@ -366,6 +378,7 @@ let initial_state = ; capture_error = None ; capture_error_owner = None ; timeline_notice = None + ; detail_copy_feedback = None ; write_enabled = false ; graph_ready = false ; feed_loaded = false @@ -2807,14 +2820,8 @@ module Detail_list = struct ~on_visible_range ~children = - let depth = function - | Journal_detail.Block row -> row.depth - | More row -> row.depth - in - let rec siblings level items = - match items with - | (row, label) :: rest when depth row = level -> - let nested, rest = siblings (level + 1) rest in + let rows = + List.map2 (fun row label -> let row_key = Ui.Key.string (Journal_detail.row_key row) in let item = match row with @@ -2827,7 +2834,7 @@ module Detail_list = struct let context_menu = V.Context_menu.create ~actions: - [ V.Context_menu.action + ([ V.Context_menu.action ~key:(Ui.Key.string "open") ~title:"Open block" ~symbol:"arrow.up.right.square" @@ -2849,31 +2856,21 @@ module Detail_list = struct ~on_press:(bind_action on_action (Copy_block id)) () ] + |> fun actions -> + if leaf then actions else + V.Context_menu.action + ~key:(Ui.Key.string (if expanded then "collapse" else "expand")) + ~title:(if expanded then "Hide children" else "Show children") + ~on_press:(bind_action on_action + (if expanded then Detail_collapse id else Detail_expand id)) + () :: actions) () in - if leaf - then V.Native_list.row ~key:row_key ~separator:Hidden ~context_menu label - else - V.Native_list.disclosure_row - ~key:row_key - ~separator:Hidden - ~context_menu - ~test_id:(Ui.Test_id.string ("detail-disclosure:" ^ id)) - ~expanded - ~on_expanded_changed: - (Ui.Event.Handler.create (function - | Ui.Event.Payload.Bool expanded -> - on_action.send - (if expanded then Detail_expand id else Detail_collapse id) - | _ -> ())) - ~label - nested + V.Native_list.row ~key:row_key ~separator:Hidden ~context_menu label in - let siblings, rest = siblings level rest in - item :: siblings, rest - | _ -> [], items + item) + (Journal_detail.rows detail) children in - let rows, _ = siblings 0 (List.combine (Journal_detail.rows detail) children) in let scroll_request = Option.map (fun id -> @@ -2883,10 +2880,7 @@ module Detail_list = struct (V.Native_list.target ~section:(Ui.Key.string "outline") ~row_path: - [ Ui.Key.string - ("block:" ^ Journal_model.id (Journal_detail.root detail)) - ; Ui.Key.string ("block:" ^ id) - ]) + [ Ui.Key.string ("block:" ^ id) ]) ~anchor:Bottom ~animated:false ()) @@ -2894,7 +2888,7 @@ module Detail_list = struct in V.Native_list.vertical ~key - ~style:Plain + ~style:Detail ?scroll_request ~on_scroll_completed ~on_visible_range @@ -2958,7 +2952,7 @@ let detail_page then "Retry loading children" else "Load more") ]) - | Block { block; _ } -> + | Block { block; depth; _ } -> let source = if Journal_model.task_state block = No_status then render_source state (Journal_model.source block) @@ -2967,8 +2961,16 @@ let detail_page ^ " " ^ render_source state (Journal_model.source block) in - V.text ~key:(Ui.Key.string ("detail-label:" ^ Journal_model.id block)) source - |> V.with_test_id (Ui.Test_id.string ("detail-block:" ^ Journal_model.id block)) + let id = "detail-block:" ^ Journal_model.id block in + Journal_header.detail_text ~id ~depth source + |> fun text -> + let tags = Journal_model.tag_titles block in + V.column ~spacing:8. (text :: + (if tags = [] then [] else + [ Journal_header.detail_text + ~id:("detail-tags:" ^ Journal_model.id block) ~depth:2 + (String.concat " " (List.map (fun title -> "#" ^ title) tags)) ])) + |> V.with_test_id (Ui.Test_id.string id) |> media_label ~store:media_store ~on_region @@ -2976,7 +2978,8 @@ let detail_page state dispatch ~title:(Journal_model.source block) - ~root:(Journal_model.id block)) + ~root:(Journal_model.id block) + |> Journal_header.detail_row ~depth) (Journal_detail.rows detail) in let content = @@ -3026,6 +3029,15 @@ let detail_page |> Journal_header.detail ~capture_enabled:actions_enabled ~on_capture:(on_action Open_append) + ~copy_enabled:(state.graph_ready && Option.is_some detail) + ~on_copy:(on_action (Copy_block + (Option.fold ~none:"" ~some:(fun d -> Journal_model.id (Journal_detail.root d)) detail))) + ~copy_feedback:(Option.bind state.detail_copy_feedback (fun feedback -> + if feedback.copy_scope <> scope then None else Some + (match feedback.copy_phase with + | Copying -> "Copying block and descendants…" + | Copied -> "Copied block and descendants" + | Copy_failed_detail message -> "Copy failed: " ^ message))) |> Journal_asset_import.view ~key:(Ui.Key.string (scope ^ "import")) ~enabled:actions_enabled @@ -3638,6 +3650,7 @@ let start let state, _ = Journal_graph_runtime.Copy.step !copy_state Cancel in copy_state := state; copy_owner := None; + set_state (fun state -> { state with detail_copy_feedback = None }) (); Hashtbl.iter (fun request_id (_, _, protocol_id) -> !projection_request_finished protocol_id; @@ -3654,7 +3667,11 @@ let start let copy_error owner message = set_state (fun state -> if copy_current owner - then { state with timeline_notice = Some (Copy_failed message) } + then (match state.detail_copy_feedback with + | Some feedback when feedback.copy_owner = owner -> + { state with detail_copy_feedback = Some + { feedback with copy_phase = Copy_failed_detail message } } + | _ -> { state with timeline_notice = Some (Copy_failed message) }) else state) in let rec copy_transition owner event = @@ -3682,7 +3699,12 @@ let start then Effect.ignore else ( match Result.bind result Journal_platform.decode_copy_text_response with - | Ok () -> Effect.ignore + | Ok () -> set_state (fun state -> + match state.detail_copy_feedback with + | Some feedback when feedback.copy_owner = owner -> + { state with detail_copy_feedback = Some + { feedback with copy_phase = Copied } } + | _ -> state) | Error message -> copy_error owner message))) | Read (ticket, command) -> Effect.bind @@ -3721,7 +3743,14 @@ let start copy_sequence := Int64.succ !copy_sequence; let owner = !state_ref.graph_state.generation, !copy_sequence in copy_owner := Some owner; - copy_transition owner (Start root) + Effect.many + [ set_state (fun state -> + { state with detail_copy_feedback = + Option.map (fun _ -> + { copy_scope = Detail_outline.scope state.routes; copy_owner = owner + ; copy_phase = Copying }) + (Journal_routes.detail state.routes) }) + ; copy_transition owner (Start root) ] in let graph_runtime = Journal_graph_runtime.create @@ -6208,6 +6237,7 @@ let start && left.asset_import_request = right.asset_import_request && left.asset_import_owner = right.asset_import_owner && left.graph_ready = right.graph_ready + && left.detail_copy_feedback = right.detail_copy_feedback && operation_failure left.timeline_notice = operation_failure right.timeline_notice in let equal_modal_presentation left right = diff --git a/app/journal_graph_runtime.ml b/app/journal_graph_runtime.ml index 8253009..9d1f45b 100644 --- a/app/journal_graph_runtime.ml +++ b/app/journal_graph_runtime.ml @@ -2147,6 +2147,21 @@ let detail_children t ~generation ~page ~root ~limit ~after = ] ;; +let detail_projection t ~page ~time_context ~root children = + Result.map + (fun (detail : Projection.detail) -> + let tagged block = + Journal_model.with_tag_titles block + ~tag_titles:(Option.value + (Hashtbl.find_opt t.tag_titles (Journal_model.id block)) ~default:[]) + in + (* This is the already returned, bounded child page; metadata needs no reads. *) + { Projection.root = tagged detail.root + ; children = { detail.children with blocks = List.map tagged detail.children.blocks } + }) + (Projection.detail_on_page ~page ~time_context ~root children) +;; + let read_child t command page = requests [ read @@ -2709,7 +2724,7 @@ let receive_response t (protocol_response : Protocol.response) = | Error _ -> () | Ok time_context -> (match - Projection.detail_on_page + detail_projection t ~page ~time_context ~root:interest.root @@ -3224,7 +3239,7 @@ let receive_response t (protocol_response : Protocol.response) = }) ] | Ok time_context -> - (match Projection.detail_on_page ~page ~time_context ~root children with + (match detail_projection t ~page ~time_context ~root children with | Ok detail -> Hashtbl.replace t.projected_blocks @@ -3330,7 +3345,7 @@ let receive_response t (protocol_response : Protocol.response) = (match projection_time_context t with | Error message -> reject message | Ok time_context -> - (match Projection.detail_on_page ~page ~time_context ~root children with + (match detail_projection t ~page ~time_context ~root children with | Ok detail -> responses [ response (Children_reconciled detail) ] | Error message -> reject message)) | Capture_children { command; page; conflict_retries } -> diff --git a/app/journal_header.ml b/app/journal_header.ml index 2118ae3..5a5a2b8 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -79,22 +79,53 @@ let bottom_controls ~key ~controls body = |> V.Body.static ;; -let detail ~capture_enabled ~on_capture body = +let detail_text ~id ~depth text = + Ui.Native_widget.widget + chrome + ~key:(Ui.Key.string ("detail-label:" ^ id)) + ~props:(`Assoc [ "mode", `String "detail-text"; "id", `String id + ; "level", `Int (min 2 depth); "text", `String text ]) + ~on_event:(fun _ -> ()) + ~children:[] + () +;; + +let detail_row ~depth body = + Ui.Native_widget.widget chrome + ~props:(`Assoc [ "mode", `String "detail-row"; "level", `Int (min 2 depth) ]) + ~on_event:(fun _ -> ()) ~children:[ body ] () +;; + +let detail ~capture_enabled ~on_capture ~copy_enabled ~on_copy ~copy_feedback body = let capture = capture_control ~enabled:(fun () -> capture_enabled) ~on_capture |> test_id "journal-detail-capture-open" in + let options = + V.buttons ~actions: + [ V.buttons_menu_action ~label:"Block options" ~icon:"ellipsis" + ~on_select:(Ui.Event.Handler.create (function + | Ui.Event.Payload.Int64 1L when copy_enabled -> + Ui.Event.Handler.Private.invoke on_copy Ui.Event.Payload.Unit + | _ -> ())) + [ V.Menu.action ~id:1L ~title:"Copy" ~icon:"doc.on.doc" ~enabled:copy_enabled () ] ] () + in Ui.Native_widget.widget chrome ~key:(Ui.Key.string "journal-detail-header") ~props:(`Assoc [ "mode", `String "detail"; "title", `String "Block" ]) ~on_event:(fun _ -> ()) - ~children:[ V.Body.Private.to_widget body ] + ~children:[ V.Body.Private.to_widget body; options ] () |> V.Body.static |> bottom_controls ~key:"journal-detail-bottom-controls" - ~controls:(V.row ~spacing:16. [ V.spacer (); capture ]) + ~controls:(V.row ~spacing:16. + [ (match copy_feedback with + | None -> V.spacer () + | Some message -> + V.text message |> V.semantics ~properties:(Ui.Semantics.create ~live_region:true ()) ) + ; V.spacer (); capture ]) ;; type account_action = diff --git a/app/journal_header.mli b/app/journal_header.mli index 7a32d86..4ac5ef2 100644 --- a/app/journal_header.mli +++ b/app/journal_header.mli @@ -67,8 +67,14 @@ val feedback val date_header : title:string -> Journal_view.View.t +val detail_text : id:string -> depth:int -> string -> Journal_view.View.t +val detail_row : depth:int -> Journal_view.View.t -> Journal_view.View.t + val detail : capture_enabled:bool -> on_capture:Journal_view.Event.Handler.t + -> copy_enabled:bool + -> on_copy:Journal_view.Event.Handler.t + -> copy_feedback:string option -> Journal_view.View.Body.t -> Journal_view.View.Body.t diff --git a/app/journal_view.ml b/app/journal_view.ml index 3358eac..a0d2820 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -1496,11 +1496,13 @@ module View = struct | Plain | Inset | Inset_grouped + | Detail let style_name = function | Plain -> "plain" | Inset -> "inset" | Inset_grouped -> "inset_grouped" + | Detail -> "detail" ;; type separator = diff --git a/app/journal_view.mli b/app/journal_view.mli index 9a78f54..859eb81 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -658,6 +658,7 @@ module View : sig | Plain | Inset | Inset_grouped + | Detail type separator = | Automatic diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index e5f6cf1..7bcea4f 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -1,5 +1,8 @@ import LUIAppleBackend import SwiftUI +#if os(iOS) +import UIKit +#endif /// The form sheet's native card transform still leaves at least a 44pt target. struct JournalSheetDismissButton: SwiftUI.View { @@ -24,6 +27,8 @@ struct JournalSheetDismissButton: SwiftUI.View { case feedback, page, header, detail, unlock case bottomControls = "bottom-controls" case toolbarControl = "toolbar-control" + case detailText = "detail-text" + case detailRow = "detail-row" } private struct Properties: Decodable { @@ -35,6 +40,58 @@ struct JournalSheetDismissButton: SwiftUI.View { let controls: Bool? let pending: Bool? let error: String? + let text: String? + let id: String? + let level: Int? + } + + private struct DetailText: SwiftUI.View { + let text: String + let id: String + @ScaledMetric(relativeTo: .body) private var size: CGFloat = 17 + @ScaledMetric(relativeTo: .body) private var targetLineHeight: CGFloat = 27 + + init(text: String, id: String, level: Int) { + self.text = text + self.id = id + let tier = min(2, max(0, level)) + _size = ScaledMetric(wrappedValue: [26, 21, 17][tier], relativeTo: .body) + _targetLineHeight = ScaledMetric(wrappedValue: [40, 33, 27][tier], relativeTo: .body) + } + + private var lineSpacing: CGFloat { + #if os(iOS) + max(0, targetLineHeight - UIFont.systemFont(ofSize: size).lineHeight) + #else + max(0, targetLineHeight - size * 1.2) + #endif + } + + var body: some SwiftUI.View { + Text(text) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(.primary) + .lineSpacing(lineSpacing) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier(id) + } + } + + private struct DetailRow: SwiftUI.View { + let content: AnyView + @ScaledMetric(relativeTo: .body) private var paragraphGap: CGFloat = 12 + + init(content: AnyView, level: Int) { + self.content = content + _paragraphGap = ScaledMetric(wrappedValue: [24, 18, 12][min(2, max(0, level))], relativeTo: .body) + } + + var body: some SwiftUI.View { + content.frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, paragraphGap) + } } fileprivate struct ControlsSizeKey: EnvironmentKey { @@ -166,15 +223,29 @@ struct JournalSheetDismissButton: SwiftUI.View { SectionDate(title: title) } case .detail: - if let title = properties?.title, context.childIDs.count == 1 { + if let title = properties?.title, context.childIDs.count == 2 { GeometryReader { bounds in child(0).frame(width: bounds.size.width, height: bounds.size.height) } #if os(iOS) .toolbar(.visible, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { child(1) } + } + #else + .toolbar { ToolbarItem { child(1) } } #endif .navigationTitle(title) } + case .detailText: + if let properties, let text = properties.text, let id = properties.id, + let level = properties.level, context.childIDs.isEmpty { + DetailText(text: text, id: id, level: level) + } + case .detailRow: + if let level = properties?.level, context.childIDs.count == 1 { + DetailRow(content: child(0), level: level) + } case .toolbarControl: if context.childIDs.count == 1 { child(0).fixedSize() diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 419cda8..e6c0958 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -368,8 +368,27 @@ import Observation } @ViewBuilder private func rowActions(_ row: Row) -> some SwiftUI.View { - childContent(row.content_index) + let branchAction = properties?.style == "detail" + ? row.context_menu?.actions.first(where: { $0.key == "expand" || $0.key == "collapse" }) + : nil + Group { + if let branchAction { + childContent(row.content_index) + .contentShape(Rectangle()) + .onTapGesture { + if actionInteractive(branchAction) { rowEvent(action: branchAction.key, row: row.key) } + } + .accessibilityHint(branchAction.title) + .accessibilityAction(named: Text(branchAction.title)) { + if actionInteractive(branchAction) { rowEvent(action: branchAction.key, row: row.key) } + } + } else { + childContent(row.content_index) + } + } .id(row.key) + .listRowInsets(properties?.style == "detail" + ? EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20) : nil) .listRowSeparator(separatorVisibility(row.separator)) .accessibilityIdentifier(row.test_id ?? row.key) .contextMenu { @@ -418,6 +437,7 @@ import Observation private var style: ListStyleConfiguration { switch properties?.style { + case "detail": return .detail case "inset": return .inset case "inset_grouped": return .insetGrouped default: return .plain @@ -472,13 +492,16 @@ import Observation } private enum ListStyleConfiguration { - case plain, inset, insetGrouped + case plain, inset, insetGrouped, detail } private struct ListStyleModifier: ViewModifier { let style: ListStyleConfiguration func body(content: Content) -> some SwiftUI.View { Group { + if style == .detail { + content.listStyle(.plain).environment(\.defaultMinListRowHeight, 0) + } else { #if os(macOS) if style == .plain { content.listStyle(.plain) @@ -494,6 +517,7 @@ import Observation content.listStyle(.insetGrouped) } #endif + } } } } diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 5ceee13..e508b2c 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1143,11 +1143,22 @@ let run_favorites_native_visibility | _ -> ()) in let find key value = - Hashtbl.fold + let direct = Hashtbl.fold (fun id values found -> if List.assoc_opt key values = Some (`String value) then Some id else found) props None + in + match direct with + | Some _ -> direct + | None when key = "text" -> Hashtbl.fold (fun id values found -> + match List.assoc_opt "payload" values with + | Some (`String payload) -> + let p = Yojson.Safe.from_string payload in + if Yojson.Safe.Util.member "mode" p = `String "detail-text" + && Yojson.Safe.Util.member "text" p = `String value then Some id else found + | _ -> found) props None + | None -> None in let rec ancestor_property id key = if @@ -1615,6 +1626,11 @@ let run_favorites_native_visibility Hashtbl.fold (fun _ values count -> if List.assoc_opt "text" values = Some (`String "Updated covered row") + || (match List.assoc_opt "payload" values with + | Some (`String payload) -> let p = Yojson.Safe.from_string payload in + Yojson.Safe.Util.member "mode" p = `String "detail-text" + && Yojson.Safe.Util.member "text" p = `String "Updated covered row" + | _ -> false) then count + 1 else count) props @@ -1717,13 +1733,14 @@ let run_favorites_native_visibility dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Journals"))); let root_list = Option.get (find "_extension" "journal-list") in - let label = Option.get (find "text" "Timeline fixture 1") in + Atomic.set detail_fixture true; + let label = Option.get (find "text" "Timeline fixture 0") in dispatch (Lui_protocol.Press (ancestor_property label "press-enabled")); wait "copy Detail loaded" (fun () -> Option.is_some (find "accessibility-identifier" - ("detail-block:" ^ G.Uuid.to_string (uuid 2)))); + ("detail-block:" ^ G.Uuid.to_string (uuid 1)))); let detail_list = Hashtbl.fold (fun id values found -> @@ -1736,7 +1753,108 @@ let run_favorites_native_visibility None |> Option.get in - check_copy_text detail_list row "Timeline fixture 1") + check_copy_text detail_list row "Timeline fixture 1"; + (* Copy's pure reducer cannot reproduce the Detail toolbar binding or + host acknowledgement feedback. Exercise their public app boundary. *) + let current_root = ref (uuid 1) in + let rec under owner id = + id = owner || match Hashtbl.find_opt parents id with + | None -> false | Some parent -> under owner parent + in + let rec chrome mode id = + let values = Option.value (Hashtbl.find_opt props id) ~default:[] in + match List.assoc_opt "payload" values with + | Some (`String payload) when Yojson.Safe.Util.member "mode" (Yojson.Safe.from_string payload) = `String mode -> id + | _ -> chrome mode (Hashtbl.find parents id) + in + let detail_body root = Hashtbl.fold (fun id values found -> + match List.assoc_opt "_extension" values, List.assoc_opt "payload" values with + | Some (`String "journal-list"), Some (`String payload) -> + let open Yojson.Safe.Util in + let p = Yojson.Safe.from_string payload in + (match p |> member "sections" |> to_list with + | section :: _ -> (match section |> member "rows" |> to_list with + | row :: _ when member "style" p = `String "detail" + && member "key" row = `String ("block:" ^ G.Uuid.to_string root) -> Some id + | _ -> found) + | _ -> found) + | _ -> found) props None in + let current_body () = + Option.get (detail_body !current_root) + in + let find_current key value = + let owner = chrome "bottom-controls" (current_body ()) in + Hashtbl.fold (fun id values found -> + if under owner id && List.assoc_opt key values = Some (`String value) + then Some id else found) props None + in + let header_copy () = + let owner = chrome "detail" (current_body ()) in + let trigger = Option.get (Hashtbl.fold (fun id values found -> + if under owner id && List.assoc_opt "accessibility-label" values = Some (`String "Block options") + then Some id else found) props None) in + let under id = under trigger id in + let entries = Hashtbl.fold (fun id values acc -> + if under id && List.mem_assoc "text" values then + (id, List.assoc "text" values) :: acc else acc) props [] in + Alcotest.(check int) "Detail menu has only Copy" 1 (List.length entries); + match entries with + | [ (id, `String "Copy") ] -> id + | _ -> Alcotest.fail "Detail menu must contain only Copy" + in + let respond_copy copied = + let json = Yojson.Safe.to_string (`Assoc [ "copied", `Bool copied ]) in + let response = Bytes.make (32 + String.length json) '\000' in + Bytes.blit_string "LJP2" 0 response 0 4; + Bytes.set_uint16_le response 4 2; + Bytes.set_uint16_le response 6 29; + Bytes.set_int32_le response 24 (Int32.of_int (String.length json)); + Bytes.blit_string json 0 response 32 (String.length json); + hooks.platform_response (Bytes.to_string response) + in + let copy_header expected = + clipboard_requests := []; + dispatch (Lui_protocol.Press (header_copy ())); + wait "Detail Copy progress" (fun () -> + Option.is_some (find_current "text" "Copying block and descendants…")); + wait "Detail header reaches shared clipboard chain" (fun () -> !clipboard_requests <> []); + let request = List.hd !clipboard_requests in + let json = Bytes.sub_string request 32 (Bytes.length request - 32) |> Yojson.Safe.from_string in + Alcotest.(check string) "header targets current Detail subtree" expected + Yojson.Safe.Util.(json |> member "text" |> to_string) + in + copy_header "Timeline fixture 0\n - Timeline fixture 1"; + respond_copy true; + wait "Detail Copy success after host acknowledgement" (fun () -> + Option.is_some (find_current "text" "Copied block and descendants")); + copy_header "Timeline fixture 0\n - Timeline fixture 1"; + let retired_copy = header_copy () in + let payload = Yojson.Safe.to_string (`Assoc [ "key", `String "open"; "row", `String row ]) in + let action = Yojson.Safe.to_string (`Assoc [ "type", `String "row_event"; "payload", `String payload ]) in + hooks.extension_event detail_list "event" + (Yojson.Safe.to_string (`Assoc [ "id", `Int 1; "payload", `String action ])) |> consume; + wait "nested Detail has its own root" (fun () -> + Option.is_some (detail_body (uuid 2))); + current_root := uuid 2; + respond_copy false; + for _ = 1 to 5 do hooks.pump () |> consume done; + Alcotest.(check bool) "old failure never becomes a new Detail/global notice" true + (Option.is_none (find "text" "Copy failed")); + Alcotest.(check bool) "old success is scoped to its Detail" true + (Option.is_none (find_current "text" "Copied block and descendants")); + clipboard_requests := []; + dispatch (Lui_protocol.Press retired_copy); + for _ = 1 to 5 do hooks.pump () |> consume done; + Alcotest.(check int) "retired toolbar cannot copy previous root" 0 (List.length !clipboard_requests); + copy_header "Timeline fixture 1"; + respond_copy false; + wait "Detail Copy failure remains visible" (fun () -> + Option.is_some (find_current "text" "Copy failed: Clipboard write was not acknowledged.")); + copy_header "Timeline fixture 1"; + respond_copy true; + wait "retry succeeds without the previous failure" (fun () -> + Option.is_some (find_current "text" "Copied block and descendants") + && Option.is_none (find_current "text" "Copy failed: Clipboard write was not acknowledged."))) else if Option.is_some media_rows then ( let count = Option.get media_rows in @@ -1744,6 +1862,11 @@ let run_favorites_native_visibility Hashtbl.fold (fun id values ids -> if List.assoc_opt key values = Some (`String value) + || (key = "text" && match List.assoc_opt "payload" values with + | Some (`String payload) -> let p = Yojson.Safe.from_string payload in + Yojson.Safe.Util.member "mode" p = `String "detail-text" + && Yojson.Safe.Util.member "text" p = `String value + | _ -> false) then id :: ids else ids) props @@ -2120,9 +2243,10 @@ let run_favorites_native_visibility let expand expanded = detail_event (`Assoc - [ "type", `String "expanded" - ; "key", `String ("block:" ^ G.Uuid.to_string (uuid 1)) - ; "expanded", `Bool expanded + [ "type", `String "row_event" + ; "payload", `String (Yojson.Safe.to_string (`Assoc + [ "row", `String ("block:" ^ G.Uuid.to_string (uuid 1)) + ; "key", `String (if expanded then "expand" else "collapse") ])) ]) in if scenario = `Detail_offscreen diff --git a/test/journal_adaptive_test.ml b/test/journal_adaptive_test.ml index 449e981..2c6f7ed 100644 --- a/test/journal_adaptive_test.ml +++ b/test/journal_adaptive_test.ml @@ -522,8 +522,8 @@ let test_detail_capsules_mount_outside_toolbars () = |> List.sort_uniq Int.compare in require - (List.length surfaces = 1) - "Detail must have one action capsule; native navigation supplies Back"; + (List.length surfaces = 2) + "Detail has Capture and Copy capsules; native navigation supplies Back"; List.iter (fun (label, command, allowed) -> let nodes = diff --git a/test/journal_graph_runtime_locality_test.ml b/test/journal_graph_runtime_locality_test.ml index b4a6515..016d024 100644 --- a/test/journal_graph_runtime_locality_test.ml +++ b/test/journal_graph_runtime_locality_test.ml @@ -869,7 +869,7 @@ let test_older_feed_uses_calendar_upper_bound () = ] ;; -let test_detail_resolves_unretained_page () = +let test_detail_resolves_unretained_page ?(tagged = false) () = let runtime = Runtime.create () in set_calendar runtime; let read = @@ -889,7 +889,9 @@ let test_detail_resolves_unretained_page () = (response read (Protocol.V2_block_outcome - (V2_present_block { value = record; revision = "block-1" }))) + (V2_present_block + { value = (if tagged then { record with tag_titles = [ "周末"; "旅行" ] } else record) + ; revision = "block-1" }))) in let read = only "unretained owning-page read" output.requests in (match read.command with @@ -907,6 +909,7 @@ let test_detail_resolves_unretained_page () = (V2_present_page { page = ordinary_page; revision = "ordinary-page-1" }))) in let children = only "ordinary page direct children" output.requests in + let child_uuid = Graph.Uuid.of_string "a1000000-0000-4000-9000-000000000003" |> Result.get_ok in let output = Runtime.receive runtime @@ -916,7 +919,11 @@ let test_detail_resolves_unretained_page () = { parent = block_uuid ; revision_scope = V2_children_revision block_uuid ; scope_revision = "children-1" - ; items = [] + ; items = (if tagged then + [ { Protocol.value = { record with block = + { block with uuid = child_uuid; parent = block_uuid; title = "Child" } + ; tag_titles = [ "地图" ] } + ; revision = "child-1" } ] else []) ; next_cursor = None })) in @@ -925,7 +932,12 @@ let test_detail_resolves_unretained_page () = Alcotest.(check (option int)) "ordinary page does not fabricate a journal date" None - (Journal_model.journal_day_opt detail.root) + (Journal_model.journal_day_opt detail.root); + if tagged then ( + Alcotest.(check (list string)) "Detail preserves root tags returned by its reader" + [ "周末"; "旅行" ] (Journal_model.tag_titles detail.root); + Alcotest.(check (list string)) "Detail preserves child tags returned by its reader" + [ "地图" ] (Journal_model.tag_titles (only "tagged child" detail.children.blocks))) | _ -> Alcotest.fail "ordinary page detail did not load" ;; @@ -3302,7 +3314,7 @@ let test_detail_children_uses_latest_root () = point (Protocol.V2_block_outcome (V2_present_block - { value = { record with block = { block with title = "Fresh root" } } + { value = { record with block = { block with title = "Fresh root" }; tag_titles = [ "Fresh tags" ] } ; revision = "root-2" })))); let output = @@ -3331,6 +3343,8 @@ let test_detail_children_uses_latest_root () = "children must not restore stale root" "Fresh root" (Journal_model.source detail.root) + ; Alcotest.(check (list string)) "reconciled Detail keeps latest root tags" + [ "Fresh tags" ] (Journal_model.tag_titles detail.root) | None -> Alcotest.fail "changed children were not reconciled" ;; @@ -4005,7 +4019,9 @@ let () = , [ Alcotest.test_case "resolve unretained page" `Quick - test_detail_resolves_unretained_page + (fun () -> test_detail_resolves_unretained_page ()) + ; Alcotest.test_case "Detail reader preserves root and child tags" `Quick + (fun () -> test_detail_resolves_unretained_page ~tagged:true ()) ; Alcotest.test_case "retain child cursor" `Quick diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 7189673..aa613e4 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -24,16 +24,18 @@ let creation_time = ;; let block + ?(id = block_id) + ?parent_id ?(source = "Literal #journal @person 👩🏽‍💻") ?(task_state = Journal_model.Done) ?(child_count = 3) () = Journal_model.create - ~id:block_id + ~id ~page_id ~journal_day:20260809 - ~parent_id:None + ~parent_id ~sibling_order:"000000000001" ~source ~task_state @@ -2345,8 +2347,11 @@ let test_detail_uses_shared_capture_control () = (ops ()) in require - (List.length slots = 1) - "Detail native Chrome retains an empty toolbar slot"; + (List.length slots = 2) + "Detail native Chrome must retain body and one options control"; + require + (List.length (mounted_nodes (ops ()) AccessibilityLabel (StringValue "Block options")) = 1) + "Detail must have one discoverable options menu"; let captures = mounted_nodes (ops ()) AccessibilityLabel (StringValue "Capture") in @@ -2366,7 +2371,79 @@ let test_detail_uses_shared_capture_control () = [ true; false ] ;; +let test_detail_three_tiers_keep_deep_order_and_plain_rows () = + let id n = Printf.sprintf "20000000-0000-4000-a000-%012d" n in + let root = block ~source:"周末去山里走走,\n顺便记录这次路线和沿途的风景。" () + |> Journal_model.with_tag_titles ~tag_titles:[ "周末"; "trip" ] in + let child = block ~id:(id 2) ~parent_id:block_id ~source:"出发前的准备" () in + let grandchild = block ~id:(id 3) ~parent_id:(id 2) ~source:"带上雨衣和充电宝\n下载离线地图" () in + let deep = block ~id:(id 4) ~parent_id:(id 3) ~source:"更深一层,完整保留中文内容" () in + let deeper = block ~id:(id 5) ~parent_id:(id 4) ~source:"第四层以下仍然可以阅读" ~child_count:0 () in + let sibling = block ~id:(id 6) ~parent_id:block_id ~source:"沿途记录" ~child_count:0 () in + let projection root children : Journal_graph_projection.detail = + { root; children = { blocks = children; continuation = None } } + in + let routes = Journal_routes.create () + |> fun t -> Journal_routes.open_detail t ~block_id ~request_generation:1L + |> fun t -> Journal_routes.apply_detail_response t ~request_generation:1L (projection root [ child; sibling ]) + in + let load detail parent children = + let detail, requests = Journal_detail.step detail (Set_branch_expanded (Journal_model.id parent, true)) in + let generation = match requests with + | [ Journal_graph_request.Load_detail { limit = 64; request_generation; _ } ] -> request_generation + | _ -> fail "Expanding one branch must issue exactly one bounded page read" + in + fst (Journal_detail.step detail (Loaded (generation, projection parent children))) + in + let detail = Option.get (Journal_routes.detail routes) + |> fun t -> load t child [ grandchild ] + |> fun t -> load t grandchild [ deep ] + |> fun t -> load t deep [ deeper ] + in + let routes = Journal_routes.update_detail routes detail in + let body = Application.For_testing.detail_page ~routes ~write_enabled:true ignore in + with_mounted (V.Body.Private.to_widget body) (fun _ ops -> + let payloads = List.filter_map (function + | Lui_protocol.SetExtensionProp (_, "payload", StringValue s) -> Some (Yojson.Basic.from_string s) + | _ -> None) (ops ()) in + let list = List.find (fun p -> Yojson.Basic.Util.member "sections" p <> `Null) payloads in + let rows = Yojson.Basic.Util.(list |> member "sections" |> to_list |> List.hd |> member "rows" |> to_list) in + require (List.length rows = 6) "Detail flattens only visual layout, preserving all loaded descendants and order"; + require (List.map (Yojson.Basic.Util.member "key") rows = + List.map (fun b -> `String ("block:" ^ Journal_model.id b)) + [ root; child; grandchild; deep; deeper; sibling ]) "Detail changes owner traversal order"; + List.iter (fun row -> + require (Yojson.Basic.Util.member "type" row = `String "row") "Detail retains disclosure nesting"; + require (Yojson.Basic.Util.member "separator" row = `String "hidden") "Detail retains hierarchy separators") rows; + let labels = List.filter (fun p -> Yojson.Basic.Util.member "mode" p = `String "detail-text") payloads in + require (List.exists (fun p -> Yojson.Basic.Util.member "id" p = `String ("detail-tags:" ^ block_id) + && Yojson.Basic.Util.member "text" p = `String "#周末 #trip") labels) "Detail drops owned tags"; + let expected = [ root, 0; child, 1; grandchild, 2; deep, 2; deeper, 2; sibling, 1 ] in + List.iter (fun (block, level) -> + let label = List.find (fun p -> Yojson.Basic.Util.member "id" p = `String ("detail-block:" ^ Journal_model.id block)) labels in + require (Yojson.Basic.Util.member "level" label = `Int level) "Detail loses the three-level typography cap"; + require (Yojson.Basic.Util.member "text" label <> `Null) "Detail drops long or Chinese text") expected) +;; + +let test_detail_flat_branch_keeps_explicit_collapse_action () = + let child = block ~id:"20000000-0000-4000-a000-000000000002" ~parent_id:block_id ~child_count:0 () in + let projection : Journal_graph_projection.detail = + { root = block (); children = { blocks = [ child ]; continuation = None } } in + let routes = Journal_routes.create () + |> fun t -> Journal_routes.open_detail t ~block_id ~request_generation:1L + |> fun t -> Journal_routes.apply_detail_response t ~request_generation:1L projection in + let actions = ref [] in + let body = Application.For_testing.detail_page ~routes ~write_enabled:true (fun a -> actions := a :: !actions) in + with_mounted (V.Body.Private.to_widget body) (fun app ops -> + dispatch_row_action app (native_list_node (ops ())) ("block:" ^ block_id) "collapse"; + require (!actions = [ Application.For_testing.Set_expanded (block_id, false) ]) + "Removing disclosure chrome loses the existing bounded collapse action") +;; + let tests = + [ "Detail three typography tiers and deep flat rows", test_detail_three_tiers_keep_deep_order_and_plain_rows + ; "Detail flat branch retains collapse", test_detail_flat_branch_keeps_explicit_collapse_action + ] @ [ "Detail shared Capture control", test_detail_uses_shared_capture_control ; ( "timeline asset child preview title" , test_timeline_document_title_follows_its_asset_child ) From b48ac5d10689b23cf1edc6f89990719943dd8718 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sat, 10 Oct 2026 23:53:12 +0800 Subject: [PATCH 02/11] Register native Copy menu symbol --- swift/JournalIcons.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/swift/JournalIcons.swift b/swift/JournalIcons.swift index 4d04bcd..a03362c 100644 --- a/swift/JournalIcons.swift +++ b/swift/JournalIcons.swift @@ -19,6 +19,7 @@ let journalIconNames: [String] = [ "circle.fill", "clock", "doc", + "doc.on.doc", "doc.text", "ellipsis", "exclamationmark.circle", From f6702eb1e2363d303cab2a1fcd9f6c4d7b096f37 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 08:14:46 +0800 Subject: [PATCH 03/11] Increase Detail page budget to 128 items --- app/application.ml | 2 +- app/journal_detail.ml | 2 +- app/journal_routes.ml | 4 +- test/application_view_test.ml | 9 +++- test/journal_routes_test.ml | 81 +++++++++++++++++++++++++++++++++- test/journal_semantics_test.ml | 2 +- 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/app/application.ml b/app/application.ml index a706988..7bdc54b 100644 --- a/app/application.ml +++ b/app/application.ml @@ -5107,7 +5107,7 @@ let start with_direct_request { snapshot with routes; next_request_generation = Int64.succ generation } (Journal_graph_request.Load_detail - { block_id; after = None; limit = 64; request_generation = generation }) + { block_id; after = None; limit = 128; request_generation = generation }) in let open_favorite membership_id = match diff --git a/app/journal_detail.ml b/app/journal_detail.ml index 4b0c54c..04aeb76 100644 --- a/app/journal_detail.ml +++ b/app/journal_detail.ml @@ -267,7 +267,7 @@ let load (t : t) id = Journal_graph_request.Load_detail { block_id = id ; after = current.continuation - ; limit = 64 + ; limit = 128 ; request_generation = generation } in diff --git a/app/journal_routes.ml b/app/journal_routes.ml index 0ccfec8..bbb4884 100644 --- a/app/journal_routes.ml +++ b/app/journal_routes.ml @@ -362,7 +362,7 @@ let open_favorite ( open_detail t ~block_id ~request_generation , Some (Journal_graph_request.Load_detail - { block_id; after = None; limit = 64; request_generation }) ) + { block_id; after = None; limit = 128; request_generation }) ) ;; let update_detail_at t ~entry_id detail = @@ -565,7 +565,7 @@ let retry_detail_at t ~entry_id ~request_generation = } , Some (Journal_graph_request.Load_detail - { block_id; after = None; limit = 64; request_generation }) ) + { block_id; after = None; limit = 128; request_generation }) ) ;; let retry_detail t ~request_generation = diff --git a/test/application_view_test.ml b/test/application_view_test.ml index e508b2c..ccb2ed9 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -430,6 +430,7 @@ let run_favorites_native_visibility let media_checksum = Atomic.make 'a' in let media_metadata_changed = Atomic.make false in let detail_fixture = Atomic.make false in + let detail_initial_limit = Atomic.make None in let released_demands = Atomic.make 0 in let capture_root : P.v2_block_tree option Atomic.t = Atomic.make None in let capture_page : G.page option Atomic.t = Atomic.make None in @@ -946,8 +947,10 @@ let run_favorites_native_visibility (Atomic.get detail_captures) ; next_cursor = None } - | V2_get_children { parent; _ } + | V2_get_children { parent; limit; _ } when Atomic.get detail_fixture && G.Uuid.equal parent (uuid 1) -> + if check_copy && Atomic.get detail_initial_limit = None + then Atomic.set detail_initial_limit (Some limit); let value = timeline_block 1 in V2_children_outcome { parent @@ -1741,6 +1744,10 @@ let run_favorites_native_visibility (find "accessibility-identifier" ("detail-block:" ^ G.Uuid.to_string (uuid 1)))); + (* Pure routes retain navigation but do not issue Application's + ordinary Open block read; inspect that public worker boundary. *) + Alcotest.(check (option int)) "ordinary Detail first-page budget" + (Some 128) (Atomic.get detail_initial_limit); let detail_list = Hashtbl.fold (fun id values found -> diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index b509af9..6ffddab 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -1094,7 +1094,8 @@ let test_favorite_target_origin_and_undo () = (Journal_routes.destination (Journal_routes.back routes) = Favorites) "favorite Back lost its destination"; (match request with - | Some (Journal_graph_request.Load_detail { block_id; _ }) -> + | Some (Journal_graph_request.Load_detail { block_id; limit; _ }) -> + require (limit = 128) "favorite Detail must request at most 128 children"; require (block_id = G.Uuid.to_string target) "favorite loaded membership instead of target" @@ -1697,7 +1698,7 @@ let test_native_disclosure_state_is_idempotent () = let opened, requests = D.step same (Set_branch_expanded (child_id, true)) in let generation = match requests with - | [ Journal_graph_request.Load_detail { request_generation; limit = 64; _ } ] -> + | [ Journal_graph_request.Load_detail { request_generation; limit = 128; _ } ] -> request_generation | _ -> fail "native disclosure did not admit a single bounded child request" in @@ -2616,6 +2617,80 @@ let test_retained_detail_picker_owner_and_terminal_delete () = "committed deletion revived its retired draft") ;; +let test_detail_128_item_pages_preserve_order_and_completion () = + let module D = Journal_detail in + let root = block () in + let parent = block ~id:(generated_block_id 1099) + ~parent_id:(Some (Journal_model.id root)) ~child_count:260 () in + let parent_id = Journal_model.id parent in + let children = List.init 260 (fun index -> + let ordinal = index + 1 in + block ~id:(generated_block_id (1100 + ordinal)) ~parent_id:(Some parent_id) + ~order:(Printf.sprintf "%06d" ordinal) ~child_count:0 ()) in + let page start count continuation : Journal_graph_projection.detail = + { root = parent; children = + { blocks = List.filteri (fun i _ -> i >= start && i < start + count) children + ; continuation } } in + let cursor ordinal : Journal_graph_projection.block_cursor = + { after_sibling_order = Printf.sprintf "%06d" ordinal + ; after_block_id = Journal_model.id (List.nth children (ordinal - 1)) + ; protocol_cursor = Some (Logseq_db_types.Graph_types.Cursor.of_string + (Printf.sprintf "detail-page-%d" ordinal) |> Result.get_ok) } in + let request after = function + | [ Journal_graph_request.Load_detail + { block_id; limit; after = actual; request_generation } ] -> + require (block_id = parent_id && actual = after) + "128-item paging lost parent or opaque cursor"; + require (limit = 128) "Detail page budget expected 128, got %d" limit; + request_generation + | _ -> fail "Detail paging must issue exactly one bounded read" in + let initial = D.create ~session_number:128L + { root; children = { blocks = [ parent ]; continuation = None } } in + let first, requests = D.step initial (Set_branch_expanded (parent_id, true)) in + let generation = request None requests in + let first, _ = D.step first (Loaded (generation, page 0 128 (Some (cursor 128)))) in + require (List.length (D.children_of first ~parent_id) = 128) + "initial 128-item page was truncated"; + let second, requests = D.step first (Load_more parent_id) in + let generation = request (Some (cursor 128)) requests in + let second, duplicate = D.step second (Load_more parent_id) in + require (duplicate = []) "pending 128-item page admitted a duplicate read"; + let second, _ = D.step second (Loaded (generation, page 128 128 (Some (cursor 256)))) in + require (List.length (D.children_of second ~parent_id) = 256) + "second 128-item page lost previously loaded children"; + let last, requests = D.step second (Load_more parent_id) in + let generation = request (Some (cursor 256)) requests in + let completion = D.Loaded (generation, page 256 4 None) in + let last, _ = D.step last completion in + let replayed, _ = D.step last completion in + require + (List.map Journal_model.id (D.children_of replayed ~parent_id) + = List.map Journal_model.id children) + "128/128/4 pages duplicated, omitted or reordered children"; + require (D.continuation replayed ~parent_id = None) "final page retained a cursor"; + let _, requests = D.step replayed (Load_more parent_id) in + require (requests = []) "completed 128-item paging kept requesting"; + require + (List.filter_map (function D.Block { block; _ } -> Some (Journal_model.id block) + | D.More _ -> None) (D.rows replayed) + = List.map Journal_model.id (root :: parent :: children)) + "flat Detail traversal lost the complete 260-child order" +;; + +let test_detail_retry_uses_128_item_first_page () = + let module R = Journal_routes in + let root = block () in + let routes = R.create () |> fun t -> + R.open_detail t ~block_id:(Journal_model.id root) ~request_generation:127L in + let routes = R.apply_detail_response routes ~request_generation:127L (detail ~root ()) in + let _, request = R.retry_detail routes ~request_generation:128L in + match request with + | Some (Journal_graph_request.Load_detail { block_id; after = None; limit; request_generation = 128L }) -> + require (block_id = Journal_model.id root && limit = 128) + "Detail retry must restart its root with a 128-item first page" + | _ -> fail "Detail retry lost its first-page owner" +;; + let tests = [ ( "retained picker and terminal delete" , test_retained_detail_picker_owner_and_terminal_delete ) @@ -2639,6 +2714,8 @@ let tests = , test_child_refresh_replaces_cursor_and_fences_pending_read ) ; "hidden favorites pagination", test_favorites_hidden_rows_do_not_block_pagination ; "native disclosure state is idempotent", test_native_disclosure_state_is_idempotent + ; "Detail 128-item pages preserve order", test_detail_128_item_pages_preserve_order_and_completion + ; "Detail retry uses 128-item first page", test_detail_retry_uses_128_item_first_page ; "Append editor session after save", test_append_editor_session_after_save ; ( "Capture editor sessions are never reused" , test_capture_editor_sessions_are_not_reused ) diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index aa613e4..776c8cc 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -2390,7 +2390,7 @@ let test_detail_three_tiers_keep_deep_order_and_plain_rows () = let load detail parent children = let detail, requests = Journal_detail.step detail (Set_branch_expanded (Journal_model.id parent, true)) in let generation = match requests with - | [ Journal_graph_request.Load_detail { limit = 64; request_generation; _ } ] -> request_generation + | [ Journal_graph_request.Load_detail { limit = 128; request_generation; _ } ] -> request_generation | _ -> fail "Expanding one branch must issue exactly one bounded page read" in fst (Journal_detail.step detail (Loaded (generation, projection parent children))) From 72e93d50f9264986cc5acabc90ee7d02f66736d2 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 08:46:35 +0800 Subject: [PATCH 04/11] Compose feedback and bottom controls with native LUI insets --- app/journal_header.ml | 74 +++++++++++++++++----------------- app/journal_view.ml | 1 + app/journal_view.mli | 1 + swift/JournalChrome.swift | 34 +--------------- test/application_view_test.ml | 16 +++++++- test/journal_semantics_test.ml | 70 ++++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 72 deletions(-) diff --git a/app/journal_header.ml b/app/journal_header.ml index 5a5a2b8..90e4754 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -29,21 +29,24 @@ let chrome = () ;; -let feedback ~key ~top ~visible ~compact ~expanded body = - Ui.Native_widget.widget - chrome - ~key - ~props: - (`Assoc [ "mode", `String "feedback"; "top", `Bool top; "visible", `Bool visible ]) - ~on_event:(fun _ -> ()) - (* Chrome slots are positional on the native side — absent slots must - still mount a (zero-size) node or the host's index lookup shifts. *) - ~children: - [ V.Body.Private.to_widget body; V.column [ compact ]; V.column [ expanded ] ] - () +let feedback_with_visibility ?visible_signal ~key ~top ~visible ~compact ~expanded body = + V.of_lui + (Lui_elements.edge_inset + ~key:(Ui.Key.to_string key) ~edge:(if top then `top else `bottom) + ~visible ?visible_signal ~gap:0 ~grow:1.0 ~background:"bar" + [ Lui_elements.stack ~grow:1.0 [ Ui.mount (V.Body.Private.to_widget body) ] + ; Lui_elements.view_that_fits ~orientation:`horizontal + (* Empty candidates still need their own node so the native fit + preference retains compact before expanded. *) + [ Ui.mount (V.column [ compact ]); Ui.mount (V.column [ expanded ]) ] + ]) |> V.Body.static ;; +let feedback ~key ~top ~visible ~compact ~expanded body = + feedback_with_visibility ~key ~top ~visible ~compact ~expanded body +;; + let date_header ~title = Ui.Native_widget.widget chrome @@ -69,13 +72,11 @@ let capture_control ~enabled ~on_capture = ;; let bottom_controls ~key ~controls body = - Ui.Native_widget.widget - chrome - ~key:(Ui.Key.string key) - ~props:(`Assoc [ "mode", `String "bottom-controls" ]) - ~on_event:(fun _ -> ()) - ~children:[ V.Body.Private.to_widget body; controls ] - () + V.of_lui + (Lui_elements.edge_inset ~key ~edge:`bottom ~gap:0 ~grow:1.0 + ~padding_horizontal:16 ~padding_vertical:8 + [ Lui_elements.stack ~grow:1.0 [ Ui.mount (V.Body.Private.to_widget body) ] + ; Ui.mount controls ]) |> V.Body.static ;; @@ -367,24 +368,23 @@ let view_impl match context with | Context.Journals -> body | Favorites -> - reactive_widget - ~key - ~props_for:(fun presentation -> - `Assoc - [ "mode", `String "feedback" - ; "top", `Bool true - ; ( "visible" - , `Bool - (presentation.sync_phase = Some Graph_service.Connecting - || Option.is_some presentation.sync_error) ) - ]) - ~children: - [ V.Body.Private.to_widget body - ; V.column [ sync_feedback ] - ; V.column [ sync_feedback ] - ] - () - |> V.Body.static + let visible presentation = + presentation.sync_phase = Some Graph_service.Connecting + || Option.is_some presentation.sync_error + in + let wrap ?visible_signal () = + feedback_with_visibility ?visible_signal ~key ~top:true + ~visible:(visible initial) ~compact:sync_feedback ~expanded:sync_feedback body + in + (match presentation_signal with + | None -> wrap () + | Some source -> + V.of_lui (fun context parent -> + let visible_signal = Signal.map visible source in + Signal.on_dispose context.Lui_ui.ui_scope (fun () -> + Signal.dispose_signal visible_signal); + Ui.mount (V.Body.Private.to_widget (wrap ~visible_signal ())) context parent) + |> V.Body.static) in let body = body |> V.Body.with_test_id (Ui.Test_id.string "journal-root-navigation") in let body = diff --git a/app/journal_view.ml b/app/journal_view.ml index a0d2820..31572cc 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -28,6 +28,7 @@ module Key = struct let string s = s let int i = string_of_int i let int64 i = Int64.to_string i + let to_string s = s end module Test_id = struct diff --git a/app/journal_view.mli b/app/journal_view.mli index 859eb81..cfd09f6 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -11,6 +11,7 @@ module Key : sig val string : string -> t val int : int -> t val int64 : int64 -> t + val to_string : t -> string end module Test_id : sig diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 7bcea4f..911a756 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -24,8 +24,7 @@ struct JournalSheetDismissButton: SwiftUI.View { /// 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 page, header, detail, unlock case toolbarControl = "toolbar-control" case detailText = "detail-text" case detailRow = "detail-row" @@ -33,8 +32,6 @@ struct JournalSheetDismissButton: SwiftUI.View { private struct Properties: Decodable { let mode: Mode - let top: Bool? - let visible: Bool? let title: String? let connecting: Bool? let controls: Bool? @@ -195,23 +192,6 @@ struct JournalSheetDismissButton: SwiftUI.View { var body: some SwiftUI.View { switch properties?.mode { - case .feedback: - if let properties, context.childIDs.count == 3, - properties.top != nil, properties.visible != nil { - GeometryReader { bounds in - child(0).frame(width: bounds.size.width, height: bounds.size.height) - } - .safeAreaInset(edge: properties.top! ? .top : .bottom, spacing: 0) { - if properties.visible! { - ViewThatFits(in: .horizontal) { - child(1) - child(2) - } - .frame(maxWidth: .infinity) - .background(.bar) - } - } - } case .page: if let properties, context.childIDs.count == 3, properties.connecting != nil, properties.controls != nil { @@ -253,18 +233,6 @@ struct JournalSheetDismissButton: SwiftUI.View { .accessibilityLabel(Text(properties?.title ?? "Close")) .accessibilityAddTraits(.isButton) } - case .bottomControls: - if context.childIDs.count == 2 { - GeometryReader { bounds in - child(0).frame(width: bounds.size.width, height: bounds.size.height) - } - .safeAreaInset(edge: .bottom, spacing: 0) { - child(1) - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - } case .unlock: if let properties, let title = properties.title, context.childIDs.count == 4 { Form { diff --git a/test/application_view_test.ml b/test/application_view_test.ml index ccb2ed9..eef71f3 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1790,7 +1790,12 @@ let run_favorites_native_visibility Option.get (detail_body !current_root) in let find_current key value = - let owner = chrome "bottom-controls" (current_body ()) in + let rec owning_inset id = + let values = Option.value (Hashtbl.find_opt props id) ~default:[] in + if List.assoc_opt "edge" values = Some (`String "bottom") + then id else owning_inset (Hashtbl.find parents id) + in + let owner = owning_inset (current_body ()) in Hashtbl.fold (fun id values found -> if under owner id && List.assoc_opt key values = Some (`String value) then Some id else found) props None @@ -3086,7 +3091,14 @@ let run_favorites_native_visibility "accessibility-identifier" ("detail-block:" ^ G.Uuid.to_string (List.nth roots 1)))); dispatch - (Lui_protocol.Press (Option.get (find "accessibility-label" "Capture"))); + (Lui_protocol.Press + (let scope = Option.get (find "accessibility-identifier" "journal-detail-capture-open") in + let rec under id = + id = scope || Option.fold ~none:false ~some:under (Hashtbl.find_opt parents id) + in + Hashtbl.fold (fun id values found -> + if under id && List.assoc_opt "accessibility-label" values = Some (`String "Capture") + then Some id else found) props None |> Option.get)); let editor = Option.get (find "style-class" "composer-input") in Alcotest.(check bool) "append sheet title is present" diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 776c8cc..9fd6f0c 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -2440,7 +2440,77 @@ let test_detail_flat_branch_keeps_explicit_collapse_action () = "Removing disclosure chrome loses the existing bounded collapse action") ;; +(* Composition and native placement have no pure reducer owner. Exercise the + public mounted controls, including visibility updates without remounting. *) +let test_feedback_reuses_lui_inset_and_keeps_control_events () = + let current = ref None in + let pressed = ref [] in + let action name = + V.button ~on_press:(Ui.Event.Handler.create (fun _ -> pressed := name :: !pressed)) + ~child:(V.text name) () + in + let slot = Signal.state_slot "feedback-visibility" in + let view = V.of_lui (fun context parent -> + let visible = Signal.state_at context.ui_scheduler context.ui_state_scope slot false in + current := Some visible; + Lui_elements.dyn (fun visible -> + Journal_header.feedback ~key:(Ui.Key.string "feedback-owner") ~top:false ~visible + ~compact:(action "Compact action") ~expanded:(action "Expanded action") + (V.Body.static (V.text "Retained body")) + |> V.Body.Private.to_widget |> Ui.mount) + (Signal.value visible) context parent) + in + with_mounted view (fun app ops -> + let inset = List.find_map (function + | Lui_protocol.CreateNode (id, EdgeInset) -> Some id | _ -> None) (ops ()) in + require (Option.is_some inset) "feedback still uses an application native layout"; + let inset = Option.get inset in + let initial = ops () in + require (mounted_nodes initial EdgeValue (StringValue "bottom") = [ inset ]) + "feedback lost its requested native edge"; + require (mounted_nodes initial Visible (BoolValue false) = [ inset ]) + "feedback lost initial hidden state"; + require (has_text initial "Retained body") "hidden feedback lost its body"; + let controls = List.map (fun label -> + mounted_nodes initial TextValue (StringValue label) |> List.hd) + [ "Compact action"; "Expanded action" ] in + Signal.set (Option.get !current) true; + ignore (Lui_app.flush app); + require (mounted_nodes (ops ()) Visible (BoolValue true) = [ inset ]) + "feedback visibility failed to update the existing native inset"; + List.iter (fun id -> ignore (Lui_app.dispatch_event app (Press id))) controls; + ignore (Lui_app.flush app); + require (List.rev !pressed = [ "Compact action"; "Expanded action" ]) + "feedback candidates lost their public control events"; + require (not (List.exists (function + | Lui_protocol.DropNode id -> id = inset || List.mem id controls + | _ -> false) (ops ()))) "visibility remounted the feedback owner or controls") +;; + +let test_bottom_controls_reuse_lui_inset_and_keep_capture () = + let pressed = ref false in + let ignored = Ui.Event.Handler.create (fun _ -> ()) in + let body = Journal_header.detail ~capture_enabled:true + ~on_capture:(Ui.Event.Handler.create (fun _ -> pressed := true)) + ~copy_enabled:true ~on_copy:ignored ~copy_feedback:None + (V.Body.static (V.text "Scrolling body")) in + with_mounted (V.Body.Private.to_widget body) (fun app ops -> + let inset = List.find_map (function + | Lui_protocol.CreateNode (id, EdgeInset) -> Some id | _ -> None) (ops ()) in + require (Option.is_some inset) "bottom controls still use an application native layout"; + let inset = Option.get inset in + require (mounted_nodes (ops ()) EdgeValue (StringValue "bottom") = [ inset ]) + "bottom controls lost native safe-area placement"; + require (has_text (ops ()) "Scrolling body") "bottom controls lost scrolling content"; + let capture = mounted_nodes (ops ()) AccessibilityLabel (StringValue "Capture") |> List.hd in + ignore (Lui_app.dispatch_event app (Press capture)); ignore (Lui_app.flush app); + require !pressed "native inset swallowed the shared Capture event") +;; + let tests = + [ "Feedback native inset visibility and events", test_feedback_reuses_lui_inset_and_keeps_control_events + ; "Bottom native inset retains Capture", test_bottom_controls_reuse_lui_inset_and_keep_capture + ] @ [ "Detail three typography tiers and deep flat rows", test_detail_three_tiers_keep_deep_order_and_plain_rows ; "Detail flat branch retains collapse", test_detail_flat_branch_keeps_explicit_collapse_action ] @ From 2c87b20fe302680400c998d273794c31a9f1127d Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 09:08:00 +0800 Subject: [PATCH 05/11] Keep operation feedback inside its native route page --- app/application.ml | 42 +++++++------ test/application_view_test.ml | 111 ++++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/app/application.ml b/app/application.ml index 7bdc54b..3c37a32 100644 --- a/app/application.ml +++ b/app/application.ml @@ -6541,6 +6541,25 @@ let start ~on_select:assets.on_event (V.Body.static (V.empty ()))) in + let feedback = + region + "feedback" + ~equal:(fun left right -> + left.timeline_notice = right.timeline_notice + && left.failed_capture_imports == right.failed_capture_imports) + (fun current -> + if current.timeline_notice = None + then V.empty () + else + operation_feedback + ~scope:"root" + ~state:current + dispatch + (* The root feedback is a sibling of retained root content. + A native flexible column gives its inset a full-page host; + an empty Stack collapses instead of owning the bottom edge. *) + (V.Body.static (V.column ~spacing:0. [ V.spacer () ]))) + in let navigator = V.of_lui (fun context parent -> let mapped = @@ -6563,7 +6582,11 @@ let start if routes == current.routes then current else { current with routes; modal = No_modal })))) - ~root:(Lui_elements.column ~grow:1.0 [ Journal_view.mount root ]) + ~root: + (* Keep feedback in the root native page so a pushed destination + covers it together with the retained Timeline/Favorites. *) + (Lui_elements.column ~grow:1.0 + [ Journal_view.mount (V.stack [ root; feedback ]) ]) ~destination:(fun entry -> let initial = Option.value @@ -6754,22 +6777,6 @@ let start dispatch (V.empty ())) in - let feedback = - region - "feedback" - ~equal:(fun left right -> - left.timeline_notice = right.timeline_notice - && left.failed_capture_imports == right.failed_capture_imports) - (fun current -> - if current.timeline_notice = None - then V.empty () - else - operation_feedback - ~scope:"root" - ~state:current - dispatch - (V.Body.static (V.empty ()))) - in let settings = region "settings" @@ -6794,7 +6801,6 @@ let start ; capture_adapter ; modal ; confirmations - ; feedback ; settings ] in diff --git a/test/application_view_test.ml b/test/application_view_test.ml index eef71f3..83169ce 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1507,12 +1507,111 @@ let run_favorites_native_visibility (Atomic.get imports_entered); Atomic.set import_release true; wait "both imports finish" (fun () -> Atomic.get imports_finished = 2); - if scenario = "retry-imports" then ( - wait "failed import exposes target retry" (fun () -> Option.is_some (find "accessibility-label" "Retry imports")); - Alcotest.(check bool) "failed import keeps its own staged file" true (Sys.file_exists (List.hd !staged_paths)); - dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Retry imports"))); - wait "same child import retries" (fun () -> Atomic.get imports_finished = 3); - Alcotest.(check int) "import retry does not create another child" 1 (Atomic.get child_attempts)); + if scenario = "retry-imports" + then ( + wait "failed import exposes target retry" (fun () -> + Option.is_some (find "accessibility-label" "Retry imports")); + (* Routes reducers own the path, but cannot express native + feedback ancestry or layout. Mount the real failure through the + public Application boundary; navigation must cover the + root feedback together with the root page. *) + let rec navigation_page id = + let values = Option.value (Hashtbl.find_opt props id) ~default:[] in + if List.assoc_opt "_extension" values = Some (`String "navigation-page") + then List.assoc_opt "entry-id" values + else Option.bind (Hashtbl.find_opt parents id) navigation_page + in + let root_actions = + Option.get (find "accessibility-identifier" "root-operation-actions") + in + Alcotest.(check (option string)) + "root feedback is retained inside the native root page" + (Some "root") + (Option.map Yojson.Safe.Util.to_string (navigation_page root_actions)); + let detail_actions = + all "accessibility-label" "Retry imports" + |> List.find (fun id -> navigation_page id <> Some (`String "root")) + in + Alcotest.(check bool) + "Detail feedback belongs to its native destination" + true + (match navigation_page detail_actions with + | Some (`String entry) -> entry <> "root" + | _ -> false); + let rec inset_owner id = + let values = Option.value (Hashtbl.find_opt props id) ~default:[] in + if List.assoc_opt "_kind" values = Some (`String "edge-inset") + then id + else inset_owner (Hashtbl.find parents id) + in + let root_inset = inset_owner root_actions in + let root_body = + Hashtbl.fold + (fun id parent found -> + if + parent = root_inset + && List.assoc_opt "_kind" (Hashtbl.find props id) + = Some (`String "stack") + then Some id + else found) + parents + None + |> Option.get + in + let rec inside_body id = + id = root_body + || Option.fold + ~none:false + ~some:inside_body + (Hashtbl.find_opt parents id) + in + Alcotest.(check bool) + "empty root feedback body fills its native page" + true + (Hashtbl.fold + (fun id values found -> + found + || (List.assoc_opt "_kind" values = Some (`String "spacer") + && inside_body id)) + props + false); + Alcotest.(check bool) + "failed import keeps its own staged file" + true + (Sys.file_exists (List.hd !staged_paths)); + let retained_root = Option.get (find "text" "Timeline fixture 1") in + back (); + Alcotest.(check int) + "Back retains the root feedback owner and its unresolved error" + root_actions + (Option.get (find "accessibility-identifier" "root-operation-actions")); + Alcotest.(check int) + "feedback updates retain the root content" + retained_root + (Option.get (find "text" "Timeline fixture 1")); + enter 0; + dispatch (Lui_protocol.Press detail_actions); + settle_worker (); + Alcotest.(check int) + "retired Detail feedback cannot start an import retry" + 2 + (Atomic.get imports_finished); + let current_retry = + all "accessibility-label" "Retry imports" + |> List.find (fun id -> + match navigation_page id with + | Some (`String entry) -> entry <> "root" + | _ -> false) + in + dispatch (Lui_protocol.Press current_retry); + wait "same child import retries" (fun () -> + Atomic.get imports_finished = 3); + wait "successful retry retires both feedback controls" (fun () -> + all "accessibility-label" "Retry imports" = []); + Alcotest.(check int) + "import retry does not create another child" + 1 + (Atomic.get child_attempts)); if not (List.mem scenario [ "retry-imports"; "selection-error" ]) then ( wait "failed import offers explicit discard" (fun () -> Option.is_some (find "accessibility-label" "Dismiss")); dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Dismiss")))); From eb0ea7751edc09fcc743ebf27081bd56df789634 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 09:29:02 +0800 Subject: [PATCH 06/11] Reserve root control space above operation feedback --- app/application.ml | 18 +++++------------- test/application_view_test.ml | 15 +++++++-------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/app/application.ml b/app/application.ml index 3c37a32..b80162b 100644 --- a/app/application.ml +++ b/app/application.ml @@ -6548,17 +6548,10 @@ let start left.timeline_notice = right.timeline_notice && left.failed_capture_imports == right.failed_capture_imports) (fun current -> - if current.timeline_notice = None - then V.empty () - else - operation_feedback - ~scope:"root" - ~state:current - dispatch - (* The root feedback is a sibling of retained root content. - A native flexible column gives its inset a full-page host; - an empty Stack collapses instead of owning the bottom edge. *) - (V.Body.static (V.column ~spacing:0. [ V.spacer () ]))) + (* Keep the same inset/body owner while its visibility changes. + Wrapping retained root content lets native safe-area layout + reserve room for Capture and navigation above the feedback. *) + operation_feedback ~scope:"root" ~state:current dispatch (V.Body.static root)) in let navigator = V.of_lui (fun context parent -> @@ -6585,8 +6578,7 @@ let start ~root: (* Keep feedback in the root native page so a pushed destination covers it together with the retained Timeline/Favorites. *) - (Lui_elements.column ~grow:1.0 - [ Journal_view.mount (V.stack [ root; feedback ]) ]) + (Lui_elements.column ~grow:1.0 [ Journal_view.mount feedback ]) ~destination:(fun entry -> let initial = Option.value diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 83169ce..0a10766 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1335,6 +1335,7 @@ let run_favorites_native_visibility hooks.extension_event nav "path-changed" (payload ()) |> consume; hooks.extension_event nav "settled" (payload ()) |> consume in + let original_root_content = Option.get (find "text" "Timeline fixture 1") in enter 0; Alcotest.(check bool) "old Append toolbar action is removed" @@ -1566,15 +1567,13 @@ let run_favorites_native_visibility (Hashtbl.find_opt parents id) in Alcotest.(check bool) - "empty root feedback body fills its native page" + "root feedback reserves space for the retained page content" true - (Hashtbl.fold - (fun id values found -> - found - || (List.assoc_opt "_kind" values = Some (`String "spacer") - && inside_body id)) - props - false); + (inside_body (Option.get (find "text" "Timeline fixture 1"))); + Alcotest.(check int) + "showing feedback preserves the original root content node" + original_root_content + (Option.get (find "text" "Timeline fixture 1")); Alcotest.(check bool) "failed import keeps its own staged file" true From 69a4d234b077b507e6f9dc48ed023d4d2d7da08d Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 09:59:24 +0800 Subject: [PATCH 07/11] Update LUI to merged iOS button touch target fixes --- logseq_journal.opam | 2 +- logseq_journal.opam.locked | 2 +- test/source_boundary_test.ml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/logseq_journal.opam b/logseq_journal.opam index 9ba8121..0ff5067 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#c0b007ff80eea8934b75e631b59b4f0609bb4c72"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#eb60323c0d75a4f73b1080fe58e8c44682737f21"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index 047e23b..0d50126 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -23,7 +23,7 @@ pin-depends: [ ] [ "lui.0.1.0" - "git+https://github.com/logseq/lui.git#c0b007ff80eea8934b75e631b59b4f0609bb4c72" + "git+https://github.com/logseq/lui.git#eb60323c0d75a4f73b1080fe58e8c44682737f21" ] [ "melange-edn-core.0.5.0" diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index f7c3906..fc39271 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1125,7 +1125,7 @@ let () = (* Journal uses the upstream native host ABI decoder; keep the OCaml installation and Apple package on the same validated revision. *) let compatible_lui_revision = - "git+https://github.com/logseq/lui.git#c0b007ff80eea8934b75e631b59b4f0609bb4c72" + "git+https://github.com/logseq/lui.git#eb60323c0d75a4f73b1080fe58e8c44682737f21" in List.iter (fun relative -> require_occurrences root relative compatible_lui_revision 1) From e6e0fe8c68390b989c187b4211ed00793568ff42 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 12:36:22 +0800 Subject: [PATCH 08/11] Use merged LUI native Menu toolbar touch targets --- logseq_journal.opam | 2 +- logseq_journal.opam.locked | 2 +- test/source_boundary_test.ml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/logseq_journal.opam b/logseq_journal.opam index 0ff5067..ade8646 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#eb60323c0d75a4f73b1080fe58e8c44682737f21"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#0a41238954fc22dbe39a792619836de335449192"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index 0d50126..9b57dfb 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -23,7 +23,7 @@ pin-depends: [ ] [ "lui.0.1.0" - "git+https://github.com/logseq/lui.git#eb60323c0d75a4f73b1080fe58e8c44682737f21" + "git+https://github.com/logseq/lui.git#0a41238954fc22dbe39a792619836de335449192" ] [ "melange-edn-core.0.5.0" diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index fc39271..fc02fb9 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1125,7 +1125,7 @@ let () = (* Journal uses the upstream native host ABI decoder; keep the OCaml installation and Apple package on the same validated revision. *) let compatible_lui_revision = - "git+https://github.com/logseq/lui.git#eb60323c0d75a4f73b1080fe58e8c44682737f21" + "git+https://github.com/logseq/lui.git#0a41238954fc22dbe39a792619836de335449192" in List.iter (fun relative -> require_occurrences root relative compatible_lui_revision 1) From 1436d5329e0a083f15d1673b42d7770029a15bcc Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 12:43:51 +0800 Subject: [PATCH 09/11] Freeze latest merged LUI dependency snapshot --- logseq_journal.opam | 2 +- logseq_journal.opam.locked | 2 +- test/source_boundary_test.ml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/logseq_journal.opam b/logseq_journal.opam index ade8646..814e201 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#0a41238954fc22dbe39a792619836de335449192"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#38551254511c59d9141a8f9b276c78783cbac044"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#0561660e4894faee250d551ab2a32a6b5c25a5fb"] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index 9b57dfb..4e4bd44 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -23,7 +23,7 @@ pin-depends: [ ] [ "lui.0.1.0" - "git+https://github.com/logseq/lui.git#0a41238954fc22dbe39a792619836de335449192" + "git+https://github.com/logseq/lui.git#38551254511c59d9141a8f9b276c78783cbac044" ] [ "melange-edn-core.0.5.0" diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index fc02fb9..e308e1d 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1125,7 +1125,7 @@ let () = (* Journal uses the upstream native host ABI decoder; keep the OCaml installation and Apple package on the same validated revision. *) let compatible_lui_revision = - "git+https://github.com/logseq/lui.git#0a41238954fc22dbe39a792619836de335449192" + "git+https://github.com/logseq/lui.git#38551254511c59d9141a8f9b276c78783cbac044" in List.iter (fun relative -> require_occurrences root relative compatible_lui_revision 1) From 3bbcf6829af69b3c7d0cf600afddb91f1873a52b Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 12:49:41 +0800 Subject: [PATCH 10/11] Apply native Menu styles in the Detail toolbar --- swift/JournalChrome.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 911a756..4128a55 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -210,7 +210,9 @@ struct JournalSheetDismissButton: SwiftUI.View { #if os(iOS) .toolbar(.visible, for: .navigationBar) .toolbar { - ToolbarItem(placement: .topBarTrailing) { child(1) } + ToolbarItem(placement: .topBarTrailing) { + child(1).menuStyle(.button).buttonStyle(.plain) + } } #else .toolbar { ToolbarItem { child(1) } } From 318f4de29bafc9eee7a637a213c96e60d15729e7 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 11 Oct 2026 12:53:23 +0800 Subject: [PATCH 11/11] Let the Detail menu capsule own its toolbar background --- swift/JournalChrome.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 4128a55..21e8cc2 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -213,6 +213,7 @@ struct JournalSheetDismissButton: SwiftUI.View { ToolbarItem(placement: .topBarTrailing) { child(1).menuStyle(.button).buttonStyle(.plain) } + .sharedBackgroundVisibility(.hidden) } #else .toolbar { ToolbarItem { child(1) } }