Compare commits

..

7 Commits

66 changed files with 25649 additions and 9045 deletions

View File

@@ -20,6 +20,7 @@ This document provides context and best practices for GitHub Copilot when workin
- you must use ecto to generate migrations and snapshots - you must use ecto to generate migrations and snapshots
- on MacOS we use native menus and you have to hook them into the intercept for new menu items - on MacOS we use native menus and you have to hook them into the intercept for new menu items
- there are two areas of localization, you sometimes need both (menus for example) - there are two areas of localization, you sometimes need both (menus for example)
- localization is done with elixier gettext and you need mix gettext.extract to update translation files
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast - all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other. - metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database - if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database

4275
API.md

File diff suppressed because it is too large Load Diff

577
TAILWIND.md Normal file
View File

@@ -0,0 +1,577 @@
# Tailwind And Phoenix Asset Tooling For bDS2 Desktop
## Purpose
This document describes the target styling architecture for a Tailwind-integrated Phoenix LiveView desktop app in this repository.
It is written as a handoff for a coding agent that will perform the implementation.
This is not a migration guide from a public web app. It is a target-state guide for a Phoenix LiveView desktop shell with local assets, dense editor surfaces, overlays, resizable panes, and desktop-specific titlebar behavior.
## Verified Current State
The current app does not use the default Phoenix asset pipeline.
- CSS is served directly from `priv/ui/app.css`.
- JS is served directly from `priv/ui/live.js`.
- Static assets are served from `priv/ui` in `lib/bds/desktop/endpoint.ex`.
- The root layout links `/assets/app.css` in `lib/bds/desktop/layouts.ex`, but that path is currently backed by `priv/ui/app.css`, not by generated Phoenix assets.
- There is no `:tailwind` or `:esbuild` setup in `mix.exs`.
Relevant files:
- `lib/bds/desktop/endpoint.ex`
- `lib/bds/desktop/layouts.ex`
- `mix.exs`
- `priv/ui/app.css`
- `priv/ui/live.js`
## Official Phoenix Facts
The implementation target should follow Phoenix defaults where they fit the desktop shell.
Official Phoenix references:
- Phoenix Asset Management: https://hexdocs.pm/phoenix/asset_management.html
- Phoenix Components and HEEx: https://hexdocs.pm/phoenix/components.html
- Phoenix.Component reference: https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html
Facts from Phoenix docs and generator templates:
- Phoenix v1.7+ defaults to Tailwind for CSS and esbuild for JS.
- The default CSS source entrypoint is `assets/css/app.css`.
- The default generated CSS output is `priv/static/assets/css/app.css`.
- The default generated JS output is `priv/static/assets/js/app.js`.
- Phoenix promotes function components and HEEx as the main rendering model.
- Phoenix does not prescribe per-component CSS modules. Tailwind-first HEEx plus shared source CSS is a valid Phoenix-default shape.
## Target Outcome
The target outcome is a solid Phoenix-default asset setup with Tailwind as the main styling system and a generated production stylesheet.
The target should look like this:
- Tailwind source lives in `assets/css/app.css` and imported CSS modules.
- The final stylesheet is generated into `priv/static/assets/css/app.css`.
- LiveView JS entrypoint lives in `assets/js/app.js` and builds to `priv/static/assets/js/app.js`.
- The desktop endpoint serves static assets from `priv/static`.
- Layouts reference the generated CSS and JS outputs.
- HEEx markup carries most layout, spacing, typography, responsive, and state classes.
- Authored CSS remains for global tokens, app-region behavior, pseudo-elements, scrollbars, Monaco integration, hard selectors, and overlay mechanics.
## Architecture Rules
### 1. Tailwind Owns The Common Case
Use Tailwind utility classes directly in HEEx for:
- flex and grid layout
- spacing
- typography
- borders and radii
- colors and opacity
- active, selected, disabled, and hover states
- responsive breakpoints
- overflow and truncation
Do not preserve large semantic wrapper classes when they only encode simple layout decisions.
Good examples to move into HEEx classes:
- tab rows
- button rows
- editor header layout
- metadata field layout
- sidebar list row spacing
- status bar item alignment
### 2. Authored CSS Owns The Desktop-Specific Case
Keep authored CSS source files for:
- `app-region` and `-webkit-app-region`
- pseudo-elements used for icons, handles, and active markers
- custom scrollbars
- Monaco/editor iframe or host integration
- absolute overlay stacks and backdrops
- drag and drop affordances
- complex attribute selectors
- hard-to-read repeated combinations that should become shared component classes
Do not force these into giant utility strings if readability drops.
### 3. Keep A Thin Semantic CSS Layer
It is acceptable to keep a small number of semantic component classes when they encode a repeated desktop UI primitive.
Examples of valid semantic classes in the target state:
- `.window-titlebar`
- `.resizable-panel-divider`
- `.overlay-root`
- `.monaco-host`
- `.panel-entry`
- `.btn-base`, `.btn-theme-primary`, `.btn-theme-danger`
These classes should be implemented in Tailwind source CSS using `@layer components` and should remain small, stable, and reusable.
### 4. Tokens Must Be Centralized
The current stylesheet relies heavily on VS Code-like CSS variables. Preserve that idea.
The new `assets/css/app.css` must define the design tokens once, using Tailwind v4 `@theme` plus any required raw CSS custom properties for runtime-driven values.
Examples from the current app that must survive:
- shell/background colors
- tab active/inactive colors
- status bar colors
- focus border color
- input background and border colors
- sidebar width
- assistant width
- font family and font size
### 5. Desktop Layout Constraints Must Stay Intact
The styling rewrite must preserve these runtime constraints:
- the app occupies full window width and height
- the shell uses `overflow: hidden` at the top level
- the main workbench uses `min-height: 0` and `min-width: 0` correctly
- resizable sidebars remain width-variable
- titlebar remains draggable except for interactive controls
- overlays render above the shell without breaking keyboard focus or pointer behavior
## Proposed Asset Layout
Use the standard Phoenix asset layout.
```text
assets/
css/
app.css
shell.css
sidebar.css
tabs.css
editor.css
forms.css
overlays.css
panel.css
assistant.css
menu_editor.css
media_editor.css
utilities.css
js/
app.js
...
priv/
static/
assets/
css/
js/
```
Recommended responsibilities:
- `assets/css/app.css`: Tailwind import, `@source`, tokens, base layer, imports
- `assets/css/shell.css`: app shell, titlebar, activity bar, pane shells, status bar
- `assets/css/sidebar.css`: sidebar filters, search, chips, calendar tree, load more
- `assets/css/tabs.css`: workbench tabs and editor tabs
- `assets/css/editor.css`: common editor frame, toolbar, meta column, shared form shell
- `assets/css/forms.css`: shared input, textarea, tag chip, picker, inline action primitives
- `assets/css/overlays.css`: overlay root, modal backdrop, dialog shells, gallery/lightbox
- `assets/css/panel.css`: panel tabs, panel entry cards, tasks, output, git log
- `assets/css/assistant.css`: assistant sidebar and chat-specific shared surfaces
- `assets/css/menu_editor.css`: menu tree, drag/drop indicators, picker lists
- `assets/css/media_editor.css`: media preview, linked post picker, detail forms
- `assets/css/utilities.css`: a very small set of custom utilities that are truly reused
## Proposed Phoenix Asset Tooling
The implementation should introduce the standard Phoenix aliases and configs.
### mix.exs
Add:
- `{:tailwind, "~> 0.3", runtime: Mix.env() == :dev}`
- `{:esbuild, "~> 0.10", runtime: Mix.env() == :dev}`
Add aliases similar to:
- `assets.setup`
- `assets.build`
- `assets.deploy`
### Versioning (mandatory)
The Elixir `:tailwind` wrapper still defaults to Tailwind v3. The plan in this document assumes **Tailwind v4** syntax (`@import "tailwindcss"`, `@theme`, `@source`, `@layer components`). Pin both tools explicitly in `config/config.exs`:
- `config :tailwind, version: "4.1.14"` (or current 4.1.x)
- `config :esbuild, version: "0.25.4"` (or current 0.25.x)
Without an explicit v4 pin the build will silently install Tailwind v3 and v4 directives will not resolve.
### No Node.js policy
The Elixir `:tailwind` and `:esbuild` wrappers download self-contained binaries and do **not** require Node.js. The implementation MUST stay Node-free unless a third-party Tailwind plugin is later required (in which case the custom `assets/build.js` route from the Phoenix asset_management guide is used). No `package.json` is added under `assets/`.
### config/config.exs
Configure Tailwind input and output paths.
Target output:
- `assets/css/app.css` -> `priv/static/assets/css/app.css`
Configure esbuild for:
- `assets/js/app.js` -> `priv/static/assets/js/app.js`
esbuild profile must include `--bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/*` and `nodePaths: [Mix.Project.build_path() <> "/../../deps"]` so `phoenix`, `phoenix_html`, and `phoenix_live_view` resolve from `deps/` without an `npm install`.
### config/dev.exs
Add Phoenix watchers for:
- Tailwind `--watch`
- esbuild `--watch`
### No phx.digest in desktop builds
This is a desktop app served through an embedded WebView, not a public web app behind a CDN.
- Do NOT run `mix phx.digest` as part of `assets.deploy`.
- Output filenames stay stable (`app.css`, `app.js`) so the layout can link them by fixed path.
- `assets.deploy` for this repo is: `tailwind default --minify`, `esbuild default --minify`. Nothing else.
### endpoint/layout changes
Update the desktop endpoint and root layout to serve and link generated assets from `priv/static/assets` instead of `priv/ui`.
Specifically:
- Replace the existing `Plug.Static` for `/assets` with `from: {:bds, "priv/static/assets"}` and `only` listing the generated `css` and `js` directories.
- Drop the `/vendor/phoenix` and `/vendor/live_view` `Plug.Static` blocks; those scripts are now bundled by esbuild from `deps/`.
- Add a dedicated `Plug.Static` for `/monaco` pointing at `priv/ui/monaco` (or move it to `priv/static/monaco`). Monaco is a prebuilt vendor drop and MUST NOT be passed through esbuild.
- Remove the `<script src="/vendor/phoenix/...">` and `<script src="/vendor/live_view/...">` tags from `lib/bds/desktop/layouts.ex`. Keep only `/assets/app.css` and `/assets/app.js`.
## JS Pipeline
The JS pipeline mirrors the CSS pipeline.
### Entrypoint
`assets/js/app.js` is the single esbuild entrypoint. It:
- imports `phoenix`, `phoenix_html`, and `phoenix_live_view` (resolved from `deps/` via esbuild `nodePaths`)
- constructs the `LiveSocket` with the desktop CSRF token
- registers all hooks currently defined inline in `priv/ui/live.js`
- wires the native menu / titlebar / shortcut bridges
### Module layout
Split the current `priv/ui/live.js` (1.4k lines) into focused modules under `assets/js/`:
- `assets/js/app.js` - entrypoint, LiveSocket, hook registration
- `assets/js/hooks/` - one file per hook
- `assets/js/bridges/` - native menu, titlebar, shortcut bridges
- `assets/js/monaco/` - thin host glue only; do NOT bundle Monaco itself
### Monaco carve-out
Monaco is loaded as prebuilt assets from `/monaco/...` and is not part of the esbuild graph. The host glue in `assets/js/monaco/` only configures the loader URL and posts messages; it does not `import` Monaco modules.
### Vendor stripping
After the switch:
- `priv/ui/app.css` is deleted (its content has been redistributed under `assets/css/`).
- `priv/ui/live.js` is deleted.
- `priv/ui/monaco/` stays (or moves to `priv/static/monaco/`).
- The `phoenix` and `phoenix_live_view` dep static drops are no longer served.
## Iconography
The app keeps its existing **inline SVG** icon set. Do NOT adopt the Phoenix 1.8 Heroicons-via-Tailwind-plugin pattern.
Reasons:
- The target visual look is defined by the current bDS UI; its icons are part of that look.
- Inline SVG keeps icons under direct control for sizing, stroke, and currentColor styling via Tailwind utility classes.
- Avoiding the Heroicons git dep keeps the build Node-free and dependency-light.
Rules:
- Existing SVG icons stay where they are (HEEx components / inline `<svg>` markup).
- Icon size and color are controlled by Tailwind utilities on the wrapping element (`size-4`, `text-[var(--color-icon)]`, etc.) using `fill="currentColor"` or `stroke="currentColor"`.
- No `:heroicons` mix dep, no Tailwind icon plugin, no `assets/vendor/heroicons.js`.
- If a new icon is needed, add the SVG inline in the appropriate component module.
## HEEx Styling Strategy
### Prefer Utility Classes In Templates
Use utility classes in HEEx for:
- shell flex layout
- editor content spacing
- section stacks
- truncation and overflow
- standard button alignment
- grid column changes at breakpoints
- selected and hovered states that are directly tied to assign state
### Keep Dynamic Class Lists Explicit
LiveView templates should build classes with arrays where state is already in assigns.
Example pattern:
```elixir
class={[
"flex items-center gap-2 px-3 py-2 text-sm",
selected? && "bg-[var(--color-selected-bg)] text-white",
disabled? && "opacity-50 pointer-events-none"
]}
```
### Do Not Hide Structure Behind Giant Custom Class Trees
Avoid re-creating the current `app.css` by keeping every nested selector and merely moving it into `assets/css`.
If a selector exists only because the old CSS owned all layout, move that responsibility into HEEx.
## Tailwind Source Conventions
### app.css
`assets/css/app.css` should use Tailwind v4 import syntax.
`source(none)` disables Tailwind's automatic content detection so only directories listed via `@source` are scanned. Every directory that ships HEEx or class strings MUST be listed. Audit `lib/` for additional renderers (preview, generation, MCP surfaces) and add `@source` lines for any that emit class names consumed at build time.
Runtime-driven values (e.g. resizable panel widths that change via CSS variable assignment from JS) MUST stay as plain CSS custom properties under `:root` or a scoped selector. `@theme` values are baked at build time and are not appropriate for values mutated at runtime. Use `@theme` for stable design tokens (colors, font sizes, spacing scale extensions) that should produce utility classes; use `:root { --foo: … }` for everything that the app writes to at runtime.
Suggested structure:
```css
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/bds/desktop";
@theme {
/* app tokens */
}
@layer base {
/* html, body, root shell defaults */
}
@import "./shell.css";
@import "./sidebar.css";
@import "./tabs.css";
@import "./editor.css";
@import "./forms.css";
@import "./panel.css";
@import "./assistant.css";
@import "./overlays.css";
@import "./menu_editor.css";
@import "./media_editor.css";
@import "./utilities.css";
```
### Component CSS Rules
When writing component CSS in imported files:
- use `@layer components` for shared semantic classes
- use `@layer utilities` only for narrowly reusable custom utilities
- keep selectors shallow
- avoid giant descendant chains unless required by generated HTML structure or overlay mechanics
- prefer `@apply` sparingly and only for stable component classes, not as a substitute for writing HEEx utilities
## Current UI Structure Reference
The implementation agent must use the current monolith as a source map, not as the final architecture.
The current stylesheet is `priv/ui/app.css` and is approximately 8.5k lines.
Use the following region map when carrying styling over.
### Current app.css region map
- Lines `1-140`: root variables, base element reset, buttons, top-level shell defaults
- Lines `141-415`: app shell and window titlebar
- `.app`, `.app-main`, `.app-content`
- `.window-titlebar*`
- Lines `416-623`: activity bar and sidebar shells
- `.activity-bar*`
- `.sidebar-shell*`, `.assistant-sidebar-shell*`
- `.sidebar*`, `.assistant-sidebar*`
- `.resizable-panel-divider`
- Lines `624-812`: workbench tab bar
- `.tab-bar*`, `.tab*`
- Lines `813-1100`: shared editor shell and editor tabs
- `.editor-shell`, `.editor-frame`, `.editor-main`, `.editor-meta`
- `.editor-toolbar*`
- `.post-editor .editor-header`, `.editor-tabs`, `.editor-tab*`
- Lines `1100-1700`: post editor forms and metadata/media panel
- `.post-editor .editor-content`
- `.post-editor .editor-field*`
- `.post-editor .post-editor-input`, `.post-editor-textarea`
- `.tag-input*`, `.tag-chip*`
- media insertion and post media list
- Line `1691` onward: first responsive collapse for editor/media layout
- Lines `1736-1833`: early shell/gallery overlay and insert-media grid rules
- Lines `1833+`: more mobile/narrow viewport adjustments
- Lines `1950-2263`: status bar and shell footer controls
- `.status-bar*`
- `.project-selector*`
- Lines `2264-2599`: overlay root and modal system
- `.overlay-root`
- `.ai-suggestions-modal*`
- `.insert-modal*`
- `.language-picker-modal*`
- `.confirm-delete-modal*`
- `.gallery-overlay*`
- Lines `2600-2876`: menu editor
- `.menu-editor-*`
- Lines `2889+`: media editor
- `[data-testid="media-editor"] *`
- Lines `3458+`: style/theme picker surface
- `.style-theme-*`
- Lines `4958`, `6722`, `8268`, `8514`: later breakpoint-specific adjustments for desktop shell and advanced editors
Treat those ranges as source material to be redistributed into the new Tailwind source layout.
## Current HEEx And Component Surface Reference
The styling rewrite needs to move with the component structure, not only with CSS selectors.
Important current rendering surfaces:
- `lib/bds/desktop/shell_live.ex`
- top-level workbench render entry
- `lib/bds/desktop/shell_live/sidebar_components.ex`
- sidebar search, archive tree, tag/category chips, nav/settings lists, load more
- `lib/bds/desktop/shell_live/panel_renderer.ex`
- tasks, output, git log, panel toolbars
- `lib/bds/desktop/shell_live/post_editor.ex`
- post editor render surface
- `lib/bds/desktop/shell_live/media_editor.ex`
- media editor render surface
- `lib/bds/desktop/shell_live/script_editor.ex`
- script editor render surface
- `lib/bds/desktop/shell_live/template_editor.ex`
- template editor render surface
- `lib/bds/desktop/shell_live/chat_editor.ex`
- assistant/chat surface
- `lib/bds/desktop/shell_live/menu_editor.ex`
- menu editor tree surface
Implementation rule:
- move simple layout and state styling into these HEEx/component surfaces
- keep authored CSS for shared primitives and complex desktop behavior
## Desktop-Specific Styling Rules
The app is a desktop shell, not a normal browser page.
The implementation must preserve:
- draggable titlebar regions
- non-draggable controls inside the titlebar
- local, app-like split-pane behavior
- fixed-height titlebar, tabs, and status bar rails
- overlay stacking over the shell
- editor and sidebar widths controlled by CSS variables where appropriate
- visual parity for assistant/sidebar/panel open and closed states
Do not optimize for tiny public web payloads at the expense of shell clarity.
Do optimize for maintainability, explicit component ownership, and predictable desktop behavior.
## Implementation Phases
### Phase 0: Tests And Validation Baseline
- Add a smoke test that requests `/assets/app.css` and `/assets/app.js` and asserts a 200 with non-empty body served from `priv/static/assets`.
- Add a render snapshot test for the top-level shell HEEx so class-string regressions during the rewrite are caught.
- Establish that every phase ends with clean `mix compile --warnings-as-errors`, `mix test`, and `mix dialyzer` runs (per repo AGENTS.md).
### Phase 1: Install Phoenix Asset Tooling
- add Tailwind and esbuild dependencies
- create `assets/css/app.css` and `assets/js/app.js`
- configure `config/config.exs`, `config/dev.exs`, and `mix.exs`
- switch endpoint/layouts to generated assets
- keep current visuals as close as possible
### Phase 2: Split The Monolith Into Source Modules
- move the current `priv/ui/app.css` into the proposed `assets/css/*.css` modules
- keep selectors mostly intact at first
- copy raw selectors only; do NOT rewrite to `@apply` in this phase
- defer all `@apply` and utility-extraction decisions to Phase 3
- verify the desktop shell still renders correctly
### Phase 3: Move Common Layout Into HEEx
- rewrite top-level shell markup, tabs, headers, and common forms to use Tailwind classes directly
- reduce selector nesting
- keep only the thin semantic CSS layer
### Phase 4: Normalize Shared Primitives
- standardize buttons
- standardize inputs and textareas
- standardize tabs and badges
- standardize panel entries and empty states
### Phase 5: Finish Desktop-Specific Surfaces
- overlays
- menu editor drag/drop states
- media preview/detail layouts
- assistant/chat surfaces
- narrow viewport behavior
## Acceptance Criteria
The rewrite is successful when:
- assets are built through Phoenix default tooling
- the desktop endpoint serves generated assets from `priv/static`
- the visual shell matches the existing app closely enough for feature work to continue
- the current `priv/ui/app.css` is no longer the source of truth
- the new styling is split into source modules with clear ownership
- HEEx markup owns common layout and state classes
- authored CSS is limited to tokens, desktop primitives, and complex selectors
- Tailwind v4 and esbuild versions are explicitly pinned in `config/config.exs`
- no Node.js / `package.json` is introduced under `assets/`
- no `mix phx.digest` step exists; generated asset filenames remain stable
- inline SVG iconography from the existing UI is preserved (no Heroicons dep)
- `mix compile --warnings-as-errors`, `mix test`, and `mix dialyzer` are clean after every phase
## Non-Goals
These are not goals of the rewrite:
- blindly replacing every old class with utilities in one pass
- preserving the exact old selector tree
- introducing CSS modules or a React-style styling system
- optimizing for generic web landing-page concerns
- changing product behavior unrelated to styling and asset delivery
## Guidance For The Coding Agent
When implementing this plan:
- start by wiring Phoenix asset tooling, not by rewriting 8k lines of CSS in place
- preserve runtime behavior first, then simplify
- move layout decisions to HEEx only when they become clearer there
- keep desktop-specific mechanics in CSS
- use the current `priv/ui/app.css` region map as a source index, not as a blueprint for the final architecture

19
assets/css/app.css Normal file
View File

@@ -0,0 +1,19 @@
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/bds/desktop";
@import "./tokens.css";
@import "./shell.css";
@import "./sidebar.css";
@import "./tabs.css";
@import "./editor.css";
@import "./forms.css";
@import "./panel.css";
@import "./assistant.css";
@import "./overlays.css";
@import "./menu_editor.css";
@import "./media_editor.css";
@import "./import_editor.css";
@import "./utilities.css";

223
assets/css/assistant.css Normal file
View File

@@ -0,0 +1,223 @@
.settings-view-shell,
.style-view,
.tags-view-shell,
.scripts-view-shell,
.templates-view-shell,
.chat-panel {
height: 100%;
background: var(--vscode-editor-background);
}
.chat-panel {
color: var(--vscode-editor-foreground);
}
.chat-panel-header {
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background);
}
.chat-panel-title {
flex: 1;
min-width: 0;
gap: 10px;
overflow: visible;
font-size: 14px;
font-weight: 600;
}
.chat-panel-title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-model-selector-wrap {
position: relative;
display: inline-flex;
min-width: 0;
}
.chat-model-selector-button,
.chat-model-selector-option {
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
}
.chat-model-selector-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: auto;
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
z-index: 20;
}
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chat-panel .chat-model-selector-caret {
position: static;
font-size: 10px;
}
.chat-messages,
.chat-surface-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.chat-messages {
}
.chat-message {
display: flex;
max-width: 100%;
}
.chat-message.user {
justify-content: flex-end;
}
.chat-message-content {
max-width: min(760px, 100%);
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
padding: 12px 14px;
background: var(--vscode-sideBar-background);
color: var(--vscode-editor-foreground);
}
.chat-panel .chat-message.user .chat-message-content {
background: transparent;
color: var(--vscode-list-activeSelectionForeground);
border: 0;
padding: 6px 12px;
line-height: 1.35;
}
.chat-tool-surface-table {
width: 100%;
border-collapse: collapse;
}
.chat-tool-surface-table th,
.chat-tool-surface-table td {
border-bottom: 1px solid var(--vscode-panel-border);
padding: 6px 8px;
text-align: left;
}
.chat-tool-surface-json {
overflow: auto;
padding: 10px 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-textCodeBlock-background);
}
.chat-panel .chat-input-container {
--chat-input-line-height: 20px;
--chat-input-min-height: 20px;
border-top: 1px solid var(--vscode-panel-border);
padding: 8px 16px;
background: var(--vscode-sideBar-background);
}
.chat-panel .chat-input-wrapper {
min-height: 30px;
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
padding: 4px 6px;
background: var(--vscode-input-background);
}
.chat-panel .chat-input-wrapper:focus-within {
border-color: var(--vscode-focusBorder);
}
.chat-panel .chat-input {
flex: 1;
box-sizing: border-box;
height: var(--chat-input-min-height);
min-height: var(--chat-input-min-height);
margin: 0;
padding: 6px 8px;
line-height: var(--chat-input-line-height);
max-height: 160px;
resize: vertical;
border: 0;
background: transparent;
color: var(--vscode-input-foreground);
overflow-y: hidden;
}
.chat-panel .chat-input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.chat-panel .chat-send-button {
flex: 0 0 auto;
width: 22px;
height: 22px;
max-width: 22px;
max-height: 22px;
padding: 0;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.chat-panel .chat-send-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
}
.chat-panel .chat-send-button:disabled {
opacity: 0.5;
}
@media (max-width: 720px) {
.chat-panel-header {
align-items: stretch;
flex-direction: column;
padding: 10px 12px;
}
.chat-panel-title {
width: 100%;
flex-wrap: wrap;
}
.chat-model-selector-wrap {
width: 100%;
}
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
justify-content: space-between;
width: 100%;
}
.chat-messages {
padding: 12px;
}
.chat-message-content {
max-width: 100%;
}
.chat-panel .chat-input-container {
padding: 8px 12px;
}
}

956
assets/css/editor.css Normal file
View File

