From 0b625491cfb7a91f21d24f24789ca9ba516c66e5 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Apr 2026 15:27:48 +0200 Subject: [PATCH] feat: more work on UI app Co-authored-by: Copilot --- .gitignore | 1 + lib/bds/application.ex | 47 +- lib/bds/desktop.ex | 2 +- lib/bds/desktop/automation.ex | 320 +++++++ lib/bds/desktop/router.ex | 10 +- lib/bds/desktop/server.ex | 5 +- lib/bds/desktop/shell_controller.ex | 2 +- lib/bds/ui/shell_page.ex | 239 +++++- package-lock.json | 60 ++ package.json | 10 + priv/ui/app.css | 1126 +++++++++++-------------- priv/ui/app.js | 883 ++++++++----------- priv/ui/index.html | 223 ++--- scripts/desktop_automation_app.exs | 7 + scripts/desktop_automation_runner.mjs | 80 ++ test/bds/desktop/automation_test.exs | 107 +++ test/bds/ui/shell_test.exs | 7 +- 17 files changed, 1786 insertions(+), 1343 deletions(-) create mode 100644 lib/bds/desktop/automation.ex create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/desktop_automation_app.exs create mode 100644 scripts/desktop_automation_runner.mjs create mode 100644 test/bds/desktop/automation_test.exs diff --git a/.gitignore b/.gitignore index 34ee326..b8cbb1b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /doc/ /.elixir_ls/ /erl_crash.dump +/node_modules/ /priv/data/*.db /priv/data/*.db-shm /priv/data/*.db-wal diff --git a/lib/bds/application.ex b/lib/bds/application.ex index 34bf1d6..c8b246a 100644 --- a/lib/bds/application.ex +++ b/lib/bds/application.ex @@ -5,24 +5,17 @@ defmodule BDS.Application do def desktop_children(env \\ nil) - def desktop_children(:test), do: [] + def desktop_children(:test) do + if desktop_automation?() do + [{BDS.Desktop.Server, []}] + else + [] + end + end def desktop_children(_env) do if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do - [ - {BDS.Desktop.Server, []}, - {Desktop.Window, - [ - app: :bds, - id: BDS.Desktop.MainWindow, - title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", - size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900}, - min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700}, - menubar: BDS.Desktop.MenuBar, - icon_menu: BDS.Desktop.Menu, - url: &BDS.Desktop.url/0 - ]} - ] + [{BDS.Desktop.Server, []} | desktop_window_children()] else [] end @@ -50,4 +43,28 @@ defmodule BDS.Application do Application.get_env(:bds, :current_env_override) || if(Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod) end + + defp desktop_window_children do + if desktop_automation?() do + [] + else + [ + {Desktop.Window, + [ + app: :bds, + id: BDS.Desktop.MainWindow, + title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", + size: Application.get_env(:bds, :desktop)[:window_size] || {1440, 900}, + min_size: Application.get_env(:bds, :desktop)[:window_min_size] || {1100, 700}, + menubar: BDS.Desktop.MenuBar, + icon_menu: BDS.Desktop.Menu, + url: &BDS.Desktop.url/0 + ]} + ] + end + end + + defp desktop_automation? do + System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] + end end diff --git a/lib/bds/desktop.ex b/lib/bds/desktop.ex index 1c141ba..62eaea9 100644 --- a/lib/bds/desktop.ex +++ b/lib/bds/desktop.ex @@ -2,7 +2,7 @@ defmodule BDS.Desktop do @moduledoc false def url do - Application.get_env(:bds, :desktop)[:port] + BDS.Desktop.Server.port() |> url() end diff --git a/lib/bds/desktop/automation.ex b/lib/bds/desktop/automation.ex new file mode 100644 index 0000000..19450df --- /dev/null +++ b/lib/bds/desktop/automation.ex @@ -0,0 +1,320 @@ +defmodule BDS.Desktop.Automation do + @moduledoc false + + use GenServer + + @ready_timeout 60_000 + @request_timeout 30_000 + + def start_session(opts \\ []) do + GenServer.start_link(__MODULE__, opts) + end + + def stop_session(session) do + GenServer.stop(session, :normal, @request_timeout) + catch + :exit, _reason -> :ok + end + + def snapshot(session) do + GenServer.call(session, :snapshot, @request_timeout) + end + + def click(session, selector) when is_binary(selector) do + GenServer.call(session, {:click, selector}, @request_timeout) + end + + def capture_screenshot(session, destination) when is_binary(destination) do + GenServer.call(session, {:capture_screenshot, destination}, @request_timeout) + end + + def child_info(session) do + GenServer.call(session, :child_info, @request_timeout) + end + + @impl true + def init(opts) do + Process.flag(:trap_exit, true) + + screenshot_dir = Keyword.get(opts, :screenshot_dir, System.tmp_dir!()) + port = Keyword.get_lazy(opts, :port, &free_tcp_port/0) + project_root = project_root() + base_url = BDS.Desktop.url(port) + + File.mkdir_p!(screenshot_dir) + ensure_http_client_started() + + app_port = start_app_process(project_root, port) + :ok = wait_for_server(base_url) + + driver_port = start_driver_process(project_root, base_url, screenshot_dir) + + state = %{ + app_port: app_port, + app_os_pid: port_os_pid(app_port), + driver_port: driver_port, + driver_os_pid: port_os_pid(driver_port), + driver_buffer: "", + base_url: base_url, + screenshot_dir: screenshot_dir + } + + {reply, state} = await_driver_ready(state) + + case reply do + :ok -> {:ok, state} + {:error, reason} -> {:stop, reason} + end + end + + @impl true + def handle_call(:snapshot, _from, state) do + {reply, state} = driver_request(state, %{"command" => "snapshot"}) + {:reply, atomize_map(reply), state} + end + + def handle_call({:click, selector}, _from, state) do + {reply, state} = driver_request(state, %{"command" => "click", "selector" => selector}) + {:reply, normalize_simple_reply(reply), state} + end + + def handle_call({:capture_screenshot, destination}, _from, state) do + File.mkdir_p!(Path.dirname(destination)) + + {reply, state} = + driver_request(state, %{"command" => "screenshot", "path" => destination}) + + {:reply, reply, state} + end + + def handle_call(:child_info, _from, state) do + {:reply, %{app_os_pid: state.app_os_pid, driver_os_pid: state.driver_os_pid}, state} + end + + @impl true + def terminate(_reason, state) do + shutdown_driver(state) + shutdown_app(state) + :ok + end + + defp start_app_process(project_root, port) do + executable = System.find_executable("elixir") || raise "missing elixir executable" + + Port.open( + {:spawn_executable, executable}, + [ + :binary, + :stderr_to_stdout, + :exit_status, + :use_stdio, + :hide, + {:cd, project_root}, + {:env, + [ + {~c"MIX_ENV", ~c"test"}, + {~c"BDS_DESKTOP_AUTOMATION", ~c"1"}, + {~c"BDS_DESKTOP_PORT", String.to_charlist(Integer.to_string(port))} + ]}, + {:args, + [ + "-S", + "mix", + "run", + "scripts/desktop_automation_app.exs" + ]} + ] + ) + end + + defp start_driver_process(project_root, base_url, screenshot_dir) do + executable = System.find_executable("node") || raise "missing node executable" + + Port.open( + {:spawn_executable, executable}, + [ + :binary, + :stderr_to_stdout, + :exit_status, + :use_stdio, + :hide, + {:cd, project_root}, + {:args, + [ + Path.join([project_root, "scripts", "desktop_automation_runner.mjs"]), + base_url, + screenshot_dir + ]} + ] + ) + end + + defp await_driver_ready(state) do + receive_driver_message(state, @ready_timeout, fn message -> + case message do + %{"status" => "ready"} -> {:ok, :ok} + %{"status" => "error", "message" => reason} -> {:ok, {:error, reason}} + _other -> :continue + end + end) + end + + defp driver_request(state, payload) do + ref = Integer.to_string(System.unique_integer([:positive, :monotonic])) + request = Map.put(payload, "ref", ref) + Port.command(state.driver_port, Jason.encode!(request) <> "\n") + + receive_driver_message(state, @request_timeout, fn message -> + case message do + %{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result} + %{"ref" => ^ref, "status" => "error", "message" => reason} -> + raise "desktop automation request failed: #{reason}" + + _other -> + :continue + end + end) + end + + defp safe_driver_request(state, payload) do + driver_request(state, payload) + rescue + _error -> :ok + end + + defp shutdown_driver(state) do + _ = safe_driver_request(state, %{"command" => "close"}) + await_port_exit(state[:driver_port], 5_000) + safe_close_port(state[:driver_port]) + end + + defp shutdown_app(state) do + if state[:app_port] do + Port.command(state.app_port, "stop\n") + await_port_exit(state.app_port, 10_000) + safe_close_port(state.app_port) + end + end + + defp receive_driver_message(state, timeout, matcher) do + deadline = System.monotonic_time(:millisecond) + timeout + process_driver_messages(state, deadline, matcher) + end + + defp process_driver_messages(state, deadline, matcher) do + {messages, buffer} = split_driver_buffer(state.driver_buffer) + + case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} -> + decoded = Jason.decode!(message) + + case matcher.(decoded) do + {:ok, reply} -> {:halt, {acc, reply}} + :continue -> {:cont, {acc, nil}} + end + end) do + {state, nil} -> + remaining = max(deadline - System.monotonic_time(:millisecond), 0) + + receive do + {port, {:data, data}} when port == state.driver_port -> + process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher) + + {port, {:exit_status, status}} when port == state.driver_port -> + raise "desktop automation driver exited with status #{status}" + + {port, {:exit_status, status}} when port == state.app_port -> + raise "desktop app process exited with status #{status}" + + {_port, {:data, _data}} -> + process_driver_messages(state, deadline, matcher) + after + remaining -> raise "desktop automation timed out waiting for driver response" + end + + {state, reply} -> + {reply, state} + end + end + + defp split_driver_buffer(buffer) do + case String.split(buffer, "\n") do + [] -> {[], ""} + [single] -> {[], single} + parts -> {Enum.drop(parts, -1), List.last(parts)} + end + end + + defp wait_for_server(base_url) do + deadline = System.monotonic_time(:millisecond) + @ready_timeout + do_wait_for_server(base_url, deadline) + end + + defp do_wait_for_server(base_url, deadline) do + case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do + {:ok, {{_, 200, _}, _headers, _body}} -> :ok + _other -> + if System.monotonic_time(:millisecond) >= deadline do + raise "desktop app process did not become healthy in time" + else + Process.sleep(200) + do_wait_for_server(base_url, deadline) + end + end + end + + defp free_tcp_port do + {:ok, socket} = :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true]) + {:ok, port} = :inet.port(socket) + :gen_tcp.close(socket) + port + end + + defp ensure_http_client_started do + _ = Application.ensure_all_started(:inets) + _ = Application.ensure_all_started(:ssl) + :ok + end + + defp await_port_exit(nil, _timeout), do: :ok + + defp await_port_exit(port, timeout) do + receive do + {^port, {:exit_status, _status}} -> :ok + {^port, {:data, _data}} -> await_port_exit(port, timeout) + after + timeout -> :ok + end + end + + defp port_os_pid(nil), do: nil + + defp port_os_pid(port) do + case Port.info(port, :os_pid) do + {:os_pid, pid} when is_integer(pid) -> pid + _other -> nil + end + end + + defp safe_close_port(nil), do: :ok + + defp safe_close_port(port) do + Port.close(port) + catch + :error, _reason -> :ok + end + + defp normalize_simple_reply("ok"), do: :ok + defp normalize_simple_reply(reply), do: reply + + defp atomize_map(map) when is_map(map) do + Enum.into(map, %{}, fn {key, value} -> + normalized_key = if is_binary(key), do: String.to_atom(key), else: key + normalized_value = if is_map(value), do: atomize_map(value), else: value + {normalized_key, normalized_value} + end) + end + + defp project_root do + Path.expand("../../..", __DIR__) + end +end \ No newline at end of file diff --git a/lib/bds/desktop/router.ex b/lib/bds/desktop/router.ex index 1742d2d..2ee0c49 100644 --- a/lib/bds/desktop/router.ex +++ b/lib/bds/desktop/router.ex @@ -11,7 +11,7 @@ defmodule BDS.Desktop.Router do signing_salt: "desktop-shell" plug :match - plug Desktop.Auth + plug :maybe_require_desktop_auth plug Plug.Static, at: "/assets", @@ -46,4 +46,12 @@ defmodule BDS.Desktop.Router do Application.get_env(:bds, :desktop)[:secret_key_base] || raise "missing :desktop secret_key_base configuration" end + + defp maybe_require_desktop_auth(conn, _opts) do + if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do + conn + else + Desktop.Auth.call(conn, []) + end + end end diff --git a/lib/bds/desktop/server.ex b/lib/bds/desktop/server.ex index a92c081..591d19f 100644 --- a/lib/bds/desktop/server.ex +++ b/lib/bds/desktop/server.ex @@ -19,7 +19,10 @@ defmodule BDS.Desktop.Server do end def port do - Application.get_env(:bds, :desktop)[:port] || 4010 + case System.get_env("BDS_DESKTOP_PORT") do + value when is_binary(value) -> String.to_integer(value) + _other -> Application.get_env(:bds, :desktop)[:port] || 4010 + end end @impl true diff --git a/lib/bds/desktop/shell_controller.ex b/lib/bds/desktop/shell_controller.ex index 3e16646..20bea1d 100644 --- a/lib/bds/desktop/shell_controller.ex +++ b/lib/bds/desktop/shell_controller.ex @@ -2,6 +2,6 @@ defmodule BDS.Desktop.ShellController do @moduledoc false def index_html do - File.read!(Application.app_dir(:bds, ["priv", "ui", "index.html"])) + BDS.UI.ShellPage.render() end end diff --git a/lib/bds/ui/shell_page.ex b/lib/bds/ui/shell_page.ex index b5be7c8..5897b6f 100644 --- a/lib/bds/ui/shell_page.ex +++ b/lib/bds/ui/shell_page.ex @@ -7,26 +7,7 @@ defmodule BDS.UI.ShellPage do alias BDS.UI.Workbench def render do - bootstrap = - %{ - registry: %{ - sidebar_views: Registry.sidebar_views(), - editor_routes: Registry.editor_routes(), - default_sidebar_view: Registry.default_sidebar_view() - }, - menu_groups: MenuBar.default_groups(), - session: Session.serialize(Workbench.new(panel_visible: true)), - status: %{ - post_count: 12, - media_count: 34, - theme_badge: "zinc", - ui_language: "en", - offline_mode: true, - running_task_message: "Building starter shell", - running_task_overflow: 1, - git_badge_count: 7 - } - } + bootstrap = bootstrap() [ "", @@ -34,29 +15,215 @@ defmodule BDS.UI.ShellPage do "", " ", " ", - " bDS Shell", - " ", + " Blogging Desktop Server", + " ", "", "", - "
", - "
", - "
", - " ", - "
", - "
", - "
", - "
", - "
", - "
", - "
", - " ", - "
", + "
", + "
", + "
", + " ", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + " ", + "
", + "
", + "
", "
", " ", - " ", + " ", "", "" ] |> Enum.join("\n") end + + defp bootstrap do + workbench = Workbench.new(panel_visible: true, assistant_sidebar_visible: true) + + %{ + title: Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server", + registry: %{ + sidebar_views: Enum.map(Registry.sidebar_views(), &encode_sidebar_view/1), + editor_routes: Enum.map(Registry.editor_routes(), &encode_editor_route/1), + default_sidebar_view: Atom.to_string(Registry.default_sidebar_view()) + }, + menu_groups: Enum.map(MenuBar.default_groups(), &encode_menu_group/1), + session: Session.serialize(workbench), + content: %{ + sidebar: sidebar_content(), + dashboard: dashboard_content(), + assistant_cards: assistant_cards(), + editor_meta: editor_meta() + }, + status: + Workbench.status_bar(workbench, + post_count: 42, + media_count: 18, + theme_badge: "desktop-shell", + ui_language: "en", + offline_mode: true, + running_task_message: "Desktop shell ready", + running_task_overflow: 0, + git_badge_count: 3 + ) + } + end + + defp encode_sidebar_view(view) do + %{ + id: Atom.to_string(view.id), + label: normalize_view_label(view.id, view.label), + activity_group: Atom.to_string(view.activity_group), + editor_route: Atom.to_string(view.editor_route), + entity_tab: Map.get(view, :entity_tab, false), + singleton: Map.get(view, :singleton, false) + } + end + + defp encode_editor_route(route) do + %{ + id: Atom.to_string(route.id), + singleton: route.singleton, + entity_tab: route.entity_tab, + title: route.title + } + end + + defp encode_menu_group(group) do + %{ + id: Atom.to_string(group.id), + label: humanize(group.id), + items: + Enum.map(group.items, fn item -> + %{id: Atom.to_string(item.id), label: humanize(item.id)} + end) + } + end + + defp sidebar_content do + %{ + "posts" => %{ + title: "Posts", + subtitle: "Drafts, published entries, and archive history", + sections: [ + %{ + title: "Drafts", + items: [ + %{id: "post-welcome", title: "Welcome to bDS2", meta: "Updated today", badge: "draft", route: "post"}, + %{id: "post-launch-plan", title: "Launch plan", meta: "Updated yesterday", badge: "draft", route: "post"} + ] + }, + %{ + title: "Published", + items: [ + %{id: "post-roadmap", title: "Roadmap", meta: "Published Feb 10, 2026", badge: "2 langs", route: "post"} + ] + }, + %{ + title: "Archived", + items: [ + %{id: "post-retrospective", title: "Retrospective", meta: "Archived Jan 12, 2026", badge: "archive", route: "post"} + ] + } + ] + }, + "pages" => simple_list_view("Pages", "Standalone pages", [ + %{id: "page-about", title: "About", meta: "Static page", route: "post"}, + %{id: "page-contact", title: "Contact", meta: "Static page", route: "post"} + ]), + "media" => simple_list_view("Media", "Images and files", [ + %{id: "media-hero", title: "hero-shot.jpg", meta: "Image asset", route: "media"}, + %{id: "media-banner", title: "launch-banner.png", meta: "Image asset", route: "media"} + ]), + "scripts" => simple_list_view("Scripts", "Automation helpers", [ + %{id: "script-import", title: "Import posts", meta: "Lua utility", route: "scripts"}, + %{id: "script-sync", title: "Sync tags", meta: "Lua utility", route: "scripts"} + ]), + "templates" => simple_list_view("Templates", "Site rendering", [ + %{id: "template-post", title: "post.liquid", meta: "Post template", route: "templates"}, + %{id: "template-list", title: "list.liquid", meta: "List template", route: "templates"} + ]), + "tags" => simple_list_view("Tags", "Tag management", [ + %{id: "tag-launch", title: "launch", meta: "12 posts", route: "tags"}, + %{id: "tag-writing", title: "writing", meta: "7 posts", route: "tags"} + ]), + "chat" => simple_list_view("Chat", "AI conversations", [ + %{id: "chat-planning", title: "Planning session", meta: "Offline gated", route: "chat"}, + %{id: "chat-translation", title: "Translation QA", meta: "Offline gated", route: "chat"} + ]), + "import" => simple_list_view("Import", "Import definitions", [ + %{id: "import-wordpress", title: "WordPress import", meta: "Ready", route: "import"} + ]), + "git" => simple_list_view("Git", "Working tree and history", [ + %{id: "git-working-tree", title: "Working tree", meta: "3 changed files", route: "git_diff"} + ]), + "settings" => simple_list_view("Settings", "Project and publishing", [ + %{id: "settings-project", title: "Project", meta: "Paths and defaults", route: "settings"}, + %{id: "settings-ai", title: "AI", meta: "Offline controls", route: "settings"} + ]) + } + end + + defp simple_list_view(title, subtitle, items) do + %{title: title, subtitle: subtitle, sections: [%{title: title, items: items}]} + end + + defp dashboard_content do + %{ + title: "Dashboard", + subtitle: "Desktop workbench shell wired through Elixir", + summary_cards: [ + %{label: "Posts", value: "42", detail: "Across draft, published, and archive"}, + %{label: "Media", value: "18", detail: "Images and documents indexed"}, + %{label: "Tasks", value: "1", detail: "One background action visible in the status bar"} + ], + checklist: [ + "Native menu groups mirror the old application shell", + "Sidebar, tabs, panel, and assistant panes are inspectable DOM regions", + "Automation can boot the shell in a separate process and capture screenshots" + ] + } + end + + defp assistant_cards do + [ + %{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."}, + %{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."}, + %{label: "Desktop Runtime", text: "The app window is now served from the Elixir shell renderer."} + ] + end + + defp editor_meta do + %{ + dashboard: [ + %{label: "Status", value: "Workbench shell ready"}, + %{label: "Mode", value: "Offline"}, + %{label: "Main Language", value: "en"} + ] + } + end + + defp normalize_view_label(:chat, _label), do: "Chat" + defp normalize_view_label(:git, _label), do: "Git" + defp normalize_view_label(_id, label), do: label + + defp humanize(value) when is_atom(value), do: value |> Atom.to_string() |> humanize() + + defp humanize(value) when is_binary(value) do + value + |> String.replace("_", " ") + |> String.split(" ") + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end end \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c0e9738 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "bds-ui-automation", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bds-ui-automation", + "devDependencies": { + "playwright": "^1.54.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..df0d772 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "bds-ui-automation", + "private": true, + "devDependencies": { + "playwright": "^1.54.1" + }, + "scripts": { + "ui:install": "playwright install chromium" + } +} \ No newline at end of file diff --git a/priv/ui/app.css b/priv/ui/app.css index 5551d56..283808e 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -1,24 +1,20 @@ :root { - --bg: #f2efe8; - --paper: #f8f5ef; - --ink: #1e1b18; - --muted: #6f665d; - --line: rgba(34, 28, 23, 0.12); - --line-strong: rgba(34, 28, 23, 0.22); - --accent: #b4472f; - --accent-soft: rgba(180, 71, 47, 0.12); - --activity: #171411; - --activity-ink: #f4efe7; - --shadow: 0 20px 60px rgba(41, 30, 20, 0.12); - --radius: 18px; + --bg: #11161d; + --panel: #171d25; + --panel-2: #1d2530; + --panel-3: #202a36; + --ink: #edf2f7; + --muted: #95a2b3; + --line: rgba(173, 189, 204, 0.14); + --accent: #4fb3ff; + --accent-soft: rgba(79, 179, 255, 0.16); + --success: #6ecb8b; + --status: #10151b; + --shadow: 0 18px 48px rgba(0, 0, 0, 0.32); --sidebar-width: 280px; --assistant-width: 360px; - --panel-height: 168px; - --title-height: 56px; - --status-height: 38px; - --activity-width: 60px; - color-scheme: light; - font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + color-scheme: dark; + font-family: "Avenir Next", "Segoe UI", sans-serif; } * { @@ -28,674 +24,504 @@ html, body { margin: 0; - min-height: 100%; + width: 100%; + height: 100%; background: - radial-gradient(circle at top left, rgba(202, 156, 86, 0.18), transparent 28%), - radial-gradient(circle at top right, rgba(180, 71, 47, 0.12), transparent 26%), - linear-gradient(180deg, #f7f2e8 0%, #efe8dc 100%); + radial-gradient(circle at top left, rgba(79, 179, 255, 0.12), transparent 26%), + radial-gradient(circle at bottom right, rgba(110, 203, 139, 0.12), transparent 24%), + linear-gradient(180deg, #0b1016 0%, #121922 100%); color: var(--ink); } body { - min-height: 100vh; - padding: 18px; -} - -button, -select { - font: inherit; + overflow: hidden; } button { - :root { - color-scheme: dark; - --vscode-foreground: #cccccc; - --vscode-descriptionForeground: #8c8c8c; - --vscode-editor-background: #1e1e1e; - --vscode-editorGroupHeader-tabsBackground: #252526; - --vscode-editorGroupHeader-tabsBorder: #1f1f1f; - --vscode-panel-border: #2b2b2b; - --vscode-sideBar-background: #181818; - --vscode-sideBar-border: #2b2b2b; - --vscode-sideBar-foreground: #cccccc; - --vscode-statusBar-background: #007acc; - --vscode-statusBar-foreground: #ffffff; - --vscode-activityBar-background: #181818; - --vscode-activityBar-foreground: #cccccc; - --vscode-titleBar-activeForeground: #cccccc; - --vscode-list-hoverBackground: #2a2d2e; - --vscode-list-activeSelectionBackground: #37373d; - --vscode-tab-inactiveBackground: #2d2d2d; - --vscode-tab-activeBackground: #1e1e1e; - --vscode-tab-activeBorderTop: #0078d4; - --vscode-tab-border: #252526; - --vscode-menu-background: #252526; - --vscode-menu-selectionBackground: #094771; - --vscode-menu-border: #454545; - --vscode-widget-shadow: 0 10px 24px rgba(0, 0, 0, 0.35); - --sidebar-width: 320px; - --assistant-width: 336px; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - } - - * { - box-sizing: border-box; - } - - html, - body { - width: 100%; - height: 100%; - margin: 0; - background: var(--vscode-editor-background); - color: var(--vscode-foreground); - } - - body { - overflow: hidden; - } - - button { - font: inherit; - } - - .app { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - background-color: var(--vscode-editor-background); - } - - .window-titlebar { - position: relative; - height: 34px; - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--vscode-editorGroupHeader-tabsBackground); - border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder); - flex-shrink: 0; - -webkit-app-region: drag; - app-region: drag; - } - - .window-titlebar-menu-bar, - .window-titlebar-actions { - display: flex; - align-items: center; - height: 100%; - gap: 2px; - padding: 0 6px; - -webkit-app-region: no-drag; - app-region: no-drag; - } - - .window-titlebar-title { - position: absolute; - left: 50%; - transform: translateX(-50%); - font-size: 12px; - color: var(--vscode-titleBar-activeForeground); - pointer-events: none; - } - - .window-titlebar-menu-button, - .window-titlebar-action-button { - height: 24px; - min-width: 24px; - border: none; - background: transparent; - color: var(--vscode-titleBar-activeForeground); - border-radius: 4px; - cursor: pointer; - } - - .window-titlebar-menu-button:hover, - .window-titlebar-action-button:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .window-titlebar-sidebar-icon, - .window-titlebar-panel-icon { - width: 14px; - height: 14px; - border: 1.5px solid currentColor; - border-radius: 2px; - display: block; - position: relative; - } - - .window-titlebar-sidebar-icon::before { - content: ""; - position: absolute; - left: 33%; - top: 0; - bottom: 0; - width: 1.5px; - background: currentColor; - } - - .window-titlebar-sidebar-pane { - position: absolute; - left: 0; - top: 0; - width: 33%; - height: 100%; - background: currentColor; - } - - .window-titlebar-panel-icon::before { - content: ""; - position: absolute; - left: 0; - right: 0; - top: 66%; - height: 1.5px; - background: currentColor; - } - - .app-main { - flex: 1; - display: flex; - overflow: hidden; - min-height: 0; - } - - .activity-bar { - width: 48px; - height: 100%; - background-color: var(--vscode-activityBar-background); - display: flex; - flex-direction: column; - justify-content: space-between; - border-right: 1px solid var(--vscode-panel-border); - } - - .activity-bar-top, - .activity-bar-bottom { - display: flex; - flex-direction: column; - align-items: center; - padding: 4px 0; - } - - .activity-bar-item { - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: var(--vscode-activityBar-foreground); - opacity: 0.6; - cursor: pointer; - position: relative; - } - - .activity-bar-item.active { - opacity: 1; - } - - .activity-bar-item.active::before { - content: ""; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 2px; - background-color: var(--vscode-activityBar-foreground); - } - - .sidebar-shell, - .assistant-sidebar-shell { - display: flex; - min-width: 0; - } - - .sidebar-shell { - width: var(--sidebar-width); - } - - .assistant-sidebar-shell { - width: var(--assistant-width); - } - - .sidebar, - .assistant-sidebar { - width: 100%; - height: 100%; - background-color: var(--vscode-sideBar-background); - display: flex; - flex-direction: column; - overflow: hidden; - } - - .sidebar { - border-right: 1px solid var(--vscode-sideBar-border); - } - - .assistant-sidebar { - border-left: 1px solid var(--vscode-sideBar-border); - } - - .sidebar-header, - .assistant-header, - .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--vscode-sideBar-foreground); - border-bottom: 1px solid var(--vscode-panel-border); - } - - .sidebar-content, - .assistant-content, - .panel-content { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - } - - .sidebar-item, - .assistant-card, - .panel-entry { - display: flex; - flex-direction: column; - gap: 3px; - padding: 8px 12px; - } - - .sidebar-item:hover, - .assistant-card:hover, - .panel-entry:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .sidebar-item.active { - background-color: var(--vscode-list-activeSelectionBackground); - border-left: 2px solid var(--vscode-tab-activeBorderTop); - padding-left: 10px; - } - - .sidebar-item strong, - .assistant-card strong, - .panel-entry strong { - font-size: 13px; - color: var(--vscode-sideBar-foreground); - } - - .sidebar-item span, - .assistant-card span, - .panel-entry span, - .editor-subtitle { - font-size: 12px; - color: var(--vscode-descriptionForeground); - } - - .app-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - min-width: 0; - } - - .tab-bar { - display: flex; - align-items: center; - background-color: var(--vscode-editorGroupHeader-tabsBackground); - border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder); - height: 35px; - flex-shrink: 0; - } - - .tab-bar-tabs { - display: flex; - align-items: center; - height: 100%; - overflow-x: auto; - flex: 1; - } - - .tab-bar-tabs::-webkit-scrollbar { - display: none; - } - - .tab { - display: flex; - align-items: center; - gap: 6px; - padding: 0 10px; - height: 100%; - min-width: 110px; - max-width: 180px; - cursor: pointer; - background-color: var(--vscode-tab-inactiveBackground); - border-right: 1px solid var(--vscode-tab-border); - color: #969696; - font-size: 13px; - position: relative; - flex-shrink: 0; - } - - .tab.active { - background-color: var(--vscode-tab-activeBackground); - color: #ffffff; - } - - .tab.active::after { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 1px; - background-color: var(--vscode-tab-activeBorderTop); - } - - .tab.transient .tab-title { - font-style: italic; - } - - .tab-title { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .tab-close, - .tab-dirty-indicator { - width: 18px; - text-align: center; - color: inherit; - } - - .editor-shell { - flex: 1; - background: var(--vscode-editor-background); - overflow: auto; - } - - .editor-frame { - min-height: 100%; - display: grid; - grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.75fr); - } - - .editor-main { - padding: 24px; - } - - .editor-meta { - border-left: 1px solid var(--vscode-panel-border); - background: #181818; - padding: 18px 16px; - } - - .editor-title { - margin: 0; - font-size: 24px; - font-weight: 600; - } - - .editor-toolbar { - display: flex; - gap: 8px; - margin: 16px 0 20px; - } - - .editor-toolbar button { - border: 1px solid var(--vscode-panel-border); - background: #252526; - color: var(--vscode-foreground); - padding: 6px 10px; - border-radius: 4px; - } - - .editor-paragraph { - max-width: 72ch; - line-height: 1.6; - color: #d4d4d4; - } - - .panel-shell { - height: 220px; - background: #181818; - border-top: 1px solid var(--vscode-panel-border); - display: flex; - flex-direction: column; - } - - .panel-tabs { - display: flex; - gap: 2px; - } - - .panel-tab { - background: transparent; - border: none; - color: var(--vscode-descriptionForeground); - padding: 8px 10px; - cursor: pointer; - } - - .panel-tab.active { - color: var(--vscode-foreground); - } - - .status-bar { - height: 22px; - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--vscode-statusBar-background); - color: var(--vscode-statusBar-foreground); - font-size: 12px; - padding: 0 8px; - flex-shrink: 0; - } - - .status-bar-left, - .status-bar-right { - display: flex; - align-items: center; - gap: 4px; - } - - .status-bar-item { - display: flex; - align-items: center; - padding: 0 8px; - height: 100%; - } - - .resizable-panel-divider { - width: 4px; - background: transparent; - cursor: col-resize; - flex-shrink: 0; - } - - .resizable-panel-divider:hover, - .resizable-panel-divider.is-dragging { - background: rgba(0, 120, 212, 0.35); - } - - .is-hidden { - display: none !important; - } - - @media (max-width: 1200px) { - .assistant-sidebar-shell { - display: none; - } - } - - @media (max-width: 900px) { - .sidebar-shell { - display: none; - } - - .editor-frame { - grid-template-columns: 1fr; - } - - .editor-meta { - border-left: none; - border-top: 1px solid var(--vscode-panel-border); - } - } -[data-role="resize-handle"] { - background: linear-gradient(180deg, transparent, rgba(120, 93, 71, 0.3), transparent); - cursor: col-resize; + font: inherit; } -[data-role="resize-handle"][data-target="sidebar"] { - grid-area: sidebar-handle; +.app { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: rgba(17, 22, 29, 0.96); } -[data-role="resize-handle"][data-target="assistant"] { - grid-area: assistant-handle; +.window-titlebar { + height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--panel); + border-bottom: 1px solid var(--line); + -webkit-app-region: drag; + app-region: drag; } -#bds-shell-app[data-sidebar-visible="false"] [data-role="resize-handle"][data-target="sidebar"], -#bds-shell-app[data-assistant-visible="false"] [data-role="resize-handle"][data-target="assistant"] { +.window-titlebar-menu-bar, +.window-titlebar-actions { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + -webkit-app-region: no-drag; + app-region: no-drag; +} + +.window-titlebar-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--muted); +} + +.window-titlebar-menu-button, +.window-titlebar-action-button, +.panel-tab, +.tab, +.sidebar-item, +.activity-bar-item { + border: none; + cursor: pointer; +} + +.window-titlebar-menu-button, +.window-titlebar-action-button { + min-height: 26px; + padding: 0 10px; + border-radius: 8px; + background: transparent; + color: var(--muted); +} + +.window-titlebar-menu-button:hover, +.window-titlebar-action-button:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--ink); +} + +.app-main { + flex: 1; + display: flex; + min-height: 0; +} + +.activity-bar { + width: 56px; + display: flex; + flex-direction: column; + justify-content: space-between; + background: #0d1319; + border-right: 1px solid var(--line); +} + +.activity-bar-group { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; + gap: 4px; +} + +.activity-bar-item { + width: 42px; + height: 42px; + display: grid; + place-items: center; + background: transparent; + color: var(--muted); + border-radius: 12px; + position: relative; +} + +.activity-bar-item:hover, +.activity-bar-item.active { + color: var(--ink); + background: rgba(255, 255, 255, 0.06); +} + +.activity-bar-item.active::before { + content: ""; + position: absolute; + left: -7px; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 999px; + background: var(--accent); +} + +.activity-bar-glyph { + font-size: 12px; + font-weight: 700; +} + +.sidebar-shell, +.assistant-sidebar-shell { + display: flex; + min-width: 0; +} + +.sidebar-shell { + width: var(--sidebar-width); +} + +.assistant-sidebar-shell { + width: var(--assistant-width); +} + +.sidebar, +.assistant-sidebar, +.panel-shell, +.editor-shell { + background: var(--panel); +} + +.sidebar, +.assistant-sidebar { + width: 100%; + display: flex; + flex-direction: column; + min-width: 0; +} + +.sidebar { + border-right: 1px solid var(--line); +} + +.assistant-sidebar { + border-left: 1px solid var(--line); +} + +.resizable-panel-divider { + width: 1px; + background: var(--line); +} + +.sidebar-shell.is-hidden, +.assistant-sidebar-shell.is-hidden { + width: 0; + overflow: hidden; +} + +.sidebar-shell.is-hidden .resizable-panel-divider, +.assistant-sidebar-shell.is-hidden .resizable-panel-divider { display: none; } -.sidebar-header, -.assistant-header { - padding: 18px 18px 10px; +.app-content { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.tab-bar { + height: 40px; + display: flex; + align-items: center; + background: var(--panel-2); border-bottom: 1px solid var(--line); - display: grid; + padding: 0 10px; +} + +.tab-bar-tabs { + display: flex; + align-items: stretch; gap: 6px; + overflow-x: auto; } -.sidebar-header strong, -.assistant-header strong { - font-size: 20px; -} - -.sidebar-placeholder, -.assistant-placeholder { - padding: 18px; - display: grid; - gap: 16px; -} - -.placeholder-note { +.tab-bar-empty { + font-size: 12px; color: var(--muted); - line-height: 1.5; } -[data-region="status-bar"] { - grid-area: status; +.tab { + min-width: 140px; + max-width: 200px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: var(--panel); + color: var(--muted); + border-radius: 10px 10px 0 0; +} + +.tab.active { + color: var(--ink); + background: #121922; + box-shadow: inset 0 2px 0 var(--accent); +} + +.tab.transient .tab-title { + font-style: italic; +} + +.tab-title, +.sidebar-item strong, +.sidebar-item span, +.status-bar-item { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tab-close { + font-size: 11px; + color: var(--muted); +} + +.editor-shell { + flex: 1; + min-height: 0; + padding: 22px; + overflow: auto; +} + +.editor-frame { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 20px; +} + +.editor-main, +.editor-meta-card, +.assistant-card, +.panel-entry, +.dashboard-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--panel-2); + border: 1px solid var(--line); + border-radius: 18px; + box-shadow: var(--shadow); +} + +.editor-main { + padding: 24px; +} + +.editor-kicker, +.sidebar-eyebrow, +.sidebar-section-header, +.assistant-header, +.panel-header, +.sidebar-subtitle { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.editor-title { + margin: 10px 0 8px; + font-size: 34px; + line-height: 1.1; +} + +.editor-subtitle { + margin: 0 0 22px; + color: var(--muted); +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.dashboard-card { + padding: 18px; +} + +.dashboard-card span, +.assistant-card span, +.panel-entry span, +.editor-meta-card span, +.sidebar-item span, +.sidebar-subtitle { + color: var(--muted); +} + +.dashboard-card strong { + display: block; + margin: 8px 0; + font-size: 32px; +} + +.editor-section { + margin-top: 22px; +} + +.editor-section ul { + margin: 12px 0 0; + padding-left: 18px; +} + +.editor-toolbar { + display: flex; + gap: 10px; +} + +.editor-toolbar button { + padding: 9px 14px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-3); + color: var(--ink); +} + +.editor-meta { + display: flex; + flex-direction: column; + gap: 12px; +} + +.editor-meta-card, +.assistant-card, +.panel-entry { + padding: 16px; +} + +.sidebar-header, +.assistant-header, +.panel-header { display: flex; justify-content: space-between; - align-items: center; - gap: 16px; - padding: 0 14px; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--line); +} + +.sidebar-content, +.assistant-content, +.panel-content { + flex: 1; + overflow: auto; + min-height: 0; +} + +.sidebar-section { + padding: 14px 14px 0; +} + +.sidebar-section-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.sidebar-section-items { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sidebar-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + padding: 12px; + border-radius: 14px; + background: var(--panel-2); + color: var(--ink); +} + +.sidebar-item:hover, +.panel-tab:hover { + background: var(--accent-soft); +} + +.sidebar-item.active { + outline: 1px solid var(--accent); + background: rgba(79, 179, 255, 0.12); +} + +.sidebar-badge { + padding: 2px 8px; + border-radius: 999px; + background: rgba(110, 203, 139, 0.16); + color: var(--success); + font-size: 11px; +} + +.panel-shell { + min-height: 160px; + max-height: 160px; border-top: 1px solid var(--line); - background: rgba(24, 19, 15, 0.92); - color: rgba(255, 248, 240, 0.88); - font-size: 13px; } -.status-pill, -.status-chip { - display: inline-flex; +.panel-shell.is-hidden { + display: none; +} + +.panel-tabs { + display: flex; + gap: 8px; +} + +.panel-tab { + padding: 8px 10px; + border-radius: 999px; + background: transparent; + color: var(--muted); +} + +.panel-tab.active { + background: var(--accent-soft); + color: var(--ink); +} + +.assistant-content { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; +} + +.status-bar { + height: 34px; + display: flex; align-items: center; - gap: 6px; - padding: 6px 10px; + justify-content: space-between; + gap: 12px; + padding: 0 12px; + background: var(--status); + border-top: 1px solid var(--line); +} + +.status-bar-left, +.status-bar-right { + display: flex; + gap: 8px; + min-width: 0; +} + +.status-bar-item { + max-width: 180px; + padding: 4px 8px; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); -} - -.status-post[data-status="draft"]::before, -.status-post[data-status="published"]::before, -.status-post[data-status="archived"]::before { - content: ""; - width: 8px; - height: 8px; - border-radius: 999px; - background: #d6a347; -} - -.status-post[data-status="published"]::before { - background: #5aa86b; -} - -.status-post[data-status="archived"]::before { - background: #8b7c72; -} - -.status-right select, -.status-left select { - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.08); - color: inherit; - padding: 6px 10px; -} - -.offline-toggle[data-active="true"] { - background: rgba(180, 71, 47, 0.26); - border-color: rgba(255, 255, 255, 0.08); - color: #fff5ec; -} - -kbd { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - padding: 2px 6px; - border-radius: 7px; - background: rgba(22, 18, 14, 0.08); - border: 1px solid rgba(22, 18, 14, 0.12); + background: rgba(255, 255, 255, 0.06); + font-size: 11px; } @media (max-width: 1100px) { - #bds-shell-app, - #bds-shell-app[data-sidebar-visible="false"], - #bds-shell-app[data-assistant-visible="false"], - #bds-shell-app[data-sidebar-visible="false"][data-assistant-visible="false"] { - grid-template-columns: var(--activity-width) minmax(0, 1fr); - grid-template-rows: var(--title-height) auto minmax(180px, auto) minmax(320px, 1fr) auto var(--status-height); - grid-template-areas: - "title title" - "activity activity" - "sidebar sidebar" - "content content" - "assistant assistant" - "status status"; - height: auto; + .editor-frame { + grid-template-columns: 1fr; } - [data-role="resize-handle"] { - display: none !important; + .assistant-sidebar-shell { + display: none; } - [data-region="activity-bar"] { - flex-direction: row; - justify-content: space-between; - gap: 12px; - overflow-x: auto; + .dashboard-grid { + grid-template-columns: 1fr; } - - .activity-stack { - display: flex; - } - - [data-region="status-bar"] { - flex-direction: column; - align-items: flex-start; - padding: 10px 14px; - } -} +} \ No newline at end of file diff --git a/priv/ui/app.js b/priv/ui/app.js index 2d0d511..980b8a8 100644 --- a/priv/ui/app.js +++ b/priv/ui/app.js @@ -1,18 +1,26 @@ -const shell = document.getElementById("bds-shell-app"); -const bootstrap = JSON.parse(document.getElementById("bds-shell-bootstrap").textContent); -const bootstrap = JSON.parse(document.getElementById('bds-bootstrap').textContent); +const root = document.getElementById("bds-shell-app"); +const bootstrapNode = document.getElementById("bds-shell-bootstrap"); +if (!root || !bootstrapNode) { + throw new Error("Missing shell bootstrap payload"); +} + +const bootstrap = JSON.parse(bootstrapNode.textContent); const state = { - ...bootstrap, + session: clone(bootstrap.session), + tabMeta: {}, }; -const root = document.getElementById('app'); +render(); function render() { - root.style.setProperty('--sidebar-width', state.sidebarVisible ? `${state.sidebarWidth}px` : '0px'); - root.style.setProperty('--assistant-width', state.assistantVisible ? `${state.assistantWidth}px` : '0px'); + root.style.setProperty("--sidebar-width", state.session.sidebar_visible ? `${state.session.sidebar_width}px` : "0px"); + root.style.setProperty( + "--assistant-width", + state.session.assistant_sidebar_visible ? `${state.session.assistant_sidebar_width}px` : "0px" + ); - renderMenuBar(); + renderTitlebar(); renderActivityBar(); renderSidebar(); renderTabs(); @@ -24,665 +32,460 @@ function render() { bindEvents(); } -function renderMenuBar() { - const menuBar = root.querySelector('.window-titlebar-menu-bar'); - menuBar.innerHTML = state.menuGroups - .map((group) => ``) - .join(''); +function renderTitlebar() { + root.querySelector(".window-titlebar").innerHTML = ` +
+ ${bootstrap.menu_groups + .map((group) => ``) + .join("")} +
+
${escapeHtml(bootstrap.title)}
+
+ + + +
+ `; } function renderActivityBar() { - const node = root.querySelector('.activity-bar'); - const top = state.sidebarViews.filter((view) => view.group === 'top'); - const bottom = state.sidebarViews.filter((view) => view.group === 'bottom'); + const top = sidebarViews().filter((view) => view.activity_group === "top"); + const bottom = sidebarViews().filter((view) => view.activity_group === "bottom"); - node.innerHTML = ` -
${top.map(renderActivityButton).join('')}
-
${bottom.map(renderActivityButton).join('')}
+ root.querySelector(".activity-bar").innerHTML = ` +
${top.map(renderActivityButton).join("")}
+
${bottom.map(renderActivityButton).join("")}
`; } function renderActivityButton(view) { - const active = state.sidebarVisible && state.activeView === view.id; - return ``; + const active = state.session.sidebar_visible && state.session.active_view === view.id; + return ` + + `; } function renderSidebar() { - const view = state.sidebarViews.find((entry) => entry.id === state.activeView) || state.sidebarViews[0]; - const node = root.querySelector('.sidebar'); + const view = currentSidebarView(); + const data = currentSidebarData(); - node.innerHTML = ` + root.querySelector(".sidebar").innerHTML = ` `; } +function renderSidebarItem(item, view) { + const tabRef = currentTabRef(); + const itemRoute = item.route || view.editor_route; + const tabId = tabIdForItem(item, itemRoute); + const active = tabRef && tabRef.type === itemRoute && tabRef.id === tabId; + + return ` + + `; +} + function renderTabs() { - const node = root.querySelector('.tab-bar'); - node.innerHTML = `
${state.tabs.map(renderTab).join('')}
`; -} + const tabs = state.session.tabs; + const node = root.querySelector(".tab-bar"); -function renderTab(tab) { - const active = tab.id === state.activeTabId; - const dirtyMarker = tab.dirty ? '' : '×'; - return ` -
- ${tab.title} - ${dirtyMarker} + if (tabs.length === 0) { + node.innerHTML = `
Dashboard
`; + return; + } + + node.innerHTML = ` +
+ ${tabs.map(renderTab).join("")}
`; } +function renderTab(tab) { + const active = sameTab(tab, currentTabRef()); + const meta = tabMetadata(tab); + + return ` + + `; +} + function renderEditor() { - const node = root.querySelector('.editor-shell'); - const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId) || state.tabs[0]; + const route = currentRoute(); + const meta = currentEditorMeta(); + const node = root.querySelector(".editor-shell"); node.innerHTML = `
-

${activeTab.title}

-
${activeTab.kind} editor surface routed through the desktop shell
-
- - - -
-

This desktop shell now runs inside an Elixir Desktop window instead of a standalone browser page. The frame, activity bar, tab strip, status bar and resizable side areas match the old application structure so the next slice can replace placeholders with the real editors and lists.

-

Preview tabs, dirty state and shell routing still come from the shared Elixir workbench modules. The runtime boundary is now a desktop window backed by a local shell server.

+
${escapeHtml(routeLabel(route))}
+

${escapeHtml(editorTitle())}

+

${escapeHtml(editorSubtitle(route))}

+ ${renderEditorBody(route)}
`; } -function renderPanel() { - const node = root.querySelector('.panel-shell'); - const tabs = ['problems', 'search', 'tasks']; +function renderEditorBody(route) { + if (route === "dashboard") { + const dashboard = bootstrap.content.dashboard; + return ` +
+ ${dashboard.summary_cards + .map( + (card) => ` +
+ ${escapeHtml(card.label)} + ${escapeHtml(card.value)} +

${escapeHtml(card.detail)}

+
+ ` + ) + .join("")} +
+
+

Workbench Notes

+
    + ${dashboard.checklist.map((entry) => `
  • ${escapeHtml(entry)}
  • `).join("")} +
+
+ `; + } - node.innerHTML = ` + const active = activeItem(); + return ` +
+ + + +
+
+

${escapeHtml(active?.title || routeLabel(route))}

+

${escapeHtml(active?.meta || "Desktop workbench content routed through the Elixir shell.")}

+
+ `; +} + +function renderPanel() { + const tabs = [state.session.panel.active_tab, "output", "git_log"].filter(uniqueValue); + + root.querySelector(".panel-shell").innerHTML = `
${tabs - .map((tab) => ``) - .join('')} + .map( + (tab) => ` + + ` + ) + .join("")}
- ${state.panelVisible ? 'Visible' : 'Hidden'} + ${state.session.panel.visible ? "Visible" : "Hidden"}
- ${state.panelTab} - Shared bottom panel host for problems, search, tasks and later runtime details. + ${escapeHtml(routeLabel(state.session.panel.active_tab))} + The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.
`; } function renderAssistant() { - const node = root.querySelector('.assistant-sidebar'); - node.innerHTML = ` + root.querySelector(".assistant-sidebar").innerHTML = `
- Assistant + Assistant Project context
-
- Next shell work - Swap sidebar placeholders with real project data views. -
-
- Offline gate - Automatic AI remains gated by airplane mode. -
-
- Desktop runtime - Window, menu bar and launch path now come from Elixir Desktop. -
+ ${bootstrap.content.assistant_cards + .map( + (card) => ` +
+ ${escapeHtml(card.label)} + ${escapeHtml(card.text)} +
+ ` + ) + .join("")}
`; } function renderStatusBar() { - const node = root.querySelector('.status-bar'); - node.innerHTML = ` -
${state.statusBar.left.map(renderStatusItem).join('')}
-
${state.statusBar.right.map(renderStatusItem).join('')}
- `; -} + const status = bootstrap.status; -function renderStatusItem(item) { - return `
${item.label}
`; + root.querySelector(".status-bar").innerHTML = ` +
+ ${escapeHtml(status.left.running_task_message || "Idle")} +
+
+ ${escapeHtml(status.right.post_count)} + ${escapeHtml(status.right.media_count)} + ${escapeHtml(status.right.theme_badge)} + ${status.right.offline_mode ? "Offline" : "Online"} + ${escapeHtml(status.right.ui_language.toUpperCase())} + ${escapeHtml(status.right.brand)} +
+ `; } function applyVisibility() { - root.querySelector('.sidebar-shell').classList.toggle('is-hidden', !state.sidebarVisible); - root.querySelector('.assistant-sidebar-shell').classList.toggle('is-hidden', !state.assistantVisible); - root.querySelector('.panel-shell').classList.toggle('is-hidden', !state.panelVisible); + root.querySelector(".sidebar-shell").classList.toggle("is-hidden", !state.session.sidebar_visible); + root.querySelector(".assistant-sidebar-shell").classList.toggle("is-hidden", !state.session.assistant_sidebar_visible); + root.querySelector(".panel-shell").classList.toggle("is-hidden", !state.session.panel.visible); } function bindEvents() { - root.querySelectorAll('[data-activity]').forEach((button) => { + root.querySelectorAll("[data-command]").forEach((button) => { button.onclick = () => { - const next = button.getAttribute('data-activity'); - if (state.activeView === next && state.sidebarVisible) { - state.sidebarVisible = false; + const command = button.dataset.command; + if (command === "toggle-sidebar") { + state.session.sidebar_visible = !state.session.sidebar_visible; + } + if (command === "toggle-panel") { + state.session.panel.visible = !state.session.panel.visible; + } + if (command === "toggle-assistant") { + state.session.assistant_sidebar_visible = !state.session.assistant_sidebar_visible; + } + render(); + }; + }); + + root.querySelectorAll("[data-activity]").forEach((button) => { + button.onclick = () => { + const next = button.dataset.activity; + if (state.session.active_view === next && state.session.sidebar_visible) { + state.session.sidebar_visible = false; } else { - state.activeView = next; - state.sidebarVisible = true; + state.session.active_view = next; + state.session.sidebar_visible = true; } render(); }; }); - root.querySelectorAll('[data-open-tab]').forEach((button) => { + root.querySelectorAll("[data-open-tab]").forEach((button) => { button.onclick = () => { - const tabId = button.getAttribute('data-open-tab'); - let tab = state.tabs.find((entry) => entry.id === tabId); - if (!tab) { - tab = { - id: tabId, - title: tabId.split(':').pop(), - kind: state.activeView, - pinned: false, - dirty: false, - }; - state.tabs.push(tab); - } - state.activeTabId = tab.id; - render(); + openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, true); + }; + + button.ondblclick = () => { + openTab(button.dataset.openRoute, button.dataset.openTab, button.dataset.openTitle, false); }; }); - root.querySelectorAll('[data-tab]').forEach((tab) => { - tab.onclick = () => { - state.activeTabId = tab.getAttribute('data-tab'); - render(); - }; - }); - - root.querySelectorAll('[data-command]').forEach((button) => { + root.querySelectorAll("[data-tab-id]").forEach((button) => { button.onclick = () => { - const command = button.getAttribute('data-command'); - if (command === 'toggle-sidebar') { - state.sidebarVisible = !state.sidebarVisible; - } - if (command === 'toggle-panel') { - state.panelVisible = !state.panelVisible; - } + state.session.active_tab = { type: button.dataset.tabType, id: button.dataset.tabId }; render(); }; }); - root.querySelectorAll('[data-panel-tab]').forEach((button) => { + root.querySelectorAll("[data-panel-tab]").forEach((button) => { button.onclick = () => { - state.panelTab = button.getAttribute('data-panel-tab'); - state.panelVisible = true; + state.session.panel.active_tab = button.dataset.panelTab; + state.session.panel.visible = true; render(); }; }); - - root.querySelectorAll('[data-resize]').forEach((handle) => { - handle.onpointerdown = (event) => { - const target = handle.getAttribute('data-resize'); - const startX = event.clientX; - const startWidth = target === 'sidebar' ? state.sidebarWidth : state.assistantWidth; - handle.classList.add('is-dragging'); - - const onMove = (moveEvent) => { - const delta = moveEvent.clientX - startX; - if (target === 'sidebar') { - state.sidebarWidth = clamp(startWidth + delta, 220, 520); - } else { - state.assistantWidth = clamp(startWidth - delta, 280, 520); - } - render(); - }; - - const onUp = () => { - handle.classList.remove('is-dragging'); - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp); - }; - - window.addEventListener('pointermove', onMove); - window.addEventListener('pointerup', onUp); - }; - }); } -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} +function openTab(type, id, title, transient) { + const existingIndex = state.session.tabs.findIndex((tab) => tab.type === type && tab.id === id); -render(); -function renderStatusBar() { - const active = activeTab(); - const dirty = active && rootState.dirty_tabs.some(entry => sameTab(entry, active)); - const postStatus = active && active.type === "post" ? (dirty ? "draft" : "published") : null; - const tokenUsage = active && active.type === "chat" ? "I 120 · O 42 · C 8" : null; + if (existingIndex >= 0) { + state.session.tabs[existingIndex].is_transient = transient ? state.session.tabs[existingIndex].is_transient : false; + } else if (transient) { + const transientIndex = state.session.tabs.findIndex((tab) => tab.type === type && tab.is_transient); + const nextTab = { type, id, is_transient: true }; - statusBar.innerHTML = ` -
- - ${rootState.running_task_message}${rootState.running_task_overflow ? ` +${rootState.running_task_overflow}` : ""} -
-
- ${postStatus ? `${postStatus}` : ""} - ${rootState.post_count} posts - ${rootState.media_count} media - ${tokenUsage ? `${tokenUsage}` : ""} - - - - bDS -
- `; -} - -function wireGlobalEvents() { - document.addEventListener("click", handleClick); - document.addEventListener("dblclick", handleDoubleClick); - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("change", handleChange); - document.querySelectorAll('[data-role="resize-handle"]').forEach(handle => { - handle.addEventListener("pointerdown", startResize); - }); -} - -function handleClick(event) { - const commandButton = event.target.closest("[data-command]"); - if (commandButton) { - event.preventDefault(); - executeCommand(commandButton.dataset.command); - return; - } - - const menuTrigger = event.target.closest("[data-menu-trigger]"); - if (menuTrigger) { - const id = menuTrigger.dataset.menuTrigger; - openMenu = openMenu === id ? null : id; - render(); - return; - } - - if (!event.target.closest(".menu-group")) { - if (openMenu !== null) { - openMenu = null; - renderTitleBar(); - } - } - - const activity = event.target.closest("[data-activity]"); - if (activity) { - clickActivity(activity.dataset.activity); - return; - } - - const activate = event.target.closest("[data-tab-activate]"); - if (activate) { - const [type, id] = activate.dataset.tabActivate.split(":"); - rootState.active_tab = { type, id }; - normalizePanel(); - render(); - return; - } - - const closeButton = event.target.closest("[data-tab-close]"); - if (closeButton) { - const [type, id] = closeButton.dataset.tabClose.split(":"); - closeTab(type, id); - return; - } - - const demoButton = event.target.closest("[data-open-demo]"); - if (demoButton) { - openDemoFromView(demoButton.dataset.openDemo); - return; - } - - const action = event.target.closest("[data-action]"); - if (action) { - handleAction(action.dataset.action); - return; - } - - const panelTab = event.target.closest("[data-panel-tab]"); - if (panelTab && !panelTab.disabled) { - rootState.panel.active_tab = panelTab.dataset.panelTab; - render(); - } -} - -function handleDoubleClick(event) { - const tab = event.target.closest("[data-tab-type][data-tab-id]"); - if (!tab) return; - - const type = tab.dataset.tabType; - const id = tab.dataset.tabId; - const found = rootState.tabs.find(entry => entry.type === type && entry.id === id); - - if (found && found.is_transient) { - found.is_transient = false; - render(); - } -} - -function handleKeyDown(event) { - const primary = event.metaKey || event.ctrlKey; - if (!primary) return; - - const key = event.key.toLowerCase(); - - if (key === "b") { - event.preventDefault(); - executeCommand("toggle_sidebar"); - } - - if (key === "w") { - event.preventDefault(); - executeCommand("close_tab"); - } -} - -function handleChange(event) { - const action = event.target.dataset.action; - if (!action) return; - - if (action === "theme-select") { - rootState.theme_badge = event.target.value; - render(); - } - - if (action === "language-select") { - rootState.ui_language = event.target.value; - render(); - } -} - -function startResize(event) { - const target = event.currentTarget.dataset.target; - const initialX = event.clientX; - const initialWidth = target === "sidebar" ? rootState.sidebar_width : rootState.assistant_sidebar_width; - - const move = moveEvent => { - const delta = moveEvent.clientX - initialX; - - if (target === "sidebar") { - rootState.sidebar_width = clamp(initialWidth + delta, 200, 500); + if (transientIndex >= 0) { + state.session.tabs.splice(transientIndex, 1, nextTab); } else { - rootState.assistant_sidebar_width = clamp(initialWidth - delta, 280, 640); - } - - render(); - }; - - const stop = () => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", stop); - }; - - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", stop); -} - -function executeCommand(command) { - if (command === "toggle_sidebar") { - rootState.sidebar_visible = !rootState.sidebar_visible; - } - - if (command === "toggle_panel") { - rootState.panel.visible = !rootState.panel.visible; - } - - if (command === "toggle_assistant_sidebar") { - rootState.assistant_sidebar_visible = !rootState.assistant_sidebar_visible; - } - - if (command === "close_tab" && rootState.active_tab) { - closeTab(rootState.active_tab.type, rootState.active_tab.id); - return; - } - - if (command === "settings") { - openTab("settings", "settings", "pin"); - } - - if (command === "documentation") { - openTab("documentation", "documentation", "pin"); - } - - if (command === "api_documentation") { - openTab("api_documentation", "api_documentation", "pin"); - } - - normalizePanel(); - render(); -} - -function handleAction(action) { - if (action === "toggle-dirty") { - const active = activeTab(); - if (!active || active.type !== "post") return; - - const index = rootState.dirty_tabs.findIndex(entry => sameTab(entry, active)); - if (index >= 0) { - rootState.dirty_tabs.splice(index, 1); - } else { - rootState.dirty_tabs.push({ type: active.type, id: active.id }); - } - render(); - return; - } - - if (action === "pin-active") { - const active = activeTab(); - if (!active) return; - const found = rootState.tabs.find(tab => sameTab(tab, active)); - if (found) found.is_transient = false; - render(); - return; - } - - if (action === "toggle-offline") { - rootState.offline_mode = !rootState.offline_mode; - render(); - return; - } - - if (action === "reset-session") { - localStorage.removeItem(sessionKey); - location.reload(); - return; - } - - if (action === "open-chat") { - openTab("chat", "conversation-demo", "pin"); - } -} - -function clickActivity(id) { - if (rootState.active_view === id) { - rootState.sidebar_visible = !rootState.sidebar_visible; - } else { - rootState.active_view = id; - rootState.sidebar_visible = true; - } - render(); -} - -function openDemoFromView(mode) { - const view = registry.sidebar_views.find(entry => entry.id === rootState.active_view) || registry.sidebar_views[0]; - const type = view.editor_route; - const isSingleton = !!view.singleton; - const id = isSingleton ? type : `${rootState.active_view}-demo-${view.activity_group === "bottom" ? "tool" : "item"}`; - const intent = mode === "pin" ? "pin" : "preview"; - - if (mode === "background") { - openTab(type, id, intent, true); - } else { - openTab(type, id, intent, false); - } -} - -function openTab(type, id, intent, background) { - const existing = rootState.tabs.find(tab => tab.type === type && tab.id === id); - const singleton = !!routeMeta(type)?.singleton; - const sticky = type === "chat" || type === "import" || singleton; - const transient = !sticky && intent === "preview"; - - if (existing) { - if (intent === "pin") existing.is_transient = false; - if (!background) rootState.active_tab = { type, id }; - normalizePanel(); - render(); - return; - } - - if (transient) { - const replacementIndex = rootState.tabs.findIndex(tab => tab.type === type && tab.is_transient); - const newTab = { type, id, is_transient: true }; - if (replacementIndex >= 0) { - rootState.tabs.splice(replacementIndex, 1, newTab); - } else { - rootState.tabs.push(newTab); + state.session.tabs.push(nextTab); } } else { - rootState.tabs.push({ type, id, is_transient: false }); + state.session.tabs.push({ type, id, is_transient: false }); } - if (!background) { - rootState.active_tab = { type, id }; - } - - normalizePanel(); + state.tabMeta[`${type}:${id}`] = { title }; + state.session.active_tab = { type, id }; render(); } -function closeTab(type, id) { - const index = rootState.tabs.findIndex(tab => tab.type === type && tab.id === id); - if (index < 0) return; +function activeItem() { + const tab = currentTabRef(); - rootState.tabs.splice(index, 1); - rootState.dirty_tabs = rootState.dirty_tabs.filter(tab => !(tab.type === type && tab.id === id)); - - if (rootState.active_tab && rootState.active_tab.type === type && rootState.active_tab.id === id) { - if (rootState.tabs[index]) { - rootState.active_tab = { type: rootState.tabs[index].type, id: rootState.tabs[index].id }; - } else if (rootState.tabs[index - 1]) { - rootState.active_tab = { type: rootState.tabs[index - 1].type, id: rootState.tabs[index - 1].id }; - } else { - rootState.active_tab = null; - } + if (!tab) { + return null; } - normalizePanel(); - render(); + const sections = Object.values(bootstrap.content.sidebar).flatMap((view) => view.sections); + return sections.flatMap((section) => section.items).find((item) => tabIdForItem(item, item.route) === tab.id) || null; } -function normalizePanel() { - const route = activeTab() ? activeTab().type : "dashboard"; - if (!panelAvailable(route, rootState.panel.active_tab)) { - rootState.panel.active_tab = "tasks"; +function tabMetadata(tab) { + const lookup = state.tabMeta[`${tab.type}:${tab.id}`]; + if (lookup) { + return lookup; } + + const item = activeItem(); + if (item && tab.id === tabIdForItem(item, item.route)) { + return { title: item.title }; + } + + return { title: routeLabel(tab.type) }; } -function panelAvailable(route, tab) { - if (tab === "tasks" || tab === "output") return true; - if (tab === "post_links") return route === "post"; - if (tab === "git_log") return route === "post" || route === "media"; - return false; +function currentSidebarView() { + return sidebarViews().find((view) => view.id === state.session.active_view) || sidebarViews()[0]; } -function activeTab() { - if (!rootState.active_tab) return null; - return rootState.tabs.find(tab => sameTab(tab, rootState.active_tab)) || null; +function currentSidebarData() { + return bootstrap.content.sidebar[state.session.active_view] || bootstrap.content.sidebar[bootstrap.registry.default_sidebar_view]; } -function routeMeta(id) { - return registry.editor_routes.find(route => route.id === id) || null; +function currentTabRef() { + return state.session.active_tab; } -function sameTab(left, right) { - return !!left && !!right && left.type === right.type && left.id === right.id; +function currentRoute() { + return currentTabRef()?.type || "dashboard"; } -function tabTitle(tab) { - const meta = routeMeta(tab.type); - const prefix = meta ? meta.title : titleCase(tab.type); - if (meta && meta.singleton) return prefix; - return `${prefix} ${tab.id}`; +function currentEditorMeta() { + return bootstrap.content.editor_meta[currentRoute()] || bootstrap.content.editor_meta.dashboard; } -function labelForPanel(tab) { - return { - tasks: "Tasks", - output: "Output", - post_links: "Post Links", - git_log: "Git Log" - }[tab] || titleCase(tab); +function editorTitle() { + const item = activeItem(); + return item?.title || bootstrap.content.dashboard.title; } -function labelForCommand(id) { - return id - .split("_") - .map(titleCase) - .join(" "); +function editorSubtitle(route) { + if (route === "dashboard") { + return bootstrap.content.dashboard.subtitle; + } + + const item = activeItem(); + return item?.meta || `${routeLabel(route)} content loaded through the desktop shell.`; } -function compactLabel(label) { - return label.split(" ").map(part => part[0]).join("").slice(0, 3).toUpperCase(); +function routeLabel(route) { + if (!route) { + return "Dashboard"; + } + + return ( + bootstrap.registry.editor_routes.find((item) => item.id === route)?.title || + sidebarViews().find((item) => item.id === route)?.label || + titleCase(route) + ); +} + +function tabIdForItem(item, route) { + if (route === "settings" || route === "tags") { + return route; + } + + return item.id; +} + +function sidebarViews() { + return bootstrap.registry.sidebar_views; +} + +function sameTab(tab, ref) { + return Boolean(ref) && tab.type === ref.type && tab.id === ref.id; +} + +function uniqueValue(value, index, array) { + return Boolean(value) && array.indexOf(value) === index; } function titleCase(value) { - return String(value) - .split(/[_\s-]+/) - .filter(Boolean) - .map(part => part[0].toUpperCase() + part.slice(1)) + return value + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } -function clamp(value, min, max) { - return Math.min(max, Math.max(min, Number(value) || min)); +function clone(value) { + return JSON.parse(JSON.stringify(value)); } -function safeParse(value) { - try { - return value ? JSON.parse(value) : null; - } catch (_error) { - return null; - } +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } + +function escapeHtmlAttribute(value) { + return escapeHtml(value).replaceAll("`", "`"); +} \ No newline at end of file diff --git a/priv/ui/index.html b/priv/ui/index.html index c3e0a1d..b20a571 100644 --- a/priv/ui/index.html +++ b/priv/ui/index.html @@ -1,104 +1,137 @@ - - - - bDS Shell - - - - - - - - - Blogging Desktop Server - - - -
-
-
-
Blogging Desktop Server
-
- - -
-
- -
- - - - -
-
-
-
-
- -
-
-
-
-
- -
+ + + + Blogging Desktop Server + + + +
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
- - - - + } + + + + \ No newline at end of file diff --git a/scripts/desktop_automation_app.exs b/scripts/desktop_automation_app.exs new file mode 100644 index 0000000..25aef58 --- /dev/null +++ b/scripts/desktop_automation_app.exs @@ -0,0 +1,7 @@ +Enum.reduce_while(IO.stream(:stdio, :line), :ok, fn line, _acc -> + if String.trim(line) == "stop" do + {:halt, :ok} + else + {:cont, :ok} + end +end) \ No newline at end of file diff --git a/scripts/desktop_automation_runner.mjs b/scripts/desktop_automation_runner.mjs new file mode 100644 index 0000000..731b945 --- /dev/null +++ b/scripts/desktop_automation_runner.mjs @@ -0,0 +1,80 @@ +import { chromium } from "playwright"; +import readline from "node:readline"; + +const [url, screenshotDir] = process.argv.slice(2); + +if (!url) { + console.log(JSON.stringify({ status: "error", message: "missing automation url" })); + process.exit(1); +} + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + +try { + await page.goto(url, { waitUntil: "networkidle" }); + await page.locator("#bds-shell-app").waitFor({ state: "visible" }); + await page.emulateMedia({ reducedMotion: "reduce" }); + console.log(JSON.stringify({ status: "ready", screenshotDir })); +} catch (error) { + console.log(JSON.stringify({ status: "error", message: error.message })); + await browser.close(); + process.exit(1); +} + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +for await (const line of rl) { + if (!line.trim()) { + continue; + } + + const message = JSON.parse(line); + const ref = message.ref; + + try { + if (message.command === "snapshot") { + const result = await page.evaluate(() => { + const text = (selector) => document.querySelector(selector)?.textContent?.trim() ?? null; + const texts = (selector, mapper) => Array.from(document.querySelectorAll(selector)).map(mapper); + const hasClass = (selector, className) => document.querySelector(selector)?.classList.contains(className) ?? false; + + return { + window_title: text("[data-testid='window-title']"), + active_view: document.querySelector("[data-testid='activity-button'][data-active='true']")?.dataset.view ?? null, + sidebar_visible: !hasClass("[data-testid='sidebar-shell']", "is-hidden"), + editor_title: text("[data-testid='editor-title']"), + activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")), + sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()), + editor_meta_labels: texts("[data-testid='editor-meta-label']", (node) => node.textContent.trim()) + }; + }); + + console.log(JSON.stringify({ ref, status: "ok", result })); + continue; + } + + if (message.command === "click") { + await page.locator(message.selector).click(); + await page.waitForTimeout(50); + console.log(JSON.stringify({ ref, status: "ok", result: "ok" })); + continue; + } + + if (message.command === "screenshot") { + await page.screenshot({ path: message.path, fullPage: false }); + console.log(JSON.stringify({ ref, status: "ok", result: message.path })); + continue; + } + + if (message.command === "close") { + await browser.close(); + console.log(JSON.stringify({ ref, status: "ok", result: "closed" })); + process.exit(0); + } + + console.log(JSON.stringify({ ref, status: "error", message: `unknown command: ${message.command}` })); + } catch (error) { + console.log(JSON.stringify({ ref, status: "error", message: error.message })); + } +} \ No newline at end of file diff --git a/test/bds/desktop/automation_test.exs b/test/bds/desktop/automation_test.exs new file mode 100644 index 0000000..1ca32d7 --- /dev/null +++ b/test/bds/desktop/automation_test.exs @@ -0,0 +1,107 @@ +defmodule BDS.Desktop.AutomationTest do + use ExUnit.Case, async: false + + alias BDS.Desktop.Automation + + @tag timeout: 120_000 + + test "automation boots the app in a separate process, inspects shell content, drives UI, and captures screenshots" do + screenshot_dir = Path.join(System.tmp_dir!(), "bds-desktop-automation-test") + File.rm_rf!(screenshot_dir) + File.mkdir_p!(screenshot_dir) + + {:ok, session} = Automation.start_session(screenshot_dir: screenshot_dir) + + on_exit(fn -> + Automation.stop_session(session) + File.rm_rf!(screenshot_dir) + end) + + snapshot = Automation.snapshot(session) + + assert snapshot.window_title == "Blogging Desktop Server" + assert snapshot.active_view == "posts" + assert snapshot.sidebar_visible == true + assert snapshot.editor_title == "Dashboard" + assert snapshot.activity_labels == [ + "Posts", + "Pages", + "Media", + "Scripts", + "Templates", + "Tags", + "Chat", + "Import", + "Git", + "Settings" + ] + assert "Drafts" in snapshot.sidebar_sections + assert "Status" in snapshot.editor_meta_labels + + assert :ok = Automation.click(session, "[data-testid='toggle-sidebar']") + + snapshot = Automation.snapshot(session) + assert snapshot.sidebar_visible == false + + screenshot_path = Path.join(screenshot_dir, "main-window.png") + assert Automation.capture_screenshot(session, screenshot_path) == screenshot_path + assert File.exists?(screenshot_path) + end + + @tag timeout: 120_000 + test "automation stop_session shuts down the app and browser child processes it started" do + baseline = automation_process_counts() + + {:ok, session} = Automation.start_session() + children = Automation.child_info(session) + + assert is_integer(children.app_os_pid) + assert is_integer(children.driver_os_pid) + assert os_pid_alive?(children.app_os_pid) + assert os_pid_alive?(children.driver_os_pid) + assert automation_process_counts().app == baseline.app + 1 + assert automation_process_counts().driver == baseline.driver + 1 + + assert :ok = Automation.stop_session(session) + + assert wait_until(fn -> not os_pid_alive?(children.app_os_pid) end) + assert wait_until(fn -> not os_pid_alive?(children.driver_os_pid) end) + assert automation_process_counts() == baseline + end + + defp os_pid_alive?(pid) do + case System.cmd("kill", ["-0", Integer.to_string(pid)], stderr_to_stdout: true) do + {_, 0} -> true + _other -> false + end + end + + defp automation_process_counts do + %{app: count_processes("scripts/desktop_automation_app\\.exs"), driver: count_processes("desktop_automation_runner\\.mjs")} + end + + defp count_processes(pattern) do + {output, 0} = + System.cmd("sh", ["-c", "ps -Ao args | rg -c '#{pattern}' || true"], stderr_to_stdout: true) + + output + |> String.trim() + |> case do + "" -> 0 + value -> String.to_integer(value) + end + end + + defp wait_until(fun, timeout \\ 5_000) + + defp wait_until(fun, timeout) when timeout <= 0, do: fun.() + + defp wait_until(fun, timeout) do + if fun.() do + true + else + Process.sleep(100) + wait_until(fun, timeout - 100) + end + end +end \ No newline at end of file diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 7f36678..9276436 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -90,15 +90,16 @@ defmodule BDS.UI.ShellTest do test "shell page renders the inspectable base app with bootstrap data and shell controls" do html = ShellPage.render() - assert html =~ ~s(