feat: p hase 3 of tailwind migration

This commit is contained in:
2026-05-04 11:12:17 +02:00
parent b17e9cc3f8
commit 35017f9793
24 changed files with 15752 additions and 425 deletions

View File

@@ -1,3 +1,21 @@
@theme {
--color-shell-bg: #1e1e1e;
--color-sidebar-bg: #252526;
--color-activity-bg: #333333;
--color-panel-bg: #1e1e1e;
--color-tab-active-bg: #1e1e1e;
--color-tab-inactive-bg: #2d2d2d;
--color-focus-border: #007fd4;
--color-input-bg: rgba(255, 255, 255, 0.06);
--color-input-border: rgba(255, 255, 255, 0.12);
--color-status-bg: #007acc;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
--text-shell: 13px;
--spacing-titlebar: 34px;
--spacing-tabbar: 35px;
--spacing-statusbar: 22px;
}
:root {
--accent-color: #007acc;
--accent-color-transparent: rgba(0, 122, 204, 0.25);

View File

@@ -0,0 +1,47 @@
@layer components {
.btn-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 4px;
font: inherit;
line-height: 1.2;
cursor: pointer;
user-select: none;
}
.btn-theme-primary {
color: var(--vscode-button-foreground, #ffffff);
background: var(--vscode-button-background, var(--vscode-focusBorder));
}
.btn-theme-primary:hover {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.btn-theme-danger {
color: var(--vscode-errorForeground, #f48771);
background: transparent;
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
}
.btn-theme-danger:hover {
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
}
.panel-entry {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.monaco-host {
min-width: 0;
min-height: 0;
overflow: hidden;
}
}

View File

@@ -1,6 +1,10 @@
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import "phoenix_html";
import { syncTitlebarOverlayInsets } from "./bridges/titlebar_overlay.js";
import { createMenuRuntimeCommandRunner } from "./bridges/menu_runtime.js";
import { createHooks } from "./hooks/index.js";
import { createMonacoServices } from "./monaco/services.js";
document.addEventListener("DOMContentLoaded", () => {
const csrfToken = document
@@ -119,46 +123,6 @@ document.addEventListener("DOMContentLoaded", () => {
window.localStorage.setItem(key, String(width));
};
const syncTitlebarOverlayInsets = () => {
const rootStyle = document.documentElement.style;
const setInsets = (left, right) => {
rootStyle.setProperty("--bds-titlebar-overlay-left", `${left}px`);
rootStyle.setProperty("--bds-titlebar-overlay-right", `${right}px`);
};
const overlay = navigator.windowControlsOverlay;
if (!overlay) {
setInsets(0, 0);
return () => {};
}
const updateInsets = () => {
if (!overlay.visible) {
setInsets(0, 0);
return;
}
const titlebarRect = overlay.getTitlebarAreaRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
const leftInset = Math.max(0, Math.round(titlebarRect.left));
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
setInsets(leftInset, rightInset);
};
const onGeometryChange = () => updateInsets();
const onResize = () => updateInsets();
updateInsets();
overlay.addEventListener("geometrychange", onGeometryChange);
window.addEventListener("resize", onResize);
return () => {
overlay.removeEventListener("geometrychange", onGeometryChange);
window.removeEventListener("resize", onResize);
};
};
let monacoLoaderPromise;
let liquidLanguageRegistered = false;
let markdownWithMacrosRegistered = false;
@@ -213,61 +177,12 @@ document.addEventListener("DOMContentLoaded", () => {
document.documentElement.style.zoom = String(zoom);
};
const runMenuRuntimeCommand = (action) => {
const editor = activeMonacoEditor();
switch (action) {
case "undo":
return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo");
case "redo":
return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo");
case "cut":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCutAction")
: runDocumentCommand("cut");
case "copy":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction")
: runDocumentCommand("copy");
case "paste":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction")
: runDocumentCommand("paste");
case "delete":
return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete");
case "select_all":
return editor
? runMonacoEditorAction(editor, "editor.action.selectAll")
: runDocumentCommand("selectAll");
case "find":
return editor ? runMonacoEditorAction(editor, "actions.find") : false;
case "replace":
return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false;
case "reload":
case "force_reload":
window.location.reload();
return true;
case "reset_zoom":
applyAppZoom(1);
return true;
case "zoom_in":
applyAppZoom((window.__bdsAppZoom || 1) + 0.1);
return true;
case "zoom_out":
applyAppZoom((window.__bdsAppZoom || 1) - 0.1);
return true;
case "toggle_full_screen":
if (document.fullscreenElement) {
document.exitFullscreen?.();
} else {
document.documentElement.requestFullscreen?.();
}
return true;
default:
return false;
}
};
const menuRuntimeCommandRunner = createMenuRuntimeCommandRunner({
activeMonacoEditor,
runMonacoEditorAction,
runDocumentCommand,
applyAppZoom
});
const cssVar = (name, fallback) => {
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
@@ -588,6 +503,8 @@ document.addEventListener("DOMContentLoaded", () => {
return monacoLoaderPromise;
};
const monacoServices = createMonacoServices({ loadMonaco, ensureMonacoTheme });
const Hooks = {
AppShell: {
mounted() {
@@ -738,7 +655,7 @@ document.addEventListener("DOMContentLoaded", () => {
this.handleEvent("menu-runtime-command", ({ action }) => {
if (action) {
runMenuRuntimeCommand(String(action));
menuRuntimeCommandRunner(String(action));
}
});
@@ -1295,7 +1212,7 @@ document.addEventListener("DOMContentLoaded", () => {
this.editor.focus();
};
loadMonaco()
monacoServices.loadMonaco()
.then(async (monaco) => {
if (!this.host || !this.textarea) {
return;
@@ -1303,7 +1220,7 @@ document.addEventListener("DOMContentLoaded", () => {
await this.waitForMonacoVisibleSize();
ensureMonacoTheme(monaco);
monacoServices.ensureMonacoTheme(monaco);
this.editor = monaco.editor.create(this.host, {
value: this.textarea.value || "",
@@ -1360,8 +1277,8 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monacoServices.loadMonaco().then((monaco) => {
monacoServices.ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
if (this.editor.getModel()?.getLanguageId() !== this.language) {
@@ -1430,13 +1347,13 @@ document.addEventListener("DOMContentLoaded", () => {
this.lastFilePath = this.filePath;
};
loadMonaco()
monacoServices.loadMonaco()
.then((monaco) => {
if (!this.host) {
return;
}
ensureMonacoTheme(monaco);
monacoServices.ensureMonacoTheme(monaco);
this.editor = monaco.editor.createDiffEditor(this.host, {
theme: "bds-theme",
@@ -1470,8 +1387,8 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monacoServices.loadMonaco().then((monaco) => {
monacoServices.ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
this.editor.updateOptions({
@@ -1515,7 +1432,7 @@ document.addEventListener("DOMContentLoaded", () => {
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
hooks: createHooks(Hooks),
metadata: {
keydown: (event) => ({
key: event.key,

View File

@@ -0,0 +1,57 @@
export const createMenuRuntimeCommandRunner = ({ activeMonacoEditor, runMonacoEditorAction, runDocumentCommand, applyAppZoom }) => {
return (action) => {
const editor = activeMonacoEditor();
switch (action) {
case "undo":
return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo");
case "redo":
return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo");
case "cut":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCutAction")
: runDocumentCommand("cut");
case "copy":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction")
: runDocumentCommand("copy");
case "paste":
return editor
? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction")
: runDocumentCommand("paste");
case "delete":
return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete");
case "select_all":
return editor
? runMonacoEditorAction(editor, "editor.action.selectAll")
: runDocumentCommand("selectAll");
case "find":
return editor ? runMonacoEditorAction(editor, "actions.find") : false;
case "replace":
return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false;
case "reload":
case "force_reload":
window.location.reload();
return true;
case "reset_zoom":
applyAppZoom(1);
return true;
case "zoom_in":
applyAppZoom((window.__bdsAppZoom || 1) + 0.1);
return true;
case "zoom_out":
applyAppZoom((window.__bdsAppZoom || 1) - 0.1);
return true;
case "toggle_full_screen":
if (document.fullscreenElement) {
document.exitFullscreen?.();
} else {
document.documentElement.requestFullscreen?.();
}
return true;
default:
return false;
}
};
};

View File

@@ -0,0 +1,39 @@
export const syncTitlebarOverlayInsets = () => {
const rootStyle = document.documentElement.style;
const setInsets = (left, right) => {
rootStyle.setProperty("--bds-titlebar-overlay-left", `${left}px`);
rootStyle.setProperty("--bds-titlebar-overlay-right", `${right}px`);
};
const overlay = navigator.windowControlsOverlay;
if (!overlay) {
setInsets(0, 0);
return () => {};
}
const updateInsets = () => {
if (!overlay.visible) {
setInsets(0, 0);
return;
}
const titlebarRect = overlay.getTitlebarAreaRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
const leftInset = Math.max(0, Math.round(titlebarRect.left));
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
setInsets(leftInset, rightInset);
};
const onGeometryChange = () => updateInsets();
const onResize = () => updateInsets();
updateInsets();
overlay.addEventListener("geometrychange", onGeometryChange);
window.addEventListener("resize", onResize);
return () => {
overlay.removeEventListener("geometrychange", onGeometryChange);
window.removeEventListener("resize", onResize);
};
};

1
assets/js/hooks/index.js Normal file
View File

@@ -0,0 +1 @@
export const createHooks = (hooks) => hooks;

View File

@@ -0,0 +1 @@
export const createMonacoServices = (services) => services;

View File

@@ -1,6 +1,6 @@
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header">
<div class="chat-panel-title">
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel flex h-full min-h-0 flex-col" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header flex shrink-0 items-center justify-between gap-3">
<div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
<span class="chat-panel-title-main">
<%= if @chat_editor.needs_api_key? do %>
<%= dgettext("ui", "AI Chat Setup") %>
@@ -10,9 +10,9 @@
</span>
<%= unless @chat_editor.needs_api_key? do %>
<span class="chat-model-selector-wrap">
<span class="chat-model-selector-wrap relative shrink-0">
<button
class="chat-model-selector-button chat-model-selector-inline"
class="chat-model-selector-button chat-model-selector-inline inline-flex items-center gap-2"
type="button"
phx-click="toggle_chat_model_selector"
phx-target={@myself}
@@ -23,7 +23,7 @@
</button>
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
<div class="chat-model-selector-menu">
<div class="chat-model-selector-menu absolute right-0 top-full z-10 mt-2 flex min-w-56 flex-col">
<%= for group <- @chat_editor.available_model_groups do %>
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
<%= if length(@chat_editor.available_model_groups) > 1 do %>
@@ -33,7 +33,7 @@
<%= for model <- group.models do %>
<button
class={[
"chat-model-selector-option",
"chat-model-selector-option flex items-center justify-between gap-2 text-left",
if(model.id == @chat_editor.effective_model, do: "active")
]}
type="button"
@@ -55,9 +55,9 @@
</div>
</div>
<div class="chat-messages chat-surface-scroll">
<div class="chat-messages chat-surface-scroll min-h-0 flex-1 overflow-auto">
<%= if @chat_editor.needs_api_key? do %>
<div class="chat-welcome chat-api-key-state" data-testid="chat-api-key-required">
<div class="chat-welcome chat-api-key-state flex flex-col items-start gap-3" data-testid="chat-api-key-required">
<div class="chat-welcome-icon">🔑</div>
<h2><%= dgettext("ui", "API Key Required") %></h2>
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
@@ -67,7 +67,7 @@
</div>
<% else %>
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
<div class="chat-welcome">
<div class="chat-welcome flex flex-col items-start gap-3">
<div class="chat-welcome-icon">🤖</div>
<h2><%= dgettext("ui", "Welcome to the AI Assistant") %></h2>
<p><%= dgettext("ui", "I can help you manage your blog with rich visualizations. Try asking me to:") %></p>
@@ -81,7 +81,7 @@
</div>
<% else %>
<%= if @chat_editor.pending_user_message do %>
<div class="chat-message user pending" data-testid="chat-pending-user-message">
<div class="chat-message user pending flex items-start gap-3" data-testid="chat-pending-user-message">
<div class="chat-message-avatar">👤</div>
<div class="chat-message-content">
<div class="chat-message-header">
@@ -93,7 +93,7 @@
<% end %>
<%= for message <- @chat_editor.messages do %>
<div class={["chat-message", to_string(message.role || "assistant")]}>
<div class={["chat-message flex items-start gap-3", to_string(message.role || "assistant")]}>
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
<div class="chat-message-content">
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
@@ -113,7 +113,7 @@
<% end %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
<div class="chat-message assistant streaming" data-testid="chat-streaming-message">
<div class="chat-message assistant streaming flex items-start gap-3" data-testid="chat-streaming-message">
<div class="chat-message-avatar">🤖</div>
<div class="chat-message-content">
<div class="chat-message-header">
@@ -133,7 +133,7 @@
<% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
<div class="chat-message assistant thinking" data-testid="chat-streaming-thinking">
<div class="chat-message assistant thinking flex items-start gap-3" data-testid="chat-streaming-thinking">
<div class="chat-message-avatar">🤖</div>
<div class="chat-message-content">
<div class="chat-thinking-indicator">
@@ -147,12 +147,12 @@
</div>
<%= unless @chat_editor.needs_api_key? do %>
<div class="chat-input-container" data-testid="chat-input-container">
<div class="chat-input-container flex shrink-0 flex-col gap-3" data-testid="chat-input-container">
<%= if @chat_editor.is_streaming do %>
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= dgettext("ui", "Stop") %></button>
<% end %>
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
</form>

View File

@@ -1,5 +1,5 @@
<div
class="app"
class="app flex h-full w-full flex-col"
id="bds-shell-app"
phx-hook="AppShell"
data-shortcuts={encoded_shortcuts(@client_shortcuts)}
@@ -112,12 +112,12 @@
</div>
<% end %>
<div class="app-main">
<aside class="activity-bar" data-region="activity-bar">
<div class="activity-bar-top">
<div class="app-main flex min-h-0 flex-1 overflow-hidden">
<aside class="activity-bar flex h-full shrink-0 flex-col items-center justify-between" data-region="activity-bar">
<div class="activity-bar-top flex flex-col items-center gap-1">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :top)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
@@ -134,10 +134,10 @@
</button>
<% end %>
</div>
<div class="activity-bar-bottom">
<div class="activity-bar-bottom flex flex-col items-center gap-1">
<%= for button <- Enum.filter(@activity_buttons, &(&1.activity_group == :bottom)) do %>
<button
class={["activity-bar-item", if(button.active, do: "active")]}
class={["activity-bar-item inline-flex h-12 w-12 items-center justify-center", if(button.active, do: "active")]}
data-testid="activity-button"
data-view={button.id}
data-active={to_string(button.active)}
@@ -157,22 +157,22 @@
</aside>
<section
class={["sidebar-shell", if(not @workbench.sidebar_visible, do: "is-hidden")]}
class={["sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.sidebar_visible, do: "is-hidden")]}
data-testid="sidebar-shell"
style={"width: #{if(@workbench.sidebar_visible, do: @workbench.sidebar_width, else: 0)}px;"}
>
<div class="sidebar" data-region="sidebar">
<div id="sidebar-content" class="sidebar-content sidebar-body" phx-hook="SidebarInteractions">
<div class="sidebar flex min-w-0 flex-1 overflow-hidden" data-region="sidebar">
<div id="sidebar-content" class="sidebar-content sidebar-body flex min-h-0 flex-1 flex-col overflow-y-auto" phx-hook="SidebarInteractions">
<div class="sidebar-section">
<% create_action = sidebar_create_action(@workbench.active_view) %>
<div class="sidebar-section-header">
<div class="sidebar-section-header flex items-center justify-between gap-2">
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
<%= if create_action || ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<div class="sidebar-actions">
<div class="sidebar-actions flex items-center gap-1">
<%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<button
class={[
"sidebar-action",
"sidebar-action inline-flex h-8 w-8 items-center justify-center",
if(ShellSidebarComponents.filters_visible?(@sidebar_data), do: "active")
]}
data-testid="sidebar-filter-toggle"
@@ -188,7 +188,7 @@
<% end %>
<%= if create_action do %>
<button
class="sidebar-action"
class="sidebar-action inline-flex h-8 w-8 items-center justify-center"
data-testid="sidebar-create-action"
data-sidebar-action={create_action.kind}
type="button"
@@ -212,16 +212,16 @@
<div class="resizable-panel-divider sidebar-divider" data-resize="sidebar" data-role="resize-handle"></div>
</section>
<main class="app-content" data-region="content">
<div class="tab-bar" data-region="tab-bar">
<main class="app-content flex min-w-0 flex-1 flex-col overflow-hidden" data-region="content">
<div class="tab-bar flex h-[35px] shrink-0 items-center overflow-hidden" data-region="tab-bar">
<%= if Enum.empty?(@workbench.tabs) do %>
<div class="tab-bar-empty"><%= dgettext("ui", "Dashboard") %></div>
<div class="tab-bar-empty flex h-full items-center px-3 text-sm"><%= dgettext("ui", "Dashboard") %></div>
<% else %>
<div class="tab-bar-tabs">
<div class="tab-bar-tabs flex min-w-0 flex-1 items-stretch overflow-x-auto">
<%= for tab <- @workbench.tabs do %>
<div
class={[
"tab",
"tab flex min-w-0 max-w-[240px] shrink-0 items-stretch",
if(@workbench.active_tab == {tab.type, tab.id}, do: "active"),
if(tab.is_transient, do: "transient"),
if(Workbench.dirty?(@workbench, tab.type, tab.id), do: "dirty")
@@ -231,21 +231,21 @@
tabindex="0"
>
<button
class="tab-select"
class="tab-select flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-3 text-sm"
type="button"
phx-click="select_tab"
phx-value-type={tab.type}
phx-value-id={tab.id}
>
<span class="tab-icon"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span>
<span class="tab-title"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
<span class="tab-icon shrink-0"><%= raw(ShellData.activity_icon(BDS.Desktop.ShellLive.TabHelpers.tab_icon_id(tab))) %></span>
<span class="tab-title truncate"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(tab, @tab_meta) %></span>
</button>
<div class="tab-actions">
<div class="tab-actions flex items-center gap-1 pr-2">
<%= if Workbench.dirty?(@workbench, tab.type, tab.id) do %>
<span class="tab-dirty-indicator">●</span>
<% end %>
<button
class="tab-close"
class="tab-close inline-flex h-6 w-6 items-center justify-center"
data-testid="tab-close"
data-tab-type={tab.type}
data-tab-id={tab.id}
@@ -265,7 +265,7 @@
<% end %>
</div>
<section class="editor-shell" data-region="editor">
<section class="editor-shell flex min-h-0 flex-1 flex-col overflow-hidden" data-region="editor">
<%= if is_nil(@current_tab) do %>
<div class="editor-empty">
<div class="dashboard-content">
@@ -452,12 +452,12 @@
<% end %>
</section>
<section class={["panel-shell", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
<div class="panel-header">
<div class="panel-tabs">
<section class={["panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden", if(not @workbench.panel.visible, do: "is-hidden")]} data-region="panel">
<div class="panel-header flex items-center justify-between gap-2">
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
<%= for tab <- @panel_tabs do %>
<button
class={["panel-tab", if(@workbench.panel.active_tab == tab, do: "active")]}
class={["panel-tab inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
type="button"
phx-click="select_panel_tab"
phx-value-tab={tab}
@@ -467,7 +467,7 @@
<% end %>
</div>
<button
class="panel-close"
class="panel-close inline-flex h-8 w-8 items-center justify-center"
data-testid="panel-close"
type="button"
phx-click="toggle_panel"
@@ -477,21 +477,21 @@
×
</button>
</div>
<div class="panel-content">
<div class="panel-content min-h-0 flex-1 overflow-auto">
<%= BDS.Desktop.ShellLive.PanelRenderer.render_panel_body(assigns) %>
</div>
</section>
</main>
<section
class={["assistant-sidebar-shell", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
class={["assistant-sidebar-shell flex min-w-0 shrink-0 overflow-hidden", if(not @workbench.assistant_sidebar_visible, do: "is-hidden")]}
data-testid="assistant-shell"
style={"width: #{if(@workbench.assistant_sidebar_visible, do: @workbench.assistant_sidebar_width, else: 0)}px;"}
>
<div class="resizable-panel-divider assistant-divider" data-resize="assistant" data-role="resize-handle"></div>
<aside class="assistant-sidebar" data-region="assistant-sidebar">
<div class="assistant-content">
<header class="assistant-sidebar-header">
<aside class="assistant-sidebar flex min-w-0 flex-1 overflow-hidden" data-region="assistant-sidebar">
<div class="assistant-content flex min-h-0 flex-1 flex-col">
<header class="assistant-sidebar-header flex items-start justify-between gap-3">
<div class="assistant-sidebar-heading">
<strong><%= dgettext("ui", "AI Assistant") %></strong>
<span class="assistant-sidebar-description"><%= dgettext("ui", "AI conversations") %></span>
@@ -504,12 +504,12 @@
</span>
</header>
<section class="assistant-sidebar-context" data-testid="assistant-context">
<div class="assistant-sidebar-context-row">
<section class="assistant-sidebar-context flex shrink-0 flex-col gap-2" data-testid="assistant-context">
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
<span class="assistant-sidebar-context-label"><%= dgettext("ui", "Project") %></span>
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_project_name(@current_project) %></span>
</div>
<div class="assistant-sidebar-context-row">
<div class="assistant-sidebar-context-row flex items-center justify-between gap-2">
<span class="assistant-sidebar-context-label"><%= dgettext("ui", "Editor") %></span>
<span class="assistant-sidebar-context-value"><%= BDS.Desktop.ShellLive.TabHelpers.tab_title(@current_tab, @tab_meta) %></span>
</div>
@@ -517,13 +517,13 @@
</section>
<form
class="assistant-sidebar-prompt-form"
class="assistant-sidebar-prompt-form flex shrink-0 flex-col gap-3"
data-testid="assistant-prompt-form"
phx-change="update_assistant_prompt"
phx-submit="submit_assistant_prompt"
>
<textarea
class="assistant-sidebar-prompt"
class="assistant-sidebar-prompt min-h-[8rem] w-full resize-y"
data-testid="assistant-prompt-input"
name="assistant[prompt]"
rows="6"
@@ -541,19 +541,19 @@
</form>
<%= if Enum.empty?(@assistant_messages) do %>
<div class="assistant-sidebar-welcome">
<div class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto">
<%= for card <- @assistant_cards do %>
<section class="assistant-card">
<section class="assistant-card flex flex-col gap-1">
<strong><%= card.label %></strong>
<span><%= card.text %></span>
</section>
<% end %>
</div>
<% else %>
<div class="assistant-sidebar-transcript">
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
<%= for message <- @assistant_messages do %>
<article
class={["assistant-sidebar-message", message.role]}
class={["assistant-sidebar-message flex flex-col gap-1", message.role]}
data-testid={BDS.Desktop.ShellLive.ChatSurface.assistant_message_testid(message.role)}
>
<span class="assistant-sidebar-message-role"><%= BDS.Desktop.ShellLive.ChatSurface.assistant_message_label(message.role) %></span>
@@ -567,12 +567,12 @@
</section>
</div>
<footer class="status-bar" data-region="status-bar" data-testid="status-bar">
<div class="status-bar-left">
<footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
<div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden">
<%= if @is_mac_ui do %>
<div class="status-shell-controls" data-testid="status-shell-controls">
<div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
<button
class="status-shell-toggle-button"
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-sidebar"
type="button"
phx-click="toggle_sidebar"
@@ -584,7 +584,7 @@
</span>
</button>
<button
class="status-shell-toggle-button"
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-panel"
type="button"
phx-click="toggle_panel"
@@ -596,7 +596,7 @@
</span>
</button>
<button
class="status-shell-toggle-button"
class="status-shell-toggle-button inline-flex h-6 w-6 items-center justify-center"
data-testid="toggle-assistant"
type="button"
phx-click="toggle_assistant_sidebar"
@@ -609,9 +609,9 @@
</button>
</div>
<% end %>
<div class="project-selector">
<div class="project-selector relative shrink-0">
<button
class="project-selector-trigger"
class="project-selector-trigger inline-flex items-center gap-2"
data-testid="project-selector-trigger"
type="button"
title={dgettext("ui", "Switch project")}
@@ -659,7 +659,7 @@
</div>
<% end %>
</div>
<button class="status-bar-item status-bar-task-button" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
<button class="status-bar-item status-bar-task-button inline-flex items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
<%= if @status.left.running_task_message do %>
<span class="task-spinner"></span>
<% end %>
@@ -669,12 +669,12 @@
<% end %>
</button>
</div>
<div class="status-bar-right">
<div class="status-bar-right flex items-center gap-2 overflow-hidden">
<span class="status-bar-item"><%= @status.right.post_count %></span>
<span class="status-bar-item"><%= @status.right.media_count %></span>
<span class="status-bar-item theme-badge"><%= @status.right.theme_badge %></span>
<button class={["status-bar-item", "offline-badge", if(@status.right.offline_mode, do: "active")]} data-testid="status-offline-button" type="button" phx-click="toggle_offline_mode" title={dgettext("ui", "Toggle offline mode")}>✈</button>
<form class="status-bar-item language-badge" data-testid="status-language-form" phx-change="change_ui_language">
<form class="status-bar-item language-badge flex items-center gap-1" data-testid="status-language-form" phx-change="change_ui_language">
<span><%= dgettext("ui", "UI") %></span>
<select class="status-bar-language-select" name="ui_language" data-testid="status-language-select">
<%= for language <- @supported_ui_languages do %>

View File

@@ -1,26 +1,25 @@
<div class="media-editor editor" data-testid="media-editor">
<div class="editor-header">
<div class="editor-tabs">
<div class="media-editor editor flex h-full min-h-0 flex-col" data-testid="media-editor">
<div class="editor-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs flex min-w-0 flex-1 overflow-hidden">
<div class={[
"editor-tab",
"active",
"editor-tab active inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2",
if(@media_editor.dirty?, do: "dirty")
]}>
<span class="editor-tab-title" data-testid="editor-title"><%= @media_editor.display_title %></span>
<span class="editor-tab-title truncate" data-testid="editor-title"><%= @media_editor.display_title %></span>
<%= if @media_editor.dirty? do %>
<span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span>
<% end %>
</div>
</div>
<div class="editor-actions">
<div class="editor-actions flex flex-wrap items-center justify-end gap-2">
<%= if @media_editor.save_state in [:dirty, :saved] do %>
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
<% end %>
<div class="quick-actions-wrapper">
<div class="quick-actions-wrapper relative">
<button
class="secondary quick-actions-btn"
class="secondary quick-actions-btn inline-flex items-center gap-2"
type="button"
phx-click="toggle_media_editor_quick_actions"
phx-target={@myself}
@@ -30,16 +29,16 @@
</button>
<%= if @media_editor.quick_actions_open? do %>
<div class="quick-actions-menu">
<div class="quick-actions-menu absolute right-0 top-full z-10 mt-2 flex min-w-72 flex-col">
<%= if @media_editor.is_image do %>
<button
class="quick-action-item"
class="quick-action-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
phx-value-kind="ai_suggestions"
>
<span class="quick-action-text">
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "AI Suggestions") %></strong>
<small><%= dgettext("ui", "Review title, alt text, and caption suggestions") %></small>
</span>
@@ -50,13 +49,13 @@
<% end %>
<button
class="quick-action-item"
class="quick-action-item flex items-start gap-3 text-left"
type="button"
phx-click="detect_media_editor_language"
phx-target={@myself}
disabled={not @media_editor.can_detect_language?}
>
<span class="quick-action-text">
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "Detect Language") %></strong>
<small><%= dgettext("ui", "Persist the detected language for this media item") %></small>
</span>
@@ -66,14 +65,14 @@
<div class="quick-actions-divider"></div>
<button
class="quick-action-item"
class="quick-action-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
phx-value-kind="language_picker"
disabled={not @media_editor.can_translate?}
>
<span class="quick-action-text">
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "Translate") %></strong>
<small><%= dgettext("ui", "Select a target language for this media item") %></small>
</span>
@@ -101,14 +100,14 @@
</div>
</div>
<div class="editor-content media-editor">
<div class="media-preview">
<div class="editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]">
<div class="media-preview flex min-h-[16rem] items-center justify-center">
<%= if @media_editor.is_image and @media_editor.preview_url do %>
<div class="media-preview-image">
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
</div>
<% else %>
<div class="media-preview-placeholder">
<div class="media-preview-placeholder flex h-full w-full flex-col items-center justify-center gap-3">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"></path>
</svg>
@@ -117,58 +116,58 @@
<% end %>
</div>
<div class="media-details">
<form class="media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor" phx-target={@myself}>
<div class="editor-field">
<div class="media-details min-w-0">
<form class="media-editor-details-form flex flex-col gap-4" data-testid="media-editor-form" phx-change="change_media_editor" phx-target={@myself}>
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "File Name") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.original_name} disabled />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "MIME Type") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.mime_type} disabled />
</div>
<div class="editor-field-row">
<div class="editor-field">
<div class="editor-field-row grid gap-4 md:grid-cols-2">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Size") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.file_size} disabled />
</div>
<%= if @media_editor.dimensions do %>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Dimensions") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.dimensions} disabled />
</div>
<% end %>
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Tags") %></label>
<input class="post-editor-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Author") %></label>
<input class="post-editor-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Language") %></label>
<select class="post-editor-input" name="media_editor[language]">
<option value=""><%= dgettext("ui", "None") %></option>
@@ -180,15 +179,15 @@
</form>
<%= if @media_editor.form["language"] not in [nil, ""] do %>
<div class="editor-field media-translations-section">
<div class="editor-field media-translations-section flex flex-col gap-2">
<label><%= dgettext("ui", "Translations") %></label>
<%= if Enum.empty?(@media_editor.translations) do %>
<div class="no-linked-posts"><%= dgettext("ui", "No translations") %></div>
<% else %>
<div class="linked-posts-list">
<div class="linked-posts-list flex flex-col gap-2">
<%= for translation <- @media_editor.translations do %>
<div class="linked-post-item">
<div class="linked-post-item flex items-center justify-between gap-2">
<button
class="linked-post-title linked-post-link"
type="button"
@@ -209,7 +208,7 @@
</div>
<% end %>
<div class="editor-field linked-posts-section">
<div class="editor-field linked-posts-section flex flex-col gap-2">
<label>
<%= dgettext("ui", "Linked Posts") %>
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
@@ -218,7 +217,7 @@
</label>
<%= if @media_editor.post_picker_open? do %>
<div class="post-picker">
<div class="post-picker flex flex-col gap-3">
<div class="post-picker-search">
<input
type="text"
@@ -233,7 +232,7 @@
<%= if Enum.empty?(@media_editor.post_picker_results) do %>
<div class="no-posts"><%= dgettext("ui", "No posts to link") %></div>
<% else %>
<div class="post-picker-list">
<div class="post-picker-list flex flex-col gap-2">
<%= for result <- @media_editor.post_picker_results do %>
<button class="post-picker-item" type="button" phx-click="link_media_to_post" phx-target={@myself} phx-value-post-id={result.post_id}>
<%= result.title %>
@@ -250,9 +249,9 @@
<%= if Enum.empty?(@media_editor.linked_posts) do %>
<div class="no-linked-posts"><%= dgettext("ui", "Not linked to any posts") %></div>
<% else %>
<div class="linked-posts-list">
<div class="linked-posts-list flex flex-col gap-2">
<%= for linked_post <- @media_editor.linked_posts do %>
<div class="linked-post-item">
<div class="linked-post-item flex items-center justify-between gap-2">
<button
class="linked-post-title linked-post-link"
type="button"
@@ -275,27 +274,27 @@
<%= if @media_editor.editing_translation do %>
<div class="translation-modal-backdrop">
<div class="translation-modal">
<div class="translation-modal-header">
<div class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden">
<div class="translation-modal-header flex items-center justify-between gap-3">
<h2><%= dgettext("ui", "Edit Translation") %></h2>
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor" phx-target={@myself}>×</button>
</div>
<form class="translation-modal-body" phx-change="change_media_translation" phx-target={@myself}>
<form class="translation-modal-body flex flex-col gap-4 overflow-auto" phx-change="change_media_translation" phx-target={@myself}>
<input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
</div>
</form>
<div class="translation-modal-footer">
<div class="translation-modal-footer flex items-center justify-end gap-2">
<button class="secondary" type="button" phx-click="close_media_translation_editor" phx-target={@myself}><%= dgettext("ui", "Cancel") %></button>
<button type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
</div>

View File

@@ -1,51 +1,51 @@
<div class="menu-editor-view" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")} phx-target={@myself}>
<div class="menu-editor-header">
<div class="menu-editor-view flex h-full min-h-0 flex-col" data-testid="menu-editor" phx-window-keydown={if(@menu_editor.draft, do: "menu_editor_keydown")} phx-target={@myself}>
<div class="menu-editor-header flex shrink-0 items-start justify-between gap-3">
<div>
<h2><%= @menu_editor.title %></h2>
<p><%= @menu_editor.description %></p>
</div>
</div>
<div class="menu-editor-main">
<div class="menu-editor-tree-wrap">
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" phx-target={@myself} title={dgettext("ui", "menuEditor.addEntry")}>
<div class="menu-editor-main min-h-0 flex-1 overflow-hidden">
<div class="menu-editor-tree-wrap flex h-full min-h-0 flex-col">
<div class="menu-editor-toolbar flex flex-wrap items-center gap-2" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="add-entry" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-entry" phx-target={@myself} title={dgettext("ui", "menuEditor.addEntry")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" phx-target={@myself} title={dgettext("ui", "menuEditor.save")}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="save" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="save" phx-target={@myself} title={dgettext("ui", "menuEditor.save")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" phx-target={@myself} title={dgettext("ui", "menuEditor.addCategoryArchive")}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="add-category-archive" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="add-category-archive" phx-target={@myself} title={dgettext("ui", "menuEditor.addCategoryArchive")}>
<span aria-hidden="true"><%= dgettext("ui", "menuEditor.addCategoryArchiveShort") %></span>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" phx-target={@myself} title={dgettext("ui", "menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-up" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-up" phx-target={@myself} title={dgettext("ui", "menuEditor.moveUp")} disabled={not @menu_editor.can_move_up?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" phx-target={@myself} title={dgettext("ui", "menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="move-down" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="move-down" phx-target={@myself} title={dgettext("ui", "menuEditor.moveDown")} disabled={not @menu_editor.can_move_down?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" phx-target={@myself} title={dgettext("ui", "menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="indent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="indent" phx-target={@myself} title={dgettext("ui", "menuEditor.indent")} disabled={not @menu_editor.can_indent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" phx-target={@myself} title={dgettext("ui", "menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="unindent" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="unindent" phx-target={@myself} title={dgettext("ui", "menuEditor.unindent")} disabled={not @menu_editor.can_unindent?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
</button>
<button class="menu-editor-tool" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" phx-target={@myself} title={dgettext("ui", "menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
<button class="menu-editor-tool inline-flex h-9 min-w-9 items-center justify-center" data-testid="menu-editor-toolbar-button" data-action="delete" type="button" phx-click="menu_editor_toolbar_action" phx-value-action="delete" phx-target={@myself} title={dgettext("ui", "menuEditor.delete")} disabled={not @menu_editor.can_delete?}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
</button>
</div>
<%= if @menu_editor.items == [] do %>
<div class="menu-editor-empty"><%= dgettext("ui", "menuEditor.empty") %></div>
<div class="menu-editor-empty flex min-h-0 flex-1 items-center justify-center"><%= dgettext("ui", "menuEditor.empty") %></div>
<% else %>
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell" phx-hook="MenuEditorTree">
<div id="menu-editor-tree-shell" class="menu-editor-tree-shell min-h-0 flex-1 overflow-auto" phx-hook="MenuEditorTree">
<ul class="menu-editor-tree-level">
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} myself={@myself} />
</ul>

View File

@@ -1,10 +1,10 @@
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
<div class="misc-editor-header">
<div class={["misc-editor-shell flex h-full min-h-0 flex-col overflow-hidden", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
<div class="misc-editor-header flex shrink-0 items-start justify-between gap-3">
<div>
<h2><%= @misc_editor.title %></h2>
<p><%= @misc_editor.subtitle %></p>
</div>
<div class="misc-editor-actions">
<div class="misc-editor-actions flex flex-wrap items-center justify-end gap-2">
<%= if refreshable?(@misc_editor.kind) do %>
<button class="secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button>
<% end %>
@@ -17,13 +17,13 @@
</div>
</div>
<div class="misc-editor-summary">
<div class="misc-editor-summary flex flex-wrap gap-2">
<%= for {label, value} <- summary_items(@misc_editor) do %>
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
<% end %>
</div>
<div class="misc-editor-content">
<div class="misc-editor-content min-h-0 flex-1 overflow-auto">
<%= case @misc_editor.kind do %>
<% :documentation -> %>
<div class="documentation-view">

View File

@@ -30,10 +30,10 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
~H"""
<%= if Enum.any?(@editor_toolbar_buttons) do %>
<div class="editor-toolbar">
<div class="editor-toolbar flex items-center gap-2">
<%= for button <- @editor_toolbar_buttons do %>
<button
class={["editor-toolbar-button", if(button.destructive, do: "is-destructive")]}
class={["editor-toolbar-button inline-flex items-center justify-center", if(button.destructive, do: "is-destructive")]}
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
@@ -55,10 +55,10 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<span><%= dgettext("ui", "No background tasks running") %></span>
</div>
<% else %>
<div class="task-list">
<div class="task-list flex flex-col gap-2">
<%= for task <- Map.get(@task_status, :tasks, []) do %>
<div class="panel-entry task-entry">
<div class="task-entry-header">
<div class="panel-entry task-entry flex flex-col gap-2">
<div class="task-entry-header flex items-center justify-between gap-2">
<strong><%= task.name %></strong>
<span class={"task-status task-status-#{task.status}"}><%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %></span>
</div>
@@ -84,7 +84,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<span><%= dgettext("ui", "No shell output yet") %></span>
</div>
<% else %>
<div class="output-list">
<div class="output-list flex flex-col gap-2">
<%= for entry <- @output_entries do %>
<div class={[
"panel-entry",
@@ -118,7 +118,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<span><%= dgettext("ui", "No post links yet") %></span>
</div>
<% else %>
<div class="git-log-list">
<div class="git-log-list flex flex-col gap-2">
<%= if Enum.any?(@backlinks) do %>
<div class="panel-entry"><strong><%= dgettext("ui", "Backlinks") %></strong></div>
<%= for entry <- @backlinks do %>
@@ -165,7 +165,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
~H"""
<%= if Enum.empty?(@git_entries) do %>
<div class="git-log-list">
<div class="git-log-list flex flex-col gap-2">
<div class="panel-entry panel-empty-state">
<strong><%= dgettext("ui", "Git Log") %></strong>
<span><%= dgettext("ui", "No git history yet") %></span>

View File

@@ -1,15 +1,15 @@
<div class="post-editor editor" data-testid="post-editor">
<div class="editor-header">
<div class="editor-tabs">
<div class={["editor-tab", "active", if(@post_editor.dirty?, do: "dirty")]}>
<span class="editor-tab-title" data-testid="editor-title"><%= @post_editor.display_title %></span>
<div class="post-editor editor flex h-full min-h-0 flex-col" data-testid="post-editor">
<div class="editor-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs flex min-w-0 flex-1 overflow-hidden">
<div class={["editor-tab active inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2", if(@post_editor.dirty?, do: "dirty")]}>
<span class="editor-tab-title truncate" data-testid="editor-title"><%= @post_editor.display_title %></span>
<%= if @post_editor.dirty? do %>
<span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span>
<% end %>
</div>
</div>
<div class="editor-actions">
<div class="editor-actions flex flex-wrap items-center justify-end gap-2">
<span class={["status-badge", "status-#{@post_editor.status}"]} data-testid="post-status-badge">
<%= post_status_label(@post_editor.status) %>
</span>
@@ -17,9 +17,9 @@
<span class="auto-save-indicator"><%= post_editor_save_state_label(@post_editor.save_state) %></span>
<% end %>
<div class="quick-actions-wrapper">
<div class="quick-actions-wrapper relative">
<button
class="secondary quick-actions-btn"
class="secondary quick-actions-btn inline-flex items-center gap-2"
type="button"
phx-click="toggle_post_editor_quick_actions"
phx-target={@myself}
@@ -29,9 +29,9 @@
</button>
<%= if @post_editor.quick_actions_open? do %>
<div class="quick-actions-menu">
<div class="quick-actions-menu absolute right-0 top-full z-10 mt-2 flex min-w-72 flex-col">
<button
class="quick-action-item"
class="quick-action-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
@@ -39,7 +39,7 @@
disabled={not @post_editor.detect_language_enabled?}
>
<span class="quick-action-icon">🤖</span>
<span class="quick-action-text">
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "AI Suggestions") %></strong>
<small><%= dgettext("ui", "Review title, excerpt, and content suggestions") %></small>
</span>
@@ -48,7 +48,7 @@
<div class="quick-actions-divider"></div>
<button
class="quick-action-item"
class="quick-action-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button"
type="button"
phx-click="open_overlay"
@@ -56,7 +56,7 @@
disabled={not @post_editor.can_translate?}
>
<span class="quick-action-icon">🌍</span>
<span class="quick-action-text">
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
<strong><%= dgettext("ui", "Translate") %></strong>
<small><%= dgettext("ui", "Select a target language for this post") %></small>
</span>
@@ -83,14 +83,14 @@
</div>
</div>
<form class="post-editor-form editor-content" data-testid="post-editor-form" phx-change="change_post_editor" phx-target={@myself}>
<div class="metadata-toggle-header">
<form class="post-editor-form editor-content flex min-h-0 flex-1 flex-col gap-4 overflow-auto" data-testid="post-editor-form" phx-change="change_post_editor" phx-target={@myself}>
<div class="metadata-toggle-header flex items-center justify-between gap-3">
<button class={["metadata-toggle", if(@post_editor.metadata_expanded, do: "expanded")]} type="button" phx-click="toggle_post_metadata" phx-target={@myself}>
<span class="metadata-toggle-chevron"><%= if @post_editor.metadata_expanded, do: "▼", else: "▶" %></span>
<span><%= dgettext("ui", "Metadata") %></span>
</button>
<div class="editor-translations-flags" aria-label={dgettext("ui", "Translations")}>
<div class="editor-translations-flags flex flex-wrap items-center gap-2" aria-label={dgettext("ui", "Translations")}>
<%= for flag <- @post_editor.translation_flags do %>
<button
class={[
@@ -111,18 +111,18 @@
</div>
</div>
<div class={["editor-header-row", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
<div class="editor-meta">
<div class="editor-field">
<div class={["editor-header-row grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(280px,1fr)]", if(not @post_editor.metadata_expanded, do: "is-collapsed")]}>
<div class="editor-meta flex min-w-0 flex-col gap-4">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input" type="text" name="post_editor[title]" value={@post_editor.form["title"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Tags") %></label>
<div class="tag-input-container">
<div class="tag-input-container relative">
<input type="hidden" name="post_editor[tags]" value={@post_editor.form["tags"]} />
<div class="tag-input-wrapper">
<div class="tag-input-wrapper flex flex-wrap items-center gap-2">
<%= for tag <- @post_editor.tag_chips do %>
<span class={["tag-chip", if(tag.color, do: "has-color")]} style={tag_chip_style(tag.color)}>
<span><%= tag.name %></span>
@@ -141,7 +141,7 @@
</div>
<%= if String.trim(@post_editor.tag_query || "") != "" and (Enum.any?(@post_editor.tag_suggestions) or @post_editor.tag_query_addable?) do %>
<div class="tag-suggestions">
<div class="tag-suggestions mt-2 flex flex-col">
<%= for tag <- @post_editor.tag_suggestions do %>
<button class="tag-suggestion" type="button" phx-click="add_post_editor_tag" phx-value-tag={tag.name} phx-target={@myself}>
<%= if tag.color do %>
@@ -162,14 +162,14 @@
</div>
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Author") %></label>
<input class="post-editor-input" type="text" name="post_editor[author]" value={@post_editor.form["author"]} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Language") %></label>
<div class="editor-language-row">
<div class="editor-language-row flex items-center gap-2">
<select class="post-editor-input" name="post_editor[language]">
<%= for language <- @post_editor.languages do %>
<option value={language} selected={language == @post_editor.form["language"]}><%= String.upcase(language) %></option>
@@ -189,7 +189,7 @@
</div>
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label class="editor-checkbox-label">
<input type="hidden" name="post_editor[do_not_translate]" value="false" />
<input type="checkbox" name="post_editor[do_not_translate]" value="true" checked={@post_editor.form["do_not_translate"]} />
@@ -197,17 +197,17 @@
</label>
</div>
<div class="editor-field-row">
<div class="editor-field">
<div class="editor-field-row grid gap-4 md:grid-cols-2">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Slug") %></label>
<input class="post-editor-input is-readonly" type="text" readonly value={@post_editor.slug} />
</div>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Categories") %></label>
<div class="tag-input-container">
<div class="tag-input-container relative">
<input type="hidden" name="post_editor[categories]" value={@post_editor.form["categories"]} />
<div class="tag-input-wrapper">
<div class="tag-input-wrapper flex flex-wrap items-center gap-2">
<%= for category <- @post_editor.category_values do %>
<span class="tag-chip">
<span><%= category %></span>
@@ -226,7 +226,7 @@
</div>
<%= if String.trim(@post_editor.category_query || "") != "" and (Enum.any?(@post_editor.category_suggestions) or @post_editor.category_query_addable?) do %>
<div class="tag-suggestions">
<div class="tag-suggestions mt-2 flex flex-col">
<%= for category <- @post_editor.category_suggestions do %>
<button class="tag-suggestion" type="button" phx-click="add_post_editor_category" phx-value-category={category} phx-target={@myself}>
<span class="tag-suggestion-name"><%= category %></span>
@@ -246,7 +246,7 @@
</div>
<%= if @post_editor.show_template_selector? do %>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Template") %></label>
<select class="post-editor-input" name="post_editor[template_slug]">
<option value=""><%= dgettext("ui", "Default") %></option>
@@ -257,9 +257,9 @@
</div>
<% end %>
<div class="post-editor-links-panel">
<div class="post-editor-links-panel flex flex-col gap-3">
<strong><%= dgettext("ui", "Post Links") %></strong>
<div class="post-editor-links-columns">
<div class="post-editor-links-columns grid gap-4 md:grid-cols-2">
<div>
<span class="post-editor-links-label"><%= dgettext("ui", "Backlinks") %></span>
<%= if Enum.any?(@post_editor.post_links.backlinks) do %>
@@ -288,15 +288,15 @@
</div>
</div>
<aside class="editor-media-panel post-editor-side-panel">
<aside class="editor-media-panel post-editor-side-panel flex flex-col gap-3">
<div class="post-editor-side-panel-header">
<strong><%= dgettext("ui", "Linked Media") %></strong>
</div>
<%= if Enum.any?(@post_editor.linked_media) do %>
<ul class="post-editor-media-list">
<ul class="post-editor-media-list flex flex-col gap-2">
<%= for item <- @post_editor.linked_media do %>
<li class="post-editor-media-item">
<li class="post-editor-media-item flex flex-col gap-1">
<span class="post-editor-media-title"><%= item.name %></span>
<span class="post-editor-media-meta"><%= dgettext("ui", "Order") %>: <%= item.sort_order %></span>
</li>
@@ -314,19 +314,19 @@
</button>
<div class={["editor-excerpt-panel", if(not @post_editor.excerpt_expanded, do: "is-collapsed")]}>
<div class="editor-field">
<div class="editor-field flex flex-col gap-1.5">
<label><%= dgettext("ui", "Excerpt") %></label>
<textarea class="post-editor-textarea post-editor-excerpt" name="post_editor[excerpt]" rows="4"><%= @post_editor.form["excerpt"] %></textarea>
</div>
</div>
<div class="editor-body">
<div class="editor-toolbar">
<div class="editor-toolbar-left">
<div class="editor-body flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="editor-toolbar flex items-center gap-3">
<div class="editor-toolbar-left flex items-center gap-2">
<label><%= dgettext("ui", "Content") %></label>
</div>
<div class="editor-toolbar-center">
<div class="editor-toolbar-center flex flex-1 justify-center">
<div class="editor-mode-toggle">
<%= for mode <- [:markdown, :preview] do %>
<button
@@ -342,7 +342,7 @@
</div>
</div>
<div class="editor-toolbar-right">
<div class="editor-toolbar-right flex items-center gap-2">
<%= if @post_editor.mode == :markdown do %>
<button
class="insert-post-link-button"
@@ -379,7 +379,7 @@
</div>
<%= if @post_editor.mode == :preview do %>
<div class="editor-preview post-editor-preview" data-testid="post-editor-preview">
<div class="editor-preview post-editor-preview flex min-h-0 flex-1" data-testid="post-editor-preview">
<%= if @post_editor.preview_url do %>
<iframe class="editor-preview-frame" src={@post_editor.preview_url}></iframe>
<% else %>
@@ -398,14 +398,14 @@
data-monaco-word-wrap="on"
data-monaco-insert-event="post-editor-insert-content"
>
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
<textarea id={"post-editor-content-#{@post_editor.id}"} class="monaco-editor-input post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18" spellcheck="false"><%= @post_editor.form["content"] %></textarea>
</div>
<% end %>
</div>
</form>
<div class="editor-footer">
<div class="editor-footer flex shrink-0 flex-wrap gap-4">
<span><strong><%= dgettext("ui", "Created") %>:</strong> <%= @post_editor.footer.created_at %></span>
<span><strong><%= dgettext("ui", "Updated") %>:</strong> <%= @post_editor.footer.updated_at %></span>
<%= if @post_editor.footer.published_at do %>

View File

@@ -1,7 +1,7 @@
<div class="scripts-view-shell editor" data-testid="script-editor">
<div class="editor-header scripts-header">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @script_editor.title %></span></div></div>
<div class="editor-actions">
<div class="scripts-view-shell editor flex h-full min-h-0 flex-col" data-testid="script-editor">
<div class="editor-header scripts-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs flex min-w-0 flex-1 overflow-hidden"><div class="editor-tab active inline-flex max-w-full items-center overflow-hidden px-3 py-2"><span class="editor-tab-title truncate"><%= @script_editor.title %></span></div></div>
<div class="editor-actions flex flex-wrap items-center justify-end gap-2">
<span class={[
"status-badge",
"status-#{@script_editor.status}"
@@ -15,35 +15,35 @@
<button class="secondary danger" type="button" phx-click="delete_script_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button>
</div>
</div>
<form class="editor-content scripts-view" phx-change="change_script_editor" phx-target={@myself}>
<div class="editor-header-row scripts-meta-row">
<div class="editor-meta">
<div class="editor-field-row">
<div class="editor-field"><label><%= dgettext("ui", "Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div>
<div class="editor-field"><label><%= dgettext("ui", "Slug") %></label><input type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
<form class="editor-content scripts-view flex min-h-0 flex-1 flex-col gap-4 overflow-hidden" phx-change="change_script_editor" phx-target={@myself}>
<div class="editor-header-row scripts-meta-row grid gap-4">
<div class="editor-meta flex min-w-0 flex-col gap-4">
<div class="editor-field-row grid gap-4 md:grid-cols-2">
<div class="editor-field flex flex-col gap-1.5"><label><%= dgettext("ui", "Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div>
<div class="editor-field flex flex-col gap-1.5"><label><%= dgettext("ui", "Slug") %></label><input type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
</div>
<div class="editor-field-row">
<div class="editor-field"><label><%= dgettext("ui", "Kind") %></label><select name="script_editor[kind]"><option value="utility" selected={@script_editor.kind == "utility"}>utility</option><option value="macro" selected={@script_editor.kind == "macro"}>macro</option><option value="transform" selected={@script_editor.kind == "transform"}>transform</option></select></div>
<div class="editor-field"><label><%= dgettext("ui", "Entrypoint") %></label><select name="script_editor[entrypoint]"><%= for entrypoint <- @script_editor.entrypoints do %><option value={entrypoint} selected={entrypoint == @script_editor.entrypoint}><%= entrypoint %></option><% end %></select></div>
<div class="editor-field scripts-enabled-field"><label><input type="checkbox" name="script_editor[enabled]" checked={@script_editor.enabled} /> <%= dgettext("ui", "Enabled") %></label></div>
<div class="editor-field-row grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div class="editor-field flex flex-col gap-1.5"><label><%= dgettext("ui", "Kind") %></label><select name="script_editor[kind]"><option value="utility" selected={@script_editor.kind == "utility"}>utility</option><option value="macro" selected={@script_editor.kind == "macro"}>macro</option><option value="transform" selected={@script_editor.kind == "transform"}>transform</option></select></div>
<div class="editor-field flex flex-col gap-1.5"><label><%= dgettext("ui", "Entrypoint") %></label><select name="script_editor[entrypoint]"><%= for entrypoint <- @script_editor.entrypoints do %><option value={entrypoint} selected={entrypoint == @script_editor.entrypoint}><%= entrypoint %></option><% end %></select></div>
<div class="editor-field scripts-enabled-field flex flex-col justify-end gap-1.5"><label><input type="checkbox" name="script_editor[enabled]" checked={@script_editor.enabled} /> <%= dgettext("ui", "Enabled") %></label></div>
</div>
</div>
</div>
<div class="editor-body scripts-editor">
<div class="editor-toolbar scripts-toolbar"><div class="editor-toolbar-left"><label><%= dgettext("ui", "Content") %></label></div></div>
<div class="editor-body scripts-editor flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="editor-toolbar scripts-toolbar flex items-center gap-3"><div class="editor-toolbar-left flex items-center gap-2"><label><%= dgettext("ui", "Content") %></label></div></div>
<div
id={"script-editor-monaco-shell-#{@script_editor.id}"}
class="scripts-monaco monaco-editor-shell"
class="scripts-monaco monaco-editor-shell min-h-0 flex-1 overflow-hidden"
phx-hook="MonacoEditor"
data-monaco-editor-id={@script_editor.id}
data-monaco-input-id={"script-editor-content-#{@script_editor.id}"}
data-monaco-language="lua"
data-monaco-word-wrap="on"
>
<div id={"script-editor-monaco-#{@script_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
<div id={"script-editor-monaco-#{@script_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
<textarea id={"script-editor-content-#{@script_editor.id}"} class="monaco-editor-input code-editor-textarea" name="script_editor[content]" spellcheck="false"><%= @script_editor.content %></textarea>
</div>
</div>
<div class="editor-footer"><span class="text-muted text-small"><%= dgettext("ui", "Created") %>: <%= BDS.Persistence.timestamp_to_iso8601(@script_editor.created_at) %></span><span class="text-muted text-small"><%= dgettext("ui", "Updated") %>: <%= BDS.Persistence.timestamp_to_iso8601(@script_editor.updated_at) %></span></div>
<div class="editor-footer flex shrink-0 flex-wrap gap-4"><span class="text-muted text-small"><%= dgettext("ui", "Created") %>: <%= BDS.Persistence.timestamp_to_iso8601(@script_editor.created_at) %></span><span class="text-muted text-small"><%= dgettext("ui", "Updated") %>: <%= BDS.Persistence.timestamp_to_iso8601(@script_editor.updated_at) %></span></div>
</form>
</div>

View File

@@ -1,22 +1,22 @@
<div
id="settings-editor-shell"
class="settings-view-shell"
class="settings-view-shell flex h-full min-h-0 flex-col overflow-hidden"
data-testid="settings-editor"
phx-hook="SettingsSectionScroll"
data-selected-settings-section={@settings_editor.selected_section}
data-settings-scroll-target={"settings-section-#{@settings_editor.selected_section}"}
>
<div class="settings-view">
<div class="settings-header">
<div class="settings-view flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="settings-header flex shrink-0 items-center justify-between gap-3">
<h2 data-testid="editor-title"><%= dgettext("ui", "Settings") %></h2>
<form class="settings-search" phx-change="change_settings_search" phx-target={@myself}>
<form class="settings-search w-full max-w-xs" phx-change="change_settings_search" phx-target={@myself}>
<input type="text" name="query" value={@settings_editor.search_query} placeholder={dgettext("ui", "Search settings")} />
</form>
</div>
<div class="settings-content">
<div class="settings-content min-h-0 flex-1 overflow-auto">
<%= if Enum.empty?(@settings_editor.active_sections) do %>
<div class="settings-no-results">
<div class="settings-no-results flex items-center justify-center py-6">
<p><%= dgettext("ui", "No settings match the current search") %></p>
</div>
<% end %>

View File

@@ -56,25 +56,25 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
if is_map(filters) and Map.get(filters, :enabled) do
~H"""
<form class="search-box" data-testid="sidebar-search-form" phx-change="update_sidebar_search" phx-submit="update_sidebar_search">
<form class="search-box flex items-center gap-2" data-testid="sidebar-search-form" phx-change="update_sidebar_search" phx-submit="update_sidebar_search">
<input
type="text"
name="sidebar_filters[search]"
value={Map.get(@selected_filters, :search) || ""}
placeholder={@sidebar_filters_config.search_placeholder}
/>
<button type="submit" title={dgettext("ui", "Search")}>
<button class="inline-flex h-8 w-8 items-center justify-center" type="submit" title={dgettext("ui", "Search")}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M15.7 14.3l-4.2-4.2c-.2-.2-.5-.3-.8-.3.9-1.1 1.5-2.5 1.5-4C12.2 2.6 9.6 0 6.4 0S.6 2.6.6 5.8s2.6 5.8 5.8 5.8c1.5 0 2.9-.5 4-1.4 0 .3.1.6.3.8l4.2 4.2c.2.2.5.3.7.3s.5-.1.7-.3c.4-.4.4-1 0-1.4zm-9.3-4c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"/>
</svg>
</button>
<%= if Map.get(@selected_filters, :search) do %>
<button class="clear-search" data-testid="sidebar-clear-search" type="button" phx-click="clear_sidebar_search">×</button>
<button class="clear-search inline-flex h-8 w-8 items-center justify-center" data-testid="sidebar-clear-search" type="button" phx-click="clear_sidebar_search">×</button>
<% end %>
</form>
<%= if Map.get(@sidebar_filters_config, :has_active_filters) do %>
<div class="filter-status">
<div class="filter-status flex items-center justify-between gap-2">
<span>
<%= @sidebar_filters_config.results_label %>: <%= @sidebar_filters_config.loaded_count %>/<%= @sidebar_filters_config.total_count %>
</span>
@@ -239,7 +239,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
if Map.get(filters, :has_more) do
~H"""
<div class="sidebar-load-more">
<div class="sidebar-load-more flex justify-center pt-2">
<button class="load-more-button" data-testid="sidebar-load-more" type="button" phx-click="load_more_sidebar">
<%= dgettext("ui", "Load more") %>
</button>
@@ -270,9 +270,9 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
<span data-testid="sidebar-section-title"><%= section.title %></span>
<span class="sidebar-section-count"><%= Map.get(section, :count, length(Map.get(section, :items, []))) %></span>
</div>
<div class="sidebar-list">
<div class="sidebar-list flex flex-col">
<%= for item <- Map.get(section, :items, []) do %>
<div class="sidebar-item-row" data-item-id={item.id}>
<div class="sidebar-item-row flex items-center gap-2" data-item-id={item.id}>
<button
class={["sidebar-item", "sidebar-post-item", "post-type-post", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]}
data-testid="sidebar-open-item"

View File

@@ -1,27 +1,27 @@
<div
id="tags-editor-shell"
class="tags-view-shell"
class="tags-view-shell flex h-full min-h-0 flex-col overflow-hidden"
data-testid="tags-editor"
phx-hook="TagsSectionScroll"
data-selected-tags-section={@tags_editor.selected_section}
data-tags-scroll-target={"tags-section-#{@tags_editor.selected_section}"}
>
<div class="tags-view">
<div class="tags-view-header">
<div class="tags-view flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="tags-view-header flex shrink-0 items-center justify-between gap-3">
<h2><%= dgettext("ui", "Tags") %></h2>
</div>
<div class="tags-view-content">
<div class="tags-view-content flex min-h-0 flex-1 flex-col gap-4 overflow-auto">
<div class="tags-section" id="tags-section-cloud">
<div class="tags-section-header"><h3><%= dgettext("ui", "Tag Cloud") %></h3></div>
<div class="tags-section-content">
<%= if Enum.empty?(@tags_editor.tags) do %>
<div class="tags-empty-state">
<div class="tags-empty-state flex flex-col gap-3">
<p><%= dgettext("ui", "No tags found") %></p>
<button class="secondary" type="button" phx-click="sync_tags_editor" phx-target={@myself}><%= dgettext("ui", "Discover") %></button>
</div>
<% else %>
<div class="tag-cloud">
<div class="tag-cloud flex flex-wrap gap-2">
<%= for tag <- @tags_editor.tags do %>
<button class={["tag-cloud-item", if(tag.name in @tags_editor.selected, do: "selected"), if(tag.color, do: "has-color")]} style={tag_style(tag, @tags_editor.tags)} type="button" phx-click="toggle_tag_selection" phx-value-name={tag.name} phx-target={@myself}>
<%= tag.name %><span class="tag-count"><%= tag.count %></span>
@@ -36,7 +36,7 @@
<div class="tags-section-header"><h3><%= dgettext("ui", "Create / Edit") %></h3></div>
<div class="tags-section-content">
<form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}>
<div class="tag-form-row">
<div class="tag-form-row flex flex-wrap items-center gap-3">
<input type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={dgettext("ui", "Tag name")} />
<input type="color" name="new_tag[color]" value={if(@tags_editor.new_tag["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.new_tag["color"])} />
<button class="primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button>
@@ -45,7 +45,7 @@
<%= if @tags_editor.edit_draft != %{} do %>
<form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}>
<div class="tag-form-row">
<div class="tag-form-row flex flex-wrap items-center gap-3">
<input type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} />
<input type="color" name="edit_tag[color]" value={if(@tags_editor.edit_draft["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.edit_draft["color"])} />
<select name="edit_tag[post_template_slug]">
@@ -65,8 +65,8 @@
<div class="tags-section" id="tags-section-merge">
<div class="tags-section-header"><h3><%= dgettext("ui", "Merge Tags") %></h3></div>
<div class="tags-section-content">
<div class="merge-form">
<div class="tag-form-row">
<div class="merge-form flex flex-col gap-3">
<div class="tag-form-row flex flex-wrap items-center gap-3">
<select phx-change="change_merge_target" name="target" phx-target={@myself}>
<%= for tag_name <- @tags_editor.selected do %>
<option value={tag_name} selected={tag_name == @tags_editor.merge_target}><%= tag_name %></option>

View File

@@ -1,7 +1,7 @@
<div class="templates-view-shell editor" data-testid="template-editor">
<div class="editor-header templates-header">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @template_editor.title %></span></div></div>
<div class="editor-actions">
<div class="templates-view-shell editor flex h-full min-h-0 flex-col" data-testid="template-editor">
<div class="editor-header templates-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs flex min-w-0 flex-1 overflow-hidden"><div class="editor-tab active inline-flex max-w-full items-center overflow-hidden px-3 py-2"><span class="editor-tab-title truncate"><%= @template_editor.title %></span></div></div>
<div class="editor-actions flex flex-wrap items-center justify-end gap-2">
<span class={[
"status-badge",
"status-#{@template_editor.status}"
@@ -14,34 +14,34 @@
<button class="secondary danger" type="button" phx-click="delete_template_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button>
</div>
</div>
<form class="editor-content templates-view" phx-change="change_template_editor" phx-target={@myself}>
<div class="editor-header-row templates-meta-row">
<div class="editor-meta">
<div class="editor-field-row">
<div class="editor-field"><label><%= dgettext("ui", "Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div>
<div class="editor-field"><label><%= dgettext("ui", "Slug") %></label><input type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
<form class="editor-content templates-view flex min-h-0 flex-1 flex-col gap-4 overflow-hidden" phx-change="change_template_editor" phx-target={@myself}>
<div class="editor-header-row templates-meta-row grid gap-4">
<div class="editor-meta flex min-w-0 flex-col gap-4">
<div class="editor-field-row grid gap-4 md:grid-cols-2">
<div class="editor-field flex flex-col gap-1.5"><label><%= dgettext("ui", "Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div>
<div class="editor-field flex flex-col gap-1.5"><label><%= dgettext("ui", "Slug") %></label><input type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
</div>
<div class="editor-field-row">
<div class="editor-field"><label><%= dgettext("ui", "Kind") %></label><select name="template_editor[kind]"><option value="post" selected={@template_editor.kind == :post or @template_editor.kind == "post"}>post</option><option value="list" selected={@template_editor.kind == :list or @template_editor.kind == "list"}>list</option><option value="not-found" selected={@template_editor.kind == :"not-found" or @template_editor.kind == "not-found"}>not-found</option><option value="partial" selected={@template_editor.kind == :partial or @template_editor.kind == "partial"}>partial</option></select></div>
<div class="editor-field templates-enabled-field"><label><input type="checkbox" name="template_editor[enabled]" checked={@template_editor.enabled} /> <%= dgettext("ui", "Enabled") %></label></div>
<div class="editor-field-row grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<div class="editor-field flex flex-col gap-1.5"><label><%= dgettext("ui", "Kind") %></label><select name="template_editor[kind]"><option value="post" selected={@template_editor.kind == :post or @template_editor.kind == "post"}>post</option><option value="list" selected={@template_editor.kind == :list or @template_editor.kind == "list"}>list</option><option value="not-found" selected={@template_editor.kind == :"not-found" or @template_editor.kind == "not-found"}>not-found</option><option value="partial" selected={@template_editor.kind == :partial or @template_editor.kind == "partial"}>partial</option></select></div>
<div class="editor-field templates-enabled-field flex flex-col justify-end gap-1.5"><label><input type="checkbox" name="template_editor[enabled]" checked={@template_editor.enabled} /> <%= dgettext("ui", "Enabled") %></label></div>
</div>
</div>
</div>
<div class="editor-body templates-editor">
<div class="editor-toolbar templates-toolbar"><div class="editor-toolbar-left"><label><%= dgettext("ui", "Content") %></label></div></div>
<div class="editor-body templates-editor flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="editor-toolbar templates-toolbar flex items-center gap-3"><div class="editor-toolbar-left flex items-center gap-2"><label><%= dgettext("ui", "Content") %></label></div></div>
<div
id={"template-editor-monaco-shell-#{@template_editor.id}"}
class="templates-monaco monaco-editor-shell"
class="templates-monaco monaco-editor-shell min-h-0 flex-1 overflow-hidden"
phx-hook="MonacoEditor"
data-monaco-editor-id={@template_editor.id}
data-monaco-input-id={"template-editor-content-#{@template_editor.id}"}
data-monaco-language="liquid"
data-monaco-word-wrap="on"
>
<div id={"template-editor-monaco-#{@template_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
<div id={"template-editor-monaco-#{@template_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
<textarea id={"template-editor-content-#{@template_editor.id}"} class="monaco-editor-input code-editor-textarea" name="template_editor[content]" spellcheck="false"><%= @template_editor.content %></textarea>
</div>
</div>
<div class="editor-footer"><span class="text-muted text-small"><%= dgettext("ui", "Created") %>: <%= BDS.Persistence.timestamp_to_iso8601(@template_editor.created_at) %></span><span class="text-muted text-small"><%= dgettext("ui", "Updated") %>: <%= BDS.Persistence.timestamp_to_iso8601(@template_editor.updated_at) %></span></div>
<div class="editor-footer flex shrink-0 flex-wrap gap-4"><span class="text-muted text-small"><%= dgettext("ui", "Created") %>: <%= BDS.Persistence.timestamp_to_iso8601(@template_editor.created_at) %></span><span class="text-muted text-small"><%= dgettext("ui", "Updated") %>: <%= BDS.Persistence.timestamp_to_iso8601(@template_editor.updated_at) %></span></div>
</form>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLiveTest do
import Phoenix.LiveViewTest
@shell_live_source_root Path.expand("../../../lib/bds/desktop/shell_live", __DIR__)
@endpoint BDS.Desktop.Endpoint
@css_source_files [
"tokens.css",
"shell.css",
@@ -28,6 +29,210 @@ defmodule BDS.Desktop.ShellLiveTest do
|> Enum.join("\n")
end
defp phase3_post_editor_assigns do
%{
myself: nil,
post_editor: %{
id: 42,
dirty?: true,
display_title: "Phase 3 Post",
status: :draft,
save_state: :saving,
quick_actions_open?: false,
can_publish?: true,
discard_title: "Discard draft",
discard_label: "Discard",
can_delete?: true,
metadata_expanded: true,
translation_flags: [
%{language: "en", status: :draft, active: true, label: "English", flag: "EN"}
],
form: %{
"title" => "Phase 3 Post",
"author" => "Author",
"language" => "en",
"do_not_translate" => false,
"template_slug" => "",
"excerpt" => "Excerpt",
"content" => "# Hello",
"tags" => "elixir",
"categories" => "news"
},
tag_chips: [%{name: "elixir", color: "#3b82f6"}],
tag_query: "",
tag_suggestions: [],
tag_query_addable?: false,
languages: ["en", "de"],
detect_language_enabled?: true,
slug: "phase-3-post",
category_values: ["news"],
category_query: "",
category_suggestions: [],
category_query_addable?: false,
show_template_selector?: true,
template_options: [%{slug: "default", title: "Default"}],
post_links: %{backlinks: [], outlinks: []},
linked_media: [],
excerpt_expanded: true,
mode: :markdown,
gallery_count: 1,
preview_url: nil,
footer: %{created_at: "2026-05-04", updated_at: "2026-05-04", published_at: nil},
can_translate?: true
}
}
end
defp phase3_media_editor_assigns do
%{
myself: nil,
media_editor: %{
dirty?: true,
display_title: "Hero Image",
save_state: :saved,
quick_actions_open?: false,
is_image: true,
can_detect_language?: true,
can_translate?: true,
preview_url: "/media/hero.jpg",
form: %{
"title" => "Hero Image",
"alt" => "Hero alt",
"caption" => "Caption",
"tags" => "cover",
"author" => "Author",
"language" => "en"
},
original_name: "hero.jpg",
mime_type: "image/jpeg",
file_size: "42 KB",
dimensions: "1200x800",
languages: ["en", "de"],
translations: [],
post_picker_open?: false,
post_picker_query: "",
post_picker_results: [],
post_picker_overflow_count: 0,
linked_posts: [],
editing_translation: nil
}
}
end
defp phase3_script_editor_assigns do
%{
myself: nil,
script_editor: %{
id: 7,
title: "Build Feed",
slug: "build-feed",
kind: "utility",
entrypoint: "run",
enabled: true,
content: "print('ok')",
entrypoints: ["run"],
status: :draft,
can_publish?: true,
created_at: 1_714_816_000,
updated_at: 1_714_816_000
}
}
end
defp phase3_template_editor_assigns do
%{
myself: nil,
template_editor: %{
id: 9,
title: "Post Template",
slug: "post-template",
kind: :post,
enabled: true,
content: "{{ content }}",
status: :draft,
can_publish?: true,
created_at: 1_714_816_000,
updated_at: 1_714_816_000
}
}
end
defp phase3_chat_editor_assigns do
%{
myself: nil,
chat_editor: %{
id: 5,
needs_api_key?: false,
title: "AI Assistant",
effective_model: "gpt-4.1",
model_selector_open?: false,
available_models: [],
available_model_groups: [],
messages: [],
is_streaming: false,
pending_user_message: nil,
streaming_content: "",
streaming_tool_markers: [],
streaming_inline_surfaces: [],
input: "",
send_disabled?: true,
action_error: nil
}
}
end
defp phase3_menu_editor_assigns do
%{
myself: nil,
menu_editor: %{
draft: nil,
title: "Navigation",
description: "Manage site navigation",
can_move_up?: false,
can_move_down?: false,
can_indent?: false,
can_unindent?: false,
can_delete?: false,
items: []
}
}
end
defp phase3_settings_editor_assigns do
%{
myself: nil,
current_tab: %{type: :settings, id: "settings"},
settings_editor: %{
selected_section: "project",
search_query: "",
active_sections: [],
project_visible?: false,
editor_visible?: false,
content_visible?: false,
ai_visible?: false,
publishing_visible?: false,
data_visible?: false,
technology_visible?: false,
mcp_visible?: false
}
}
end
defp phase3_tags_editor_assigns do
%{
myself: nil,
tags_editor: %{
selected_section: "cloud",
tags: [],
new_tag: %{"name" => "", "color" => "#3b82f6"},
edit_draft: %{},
selected: [],
merge_target: nil,
templates: []
}
}
end
test "shell live modules use contexts instead of direct Repo.get calls" do
source_files =
[
@@ -51,6 +256,56 @@ defmodule BDS.Desktop.ShellLiveTest do
assert offenders == []
end
@tag :phase3
test "phase 3 shell chrome renders utility-owned layout classes" do
conn = Plug.Conn.put_private(build_conn(), :phoenix_endpoint, BDS.Desktop.Endpoint)
{:ok, _view, html} = live_isolated(conn, BDS.Desktop.ShellLive)
assert html =~ "activity-bar flex h-full shrink-0 flex-col items-center"
assert html =~ "sidebar-shell flex min-w-0 shrink-0 overflow-hidden"
assert html =~ "tab-bar-empty flex h-full items-center px-3 text-sm"
assert html =~ "assistant-sidebar-shell flex min-w-0 shrink-0 overflow-hidden"
assert html =~ "status-bar flex h-[22px] shrink-0 items-center justify-between"
end
@tag :phase3
test "phase 3 editors and shared surfaces render utility-owned layouts" do
post_html = render_component(&BDS.Desktop.ShellLive.PostEditor.render/1, phase3_post_editor_assigns())
media_html = render_component(&BDS.Desktop.ShellLive.MediaEditor.render/1, phase3_media_editor_assigns())
script_html = render_component(&BDS.Desktop.ShellLive.ScriptEditor.render/1, phase3_script_editor_assigns())
template_html = render_component(&BDS.Desktop.ShellLive.TemplateEditor.render/1, phase3_template_editor_assigns())
chat_html = render_component(&BDS.Desktop.ShellLive.ChatEditor.render/1, phase3_chat_editor_assigns())
menu_html = render_component(&BDS.Desktop.ShellLive.MenuEditor.render/1, phase3_menu_editor_assigns())
settings_html = render_component(&BDS.Desktop.ShellLive.SettingsEditor.render/1, phase3_settings_editor_assigns())
tags_html = render_component(&BDS.Desktop.ShellLive.TagsEditor.render/1, phase3_tags_editor_assigns())
assert post_html =~ "post-editor editor flex h-full min-h-0 flex-col"
assert post_html =~ "editor-header flex shrink-0 items-start justify-between gap-3"
assert post_html =~ "editor-field flex flex-col gap-1.5"
assert post_html =~ "editor-toolbar flex items-center gap-3"
assert media_html =~ "media-editor editor flex h-full min-h-0 flex-col"
assert media_html =~ "editor-content media-editor grid min-h-0 flex-1 gap-4"
assert script_html =~ "scripts-view-shell editor flex h-full min-h-0 flex-col"
assert script_html =~ "editor-content scripts-view flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
assert template_html =~ "templates-view-shell editor flex h-full min-h-0 flex-col"
assert template_html =~ "editor-content templates-view flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
assert chat_html =~ "chat-panel flex h-full min-h-0 flex-col"
assert chat_html =~ "chat-panel-header flex shrink-0 items-center justify-between gap-3"
assert menu_html =~ "menu-editor-view flex h-full min-h-0 flex-col"
assert menu_html =~ "menu-editor-toolbar flex flex-wrap items-center gap-2"
assert settings_html =~ "settings-view-shell flex h-full min-h-0 flex-col overflow-hidden"
assert settings_html =~ "settings-header flex shrink-0 items-center justify-between gap-3"
assert tags_html =~ "tags-view-shell flex h-full min-h-0 flex-col overflow-hidden"
assert tags_html =~ "tag-form-row flex flex-wrap items-center gap-3"
end
alias BDS.Persistence
alias BDS.AI
alias BDS.CliSync.Watcher
@@ -191,8 +446,6 @@ defmodule BDS.Desktop.ShellLiveTest do
end
end
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
@@ -694,7 +947,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(data-testid="status-bar")
assert html =~ ~s(data-testid="status-task-button")
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
assert html =~ ~s(data-testid="activity-button")
assert html =~ ~s(data-view="posts")
assert html =~ ~s(data-view="media")
@@ -711,14 +964,14 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html =
view
|> element("[data-testid='toggle-sidebar']")
|> render_click()
refute html =~ ~s(class="sidebar-shell is-hidden")
refute html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html =
view
@@ -726,7 +979,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click()
assert html =~ ~s(data-region="panel")
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
assert html =~ ~s(data-testid="panel-close")
html =
@@ -734,7 +987,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='panel-close']")
|> render_click()
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html =
view
@@ -765,7 +1018,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click()
refute html =~ ~s(data-tab-type="settings")
assert html =~ ~s(class="tab-bar-empty")
assert html =~ ~s(class="tab-bar-empty flex h-full items-center px-3 text-sm")
end
test "macos hides the custom titlebar and moves shell toggles into the status bar" do
@@ -785,7 +1038,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html =
render_hook(view, "native_menu_action", %{"action" => "edit_preferences"})
@@ -1053,7 +1306,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(data-tab-type="media")
assert html =~ ~s(data-tab-id="media-1")
assert html =~ ~s(class="tab active transient")
assert Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html)
end
test "workbench session restore renders documentation tab content" do
@@ -1469,7 +1722,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(class="tab active transient")
assert Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html)
html =
render_click(view, "pin_sidebar_item", %{
@@ -1480,7 +1733,7 @@ defmodule BDS.Desktop.ShellLiveTest do
})
assert html =~ ~s(data-tab-id="post-1")
refute html =~ ~s(class="tab active transient")
refute Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html)
html =
render_click(view, "open_sidebar_item", %{
@@ -1521,13 +1774,13 @@ defmodule BDS.Desktop.ShellLiveTest do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html = render_keydown(view, "shortcut", %{key: "b", meta: true})
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html = render_keydown(view, "shortcut", %{key: "j", meta: true})
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html = render_keydown(view, "shortcut", %{key: "2", meta: true})
assert html =~ ~s(data-view="media")
@@ -1557,7 +1810,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
assert html =~ ~s(style="width: 0px;")
end
@@ -1596,8 +1849,8 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="sidebar-search-form")
assert html =~ ~s(data-testid="sidebar-filter-toggle")
assert html =~ ~s(class="sidebar-section-header")
assert html =~ ~s(class="sidebar-actions")
assert html =~ ~s(class="sidebar-section-header flex items-center justify-between gap-2")
assert html =~ ~s(class="sidebar-actions flex items-center gap-1")
assert html =~ ~s(data-testid="sidebar-load-more")
assert html_position(html, ~s(data-testid="sidebar-load-more")) >
@@ -1762,19 +2015,22 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Add published posts"
html = render_click(view, "select_panel_tab", %{"tab" => "output"})
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html =
view
|> element("[data-testid='status-task-button']")
|> render_click()
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
assert html =~
~s(<button class="panel-tab active" type="button" phx-click="select_panel_tab" phx-value-tab="tasks">)
assert Regex.match?(
~r/<button class="panel-tab [^"]*active" type="button" phx-click="select_panel_tab" phx-value-tab="tasks">/,
html
)
assert html =~ ~s(class="task-list") or html =~ "No background tasks running"
assert html =~ ~s(class="task-list flex flex-col gap-2") or
html =~ ~s(class="panel-entry panel-empty-state")
end
test "metadata diff tasks localize task text, show progress, and open the diff result in the UI" do
@@ -2273,7 +2529,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_change()
html = render(view)
assert html =~ ~s(class="tab active dirty")
assert Regex.match?(~r/class="tab [^"]*active[^"]*dirty/, html)
assert html =~ "Updated Shell Post"
_html = render_hook(view, "native_menu_action", %{"action" => "save"})
@@ -2868,7 +3124,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "published"
})
assert published_script_html =~ ~s(class="scripts-view-shell editor")
assert published_script_html =~ ~s(class="scripts-view-shell editor flex h-full min-h-0 flex-col")
assert published_script_html =~ ~s(data-testid="script-editor")
assert published_script_html =~ ~s(data-testid="script-status-badge")
assert published_script_html =~ ~s(class="status-badge status-published")
@@ -2889,7 +3145,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "published"
})
assert published_template_html =~ ~s(class="templates-view-shell editor")
assert published_template_html =~ ~s(class="templates-view-shell editor flex h-full min-h-0 flex-col")
assert published_template_html =~ ~s(data-testid="template-editor")
assert published_template_html =~ ~s(data-testid="template-status-badge")
assert published_template_html =~ ~s(class="status-badge status-published")
@@ -3082,8 +3338,9 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => media.original_name
})
assert html =~ ~s(class="editor-content media-editor")
assert html =~ ~s(class="quick-actions-wrapper")
assert html =~
"class=\"editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]\""
assert html =~ ~s(class="quick-actions-wrapper relative")
refute html =~ ~s(class="media-editor-form")
assert has_element?(
@@ -3107,7 +3364,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click()
assert html =~ ~s(class="translation-modal-backdrop")
assert html =~ ~s(class="translation-modal")
assert html =~ ~s(class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden")
assert html =~ ~s(name="media_translation[title]")
assert html =~ ~s(name="media_translation[alt]")
assert html =~ ~s(name="media_translation[caption]")
@@ -3195,7 +3452,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "Project settings"
})
assert settings_html =~ ~s(class="settings-view-shell")
assert settings_html =~ ~s(class="settings-view-shell flex h-full min-h-0 flex-col overflow-hidden")
assert settings_html =~ ~s(class="setting-section")
refute settings_html =~ "Desktop workbench content routed through the Elixir shell."
@@ -3207,7 +3464,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "Manage tags"
})
assert tags_html =~ ~s(class="tags-view-shell")
assert tags_html =~ ~s(class="tags-view-shell flex h-full min-h-0 flex-col overflow-hidden")
assert tags_html =~ ~s(class="tags-section")
refute tags_html =~ "Desktop workbench content routed through the Elixir shell."
@@ -3231,7 +3488,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => script.slug
})
assert script_html =~ ~s(class="scripts-view-shell editor")
assert script_html =~ ~s(class="scripts-view-shell editor flex h-full min-h-0 flex-col")
assert script_html =~ "scripts-monaco"
assert script_html =~ ~s(data-monaco-language="lua")
assert script_html =~ ~s(data-monaco-word-wrap="on")
@@ -3246,7 +3503,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => template.slug
})
assert template_html =~ ~s(class="templates-view-shell editor")
assert template_html =~ ~s(class="templates-view-shell editor flex h-full min-h-0 flex-col")
assert template_html =~ "templates-monaco"
assert template_html =~ ~s(data-monaco-language="liquid")
assert template_html =~ ~s(data-monaco-word-wrap="on")
@@ -3261,8 +3518,8 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
assert chat_html =~ ~s(class="chat-panel")
assert chat_html =~ ~s(class="chat-input-container")
assert chat_html =~ ~s(class="chat-panel flex h-full min-h-0 flex-col")
assert chat_html =~ ~s(class="chat-input-container flex shrink-0 flex-col gap-3")
refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
end
@@ -3281,8 +3538,8 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="chat-model-selector-button")
assert html =~ ~s(class="chat-panel-title-main")
assert html =~ ~s(class="chat-model-selector-wrap")
assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline")
assert html =~ ~s(class="chat-model-selector-wrap relative shrink-0")
assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline inline-flex items-center gap-2")
refute html =~ ~s(class="chat-panel-header-actions")
css = desktop_css_source()
@@ -3330,7 +3587,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='chat-model-selector-button']")
|> render_click()
assert selector_html =~ ~s(class="chat-model-selector-menu")
assert selector_html =~ ~s(class="chat-model-selector-menu absolute right-0 top-full z-10 mt-2 flex min-w-56 flex-col")
assert selector_html =~ ~s(data-testid="chat-model-selector-option")
assert selector_html =~ "llama-current"
@@ -3380,7 +3637,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
assert html =~ ~s(<span class="tab-title">New Chat</span>)
assert html =~ ~s(<span class="tab-title truncate">New Chat</span>)
_html =
view
@@ -3408,10 +3665,10 @@ defmodule BDS.Desktop.ShellLiveTest do
end)
assert AI.get_chat_conversation(conversation.id).title == "Posts 2026"
assert html =~ ~s(<span class="tab-title">Posts 2026</span>)
assert html =~ ~s(<span class="tab-title truncate">Posts 2026</span>)
assert html =~ ~r/<span class="chat-panel-title-main">\s*Posts 2026\s*<\/span>/
assert html =~ ~s(<span class="chat-item-title">Posts 2026</span>)
refute html =~ ~s(<span class="tab-title">New Chat</span>)
refute html =~ ~s(<span class="tab-title truncate">New Chat</span>)
refute html =~ ~s(<span class="chat-item-title">New Chat</span>)
end

View File

@@ -261,16 +261,16 @@ defmodule BDS.DesktopTest do
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
assert conn.status == 200
assert conn.resp_body =~ ~s(class="app")
assert conn.resp_body =~ ~s(class="app flex h-full w-full flex-col")
refute conn.resp_body =~ ~s(data-testid="window-titlebar")
refute conn.resp_body =~ ~s(data-testid="window-titlebar-menu-bar")
assert conn.resp_body =~ ~s(data-testid="status-shell-controls")
assert conn.resp_body =~ ~s(data-testid="toggle-sidebar")
assert conn.resp_body =~ ~s(data-testid="toggle-panel")
assert conn.resp_body =~ ~s(data-testid="toggle-assistant")
assert conn.resp_body =~ ~s(class="activity-bar")
assert conn.resp_body =~ ~s(class="sidebar")
assert conn.resp_body =~ ~s(class="status-bar")
assert conn.resp_body =~ ~s(class="activity-bar flex h-full shrink-0 flex-col items-center justify-between")
assert conn.resp_body =~ ~s(class="sidebar flex min-w-0 flex-1 overflow-hidden")
assert conn.resp_body =~ ~s(class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2")
assert conn.resp_body =~ ~s(data-phx-main)
assert conn.resp_body =~ ~s(href="/assets/app.css")
assert conn.resp_body =~ ~s(src="/assets/app.js")

View File

@@ -140,6 +140,44 @@ defmodule BDS.UI.ShellTest do
assert template =~ "data-workbench-session={encoded_workbench_session(@workbench)}"
end
test "tailwind source keeps theme tokens and shared component primitives" do
css = css_source()
app_css = File.read!("/Users/gb/Projects/bDS2/assets/css/app.css")
assert app_css =~ ~s|@import "tailwindcss" source(none);|
assert css =~ "@theme"
assert css =~ "--color-shell-bg:"
assert css =~ "--font-sans:"
assert css =~ "@layer components"
assert css =~ ".btn-base"
assert css =~ ".btn-theme-primary"
assert css =~ ".btn-theme-danger"
assert css =~ ".panel-entry"
assert css =~ ".monaco-host"
end
test "live javascript is split into focused Phoenix asset modules" do
app_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js")
assert app_js =~ ~s(import { createHooks } from "./hooks/index.js";)
assert app_js =~ ~s(import { syncTitlebarOverlayInsets } from "./bridges/titlebar_overlay.js";)
assert app_js =~ ~s(import { createMenuRuntimeCommandRunner } from "./bridges/menu_runtime.js";)
assert app_js =~ ~s(import { createMonacoServices } from "./monaco/services.js";)
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/index.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/bridges/titlebar_overlay.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/bridges/menu_runtime.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/services.js")
end
test "top level shell render uses utility classes for common layout" do
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert template =~ ~s(class="app flex h-full w-full flex-col")
assert template =~ ~s(class="app-main flex min-h-0 flex-1 overflow-hidden")
assert template =~ ~s(class="app-content flex min-w-0 flex-1 flex-col overflow-hidden")
assert template =~ ~s(class="tab-bar flex h-[35px] shrink-0 items-center overflow-hidden")
end
test "desktop shell css keeps editor and help docs on the VS Code dark surface" do
css = css_source()
@@ -261,6 +299,7 @@ defmodule BDS.UI.ShellTest do
test "desktop shell assets keep old activity, tab, focus, and titlebar overlay parity rules" do
css = css_source()
live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js")
titlebar_js = File.read!("/Users/gb/Projects/bDS2/assets/js/bridges/titlebar_overlay.js")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert css =~ "color: var(--vscode-activityBar-foreground)"
@@ -276,9 +315,9 @@ defmodule BDS.UI.ShellTest do
assert css =~ "justify-content: space-between"
assert css =~ "align-items: center"
assert css =~ "padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));"
assert live_js =~ "windowControlsOverlay"
assert live_js =~ "geometrychange"
assert live_js =~ "--bds-titlebar-overlay-left"
assert titlebar_js =~ "windowControlsOverlay"
assert titlebar_js =~ "geometrychange"
assert titlebar_js =~ "--bds-titlebar-overlay-left"
assert live_js =~ "dataset.shortcuts"
assert live_js =~ "addEventListener(\"keydown\", this.handleShortcutKeyDown, true)"
assert live_js =~ "event.preventDefault()"
@@ -360,15 +399,15 @@ defmodule BDS.UI.ShellTest do
assert css =~ "opacity: 1;"
assert Regex.match?(
~r/class="secondary quick-actions-btn".*?<span class="quick-actions-btn-icon">⚡<\/span>\s*<span class="quick-actions-btn-label"><%= dgettext\("ui", "Quick Actions"\) %><\/span>/s,
~r/class="secondary quick-actions-btn inline-flex items-center gap-2".*?<span class="quick-actions-btn-icon">⚡<\/span>\s*<span class="quick-actions-btn-label"><%= dgettext\("ui", "Quick Actions"\) %><\/span>/s,
template
)
assert template =~ ~s(class="quick-action-text")
assert template =~ ~s(class="quick-action-text flex min-w-0 flex-1 flex-col")
assert template =~ ~s(class="quick-action-icon">🤖</span>)
assert Regex.match?(
~r/class="quick-action-text">\s*<strong><%= dgettext\("ui", "AI Suggestions"\) %><\/strong>.*?<\/span>\s*<span class="quick-action-icon">🤖<\/span>/s,
~r/class="quick-action-text[^"]*">\s*<strong><%= dgettext\("ui", "AI Suggestions"\) %><\/strong>.*?<\/span>\s*<span class="quick-action-icon">🤖<\/span>/s,
template
)
@@ -480,7 +519,7 @@ defmodule BDS.UI.ShellTest do
assert post_editor_ex =~ "defp build_data(socket)"
assert Regex.match?(
~r/class="secondary quick-actions-btn".*?<span class="quick-actions-btn-icon">⚡<\/span>\s*<span class="quick-actions-btn-label"><%= dgettext\("ui", "Quick Actions"\) %><\/span>/s,
~r/class="secondary quick-actions-btn inline-flex items-center gap-2".*?<span class="quick-actions-btn-icon">⚡<\/span>\s*<span class="quick-actions-btn-label"><%= dgettext\("ui", "Quick Actions"\) %><\/span>/s,
post_template
)