@@ -0,0 +1,956 @@
.editor-shell {
flex: 1;
min-height: 0;
overflow: auto;
background: var(--vscode-editor-background);
}
.editor-frame {
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
gap: 16px;
padding: 14px 16px;
}
.editor-main,
.editor-meta,
.panel-shell,
.assistant-card {
min-width: 0;
}
.editor-kicker {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.editor-title {
margin: 10px 0 6px;
font-size: 24px;
font-weight: 600;
}
.editor-subtitle {
margin: 0 0 14px;
}
.editor-toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.editor-toolbar-button {
border: 1px solid var(--vscode-panel-border);
background: transparent;
color: var(--vscode-foreground);
padding: 4px 8px;
border-radius: 3px;
}
.editor-toolbar-button:hover,
.panel-tab:hover {
background: var(--vscode-toolbar-hoverBackground);
}
.editor-section {
padding-top: 4px;
}
.editor-section h2 {
margin: 0 0 8px;
font-size: 16px;
}
.editor-list {
margin: 0;
padding-left: 18px;
line-height: 1.5;
}
.editor-list.compact li {
margin-bottom: 6px;
}
.editor-meta {
border-left: 1px solid var(--vscode-panel-border);
padding-left: 16px;
}
.editor-meta-row {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
.post-editor .post-editor-markdown-surface,
.scripts-monaco.monaco-editor-shell,
.templates-monaco.monaco-editor-shell {
min-height: 0;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border-color: var(--vscode-panel-border);
}
.post-editor .monaco-editor-instance,
.scripts-monaco .monaco-editor-instance,
.templates-monaco .monaco-editor-instance {
min-height: 0;
background: var(--vscode-editor-background);
}
.monaco-editor-shell .monaco-editor,
.monaco-editor-shell .monaco-editor .margin,
.monaco-editor-shell .monaco-editor-background,
.monaco-editor-shell .monaco-editor .inputarea.ime-input {
background-color: var(--vscode-editor-background) !important;
}
.monaco-editor-shell .monaco-editor,
.monaco-editor-shell .monaco-editor .view-line {
color: var(--vscode-editor-foreground) !important;
}
.monaco-editor-shell .monaco-editor .line-numbers {
color: var(--vscode-editorLineNumber-foreground, #858585) !important;
}
.monaco-editor-shell .monaco-editor .current-line,
.monaco-editor-shell .monaco-editor .view-overlays .current-line {
border-color: var(--vscode-editor-lineHighlightBorder, transparent) !important;
}
.help-doc-view {
--doc-bg: var(--panel-1, #1e1e1e);
--doc-surface: var(--panel-2, #252526);
--doc-border: var(--line, #3c3c3c);
--doc-text: var(--vscode-editor-foreground, #d4d4d4);
--doc-muted: var(--vscode-descriptionForeground, #9da3ad);
--doc-link: var(--vscode-textLink-foreground, #9cdcfe);
--doc-code-bg: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2));
--doc-hover: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06));
}
.help-doc-view .misc-editor-content {
padding: 0;
overflow: hidden;
}
.documentation-view,
.documentation-scroll {
background: var(--doc-bg, var(--vscode-editor-background));
}
.documentation-view {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.documentation-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 28px 24px 40px;
}
.documentation-content {
max-width: 920px;
margin: 0 auto;
color: var(--doc-text, var(--vscode-editor-foreground));
}
.documentation-article,
.help-doc-markdown {
background: var(--doc-surface);
padding: 18px 20px 24px;
border: 1px solid var(--doc-border);
border-radius: 10px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.documentation-content.markdown-body > .documentation-article > :first-child {
margin-top: 0;
}
.documentation-content.markdown-body > .documentation-article > :last-child {
margin-bottom: 0;
}
.documentation-content.markdown-body h1,
.documentation-content.markdown-body h2,
.documentation-content.markdown-body h3 {
color: var(--doc-text);
border-bottom: 1px solid var(--doc-border);
padding-bottom: 6px;
line-height: 1.25;
}
.documentation-content.markdown-body h1 {
font-size: 1.9rem;
}
.documentation-content.markdown-body h2 {
margin-top: 2rem;
font-size: 1.35rem;
}
.documentation-content.markdown-body h3 {
margin-top: 1.6rem;
font-size: 1.05rem;
}
.documentation-content.markdown-body p,
.documentation-content.markdown-body li,
.documentation-content.markdown-body td,
.documentation-content.markdown-body th {
line-height: 1.6;
}
.documentation-content.markdown-body a {
color: var(--doc-link);
text-decoration-thickness: 1px;
text-underline-offset: 0.14em;
}
.documentation-content.markdown-body a:hover {
color: var(--doc-text);
}
.documentation-content.markdown-body hr {
border: 0;
border-top: 1px solid var(--doc-border);
opacity: 0.8;
}
.documentation-content.markdown-body code {
background: var(--doc-code-bg);
padding: 0.12em 0.4em;
border-radius: 4px;
font: 0.92em/1.45 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
}
.documentation-content.markdown-body pre {
margin: 0.9rem 0 1.2rem;
background: var(--doc-code-bg);
border: 1px solid var(--doc-border);
border-radius: 8px;
padding: 14px 16px;
overflow: auto;
}
.documentation-content.markdown-body pre code {
padding: 0;
background: transparent;
font-size: 0.9em;
}
.documentation-content.markdown-body blockquote {
margin: 1rem 0;
padding: 0 0 0 12px;
border-left: 3px solid var(--doc-border);
color: var(--doc-muted);
}
.documentation-content.markdown-body table {
width: 100%;
margin: 1rem 0 1.4rem;
border-collapse: collapse;
display: table;
}
.documentation-content.markdown-body th,
.documentation-content.markdown-body td {
border: 1px solid var(--doc-border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.documentation-content.markdown-body th {
background: var(--doc-hover);
font-weight: 700;
}
.documentation-content.markdown-body ul,
.documentation-content.markdown-body ol {
margin: 0.85rem 0 1rem;
padding-left: 1.5rem;
display: block;
}
.documentation-content.markdown-body ul {
list-style: disc;
}
.documentation-content.markdown-body ol {
list-style: decimal;
}
.documentation-content.markdown-body li {
margin: 0.3rem 0;
}
.documentation-content.markdown-body li > ul,
.documentation-content.markdown-body li > ol {
margin-top: 0.35rem;
margin-bottom: 0.35rem;
}
.documentation-content.markdown-body strong {
color: var(--doc-text);
}
.documentation-content.markdown-body img {
max-width: 100%;
height: auto;
}
.post-editor,
.scripts-view-shell,
.templates-view-shell {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
overflow: hidden;
}
.post-editor .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
.post-editor .editor-tab-meta {
color: var(--vscode-descriptionForeground);
font-size: 11px;
white-space: nowrap;
}
.post-editor .quick-actions-wrapper {
position: relative;
display: inline-block;
}
.post-editor .quick-actions-btn {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.post-editor .quick-actions-btn-icon {
font-size: 12px;
line-height: 1;
}
.post-editor .quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
.post-editor .quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
.post-editor .quick-action-text strong {
font-size: 13px;
font-weight: 500;
}
.post-editor .quick-action-text small {
font-size: 11px;
opacity: 0.7;
}
.post-editor .status-badge,
.scripts-view-shell .status-badge,
.templates-view-shell .status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.post-editor .status-badge.status-draft,
.scripts-view-shell .status-badge.status-draft,
.templates-view-shell .status-badge.status-draft {
background-color: rgba(204, 167, 0, 0.2);
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
}
.post-editor .status-badge.status-published,
.scripts-view-shell .status-badge.status-published,
.templates-view-shell .status-badge.status-published {
background-color: rgba(115, 201, 145, 0.2);
color: var(--vscode-testing-iconPassed);
}
.post-editor .status-badge.status-archived,
.scripts-view-shell .status-badge.status-archived,
.templates-view-shell .status-badge.status-archived {
background-color: rgba(133, 133, 133, 0.2);
color: var(--vscode-descriptionForeground);
}
.post-editor .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
.post-editor .metadata-toggle-header {
}
.post-editor .metadata-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
background: none;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
flex-shrink: 0;
}
.post-editor .metadata-toggle:hover {
color: var(--vscode-foreground);
}
.post-editor .metadata-toggle-chevron {
font-size: 10px;
}
.post-editor .editor-header-row.is-collapsed {
display: none;
}
.post-editor .editor-media-panel {
width: 200px;
flex-shrink: 0;
}
.post-editor .editor-field label,
.post-editor .editor-body label,
.post-editor .post-editor-links-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.post-editor .editor-checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
text-transform: none;
letter-spacing: 0;
color: var(--vscode-foreground);
}
.post-editor .post-editor-input.is-readonly {
opacity: 0.7;
cursor: not-allowed;
}
.post-editor .post-editor-excerpt {
min-height: 96px;
}
.post-editor .tag-input-container {
position: relative;
width: 100%;
}
.post-editor .tag-input-container.is-disabled {
opacity: 0.72;
}
.post-editor .tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
min-height: 38px;
border: 1px solid var(--vscode-input-border, #3c3c3c);
border-radius: 4px;
background: var(--vscode-input-background, #3c3c3c);
cursor: text;
}
.post-editor .tag-input-wrapper:focus-within {
border-color: var(--vscode-focusBorder, #007fd4);
outline: none;
}
.post-editor .tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.85rem;
background: var(--vscode-badge-background, #4d4d4d);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 4px;
color: var(--vscode-badge-foreground, #ffffff);
white-space: nowrap;
}
.post-editor .tag-chip.has-color {
border-radius: 12px;
padding: 3px 10px;
}
.post-editor .tag-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin-left: 2px;
border: none;
background: transparent;
color: inherit;
font-size: 1rem;
line-height: 1;
cursor: pointer;
opacity: 0.6;
border-radius: 50%;
transition: opacity 0.15s, background 0.15s;
}
.post-editor .tag-chip-remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
.post-editor .tag-chip.has-color .tag-chip-remove:hover {
background: rgba(0, 0, 0, 0.2);
}
.post-editor .tag-input-field {
flex: 1;
min-width: 120px;
padding: 2px 4px;
border: none;
background: transparent;
color: var(--vscode-input-foreground, #cccccc);
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
.post-editor .tag-input-field::placeholder {
color: var(--vscode-input-placeholderForeground, #a6a6a6);
}
.post-editor .tag-input-field:disabled {
cursor: not-allowed;
}
.post-editor .tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 4px;
background: var(--vscode-dropdown-background, #3c3c3c);
border: 1px solid var(--vscode-widget-border, #454545);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2);
z-index: 1000;
max-height: 240px;
overflow-y: auto;
}
.post-editor .tag-suggestion {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--vscode-dropdown-foreground, #f0f0f0);
font-family: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.post-editor .tag-suggestion:hover,
.post-editor .tag-suggestion.selected {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.post-editor .tag-suggestion-color {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.post-editor .tag-suggestion-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-editor .tag-suggestion.create-new {
border-top: 1px solid var(--vscode-widget-border, #454545);
margin-top: 4px;
padding: 6px 8px;
padding-top: 12px;
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
}
.post-editor .tag-suggestion.create-new:first-child {
border-top: none;
margin-top: 0;
padding-top: 8px;
}
.post-editor .tag-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px dashed currentColor;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 600;
}
.post-editor .editor-language-row select {
flex: 1;
min-width: 0;
}
.post-editor .editor-translation-flag {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.post-editor .editor-translation-flag.status-draft {
opacity: 0.82;
}
.post-editor .editor-translation-flag.status-archived {
opacity: 0.45;
filter: grayscale(0.35);
}
.post-editor .editor-translation-flag.active {
border-color: var(--vscode-testing-iconQueued, #cca700);
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
}
.post-editor .editor-translation-flag:hover {
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
}
.post-editor .post-editor-links-panel,
.post-editor .post-editor-side-panel {
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 82%, white 3%);
}
.post-editor .post-editor-side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.post-editor .post-editor-links-columns {
display: flex;
gap: 18px;
align-items: flex-start;
margin-top: 10px;
}
.post-editor .post-editor-links-columns > div {
flex: 1;
min-width: 0;
}
.post-editor .post-editor-empty,
.post-editor .post-editor-media-meta {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.post-editor .post-editor-media-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.post-editor .post-editor-media-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.post-editor .editor-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 320px;
}
.post-editor .editor-toolbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.post-editor .editor-toolbar-left {
display: flex;
align-items: center;
justify-content: flex-start;
}
.post-editor .editor-toolbar-center {
display: flex;
align-items: center;
justify-content: center;
}
.post-editor .editor-toolbar-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.post-editor .editor-mode-toggle {
display: flex;
gap: 4px;
}
.post-editor .editor-mode-toggle button,
.post-editor .editor-toolbar-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .editor-mode-toggle button {
background-color: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-mode-toggle button:hover,
.post-editor .editor-toolbar-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground));
}
.post-editor .editor-mode-toggle button.active {
background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff);
}
.post-editor .editor-toolbar-button {
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
.post-editor .editor-excerpt-panel.is-collapsed {
display: none;
}
.post-editor .gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .insert-post-link-button,
.post-editor .insert-media-button {
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .insert-post-link-button:hover,
.post-editor .insert-media-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .editor-preview {
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
overflow: hidden;
position: relative;
min-height: 240px;
padding: 0;
border: none;
}
.post-editor .editor-preview {
flex: 1;
min-height: 240px;
padding: 14px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
overflow: auto;
line-height: 1.6;
}
.post-editor .editor-preview-frame {
width: 100%;
min-height: 520px;
border: none;
background: #ffffff;
}
.post-editor .post-editor-markdown-surface {
position: relative;
flex: 1;
min-height: 380px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px;
background: var(--vscode-input-background);
overflow: hidden;
}
.post-editor .monaco-editor-shell,
.scripts-monaco.monaco-editor-shell,
.templates-monaco.monaco-editor-shell {
position: relative;
}
.monaco-editor-instance {
width: 100%;
height: 100%;
min-height: 100%;
}
.post-editor .monaco-editor-instance {
min-height: 380px;
}
.scripts-monaco .monaco-editor-instance,
.templates-monaco .monaco-editor-instance {
min-height: 420px;
}
.monaco-editor-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: pre;
border: 0;
}
.post-editor .editor-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
color: var(--vscode-descriptionForeground);
font-size: 12px;
flex-wrap: wrap;
}
@media (max-width: 980px) {
.post-editor .editor-header,
.scripts-view-shell .ui-editor-header,
.templates-view-shell .ui-editor-header,
.post-editor .metadata-toggle-header,
.post-editor .editor-toolbar {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.post-editor .editor-header-row,
.post-editor .editor-field-row,
.post-editor .post-editor-links-columns {
flex-direction: column;
}
.post-editor .editor-media-panel {
width: 100%;
}
.post-editor .editor-toolbar-right,
.post-editor .ui-editor-actions,
.scripts-view-shell .ui-editor-actions,
.templates-view-shell .ui-editor-actions {
justify-content: flex-start;
}
}

141
assets/css/forms.css Normal file
View File

@@ -0,0 +1,141 @@
.settings-view,
.style-view {
height: 100%;
display: flex;
flex-direction: column;
}
.settings-header,
.style-view-header {
padding: 18px 20px;
border-bottom: 1px solid var(--line, #3c3c3c);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-search input {
width: min(320px, 40vw);
}
.settings-content {
padding: 20px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.setting-section {
border: 1px solid var(--line, #3c3c3c);
border-radius: 12px;
background: var(--panel-2, #252526);
}
.setting-section-header {
padding: 14px 16px;
border-bottom: 1px solid var(--line, #3c3c3c);
}
.setting-section-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-row {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.setting-label {
font-weight: 600;
}
.setting-control,
.setting-input-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.setting-actions {
padding: 0 16px 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.style-theme-picker {
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
}
.style-theme-option {
border: 1px solid var(--line, #3c3c3c);
background: var(--panel-2, #252526);
border-radius: 14px;
padding: 14px;
text-align: left;
cursor: pointer;
}
.style-theme-option.selected {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.style-theme-swatch {
display: flex;
flex-direction: column;
gap: 12px;
}
.style-theme-tones {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 8px;
}
.style-theme-tone {
height: 42px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.style-apply-row {
padding: 0 20px 20px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.style-preview-container {
padding: 0 20px 20px;
flex: 1;
min-height: 0;
}
.style-preview-frame {
width: 100%;
height: 100%;
min-height: 420px;
border: 1px solid var(--line, #3c3c3c);
border-radius: 14px;
background: #ffffff;
}
@media (max-width: 1100px) {
.setting-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,689 @@
.import-analysis {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px 20px 26px;
color: var(--vscode-foreground);
}
.import-analysis-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-analysis-header p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 13px;
line-height: 1.5;
}
.import-definition-name {
width: min(480px, 100%);
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground, var(--vscode-foreground));
border-radius: 6px;
padding: 10px 12px;
font-size: 18px;
font-weight: 600;
}
.import-file-selectors {
display: grid;
gap: 12px;
}
.import-file-row {
display: grid;
grid-template-columns: 150px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 76%, var(--vscode-input-background));
}
.import-file-row label {
font-size: 12px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-file-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 12px;
}
.import-file-path.placeholder {
color: var(--vscode-descriptionForeground);
}
.import-analysis button,
.import-analysis select {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 6px;
font-size: 12px;
}
.import-analysis button {
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
padding: 8px 12px;
cursor: pointer;
}
.import-analysis button:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
}
.import-analyze-btn,
.import-execute-btn {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
}
.import-analysis button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.import-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: import-spinner-rotate 0.8s linear infinite;
flex-shrink: 0;
}
.import-progress {
display: flex;
flex-direction: column;
gap: 2px;
}
.import-progress-step {
font-size: 13px;
color: var(--vscode-foreground);
}
.import-progress-detail {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
@keyframes import-spinner-rotate {
to {
transform: rotate(360deg);
}
}
.import-site-info {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.import-site-info-item,
.import-stat-card,
.import-date-distribution,
.import-detail-section,
.import-execute-section {
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-site-info-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
}
.info-label {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 13px;
font-weight: 500;
overflow-wrap: anywhere;
}
.import-stat-cards {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.import-stat-card {
padding: 14px;
}
.import-stat-card h3,
.import-date-distribution h3,
.import-detail-section h3,
.taxonomy-group h4 {
margin: 0;
}
.import-stat-number {
margin-top: 10px;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.import-stat-breakdown,
.import-execute-summary,
.import-taxonomy-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.import-stat-breakdown {
margin-top: 12px;
}
.import-stat-tag,
.import-count-tag,
.import-taxonomy-pill,
.macro-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.stat-new,
.import-taxonomy-pill.new-tax {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.stat-update,
.stat-mapped,
.import-taxonomy-pill.exists,
.import-taxonomy-pill.mapped,
.macro-status-badge.mapped,
.import-execution-complete {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.stat-conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.stat-duplicate,
.stat-missing,
.macro-status-badge.unmapped,
.import-execution-error {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.import-date-distribution,
.import-detail-section,
.import-execute-section {
padding: 16px;
}
.import-section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
color: inherit !important;
font-size: 16px !important;
font-weight: 600;
text-align: left;
}
.import-section-toggle:hover {
background: transparent !important;
opacity: 0.9;
}
.toggle-icon {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.distribution-bars {
display: grid;
gap: 10px;
margin-top: 14px;
}
.distribution-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 72px;
gap: 10px;
align-items: center;
}
.distribution-year,
.distribution-count,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
font-size: 11px;
}
.distribution-bar-container {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.distribution-bar {
height: 100%;
min-width: 8px;
border-radius: inherit;
}
.distribution-bar-posts {
background: linear-gradient(90deg, rgba(117, 190, 255, 0.8), rgba(117, 190, 255, 0.35));
}
.import-execute-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.import-execute-summary {
color: var(--vscode-descriptionForeground);
}
.import-execution-complete,
.import-execution-error {
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.import-execution-progress {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--vscode-panel-border);
border-radius: 10px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.import-execution-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.import-execution-header h3 {
margin: 0;
font-size: 14px;
}
.import-progress-bar {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--vscode-input-background);
}
.import-progress-fill {
height: 100%;
background: linear-gradient(90deg, rgba(117, 190, 255, 0.85), rgba(117, 190, 255, 0.45));
}
.import-progress-info {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
}
.import-phase {
font-weight: 600;
}
.import-detail,
.import-counter {
color: var(--vscode-descriptionForeground);
}
.import-detail-table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
}
.import-detail-table th,
.import-detail-table td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid var(--vscode-panel-border);
vertical-align: middle;
font-size: 12px;
}
.import-detail-table th {
font-size: 11px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.import-detail-table .status-badge.new {
background: rgba(117, 190, 255, 0.16);
color: #75beff;
}
.import-detail-table .status-badge.update {
background: rgba(115, 201, 145, 0.16);
color: #73c991;
}
.import-detail-table .status-badge.conflict {
background: rgba(255, 166, 87, 0.16);
color: #ffb169;
}
.import-detail-table .status-badge.duplicate,
.import-detail-table .status-badge.missing {
background: rgba(204, 167, 0, 0.16);
color: #cca700;
}
.categories-cell,
.existing-match,
.mime-type-cell,
.post-type-cell {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.mime-type-cell,
.post-type-cell,
.existing-match,
.slug-cell {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.resolution-select,
.taxonomy-mapping-input {
min-width: 150px;
background: var(--vscode-dropdown-background, var(--vscode-input-background));
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
padding: 6px 8px;
}
.taxonomy-analyze-row {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 12px;
margin-top: 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.taxonomy-analyze-dropdown {
position: relative;
}
.taxonomy-analyze-btn {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.taxonomy-model-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 220px;
max-height: 280px;
overflow-y: auto;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
z-index: 20;
}
.taxonomy-model-option {
width: 100%;
display: block;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--vscode-foreground) !important;
text-align: left;
padding: 8px 12px !important;
}
.taxonomy-model-option:hover {
background: var(--vscode-list-hoverBackground) !important;
}
.taxonomy-analyze-hint {
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-taxonomy-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 14px;
}
.taxonomy-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
display: inline-flex;
align-items: center;
gap: 8px;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
flex-wrap: wrap;
}
.import-taxonomy-pill {
border: none;
cursor: default;
}
button.import-taxonomy-pill {
cursor: pointer;
}
.mapped-target {
background: rgba(115, 201, 145, 0.1);
}
.taxonomy-mapping-arrow {
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.taxonomy-mapping-input {
min-width: 170px;
border-radius: 6px;
}
.taxonomy-edit-btn,
.taxonomy-clear-btn {
min-width: 28px;
min-height: 28px;
padding: 0 8px !important;
display: inline-flex;
align-items: center;
justify-content: center;
}
.taxonomy-edit-btn.ghost,
.taxonomy-clear-btn {
background: transparent !important;
border: 1px solid var(--vscode-panel-border) !important;
color: var(--vscode-descriptionForeground) !important;
}
.macros-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.macro-item {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: var(--vscode-input-background);
}
.macro-item.unmapped {
border-left: 3px solid #cca700;
}
.macro-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
}
.macro-name,
.import-taxonomy-pill {
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
}
.macro-count {
margin-left: auto;
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.import-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 56px 20px;
color: var(--vscode-descriptionForeground);
border: 1px dashed var(--vscode-panel-border);
border-radius: 12px;
}
.import-empty-state p {
margin: 0;
font-size: 13px;
}
@media (max-width: 1100px) {
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.import-analysis {
padding: 14px;
}
.import-file-row,
.distribution-row,
.import-execute-section,
.import-site-info,
.import-stat-cards,
.import-taxonomy-groups {
grid-template-columns: 1fr;
}
.import-execute-section {
align-items: stretch;
}
.import-file-row {
align-items: stretch;
}
.import-analysis button,
.resolution-select,
.taxonomy-mapping-input {
width: 100%;
}
.taxonomy-analyze-row {
flex-direction: column;
align-items: stretch;
}
.import-taxonomy-entry,
.import-taxonomy-edit-form {
width: 100%;
flex-direction: column;
align-items: stretch;
}
}

325
assets/css/media_editor.css Normal file
View File

@@ -0,0 +1,325 @@
[data-testid="media-editor"] .editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
font-size: 10px;
}
[data-testid="media-editor"] .ui-editor-actions button {
padding: 4px 10px;
font-size: 12px;
}
[data-testid="media-editor"] .ui-editor-actions button.danger:hover {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
[data-testid="media-editor"] .auto-save-indicator {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-style: italic;
}
[data-testid="media-editor"] .quick-actions-wrapper {
position: relative;
}
[data-testid="media-editor"] .quick-actions-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
[data-testid="media-editor"] .quick-actions-btn-icon {
font-size: 12px;
line-height: 1;
}
[data-testid="media-editor"] .quick-actions-divider {
height: 1px;
background: var(--vscode-dropdown-border, #454545);
}
[data-testid="media-editor"] .quick-action-icon {
font-size: 16px;
flex-shrink: 0;
margin-top: 2px;
}
[data-testid="media-editor"] .quick-action-text strong {
font-size: 13px;
font-weight: 500;
}
[data-testid="media-editor"] .quick-action-text small {
font-size: 11px;
opacity: 0.7;
}
[data-testid="media-editor"] > .editor-content.media-editor {
flex-direction: row;
align-items: stretch;
gap: 24px;
}
[data-testid="media-editor"] .editor-field label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
[data-testid="media-editor"] .post-editor-input.disabled,
[data-testid="media-editor"] .post-editor-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
[data-testid="media-editor"] .media-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 8px;
min-height: 300px;
overflow: hidden;
}
[data-testid="media-editor"] .media-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
[data-testid="media-editor"] .media-preview-image {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
width: 100%;
height: 100%;
min-height: 0;
padding: 16px;
box-sizing: border-box;
}
[data-testid="media-editor"] .media-preview-image img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}
[data-testid="media-editor"] .media-details {
width: 320px;
gap: 12px;
flex-shrink: 0;
}
[data-testid="media-editor"] .media-details textarea {
resize: vertical;
}
[data-testid="media-editor"] .linked-posts-section label {
display: flex;
justify-content: space-between;
align-items: center;
}
[data-testid="media-editor"] .add-link-btn {
background: var(--vscode-button-secondaryBackground);
border: none;
color: var(--vscode-button-secondaryForeground);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
[data-testid="media-editor"] .add-link-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
[data-testid="media-editor"] .post-picker {
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
margin-top: 8px;
max-height: 250px;
overflow-y: auto;
}
[data-testid="media-editor"] .post-picker-search {
padding: 8px;
border-bottom: 1px solid var(--vscode-dropdown-border);
position: sticky;
top: 0;
background: var(--vscode-dropdown-background);
}
[data-testid="media-editor"] .post-picker-search input {
width: 100%;
padding: 6px 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
color: var(--vscode-input-foreground);
font-size: 12px;
}
[data-testid="media-editor"] .post-picker-search input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
[data-testid="media-editor"] .post-picker-list {
padding: 4px;
}
[data-testid="media-editor"] .post-picker-item {
width: 100%;
padding: 6px 8px;
cursor: pointer;
border: none;
border-radius: 3px;
background: transparent;
color: inherit;
font-size: 12px;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .post-picker-item:hover {
background: var(--vscode-list-hoverBackground);
}
[data-testid="media-editor"] .post-picker-more {
padding: 6px 8px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-style: italic;
}
[data-testid="media-editor"] .no-posts,
[data-testid="media-editor"] .no-linked-posts {
padding: 12px 8px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
font-style: italic;
}
[data-testid="media-editor"] .linked-posts-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
[data-testid="media-editor"] .linked-post-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--vscode-sideBar-background);
border-radius: 4px;
}
[data-testid="media-editor"] .linked-post-title,
[data-testid="media-editor"] .linked-post-link {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 0;
color: inherit;
text-align: left;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-testid="media-editor"] .linked-post-title:hover,
[data-testid="media-editor"] .linked-post-link:hover {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
opacity: 0;
transition: opacity 0.1s;
}
[data-testid="media-editor"] .linked-post-item:hover .unlink-btn {
opacity: 1;
}
[data-testid="media-editor"] .linked-post-item .unlink-btn:hover {
color: var(--vscode-errorForeground);
}
.translation-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 10001;
}
.translation-modal {
width: min(640px, calc(100vw - 32px));
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.translation-modal-header,
.translation-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
}
.translation-modal-header {
border-bottom: 1px solid #3c3c3c;
}
.translation-modal-footer {
border-top: 1px solid #3c3c3c;
justify-content: flex-end;
gap: 10px;
}
.translation-modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.translation-modal-close {
border: none;
background: transparent;
color: #c5c5c5;
cursor: pointer;
font-size: 20px;
line-height: 1;
}

259
assets/css/menu_editor.css Normal file
View File

@@ -0,0 +1,259 @@
.menu-editor-header {
}
.menu-editor-header h2 {
margin: 0;
}
.menu-editor-header p {
margin: 0.25rem 0 0;
color: var(--vscode-descriptionForeground);
}
.menu-editor-tree-wrap {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editor-background);
padding: 0.5rem;
min-height: 0;
}
.menu-editor-toolbar {
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--vscode-panel-border);
}
.menu-editor-tool {
width: 1.8rem;
height: 1.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--vscode-foreground);
cursor: pointer;
padding: 0;
}
.menu-editor-tool:hover:not(:disabled) {
background: var(--vscode-toolbar-hoverBackground);
border-color: var(--vscode-panel-border);
}
.menu-editor-tool:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.menu-editor-tree-shell {
flex: 1;
min-height: 0;
overflow: auto;
}
.menu-editor-tree-level {
list-style: none;
margin: 0;
padding: 0;
}
.menu-editor-tree-item {
margin: 0;
padding: 0;
}
.menu-editor-row {
--menu-editor-indent: calc(var(--menu-editor-depth) * 1rem);
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.3rem 0.45rem 0.3rem calc(0.4rem + var(--menu-editor-indent));
border-radius: 4px;
cursor: pointer;
position: relative;
}
.menu-editor-row.is-selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.menu-editor-row.is-dragging {
opacity: 0.45;
}
.menu-editor-row.is-drop-before::before,
.menu-editor-row.is-drop-after::after {
content: "";
position: absolute;
left: calc(0.4rem + var(--menu-editor-indent));
right: 0.45rem;
height: 2px;
background: var(--vscode-focusBorder);
}
.menu-editor-row.is-drop-before::before {
top: 0;
}
.menu-editor-row.is-drop-after::after {
bottom: 0;
}
.menu-editor-row.is-drop-inside {
box-shadow: inset 0 0 0 1px var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-row-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
color: var(--vscode-descriptionForeground);
cursor: grab;
user-select: none;
}
.menu-editor-row-handle:active {
cursor: grabbing;
}
.menu-editor-row-kind {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
min-width: 1rem;
opacity: 0.9;
}
.menu-editor-row-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-editor-row-title.is-editing {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.menu-editor-entry-form {
display: block;
}
.menu-editor-inline-input {
width: 100%;
border: 1px solid var(--vscode-focusBorder);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.25rem 0.45rem;
min-height: 1.8rem;
}
.menu-editor-inline-search {
margin-top: 0.5rem;
border-top: 1px solid var(--vscode-panel-border);
padding-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 18rem;
overflow: hidden;
}
.menu-editor-inline-search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.menu-editor-inline-search-head strong {
display: block;
font-size: 0.8rem;
}
.menu-editor-inline-search-head span {
color: var(--vscode-descriptionForeground);
font-size: 0.75rem;
}
.menu-editor-inline-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.menu-editor-inline-action {
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 4px;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.menu-editor-inline-action:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.menu-editor-picker-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 16rem;
overflow-y: auto;
}
.menu-editor-picker-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.45rem 0.55rem;
text-align: left;
cursor: pointer;
}
.menu-editor-picker-item:hover {
border-color: var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-picker-item small,
.menu-editor-picker-state {
color: var(--vscode-descriptionForeground);
}
.menu-editor-empty {
color: var(--vscode-descriptionForeground);
padding: 0.5rem 0.25rem;
}
@media (max-width: 720px) {
.menu-editor-inline-search-head {
flex-direction: column;
align-items: flex-start;
}
.menu-editor-inline-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
}

336
assets/css/overlays.css Normal file
View File

@@ -0,0 +1,336 @@
.overlay-root {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10000;
}
.overlay-root:empty {
display: none;
}
.editor-shared-actions {
position: relative;
margin-bottom: 14px;
}
.ai-suggestions-modal-backdrop,
.insert-modal-backdrop,
.language-picker-modal-backdrop,
.confirm-delete-modal-backdrop,
.confirm-dialog-overlay,
.gallery-overlay,
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.ai-suggestions-modal,
.insert-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog,
.gallery-overlay-content {
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.ai-suggestions-modal,
.language-picker-modal,
.confirm-delete-modal,
.confirm-dialog {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
}
.insert-modal {
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.gallery-overlay-content {
width: min(980px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.ai-suggestions-modal-header,
.language-picker-modal-header,
.confirm-delete-modal-header,
.insert-modal-header,
.gallery-overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.insert-modal-header.media-header-only {
flex-direction: row;
align-items: center;
}
.ai-suggestions-modal-header h2,
.language-picker-modal-header h2,
.confirm-delete-modal-header h2,
.gallery-overlay-header h2,
.insert-modal-title,
.confirm-dialog h3 {
margin: 0;
color: #ffffff;
}
.ai-suggestions-modal-close,
.confirm-delete-modal-close,
.gallery-overlay-close,
.shared-popover-close,
.lightbox-close {
border: none;
background: transparent;
color: #c5c5c5;
cursor: pointer;
font-size: 20px;
line-height: 1;
}
.ai-suggestions-modal-body,
.language-picker-modal-body,
.confirm-delete-modal-body {
padding: 20px;
overflow: auto;
}
.ai-suggestions-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-suggestion-item {
display: flex;
gap: 12px;
padding: 16px;
border: 1px solid #3c3c3c;
border-radius: 6px;
background: #252526;
}
.ai-suggestion-checkbox {
position: relative;
display: flex;
align-items: flex-start;
cursor: pointer;
}
.ai-suggestion-checkbox input {
position: absolute;
opacity: 0;
}
.checkmark {
width: 20px;
height: 20px;
border: 2px solid #555555;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #1e1e1e;
}
.ai-suggestion-checkbox input:checked + .checkmark,
.ai-suggestion-checkbox input:checked ~ .checkmark {
background: #0078d4;
border-color: #0078d4;
}
.ai-suggestion-checkbox input:checked + .checkmark::after,
.ai-suggestion-checkbox input:checked ~ .checkmark::after {
content: "✓";
color: #ffffff;
font-size: 12px;
}
.ai-suggestion-content {
flex: 1;
min-width: 0;
}
.ai-suggestion-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 600;
}
.ai-suggestion-has-value,
.language-picker-badge,
.insert-modal-similarity-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #c5c5c5;
font-size: 11px;
}
.ai-suggestion-comparison {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.ai-suggestion-column {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.ai-suggestion-column.muted {
color: #9d9d9d;
}
.ai-suggestion-column.highlighted {
border: 1px solid rgba(0, 122, 204, 0.4);
color: #ffffff;
}
.ai-suggestion-column-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-suggestion-arrow {
color: #9d9d9d;
}
.ai-suggestion-value {
min-height: 1.4em;
}
.ai-suggestion-value.loading {
color: var(--accent-color);
font-style: italic;
}
.ai-suggestions-error {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
margin-bottom: 16px;
border-radius: 6px;
background: rgba(220, 50, 50, 0.12);
border: 1px solid rgba(220, 50, 50, 0.35);
color: #ff6b6b;
}
.ai-suggestions-modal-footer,
.confirm-delete-modal-footer,
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #3c3c3c;
}
.button-cancel,
.button-delete,
.button-apply,
.confirm-dialog-actions button,
.insert-modal-submit,
.language-picker-row,
.shared-popover-entry,
.colour-swatch {
cursor: pointer;
}
.button-cancel,
.confirm-dialog-actions button,
.insert-modal-submit {
border: 1px solid #4c4c4c;
border-radius: 4px;
padding: 8px 14px;
background: transparent;
color: #f0f0f0;
}
.button-apply,
.confirm-dialog-actions .primary,
.insert-modal-submit {
background: #0e639c;
border-color: #0e639c;
}
.button-delete {
border: none;
border-radius: 4px;
padding: 8px 14px;
background: #c73c3c;
color: #ffffff;
}
.insert-modal-tabs {
display: flex;
margin: 0 -20px;
}
.insert-modal-tab {
flex: 1;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: #9d9d9d;
padding: 10px 16px;
}
.insert-modal-tab.active {
color: #ffffff;
border-bottom-color: #0e639c;
background: #252526;
}
.insert-modal-search {
border-bottom: 1px solid #3c3c3c;
}
.insert-modal-input,
.shared-popover-input {
width: 100%;
border: none;
background: transparent;
color: #f0f0f0;
padding: 14px 20px;
font: inherit;
}

541
assets/css/panel.css Normal file
View File

@@ -0,0 +1,541 @@
.panel-shell {
min-height: 160px;
max-height: 160px;
border-top: 1px solid var(--line);
}
.panel-shell.is-hidden {
display: none;
}
.panel-tabs {
display: flex;
gap: 8px;
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-tab {
background: transparent;
border: none;
padding: 6px 12px;
font-size: 12px;
color: var(--vscode-tab-inactiveForeground);
cursor: pointer;
border-bottom: 2px solid transparent;
border-radius: 0;
}
.panel-tab:hover {
color: var(--vscode-tab-activeForeground);
background: transparent;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
border-bottom-color: var(--vscode-focusBorder);
background: transparent;
}
.assistant-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.assistant-sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-heading {
display: flex;
flex-direction: column;
gap: 4px;
}
.assistant-sidebar-description,
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-status {
border-radius: 999px;
border: 1px solid var(--vscode-panel-border);
padding: 2px 8px;
font-size: 11px;
line-height: 1.4;
}
.assistant-sidebar-status.is-offline {
background: rgba(255, 196, 0, 0.18);
border-color: rgba(255, 196, 0, 0.35);
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.assistant-sidebar-context-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.assistant-sidebar-context-label,
.assistant-sidebar-message-role {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.assistant-sidebar-context-value {
text-align: right;
color: var(--vscode-editor-foreground);
}
.assistant-sidebar-context-text,
.assistant-sidebar-message-content {
margin: 0;
white-space: pre-wrap;
}
.assistant-sidebar-prompt-form,
.assistant-sidebar-welcome,
.assistant-sidebar-transcript {
display: flex;
flex-direction: column;
gap: 10px;
}
.assistant-sidebar-prompt {
width: 100%;
min-height: 120px;
resize: vertical;
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 10px;
font: inherit;
}
.assistant-sidebar-prompt:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
.assistant-sidebar-start-button {
align-self: flex-start;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 999px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
padding: 7px 14px;
cursor: pointer;
}
.assistant-sidebar-start-button:disabled {
cursor: default;
opacity: 0.55;
}
.assistant-card,
.assistant-sidebar-message {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
border-bottom-width: 1px;
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.assistant-sidebar-message.user {
background: var(--vscode-list-hoverBackground);
}
.assistant-sidebar-message.assistant {
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
}
.status-bar {
height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
font-size: 12px;
padding: 0 8px;
user-select: none;
flex-wrap: nowrap;
gap: 0;
border-top: none;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
max-width: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 0;
background: transparent;
font-size: 12px;
}
.status-bar-item .task-message-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.task-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.panel-content {
padding: 8px;
}
.task-list {
gap: 4px;
}
.output-list,
.git-log-list {
gap: 6px;
}
.task-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.output-entry {
padding: 8px;
border-bottom: none;
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
font-size: 12px;
color: var(--vscode-editor-foreground);
}
@media (max-width: 1100px) {
.editor-frame {
grid-template-columns: 1fr;
}
.assistant-sidebar-shell {
display: none;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.text-muted {
color: var(--vscode-descriptionForeground);
}
.editor-empty {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
background-color: var(--vscode-editor-background);
overflow-y: auto;
padding: 40px 20px;
}
.dashboard-content {
max-width: 720px;
width: 100%;
}
.dashboard-content h1 {
font-size: 24px;
font-weight: 400;
margin: 0 0 4px;
color: var(--vscode-editor-foreground);
}
.dashboard-content > .text-muted {
margin-bottom: 24px;
display: block;
}
.dashboard-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
padding: 16px;
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
}
.stat-number {
font-size: 32px;
font-weight: 600;
color: var(--vscode-editor-foreground);
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.stat-breakdown {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.stat-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
background-color: var(--vscode-input-background);
color: var(--vscode-descriptionForeground);
}
.stat-published {
color: var(--vscode-testing-iconPassed);
}
.stat-draft {
color: var(--vscode-editorWarning-foreground);
}
.stat-archived {
color: var(--vscode-descriptionForeground);
}
.dashboard-section {
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
}
.dashboard-section h4 {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 12px;
}
.timeline-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 100px;
}
.timeline-bar-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.timeline-bar {
width: 100%;
max-width: 40px;
background-color: var(--vscode-activityBarBadge-background);
border-radius: 3px 3px 0 0;
margin-top: auto;
min-height: 4px;
position: relative;
transition: opacity 0.15s;
}
.timeline-bar:hover {
opacity: 0.8;
}
.timeline-bar-count {
position: absolute;
top: -16px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
.timeline-bar-label {
display: flex;
flex-direction: column;
align-items: center;
font-size: 9px;
color: var(--vscode-descriptionForeground);
margin-top: 4px;
line-height: 1.15;
}
.timeline-bar-label-month {
white-space: nowrap;
}
.timeline-bar-label-year {
font-size: 8px;
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
line-height: 1.6;
}
.dashboard-tag {
padding: 2px 8px;
border-radius: 10px;
background-color: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
cursor: default;
transition: opacity 0.15s;
white-space: nowrap;
}
.dashboard-tag:hover {
opacity: 0.75;
}
.dashboard-tag.has-color {
border-radius: 12px;
}
.dashboard-tag.has-color:hover {
opacity: 0.85;
}
.tag-cloud-more {
font-size: 11px;
}
.tag-count {
font-size: 10px;
opacity: 0.5;
margin-left: 2px;
}
.dashboard-category {
font-size: 12px;
border: 1px solid var(--vscode-input-border);
}
.recent-posts-list {
display: flex;
flex-direction: column;
}
.recent-post-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
width: 100%;
border: none;
background: transparent;
text-align: left;
color: inherit;
}
.recent-post-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.recent-post-title {
flex: 1;
color: var(--vscode-editor-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-post-status {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background-color: var(--vscode-input-background);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.recent-post-status.status-published {
color: var(--vscode-testing-iconPassed);
}
.recent-post-status.status-draft {
color: var(--vscode-editorWarning-foreground);
}
.recent-post-status.status-archived {
color: var(--vscode-descriptionForeground);
}
.recent-post-date {
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}

807
assets/css/shell.css Normal file
View File

@@ -0,0 +1,807 @@
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
.app-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.window-titlebar {
position: relative;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-editorGroupHeader-tabsBackground);
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
flex-shrink: 0;
app-region: drag;
-webkit-app-region: drag;
padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));
}
.window-titlebar-menu-bar {
display: flex;
align-items: center;
height: 100%;
margin-left: 6px;
gap: 2px;
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 2;
}
.window-titlebar-menu-group {
position: relative;
display: flex;
align-items: center;
height: 100%;
}
.window-titlebar-menu-bar.is-hidden {
display: none;
}
.window-titlebar.is-mac .window-titlebar-menu-bar {
margin-left: max(var(--bds-titlebar-macos-left-inset, 78px), calc(6px + var(--bds-titlebar-overlay-left, 0px)));
}
.window-titlebar-menu-button {
height: 24px;
border: none;
background: transparent;
color: var(--vscode-titleBar-activeForeground);
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.window-titlebar-menu-button:hover,
.window-titlebar-action-button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-button.is-active {
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-button:focus,
.window-titlebar-menu-button:focus-visible,
.window-titlebar-action-button:focus,
.window-titlebar-action-button:focus-visible {
outline: none;
box-shadow: none;
}
.window-titlebar-menu-dropdown {
position: absolute;
top: 30px;
left: 0;
min-width: 210px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--vscode-menu-background, var(--vscode-editorWidget-background));
border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 10;
}
.window-titlebar-menu-item {
border: none;
background: transparent;
color: var(--vscode-menu-foreground, var(--vscode-foreground));
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
text-align: left;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
}
.window-titlebar-menu-item:focus,
.window-titlebar-menu-item:focus-visible {
outline: none;
box-shadow: none;
background-color: var(--vscode-toolbar-hoverBackground);
}
.window-titlebar-menu-item:hover,
.window-titlebar-menu-item.is-keyboard-active {
background-color: var(--vscode-menu-selectionBackground, var(--vscode-toolbar-hoverBackground));
}
.window-titlebar-menu-item-accelerator {
opacity: 0.8;
}
.window-titlebar-menu-separator {
height: 1px;
margin: 4px 2px;
background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08));
}
.window-titlebar-drag-region {
flex: 1;
height: 100%;
}
.window-titlebar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 45%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-titleBar-activeForeground);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
.window-titlebar-actions {
height: 100%;
display: flex;
align-items: center;
margin-right: 6px;
app-region: no-drag;
-webkit-app-region: no-drag;
}
.window-titlebar-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
line-height: 0;
background: transparent;
border: none;
color: var(--vscode-foreground);
cursor: pointer;
border-radius: 4px;
}
.window-titlebar-sidebar-icon,
.window-titlebar-panel-icon,
.window-titlebar-assistant-icon {
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
border-radius: 2px;
display: block;
position: relative;
overflow: hidden;
}
.window-titlebar-sidebar-icon::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 33.3333%;
width: 1.5px;
transform: translateX(-50%);
background-color: currentColor;
}
.window-titlebar-panel-icon::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 66.6667%;
height: 1.5px;
transform: translateY(-50%);
background-color: currentColor;
}
.window-titlebar-assistant-icon::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 66.6667%;
width: 1.5px;
transform: translateX(-50%);
background-color: currentColor;
}
.window-titlebar-sidebar-pane,
.window-titlebar-panel-pane,
.window-titlebar-assistant-pane {
position: absolute;
background-color: currentColor;
transition: opacity 120ms ease;
}
.window-titlebar-sidebar-pane {
left: 0;
top: 0;
width: 33.3333%;
height: 100%;
}
.window-titlebar-panel-pane {
left: 0;
bottom: 0;
width: 100%;
height: 33.3333%;
}
.window-titlebar-assistant-pane {
right: 0;
top: 0;
width: 33.3333%;
height: 100%;
}
.window-titlebar-sidebar-icon.is-inactive .window-titlebar-sidebar-pane,
.window-titlebar-panel-icon.is-inactive .window-titlebar-panel-pane,
.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane {
opacity: 0;
}
.panel-shell {
height: 200px;
border-top: 1px solid var(--vscode-panel-border);
background: var(--vscode-panel-background);
display: flex;
flex-direction: column;
}
.panel-shell.is-hidden {
display: none;
}
.editor-toolbar-button.is-destructive {
color: #f48771;
}
.shell-overlay-backdrop,
.gallery-overlay-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.68);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
z-index: 10000;
}
.shell-overlay-dismiss {
position: absolute;
inset: 0;
border: none;
background: transparent;
padding: 0;
}
.gallery-overlay {
position: relative;
width: min(980px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1;
}
.insert-modal-media-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
padding: 16px;
}
.insert-modal-media-item {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid #3c3c3c;
border-radius: 8px;
background: #252526;
color: inherit;
padding: 10px;
text-align: left;
}
.insert-modal-media-thumb {
width: 100%;
min-height: 112px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.04);
}
.insert-modal-media-title {
font-weight: 600;
color: #ffffff;
}
.language-picker-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.language-picker-option {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 12px;
align-items: center;
border: none;
border-radius: 4px;
padding: 12px 16px;
background: transparent;
color: inherit;
text-align: left;
}
.language-picker-label,
.language-picker-status,
.lightbox-counter {
color: #9d9d9d;
font-size: 12px;
}
.lightbox-counter {
margin-top: 4px;
}
@media (max-width: 720px) {
.insert-modal-media-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.panel-header {
height: 35px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
}
.panel-tabs {
display: flex;
align-items: stretch;
height: 100%;
}
.panel-tab {
border: none;
background: transparent;
color: var(--vscode-descriptionForeground);
padding: 0 12px;
cursor: pointer;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
}
.panel-close {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
padding: 0;
}
.panel-close:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.panel-content {
flex: 1;
overflow: auto;
padding: 12px 14px;
}
.panel-entry,
.assistant-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.output-list,
.git-log-list {
display: flex;
flex-direction: column;
}
.task-list {
display: flex;
flex-direction: column;
}
.task-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.task-status {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground);
}
.task-status-running {
color: var(--vscode-terminal-ansiGreen, var(--vscode-statusBar-foreground));
}
.task-status-pending {
color: var(--vscode-terminal-ansiYellow, var(--vscode-statusBar-foreground));
}
.panel-empty-state {
min-height: 100%;
justify-content: center;
}
.status-bar {
height: 22px;
background: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
font-size: 12px;
flex-shrink: 0;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.status-bar-left {
flex-shrink: 1;
min-width: 0;
}
.status-shell-controls {
display: flex;
align-items: stretch;
gap: 2px;
flex-shrink: 0;
}
.status-shell-toggle-button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 100%;
padding: 0;
line-height: 0;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
border-radius: 3px;
}
.status-shell-toggle-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-shell-toggle-button:focus,
.status-shell-toggle-button:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
}
.status-shell-toggle-button .window-titlebar-sidebar-icon,
.status-shell-toggle-button .window-titlebar-panel-icon,
.status-shell-toggle-button .window-titlebar-assistant-icon {
width: 12px;
height: 12px;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.status-bar-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-bar-task-button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.status-bar-item.theme-badge {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
}
.status-bar-item.language-badge {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px;
gap: 4px;
}
.status-bar-item.offline-badge {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.4;
font-size: 13px;
padding: 0 4px;
}
.status-bar-item.offline-badge.active {
background-color: rgba(255, 196, 0, 0.28);
opacity: 1;
}
.project-selector {
position: relative;
flex-shrink: 0;
}
.project-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 22px;
background: transparent;
border: none;
color: var(--vscode-statusBar-foreground);
cursor: pointer;
font-size: 12px;
text-align: left;
}
.project-selector-trigger:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.project-selector-trigger:focus {
outline: none;
}
.project-icon,
.dropdown-arrow,
.project-check-icon {
flex-shrink: 0;
}
.project-name,
.project-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-name {
max-width: 180px;
}
.dropdown-arrow {
opacity: 0.6;
}
.project-dropdown {
position: absolute;
left: 0;
bottom: 100%;
min-width: 220px;
margin-bottom: 4px;
background-color: #252526;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 4px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.project-dropdown-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.project-list {
max-height: 200px;
overflow-y: auto;
}
.project-item {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 12px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.project-item:hover,
.project-item.active {
background-color: var(--vscode-list-hoverBackground);
}
.project-item.active .project-check-icon {
color: #89d185;
}
.project-dropdown-footer {
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: grid;
gap: 6px;
}
.create-project-btn,
.existing-project-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
background-color: rgba(255, 255, 255, 0.12);
color: inherit;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.create-project-btn:hover,
.existing-project-btn:hover {
background-color: rgba(255, 255, 255, 0.18);
}
.status-bar-language-select {
background: transparent;
border: none;
color: inherit;
font: inherit;
padding: 0;
}
.status-bar-language-select:focus {
outline: none;
}
.status-bar-count {
font-size: 11px;
opacity: 0.85;
}
.status-bar-item.brand {
font-weight: 600;
}
@media (max-width: 960px) {
.editor-frame {
grid-template-columns: minmax(0, 1fr);
}
.editor-meta {
border-left: none;
border-top: 1px solid var(--vscode-panel-border);
padding-left: 0;
padding-top: 10px;
}
}
.editor-section ul {
margin: 12px 0 0;
padding-left: 18px;
}
.editor-toolbar {
display: flex;
gap: 10px;
}
.editor-toolbar button {
padding: 9px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-3);
color: var(--ink);
}
.editor-meta {
display: flex;
flex-direction: column;
gap: 12px;
}
.editor-meta-card,
.assistant-card,
.panel-entry {
padding: 16px;
}
.sidebar-header,
.assistant-header,
.panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}

1049
assets/css/sidebar.css Normal file

File diff suppressed because it is too large Load Diff

189
assets/css/tabs.css Normal file
View File

@@ -0,0 +1,189 @@
.tab-bar {
display: flex;
align-items: center;
background-color: var(--vscode-editorGroupHeader-tabsBackground);
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
height: 35px;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.tab-bar-tabs {
display: flex;
align-items: center;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
}
.tab-bar-tabs::-webkit-scrollbar {
height: 0;
display: none;
}
.tab-bar-empty {
display: flex;
align-items: center;
height: 100%;
padding: 0 12px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 0 6px 0 10px;
height: 100%;
min-width: 100px;
max-width: 180px;
cursor: pointer;
background-color: var(--vscode-tab-inactiveBackground);
border: none;
border-right: 1px solid var(--vscode-tab-border);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
user-select: none;
position: relative;
flex-shrink: 0;
}
.tab-select {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
height: 100%;
padding: 0;
background: transparent;
border: none;
color: inherit;
font: inherit;
cursor: inherit;
}
.tab:hover {
background-color: var(--vscode-list-hoverBackground);
}
.tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.tab.active::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--vscode-focusBorder);
}
.tab.transient .tab-title {
font-style: italic;
}
.tab-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.tab-dirty-indicator {
color: var(--vscode-editorWarning-foreground, #e2c08d);
font-size: 10px;
line-height: 1;
}
.tab-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.85;
}
.tab-title,
.status-bar-item {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-close {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
color: var(--vscode-icon-foreground, #c5c5c5);
border-radius: 3px;
cursor: pointer;
flex-shrink: 0;
border: none;
background: transparent;
padding: 0;
opacity: 0;
}
.tab:hover .tab-close {
opacity: 0.7;
}
.tab.active .tab-close {
opacity: 0.7;
}
.tab-close:hover {
opacity: 1 !important;
background-color: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-tab-activeForeground);
}
.tab-close:active {
background-color: var(--vscode-toolbar-activeBackground, rgba(99, 102, 103, 0.31));
}
.tab.dirty .tab-dirty-indicator {
display: block;
}
.tab.dirty .tab-close {
display: none;
}
.tab.dirty:hover .tab-close {
display: flex;
opacity: 0.7;
}
.tab.dirty:hover .tab-dirty-indicator {
display: none;
}
.tab:focus-visible {
outline: 1px solid var(--vscode-focusBorder, #007fd4);
outline-offset: -1px;
}
.output-item-details {
margin: 4px 0 0;
padding: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
color: inherit;
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap;
user-select: text;
}

166
assets/css/tokens.css Normal file
View File

@@ -0,0 +1,166 @@
@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);
--vscode-editor-background: #1e1e1e;
--vscode-editor-foreground: #cccccc;
--vscode-sideBar-background: #252526;
--vscode-activityBar-background: #333333;
--vscode-activityBar-foreground: #ffffff;
--vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #252526;
--vscode-titleBar-activeForeground: #cccccc;
--vscode-statusBar-background: #007acc;
--vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d;
--vscode-tab-activeForeground: #ffffff;
--vscode-tab-inactiveForeground: #969696;
--vscode-editorGroupHeader-tabsBackground: #252526;
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
--vscode-foreground: #cccccc;
--vscode-descriptionForeground: #858585;
--vscode-panel-border: #80808059;
--vscode-sideBar-border: #80808059;
--vscode-tab-border: #252526;
--vscode-focusBorder: #007fd4;
--vscode-input-background: rgba(255, 255, 255, 0.06);
--vscode-input-border: rgba(255, 255, 255, 0.12);
--vscode-list-hoverBackground: #2a2d2e;
--vscode-list-activeSelectionBackground: #094771;
--vscode-list-activeSelectionForeground: #ffffff;
--vscode-activityBarBadge-background: #007acc;
--vscode-activityBarBadge-foreground: #ffffff;
--vscode-testing-iconPassed: #73c991;
--vscode-editorWarning-foreground: #cca700;
--vscode-input-foreground: #cccccc;
--vscode-input-placeholderForeground: #a6a6a6;
--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--vscode-font-size: 13px;
--panel-1: var(--vscode-editor-background);
--panel-2: var(--vscode-sideBar-background);
--panel-3: var(--vscode-input-background);
--ink: var(--vscode-foreground);
--line: var(--vscode-panel-border);
--accent: var(--vscode-focusBorder);
--accent-soft: var(--vscode-list-hoverBackground);
--success: var(--vscode-testing-iconPassed);
--sidebar-width: 280px;
--assistant-width: 360px;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: var(--vscode-editor-background);
color: var(--vscode-foreground);
}
body {
overflow: hidden;
user-select: none;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
body > [data-phx-session],
body > [data-phx-main] {
width: 100%;
height: 100%;
min-height: 0;
}
button {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
padding: 6px 14px;
cursor: pointer;
border-radius: 2px;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
button.secondary {
background-color: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background-color: #4a4d51;
}
button.compact {
padding: 4px 8px;
font-size: 12px;
}
button.primary {
background-color: var(--vscode-button-background);
font-weight: 500;
}
button.primary:hover {
background-color: var(--vscode-button-hoverBackground);
}
button.success {
background-color: #28a745;
}
button.success:hover {
background-color: #218838;
}
button.danger {
background-color: #dc3545;
}
button.danger:hover {
background-color: #c82333;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button svg,
button svg * {
pointer-events: none;
}

301
assets/css/utilities.css Normal file
View File

@@ -0,0 +1,301 @@
@layer components {
.ui-button {
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;
}
.ui-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.ui-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-button-primary {
color: var(--vscode-button-foreground, #ffffff);
background: var(--vscode-button-background, var(--vscode-focusBorder));
}
.ui-button-primary:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground, #0e639c);
}
.ui-button-secondary {
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
border-color: var(--vscode-button-border, transparent);
}
.ui-button-secondary:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, #4a4d51);
}
.ui-button-danger {
color: var(--vscode-errorForeground, #f48771);
background: transparent;
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
}
.ui-button-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
}
.ui-button-compact {
min-height: 24px;
padding: 3px 8px;
font-size: 12px;
}
.ui-input,
.ui-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
border-radius: 4px;
background: var(--vscode-input-background, rgba(255, 255, 255, 0.06));
color: var(--vscode-input-foreground, var(--vscode-foreground));
font: inherit;
}
.ui-textarea {
line-height: 1.5;
resize: vertical;
}
.ui-input:focus,
.ui-textarea:focus {
outline: 1px solid var(--vscode-focusBorder, #007fd4);
outline-offset: 1px;
}
.ui-input-readonly,
.ui-input[readonly] {
opacity: 0.7;
cursor: not-allowed;
}
.ui-input-disabled,
.ui-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ui-tab {
border: none;
color: var(--vscode-tab-inactiveForeground, var(--vscode-foreground));
background: transparent;
}
.ui-tab:hover {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.ui-tab-active {
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
}
.ui-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ui-panel-entry {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background-color: var(--vscode-sideBar-background);
}
.ui-empty-state {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--vscode-descriptionForeground);
}
.ui-editor-shell {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--vscode-editor-background);
}
.ui-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
min-height: 35px;
padding: 0 12px;
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
}
.ui-editor-tab-current {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 6px;
overflow: hidden;
padding: 6px 12px;
border-radius: 4px 4px 0 0;
background: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.ui-editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.ui-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 32px;
}
.ui-toolbar-group {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ui-field-stack {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.ui-field-stack > label,
.ui-field-label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ui-field-grid-2,
.ui-field-grid-3 {
display: grid;
gap: 16px;
}
.ui-dropdown-menu {
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.ui-dropdown-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ui-dropdown-item:hover:not(:disabled) {
background: var(--vscode-list-hoverBackground, #2a2d2e);
}
.ui-dropdown-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-section-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
}
.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;
}
}
@media (min-width: 768px) {
.ui-field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ui-field-grid-3 {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
}
}

29
assets/js/app.js Normal file
View File

@@ -0,0 +1,29 @@
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import "phoenix_html";
import { Hooks } from "./hooks/index.js";
document.addEventListener("DOMContentLoaded", () => {
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
metadata: {
keydown: (event) => ({
key: event.key,
meta: event.metaKey,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
tag: event.target?.tagName || null,
contentEditable: event.target?.isContentEditable || false
})
}
});
liveSocket.connect();
window.liveSocket = liveSocket;
});

View File

@@ -0,0 +1,19 @@
import { clamp } from "../utils/dom.js";
export const applyAppZoom = (nextZoom) => {
const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2);
window.__bdsAppZoom = zoom;
document.documentElement.style.zoom = String(zoom);
};
export const runDocumentCommand = (command) => {
if (typeof document.execCommand !== "function") {
return false;
}
try {
return document.execCommand(command);
} catch (_error) {
return false;
}
};

View File

@@ -0,0 +1,58 @@
import { activeMonacoEditor, runMonacoEditorAction } from "../monaco/services.js";
import { applyAppZoom, runDocumentCommand } from "./document_commands.js";
export 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;
}
};

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);
};
};

4
assets/js/constants.js Normal file
View File

@@ -0,0 +1,4 @@
export const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
export const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
export const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
export const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-";

View File

@@ -0,0 +1,226 @@
import {
SIDEBAR_STORAGE_KEY,
ASSISTANT_STORAGE_KEY,
UI_LANGUAGE_STORAGE_KEY,
WORKBENCH_SESSION_STORAGE_KEY_PREFIX
} from "../constants.js";
import {
parseJsonObject,
setMediaThumbnailLoaded,
syncMediaThumbnailState,
clamp
} from "../utils/dom.js";
import { shellWidth, setShellWidth, persistWidth, readStoredSize } from "../utils/layout.js";
import {
parseShortcutConfig,
normalizeShortcutKey,
shortcutMatchesEvent,
shortcutTargetIsEditable
} from "../utils/shortcuts.js";
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
export const AppShell = {
mounted() {
this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts);
this.currentProjectId = this.el.dataset.projectId || "";
this.syncStoredLayout();
this.syncStoredUiLanguage();
this.destroyOverlaySync = syncTitlebarOverlayInsets();
this.workbenchStorageKey = (projectId) =>
projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null;
this.restoreStoredWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
if (!storageKey) {
return false;
}
const session = parseJsonObject(window.localStorage.getItem(storageKey));
if (!session) {
return false;
}
this.pushEvent("restore_workbench_session", { session });
return true;
};
this.persistWorkbenchSession = () => {
const projectId = this.el.dataset.projectId || "";
const storageKey = this.workbenchStorageKey(projectId);
const session = this.el.dataset.workbenchSession;
if (!storageKey || !session) {
return;
}
window.localStorage.setItem(storageKey, session);
};
this.handleMouseDown = (event) => {
const handle = event.target.closest("[data-role='resize-handle']");
if (!handle || !this.el.contains(handle)) {
return;
}
event.preventDefault();
const target = handle.dataset.resize;
const startX = event.clientX;
const startWidth =
target === "assistant"
? shellWidth("[data-testid='assistant-shell']")
: shellWidth("[data-testid='sidebar-shell']");
const min = target === "assistant" ? 280 : 200;
const max = target === "assistant" ? 640 : 500;
const invert = target === "assistant";
const onMouseMove = (moveEvent) => {
const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX;
const width = clamp(startWidth + delta, min, max);
const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']";
setShellWidth(selector, width);
persistWidth(target, width);
};
const onMouseUp = (upEvent) => {
const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX;
const width = clamp(startWidth + delta, min, max);
persistWidth(target, width);
this.pushEvent("resize_panel", { target, width });
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
this.el.addEventListener("mousedown", this.handleMouseDown);
this.handleNativeMenuAction = (event) => {
const action = event.detail?.action;
const ackId = event.detail?.ackId;
if (action) {
this.pushEvent("native_menu_action", { action }, () => {
if (ackId) {
window.dispatchEvent(
new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } })
);
}
});
}
};
this.handleChange = (event) => {
const select = event.target.closest(".status-bar-language-select");
if (select && this.el.contains(select)) {
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value);
}
};
this.handleShortcutKeyDown = (event) => {
if (shortcutTargetIsEditable(event)) {
return;
}
const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event));
if (!shortcut) {
return;
}
event.preventDefault();
event.stopPropagation();
this.pushEvent("shortcut", {
key: normalizeShortcutKey(event.key),
meta: event.metaKey,
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
tag: event.target?.tagName || null,
contentEditable: event.target?.isContentEditable || false
});
};
this.handleThumbnailLoad = (event) => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
setMediaThumbnailLoaded(event.target, true);
}
};
this.handleThumbnailError = (event) => {
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
setMediaThumbnailLoaded(event.target, false);
}
};
this.handleEvent("menu-runtime-command", ({ action }) => {
if (action) {
runMenuRuntimeCommand(String(action));
}
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true);
this.el.addEventListener("error", this.handleThumbnailError, true);
this.el.addEventListener("change", this.handleChange);
syncMediaThumbnailState(this.el);
this.restoreStoredWorkbenchSession();
},
updated() {
const nextProjectId = this.el.dataset.projectId || "";
if (nextProjectId !== this.currentProjectId) {
this.currentProjectId = nextProjectId;
if (this.restoreStoredWorkbenchSession()) {
return;
}
}
syncMediaThumbnailState(this.el);
this.persistWorkbenchSession();
},
destroyed() {
this.el.removeEventListener("mousedown", this.handleMouseDown);
this.el.removeEventListener("load", this.handleThumbnailLoad, true);
this.el.removeEventListener("error", this.handleThumbnailError, true);
this.el.removeEventListener("change", this.handleChange);
window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.removeEventListener("keydown", this.handleShortcutKeyDown, true);
if (this.destroyOverlaySync) {
this.destroyOverlaySync();
}
},
syncStoredLayout() {
this.pushEvent("sync_layout", {
sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500),
assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640)
});
},
syncStoredUiLanguage() {
const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY);
if (stored) {
this.pushEvent("sync_ui_language", { language: stored });
}
}
};

View File

@@ -0,0 +1,139 @@
export const ChatSurface = {
mounted() {
this.stickToBottom = true;
this.scrollContainer = null;
this.autoResize = () => {
const textarea = this.el.querySelector(".chat-input");
if (!textarea) {
return;
}
const styles = getComputedStyle(textarea);
const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20;
const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160;
textarea.rows = 1;
textarea.style.minHeight = `${minHeight}px`;
if (textarea.value.trim() === "") {
textarea.style.height = `${minHeight}px`;
textarea.style.maxHeight = `${minHeight}px`;
textarea.style.overflowY = "hidden";
return;
}
textarea.style.maxHeight = `${maxHeight}px`;
textarea.style.height = "0px";
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden";
};
this.syncScrollContainer = () => {
const nextContainer = this.el.querySelector(".chat-messages");
if (nextContainer === this.scrollContainer) {
return;
}
if (this.scrollContainer) {
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
}
this.scrollContainer = nextContainer;
if (this.scrollContainer) {
this.scrollContainer.addEventListener("scroll", this.handleScroll);
}
};
this.scrollToBottom = (force = false) => {
if (!this.scrollContainer) {
return;
}
if (force || this.stickToBottom) {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
}
};
this.syncExpandedSurfaces = () => {
this.el
.querySelectorAll(".chat-inline-surface[data-expanded='true']")
.forEach((surface) => {
surface.open = true;
});
};
this.surfaceObserver = new MutationObserver(() => {
this.syncExpandedSurfaces();
});
this.handleScroll = () => {
if (!this.scrollContainer) {
this.stickToBottom = true;
return;
}
const distanceFromBottom =
this.scrollContainer.scrollHeight -
this.scrollContainer.scrollTop -
this.scrollContainer.clientHeight;
this.stickToBottom = distanceFromBottom < 48;
};
this.handleInput = (event) => {
if (!event.target.closest(".chat-input")) {
return;
}
this.stickToBottom = true;
this.autoResize();
};
this.handleKeyDown = (event) => {
if (!event.target.closest(".chat-input")) {
return;
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
if (sendButton && !sendButton.disabled) {
sendButton.click();
}
}
};
this.el.addEventListener("input", this.handleInput);
this.el.addEventListener("keydown", this.handleKeyDown);
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.surfaceObserver.observe(this.el, { childList: true, subtree: true });
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom(true));
},
updated() {
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom());
},
destroyed() {
this.surfaceObserver.disconnect();
this.el.removeEventListener("input", this.handleInput);
this.el.removeEventListener("keydown", this.handleKeyDown);
if (this.scrollContainer) {
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
}
}
};

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

@@ -0,0 +1,18 @@
import { AppShell } from "./app_shell.js";
import { SidebarInteractions } from "./sidebar_interactions.js";
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
import { ChatSurface } from "./chat_surface.js";
import { MenuEditorTree } from "./menu_editor_tree.js";
import { MonacoEditor } from "./monaco_editor.js";
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
export const Hooks = {
AppShell,
SidebarInteractions,
SettingsSectionScroll,
TagsSectionScroll,
ChatSurface,
MenuEditorTree,
MonacoEditor,
MonacoDiffEditor
};

View File

@@ -0,0 +1,134 @@
export const MenuEditorTree = {
mounted() {
this.dragItemId = null;
this.dragSourceEl = null;
this.dropTargetEl = null;
this.dropPosition = null;
this.clearDropTarget = () => {
if (this.dropTargetEl) {
this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside");
}
this.dropTargetEl = null;
this.dropPosition = null;
};
this.setDropTarget = (row, position) => {
if (this.dropTargetEl === row && this.dropPosition === position) {
return;
}
this.clearDropTarget();
this.dropTargetEl = row;
this.dropPosition = position;
row.classList.add(`is-drop-${position}`);
};
this.handleDragStart = (event) => {
const handle = event.target.closest("[data-menu-drag-handle='true']");
const row = event.target.closest("[data-menu-item-id]");
if (!handle || !row || !this.el.contains(row)) {
return;
}
this.dragItemId = row.dataset.menuItemId || null;
this.dragSourceEl = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", this.dragItemId || "");
}
};
this.handleDragOver = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row)) {
this.clearDropTarget();
return;
}
const targetItemId = row.dataset.menuItemId || "";
if (!targetItemId || targetItemId === this.dragItemId) {
this.clearDropTarget();
return;
}
event.preventDefault();
const rect = row.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const allowInside = row.dataset.menuCanDropInside === "true";
const insideBandTop = rect.height * 0.3;
const insideBandBottom = rect.height * 0.7;
const position =
allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom
? "inside"
: offsetY < rect.height / 2
? "before"
: "after";
this.setDropTarget(row, position);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
};
this.handleDrop = (event) => {
const row = event.target.closest("[data-menu-item-id]");
if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) {
this.clearDropTarget();
return;
}
event.preventDefault();
this.pushEvent("menu_editor_drop_item", {
drag_item_id: this.dragItemId,
target_item_id: row.dataset.menuItemId,
position: this.dropPosition
});
this.clearDropTarget();
};
this.handleDragLeave = (event) => {
const related = event.relatedTarget;
if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) {
this.clearDropTarget();
}
};
this.handleDragEnd = () => {
if (this.dragSourceEl) {
this.dragSourceEl.classList.remove("is-dragging");
}
this.dragItemId = null;
this.dragSourceEl = null;
this.clearDropTarget();
};
this.el.addEventListener("dragstart", this.handleDragStart);
this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop);
this.el.addEventListener("dragleave", this.handleDragLeave);
this.el.addEventListener("dragend", this.handleDragEnd);
},
destroyed() {
this.el.removeEventListener("dragstart", this.handleDragStart);
this.el.removeEventListener("dragover", this.handleDragOver);
this.el.removeEventListener("drop", this.handleDrop);
this.el.removeEventListener("dragleave", this.handleDragLeave);
this.el.removeEventListener("dragend", this.handleDragEnd);
}
};

