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

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,
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,
runtime: BDS.Scripting.Lua,
timeout: 300_000,

View File

@@ -1,3 +1,9 @@
import Config
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,
at: "/assets",
from: {:bds, "priv/ui"},
only: ["app.css", "live.js", "monaco"]
from: {:bds, "priv/static/assets"},
only: ["app.css", "app.js"]
)
plug(Plug.Static,
at: "/vendor/phoenix",
from: {:phoenix, "priv/static"},
only: ["phoenix.min.js"]
)
plug(Plug.Static,
at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"]
at: "/monaco",
from: {:bds, "priv/ui/monaco"},
only: ["vs"]
)
plug(BDS.Desktop.Router)

View File

@@ -16,9 +16,7 @@ defmodule BDS.Desktop.Layouts do
</head>
<body>
<%= @inner_content %>
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.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>
<script defer phx-track-static src="/assets/app.js"></script>
</body>
</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()}
@impl true
def update(%{action: :finish_request}, %{assigns: %{request: nil}} = socket) do
{:ok, socket}
end
def update(%{action: :finish_request, result: result}, socket) do
{:ok, do_finish_request(socket, result)}
end
@@ -252,15 +256,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
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
|> assign(:request, nil)
|> build_data()
|> clear_streaming_state()
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
case result do
{:ok, reply} ->
@@ -314,7 +331,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp update_request(socket, updater) do
case socket.assigns.request do
nil ->
socket
build_data(socket)
request ->
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 class="chat-panel-header">
<div class="chat-panel-title">
<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 flex shrink-0 items-center justify-between gap-3 px-4 py-3">
<div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
<span class="chat-panel-title-main">
<%= if @chat_editor.needs_api_key? do %>
<%= dgettext("ui", "AI Chat Setup") %>
@@ -10,9 +10,9 @@
</span>
<%= unless @chat_editor.needs_api_key? do %>
<span class="chat-model-selector-wrap">
<span class="chat-model-selector-wrap relative shrink-0">
<button
class="chat-model-selector-button chat-model-selector-inline"
class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2"
type="button"
phx-click="toggle_chat_model_selector"
phx-target={@myself}
@@ -23,7 +23,7 @@
</button>
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
<div class="chat-model-selector-menu">
<div class="chat-model-selector-menu 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 %>
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
<%= if length(@chat_editor.available_model_groups) > 1 do %>
@@ -33,7 +33,7 @@
<%= for model <- group.models do %>
<button
class={[
"chat-model-selector-option",
"chat-model-selector-option ui-dropdown-item flex items-center justify-between gap-2 text-left",
if(model.id == @chat_editor.effective_model, do: "active")
]}
type="button"
@@ -55,19 +55,19 @@
</div>
</div>
<div class="chat-messages chat-surface-scroll">
<div class="chat-messages chat-surface-scroll min-h-0 flex-1 overflow-auto">
<%= if @chat_editor.needs_api_key? do %>
<div class="chat-welcome chat-api-key-state" data-testid="chat-api-key-required">
<div class="chat-welcome chat-api-key-state ui-section-card flex flex-col items-start gap-3 p-4" data-testid="chat-api-key-required">
<div class="chat-welcome-icon">🔑</div>
<h2><%= dgettext("ui", "API Key Required") %></h2>
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
<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>
<% else %>
<%= 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>
<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>
@@ -80,22 +80,10 @@
</ul>
</div>
<% else %>
<%= if @chat_editor.pending_user_message do %>
<div class="chat-message user pending" data-testid="chat-pending-user-message">
<div class="chat-message-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 %>
<div class={["chat-message", to_string(message.role || "assistant")]}>
<div class={["chat-message flex items-start gap-3", to_string(message.role || "assistant")]}>
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
<div class="chat-message-content">
<div class="chat-message-content ui-section-card">
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} />
@@ -112,10 +100,22 @@
<% 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 %>
<div class="chat-message assistant streaming" data-testid="chat-streaming-message">
<div class="chat-message assistant streaming flex items-start gap-3" data-testid="chat-streaming-message">
<div class="chat-message-avatar">🤖</div>
<div class="chat-message-content">
<div class="chat-message-content ui-section-card">
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
<span class="streaming-indicator">●</span>
@@ -133,7 +133,7 @@
<% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
<div class="chat-message assistant thinking" data-testid="chat-streaming-thinking">
<div class="chat-message assistant thinking flex items-start gap-3" data-testid="chat-streaming-thinking">
<div class="chat-message-avatar">🤖</div>
<div class="chat-message-content">
<div class="chat-thinking-indicator">
@@ -147,14 +147,14 @@
</div>
<%= 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 %>
<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 %>
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
<form 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 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 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>
<%= if @chat_editor.action_error do %>