View File

@@ -0,0 +1,129 @@
import { loadMonaco, ensureMonacoTheme, diffModelPath } from "../monaco/services.js";
export const MonacoDiffEditor = {
mounted() {
this.host = this.el.querySelector(".monaco-diff-editor-instance");
this.originalInput = this.el.querySelector(".monaco-diff-original");
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
this.readValues = () => ({
original: this.originalInput?.value || "",
modified: this.modifiedInput?.value || ""
});
this.applyDataset = () => {
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
};
this.setModels = (monaco) => {
const values = this.readValues();
this.originalModel?.dispose();
this.modifiedModel?.dispose();
this.originalModel = monaco.editor.createModel(
values.original,
this.language,
monaco.Uri.parse(diffModelPath(this.filePath, "original"))
);
this.modifiedModel = monaco.editor.createModel(
values.modified,
this.language,
monaco.Uri.parse(diffModelPath(this.filePath, "modified"))
);
this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel });
this.lastFilePath = this.filePath;
};
loadMonaco()
.then((monaco) => {
if (!this.host) {
return;
}
ensureMonacoTheme(monaco);
this.editor = monaco.editor.createDiffEditor(this.host, {
theme: "bds-theme",
automaticLayout: true,
readOnly: true,
renderSideBySide: this.viewStyle === "side-by-side",
minimap: { enabled: false },
scrollBeyondLastLine: false,
lineNumbers: "on",
diffCodeLens: false,
originalEditable: false,
wordWrap: this.wordWrap,
hideUnchangedRegions: { enabled: this.hideUnchanged },
ignoreTrimWhitespace: false
});
this.setModels(monaco);
})
.catch((error) => {
console.error("Failed to load Monaco diff editor", error);
});
},
updated() {
this.host = this.el.querySelector(".monaco-diff-editor-instance");
this.originalInput = this.el.querySelector(".monaco-diff-original");
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
this.applyDataset();
if (!this.editor) {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
this.editor.updateOptions({
renderSideBySide: this.viewStyle === "side-by-side",
wordWrap: this.wordWrap,
hideUnchangedRegions: { enabled: this.hideUnchanged }
});
if (this.lastFilePath !== this.filePath) {
this.setModels(monaco);
return;
}
const values = this.readValues();
if (this.originalModel && this.originalModel.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.originalModel, this.language);
}
if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.modifiedModel, this.language);
}
if (this.originalModel && this.originalModel.getValue() !== values.original) {
this.originalModel.setValue(values.original);
}
if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) {
this.modifiedModel.setValue(values.modified);
}
});
},
destroyed() {
this.originalModel?.dispose();
this.modifiedModel?.dispose();
this.editor?.dispose();
}
};

View File

@@ -0,0 +1,238 @@
import {
loadMonaco,
ensureMonacoTheme,
registerMonacoEditor,
unregisterMonacoEditor
} from "../monaco/services.js";
export const MonacoEditor = {
mounted() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
this.host = this.el.querySelector(".monaco-editor-instance");
this.language = this.el.dataset.monacoLanguage || "plaintext";
this.wordWrap = this.el.dataset.monacoWordWrap || "off";
this.editorId = this.el.dataset.monacoEditorId || "";
this.insertEvent = this.el.dataset.monacoInsertEvent || "";
this.syncTimer = null;
this.isApplyingRemoteUpdate = false;
this.lastKnownValue = this.textarea?.value || "";
this.syncEditorFromTextarea = () => {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
if (!this.textarea || !this.editor) {
return;
}
const value = this.textarea.value || "";
if (this.editor.getValue() !== value) {
this.isApplyingRemoteUpdate = true;
this.editor.setValue(value);
this.isApplyingRemoteUpdate = false;
}
this.lastKnownValue = value;
};
this.layoutEditorSoon = () => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (!this.editor) {
return;
}
this.editor.layout();
});
});
};
this.waitForMonacoVisibleSize = () =>
new Promise((resolve) => {
let settled = false;
let attempts = 0;
const hasVisibleSize = () => {
const rect = this.host?.getBoundingClientRect();
return Boolean(rect && rect.width > 0 && rect.height > 0);
};
const finish = () => {
if (settled) {
return;
}
settled = true;
this.visibleSizeObserver?.disconnect();
this.visibleSizeObserver = null;
resolve();
};
const check = () => {
if (hasVisibleSize() || attempts >= 20) {
finish();
return;
}
attempts += 1;
window.requestAnimationFrame(check);
};
if (hasVisibleSize()) {
finish();
return;
}
if (window.ResizeObserver && this.host) {
this.visibleSizeObserver = new ResizeObserver(() => {
if (hasVisibleSize()) {
finish();
}
});
this.visibleSizeObserver.observe(this.host);
}
window.requestAnimationFrame(check);
});
this.queueSync = () => {
if (!this.textarea || !this.editor) {
return;
}
window.clearTimeout(this.syncTimer);
this.syncTimer = window.setTimeout(() => {
if (!this.textarea || !this.editor) {
return;
}
const value = this.editor.getValue();
if (this.textarea.value === value) {
return;
}
this.lastKnownValue = value;
this.textarea.value = value;
this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
}, 120);
};
this.handleInsert = ({ id, content }) => {
if (!this.editor || !content || String(id) !== String(this.editorId)) {
return;
}
const model = this.editor.getModel();
const selection = this.editor.getSelection();
if (!model || !selection) {
return;
}
const value = this.editor.getValue();
const start = model.getOffsetAt(selection.getStartPosition());
const end = model.getOffsetAt(selection.getEndPosition());
const before = value.slice(0, start);
const after = value.slice(end);
const separator = before !== "" && !before.endsWith("\n") ? "\n" : "";
const suffix = after !== "" && !content.endsWith("\n") ? "\n" : "";
const inserted = `${separator}${content}${suffix}`;
this.editor.executeEdits("bds-insert-content", [
{
range: selection,
text: inserted,
forceMoveMarkers: true
}
]);
this.editor.focus();
};
loadMonaco()
.then(async (monaco) => {
if (!this.host || !this.textarea) {
return;
}
await this.waitForMonacoVisibleSize();
ensureMonacoTheme(monaco);
this.editor = monaco.editor.create(this.host, {
value: this.textarea.value || "",
language: this.language,
theme: "bds-theme",
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: this.wordWrap,
lineNumbers: "on",
lineNumbersMinChars: 3,
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
roundedSelection: false,
renderLineHighlight: "line",
formatOnPaste: true,
cursorStyle: "line",
cursorBlinking: "smooth",
quickSuggestions: this.language === "markdown-with-macros" ? false : true,
tabSize: 2,
insertSpaces: true
});
registerMonacoEditor(this.editorId || this.el.id, this.editor);
monaco.editor.setTheme("bds-theme");
this.syncEditorFromTextarea();
this.layoutEditorSoon();
this.changeSubscription = this.editor.onDidChangeModelContent(() => {
if (this.isApplyingRemoteUpdate) {
return;
}
this.queueSync();
});
if (this.insertEvent) {
this.handleEvent(this.insertEvent, this.handleInsert);
}
})
.catch((error) => {
console.error("Failed to load Monaco editor", error);
});
},
updated() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
this.host = this.el.querySelector(".monaco-editor-instance");
this.language = this.el.dataset.monacoLanguage || this.language || "plaintext";
this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off";
if (!this.editor || !this.textarea) {
return;
}
loadMonaco().then((monaco) => {
ensureMonacoTheme(monaco);
monaco.editor.setTheme("bds-theme");
if (this.editor.getModel()?.getLanguageId() !== this.language) {
monaco.editor.setModelLanguage(this.editor.getModel(), this.language);
}
this.editor.updateOptions({ wordWrap: this.wordWrap });
});
this.syncEditorFromTextarea();
this.layoutEditorSoon();
},
destroyed() {
window.clearTimeout(this.syncTimer);
this.visibleSizeObserver?.disconnect();
this.changeSubscription?.dispose();
unregisterMonacoEditor(this.editorId || this.el.id);
this.editor?.dispose();
}
};

View File

@@ -0,0 +1,31 @@
const makeSectionScrollHook = (datasetKey) => ({
mounted() {
this.lastTargetId = null;
this.scrollToSelectedSection();
},
updated() {
this.scrollToSelectedSection();
},
scrollToSelectedSection() {
const targetId = this.el.dataset[datasetKey];
if (!targetId || targetId === this.lastTargetId) {
return;
}
this.lastTargetId = targetId;
window.requestAnimationFrame(() => {
const target = document.getElementById(targetId);
if (target && this.el.contains(target)) {
target.scrollIntoView({ block: "start", behavior: "smooth" });
}
});
}
});
export const SettingsSectionScroll = makeSectionScrollHook("settingsScrollTarget");
export const TagsSectionScroll = makeSectionScrollHook("tagsScrollTarget");

View File

@@ -0,0 +1,24 @@
export const SidebarInteractions = {
mounted() {
this.handleDblClick = (event) => {
const button = event.target.closest("[data-testid='sidebar-open-item']");
if (!button || !this.el.contains(button)) {
return;
}
this.pushEvent("pin_sidebar_item", {
route: button.dataset.route,
id: button.dataset.itemId,
title: button.dataset.openTitle || "",
subtitle: button.dataset.openSubtitle || ""
});
};
this.el.addEventListener("dblclick", this.handleDblClick);
},
destroyed() {
this.el.removeEventListener("dblclick", this.handleDblClick);
}
};

View File

@@ -0,0 +1,145 @@
let liquidLanguageRegistered = false;
let markdownWithMacrosRegistered = false;
export const registerLiquidLanguage = (monaco) => {
if (liquidLanguageRegistered) {
return;
}
monaco.languages.register({ id: "liquid" });
monaco.languages.setLanguageConfiguration("liquid", {
comments: {
blockComment: ["{% comment %}", "{% endcomment %}"]
},
brackets: [
["{", "}"],
["[", "]"],
["(", ")"]
],
autoClosingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
],
surroundingPairs: [
{ open: "{", close: "}" },
{ open: "[", close: "]" },
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
]
});
monaco.languages.setMonarchTokensProvider("liquid", {
defaultToken: "",
tokenizer: {
root: [
[/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }],
[/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }],
[/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }],
[/<!DOCTYPE/i, "metatag"],
[/<!--/, { token: "comment", next: "@htmlComment" }],
[/(<)(script)/i, ["delimiter.html", "tag.html"], "@scriptTag"],
[/(<)(style)/i, ["delimiter.html", "tag.html"], "@styleTag"],
[/(<\/)([\w:-]+)/, ["delimiter.html", "tag.html"]],
[/(<)([\w:-]+)/, ["delimiter.html", "tag.html"], "@htmlTag"],
[/[^<{]+/, ""],
[/./, ""]
],
liquidOutput: [
[/-?\}\}/, { token: "delimiter.output", next: "@pop" }],
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
[/\b(?:true|false|nil|blank|empty)\b/, "keyword"],
[/\b\d+(?:\.\d+)?\b/, "number"],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
[/[a-zA-Z_][\w.-]*/, "identifier"],
[/[,:()[\]]/, "delimiter"]
],
liquidTag: [
[/-?%\}/, { token: "delimiter.tag", next: "@pop" }],
[/\b(?:assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when)\b/, "keyword"],
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
[/\b(?:true|false|nil|blank|empty|contains)\b/, "keyword"],
[/\b\d+(?:\.\d+)?\b/, "number"],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
[/[><=!]=?|\.|:/, "operator"],
[/[a-zA-Z_][\w.-]*/, "identifier"],
[/[,:()[\]]/, "delimiter"]
],
liquidComment: [
[/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }],
[/./, "comment.block"]
],
htmlComment: [
[/-->/, { token: "comment", next: "@pop" }],
[/./, "comment"]
],
htmlTag: [
[/\/>/, { token: "delimiter.html", next: "@pop" }],
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
],
scriptTag: [
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
],
styleTag: [
[/>/, { token: "delimiter.html", next: "@pop" }],
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
[/[\w:-]+/, "attribute.name"],
[/=/, "delimiter"]
]
}
});
liquidLanguageRegistered = true;
};
export const registerMarkdownWithMacrosLanguage = (monaco) => {
if (markdownWithMacrosRegistered) {
return;
}
monaco.languages.register({ id: "markdown-with-macros" });
monaco.languages.setMonarchTokensProvider("markdown-with-macros", {
defaultToken: "",
tokenPostfix: ".md",
tokenizer: {
root: [
[/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }],
[/^#{1,6}\s.*$/, "keyword.header"],
[/^\s*>+/, "string.quote"],
[/^\s*[-+*]\s/, "keyword"],
[/^\s*\d+\.\s/, "keyword"],
[/^\s*```\w*/, { token: "string.code", next: "@codeblock" }],
[/\*\*[^*]+\*\*/, "strong"],
[/\*[^*]+\*/, "emphasis"],
[/__[^_]+__/, "strong"],
[/_[^_]+_/, "emphasis"],
[/`[^`]+`/, "variable"],
[/!?\[[^\]]*\]\([^)]*\)/, "string.link"],
[/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"]
],
macroParams: [
[/\]\]/, { token: "keyword.macro", next: "@root" }],
[/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"],
[/=/, "delimiter"],
[/"[^"]*"/, "string"],
[/\s+/, "white"],
[/[^\]"=\s]+/, "attribute.value"]
],
codeblock: [
[/^\s*```\s*$/, { token: "string.code", next: "@root" }],
[/.*$/, "variable.source"]
]
}
});
markdownWithMacrosRegistered = true;
};

View File

@@ -0,0 +1,88 @@
import { loadScript } from "../utils/script_loader.js";
import { ensureMonacoTheme } from "./theme.js";
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
let monacoLoaderPromise;
const monacoEditors = new Map();
export const loadMonaco = () => {
if (window.monaco?.editor) {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);
registerMarkdownWithMacrosLanguage(window.monaco);
return Promise.resolve(window.monaco);
}
if (monacoLoaderPromise) {
return monacoLoaderPromise;
}
monacoLoaderPromise = loadScript("/monaco/vs/loader.js")
.then(
() =>
new Promise((resolve, reject) => {
window.require.config({ paths: { vs: "/monaco/vs" } });
window.require(["vs/editor/editor.main"], () => {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);
registerMarkdownWithMacrosLanguage(window.monaco);
resolve(window.monaco);
}, reject);
})
)
.catch((error) => {
monacoLoaderPromise = null;
throw error;
});
return monacoLoaderPromise;
};
export const registerMonacoEditor = (key, editor) => {
if (key) {
monacoEditors.set(key, editor);
}
};
export const unregisterMonacoEditor = (key) => {
if (key) {
monacoEditors.delete(key);
}
};
export const activeMonacoEditor = () => {
for (const editor of monacoEditors.values()) {
if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) {
return editor;
}
}
return null;
};
export const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => {
if (!editor) {
return false;
}
const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null;
if (action && typeof action.run === "function") {
action.run();
return true;
}
if (typeof editor.trigger === "function") {
editor.trigger("bds-menu", triggerId, null);
return true;
}
return false;
};
export const diffModelPath = (filePath, side) => {
const normalized = String(filePath || "working-tree").replace(/^\/+/, "");
return `inmemory://model/git-diff/${side}/${normalized}`;
};
export { ensureMonacoTheme };

62
assets/js/monaco/theme.js Normal file
View File

@@ -0,0 +1,62 @@
import { cssVar, normalizeMonacoColor } from "../utils/color.js";
let monacoThemeSignature = null;
export const ensureMonacoTheme = (monaco) => {
const background = normalizeMonacoColor(
cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")),
"#1e1e1e"
);
const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4");
const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585");
const activeLineNumber = normalizeMonacoColor(
cssVar("--vscode-editorLineNumber-activeForeground", foreground),
foreground
);
const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78");
const inactiveSelection = normalizeMonacoColor(
cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"),
"#3a3d41"
);
const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground);
const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c");
const lineHighlight = normalizeMonacoColor(
cssVar("--vscode-editor-lineHighlightBackground", background),
background
);
const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|");
if (signature === monacoThemeSignature) {
monaco.editor.setTheme("bds-theme");
return;
}
monaco.editor.defineTheme("bds-theme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" },
{ token: "attribute.name", foreground: "9CDCFE" },
{ token: "attribute.value", foreground: "CE9178" }
],
colors: {
"editor.background": background,
"editor.foreground": foreground,
"editor.lineHighlightBackground": lineHighlight,
"editorCursor.foreground": cursor,
"editor.selectionBackground": selection,
"editor.inactiveSelectionBackground": inactiveSelection,
"editorLineNumber.foreground": lineNumber,
"editorLineNumber.activeForeground": activeLineNumber,
"editorIndentGuide.background1": border,
"editorIndentGuide.activeBackground1": foreground,
"editorWidget.border": border,
"editorGutter.background": background,
"focusBorder": border,
"input.border": border
}
});
monacoThemeSignature = signature;
monaco.editor.setTheme("bds-theme");
};

46
assets/js/utils/color.js Normal file
View File

@@ -0,0 +1,46 @@
import { clamp } from "./dom.js";
export const cssVar = (name, fallback) => {
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
};
const parseRgbColor = (value) => {
if (!value) {
return null;
}
const hex = value.match(/^#([0-9a-f]{6})$/i);
if (hex) {
return {
r: Number.parseInt(hex[1].slice(0, 2), 16),
g: Number.parseInt(hex[1].slice(2, 4), 16),
b: Number.parseInt(hex[1].slice(4, 6), 16)
};
}
const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!rgb) {
return null;
}
return {
r: Number.parseInt(rgb[1], 10),
g: Number.parseInt(rgb[2], 10),
b: Number.parseInt(rgb[3], 10)
};
};
export const normalizeMonacoColor = (value, fallback) => {
const rgb = parseRgbColor(value);
if (!rgb) {
return fallback;
}
return `#${[rgb.r, rgb.g, rgb.b]
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
.join("")}`;
};

34
assets/js/utils/dom.js Normal file
View File

@@ -0,0 +1,34 @@
export const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
export const parseJsonObject = (value) => {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch (_error) {
return null;
}
};
export const setMediaThumbnailLoaded = (image, loaded) => {
const thumbnail = image?.closest(".media-thumbnail");
if (!thumbnail) {
return;
}
if (loaded) {
thumbnail.classList.add("is-loaded");
} else {
thumbnail.classList.remove("is-loaded");
}
};
export const syncMediaThumbnailState = (root) => {
root.querySelectorAll(".media-thumbnail-image").forEach((image) => {
setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0));
});
};

43
assets/js/utils/layout.js Normal file
View File

@@ -0,0 +1,43 @@
import { clamp } from "./dom.js";
import { SIDEBAR_STORAGE_KEY, ASSISTANT_STORAGE_KEY } from "../constants.js";
export const shellWidth = (selector) => {
const shell = document.querySelector(selector);
if (!shell) {
return 0;
}
const width = Number.parseInt(shell.style.width || "0", 10);
return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width;
};
export const setShellWidth = (selector, width) => {
const shell = document.querySelector(selector);
if (shell) {
shell.style.width = `${width}px`;
shell.classList.remove("is-hidden");
}
};
export const persistWidth = (target, width) => {
const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY;
window.localStorage.setItem(key, String(width));
};
export const readStoredSize = (key, fallback, min, max) => {
const raw = window.localStorage.getItem(key);
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) {
return fallback;
}
return clamp(parsed, min, max);
};

View File

@@ -0,0 +1,33 @@
export const loadScript = (src) =>
new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.addEventListener(
"load",
() => {
script.dataset.loaded = "true";
resolve();
},
{ once: true }
);
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
document.head.appendChild(script);
});

View File

@@ -0,0 +1,30 @@
export const normalizeShortcutKey = (key) => String(key || "").toLowerCase();
export const shortcutTargetIsEditable = (event) => {
const tag = event.target?.tagName || null;
return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag);
};
export const shortcutMatchesEvent = (shortcut, event) => {
const primary = event.metaKey || event.ctrlKey;
return (
normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) &&
primary === Boolean(shortcut.primary) &&
event.shiftKey === Boolean(shortcut.shift) &&
event.altKey === Boolean(shortcut.alt)
);
};
export const parseShortcutConfig = (value) => {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
};

View File