View File

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

View File

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

View File

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

View File

@@ -1,39 +1,51 @@
<div class={["misc-editor-shell", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
<div class="misc-editor-header">
<div class={["misc-editor-shell flex h-full min-h-0 flex-col overflow-hidden", misc_class(@misc_editor.kind)]} data-testid="misc-editor">
<div class="misc-editor-header flex shrink-0 items-start justify-between gap-3">
<div>
<h2><%= @misc_editor.title %></h2>
<p><%= @misc_editor.subtitle %></p>
</div>
<div class="misc-editor-actions">
<div class="misc-editor-actions flex flex-wrap items-center justify-end gap-2">
<%= if refreshable?(@misc_editor.kind) do %>
<button class="secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button>
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="rerun_misc_editor" phx-target={@myself}><%= dgettext("ui", "Refresh") %></button>
<% end %>
<%= 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 %>
<%= 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 %>
</div>
</div>
<div class="misc-editor-summary">
<div class="misc-editor-summary flex flex-wrap gap-2">
<%= for {label, value} <- summary_items(@misc_editor) do %>
<div class="misc-summary-pill"><span><%= label %></span><strong><%= value %></strong></div>
<% end %>
</div>
<div class="misc-editor-content">
<div class="misc-editor-content min-h-0 flex-1 overflow-auto">
<%= case @misc_editor.kind do %>
<% :documentation -> %>
<article class="misc-card help-doc-markdown" data-testid="help-documentation">
<%= markdown_html(@misc_editor.markdown) %>
</article>
<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) %>
</article>
</div>
</main>
</div>
<% :api_documentation -> %>
<article class="misc-card help-doc-markdown" data-testid="help-api-documentation">
<%= markdown_html(@misc_editor.markdown) %>
</article>
<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) %>
</article>
</div>
</main>
</div>
<% :site_validation -> %>
<div class="misc-columns">
@@ -47,7 +59,7 @@
<div class="metadata-diff-tabs" role="tablist">
<%= for tab <- @misc_editor.tabs do %>
<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-entity-tab={tab.id}
type="button"
@@ -57,7 +69,7 @@
>
<span><%= tab.label %></span>
<%= 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 %>
</button>
<% end %>
@@ -83,7 +95,7 @@
<%= if @misc_editor.repair_enabled do %>
<div class="metadata-diff-field-pill-actions">
<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-direction="db_to_file"
data-field={field.field_name}
@@ -97,7 +109,7 @@
</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-direction="file_to_db"
data-field={field.field_name}
@@ -161,7 +173,7 @@
<div class="orphan-files-actions">
<span class="misc-summary-pill"><%= length(@misc_editor.orphan_files) %></span>
<button
class="secondary metadata-diff-action-button"
class="secondary metadata-diff-action-button ui-button ui-button-secondary"
data-testid="metadata-diff-import-button"
type="button"
phx-click="import_metadata_diff_orphans"
@@ -268,8 +280,8 @@
</section>
<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="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="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 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>
@@ -282,7 +294,7 @@
<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>
<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>
<% end %>
</div>
@@ -294,7 +306,7 @@
<% else %>
<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>
<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 %>
<option value={file_path} selected={file_path == @misc_editor.selected_file_path}><%= file_path %></option>
<% end %>

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,29 @@
<div
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"
phx-hook="SettingsSectionScroll"
data-selected-settings-section={@settings_editor.selected_section}
data-settings-scroll-target={"settings-section-#{@settings_editor.selected_section}"}
>
<div class="settings-view">
<div class="settings-header">
<div class="settings-view flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="settings-header flex shrink-0 items-center justify-between gap-3">
<h2 data-testid="editor-title"><%= dgettext("ui", "Settings") %></h2>
<form class="settings-search" phx-change="change_settings_search" phx-target={@myself}>
<input type="text" name="query" value={@settings_editor.search_query} placeholder={dgettext("ui", "Search settings")} />
<form class="settings-search w-full max-w-xs" phx-change="change_settings_search" phx-target={@myself}>
<input class="ui-input" type="text" name="query" value={@settings_editor.search_query} placeholder={dgettext("ui", "Search settings")} />
</form>
</div>
<div class="settings-content">
<div class="settings-content min-h-0 flex-1 overflow-auto">
<%= if Enum.empty?(@settings_editor.active_sections) do %>
<div class="settings-no-results">
<div class="settings-no-results flex items-center justify-center py-6">
<p><%= dgettext("ui", "No settings match the current search") %></p>
</div>
<% end %>
<%= if @settings_editor.project_visible? do %>
<div class="setting-section" id="settings-section-project">
<div class="setting-section-header">
<div class="setting-section ui-section-card" id="settings-section-project">
<div class="setting-section-header ui-field-stack">
<h3><%= dgettext("ui", "Project") %></h3>
<p class="setting-section-description"><%= dgettext("ui", "Blog identity, URLs, authoring defaults, and bookmarklet setup") %></p>
</div>
@@ -32,29 +32,29 @@
<div class="setting-info">
<label class="setting-label"><%= dgettext("ui", "Project Name") %></label>
</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 class="setting-row">
<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 class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Data Path") %></label></div>
<div class="setting-control">
<div class="setting-input-group">
<input 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>
<input class="ui-input ui-input-readonly" type="text" value={@settings_editor.project_data_path} readonly />
<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 class="setting-row">
<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 class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Main Language") %></label></div>
<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 %>
<option value={language} selected={language == @settings_editor.project["main_language"]}><%= String.upcase(language) %></option>
<% end %>
@@ -76,16 +76,16 @@
</div>
<div class="setting-row">
<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 class="setting-row">
<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 class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
<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 %>
<option value={category} selected={category == @settings_editor.project["blogmark_category"]}><%= category %></option>
<% 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>
</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>
<% end %>
<%= if @settings_editor.editor_visible? do %>
<div class="setting-section" id="settings-section-editor">
<div class="setting-section-header">
<div class="setting-section ui-section-card" id="settings-section-editor">
<div class="setting-section-header ui-field-stack">
<h3><%= dgettext("ui", "Editor") %></h3>
<p class="setting-section-description"><%= dgettext("ui", "Default editing mode and diff presentation") %></p>
</div>
@@ -111,7 +111,7 @@
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Default Editor Mode") %></label></div>
<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="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>
@@ -121,7 +121,7 @@
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Diff View Style") %></label></div>
<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="side-by-side" selected={@settings_editor.editor["diff_view_style"] == "side-by-side"}><%= dgettext("ui", "Side by Side") %></option>
</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>
</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>
<% end %>
<%= if @settings_editor.content_visible? do %>
<div class="setting-section" 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 ui-section-card" id="settings-section-content">
<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">
<table class="categories-table">
<thead>
@@ -161,12 +161,12 @@
<tr>
<td><%= category.name %></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><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>
<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>
<%= for template <- @settings_editor.template_options.post do %>
<option value={template.slug} selected={template.slug == category.post_template_slug}><%= template.title %></option>
@@ -174,7 +174,7 @@
</select>
</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>
<%= for template <- @settings_editor.template_options.list do %>
<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}>
<input type="hidden" name="category_settings[category]" value={category.name} />
</form>
<button class="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="submit" form={"category-form-#{category.name}"}><%= dgettext("ui", "Save") %></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>
</td>
</tr>
@@ -198,36 +198,36 @@
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Add Category") %></label></div>
<div class="setting-control">
<div class="setting-input-group">
<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>
<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 ui-button ui-button-primary" type="button" phx-click="add_settings_category" phx-target={@myself}><%= dgettext("ui", "Add") %></button>
</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>
<% end %>
<%= if @settings_editor.ai_visible? do %>
<div class="setting-section" 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 ui-section-card" id="settings-section-ai">
<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}>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Endpoint URL") %></label></div>
<div class="setting-control">
<div class="setting-input-group">
<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>
<input class="ui-input" type="url" name="settings_ai[online_url]" value={@settings_editor.ai["online_url"]} />
<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 class="setting-row">
<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 class="setting-row">
<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 class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Online Chat Tools") %></label></div>
@@ -239,11 +239,11 @@
</div>
<div class="setting-row">
<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 class="setting-row">
<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 class="setting-row">
<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-control">
<div class="setting-input-group">
<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>
<input class="ui-input" type="url" name="settings_ai[offline_url]" value={@settings_editor.ai["offline_url"]} />
<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 class="setting-row">
<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 class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Airplane Mode") %></label></div>
@@ -268,7 +268,7 @@
</div>
<div class="setting-row">
<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 class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Chat Tools") %></label></div>
@@ -280,11 +280,11 @@
</div>
<div class="setting-row">
<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 class="setting-row">
<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 class="setting-row">
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Offline Image Support") %></label></div>
@@ -292,7 +292,7 @@
</div>
<div class="setting-row">
<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>
<datalist id="settings-ai-online-models">
<%= for model <- @settings_editor.online_endpoint_models do %>
@@ -305,13 +305,13 @@
<% end %>
</datalist>
</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>
<% end %>
<%= if @settings_editor.technology_visible? do %>
<div class="setting-section" 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 ui-section-card" id="settings-section-technology">
<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}>
<div class="setting-row">
<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>
</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>
<% end %>
<%= if @settings_editor.publishing_visible? do %>
<div class="setting-section" 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 ui-section-card" id="settings-section-publishing">
<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}>
<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", "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", "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", "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", "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 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 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 class="ui-input" type="text" name="settings_publishing[ssh_remote_path]" value={@settings_editor.publishing["ssh_remote_path"]} /></div></div>
</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>
<% end %>
<%= if @settings_editor.mcp_visible? do %>
<div class="setting-section" 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 ui-section-card" id="settings-section-mcp">
<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">
<%= for agent <- @settings_editor.mcp do %>
<div class="setting-row">
@@ -350,7 +350,7 @@
<p class="setting-description"><%= agent.config_path || dgettext("ui", "Not supported in the rewrite yet") %></p>
</div>
<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") %>
</button>
</div>
@@ -361,17 +361,17 @@
<% end %>
<%= if @settings_editor.data_visible? do %>
<div class="setting-section" 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 ui-section-card" id="settings-section-data">
<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">
<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" 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" 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" 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" 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="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_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_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_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_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="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="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="open_data_folder"><%= dgettext("ui", "Open Data Folder") %></button>
</div>
</div>
<% end %>