@@ -27,6 +27,31 @@ config :bds, BDS.Desktop.Endpoint,
pubsub_server: BDS.PubSub, pubsub_server: BDS.PubSub,
live_view: [signing_salt: "desktop-live-view"] live_view: [signing_salt: "desktop-live-view"]
config :tailwind,
version: "4.1.14",
default: [
cd: Path.expand("..", __DIR__),
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/app.css
)
]
config :esbuild,
version: "0.25.4",
default: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/app.js
--bundle
--target=es2022
--outdir=../priv/static/assets
--external:/fonts/*
--external:/images/*
),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
config :bds, :scripting, config :bds, :scripting,
runtime: BDS.Scripting.Lua, runtime: BDS.Scripting.Lua,
timeout: 300_000, timeout: 300_000,

View File

@@ -1,3 +1,9 @@
import Config import Config
config :bds, BDS.Repo, pool_size: 5 config :bds, BDS.Repo, pool_size: 5
config :bds, BDS.Desktop.Endpoint,
watchers: [
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
esbuild: {Esbuild, :install_and_run, [:default, ~w(--watch)]}
]

View File

@@ -16,20 +16,14 @@ defmodule BDS.Desktop.Endpoint do
plug(Plug.Static, plug(Plug.Static,
at: "/assets", at: "/assets",
from: {:bds, "priv/ui"}, from: {:bds, "priv/static/assets"},
only: ["app.css", "live.js", "monaco"] only: ["app.css", "app.js"]
) )
plug(Plug.Static, plug(Plug.Static,
at: "/vendor/phoenix", at: "/monaco",
from: {:phoenix, "priv/static"}, from: {:bds, "priv/ui/monaco"},
only: ["phoenix.min.js"] only: ["vs"]
)
plug(Plug.Static,
at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"]
) )
plug(BDS.Desktop.Router) plug(BDS.Desktop.Router)

View File

@@ -16,9 +16,7 @@ defmodule BDS.Desktop.Layouts do
</head> </head>
<body> <body>
<%= @inner_content %> <%= @inner_content %>
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script> <script defer phx-track-static src="/assets/app.js"></script>
<script defer phx-track-static src="/vendor/live_view/phoenix_live_view.min.js"></script>
<script defer phx-track-static src="/assets/live.js"></script>
</body> </body>
</html> </html>
""" """

View File

@@ -16,6 +16,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true @impl true
def update(%{action: :finish_request}, %{assigns: %{request: nil}} = socket) do
{:ok, socket}
end
def update(%{action: :finish_request, result: result}, socket) do def update(%{action: :finish_request, result: result}, socket) do
{:ok, do_finish_request(socket, result)} {:ok, do_finish_request(socket, result)}
end end
@@ -252,15 +256,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
notify_parent({:chat_editor_task_cancelled, conversation_id, ref}) notify_parent({:chat_editor_task_cancelled, conversation_id, ref})
# Allow the terminated task's DB connection to be cleaned up before rebuilding.
Process.sleep(20)
socket socket
|> assign(:request, nil) |> assign(:request, nil)
|> build_data() |> clear_streaming_state()
end end
end end
defp clear_streaming_state(socket) do
input = socket.assigns.input || ""
chat_editor = socket.assigns.chat_editor || %{}
chat_editor =
chat_editor
|> Map.put(:is_streaming, false)
|> Map.put(:pending_user_message, nil)
|> Map.put(:streaming_content, "")
|> Map.put(:streaming_tool_markers, [])
|> Map.put(:streaming_inline_surfaces, [])
|> Map.put(:send_disabled?, String.trim(input) == "")
assign(socket, :chat_editor, chat_editor)
end
defp do_finish_request(socket, result) do defp do_finish_request(socket, result) do
case result do case result do
{:ok, reply} -> {:ok, reply} ->
@@ -314,7 +331,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp update_request(socket, updater) do defp update_request(socket, updater) do
case socket.assigns.request do case socket.assigns.request do
nil -> nil ->
socket build_data(socket)
request -> request ->
socket socket

View File

@@ -1,6 +1,6 @@
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface"> <div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header"> <div class="chat-panel-header flex shrink-0 items-center justify-between gap-3 px-4 py-3">
<div class="chat-panel-title"> <div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
<span class="chat-panel-title-main"> <span class="chat-panel-title-main">
<%= if @chat_editor.needs_api_key? do %> <%= if @chat_editor.needs_api_key? do %>
<%= dgettext("ui", "AI Chat Setup") %> <%= dgettext("ui", "AI Chat Setup") %>
@@ -10,9 +10,9 @@
</span> </span>
<%= unless @chat_editor.needs_api_key? do %> <%= unless @chat_editor.needs_api_key? do %>
<span class="chat-model-selector-wrap"> <span class="chat-model-selector-wrap relative shrink-0">
<button <button
class="chat-model-selector-button chat-model-selector-inline" class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2"
type="button" type="button"
phx-click="toggle_chat_model_selector" phx-click="toggle_chat_model_selector"
phx-target={@myself} phx-target={@myself}
@@ -23,7 +23,7 @@
</button> </button>
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %> <%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
<div class="chat-model-selector-menu"> <div class="chat-model-selector-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-56 flex-col">
<%= for group <- @chat_editor.available_model_groups do %> <%= for group <- @chat_editor.available_model_groups do %>
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}> <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 %> <%= if length(@chat_editor.available_model_groups) > 1 do %>
@@ -33,7 +33,7 @@
<%= for model <- group.models do %> <%= for model <- group.models do %>
<button <button
class={[ class={[
"chat-model-selector-option", "chat-model-selector-option ui-dropdown-item flex items-center justify-between gap-2 text-left",
if(model.id == @chat_editor.effective_model, do: "active") if(model.id == @chat_editor.effective_model, do: "active")
]} ]}
type="button" type="button"
@@ -55,19 +55,19 @@
</div> </div>
</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 %> <%= 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 ui-section-card flex flex-col items-start gap-3 p-4" data-testid="chat-api-key-required">
<div class="chat-welcome-icon">🔑</div> <div class="chat-welcome-icon">🔑</div>
<h2><%= dgettext("ui", "API Key Required") %></h2> <h2><%= dgettext("ui", "API Key Required") %></h2>
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p> <p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
<div class="api-key-form"> <div class="api-key-form">
<button class="api-key-submit" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button> <button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
</div> </div>
</div> </div>
<% else %> <% else %>
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %> <%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
<div class="chat-welcome"> <div class="chat-welcome ui-section-card flex flex-col items-start gap-3 p-4">
<div class="chat-welcome-icon">🤖</div> <div class="chat-welcome-icon">🤖</div>
<h2><%= dgettext("ui", "Welcome to the AI Assistant") %></h2> <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> <p><%= dgettext("ui", "I can help you manage your blog with rich visualizations. Try asking me to:") %></p>
@@ -80,22 +80,10 @@
</ul> </ul>
</div> </div>
<% else %> <% else %>
<%= if @chat_editor.pending_user_message do %>
<div class="chat-message user pending" data-testid="chat-pending-user-message">
<div class="chat-message-avatar">👤</div>
<div class="chat-message-content">
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span>
</div>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
</div>
</div>
<% end %>
<%= for message <- @chat_editor.messages do %> <%= 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-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
<div class="chat-message-content"> <div class="chat-message-content ui-section-card">
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div> <div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} /> <.chat_tool_markers markers={message.tool_markers} />
@@ -112,10 +100,22 @@
<% end %> <% end %>
<%= if @chat_editor.pending_user_message do %>
<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 ui-section-card">
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span>
</div>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
</div>
</div>
<% end %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %> <%= 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-avatar">🤖</div>
<div class="chat-message-content"> <div class="chat-message-content ui-section-card">
<div class="chat-message-header"> <div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:assistant) %></span> <span class="chat-message-role"><%= message_role_label(:assistant) %></span>
<span class="streaming-indicator">●</span> <span class="streaming-indicator">●</span>
@@ -133,7 +133,7 @@
<% end %> <% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %> <%= 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-avatar">🤖</div>
<div class="chat-message-content"> <div class="chat-message-content">
<div class="chat-thinking-indicator"> <div class="chat-thinking-indicator">
@@ -147,14 +147,14 @@
</div> </div>
<%= unless @chat_editor.needs_api_key? do %> <%= unless @chat_editor.needs_api_key? do %>
<div class="chat-input-container" data-testid="chat-input-container"> <div class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3" data-testid="chat-input-container">
<%= if @chat_editor.is_streaming do %> <%= 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> <button class="chat-abort-button ui-button ui-button-secondary" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= dgettext("ui", "Stop") %></button>
<% end %> <% 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> <textarea class="chat-input chat-surface-input ui-textarea" 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> <button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
</form> </form>
<%= if @chat_editor.action_error do %> <%= if @chat_editor.action_error do %>

View File

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

View File

@@ -1,26 +1,25 @@
<div class="media-editor editor" data-testid="media-editor"> <div class="media-editor ui-editor-shell flex h-full min-h-0 flex-col" data-testid="media-editor">
<div class="editor-header"> <div class="editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs"> <div class="flex min-w-0 flex-1 overflow-hidden">
<div class={[ <div class={[
"editor-tab", "ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center gap-2 overflow-hidden px-3 py-2",
"active",
if(@media_editor.dirty?, do: "dirty") if(@media_editor.dirty?, do: "dirty")
]}> ]}>
<span class="editor-tab-title" data-testid="editor-title"><%= @media_editor.display_title %></span> <span class="truncate" data-testid="editor-title"><%= @media_editor.display_title %></span>
<%= if @media_editor.dirty? do %> <%= if @media_editor.dirty? do %>
<span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span> <span class="editor-tab-dirty" title={dgettext("ui", "Unsaved")}>●</span>
<% end %> <% end %>
</div> </div>
</div> </div>
<div class="editor-actions"> <div class="ui-editor-actions flex flex-wrap items-center justify-end gap-2">
<%= if @media_editor.save_state in [:dirty, :saved] do %> <%= if @media_editor.save_state in [:dirty, :saved] do %>
<span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span> <span class="auto-save-indicator"><%= media_editor_save_state_label(@media_editor.save_state) %></span>
<% end %> <% end %>
<div class="quick-actions-wrapper"> <div class="quick-actions-wrapper relative">
<button <button
class="secondary quick-actions-btn" class="secondary quick-actions-btn ui-button ui-button-secondary inline-flex items-center gap-2"
type="button" type="button"
phx-click="toggle_media_editor_quick_actions" phx-click="toggle_media_editor_quick_actions"
phx-target={@myself} phx-target={@myself}
@@ -30,16 +29,16 @@
</button> </button>
<%= if @media_editor.quick_actions_open? do %> <%= if @media_editor.quick_actions_open? do %>
<div class="quick-actions-menu"> <div class="quick-actions-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-72 flex-col">
<%= if @media_editor.is_image do %> <%= if @media_editor.is_image do %>
<button <button
class="quick-action-item" class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
phx-value-kind="ai_suggestions" 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> <strong><%= dgettext("ui", "AI Suggestions") %></strong>
<small><%= dgettext("ui", "Review title, alt text, and caption suggestions") %></small> <small><%= dgettext("ui", "Review title, alt text, and caption suggestions") %></small>
</span> </span>
@@ -50,13 +49,13 @@
<% end %> <% end %>
<button <button
class="quick-action-item" class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
type="button" type="button"
phx-click="detect_media_editor_language" phx-click="detect_media_editor_language"
phx-target={@myself} phx-target={@myself}
disabled={not @media_editor.can_detect_language?} 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> <strong><%= dgettext("ui", "Detect Language") %></strong>
<small><%= dgettext("ui", "Persist the detected language for this media item") %></small> <small><%= dgettext("ui", "Persist the detected language for this media item") %></small>
</span> </span>
@@ -66,14 +65,14 @@
<div class="quick-actions-divider"></div> <div class="quick-actions-divider"></div>
<button <button
class="quick-action-item" class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
data-testid="editor-toolbar-overlay-button" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
phx-value-kind="language_picker" phx-value-kind="language_picker"
disabled={not @media_editor.can_translate?} 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> <strong><%= dgettext("ui", "Translate") %></strong>
<small><%= dgettext("ui", "Select a target language for this media item") %></small> <small><%= dgettext("ui", "Select a target language for this media item") %></small>
</span> </span>
@@ -83,14 +82,14 @@
<% end %> <% end %>
</div> </div>
<button class="secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="replace_media_editor_file" phx-target={@myself}>
<%= dgettext("ui", "Replace File") %> <%= dgettext("ui", "Replace File") %>
</button> </button>
<button data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}> <button class="ui-button ui-button-primary" data-testid="media-save-button" type="button" phx-click="save_media_editor" phx-target={@myself}>
<%= dgettext("ui", "Save") %> <%= dgettext("ui", "Save") %>
</button> </button>
<button <button
class="secondary danger" class="secondary danger ui-button ui-button-secondary ui-button-danger"
data-testid="media-delete-button" data-testid="media-delete-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
@@ -101,14 +100,14 @@
</div> </div>
</div> </div>
<div class="editor-content media-editor"> <div class="editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4 xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]">
<div class="media-preview"> <div class="media-preview flex min-h-[16rem] items-center justify-center">
<%= if @media_editor.is_image and @media_editor.preview_url do %> <%= if @media_editor.is_image and @media_editor.preview_url do %>
<div class="media-preview-image"> <div class="media-preview-image">
<img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} /> <img src={@media_editor.preview_url} alt={@media_editor.form["alt"] || @media_editor.original_name} />
</div> </div>
<% else %> <% 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"> <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> <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> </svg>
@@ -117,60 +116,60 @@
<% end %> <% end %>
</div> </div>
<div class="media-details"> <div class="media-details min-w-0">
<form class="media-editor-details-form" data-testid="media-editor-form" phx-change="change_media_editor" phx-target={@myself}> <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"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "File Name") %></label> <label><%= dgettext("ui", "File Name") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.original_name} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.original_name} disabled />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "MIME Type") %></label> <label><%= dgettext("ui", "MIME Type") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.mime_type} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.mime_type} disabled />
</div> </div>
<div class="editor-field-row"> <div class="editor-field-row ui-field-grid-2 grid gap-4 md:grid-cols-2">
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Size") %></label> <label><%= dgettext("ui", "Size") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.file_size} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.file_size} disabled />
</div> </div>
<%= if @media_editor.dimensions do %> <%= if @media_editor.dimensions do %>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Dimensions") %></label> <label><%= dgettext("ui", "Dimensions") %></label>
<input class="post-editor-input disabled" type="text" value={@media_editor.dimensions} disabled /> <input class="post-editor-input ui-input disabled ui-input-disabled" type="text" value={@media_editor.dimensions} disabled />
</div> </div>
<% end %> <% end %>
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Title") %></label> <label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[title]" value={@media_editor.form["title"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Alt Text") %></label> <label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[alt]" value={@media_editor.form["alt"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Caption") %></label> <label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea> <textarea class="post-editor-textarea ui-textarea" name="media_editor[caption]" rows="3"><%= @media_editor.form["caption"] %></textarea>
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Tags") %></label> <label><%= dgettext("ui", "Tags") %></label>
<input class="post-editor-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[tags]" value={@media_editor.form["tags"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Author") %></label> <label><%= dgettext("ui", "Author") %></label>
<input class="post-editor-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} /> <input class="post-editor-input ui-input" type="text" name="media_editor[author]" value={@media_editor.form["author"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Language") %></label> <label><%= dgettext("ui", "Language") %></label>
<select class="post-editor-input" name="media_editor[language]"> <select class="post-editor-input ui-input" name="media_editor[language]">
<option value=""><%= dgettext("ui", "None") %></option> <option value=""><%= dgettext("ui", "None") %></option>
<%= for language <- @media_editor.languages do %> <%= for language <- @media_editor.languages do %>
<option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option> <option value={language} selected={language == @media_editor.form["language"]}><%= language_label(language) %></option>
@@ -180,15 +179,15 @@
</form> </form>
<%= if @media_editor.form["language"] not in [nil, ""] do %> <%= 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> <label><%= dgettext("ui", "Translations") %></label>
<%= if Enum.empty?(@media_editor.translations) do %> <%= if Enum.empty?(@media_editor.translations) do %>
<div class="no-linked-posts"><%= dgettext("ui", "No translations") %></div> <div class="no-linked-posts"><%= dgettext("ui", "No translations") %></div>
<% else %> <% else %>
<div class="linked-posts-list"> <div class="linked-posts-list flex flex-col gap-2">
<%= for translation <- @media_editor.translations do %> <%= for translation <- @media_editor.translations do %>
<div class="linked-post-item"> <div class="linked-post-item flex items-center justify-between gap-2">
<button <button
class="linked-post-title linked-post-link" class="linked-post-title linked-post-link"
type="button" type="button"
@@ -198,7 +197,7 @@
> >
<%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %> <%= translation.flag %> <%= language_label(translation.language) %><%= if translation.title, do: " — #{translation.title}" %>
</button> </button>
<button class="secondary compact" type="button" phx-click="refresh_media_translation" phx-target={@myself} phx-value-language={translation.language}> <button class="secondary compact ui-button ui-button-secondary ui-button-compact" type="button" phx-click="refresh_media_translation" phx-target={@myself} phx-value-language={translation.language}>
<%= dgettext("ui", "Refresh") %> <%= dgettext("ui", "Refresh") %>
</button> </button>
<button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-target={@myself} phx-value-language={translation.language}>×</button> <button class="unlink-btn" type="button" phx-click="delete_media_translation" phx-target={@myself} phx-value-language={translation.language}>×</button>
@@ -209,7 +208,7 @@
</div> </div>
<% end %> <% end %>
<div class="editor-field linked-posts-section"> <div class="editor-field linked-posts-section flex flex-col gap-2">
<label> <label>
<%= dgettext("ui", "Linked Posts") %> <%= dgettext("ui", "Linked Posts") %>
<button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}> <button class="add-link-btn" type="button" phx-click="toggle_media_post_picker" phx-target={@myself}>
@@ -218,7 +217,7 @@
</label> </label>
<%= if @media_editor.post_picker_open? do %> <%= 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"> <div class="post-picker-search">
<input <input
type="text" type="text"
@@ -233,7 +232,7 @@
<%= if Enum.empty?(@media_editor.post_picker_results) do %> <%= if Enum.empty?(@media_editor.post_picker_results) do %>
<div class="no-posts"><%= dgettext("ui", "No posts to link") %></div> <div class="no-posts"><%= dgettext("ui", "No posts to link") %></div>
<% else %> <% else %>
<div class="post-picker-list"> <div class="post-picker-list flex flex-col gap-2">
<%= for result <- @media_editor.post_picker_results do %> <%= 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}> <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 %> <%= result.title %>
@@ -250,9 +249,9 @@
<%= if Enum.empty?(@media_editor.linked_posts) do %> <%= if Enum.empty?(@media_editor.linked_posts) do %>
<div class="no-linked-posts"><%= dgettext("ui", "Not linked to any posts") %></div> <div class="no-linked-posts"><%= dgettext("ui", "Not linked to any posts") %></div>
<% else %> <% else %>
<div class="linked-posts-list"> <div class="linked-posts-list flex flex-col gap-2">
<%= for linked_post <- @media_editor.linked_posts do %> <%= 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 <button
class="linked-post-title linked-post-link" class="linked-post-title linked-post-link"
type="button" type="button"
@@ -275,29 +274,29 @@
<%= if @media_editor.editing_translation do %> <%= if @media_editor.editing_translation do %>
<div class="translation-modal-backdrop"> <div class="translation-modal-backdrop">
<div class="translation-modal"> <div class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden">
<div class="translation-modal-header"> <div class="translation-modal-header flex items-center justify-between gap-3">
<h2><%= dgettext("ui", "Edit Translation") %></h2> <h2><%= dgettext("ui", "Edit Translation") %></h2>
<button class="translation-modal-close" type="button" phx-click="close_media_translation_editor" phx-target={@myself}>×</button> <button class="translation-modal-close" type="button" phx-click="close_media_translation_editor" phx-target={@myself}>×</button>
</div> </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"]} /> <input type="hidden" name="media_translation[language]" value={@media_editor.editing_translation["language"]} />
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Title") %></label> <label><%= dgettext("ui", "Title") %></label>
<input class="post-editor-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} /> <input class="post-editor-input ui-input" type="text" name="media_translation[title]" value={@media_editor.editing_translation["title"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Alt Text") %></label> <label><%= dgettext("ui", "Alt Text") %></label>
<input class="post-editor-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} /> <input class="post-editor-input ui-input" type="text" name="media_translation[alt]" value={@media_editor.editing_translation["alt"]} />
</div> </div>
<div class="editor-field"> <div class="editor-field ui-field-stack flex flex-col gap-1.5">
<label><%= dgettext("ui", "Caption") %></label> <label><%= dgettext("ui", "Caption") %></label>
<textarea class="post-editor-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea> <textarea class="post-editor-textarea ui-textarea" name="media_translation[caption]" rows="3"><%= @media_editor.editing_translation["caption"] %></textarea>
</div> </div>
</form> </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 class="secondary ui-button ui-button-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> <button class="ui-button ui-button-primary" type="button" phx-click="save_media_translation" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
</div> </div>
</div> </div>
</div> </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-view ui-editor-shell flex h-full min-h-0 flex-col p-4" 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-header flex shrink-0 items-start justify-between gap-3">
<div> <div class="ui-field-stack">
<h2><%= @menu_editor.title %></h2> <h2><%= @menu_editor.title %></h2>
<p><%= @menu_editor.description %></p> <p><%= @menu_editor.description %></p>
</div> </div>
</div> </div>
<div class="menu-editor-main"> <div class="menu-editor-main min-h-0 flex-1 overflow-hidden">
<div class="menu-editor-tree-wrap"> <div class="menu-editor-tree-wrap ui-section-card flex h-full min-h-0 flex-col">
<div class="menu-editor-toolbar" data-testid="menu-editor-toolbar" role="toolbar" aria-label={@menu_editor.title}> <div class="menu-editor-toolbar ui-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" 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")}> <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> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5V2z" /></svg>
</button> </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> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
</button> </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> <span aria-hidden="true"><%= dgettext("ui", "menuEditor.addCategoryArchiveShort") %></span>
</button> </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> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
</button> </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> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
</button> </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> <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>
<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> <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>
<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> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
</button> </button>
</div> </div>
<%= if @menu_editor.items == [] do %> <%= 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 %> <% 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"> <ul class="menu-editor-tree-level">
<.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} myself={@myself} /> <.menu_tree_level items={@menu_editor.items} menu_editor={@menu_editor} depth={0} myself={@myself} />
</ul> </ul>

View File

@@ -1,39 +1,51 @@
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor"> <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"> <div class="misc-editor-header flex shrink-0 items-start justify-between gap-3">
<div> <div>
<h2><%= @misc_editor.title %></h2> <h2><%= @misc_editor.title %></h2>
<p><%= @misc_editor.subtitle %></p> <p><%= @misc_editor.subtitle %></p>
</div> </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 %> <%= if refreshable?(@misc_editor.kind) do %>
<button class="secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button>
<% end %> <% end %>
<%= if @misc_editor.kind == :site_validation do %> <%= if @misc_editor.kind == :site_validation do %>
<button class="primary" type="button" phx-click="apply_site_validation" phx-target={@myself} disabled={Enum.empty?(@misc_editor.missing_url_paths) and Enum.empty?(@misc_editor.extra_url_paths) and Enum.empty?(@misc_editor.updated_post_url_paths)}><%= dgettext("ui", "Apply") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="apply_site_validation" phx-target={@myself} disabled={Enum.empty?(@misc_editor.missing_url_paths) and Enum.empty?(@misc_editor.extra_url_paths) and Enum.empty?(@misc_editor.updated_post_url_paths)}><%= dgettext("ui", "Apply") %></button>
<% end %> <% end %>
<%= if @misc_editor.kind == :find_duplicates do %> <%= if @misc_editor.kind == :find_duplicates do %>
<button class="secondary" type="button" phx-click="dismiss_selected_duplicates" phx-target={@myself} disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= dgettext("ui", "Dismiss Checked") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="dismiss_selected_duplicates" phx-target={@myself} disabled={MapSet.size(@misc_editor.selected_pairs) == 0}><%= dgettext("ui", "Dismiss Checked") %></button>
<% end %> <% end %>
</div> </div>
</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 %> <%= for {label, value} <- summary_items(@misc_editor) do %>
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div> <div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
<% end %> <% end %>
</div> </div>
<div class="misc-editor-content"> <div class="misc-editor-content min-h-0 flex-1 overflow-auto">
<%= case @misc_editor.kind do %> <%= case @misc_editor.kind do %>
<% :documentation -> %> <% :documentation -> %>
<article class="misc-card help-doc-markdown" data-testid="help-documentation"> <div class="documentation-view">
<main class="documentation-scroll">
<div class="documentation-content markdown-body">
<article class="documentation-article help-doc-markdown" data-testid="help-documentation">
<%= markdown_html(@misc_editor.markdown) %> <%= markdown_html(@misc_editor.markdown) %>
</article> </article>
</div>
</main>
</div>
<% :api_documentation -> %> <% :api_documentation -> %>
<article class="misc-card help-doc-markdown" data-testid="help-api-documentation"> <div class="documentation-view">
<main class="documentation-scroll">
<div class="documentation-content markdown-body">
<article class="documentation-article help-doc-markdown" data-testid="help-api-documentation">
<%= markdown_html(@misc_editor.markdown) %> <%= markdown_html(@misc_editor.markdown) %>
</article> </article>
</div>
</main>
</div>
<% :site_validation -> %> <% :site_validation -> %>
<div class="misc-columns"> <div class="misc-columns">
@@ -47,7 +59,7 @@
<div class="metadata-diff-tabs" role="tablist"> <div class="metadata-diff-tabs" role="tablist">
<%= for tab <- @misc_editor.tabs do %> <%= for tab <- @misc_editor.tabs do %>
<button <button
class={["metadata-diff-tab", if(@misc_editor.active_tab == tab.id, do: "active")]} class={["metadata-diff-tab", "ui-tab", if(@misc_editor.active_tab == tab.id, do: "active ui-tab-active")]}
data-testid="metadata-diff-tab" data-testid="metadata-diff-tab"
data-entity-tab={tab.id} data-entity-tab={tab.id}
type="button" type="button"
@@ -57,7 +69,7 @@
> >
<span><%= tab.label %></span> <span><%= tab.label %></span>
<%= if tab.badge_count > 0 do %> <%= if tab.badge_count > 0 do %>
<span class="tab-badge"><%= tab.badge_count %></span> <span class="tab-badge ui-badge"><%= tab.badge_count %></span>
<% end %> <% end %>
</button> </button>
<% end %> <% end %>
@@ -83,7 +95,7 @@
<%= if @misc_editor.repair_enabled do %> <%= if @misc_editor.repair_enabled do %>
<div class="metadata-diff-field-pill-actions"> <div class="metadata-diff-field-pill-actions">
<button <button
class="secondary metadata-diff-action-button" class="secondary metadata-diff-action-button ui-button ui-button-secondary"
data-testid="metadata-diff-repair-button" data-testid="metadata-diff-repair-button"
data-direction="db_to_file" data-direction="db_to_file"
data-field={field.field_name} data-field={field.field_name}
@@ -97,7 +109,7 @@
</button> </button>
<button <button
class="secondary metadata-diff-action-button" class="secondary metadata-diff-action-button ui-button ui-button-secondary"
data-testid="metadata-diff-repair-button" data-testid="metadata-diff-repair-button"
data-direction="file_to_db" data-direction="file_to_db"
data-field={field.field_name} data-field={field.field_name}
@@ -161,7 +173,7 @@
<div class="orphan-files-actions"> <div class="orphan-files-actions">
<span class="misc-summary-pill"><%= length(@misc_editor.orphan_files) %></span> <span class="misc-summary-pill"><%= length(@misc_editor.orphan_files) %></span>
<button <button
class="secondary metadata-diff-action-button" class="secondary metadata-diff-action-button ui-button ui-button-secondary"
data-testid="metadata-diff-import-button" data-testid="metadata-diff-import-button"
type="button" type="button"
phx-click="import_metadata_diff_orphans" phx-click="import_metadata_diff_orphans"
@@ -268,8 +280,8 @@
</section> </section>
<div class="translation-validation-actions"> <div class="translation-validation-actions">
<button class="secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself} data-testid="translation-validation-revalidate"><%= dgettext("ui", "translationValidation.revalidate") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself} data-testid="translation-validation-revalidate"><%= dgettext("ui", "translationValidation.revalidate") %></button>
<button class="primary" type="button" phx-click="fix_translation_validation" phx-target={@myself} data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= dgettext("ui", "translationValidation.fix") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="fix_translation_validation" phx-target={@myself} data-testid="translation-validation-fix" disabled={not @misc_editor.can_fix?}><%= dgettext("ui", "translationValidation.fix") %></button>
</div> </div>
</div> </div>
@@ -282,7 +294,7 @@
<span>→</span> <span>→</span>
<button class="linkish" type="button" phx-click="open_duplicate_post" phx-target={@myself} phx-value-id={BDS.MapUtils.attr(pair, :post_id_b)} phx-value-title={BDS.MapUtils.attr(pair, :title_b)}><%= BDS.MapUtils.attr(pair, :title_b) %></button> <button class="linkish" type="button" phx-click="open_duplicate_post" phx-target={@myself} phx-value-id={BDS.MapUtils.attr(pair, :post_id_b)} phx-value-title={BDS.MapUtils.attr(pair, :title_b)}><%= BDS.MapUtils.attr(pair, :title_b) %></button>
<span class="misc-summary-pill"><%= if(BDS.MapUtils.attr(pair, :exact_match), do: dgettext("ui", "Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %></span> <span class="misc-summary-pill"><%= if(BDS.MapUtils.attr(pair, :exact_match), do: dgettext("ui", "Exact Match"), else: "#{Float.round((BDS.MapUtils.attr(pair, :similarity) || 0.0) * 100, 1)}%") %></span>
<button class="secondary" type="button" phx-click="dismiss_duplicate_pair" phx-target={@myself} phx-value-post-id-a={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-post-id-b={BDS.MapUtils.attr(pair, :post_id_b)}><%= dgettext("ui", "Dismiss") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="dismiss_duplicate_pair" phx-target={@myself} phx-value-post-id-a={BDS.MapUtils.attr(pair, :post_id_a)} phx-value-post-id-b={BDS.MapUtils.attr(pair, :post_id_b)}><%= dgettext("ui", "Dismiss") %></button>
</article> </article>
<% end %> <% end %>
</div> </div>
@@ -294,7 +306,7 @@
<% else %> <% else %>
<form class="git-diff-toolbar" phx-change="select_git_diff_file" phx-target={@myself}> <form class="git-diff-toolbar" phx-change="select_git_diff_file" phx-target={@myself}>
<label for="git-diff-file-select"><%= dgettext("ui", "gitDiff.changedFiles") %></label> <label for="git-diff-file-select"><%= dgettext("ui", "gitDiff.changedFiles") %></label>
<select id="git-diff-file-select" data-testid="git-diff-file-select" name="path"> <select class="ui-input" id="git-diff-file-select" data-testid="git-diff-file-select" name="path">
<%= for file_path <- @misc_editor.files do %> <%= for file_path <- @misc_editor.files do %>
<option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option> <option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option>
<% end %> <% end %>

View File

@@ -30,10 +30,10 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
~H""" ~H"""
<%= if Enum.any?(@editor_toolbar_buttons) do %> <%= 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 %> <%= for button <- @editor_toolbar_buttons do %>
<button <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" data-testid="editor-toolbar-overlay-button"
type="button" type="button"
phx-click="open_overlay" phx-click="open_overlay"
@@ -50,15 +50,15 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp render_task_entries(assigns) do defp render_task_entries(assigns) do
~H""" ~H"""
<%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %> <%= if Enum.empty?(Map.get(@task_status, :tasks, [])) do %>
<div class="panel-entry panel-empty-state"> <div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Tasks") %></strong> <strong><%= dgettext("ui", "Tasks") %></strong>
<span><%= dgettext("ui", "No background tasks running") %></span> <span><%= dgettext("ui", "No background tasks running") %></span>
</div> </div>
<% else %> <% else %>
<div class="task-list"> <div class="task-list flex flex-col gap-2">
<%= for task <- Map.get(@task_status, :tasks, []) do %> <%= for task <- Map.get(@task_status, :tasks, []) do %>
<div class="panel-entry task-entry"> <div class="panel-entry ui-panel-entry task-entry flex flex-col gap-2">
<div class="task-entry-header"> <div class="task-entry-header flex items-center justify-between gap-2">
<strong><%= task.name %></strong> <strong><%= task.name %></strong>
<span class={"task-status task-status-#{task.status}"}><%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %></span> <span class={"task-status task-status-#{task.status}"}><%= Map.get(task, :status_label, task.status |> to_string() |> String.capitalize()) %></span>
</div> </div>
@@ -79,15 +79,16 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp render_output_entries(assigns) do defp render_output_entries(assigns) do
~H""" ~H"""
<%= if Enum.empty?(@output_entries) do %> <%= if Enum.empty?(@output_entries) do %>
<div class="panel-entry panel-empty-state output-list"> <div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state output-list">
<strong><%= dgettext("ui", "Output") %></strong> <strong><%= dgettext("ui", "Output") %></strong>
<span><%= dgettext("ui", "No shell output yet") %></span> <span><%= dgettext("ui", "No shell output yet") %></span>
</div> </div>
<% else %> <% else %>
<div class="output-list"> <div class="output-list flex flex-col gap-2">
<%= for entry <- @output_entries do %> <%= for entry <- @output_entries do %>
<div class={[ <div class={[
"panel-entry", "panel-entry",
"ui-panel-entry",
"output-entry", "output-entry",
if(Map.get(entry, :level) == "error", do: "output-entry-error") if(Map.get(entry, :level) == "error", do: "output-entry-error")
]}> ]}>
@@ -113,17 +114,17 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
~H""" ~H"""
<%= if Enum.empty?(@backlinks) and Enum.empty?(@outlinks) do %> <%= if Enum.empty?(@backlinks) and Enum.empty?(@outlinks) do %>
<div class="panel-entry panel-empty-state"> <div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Post Links") %></strong> <strong><%= dgettext("ui", "Post Links") %></strong>
<span><%= dgettext("ui", "No post links yet") %></span> <span><%= dgettext("ui", "No post links yet") %></span>
</div> </div>
<% else %> <% else %>
<div class="git-log-list"> <div class="git-log-list flex flex-col gap-2">
<%= if Enum.any?(@backlinks) do %> <%= if Enum.any?(@backlinks) do %>
<div class="panel-entry"><strong><%= dgettext("ui", "Backlinks") %></strong></div> <div class="panel-entry ui-panel-entry"><strong><%= dgettext("ui", "Backlinks") %></strong></div>
<%= for entry <- @backlinks do %> <%= for entry <- @backlinks do %>
<button <button
class="panel-entry task-entry" class="panel-entry ui-panel-entry task-entry"
type="button" type="button"
phx-click="pin_sidebar_item" phx-click="pin_sidebar_item"
phx-value-route="post" phx-value-route="post"
@@ -138,10 +139,10 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<% end %> <% end %>
<%= if Enum.any?(@outlinks) do %> <%= if Enum.any?(@outlinks) do %>
<div class="panel-entry"><strong><%= dgettext("ui", "Links To") %></strong></div> <div class="panel-entry ui-panel-entry"><strong><%= dgettext("ui", "Links To") %></strong></div>
<%= for entry <- @outlinks do %> <%= for entry <- @outlinks do %>
<button <button
class="panel-entry task-entry" class="panel-entry ui-panel-entry task-entry"
type="button" type="button"
phx-click="pin_sidebar_item" phx-click="pin_sidebar_item"
phx-value-route="post" phx-value-route="post"
@@ -165,8 +166,8 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
~H""" ~H"""
<%= if Enum.empty?(@git_entries) do %> <%= 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"> <div class="panel-entry ui-panel-entry panel-empty-state ui-empty-state">
<strong><%= dgettext("ui", "Git Log") %></strong> <strong><%= dgettext("ui", "Git Log") %></strong>
<span><%= dgettext("ui", "No git history yet") %></span> <span><%= dgettext("ui", "No git history yet") %></span>
</div> </div>
@@ -174,7 +175,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
<% else %> <% else %>
<div class="git-log-list"> <div class="git-log-list">
<%= for entry <- @git_entries do %> <%= for entry <- @git_entries do %>
<div class="panel-entry task-entry"> <div class="panel-entry ui-panel-entry task-entry">
<strong><%= short_commit_hash(entry.hash) %> <%= entry.subject || dgettext("ui", "No commit subject") %></strong> <strong><%= short_commit_hash(entry.hash) %> <%= entry.subject || dgettext("ui", "No commit subject") %></strong>
<span><%= entry.hash %></span> <span><%= entry.hash %></span>
</div> </div>
@@ -188,7 +189,7 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
assigns = assign(assigns, :panel_label, ShellData.route_label(tab)) assigns = assign(assigns, :panel_label, ShellData.route_label(tab))
~H""" ~H"""
<div class="panel-entry"> <div class="panel-entry ui-panel-entry">
<strong><%= @panel_label %></strong> <strong><%= @panel_label %></strong>
<span><%= dgettext("ui", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %></span> <span><%= dgettext("ui", "The shared lower panel is available for tasks, output, git details, and editor-specific diagnostics.") %></span>
</div> </div>

View File

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

View File

@@ -1,49 +1,50 @@
<div class="scripts-view-shell editor" data-testid="script-editor"> <div class="scripts-view-shell ui-editor-shell flex h-full min-h-0 flex-col" data-testid="script-editor">
<div class="editor-header scripts-header"> <div class="ui-editor-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @script_editor.title %></span></div></div> <div class="flex min-w-0 flex-1 overflow-hidden"><div class="ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center overflow-hidden px-3 py-2"><span class="truncate"><%= @script_editor.title %></span></div></div>
<div class="editor-actions"> <div class="ui-editor-actions flex flex-wrap items-center justify-end gap-2">
<span class={[ <span class={[
"status-badge", "status-badge",
"ui-badge",
"status-#{@script_editor.status}" "status-#{@script_editor.status}"
]} data-testid="script-status-badge"><%= BDS.Desktop.ShellData.dashboard_status_label(@script_editor.status) %></span> ]} data-testid="script-status-badge"><%= BDS.Desktop.ShellData.dashboard_status_label(@script_editor.status) %></span>
<%= if @script_editor.can_publish? do %> <%= if @script_editor.can_publish? do %>
<button class="success" data-testid="script-publish-button" type="button" phx-click="publish_script_editor" phx-target={@myself}><%= dgettext("ui", "Publish") %></button> <button class="success ui-button ui-button-primary" data-testid="script-publish-button" type="button" phx-click="publish_script_editor" phx-target={@myself}><%= dgettext("ui", "Publish") %></button>
<% end %> <% end %>
<button class="secondary scripts-save-button" type="button" phx-click="save_script_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button> <button class="secondary scripts-save-button ui-button ui-button-secondary" type="button" phx-click="save_script_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
<button class="secondary scripts-run-button" type="button" phx-click="run_script_editor" phx-target={@myself}><%= dgettext("ui", "Run") %></button> <button class="secondary scripts-run-button ui-button ui-button-secondary" type="button" phx-click="run_script_editor" phx-target={@myself}><%= dgettext("ui", "Run") %></button>
<button class="secondary scripts-check-button" type="button" phx-click="check_script_editor" phx-target={@myself}><%= dgettext("ui", "Check Syntax") %></button> <button class="secondary scripts-check-button ui-button ui-button-secondary" type="button" phx-click="check_script_editor" phx-target={@myself}><%= dgettext("ui", "Check Syntax") %></button>
<button class="secondary danger" type="button" phx-click="delete_script_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button> <button class="secondary danger ui-button ui-button-secondary ui-button-danger" type="button" phx-click="delete_script_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button>
</div> </div>
</div> </div>
<form class="editor-content scripts-view" phx-change="change_script_editor" phx-target={@myself}> <form class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4" phx-change="change_script_editor" phx-target={@myself}>
<div class="editor-header-row scripts-meta-row"> <div class="grid gap-4">
<div class="editor-meta"> <div class="flex min-w-0 flex-col gap-4">
<div class="editor-field-row"> <div class="ui-field-grid-2 grid gap-4 md:grid-cols-2">
<div class="editor-field"><label><%= dgettext("ui", "Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div> <div class="ui-field-stack flex flex-col gap-1.5"><label><%= dgettext("ui", "Title") %></label><input class="ui-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> <div class="ui-field-stack flex flex-col gap-1.5"><label><%= dgettext("ui", "Slug") %></label><input class="ui-input" type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
</div> </div>
<div class="editor-field-row"> <div class="ui-field-grid-3 grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<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="ui-field-stack flex flex-col gap-1.5"><label><%= dgettext("ui", "Kind") %></label><select class="ui-input" 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="ui-field-stack flex flex-col gap-1.5"><label><%= dgettext("ui", "Entrypoint") %></label><select class="ui-input" 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="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>
</div> </div>
<div class="editor-body scripts-editor"> <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="editor-toolbar scripts-toolbar"><div class="editor-toolbar-left"><label><%= dgettext("ui", "Content") %></label></div></div> <div class="ui-toolbar flex items-center gap-3"><div class="ui-toolbar-group flex items-center gap-2"><label><%= dgettext("ui", "Content") %></label></div></div>
<div <div
id={"script-editor-monaco-shell-#{@script_editor.id}"} 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" phx-hook="MonacoEditor"
data-monaco-editor-id={@script_editor.id} data-monaco-editor-id={@script_editor.id}
data-monaco-input-id={"script-editor-content-#{@script_editor.id}"} data-monaco-input-id={"script-editor-content-#{@script_editor.id}"}
data-monaco-language="lua" data-monaco-language="lua"
data-monaco-word-wrap="on" 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> <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> </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="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> </form>
</div> </div>

View File

@@ -1,29 +1,29 @@
<div <div
id="settings-editor-shell" id="settings-editor-shell"
class="settings-view-shell" class="settings-view-shell ui-editor-shell flex h-full min-h-0 flex-col overflow-hidden"
data-testid="settings-editor" data-testid="settings-editor"
phx-hook="SettingsSectionScroll" phx-hook="SettingsSectionScroll"
data-selected-settings-section={@settings_editor.selected_section} data-selected-settings-section={@settings_editor.selected_section}
data-settings-scroll-target={"settings-section-#{@settings_editor.selected_section}"} data-settings-scroll-target={"settings-section-#{@settings_editor.selected_section}"}
> >
<div class="settings-view"> <div class="settings-view flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="settings-header"> <div class="settings-header flex shrink-0 items-center justify-between gap-3">
<h2 data-testid="editor-title"><%= dgettext("ui", "Settings") %></h2> <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")} /> <input class="ui-input" type="text" name="query" value={@settings_editor.search_query} placeholder={dgettext("ui", "Search settings")} />
</form> </form>
</div> </div>
<div class="settings-content"> <div class="settings-content min-h-0 flex-1 overflow-auto">
<%= if Enum.empty?(@settings_editor.active_sections) do %> <%= 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> <p><%= dgettext("ui", "No settings match the current search") %></p>
</div> </div>
<% end %> <% end %>
<%= if @settings_editor.project_visible? do %> <%= if @settings_editor.project_visible? do %>
<div class="setting-section" id="settings-section-project"> <div class="setting-section ui-section-card" id="settings-section-project">
<div class="setting-section-header"> <div class="setting-section-header ui-field-stack">
<h3><%= dgettext("ui", "Project") %></h3> <h3><%= dgettext("ui", "Project") %></h3>
<p class="setting-section-description"><%= dgettext("ui", "Blog identity, URLs, authoring defaults, and bookmarklet setup") %></p> <p class="setting-section-description"><%= dgettext("ui", "Blog identity, URLs, authoring defaults, and bookmarklet setup") %></p>
</div> </div>
@@ -32,29 +32,29 @@
<div class="setting-info"> <div class="setting-info">
<label class="setting-label"><%= dgettext("ui", "Project Name") %></label> <label class="setting-label"><%= dgettext("ui", "Project Name") %></label>
</div> </div>
<div class="setting-control"><input type="text" name="settings_project[name]" value={@settings_editor.project["name"]} /></div> <div class="setting-control"><input class="ui-input" type="text" name="settings_project[name]" value={@settings_editor.project["name"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Description") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Description") %></label></div>
<div class="setting-control"><textarea name="settings_project[description]" rows="3"><%= @settings_editor.project["description"] %></textarea></div> <div class="setting-control"><textarea class="ui-textarea" name="settings_project[description]" rows="3"><%= @settings_editor.project["description"] %></textarea></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Data Path") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Data Path") %></label></div>
<div class="setting-control"> <div class="setting-control">
<div class="setting-input-group"> <div class="setting-input-group">
<input type="text" value={@settings_editor.project_data_path} readonly /> <input class="ui-input ui-input-readonly" type="text" value={@settings_editor.project_data_path} readonly />
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open") %></button>
</div> </div>
</div> </div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Public URL") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Public URL") %></label></div>
<div class="setting-control"><input type="url" name="settings_project[public_url]" value={@settings_editor.project["public_url"]} /></div> <div class="setting-control"><input class="ui-input" type="url" name="settings_project[public_url]" value={@settings_editor.project["public_url"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Main Language") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Main Language") %></label></div>
<div class="setting-control"> <div class="setting-control">
<select name="settings_project[main_language]"> <select class="ui-input" name="settings_project[main_language]">
<%= for language <- @settings_editor.supported_languages do %> <%= for language <- @settings_editor.supported_languages do %>
<option value={language} selected={language == @settings_editor.project["main_language"]}><%= String.upcase(language) %></option> <option value={language} selected={language == @settings_editor.project["main_language"]}><%= String.upcase(language) %></option>
<% end %> <% end %>
@@ -76,16 +76,16 @@
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Default Author") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Default Author") %></label></div>
<div class="setting-control"><input type="text" name="settings_project[default_author]" value={@settings_editor.project["default_author"]} /></div> <div class="setting-control"><input class="ui-input" type="text" name="settings_project[default_author]" value={@settings_editor.project["default_author"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div>
<div class="setting-control"><input type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div> <div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
<div class="setting-control"> <div class="setting-control">
<select name="settings_project[blogmark_category]"> <select class="ui-input" name="settings_project[blogmark_category]">
<%= for category <- Enum.map(@settings_editor.categories, & &1.name) do %> <%= for category <- Enum.map(@settings_editor.categories, & &1.name) do %>
<option value={category} selected={category == @settings_editor.project["blogmark_category"]}><%= category %></option> <option value={category} selected={category == @settings_editor.project["blogmark_category"]}><%= category %></option>
<% end %> <% end %>
@@ -97,13 +97,13 @@
<div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p></div> <div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Bookmarklet copy support is wired through the desktop runtime and project public URL.") %></p></div>
</div> </div>
</form> </form>
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div> <div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
</div> </div>
<% end %> <% end %>
<%= if @settings_editor.editor_visible? do %> <%= if @settings_editor.editor_visible? do %>
<div class="setting-section" id="settings-section-editor"> <div class="setting-section ui-section-card" id="settings-section-editor">
<div class="setting-section-header"> <div class="setting-section-header ui-field-stack">
<h3><%= dgettext("ui", "Editor") %></h3> <h3><%= dgettext("ui", "Editor") %></h3>
<p class="setting-section-description"><%= dgettext("ui", "Default editing mode and diff presentation") %></p> <p class="setting-section-description"><%= dgettext("ui", "Default editing mode and diff presentation") %></p>
</div> </div>
@@ -111,7 +111,7 @@
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Default Editor Mode") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Default Editor Mode") %></label></div>
<div class="setting-control"> <div class="setting-control">
<select name="settings_editor[default_mode]"> <select class="ui-input" name="settings_editor[default_mode]">
<option value="wysiwyg" selected={@settings_editor.editor["default_mode"] == "wysiwyg"}><%= dgettext("ui", "WYSIWYG") %></option> <option value="wysiwyg" selected={@settings_editor.editor["default_mode"] == "wysiwyg"}><%= dgettext("ui", "WYSIWYG") %></option>
<option value="markdown" selected={@settings_editor.editor["default_mode"] == "markdown"}><%= dgettext("ui", "Markdown") %></option> <option value="markdown" selected={@settings_editor.editor["default_mode"] == "markdown"}><%= dgettext("ui", "Markdown") %></option>
<option value="preview" selected={@settings_editor.editor["default_mode"] == "preview"}><%= dgettext("ui", "Preview") %></option> <option value="preview" selected={@settings_editor.editor["default_mode"] == "preview"}><%= dgettext("ui", "Preview") %></option>
@@ -121,7 +121,7 @@
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Diff View Style") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Diff View Style") %></label></div>
<div class="setting-control"> <div class="setting-control">
<select name="settings_editor[diff_view_style]"> <select class="ui-input" name="settings_editor[diff_view_style]">
<option value="inline" selected={@settings_editor.editor["diff_view_style"] == "inline"}><%= dgettext("ui", "Inline") %></option> <option value="inline" selected={@settings_editor.editor["diff_view_style"] == "inline"}><%= dgettext("ui", "Inline") %></option>
<option value="side-by-side" selected={@settings_editor.editor["diff_view_style"] == "side-by-side"}><%= dgettext("ui", "Side by Side") %></option> <option value="side-by-side" selected={@settings_editor.editor["diff_view_style"] == "side-by-side"}><%= dgettext("ui", "Side by Side") %></option>
</select> </select>
@@ -136,13 +136,13 @@
<div class="setting-control"><label><input type="checkbox" name="settings_editor[hide_unchanged_regions]" checked={@settings_editor.editor["hide_unchanged_regions"]} /> <%= dgettext("ui", "Collapse unchanged diff hunks") %></label></div> <div class="setting-control"><label><input type="checkbox" name="settings_editor[hide_unchanged_regions]" checked={@settings_editor.editor["hide_unchanged_regions"]} /> <%= dgettext("ui", "Collapse unchanged diff hunks") %></label></div>
</div> </div>
</form> </form>
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div> <div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
</div> </div>
<% end %> <% end %>
<%= if @settings_editor.content_visible? do %> <%= if @settings_editor.content_visible? do %>
<div class="setting-section" id="settings-section-content"> <div class="setting-section ui-section-card" id="settings-section-content">
<div class="setting-section-header"><h3><%= dgettext("ui", "Content Categories") %></h3><p class="setting-section-description"><%= dgettext("ui", "Category defaults, rendering flags, and template wiring") %></p></div> <div class="setting-section-header ui-field-stack"><h3><%= dgettext("ui", "Content Categories") %></h3><p class="setting-section-description"><%= dgettext("ui", "Category defaults, rendering flags, and template wiring") %></p></div>
<div class="setting-section-content"> <div class="setting-section-content">
<table class="categories-table"> <table class="categories-table">
<thead> <thead>
@@ -161,12 +161,12 @@
<tr> <tr>
<td><%= category.name %></td> <td><%= category.name %></td>
<td> <td>
<input type="text" name="category_settings[title]" value={category.title} form={"category-form-#{category.name}"} /> <input class="ui-input" type="text" name="category_settings[title]" value={category.title} form={"category-form-#{category.name}"} />
</td> </td>
<td><input type="checkbox" name="category_settings[render_in_lists]" checked={category.render_in_lists} form={"category-form-#{category.name}"} /></td> <td><input type="checkbox" name="category_settings[render_in_lists]" checked={category.render_in_lists} form={"category-form-#{category.name}"} /></td>
<td><input type="checkbox" name="category_settings[show_title]" checked={category.show_title} form={"category-form-#{category.name}"} /></td> <td><input type="checkbox" name="category_settings[show_title]" checked={category.show_title} form={"category-form-#{category.name}"} /></td>
<td> <td>
<select name="category_settings[post_template_slug]" form={"category-form-#{category.name}"}> <select class="ui-input" name="category_settings[post_template_slug]" form={"category-form-#{category.name}"}>
<option value=""><%= dgettext("ui", "Default") %></option> <option value=""><%= dgettext("ui", "Default") %></option>
<%= for template <- @settings_editor.template_options.post do %> <%= for template <- @settings_editor.template_options.post do %>
<option value={template.slug} selected={template.slug == category.post_template_slug}><%= template.title %></option> <option value={template.slug} selected={template.slug == category.post_template_slug}><%= template.title %></option>
@@ -174,7 +174,7 @@
</select> </select>
</td> </td>
<td> <td>
<select name="category_settings[list_template_slug]" form={"category-form-#{category.name}"}> <select class="ui-input" name="category_settings[list_template_slug]" form={"category-form-#{category.name}"}>
<option value=""><%= dgettext("ui", "Default") %></option> <option value=""><%= dgettext("ui", "Default") %></option>
<%= for template <- @settings_editor.template_options.list do %> <%= for template <- @settings_editor.template_options.list do %>
<option value={template.slug} selected={template.slug == category.list_template_slug}><%= template.title %></option> <option value={template.slug} selected={template.slug == category.list_template_slug}><%= template.title %></option>
@@ -186,8 +186,8 @@
<form id={"category-form-#{category.name}"} phx-submit="save_settings_category" phx-target={@myself}> <form id={"category-form-#{category.name}"} phx-submit="save_settings_category" phx-target={@myself}>
<input type="hidden" name="category_settings[category]" value={category.name} /> <input type="hidden" name="category_settings[category]" value={category.name} />
</form> </form>
<button class="secondary" type="submit" form={"category-form-#{category.name}"}><%= dgettext("ui", "Save") %></button> <button class="secondary ui-button ui-button-secondary" type="submit" form={"category-form-#{category.name}"}><%= dgettext("ui", "Save") %></button>
<button class="secondary" type="button" phx-click="remove_settings_category" phx-target={@myself} phx-value-category={category.name} disabled={category.protected?}><%= dgettext("ui", "Remove") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="remove_settings_category" phx-target={@myself} phx-value-category={category.name} disabled={category.protected?}><%= dgettext("ui", "Remove") %></button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -198,36 +198,36 @@
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Add Category") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Add Category") %></label></div>
<div class="setting-control"> <div class="setting-control">
<div class="setting-input-group"> <div class="setting-input-group">
<input type="text" value={@settings_editor.new_category} phx-change="change_settings_new_category" phx-target={@myself} name="name" /> <input class="ui-input" type="text" value={@settings_editor.new_category} phx-change="change_settings_new_category" phx-target={@myself} name="name" />
<button class="primary" type="button" phx-click="add_settings_category" phx-target={@myself}><%= dgettext("ui", "Add") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="add_settings_category" phx-target={@myself}><%= dgettext("ui", "Add") %></button>
</div> </div>
</div> </div>
</div> </div>
<div class="setting-actions"><button class="secondary" type="button" phx-click="reset_settings_categories" phx-target={@myself}><%= dgettext("ui", "Reset to Defaults") %></button></div> <div class="setting-actions"><button class="secondary ui-button ui-button-secondary" type="button" phx-click="reset_settings_categories" phx-target={@myself}><%= dgettext("ui", "Reset to Defaults") %></button></div>
</div> </div>
</div> </div>
<% end %> <% end %>
<%= if @settings_editor.ai_visible? do %> <%= if @settings_editor.ai_visible? do %>
<div class="setting-section" id="settings-section-ai"> <div class="setting-section ui-section-card" id="settings-section-ai">
<div class="setting-section-header"><h3><%= dgettext("ui", "AI") %></h3><p class="setting-section-description"><%= dgettext("ui", "OpenAI-compatible endpoints, model routing, airplane mode, and system prompt") %></p></div> <div class="setting-section-header ui-field-stack"><h3><%= dgettext("ui", "AI") %></h3><p class="setting-section-description"><%= dgettext("ui", "OpenAI-compatible endpoints, model routing, airplane mode, and system prompt") %></p></div>
<form class="setting-section-content" phx-change="change_settings_ai" phx-target={@myself}> <form class="setting-section-content" phx-change="change_settings_ai" phx-target={@myself}>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Endpoint URL") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Endpoint URL") %></label></div>
<div class="setting-control"> <div class="setting-control">
<div class="setting-input-group"> <div class="setting-input-group">
<input type="url" name="settings_ai[online_url]" value={@settings_editor.ai["online_url"]} /> <input class="ui-input" type="url" name="settings_ai[online_url]" value={@settings_editor.ai["online_url"]} />
<button class="secondary" type="button" phx-click="refresh_settings_ai_models" phx-target={@myself} phx-value-endpoint="online"><%= dgettext("ui", "Refresh Online Models") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="refresh_settings_ai_models" phx-target={@myself} phx-value-endpoint="online"><%= dgettext("ui", "Refresh Online Models") %></button>
</div> </div>
</div> </div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online API Key") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online API Key") %></label></div>
<div class="setting-control"><input type="password" name="settings_ai[online_api_key]" value={@settings_editor.ai["online_api_key"]} /></div> <div class="setting-control"><input class="ui-input" type="password" name="settings_ai[online_api_key]" value={@settings_editor.ai["online_api_key"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Chat Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Chat Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_chat_model]" value={@settings_editor.ai["online_chat_model"]} /></div> <div class="setting-control"><input class="ui-input" type="text" list="settings-ai-online-models" name="settings_ai[online_chat_model]" value={@settings_editor.ai["online_chat_model"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Chat Tools") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Chat Tools") %></label></div>
@@ -239,11 +239,11 @@
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Title Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Title Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_title_model]" value={@settings_editor.ai["online_title_model"]} /></div> <div class="setting-control"><input class="ui-input" type="text" list="settings-ai-online-models" name="settings_ai[online_title_model]" value={@settings_editor.ai["online_title_model"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Image Analysis Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Image Analysis Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_image_analysis_model]" value={@settings_editor.ai["online_image_analysis_model"]} /></div> <div class="setting-control"><input class="ui-input" type="text" list="settings-ai-online-models" name="settings_ai[online_image_analysis_model]" value={@settings_editor.ai["online_image_analysis_model"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Image Support") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Image Support") %></label></div>
@@ -253,14 +253,14 @@
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Endpoint URL") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Endpoint URL") %></label></div>
<div class="setting-control"> <div class="setting-control">
<div class="setting-input-group"> <div class="setting-input-group">
<input type="url" name="settings_ai[offline_url]" value={@settings_editor.ai["offline_url"]} /> <input class="ui-input" type="url" name="settings_ai[offline_url]" value={@settings_editor.ai["offline_url"]} />
<button class="secondary" type="button" phx-click="refresh_settings_ai_models" phx-target={@myself} phx-value-endpoint="airplane"><%= dgettext("ui", "Refresh Offline Models") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="refresh_settings_ai_models" phx-target={@myself} phx-value-endpoint="airplane"><%= dgettext("ui", "Refresh Offline Models") %></button>
</div> </div>
</div> </div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline API Key") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline API Key") %></label></div>
<div class="setting-control"><input type="password" name="settings_ai[offline_api_key]" value={@settings_editor.ai["offline_api_key"]} /></div> <div class="setting-control"><input class="ui-input" type="password" name="settings_ai[offline_api_key]" value={@settings_editor.ai["offline_api_key"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Airplane Mode") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Airplane Mode") %></label></div>
@@ -268,7 +268,7 @@
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Chat Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Chat Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_chat_model]" value={@settings_editor.ai["offline_chat_model"]} /></div> <div class="setting-control"><input class="ui-input" type="text" list="settings-ai-offline-models" name="settings_ai[offline_chat_model]" value={@settings_editor.ai["offline_chat_model"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Chat Tools") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Chat Tools") %></label></div>
@@ -280,11 +280,11 @@
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Title Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Title Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_title_model]" value={@settings_editor.ai["offline_title_model"]} /></div> <div class="setting-control"><input class="ui-input" type="text" list="settings-ai-offline-models" name="settings_ai[offline_title_model]" value={@settings_editor.ai["offline_title_model"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Image Analysis Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Image Analysis Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_image_analysis_model]" value={@settings_editor.ai["offline_image_analysis_model"]} /></div> <div class="setting-control"><input class="ui-input" type="text" list="settings-ai-offline-models" name="settings_ai[offline_image_analysis_model]" value={@settings_editor.ai["offline_image_analysis_model"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Image Support") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Image Support") %></label></div>
@@ -292,7 +292,7 @@
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "System Prompt") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "System Prompt") %></label></div>
<div class="setting-control"><textarea name="settings_ai[system_prompt]" rows="12"><%= @settings_editor.ai["system_prompt"] %></textarea></div> <div class="setting-control"><textarea class="ui-textarea" name="settings_ai[system_prompt]" rows="12"><%= @settings_editor.ai["system_prompt"] %></textarea></div>
</div> </div>
<datalist id="settings-ai-online-models"> <datalist id="settings-ai-online-models">
<%= for model <- @settings_editor.online_endpoint_models do %> <%= for model <- @settings_editor.online_endpoint_models do %>
@@ -305,13 +305,13 @@
<% end %> <% end %>
</datalist> </datalist>
</form> </form>
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_ai" phx-target={@myself}><%= dgettext("ui", "Save") %></button><button class="secondary" type="button" phx-click="reset_settings_ai_prompt" phx-target={@myself}><%= dgettext("ui", "Reset to Default") %></button></div> <div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_ai" phx-target={@myself}><%= dgettext("ui", "Save") %></button><button class="secondary ui-button ui-button-secondary" type="button" phx-click="reset_settings_ai_prompt" phx-target={@myself}><%= dgettext("ui", "Reset to Default") %></button></div>
</div> </div>
<% end %> <% end %>
<%= if @settings_editor.technology_visible? do %> <%= if @settings_editor.technology_visible? do %>
<div class="setting-section" id="settings-section-technology"> <div class="setting-section ui-section-card" id="settings-section-technology">
<div class="setting-section-header"><h3><%= dgettext("ui", "Technology") %></h3><p class="setting-section-description"><%= dgettext("ui", "Application-level runtime behavior and semantic indexing") %></p></div> <div class="setting-section-header ui-field-stack"><h3><%= dgettext("ui", "Technology") %></h3><p class="setting-section-description"><%= dgettext("ui", "Application-level runtime behavior and semantic indexing") %></p></div>
<form class="setting-section-content" phx-change="change_settings_project" phx-target={@myself}> <form class="setting-section-content" phx-change="change_settings_project" phx-target={@myself}>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Semantic Similarity") %></label></div> <div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Semantic Similarity") %></label></div>
@@ -322,26 +322,26 @@
<div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.") %></p></div> <div class="setting-control"><p class="setting-description"><%= dgettext("ui", "Scripting capabilities are configured at the application layer in the rewrite and do not expose runtime switching here.") %></p></div>
</div> </div>
</form> </form>
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div> <div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_project" phx-target={@myself}><%= dgettext("ui", "Save") %></button></div>
</div> </div>
<% end %> <% end %>
<%= if @settings_editor.publishing_visible? do %> <%= if @settings_editor.publishing_visible? do %>
<div class="setting-section" id="settings-section-publishing"> <div class="setting-section ui-section-card" id="settings-section-publishing">
<div class="setting-section-header"><h3><%= dgettext("ui", "Publishing") %></h3><p class="setting-section-description"><%= dgettext("ui", "Deployment credentials for upload tasks") %></p></div> <div class="setting-section-header ui-field-stack"><h3><%= dgettext("ui", "Publishing") %></h3><p class="setting-section-description"><%= dgettext("ui", "Deployment credentials for upload tasks") %></p></div>
<form class="setting-section-content" phx-change="change_settings_publishing" phx-target={@myself}> <form class="setting-section-content" phx-change="change_settings_publishing" phx-target={@myself}>
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "SSH Mode") %></label></div><div class="setting-control"><select name="settings_publishing[ssh_mode]"><option value="scp" selected={@settings_editor.publishing["ssh_mode"] == "scp"}>scp</option><option value="rsync" selected={@settings_editor.publishing["ssh_mode"] == "rsync"}>rsync</option></select></div></div> <div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "SSH Mode") %></label></div><div class="setting-control"><select class="ui-input" name="settings_publishing[ssh_mode]"><option value="scp" selected={@settings_editor.publishing["ssh_mode"] == "scp"}>scp</option><option value="rsync" selected={@settings_editor.publishing["ssh_mode"] == "rsync"}>rsync</option></select></div></div>
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Host") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_host]" value={@settings_editor.publishing["ssh_host"]} /></div></div> <div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Host") %></label></div><div class="setting-control"><input class="ui-input" type="text" name="settings_publishing[ssh_host]" value={@settings_editor.publishing["ssh_host"]} /></div></div>
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Username") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_user]" value={@settings_editor.publishing["ssh_user"]} /></div></div> <div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Username") %></label></div><div class="setting-control"><input class="ui-input" type="text" name="settings_publishing[ssh_user]" value={@settings_editor.publishing["ssh_user"]} /></div></div>
<div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Remote Path") %></label></div><div class="setting-control"><input type="text" name="settings_publishing[ssh_remote_path]" value={@settings_editor.publishing["ssh_remote_path"]} /></div></div> <div class="setting-row"><div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Remote Path") %></label></div><div class="setting-control"><input class="ui-input" type="text" name="settings_publishing[ssh_remote_path]" value={@settings_editor.publishing["ssh_remote_path"]} /></div></div>
</form> </form>
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_publishing" phx-target={@myself}><%= dgettext("ui", "Save") %></button><button class="secondary" type="button" phx-click="clear_settings_publishing" phx-target={@myself}><%= dgettext("ui", "Clear") %></button></div> <div class="setting-actions"><button class="primary ui-button ui-button-primary" type="button" phx-click="save_settings_publishing" phx-target={@myself}><%= dgettext("ui", "Save") %></button><button class="secondary ui-button ui-button-secondary" type="button" phx-click="clear_settings_publishing" phx-target={@myself}><%= dgettext("ui", "Clear") %></button></div>
</div> </div>
<% end %> <% end %>
<%= if @settings_editor.mcp_visible? do %> <%= if @settings_editor.mcp_visible? do %>
<div class="setting-section" id="settings-section-mcp"> <div class="setting-section ui-section-card" id="settings-section-mcp">
<div class="setting-section-header"><h3><%= dgettext("ui", "MCP") %></h3><p class="setting-section-description"><%= dgettext("ui", "Agent configuration files for the built-in bDS MCP server") %></p></div> <div class="setting-section-header ui-field-stack"><h3><%= dgettext("ui", "MCP") %></h3><p class="setting-section-description"><%= dgettext("ui", "Agent configuration files for the built-in bDS MCP server") %></p></div>
<div class="setting-section-content"> <div class="setting-section-content">
<%= for agent <- @settings_editor.mcp do %> <%= for agent <- @settings_editor.mcp do %>
<div class="setting-row"> <div class="setting-row">
@@ -350,7 +350,7 @@
<p class="setting-description"><%= agent.config_path || dgettext("ui", "Not supported in the rewrite yet") %></p> <p class="setting-description"><%= agent.config_path || dgettext("ui", "Not supported in the rewrite yet") %></p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<button class="secondary" type="button" phx-click="toggle_settings_mcp_agent" phx-target={@myself} phx-value-agent={agent.id} disabled={not agent.supported?}> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="toggle_settings_mcp_agent" phx-target={@myself} phx-value-agent={agent.id} disabled={not agent.supported?}>
<%= if agent.configured?, do: dgettext("ui", "Remove"), else: dgettext("ui", "Add") %> <%= if agent.configured?, do: dgettext("ui", "Remove"), else: dgettext("ui", "Add") %>
</button> </button>
</div> </div>
@@ -361,17 +361,17 @@
<% end %> <% end %>
<%= if @settings_editor.data_visible? do %> <%= if @settings_editor.data_visible? do %>
<div class="setting-section" id="settings-section-data"> <div class="setting-section ui-section-card" id="settings-section-data">
<div class="setting-section-header"><h3><%= dgettext("ui", "Data Maintenance") %></h3><p class="setting-section-description"><%= dgettext("ui", "Rebuild filesystem-backed records and thumbnails") %></p></div> <div class="setting-section-header ui-field-stack"><h3><%= dgettext("ui", "Data Maintenance") %></h3><p class="setting-section-description"><%= dgettext("ui", "Rebuild filesystem-backed records and thumbnails") %></p></div>
<div class="setting-actions"> <div class="setting-actions">
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_posts_from_files"><%= dgettext("ui", "Rebuild Posts From Files") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_posts_from_files"><%= dgettext("ui", "Rebuild Posts From Files") %></button>
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_from_files"><%= dgettext("ui", "Rebuild Media From Files") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_media_from_files"><%= dgettext("ui", "Rebuild Media From Files") %></button>
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_scripts_from_files"><%= dgettext("ui", "Rebuild Scripts From Files") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_scripts_from_files"><%= dgettext("ui", "Rebuild Scripts From Files") %></button>
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_templates_from_files"><%= dgettext("ui", "Rebuild Templates From Files") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_templates_from_files"><%= dgettext("ui", "Rebuild Templates From Files") %></button>
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_post_links"><%= dgettext("ui", "Rebuild Links") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_post_links"><%= dgettext("ui", "Rebuild Links") %></button>
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="regenerate_missing_thumbnails"><%= dgettext("ui", "Regenerate Missing Thumbnails") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="regenerate_missing_thumbnails"><%= dgettext("ui", "Regenerate Missing Thumbnails") %></button>
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="rebuild_embedding_index"><%= dgettext("ui", "Rebuild Embedding Index") %></button>
<button class="secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="settings_shell_command" phx-value-action="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -22,13 +22,13 @@
<div class="style-apply-row"> <div class="style-apply-row">
<label class="style-preview-mode-control"> <label class="style-preview-mode-control">
<span><%= dgettext("ui", "Preview Mode") %></span> <span><%= dgettext("ui", "Preview Mode") %></span>
<select phx-change="change_style_preview_mode" phx-target={@myself} name="mode"> <select class="ui-input" phx-change="change_style_preview_mode" phx-target={@myself} name="mode">
<option value="auto" selected={@style_editor.preview_mode == "auto"}><%= dgettext("ui", "Auto") %></option> <option value="auto" selected={@style_editor.preview_mode == "auto"}><%= dgettext("ui", "Auto") %></option>
<option value="light" selected={@style_editor.preview_mode == "light"}><%= dgettext("ui", "Light") %></option> <option value="light" selected={@style_editor.preview_mode == "light"}><%= dgettext("ui", "Light") %></option>
<option value="dark" selected={@style_editor.preview_mode == "dark"}><%= dgettext("ui", "Dark") %></option> <option value="dark" selected={@style_editor.preview_mode == "dark"}><%= dgettext("ui", "Dark") %></option>
</select> </select>
</label> </label>
<button class="primary" type="button" phx-click="apply_style_theme" phx-target={@myself} disabled={@style_editor.selected_theme == @style_editor.applied_theme}><%= dgettext("ui", "Apply Theme") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="apply_style_theme" phx-target={@myself} disabled={@style_editor.selected_theme == @style_editor.applied_theme}><%= dgettext("ui", "Apply Theme") %></button>
</div> </div>
<div class="style-preview-container"> <div class="style-preview-container">

View File

@@ -56,25 +56,25 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
if is_map(filters) and Map.get(filters, :enabled) do if is_map(filters) and Map.get(filters, :enabled) do
~H""" ~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 <input
type="text" type="text"
name="sidebar_filters[search]" name="sidebar_filters[search]"
value={Map.get(@selected_filters, :search) || ""} value={Map.get(@selected_filters, :search) || ""}
placeholder={@sidebar_filters_config.search_placeholder} 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"> <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"/> <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> </svg>
</button> </button>
<%= if Map.get(@selected_filters, :search) do %> <%= 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 %> <% end %>
</form> </form>
<%= if Map.get(@sidebar_filters_config, :has_active_filters) do %> <%= 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> <span>
<%= @sidebar_filters_config.results_label %>: <%= @sidebar_filters_config.loaded_count %>/<%= @sidebar_filters_config.total_count %> <%= @sidebar_filters_config.results_label %>: <%= @sidebar_filters_config.loaded_count %>/<%= @sidebar_filters_config.total_count %>
</span> </span>
@@ -239,7 +239,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
if Map.get(filters, :has_more) do if Map.get(filters, :has_more) do
~H""" ~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"> <button class="load-more-button" data-testid="sidebar-load-more" type="button" phx-click="load_more_sidebar">
<%= dgettext("ui", "Load more") %> <%= dgettext("ui", "Load more") %>
</button> </button>
@@ -270,9 +270,9 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
<span data-testid="sidebar-section-title"><%= section.title %></span> <span data-testid="sidebar-section-title"><%= section.title %></span>
<span class="sidebar-section-count"><%= Map.get(section, :count, length(Map.get(section, :items, []))) %></span> <span class="sidebar-section-count"><%= Map.get(section, :count, length(Map.get(section, :items, []))) %></span>
</div> </div>
<div class="sidebar-list"> <div class="sidebar-list flex flex-col">
<%= for item <- Map.get(section, :items, []) do %> <%= 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 <button
class={["sidebar-item", "sidebar-post-item", "post-type-post", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "selected")]} 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" data-testid="sidebar-open-item"

View File

@@ -1,27 +1,27 @@
<div <div
id="tags-editor-shell" 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" data-testid="tags-editor"
phx-hook="TagsSectionScroll" phx-hook="TagsSectionScroll"
data-selected-tags-section={@tags_editor.selected_section} data-selected-tags-section={@tags_editor.selected_section}
data-tags-scroll-target={"tags-section-#{@tags_editor.selected_section}"} data-tags-scroll-target={"tags-section-#{@tags_editor.selected_section}"}
> >
<div class="tags-view"> <div class="tags-view flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="tags-view-header"> <div class="tags-view-header flex shrink-0 items-center justify-between gap-3">
<h2><%= dgettext("ui", "Tags") %></h2> <h2><%= dgettext("ui", "Tags") %></h2>
</div> </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" id="tags-section-cloud">
<div class="tags-section-header"><h3><%= dgettext("ui", "Tag Cloud") %></h3></div> <div class="tags-section-header"><h3><%= dgettext("ui", "Tag Cloud") %></h3></div>
<div class="tags-section-content"> <div class="tags-section-content">
<%= if Enum.empty?(@tags_editor.tags) do %> <%= if Enum.empty?(@tags_editor.tags) do %>
<div class="tags-empty-state"> <div class="tags-empty-state ui-empty-state flex flex-col gap-3">
<p><%= dgettext("ui", "No tags found") %></p> <p><%= dgettext("ui", "No tags found") %></p>
<button class="secondary" type="button" phx-click="sync_tags_editor" phx-target={@myself}><%= dgettext("ui", "Discover") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="sync_tags_editor" phx-target={@myself}><%= dgettext("ui", "Discover") %></button>
</div> </div>
<% else %> <% else %>
<div class="tag-cloud"> <div class="tag-cloud flex flex-wrap gap-2">
<%= for tag <- @tags_editor.tags do %> <%= 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}> <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> <%= tag.name %><span class="tag-count"><%= tag.count %></span>
@@ -36,26 +36,26 @@
<div class="tags-section-header"><h3><%= dgettext("ui", "Create / Edit") %></h3></div> <div class="tags-section-header"><h3><%= dgettext("ui", "Create / Edit") %></h3></div>
<div class="tags-section-content"> <div class="tags-section-content">
<form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}> <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 class="ui-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"])} /> <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> <button class="primary ui-button ui-button-primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button>
</div> </div>
</form> </form>
<%= if @tags_editor.edit_draft != %{} do %> <%= if @tags_editor.edit_draft != %{} do %>
<form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}> <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 class="ui-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"])} /> <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]"> <select class="ui-input" name="edit_tag[post_template_slug]">
<option value=""><%= dgettext("ui", "No Template") %></option> <option value=""><%= dgettext("ui", "No Template") %></option>
<%= for template <- @tags_editor.templates do %> <%= for template <- @tags_editor.templates do %>
<option value={template.slug} selected={template.slug == @tags_editor.edit_draft["post_template_slug"]}><%= template.title %></option> <option value={template.slug} selected={template.slug == @tags_editor.edit_draft["post_template_slug"]}><%= template.title %></option>
<% end %> <% end %>
</select> </select>
<button class="primary" type="button" phx-click="save_tag_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="save_tag_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
<button class="danger" type="button" phx-click="delete_tag_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button> <button class="danger ui-button ui-button-danger" type="button" phx-click="delete_tag_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button>
</div> </div>
</form> </form>
<% end %> <% end %>
@@ -65,14 +65,14 @@
<div class="tags-section" id="tags-section-merge"> <div class="tags-section" id="tags-section-merge">
<div class="tags-section-header"><h3><%= dgettext("ui", "Merge Tags") %></h3></div> <div class="tags-section-header"><h3><%= dgettext("ui", "Merge Tags") %></h3></div>
<div class="tags-section-content"> <div class="tags-section-content">
<div class="merge-form"> <div class="merge-form flex flex-col gap-3">
<div class="tag-form-row"> <div class="tag-form-row flex flex-wrap items-center gap-3">
<select phx-change="change_merge_target" name="target" phx-target={@myself}> <select class="ui-input" phx-change="change_merge_target" name="target" phx-target={@myself}>
<%= for tag_name <- @tags_editor.selected do %> <%= for tag_name <- @tags_editor.selected do %>
<option value={tag_name} selected={tag_name == @tags_editor.merge_target}><%= tag_name %></option> <option value={tag_name} selected={tag_name == @tags_editor.merge_target}><%= tag_name %></option>
<% end %> <% end %>
</select> </select>
<button class="primary" type="button" phx-click="merge_tags_editor" disabled={length(@tags_editor.selected) < 2} phx-target={@myself}><%= dgettext("ui", "Merge") %></button> <button class="primary ui-button ui-button-primary" type="button" phx-click="merge_tags_editor" disabled={length(@tags_editor.selected) < 2} phx-target={@myself}><%= dgettext("ui", "Merge") %></button>
</div> </div>
</div> </div>
</div> </div>
@@ -81,7 +81,7 @@
<div class="tags-section" id="tags-section-sync"> <div class="tags-section" id="tags-section-sync">
<div class="tags-section-header"><h3><%= dgettext("ui", "Sync") %></h3></div> <div class="tags-section-header"><h3><%= dgettext("ui", "Sync") %></h3></div>
<div class="tags-section-content"> <div class="tags-section-content">
<button class="secondary" type="button" phx-click="sync_tags_editor" phx-target={@myself}><%= dgettext("ui", "Discover") %></button> <button class="secondary ui-button ui-button-secondary" type="button" phx-click="sync_tags_editor" phx-target={@myself}><%= dgettext("ui", "Discover") %></button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,47 +1,48 @@
<div class="templates-view-shell editor" data-testid="template-editor"> <div class="templates-view-shell ui-editor-shell flex h-full min-h-0 flex-col" data-testid="template-editor">
<div class="editor-header templates-header"> <div class="ui-editor-header flex shrink-0 items-start justify-between gap-3">
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @template_editor.title %></span></div></div> <div class="flex min-w-0 flex-1 overflow-hidden"><div class="ui-tab ui-tab-active ui-editor-tab-current inline-flex max-w-full items-center overflow-hidden px-3 py-2"><span class="truncate"><%= @template_editor.title %></span></div></div>
<div class="editor-actions"> <div class="ui-editor-actions flex flex-wrap items-center justify-end gap-2">
<span class={[ <span class={[
"status-badge", "status-badge",
"ui-badge",
"status-#{@template_editor.status}" "status-#{@template_editor.status}"
]} data-testid="template-status-badge"><%= BDS.Desktop.ShellData.dashboard_status_label(@template_editor.status) %></span> ]} data-testid="template-status-badge"><%= BDS.Desktop.ShellData.dashboard_status_label(@template_editor.status) %></span>
<%= if @template_editor.can_publish? do %> <%= if @template_editor.can_publish? do %>
<button class="success" data-testid="template-publish-button" type="button" phx-click="publish_template_editor" phx-target={@myself}><%= dgettext("ui", "Publish") %></button> <button class="success ui-button ui-button-primary" data-testid="template-publish-button" type="button" phx-click="publish_template_editor" phx-target={@myself}><%= dgettext("ui", "Publish") %></button>
<% end %> <% end %>
<button class="secondary templates-save-button" type="button" phx-click="save_template_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button> <button class="secondary templates-save-button ui-button ui-button-secondary" type="button" phx-click="save_template_editor" phx-target={@myself}><%= dgettext("ui", "Save") %></button>
<button class="secondary templates-validate-button" type="button" phx-click="validate_template_editor" phx-target={@myself}><%= dgettext("ui", "Validate") %></button> <button class="secondary templates-validate-button ui-button ui-button-secondary" type="button" phx-click="validate_template_editor" phx-target={@myself}><%= dgettext("ui", "Validate") %></button>
<button class="secondary danger" type="button" phx-click="delete_template_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button> <button class="secondary danger ui-button ui-button-secondary ui-button-danger" type="button" phx-click="delete_template_editor" phx-target={@myself}><%= dgettext("ui", "Delete") %></button>
</div> </div>
</div> </div>
<form class="editor-content templates-view" phx-change="change_template_editor" phx-target={@myself}> <form class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4" phx-change="change_template_editor" phx-target={@myself}>
<div class="editor-header-row templates-meta-row"> <div class="grid gap-4">
<div class="editor-meta"> <div class="flex min-w-0 flex-col gap-4">
<div class="editor-field-row"> <div class="ui-field-grid-2 grid gap-4 md:grid-cols-2">
<div class="editor-field"><label><%= dgettext("ui", "Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div> <div class="ui-field-stack flex flex-col gap-1.5"><label><%= dgettext("ui", "Title") %></label><input class="ui-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> <div class="ui-field-stack flex flex-col gap-1.5"><label><%= dgettext("ui", "Slug") %></label><input class="ui-input" type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
</div> </div>
<div class="editor-field-row"> <div class="ui-field-grid-3 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<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="ui-field-stack flex flex-col gap-1.5"><label><%= dgettext("ui", "Kind") %></label><select class="ui-input" 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="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>
</div> </div>
<div class="editor-body templates-editor"> <div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="editor-toolbar templates-toolbar"><div class="editor-toolbar-left"><label><%= dgettext("ui", "Content") %></label></div></div> <div class="ui-toolbar flex items-center gap-3"><div class="ui-toolbar-group flex items-center gap-2"><label><%= dgettext("ui", "Content") %></label></div></div>
<div <div
id={"template-editor-monaco-shell-#{@template_editor.id}"} 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" phx-hook="MonacoEditor"
data-monaco-editor-id={@template_editor.id} data-monaco-editor-id={@template_editor.id}
data-monaco-input-id={"template-editor-content-#{@template_editor.id}"} data-monaco-input-id={"template-editor-content-#{@template_editor.id}"}
data-monaco-language="liquid" data-monaco-language="liquid"
data-monaco-word-wrap="on" 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> <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> </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="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> </form>
</div> </div>

View File

@@ -1236,21 +1236,27 @@ defmodule BDS.Scripting.ApiDocs do
end end
defp table_of_contents do defp table_of_contents do
@methods module_names()
|> Enum.map(& &1.module)
|> Enum.uniq()
|> Enum.map(fn module_name -> "- [#{module_name}](##{module_name})" end) |> Enum.map(fn module_name -> "- [#{module_name}](##{module_name})" end)
|> Kernel.++(["- [Data Structures](#data-structures)"]) |> Kernel.++(["- [Data Structures](#data-structures)"])
end end
defp render_modules do defp render_modules do
@methods module_names()
|> Enum.group_by(& &1.module) |> Enum.flat_map(fn module_name ->
|> Enum.flat_map(fn {module_name, methods} -> methods = module_methods(module_name)
[ [
"## #{module_name}", "## #{module_name}",
"", "",
"**Module APIs**",
"",
Enum.map(methods, fn method ->
"- [#{method.module}.#{method.name}](##{method.module}#{method.name})"
end),
"",
Enum.map(methods, &render_method/1), Enum.map(methods, &render_method/1),
"[↑ Back to Table of contents](#table-of-contents)",
"" ""
] ]
end) end)
@@ -1269,6 +1275,14 @@ defmodule BDS.Scripting.ApiDocs do
"**Response specification**", "**Response specification**",
"", "",
"- Return type: `#{method.returns}`", "- Return type: `#{method.returns}`",
render_nullability(method.returns),
render_data_structure_references(method.returns),
"",
"**Example response**",
"",
"```lua",
render_example_response(method.returns),
"```",
"", "",
"**Example call**", "**Example call**",
"", "",
@@ -1277,6 +1291,7 @@ defmodule BDS.Scripting.ApiDocs do
"```", "```",
"" ""
] ]
|> Enum.reject(&is_nil/1)
end end
defp render_params([]), do: ["- None"] defp render_params([]), do: ["- None"]
@@ -1289,18 +1304,237 @@ defmodule BDS.Scripting.ApiDocs do
end end
defp example_call(method) do defp example_call(method) do
args = args = Enum.map_join(method.params, ", ", &example_argument/1)
method.params
|> Enum.map(fn param -> example_value(param.type) end)
|> Enum.join(", ")
"local result = bds.#{method.module}.#{method.name}(#{args})" "local result = bds.#{method.module}.#{method.name}(#{args})"
end end
defp example_value("string"), do: "\"value\"" defp module_names do
defp example_value("table"), do: "{}" @methods
defp example_value("integer"), do: "1" |> Enum.map(& &1.module)
defp example_value(_type), do: "nil" |> Enum.uniq()
end
defp module_methods(module_name) do
Enum.filter(@methods, &(&1.module == module_name))
end
defp render_nullability(returns) do
if nullable_return?(returns) do
"- Nullability: Returns `nil` when no matching value exists or the operation cannot produce a value."
end
end
defp render_data_structure_references(returns) do
case response_structure_names(returns) do
[] -> nil
names -> "- Data structures: `#{Enum.join(names, "`, `")}`"
end
end
defp render_example_response(returns) do
returns
|> example_response_value()
|> render_lua_value(0)
end
defp example_argument(%{name: name, type: type}) do
example_argument_value(name, type)
end
defp example_argument_value(name, "string") do
case name do
"id" ->
"\"id-1\""
suffix when suffix in ["post_id", "media_id", "project_id", "tag_id", "target_tag_id"] ->
"\"id-1\""
"source_tag_ids" ->
"{\"id-1\", \"id-2\"}"
"language" ->
"\"en\""
"status" ->
"\"draft\""
"kind" ->
"\"post\""
"slug" ->
"\"example-slug\""
"title" ->
"\"Example Title\""
"name" ->
"\"Example Name\""
"query" ->
"\"example query\""
"content" ->
"\"Example content\""
"message" ->
"\"Update content\""
"folder_path" ->
"\"/Users/me/Sites/example\""
"source_path" ->
"\"/Users/me/Pictures/example.jpg\""
"item_path" ->
"\"/Users/me/Sites/example/output/index.html\""
"action" ->
"\"save\""
_ ->
"\"value\""
end
end
defp example_argument_value("limit", "integer"), do: "10"
defp example_argument_value(_name, "integer"), do: "1"
defp example_argument_value(_name, "number"), do: "1.0"
defp example_argument_value(name, "table") do
case name do
"data" -> "{title = \"Example Title\"}"
"filters" -> "{status = \"draft\"}"
"options" -> "{}"
"updates" -> "{name = \"Updated Blog\"}"
"prefs" -> "{provider = \"filesystem\"}"
"credentials" -> "{provider = \"sftp\"}"
"target_ids" -> "{\"id-2\", \"id-3\"}"
"exclude_tags" -> "{\"draft\"}"
_ -> "{}"
end
end
defp example_argument_value(_name, _type), do: "nil"
defp nullable_return?(returns), do: String.contains?(returns, "nil")
defp response_structure_names(returns) do
structure_names = MapSet.new(Enum.map(@data_structures, & &1.name))
returns
|> String.split(~r/\s*\|\s*/)
|> Enum.map(&String.replace(&1, "[]", ""))
|> Enum.reject(&(&1 in ["nil", "boolean", "string", "integer", "number", "table"]))
|> Enum.filter(&MapSet.member?(structure_names, &1))
|> Enum.uniq()
end
defp example_response_value(returns) do
cond do
returns == "nil" ->
nil
nullable_return?(returns) ->
{:nullable, example_response_value(non_nil_return(returns))}
String.ends_with?(returns, "[]") ->
[example_value_for_type(String.trim_trailing(returns, "[]"))]
true ->
example_value_for_type(returns)
end
end
defp non_nil_return(returns) do
returns
|> String.split(~r/\s*\|\s*/)
|> Enum.reject(&(&1 == "nil"))
|> List.first()
end
defp example_value_for_type("boolean"), do: true
defp example_value_for_type("string"), do: "value"
defp example_value_for_type("integer"), do: 1
defp example_value_for_type("number"), do: 1.0
defp example_value_for_type("nil"), do: nil
defp example_value_for_type("table"), do: [{"key", "value"}]
defp example_value_for_type(type) do
case Enum.find(@data_structures, &(&1.name == type)) do
nil ->
[{"key", "value"}]
structure ->
Enum.map(structure.fields, fn field -> {field.name, example_field_value(field.type)} end)
end
end
defp example_field_value(type) do
cond do
String.contains?(type, " | nil") -> nil
String.ends_with?(type, "[]") -> [example_value_for_type(String.trim_trailing(type, "[]"))]
true -> example_value_for_type(type)
end
end
defp render_lua_value({:nullable, value}, indent) do
["nil -- or", render_lua_value(value, indent)]
|> Enum.join("\n")
end
defp render_lua_value(true, _indent), do: "true"
defp render_lua_value(false, _indent), do: "false"
defp render_lua_value(nil, _indent), do: "nil"
defp render_lua_value(value, _indent) when is_integer(value), do: Integer.to_string(value)
defp render_lua_value(value, _indent) when is_float(value),
do: :erlang.float_to_binary(value, [:compact])
defp render_lua_value(value, _indent) when is_binary(value), do: inspect(value)
defp render_lua_value([], _indent), do: "{}"
defp render_lua_value(list, indent) when is_list(list) do
if keyword_like_list?(list) do
render_lua_table(list, indent)
else
render_lua_array(list, indent)
end
end
defp keyword_like_list?(list) do
Enum.all?(list, fn
{key, _value} when is_binary(key) -> true
_ -> false
end)
end
defp render_lua_table(entries, indent) do
outer_indent = indent_spaces(indent)
inner_indent = indent_spaces(indent + 2)
rendered_entries =
Enum.map_join(entries, ",\n", fn {key, value} ->
"#{inner_indent}#{key} = #{render_lua_value(value, indent + 2)}"
end)
"{\n#{rendered_entries}\n#{outer_indent}}"
end
defp render_lua_array(values, indent) do
outer_indent = indent_spaces(indent)
inner_indent = indent_spaces(indent + 2)
rendered_values =
Enum.map_join(values, ",\n", fn value ->
"#{inner_indent}#{render_lua_value(value, indent + 2)}"
end)
"{\n#{rendered_values}\n#{outer_indent}}"
end
defp indent_spaces(indent), do: String.duplicate(" ", indent)
defp render_data_structures do defp render_data_structures do
Enum.flat_map(@data_structures, fn structure -> Enum.flat_map(@data_structures, fn structure ->

View File

@@ -36,6 +36,8 @@ defmodule BDS.MixProject do
{:image, "~> 0.65"}, {:image, "~> 0.65"},
{:stemex, "~> 0.2.1"}, {:stemex, "~> 0.2.1"},
{:gettext, "~> 0.24"}, {:gettext, "~> 0.24"},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:lazy_html, ">= 0.1.0", only: :test}, {:lazy_html, ">= 0.1.0", only: :test},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
] ]
@@ -46,6 +48,9 @@ defmodule BDS.MixProject do
setup: ["deps.get", "ecto.setup"], setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate"], "ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"], "ecto.reset": ["ecto.drop", "ecto.setup"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["tailwind default", "esbuild default"],
"assets.deploy": ["tailwind default --minify", "esbuild default --minify"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
validate: ["test", "dialyzer"] validate: ["test", "dialyzer"]
] ]

View File

@@ -15,6 +15,7 @@
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"}, "ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"}, "ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"}, "ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
@@ -46,6 +47,7 @@
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"}, "saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"}, "stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},

File diff suppressed because it is too large Load Diff

9674
priv/static/assets/app.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,245 @@ defmodule BDS.Desktop.ShellLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
@shell_live_source_root Path.expand("../../../lib/bds/desktop/shell_live", __DIR__) @shell_live_source_root Path.expand("../../../lib/bds/desktop/shell_live", __DIR__)
@endpoint BDS.Desktop.Endpoint
@css_source_files [
"tokens.css",
"shell.css",
"sidebar.css",
"tabs.css",
"editor.css",
"forms.css",
"panel.css",
"assistant.css",
"overlays.css",
"menu_editor.css",
"media_editor.css",
"import_editor.css",
"utilities.css"
]
defp desktop_css_source do
@css_source_files
|> Enum.map(&File.read!(Path.expand("../../../assets/css/#{&1}", __DIR__)))
|> 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"],
project_visible?: true,
editor_visible?: false,
content_visible?: false,
ai_visible?: false,
publishing_visible?: false,
data_visible?: false,
technology_visible?: false,
mcp_visible?: false,
project: %{
"name" => "Shell Project",
"description" => "Project settings",
"public_url" => "https://example.test",
"main_language" => "en",
"blog_languages" => ["en", "fr"],
"default_author" => "Author",
"max_posts_per_page" => 10,
"blogmark_category" => "notes"
},
project_data_path: "/tmp/shell-project",
supported_languages: ["en", "fr"],
categories: [%{name: "notes"}, %{name: "posts"}]
}
}
end
defp phase3_tags_editor_assigns do
%{
myself: nil,
tags_editor: %{
selected_section: "cloud",
tags: [],
new_tag: %{"name" => "", "color" => "#3b82f6"},
edit_draft: %{"name" => "news", "color" => "#3b82f6", "post_template_slug" => ""},
selected: ["news", "updates"],
merge_target: "news",
templates: [%{slug: "post-template", title: "Post Template"}]
}
}
end
test "shell live modules use contexts instead of direct Repo.get calls" do test "shell live modules use contexts instead of direct Repo.get calls" do
source_files = source_files =
@@ -30,6 +269,142 @@ defmodule BDS.Desktop.ShellLiveTest do
assert offenders == [] assert offenders == []
end 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 ui-editor-shell flex h-full min-h-0 flex-col"
assert post_html =~ "editor-header ui-editor-header flex shrink-0 items-start justify-between gap-3"
assert post_html =~ "editor-field ui-field-stack flex flex-col gap-1.5"
assert post_html =~ "editor-toolbar ui-toolbar flex items-center gap-3"
assert media_html =~ "media-editor ui-editor-shell flex h-full min-h-0 flex-col"
assert media_html =~ "editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4"
assert script_html =~ "scripts-view-shell ui-editor-shell flex h-full min-h-0 flex-col"
assert script_html =~ "flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4"
assert template_html =~ "templates-view-shell ui-editor-shell flex h-full min-h-0 flex-col"
assert template_html =~ "flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4"
assert chat_html =~ "chat-panel ui-editor-shell 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 ui-editor-shell flex h-full min-h-0 flex-col p-4"
assert menu_html =~ "menu-editor-toolbar ui-toolbar flex flex-wrap items-center gap-2"
assert settings_html =~ "settings-view-shell ui-editor-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
@tag :phase4
test "phase 4 shared primitives render normalized classes" do
conn = Plug.Conn.put_private(build_conn(), :phoenix_endpoint, BDS.Desktop.Endpoint)
{:ok, view, _shell_html} = live_isolated(conn, BDS.Desktop.ShellLive)
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())
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())
panel_html =
render_component(&BDS.Desktop.ShellLive.PanelRenderer.render_panel_body/1, %{
current_tab: %{type: :dashboard, id: "dashboard"},
task_status: %{tasks: []},
output_entries: [],
workbench: %{panel: %{active_tab: :tasks}}
})
assert post_html =~ ~s(class="status-badge ui-badge)
assert post_html =~ ~s(class="success ui-button ui-button-primary)
assert post_html =~ ~s(class="secondary danger ui-button ui-button-secondary ui-button-danger)
assert post_html =~ ~s(class="post-editor-input ui-input)
assert post_html =~ ~s(class="post-editor-textarea post-editor-excerpt ui-textarea)
assert post_html =~ "ui-tab ui-tab-active ui-editor-tab-current"
assert media_html =~ ~s(class="secondary quick-actions-btn ui-button ui-button-secondary)
assert media_html =~ ~s(class="post-editor-input ui-input disabled ui-input-disabled)
assert media_html =~ ~s(class="post-editor-textarea ui-textarea)
assert script_html =~ ~s(class="secondary scripts-save-button ui-button ui-button-secondary)
assert script_html =~ ~s(class="status-badge ui-badge)
assert script_html =~ ~s(class="ui-input")
assert template_html =~ ~s(class="secondary templates-save-button ui-button ui-button-secondary)
assert template_html =~ ~s(class="status-badge ui-badge)
assert template_html =~ ~s(class="ui-input")
assert settings_html =~ ~s(class="ui-input")
assert settings_html =~ ~s(class="primary ui-button ui-button-primary")
assert settings_html =~ ~s(class="secondary ui-button ui-button-secondary")
assert tags_html =~ ~s(class="tags-empty-state ui-empty-state flex flex-col gap-3")
assert tags_html =~ ~s(class="secondary ui-button ui-button-secondary")
assert tags_html =~ ~s(class="primary ui-button ui-button-primary")
assert tags_html =~ ~s(class="danger ui-button ui-button-danger")
assert tags_html =~ ~s(class="ui-input")
shell_html =
view
|> element("[data-testid='toggle-panel']")
|> render_click()
assert shell_html =~ ~s(class="panel-tab ui-tab ui-tab-active)
assert panel_html =~ ~s(class="panel-entry ui-panel-entry panel-empty-state ui-empty-state)
end
@tag :phase5
test "phase 5 desktop-specific surfaces keep shell, media, menu, and chat contracts" do
conn = Plug.Conn.put_private(build_conn(), :phoenix_endpoint, BDS.Desktop.Endpoint)
{:ok, _view, shell_html} = live_isolated(conn, BDS.Desktop.ShellLive)
media_html = render_component(&BDS.Desktop.ShellLive.MediaEditor.render/1, phase3_media_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())
assert shell_html =~ ~s(class="assistant-sidebar-context flex shrink-0 flex-col gap-2")
assert shell_html =~ ~s(class="assistant-sidebar-prompt min-h-[8rem] w-full resize-y")
assert shell_html =~ ~s(class="assistant-sidebar-start-button ui-button ui-button-primary")
assert shell_html =~ ~s(class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto")
assert media_html =~ "class=\"editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4 xl:grid-cols-[minmax(320px,1fr)_minmax(0,1.2fr)]\""
assert media_html =~ "class=\"media-preview flex min-h-[16rem] items-center justify-center\""
assert media_html =~ ~s(class="media-details min-w-0")
assert chat_html =~ ~s(class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col")
assert chat_html =~ ~s(class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2")
assert chat_html =~ ~s(class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3")
assert chat_html =~ ~s(class="chat-input-wrapper flex items-end gap-2")
assert menu_html =~ ~s(class="menu-editor-view ui-editor-shell flex h-full min-h-0 flex-col p-4")
assert menu_html =~ ~s(class="menu-editor-toolbar ui-toolbar flex flex-wrap items-center gap-2")
assert menu_html =~ ~s(class="menu-editor-empty flex min-h-0 flex-1 items-center justify-center")
end
alias BDS.Persistence alias BDS.Persistence
alias BDS.AI alias BDS.AI
alias BDS.CliSync.Watcher alias BDS.CliSync.Watcher
@@ -170,8 +545,6 @@ defmodule BDS.Desktop.ShellLiveTest do
end end
end end
@endpoint BDS.Desktop.Endpoint
setup do setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
@@ -673,7 +1046,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="sidebar-shell") assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(data-testid="status-bar") assert html =~ ~s(data-testid="status-bar")
assert html =~ ~s(data-testid="status-task-button") 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-testid="activity-button")
assert html =~ ~s(data-view="posts") assert html =~ ~s(data-view="posts")
assert html =~ ~s(data-view="media") assert html =~ ~s(data-view="media")
@@ -690,14 +1063,14 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']") |> element("[data-testid='toggle-sidebar']")
|> render_click() |> 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 = html =
view view
|> element("[data-testid='toggle-sidebar']") |> element("[data-testid='toggle-sidebar']")
|> render_click() |> 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 = html =
view view
@@ -705,7 +1078,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click() |> render_click()
assert html =~ ~s(data-region="panel") 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") assert html =~ ~s(data-testid="panel-close")
html = html =
@@ -713,7 +1086,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='panel-close']") |> element("[data-testid='panel-close']")
|> render_click() |> 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 = html =
view view
@@ -744,7 +1117,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click() |> render_click()
refute html =~ ~s(data-tab-type="settings") 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 end
test "macos hides the custom titlebar and moves shell toggles into the status bar" do test "macos hides the custom titlebar and moves shell toggles into the status bar" do
@@ -764,7 +1137,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']") |> element("[data-testid='toggle-sidebar']")
|> render_click() |> 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 = html =
render_hook(view, "native_menu_action", %{"action" => "edit_preferences"}) render_hook(view, "native_menu_action", %{"action" => "edit_preferences"})
@@ -1032,7 +1405,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="post-1") assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(data-tab-type="media") assert html =~ ~s(data-tab-type="media")
assert html =~ ~s(data-tab-id="media-1") 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 end
test "workbench session restore renders documentation tab content" do test "workbench session restore renders documentation tab content" do
@@ -1047,6 +1420,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert has_element?(view, ".tab[data-tab-type='documentation'] .tab-title", "Documentation") assert has_element?(view, ".tab[data-tab-type='documentation'] .tab-title", "Documentation")
assert has_element?(view, "[data-testid='help-documentation']") assert has_element?(view, "[data-testid='help-documentation']")
assert has_element?(view, ".documentation-content.markdown-body .documentation-article")
assert render(view) =~ "bDS2 User Guide" assert render(view) =~ "bDS2 User Guide"
end end
@@ -1067,6 +1441,7 @@ defmodule BDS.Desktop.ShellLiveTest do
) )
assert has_element?(view, "[data-testid='help-api-documentation']") assert has_element?(view, "[data-testid='help-api-documentation']")
assert has_element?(view, ".documentation-content.markdown-body .documentation-article")
assert render(view) =~ "API Documentation" assert render(view) =~ "API Documentation"
assert render(view) =~ "local result = bds.posts.get" assert render(view) =~ "local result = bds.posts.get"
end end
@@ -1446,7 +1821,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-type="post") assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="post-1") 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 = html =
render_click(view, "pin_sidebar_item", %{ render_click(view, "pin_sidebar_item", %{
@@ -1457,7 +1832,7 @@ defmodule BDS.Desktop.ShellLiveTest do
}) })
assert html =~ ~s(data-tab-id="post-1") 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 = html =
render_click(view, "open_sidebar_item", %{ render_click(view, "open_sidebar_item", %{
@@ -1498,13 +1873,13 @@ defmodule BDS.Desktop.ShellLiveTest do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-shell") 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}) 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}) 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}) html = render_keydown(view, "shortcut", %{key: "2", meta: true})
assert html =~ ~s(data-view="media") assert html =~ ~s(data-view="media")
@@ -1534,7 +1909,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']") |> element("[data-testid='toggle-sidebar']")
|> render_click() |> 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;") assert html =~ ~s(style="width: 0px;")
end end
@@ -1573,8 +1948,8 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="sidebar-search-form") assert html =~ ~s(data-testid="sidebar-search-form")
assert html =~ ~s(data-testid="sidebar-filter-toggle") assert html =~ ~s(data-testid="sidebar-filter-toggle")
assert html =~ ~s(class="sidebar-section-header") assert html =~ ~s(class="sidebar-section-header flex items-center justify-between gap-2")
assert html =~ ~s(class="sidebar-actions") assert html =~ ~s(class="sidebar-actions flex items-center gap-1")
assert html =~ ~s(data-testid="sidebar-load-more") assert html =~ ~s(data-testid="sidebar-load-more")
assert html_position(html, ~s(data-testid="sidebar-load-more")) > assert html_position(html, ~s(data-testid="sidebar-load-more")) >
@@ -1739,19 +2114,22 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Add published posts" assert html =~ "Add published posts"
html = render_click(view, "select_panel_tab", %{"tab" => "output"}) 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 = html =
view view
|> element("[data-testid='status-task-button']") |> element("[data-testid='status-task-button']")
|> render_click() |> 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 =~ assert Regex.match?(
~s(<button class="panel-tab active" type="button" phx-click="select_panel_tab" phx-value-tab="tasks">) ~r/<button class="panel-tab [^"]*ui-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 ui-panel-entry panel-empty-state ui-empty-state")
end end
test "metadata diff tasks localize task text, show progress, and open the diff result in the UI" do test "metadata diff tasks localize task text, show progress, and open the diff result in the UI" do
@@ -2250,7 +2628,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_change() |> render_change()
html = render(view) html = render(view)
assert html =~ ~s(class="tab active dirty") assert Regex.match?(~r/class="tab [^"]*active[^"]*dirty/, html)
assert html =~ "Updated Shell Post" assert html =~ "Updated Shell Post"
_html = render_hook(view, "native_menu_action", %{"action" => "save"}) _html = render_hook(view, "native_menu_action", %{"action" => "save"})
@@ -2845,13 +3223,13 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "published" "subtitle" => "published"
}) })
assert published_script_html =~ ~s(class="scripts-view-shell editor") assert published_script_html =~ ~s(class="scripts-view-shell ui-editor-shell 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-editor")
assert published_script_html =~ ~s(data-testid="script-status-badge") assert published_script_html =~ ~s(data-testid="script-status-badge")
assert published_script_html =~ ~s(class="status-badge status-published") assert published_script_html =~ ~s(class="status-badge ui-badge status-published")
assert published_script_html =~ ~s(class="secondary scripts-save-button") assert published_script_html =~ ~s(class="secondary scripts-save-button ui-button ui-button-secondary")
assert published_script_html =~ ~s(class="secondary scripts-run-button") assert published_script_html =~ ~s(class="secondary scripts-run-button ui-button ui-button-secondary")
assert published_script_html =~ ~s(class="secondary scripts-check-button") assert published_script_html =~ ~s(class="secondary scripts-check-button ui-button ui-button-secondary")
assert published_script_html =~ "published" assert published_script_html =~ "published"
assert published_script_html =~ "published script" assert published_script_html =~ "published script"
@@ -2866,12 +3244,12 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "published" "subtitle" => "published"
}) })
assert published_template_html =~ ~s(class="templates-view-shell editor") assert published_template_html =~ ~s(class="templates-view-shell ui-editor-shell 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-editor")
assert published_template_html =~ ~s(data-testid="template-status-badge") assert published_template_html =~ ~s(data-testid="template-status-badge")
assert published_template_html =~ ~s(class="status-badge status-published") assert published_template_html =~ ~s(class="status-badge ui-badge status-published")
assert published_template_html =~ ~s(class="secondary templates-save-button") assert published_template_html =~ ~s(class="secondary templates-save-button ui-button ui-button-secondary")
assert published_template_html =~ ~s(class="secondary templates-validate-button") assert published_template_html =~ ~s(class="secondary templates-validate-button ui-button ui-button-secondary")
assert published_template_html =~ "published" assert published_template_html =~ "published"
assert published_template_html =~ "published template" assert published_template_html =~ "published template"
@@ -2887,7 +3265,7 @@ defmodule BDS.Desktop.ShellLiveTest do
}) })
assert draft_script_html =~ ~s(data-testid="script-publish-button") assert draft_script_html =~ ~s(data-testid="script-publish-button")
assert draft_script_html =~ ~s(class="success") assert draft_script_html =~ ~s(class="success ui-button ui-button-primary")
draft_script_html = draft_script_html =
view view
@@ -2908,7 +3286,7 @@ defmodule BDS.Desktop.ShellLiveTest do
}) })
assert draft_template_html =~ ~s(data-testid="template-publish-button") assert draft_template_html =~ ~s(data-testid="template-publish-button")
assert draft_template_html =~ ~s(class="success") assert draft_template_html =~ ~s(class="success ui-button ui-button-primary")
draft_template_html = draft_template_html =
view view
@@ -3059,8 +3437,9 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => media.original_name "subtitle" => media.original_name
}) })
assert html =~ ~s(class="editor-content media-editor") assert html =~
assert html =~ ~s(class="quick-actions-wrapper") "class=\"editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto p-4 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") refute html =~ ~s(class="media-editor-form")
assert has_element?( assert has_element?(
@@ -3084,7 +3463,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click() |> render_click()
assert html =~ ~s(class="translation-modal-backdrop") 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[title]")
assert html =~ ~s(name="media_translation[alt]") assert html =~ ~s(name="media_translation[alt]")
assert html =~ ~s(name="media_translation[caption]") assert html =~ ~s(name="media_translation[caption]")
@@ -3172,8 +3551,8 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "Project settings" "subtitle" => "Project settings"
}) })
assert settings_html =~ ~s(class="settings-view-shell") assert settings_html =~ ~s(class="settings-view-shell ui-editor-shell flex h-full min-h-0 flex-col overflow-hidden")
assert settings_html =~ ~s(class="setting-section") assert settings_html =~ ~s(class="setting-section ui-section-card")
refute settings_html =~ "Desktop workbench content routed through the Elixir shell." refute settings_html =~ "Desktop workbench content routed through the Elixir shell."
tags_html = tags_html =
@@ -3184,7 +3563,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "Manage tags" "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") assert tags_html =~ ~s(class="tags-section")
refute tags_html =~ "Desktop workbench content routed through the Elixir shell." refute tags_html =~ "Desktop workbench content routed through the Elixir shell."
@@ -3208,7 +3587,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => script.slug "subtitle" => script.slug
}) })
assert script_html =~ ~s(class="scripts-view-shell editor") assert script_html =~ ~s(class="scripts-view-shell ui-editor-shell flex h-full min-h-0 flex-col")
assert script_html =~ "scripts-monaco" assert script_html =~ "scripts-monaco"
assert script_html =~ ~s(data-monaco-language="lua") assert script_html =~ ~s(data-monaco-language="lua")
assert script_html =~ ~s(data-monaco-word-wrap="on") assert script_html =~ ~s(data-monaco-word-wrap="on")
@@ -3223,7 +3602,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => template.slug "subtitle" => template.slug
}) })
assert template_html =~ ~s(class="templates-view-shell editor") assert template_html =~ ~s(class="templates-view-shell ui-editor-shell flex h-full min-h-0 flex-col")
assert template_html =~ "templates-monaco" assert template_html =~ "templates-monaco"
assert template_html =~ ~s(data-monaco-language="liquid") assert template_html =~ ~s(data-monaco-language="liquid")
assert template_html =~ ~s(data-monaco-word-wrap="on") assert template_html =~ ~s(data-monaco-word-wrap="on")
@@ -3238,8 +3617,8 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat" "subtitle" => conversation.model || "chat"
}) })
assert chat_html =~ ~s(class="chat-panel") assert chat_html =~ ~s(class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col")
assert chat_html =~ ~s(class="chat-input-container") assert chat_html =~ ~s(class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3")
refute chat_html =~ "Desktop workbench content routed through the Elixir shell." refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
end end
@@ -3258,11 +3637,11 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="chat-model-selector-button") assert html =~ ~s(data-testid="chat-model-selector-button")
assert html =~ ~s(class="chat-panel-title-main") assert html =~ ~s(class="chat-panel-title-main")
assert html =~ ~s(class="chat-model-selector-wrap") assert html =~ ~s(class="chat-model-selector-wrap relative shrink-0")
assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline") assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2")
refute html =~ ~s(class="chat-panel-header-actions") refute html =~ ~s(class="chat-panel-header-actions")
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) css = desktop_css_source()
assert css =~ ".chat-model-selector-wrap" assert css =~ ".chat-model-selector-wrap"
assert css =~ "left: 0;" assert css =~ "left: 0;"
assert css =~ "right: auto;" assert css =~ "right: auto;"
@@ -3307,11 +3686,11 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='chat-model-selector-button']") |> element("[data-testid='chat-model-selector-button']")
|> render_click() |> render_click()
assert selector_html =~ ~s(class="chat-model-selector-menu") assert selector_html =~ ~s(class="chat-model-selector-menu ui-dropdown-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 =~ ~s(data-testid="chat-model-selector-option")
assert selector_html =~ "llama-current" assert selector_html =~ "llama-current"
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) css = desktop_css_source()
assert css =~ ".chat-panel-title {" assert css =~ ".chat-panel-title {"
assert css =~ "overflow: visible;" assert css =~ "overflow: visible;"
@@ -3357,7 +3736,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat" "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 = _html =
view view
@@ -3385,10 +3764,10 @@ defmodule BDS.Desktop.ShellLiveTest do
end) end)
assert AI.get_chat_conversation(conversation.id).title == "Posts 2026" 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 =~ ~r/<span class="chat-panel-title-main">\s*Posts 2026\s*<\/span>/
assert html =~ ~s(<span class="chat-item-title">Posts 2026</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>) refute html =~ ~s(<span class="chat-item-title">New Chat</span>)
end end
@@ -3626,7 +4005,7 @@ defmodule BDS.Desktop.ShellLiveTest do
end end
test "chat editor hook reopens server-expanded A2UI surfaces after patches" do test "chat editor hook reopens server-expanded A2UI surfaces after patches" do
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__)) live_js = File.read!(Path.expand("../../../assets/js/hooks/chat_surface.js", __DIR__))
chat_editor = chat_editor =
File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__)) File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__))
@@ -3736,7 +4115,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ assert html =~
~s(<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text">wie viele Posts sind im Blog?</div>) ~s(<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text">wie viele Posts sind im Blog?</div>)
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) css = desktop_css_source()
assert css =~ ".chat-panel .chat-message.user .chat-message-content" assert css =~ ".chat-panel .chat-message.user .chat-message-content"
assert css =~ "background: transparent;" assert css =~ "background: transparent;"
assert css =~ "border: 0;" assert css =~ "border: 0;"
@@ -3758,9 +4137,9 @@ defmodule BDS.Desktop.ShellLiveTest do
}) })
assert html =~ ~s(rows="1") assert html =~ ~s(rows="1")
assert html =~ ~s(class="chat-input chat-surface-input") assert html =~ ~s(class="chat-input chat-surface-input ui-textarea")
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) css = desktop_css_source()
assert css =~ "--chat-input-line-height: 20px;" assert css =~ "--chat-input-line-height: 20px;"
assert css =~ "--chat-input-min-height: 20px;" assert css =~ "--chat-input-min-height: 20px;"
assert css =~ ".chat-panel .chat-input-container" assert css =~ ".chat-panel .chat-input-container"
@@ -3782,7 +4161,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert css =~ "max-height: 22px;" assert css =~ "max-height: 22px;"
assert css =~ "padding: 0;" assert css =~ "padding: 0;"
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__)) live_js = File.read!(Path.expand("../../../assets/js/hooks/chat_surface.js", __DIR__))
assert live_js =~ assert live_js =~
"minHeight = parseFloat(styles.getPropertyValue(\"--chat-input-min-height\"))" "minHeight = parseFloat(styles.getPropertyValue(\"--chat-input-min-height\"))"
@@ -4076,7 +4455,7 @@ defmodule BDS.Desktop.ShellLiveTest do
{user_index, _length} = :binary.match(html, "Newest question") {user_index, _length} = :binary.match(html, "Newest question")
assert assistant_index < user_index assert assistant_index < user_index
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input"[^>]*disabled/ assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input ui-textarea"[^>]*disabled/
send(view.pid, { send(view.pid, {
:chat_tool_call, :chat_tool_call,

View File

@@ -171,7 +171,9 @@ defmodule BDS.DesktopTest do
test "desktop external links point at the bDS2 GitHub project and issue tracker" do test "desktop external links point at the bDS2 GitHub project and issue tracker" do
assert BDS.Desktop.ExternalLinks.github_url() == "https://github.com/rfc1437/bDS2" assert BDS.Desktop.ExternalLinks.github_url() == "https://github.com/rfc1437/bDS2"
assert BDS.Desktop.ExternalLinks.github_issues_url() == "https://github.com/rfc1437/bDS2/issues"
assert BDS.Desktop.ExternalLinks.github_issues_url() ==
"https://github.com/rfc1437/bDS2/issues"
end end
test "icon menu quit requests app-owned shutdown" do test "icon menu quit requests app-owned shutdown" do
@@ -254,25 +256,40 @@ defmodule BDS.DesktopTest do
assert_receive :window_quit_requested assert_receive :window_quit_requested
end end
test "desktop root html is a LiveView shell and references only the live bootstrap assets" do test "desktop root html is a LiveView shell and references the generated asset entrypoints" do
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}") conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([])) conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
assert conn.status == 200 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")
refute conn.resp_body =~ ~s(data-testid="window-titlebar-menu-bar") 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="status-shell-controls")
assert conn.resp_body =~ ~s(data-testid="toggle-sidebar") 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-panel")
assert conn.resp_body =~ ~s(data-testid="toggle-assistant") assert conn.resp_body =~ ~s(data-testid="toggle-assistant")
assert conn.resp_body =~ ~s(class="activity-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") assert conn.resp_body =~ ~s(class="sidebar flex min-w-0 flex-1 overflow-hidden")
assert conn.resp_body =~ ~s(class="status-bar") 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(data-phx-main)
assert conn.resp_body =~ ~s(src="/assets/live.js")
assert conn.resp_body =~ ~s(href="/assets/app.css") assert conn.resp_body =~ ~s(href="/assets/app.css")
refute conn.resp_body =~ ~s(src="/assets/app.js") assert conn.resp_body =~ ~s(src="/assets/app.js")
refute conn.resp_body =~ ~s(src="/assets/live.js")
refute conn.resp_body =~ ~s(src="/vendor/phoenix/phoenix.min.js")
refute conn.resp_body =~ ~s(src="/vendor/live_view/phoenix_live_view.min.js")
end
test "desktop endpoint serves generated Phoenix-style CSS and JS assets" do
css_conn = conn(:get, "/assets/app.css?k=#{Desktop.Auth.login_key()}")
css_conn = BDS.Desktop.Endpoint.call(css_conn, BDS.Desktop.Endpoint.init([]))
js_conn = conn(:get, "/assets/app.js?k=#{Desktop.Auth.login_key()}")
js_conn = BDS.Desktop.Endpoint.call(js_conn, BDS.Desktop.Endpoint.init([]))
assert css_conn.status == 200
assert byte_size(css_conn.resp_body) > 0
assert js_conn.status == 200
assert byte_size(js_conn.resp_body) > 0
end end
test "desktop endpoint serves the live shell without extra router-side secret injection" do test "desktop endpoint serves the live shell without extra router-side secret injection" do

View File

@@ -1,6 +1,16 @@
defmodule BDS.Scripting.ApiDocumentationTest do defmodule BDS.Scripting.ApiDocumentationTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
test "rendered API docs include richer module indexes and example responses" do
rendered = BDS.Scripting.ApiDocs.render()
assert rendered =~ "**Module APIs**"
assert rendered =~ "- [projects.create](#projectscreate)"
assert rendered =~ "**Example response**"
assert rendered =~ "- Data structures: `ProjectData`"
assert rendered =~ "[↑ Back to Table of contents](#table-of-contents)"
end
test "API.md matches the generated Lua scripting contract" do test "API.md matches the generated Lua scripting contract" do
api_doc_path = Path.expand("../../../API.md", __DIR__) api_doc_path = Path.expand("../../../API.md", __DIR__)

View File

@@ -6,6 +6,35 @@ defmodule BDS.UI.ShellTest do
alias BDS.UI.Session alias BDS.UI.Session
alias BDS.UI.Workbench alias BDS.UI.Workbench
@css_sources [
"tokens.css",
"shell.css",
"sidebar.css",
"tabs.css",
"editor.css",
"forms.css",
"panel.css",
"assistant.css",
"overlays.css",
"menu_editor.css",
"media_editor.css",
"import_editor.css",
"utilities.css"
]
defp css_source do
@css_sources
|> Enum.map(&File.read!("/Users/gb/Projects/bDS2/assets/css/#{&1}"))
|> Enum.join("\n")
end
defp live_js_source do
Path.wildcard("/Users/gb/Projects/bDS2/assets/js/**/*.js")
|> Enum.sort()
|> Enum.map(&File.read!/1)
|> Enum.join("\n")
end
test "registry exposes the shared sidebar and editor contracts for the base shell" do test "registry exposes the shared sidebar and editor contracts for the base shell" do
sidebar_views = Registry.sidebar_views() sidebar_views = Registry.sidebar_views()
editor_routes = Registry.editor_routes() editor_routes = Registry.editor_routes()
@@ -101,25 +130,297 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell keeps the compact frame metrics and live bootstrap assets" do test "desktop shell keeps the compact frame metrics and live bootstrap assets" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js") live_js = live_js_source()
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/app.css") assert File.exists?("/Users/gb/Projects/bDS2/assets/css/shell.css")
assert File.exists?("/Users/gb/Projects/bDS2/priv/ui/live.js") assert File.exists?("/Users/gb/Projects/bDS2/assets/js/app.js")
assert css =~ ".window-titlebar" assert css =~ ".window-titlebar"
assert css =~ "height: 34px" assert css =~ "height: 34px"
assert css =~ "width: 48px" assert css =~ "width: 48px"
assert css =~ "height: 35px" assert css =~ "height: 35px"
assert css =~ "height: 22px" assert css =~ "height: 22px"
assert live_js =~ "LiveView.LiveSocket" assert live_js =~ "new LiveSocket"
assert live_js =~ "Phoenix.Socket" assert live_js =~ "Socket"
assert template =~ "data-project-id={@projects.active_project_id || \"\"}" assert template =~ "data-project-id={@projects.active_project_id || \"\"}"
assert template =~ "data-workbench-session={encoded_workbench_session(@workbench)}" assert template =~ "data-workbench-session={encoded_workbench_session(@workbench)}"
end end
test "phase 4 css defines normalized shared primitives" do
css = css_source()
assert css =~ ".ui-button {"
assert css =~ ".ui-button-secondary {"
assert css =~ ".ui-button-danger {"
assert css =~ ".ui-input,"
assert css =~ ".ui-textarea {"
assert css =~ ".ui-tab {"
assert css =~ ".ui-badge {"
assert css =~ ".ui-panel-entry {"
assert css =~ ".ui-empty-state {"
assert css =~ ".ui-editor-shell {"
assert css =~ ".ui-editor-header {"
assert css =~ ".ui-editor-tab-current {"
assert css =~ ".ui-editor-actions {"
assert css =~ ".ui-toolbar {"
assert css =~ ".ui-toolbar-group {"
assert css =~ ".ui-field-stack {"
assert css =~ ".ui-field-grid-2 {"
assert css =~ ".ui-field-grid-3 {"
assert css =~ ".ui-dropdown-menu {"
assert css =~ ".ui-dropdown-item {"
assert css =~ ".ui-section-card {"
end
test "phase 3 templates use shared shell and form primitives for common layout" do
post_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/post_editor_html/post_editor.html.heex")
media_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/media_editor_html/media_editor.html.heex")
script_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/script_editor_html/script_editor.html.heex")
template_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/template_editor_html/template_editor.html.heex")
chat_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex")
menu_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/menu_editor_html/menu_editor.html.heex")
settings_template =
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex")
assert post_template =~ "ui-editor-shell"
assert post_template =~ "ui-editor-header"
assert post_template =~ "ui-editor-tab-current"
assert post_template =~ "ui-editor-actions"
assert post_template =~ "ui-field-stack"
assert post_template =~ "ui-field-grid-2"
assert post_template =~ "ui-toolbar"
assert post_template =~ "ui-toolbar-group"
assert post_template =~ "ui-dropdown-menu"
assert post_template =~ "ui-dropdown-item"
assert media_template =~ "ui-editor-shell"
assert media_template =~ "ui-editor-header"
assert media_template =~ "ui-editor-tab-current"
assert media_template =~ "ui-editor-actions"
assert media_template =~ "ui-field-stack"
assert media_template =~ "ui-field-grid-2"
assert media_template =~ "ui-dropdown-menu"
assert media_template =~ "ui-dropdown-item"
assert script_template =~ "ui-editor-shell"
assert script_template =~ "ui-editor-header"
assert script_template =~ "ui-editor-tab-current"
assert script_template =~ "ui-editor-actions"
assert script_template =~ "ui-field-stack"
assert template_template =~ "ui-editor-shell"
assert template_template =~ "ui-editor-header"
assert template_template =~ "ui-editor-tab-current"
assert template_template =~ "ui-editor-actions"
assert template_template =~ "ui-field-stack"
assert chat_template =~ "ui-editor-shell"
assert chat_template =~ "ui-section-card"
assert chat_template =~ "ui-dropdown-menu"
assert chat_template =~ "ui-dropdown-item"
assert chat_template =~ "ui-field-stack"
assert menu_template =~ "ui-editor-shell"
assert menu_template =~ "ui-section-card"
assert menu_template =~ "ui-toolbar"
assert settings_template =~ "ui-editor-shell"
assert settings_template =~ "ui-field-stack"
assert settings_template =~ "ui-section-card"
end
test "phase 3 trims redundant common-case layout rules from authored css slices" do
editor_css = File.read!("/Users/gb/Projects/bDS2/assets/css/editor.css")
media_css = File.read!("/Users/gb/Projects/bDS2/assets/css/media_editor.css")
assistant_css = File.read!("/Users/gb/Projects/bDS2/assets/css/assistant.css")
menu_css = File.read!("/Users/gb/Projects/bDS2/assets/css/menu_editor.css")
refute editor_css =~ ".post-editor .editor-header,\n.scripts-view-shell.editor .editor-header,\n.templates-view-shell.editor .editor-header {\n display: flex;"
refute editor_css =~ ".post-editor .editor-actions,\n.scripts-view-shell.editor .editor-actions,\n.templates-view-shell.editor .editor-actions {\n display: flex;"
refute editor_css =~ ".post-editor .quick-actions-menu {"
refute media_css =~ "[data-testid=\"media-editor\"] .editor-header {"
refute media_css =~ "[data-testid=\"media-editor\"] .editor-actions {"
refute media_css =~ "[data-testid=\"media-editor\"] .quick-actions-menu {"
refute assistant_css =~ ".chat-panel-header {\n display: flex;"
refute assistant_css =~ ".chat-panel .chat-input-wrapper {\n display: flex;"
refute menu_css =~ ".menu-editor-view {\n padding: 1rem;"
refute menu_css =~ ".menu-editor-toolbar {\n display: flex;"
end
test "phase 5 desktop-specific surfaces stay in source modules with responsive behavior" do
css = css_source()
app_js = live_js_source()
assert css =~ ".ai-suggestions-modal-backdrop"
assert css =~ ".gallery-overlay"
assert css =~ ".lightbox-overlay"
assert css =~ ".menu-editor-row.is-dragging"
assert css =~ ".menu-editor-row.is-drop-before::before"
assert css =~ ".menu-editor-row.is-drop-after::after"
assert css =~ ".menu-editor-row.is-drop-inside"
assert app_js =~ "MenuEditorTree"
assert app_js =~ "classList.add(\"is-dragging\")"
assert app_js =~ "pushEvent(\"menu_editor_drop_item\""
assert css =~ ".media-preview {"
assert css =~ ".media-preview-image img {"
assert css =~ "object-fit: contain;"
assert css =~ ".media-details {"
assert css =~ "width: 320px;"
assert css =~ ".assistant-sidebar-context {"
assert css =~ ".assistant-sidebar-message {"
assert css =~ ".chat-panel .chat-input-container"
assert css =~ ".chat-model-selector-menu"
assert css =~ "@media (max-width: 720px) {\n .chat-panel-header {\n align-items: stretch;\n flex-direction: column;"
assert css =~ ".chat-model-selector-wrap {\n width: 100%;"
assert css =~ ".chat-panel .chat-model-selector-button.chat-model-selector-inline {\n justify-content: space-between;\n width: 100%;"
assert css =~ ".chat-panel .chat-input-container {\n padding: 8px 12px;"
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")
hooks_index = File.read!("/Users/gb/Projects/bDS2/assets/js/hooks/index.js")
assert app_js =~ ~s(import { Hooks } from "./hooks/index.js";)
assert hooks_index =~ ~s(import { AppShell } from "./app_shell.js";)
assert hooks_index =~ ~s(import { ChatSurface } from "./chat_surface.js";)
assert hooks_index =~ ~s(import { MenuEditorTree } from "./menu_editor_tree.js";)
assert hooks_index =~ ~s(import { MonacoEditor } from "./monaco_editor.js";)
assert hooks_index =~ ~s(import { MonacoDiffEditor } from "./monaco_diff_editor.js";)
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/index.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/app_shell.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/chat_surface.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/menu_editor_tree.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/monaco_editor.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/monaco_diff_editor.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/sidebar_interactions.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/section_scroll.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/bridges/document_commands.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/services.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/theme.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/languages.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()
assert css =~ ".post-editor .post-editor-markdown-surface"
assert css =~ ".scripts-monaco.monaco-editor-shell"
assert css =~ ".templates-monaco.monaco-editor-shell"
assert css =~ ".help-doc-markdown"
assert css =~ "background: var(--vscode-editor-background);"
assert css =~ "color: var(--vscode-editor-foreground);"
refute Regex.match?(~r/\.sidebar-item\s*\{[^}]*background:\s*var\(--panel-2\)/s, css)
refute Regex.match?(~r/\.sidebar-item\s*\{[^}]*color:\s*var\(--ink\)/s, css)
end
test "desktop help documentation keeps the old markdown viewer styling contract" do
css = css_source()
assert css =~ ".help-doc-view"
assert css =~ ".help-doc-view .misc-editor-content"
assert css =~ ".documentation-article"
assert css =~ ".documentation-content.markdown-body h1"
assert css =~ ".documentation-content.markdown-body table"
assert css =~ ".documentation-content.markdown-body ul"
assert css =~ "background: var(--doc-surface);"
assert css =~ "box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);"
end
test "desktop settings editor keeps the old preferences styling contract" do
css = css_source()
assert css =~ ".settings-view"
assert css =~ ".settings-header"
assert css =~ ".settings-content"
assert css =~ ".setting-section"
assert css =~ ".setting-section-header"
assert css =~ ".setting-section-content"
assert css =~ ".setting-row"
assert css =~ ".setting-control"
assert css =~ "grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);"
assert css =~ "background: var(--panel-2, #252526);"
assert css =~ "border: 1px solid var(--line, #3c3c3c);"
end
test "monaco editor styling forces the internal editor surface to the dark theme" do
css = css_source()
live_js = live_js_source()
assert css =~ ".monaco-editor .margin"
assert css =~ ".monaco-editor-background"
assert css =~ "background-color: var(--vscode-editor-background) !important;"
assert css =~ ".monaco-editor .view-line"
assert live_js =~ "base: \"vs-dark\""
assert live_js =~ "monaco.editor.setTheme(\"bds-theme\");"
end
test "monaco editor hook forces first visible layout and textarea content sync" do
live_js = live_js_source()
assert live_js =~ "this.syncEditorFromTextarea"
assert live_js =~ "this.layoutEditorSoon"
assert live_js =~ "this.waitForMonacoVisibleSize"
assert live_js =~ "ResizeObserver"
assert live_js =~ "requestAnimationFrame"
assert live_js =~ "this.editor.layout()"
assert live_js =~ "this.syncEditorFromTextarea()"
end
test "monaco theme uses normalized app colors before defining the dark theme" do
live_js = live_js_source()
assert live_js =~ "normalizeMonacoColor"
assert live_js =~ "base: \"vs-dark\""
assert live_js =~ "\"editor.background\": background"
assert live_js =~ "monaco.editor.defineTheme(\"bds-theme\""
end
test "desktop shell assets persist workbench layout per project" do test "desktop shell assets persist workbench layout per project" do
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js") live_js = live_js_source()
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex") live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
session_util_ex = session_util_ex =
@@ -134,8 +435,8 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell assets reveal loaded media sidebar thumbnails" do test "desktop shell assets reveal loaded media sidebar thumbnails" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js") live_js = live_js_source()
assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-image" assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-image"
assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-fallback" assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-fallback"
@@ -145,7 +446,7 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell css keeps the status bar and hidden menu alignment rules" do test "desktop shell css keeps the status bar and hidden menu alignment rules" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
assert css =~ ".window-titlebar-menu-bar.is-hidden" assert css =~ ".window-titlebar-menu-bar.is-hidden"
assert css =~ "--vscode-statusBar-background: #007acc" assert css =~ "--vscode-statusBar-background: #007acc"
@@ -162,8 +463,9 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell assets keep old activity, tab, focus, and titlebar overlay parity rules" do test "desktop shell assets keep old activity, tab, focus, and titlebar overlay parity rules" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js") live_js = live_js_source()
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") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert css =~ "color: var(--vscode-activityBar-foreground)" assert css =~ "color: var(--vscode-activityBar-foreground)"
@@ -179,9 +481,9 @@ defmodule BDS.UI.ShellTest do
assert css =~ "justify-content: space-between" assert css =~ "justify-content: space-between"
assert css =~ "align-items: center" assert css =~ "align-items: center"
assert css =~ "padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));" assert css =~ "padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));"
assert live_js =~ "windowControlsOverlay" assert titlebar_js =~ "windowControlsOverlay"
assert live_js =~ "geometrychange" assert titlebar_js =~ "geometrychange"
assert live_js =~ "--bds-titlebar-overlay-left" assert titlebar_js =~ "--bds-titlebar-overlay-left"
assert live_js =~ "dataset.shortcuts" assert live_js =~ "dataset.shortcuts"
assert live_js =~ "addEventListener(\"keydown\", this.handleShortcutKeyDown, true)" assert live_js =~ "addEventListener(\"keydown\", this.handleShortcutKeyDown, true)"
assert live_js =~ "event.preventDefault()" assert live_js =~ "event.preventDefault()"
@@ -194,14 +496,14 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell keeps sidebar delete buttons visible in the default state" do test "desktop shell keeps sidebar delete buttons visible in the default state" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
assert Regex.match?(~r/\.sidebar-delete-button\s*\{[^}]*opacity:\s*1;/s, css) assert Regex.match?(~r/\.sidebar-delete-button\s*\{[^}]*opacity:\s*1;/s, css)
refute Regex.match?(~r/\.sidebar-delete-button\s*\{[^}]*opacity:\s*0;/s, css) refute Regex.match?(~r/\.sidebar-delete-button\s*\{[^}]*opacity:\s*0;/s, css)
end end
test "desktop shell css keeps the old activity bar active marker contrast" do test "desktop shell css keeps the old activity bar active marker contrast" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
assert css =~ "--vscode-activityBar-foreground: #ffffff" assert css =~ "--vscode-activityBar-foreground: #ffffff"
assert css =~ ".activity-bar-item:hover {" assert css =~ ".activity-bar-item:hover {"
@@ -211,8 +513,8 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell assets keep legacy titlebar menu keyboard and anchoring behavior" do test "desktop shell assets keep legacy titlebar menu keyboard and anchoring behavior" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js") live_js = live_js_source()
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex") live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
@@ -232,7 +534,7 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell css keeps the old media editor layout contract" do test "desktop shell css keeps the old media editor layout contract" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
template = template =
File.read!( File.read!(
@@ -263,15 +565,15 @@ defmodule BDS.UI.ShellTest do
assert css =~ "opacity: 1;" assert css =~ "opacity: 1;"
assert Regex.match?( 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 ui-button ui-button-secondary 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 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 template =~ ~s(class="quick-action-icon">🤖</span>)
assert Regex.match?( 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 template
) )
@@ -288,7 +590,7 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell css keeps old panel and output density" do test "desktop shell css keeps old panel and output density" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
assert css =~ ".panel-content {" assert css =~ ".panel-content {"
assert css =~ "padding: 8px;" assert css =~ "padding: 8px;"
@@ -302,7 +604,7 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell css keeps legacy sidebar header and post list layout" do test "desktop shell css keeps legacy sidebar header and post list layout" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
assert css =~ ".sidebar-section {" assert css =~ ".sidebar-section {"
assert css =~ "margin-bottom: 4px;" assert css =~ "margin-bottom: 4px;"
@@ -317,7 +619,7 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell assets keep the assistant sidebar chat surface contract" do test "desktop shell assets keep the assistant sidebar chat surface contract" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
assert css =~ ".assistant-sidebar-context" assert css =~ ".assistant-sidebar-context"
@@ -332,7 +634,7 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell assets expose the shared overlay render contract" do test "desktop shell assets expose the shared overlay render contract" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex") live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex")
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")
@@ -383,7 +685,7 @@ defmodule BDS.UI.ShellTest do
assert post_editor_ex =~ "defp build_data(socket)" assert post_editor_ex =~ "defp build_data(socket)"
assert Regex.match?( 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 ui-button ui-button-secondary 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 post_template
) )
@@ -439,7 +741,7 @@ defmodule BDS.UI.ShellTest do
end end
test "desktop shell css keeps the old assistant sidebar panel styling" do test "desktop shell css keeps the old assistant sidebar panel styling" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css") css = css_source()
assert css =~ ".assistant-content {" assert css =~ ".assistant-content {"
assert css =~ "padding: 12px;" assert css =~ "padding: 12px;"