View File

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

View File

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

View File

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

View File

@@ -1236,21 +1236,27 @@ defmodule BDS.Scripting.ApiDocs do
end
defp table_of_contents do
@methods
|> Enum.map(& &1.module)
|> Enum.uniq()
module_names()
|> Enum.map(fn module_name -> "- [#{module_name}](##{module_name})" end)
|> Kernel.++(["- [Data Structures](#data-structures)"])
end
defp render_modules do
@methods
|> Enum.group_by(& &1.module)
|> Enum.flat_map(fn {module_name, methods} ->
module_names()
|> Enum.flat_map(fn module_name ->
methods = module_methods(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),
"[↑ Back to Table of contents](#table-of-contents)",
""
]
end)
@@ -1269,6 +1275,14 @@ defmodule BDS.Scripting.ApiDocs do
"**Response specification**",
"",
"- Return type: `#{method.returns}`",
render_nullability(method.returns),
render_data_structure_references(method.returns),
"",
"**Example response**",
"",
"```lua",
render_example_response(method.returns),
"```",
"",
"**Example call**",
"",
@@ -1277,6 +1291,7 @@ defmodule BDS.Scripting.ApiDocs do
"```",
""
]
|> Enum.reject(&is_nil/1)
end
defp render_params([]), do: ["- None"]
@@ -1289,18 +1304,237 @@ defmodule BDS.Scripting.ApiDocs do
end
defp example_call(method) do
args =
method.params
|> Enum.map(fn param -> example_value(param.type) end)
|> Enum.join(", ")
args = Enum.map_join(method.params, ", ", &example_argument/1)
"local result = bds.#{method.module}.#{method.name}(#{args})"
end
defp example_value("string"), do: "\"value\""
defp example_value("table"), do: "{}"
defp example_value("integer"), do: "1"
defp example_value(_type), do: "nil"
defp module_names do
@methods
|> Enum.map(& &1.module)
|> 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
Enum.flat_map(@data_structures, fn structure ->

View File

@@ -36,6 +36,8 @@ defmodule BDS.MixProject do
{:image, "~> 0.65"},
{:stemex, "~> 0.2.1"},
{:gettext, "~> 0.24"},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:lazy_html, ">= 0.1.0", only: :test},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
]
@@ -46,6 +48,9 @@ defmodule BDS.MixProject do
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate"],
"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"],
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"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"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_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"},
@@ -46,6 +47,7 @@
"saxy": {:hex, :saxy, "1.4.0", "c7203ad20001f72eaaad07d08f82be063fa94a40924e6bb39d93d55f979abcba", [:mix], [], "hexpm", "3fe790354d3f2234ad0b5be2d99822a23fa2d4e8ccd6657c672901dac172e9a9"},
"stemex": {:hex, :stemex, "0.2.1", "47017c6b10cdd6926a0d523ccf1f801c5f3faf5a0a9c862f49304e07f9b5584f", [:mix], [], "hexpm", "dbfc76d27adfa31d831d183979c595942884e6530a4496714aa5b70d0964c2e4"},
"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"},
"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"},

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
@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
source_files =
@@ -30,6 +269,142 @@ defmodule BDS.Desktop.ShellLiveTest do
assert offenders == []
end
@tag :phase3
test "phase 3 shell chrome renders utility-owned layout classes" do
conn = Plug.Conn.put_private(build_conn(), :phoenix_endpoint, BDS.Desktop.Endpoint)
{:ok, _view, html} = live_isolated(conn, BDS.Desktop.ShellLive)
assert html =~ "activity-bar flex h-full shrink-0 flex-col items-center"
assert html =~ "sidebar-shell flex min-w-0 shrink-0 overflow-hidden"
assert html =~ "tab-bar-empty flex h-full items-center px-3 text-sm"
assert html =~ "assistant-sidebar-shell flex min-w-0 shrink-0 overflow-hidden"
assert html =~ "status-bar flex h-[22px] shrink-0 items-center justify-between"
end
@tag :phase3
test "phase 3 editors and shared surfaces render utility-owned layouts" do
post_html = render_component(&BDS.Desktop.ShellLive.PostEditor.render/1, phase3_post_editor_assigns())
media_html = render_component(&BDS.Desktop.ShellLive.MediaEditor.render/1, phase3_media_editor_assigns())
script_html = render_component(&BDS.Desktop.ShellLive.ScriptEditor.render/1, phase3_script_editor_assigns())
template_html = render_component(&BDS.Desktop.ShellLive.TemplateEditor.render/1, phase3_template_editor_assigns())
chat_html = render_component(&BDS.Desktop.ShellLive.ChatEditor.render/1, phase3_chat_editor_assigns())
menu_html = render_component(&BDS.Desktop.ShellLive.MenuEditor.render/1, phase3_menu_editor_assigns())
settings_html = render_component(&BDS.Desktop.ShellLive.SettingsEditor.render/1, phase3_settings_editor_assigns())
tags_html = render_component(&BDS.Desktop.ShellLive.TagsEditor.render/1, phase3_tags_editor_assigns())
assert post_html =~ "post-editor 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.AI
alias BDS.CliSync.Watcher
@@ -170,8 +545,6 @@ defmodule BDS.Desktop.ShellLiveTest do
end
end
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
@@ -673,7 +1046,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(data-testid="status-bar")
assert html =~ ~s(data-testid="status-task-button")
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
assert html =~ ~s(data-testid="activity-button")
assert html =~ ~s(data-view="posts")
assert html =~ ~s(data-view="media")
@@ -690,14 +1063,14 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html =
view
|> element("[data-testid='toggle-sidebar']")
|> render_click()
refute html =~ ~s(class="sidebar-shell is-hidden")
refute html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html =
view
@@ -705,7 +1078,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click()
assert html =~ ~s(data-region="panel")
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
assert html =~ ~s(data-testid="panel-close")
html =
@@ -713,7 +1086,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='panel-close']")
|> render_click()
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html =
view
@@ -744,7 +1117,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click()
refute html =~ ~s(data-tab-type="settings")
assert html =~ ~s(class="tab-bar-empty")
assert html =~ ~s(class="tab-bar-empty flex h-full items-center px-3 text-sm")
end
test "macos hides the custom titlebar and moves shell toggles into the status bar" do
@@ -764,7 +1137,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html =
render_hook(view, "native_menu_action", %{"action" => "edit_preferences"})
@@ -1032,7 +1405,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(data-tab-type="media")
assert html =~ ~s(data-tab-id="media-1")
assert html =~ ~s(class="tab active transient")
assert Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html)
end
test "workbench session restore renders documentation tab content" do
@@ -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, "[data-testid='help-documentation']")
assert has_element?(view, ".documentation-content.markdown-body .documentation-article")
assert render(view) =~ "bDS2 User Guide"
end
@@ -1067,6 +1441,7 @@ defmodule BDS.Desktop.ShellLiveTest do
)
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) =~ "local result = bds.posts.get"
end
@@ -1446,7 +1821,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(class="tab active transient")
assert Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html)
html =
render_click(view, "pin_sidebar_item", %{
@@ -1457,7 +1832,7 @@ defmodule BDS.Desktop.ShellLiveTest do
})
assert html =~ ~s(data-tab-id="post-1")
refute html =~ ~s(class="tab active transient")
refute Regex.match?(~r/class="tab [^"]*active[^"]*transient/, html)
html =
render_click(view, "open_sidebar_item", %{
@@ -1498,13 +1873,13 @@ defmodule BDS.Desktop.ShellLiveTest do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html = render_keydown(view, "shortcut", %{key: "b", meta: true})
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
html = render_keydown(view, "shortcut", %{key: "j", meta: true})
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html = render_keydown(view, "shortcut", %{key: "2", meta: true})
assert html =~ ~s(data-view="media")
@@ -1534,7 +1909,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(class="sidebar-shell flex min-w-0 shrink-0 overflow-hidden is-hidden")
assert html =~ ~s(style="width: 0px;")
end
@@ -1573,8 +1948,8 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="sidebar-search-form")
assert html =~ ~s(data-testid="sidebar-filter-toggle")
assert html =~ ~s(class="sidebar-section-header")
assert html =~ ~s(class="sidebar-actions")
assert html =~ ~s(class="sidebar-section-header flex items-center justify-between gap-2")
assert html =~ ~s(class="sidebar-actions flex items-center gap-1")
assert html =~ ~s(data-testid="sidebar-load-more")
assert html_position(html, ~s(data-testid="sidebar-load-more")) >
@@ -1739,19 +2114,22 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Add published posts"
html = render_click(view, "select_panel_tab", %{"tab" => "output"})
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
html =
view
|> element("[data-testid='status-task-button']")
|> render_click()
refute html =~ ~s(class="panel-shell is-hidden")
refute html =~ ~s(class="panel-shell flex min-h-0 shrink-0 flex-col overflow-hidden is-hidden")
assert html =~
~s(<button class="panel-tab active" type="button" phx-click="select_panel_tab" phx-value-tab="tasks">)
assert Regex.match?(
~r/<button class="panel-tab [^"]*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
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()
html = render(view)
assert html =~ ~s(class="tab active dirty")
assert Regex.match?(~r/class="tab [^"]*active[^"]*dirty/, html)
assert html =~ "Updated Shell Post"
_html = render_hook(view, "native_menu_action", %{"action" => "save"})
@@ -2845,13 +3223,13 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "published"
})
assert published_script_html =~ ~s(class="scripts-view-shell editor")
assert published_script_html =~ ~s(class="scripts-view-shell 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-status-badge")
assert published_script_html =~ ~s(class="status-badge status-published")
assert published_script_html =~ ~s(class="secondary scripts-save-button")
assert published_script_html =~ ~s(class="secondary scripts-run-button")
assert published_script_html =~ ~s(class="secondary scripts-check-button")
assert published_script_html =~ ~s(class="status-badge ui-badge status-published")
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 ui-button ui-button-secondary")
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 script"
@@ -2866,12 +3244,12 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "published"
})
assert published_template_html =~ ~s(class="templates-view-shell editor")
assert published_template_html =~ ~s(class="templates-view-shell 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-status-badge")
assert published_template_html =~ ~s(class="status-badge status-published")
assert published_template_html =~ ~s(class="secondary templates-save-button")
assert published_template_html =~ ~s(class="secondary templates-validate-button")
assert published_template_html =~ ~s(class="status-badge ui-badge status-published")
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 ui-button ui-button-secondary")
assert published_template_html =~ "published"
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(class="success")
assert draft_script_html =~ ~s(class="success ui-button ui-button-primary")
draft_script_html =
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(class="success")
assert draft_template_html =~ ~s(class="success ui-button ui-button-primary")
draft_template_html =
view
@@ -3059,8 +3437,9 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => media.original_name
})
assert html =~ ~s(class="editor-content media-editor")
assert html =~ ~s(class="quick-actions-wrapper")
assert html =~
"class=\"editor-content media-editor grid min-h-0 flex-1 gap-4 overflow-auto 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")
assert has_element?(
@@ -3084,7 +3463,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> render_click()
assert html =~ ~s(class="translation-modal-backdrop")
assert html =~ ~s(class="translation-modal")
assert html =~ ~s(class="translation-modal flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden")
assert html =~ ~s(name="media_translation[title]")
assert html =~ ~s(name="media_translation[alt]")
assert html =~ ~s(name="media_translation[caption]")
@@ -3172,8 +3551,8 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "Project settings"
})
assert settings_html =~ ~s(class="settings-view-shell")
assert settings_html =~ ~s(class="setting-section")
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 ui-section-card")
refute settings_html =~ "Desktop workbench content routed through the Elixir shell."
tags_html =
@@ -3184,7 +3563,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => "Manage tags"
})
assert tags_html =~ ~s(class="tags-view-shell")
assert tags_html =~ ~s(class="tags-view-shell flex h-full min-h-0 flex-col overflow-hidden")
assert tags_html =~ ~s(class="tags-section")
refute tags_html =~ "Desktop workbench content routed through the Elixir shell."
@@ -3208,7 +3587,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => script.slug
})
assert script_html =~ ~s(class="scripts-view-shell editor")
assert script_html =~ ~s(class="scripts-view-shell ui-editor-shell flex h-full min-h-0 flex-col")
assert script_html =~ "scripts-monaco"
assert script_html =~ ~s(data-monaco-language="lua")
assert script_html =~ ~s(data-monaco-word-wrap="on")
@@ -3223,7 +3602,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => template.slug
})
assert template_html =~ ~s(class="templates-view-shell editor")
assert template_html =~ ~s(class="templates-view-shell ui-editor-shell flex h-full min-h-0 flex-col")
assert template_html =~ "templates-monaco"
assert template_html =~ ~s(data-monaco-language="liquid")
assert template_html =~ ~s(data-monaco-word-wrap="on")
@@ -3238,8 +3617,8 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
assert chat_html =~ ~s(class="chat-panel")
assert chat_html =~ ~s(class="chat-input-container")
assert chat_html =~ ~s(class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col")
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."
end
@@ -3258,11 +3637,11 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="chat-model-selector-button")
assert html =~ ~s(class="chat-panel-title-main")
assert html =~ ~s(class="chat-model-selector-wrap")
assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline")
assert html =~ ~s(class="chat-model-selector-wrap relative shrink-0")
assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2")
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 =~ "left: 0;"
assert css =~ "right: auto;"
@@ -3307,11 +3686,11 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='chat-model-selector-button']")
|> render_click()
assert selector_html =~ ~s(class="chat-model-selector-menu")
assert selector_html =~ ~s(class="chat-model-selector-menu 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 =~ "llama-current"
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
css = desktop_css_source()
assert css =~ ".chat-panel-title {"
assert css =~ "overflow: visible;"
@@ -3357,7 +3736,7 @@ defmodule BDS.Desktop.ShellLiveTest do
"subtitle" => conversation.model || "chat"
})
assert html =~ ~s(<span class="tab-title">New Chat</span>)
assert html =~ ~s(<span class="tab-title truncate">New Chat</span>)
_html =
view
@@ -3385,10 +3764,10 @@ defmodule BDS.Desktop.ShellLiveTest do
end)
assert AI.get_chat_conversation(conversation.id).title == "Posts 2026"
assert html =~ ~s(<span class="tab-title">Posts 2026</span>)
assert html =~ ~s(<span class="tab-title truncate">Posts 2026</span>)
assert html =~ ~r/<span class="chat-panel-title-main">\s*Posts 2026\s*<\/span>/
assert html =~ ~s(<span class="chat-item-title">Posts 2026</span>)
refute html =~ ~s(<span class="tab-title">New Chat</span>)
refute html =~ ~s(<span class="tab-title truncate">New Chat</span>)
refute html =~ ~s(<span class="chat-item-title">New Chat</span>)
end
@@ -3626,7 +4005,7 @@ defmodule BDS.Desktop.ShellLiveTest do
end
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 =
File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__))
@@ -3736,7 +4115,7 @@ defmodule BDS.Desktop.ShellLiveTest do
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>)
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 =~ "background: transparent;"
assert css =~ "border: 0;"
@@ -3758,9 +4137,9 @@ defmodule BDS.Desktop.ShellLiveTest do
})
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-min-height: 20px;"
assert css =~ ".chat-panel .chat-input-container"
@@ -3782,7 +4161,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert css =~ "max-height: 22px;"
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 =~
"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")
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, {
: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
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
test "icon menu quit requests app-owned shutdown" do
@@ -254,25 +256,40 @@ defmodule BDS.DesktopTest do
assert_receive :window_quit_requested
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 = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
assert conn.status == 200
assert conn.resp_body =~ ~s(class="app")
assert conn.resp_body =~ ~s(class="app flex h-full w-full flex-col")
refute conn.resp_body =~ ~s(data-testid="window-titlebar")
refute conn.resp_body =~ ~s(data-testid="window-titlebar-menu-bar")
assert conn.resp_body =~ ~s(data-testid="status-shell-controls")
assert conn.resp_body =~ ~s(data-testid="toggle-sidebar")
assert conn.resp_body =~ ~s(data-testid="toggle-panel")
assert conn.resp_body =~ ~s(data-testid="toggle-assistant")
assert conn.resp_body =~ ~s(class="activity-bar")
assert conn.resp_body =~ ~s(class="sidebar")
assert conn.resp_body =~ ~s(class="status-bar")
assert conn.resp_body =~ ~s(class="activity-bar flex h-full shrink-0 flex-col items-center justify-between")
assert conn.resp_body =~ ~s(class="sidebar flex min-w-0 flex-1 overflow-hidden")
assert conn.resp_body =~ ~s(class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2")
assert conn.resp_body =~ ~s(data-phx-main)
assert conn.resp_body =~ ~s(src="/assets/live.js")
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
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
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
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.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
sidebar_views = Registry.sidebar_views()
editor_routes = Registry.editor_routes()
@@ -101,25 +130,297 @@ defmodule BDS.UI.ShellTest do
end
test "desktop shell keeps the compact frame metrics and live bootstrap assets" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
css = css_source()
live_js = live_js_source()
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/priv/ui/live.js")
assert File.exists?("/Users/gb/Projects/bDS2/assets/css/shell.css")
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/app.js")
assert css =~ ".window-titlebar"
assert css =~ "height: 34px"
assert css =~ "width: 48px"
assert css =~ "height: 35px"
assert css =~ "height: 22px"
assert live_js =~ "LiveView.LiveSocket"
assert live_js =~ "Phoenix.Socket"
assert live_js =~ "new LiveSocket"
assert live_js =~ "Socket"
assert template =~ "data-project-id={@projects.active_project_id || \"\"}"
assert template =~ "data-workbench-session={encoded_workbench_session(@workbench)}"
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
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")
session_util_ex =
@@ -134,8 +435,8 @@ defmodule BDS.UI.ShellTest do
end
test "desktop shell assets reveal loaded media sidebar thumbnails" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
css = css_source()
live_js = live_js_source()
assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-image"
assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-fallback"
@@ -145,7 +446,7 @@ defmodule BDS.UI.ShellTest do
end
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 =~ "--vscode-statusBar-background: #007acc"
@@ -162,8 +463,9 @@ defmodule BDS.UI.ShellTest do
end
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")
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
css = css_source()
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")
assert css =~ "color: var(--vscode-activityBar-foreground)"
@@ -179,9 +481,9 @@ defmodule BDS.UI.ShellTest do
assert css =~ "justify-content: space-between"
assert css =~ "align-items: center"
assert css =~ "padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));"
assert live_js =~ "windowControlsOverlay"
assert live_js =~ "geometrychange"
assert live_js =~ "--bds-titlebar-overlay-left"
assert titlebar_js =~ "windowControlsOverlay"
assert titlebar_js =~ "geometrychange"
assert titlebar_js =~ "--bds-titlebar-overlay-left"
assert live_js =~ "dataset.shortcuts"
assert live_js =~ "addEventListener(\"keydown\", this.handleShortcutKeyDown, true)"
assert live_js =~ "event.preventDefault()"
@@ -194,14 +496,14 @@ defmodule BDS.UI.ShellTest do
end
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)
refute Regex.match?(~r/\.sidebar-delete-button\s*\{[^}]*opacity:\s*0;/s, css)
end
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 =~ ".activity-bar-item:hover {"
@@ -211,8 +513,8 @@ defmodule BDS.UI.ShellTest do
end
test "desktop shell assets keep legacy titlebar menu keyboard and anchoring behavior" do
css = File.read!("/Users/gb/Projects/bDS2/priv/ui/app.css")
live_js = File.read!("/Users/gb/Projects/bDS2/priv/ui/live.js")
css = css_source()
live_js = live_js_source()
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")
@@ -232,7 +534,7 @@ defmodule BDS.UI.ShellTest do
end
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 =
File.read!(
@@ -263,15 +565,15 @@ defmodule BDS.UI.ShellTest do
assert css =~ "opacity: 1;"
assert Regex.match?(
~r/class="secondary quick-actions-btn".*?<span class="quick-actions-btn-icon">⚡<\/span>\s*<span class="quick-actions-btn-label"><%= dgettext\("ui", "Quick Actions"\) %><\/span>/s,
~r/class="secondary quick-actions-btn 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
)
assert template =~ ~s(class="quick-action-text")
assert template =~ ~s(class="quick-action-text flex min-w-0 flex-1 flex-col")
assert template =~ ~s(class="quick-action-icon">🤖</span>)
assert Regex.match?(
~r/class="quick-action-text">\s*<strong><%= dgettext\("ui", "AI Suggestions"\) %><\/strong>.*?<\/span>\s*<span class="quick-action-icon">🤖<\/span>/s,
~r/class="quick-action-text[^"]*">\s*<strong><%= dgettext\("ui", "AI Suggestions"\) %><\/strong>.*?<\/span>\s*<span class="quick-action-icon">🤖<\/span>/s,
template
)
@@ -288,7 +590,7 @@ defmodule BDS.UI.ShellTest do
end
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 =~ "padding: 8px;"
@@ -302,7 +604,7 @@ defmodule BDS.UI.ShellTest do
end
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 =~ "margin-bottom: 4px;"
@@ -317,7 +619,7 @@ defmodule BDS.UI.ShellTest do
end
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")
assert css =~ ".assistant-sidebar-context"
@@ -332,7 +634,7 @@ defmodule BDS.UI.ShellTest do
end
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")
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 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
)
@@ -439,7 +741,7 @@ defmodule BDS.UI.ShellTest do
end
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 =~ "padding: 12px;"