fix: A1-9 replace native color input with 17-preset colour picker popover + custom hex

This commit is contained in:
2026-05-29 09:28:57 +02:00
parent 1f645f6e5e
commit 5b21dcb17d
8 changed files with 237 additions and 5 deletions

View File

@@ -18,7 +18,7 @@ Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update
| A1-6 | ~~On-demand rendering in preview server~~ | preview.allium:53-93 | `Preview.Router` matches post/archive/home/language routes and renders on-demand via `Rendering` | **Resolved:** `Preview.Router` implements on-demand template rendering for post, archive, home, date, tag, category, page, and language-prefixed routes; static file fallback retained for non-HTML assets (pagefind, feeds); 6 tests added |
| A1-7 | ~~Template lookup must use all 4 levels (post→tag→category→default)~~ | template_context.allium:267-277 | `resolve_post_template_slug/3` implements tag→category cascade; all callers (preview, generation) updated | **Resolved:** `resolve_post_template_slug/3` in template_selection.ex, callers in preview.ex, router.ex, outputs.ex updated, 8 tests added |
| A1-8 | ~~`ValidateLiquid`/`ValidateScript` before publish~~ | template.allium:110, script.allium:165 | `publish_template` validates Liquid via `Liquex.parse`, `publish_script` validates Lua via `BDS.Scripting.validate` | **Resolved:** validation gates added to `publish_template/1` and `publish_script/1`, invalid content returns `{:error, {:invalid_liquid|:invalid_script, reason}}`, 4 tests added |
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
| A1-9 | ~~17 preset colors + custom hex in tag picker~~ | editor_tags.allium | `ColourPicker` hook + popover with 17 preset swatches grid and custom hex input, wired to both create and edit forms | **Resolved:** replaced native `<input type="color">` with `ColourPickerPopover` component (17 presets, custom hex #RRGGBB, immediate selection), JS hook for click-away dismiss, 1 test added |
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |

View File

@@ -555,3 +555,88 @@
padding: 8px 12px;
}
}
/* Colour picker popover */
.colour-picker-wrap {
position: relative;
display: inline-flex;
}
.colour-picker-trigger {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid var(--vscode-input-border);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.colour-picker-trigger:hover {
opacity: 0.85;
}
.colour-picker-popover {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
padding: 8px;
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
border-radius: 6px;
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
width: 196px;
}
.colour-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
}
.colour-picker-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: border-color 0.1s;
}
.colour-picker-swatch:hover {
border-color: var(--vscode-focusBorder);
}
.colour-picker-swatch.selected {
border-color: var(--vscode-focusBorder);
box-shadow: 0 0 0 1px var(--vscode-focusBorder);
}
.colour-picker-custom {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
.colour-picker-custom label {
font-size: 11px;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}
.colour-picker-custom input {
flex: 1;
min-width: 0;
font-size: 12px;
padding: 2px 6px;
border: 1px solid var(--vscode-input-border);
border-radius: 3px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-family: monospace;
}

View File

@@ -0,0 +1,45 @@
export const ColourPicker = {
mounted() {
this._onClickAway = (e) => {
if (!this.el.contains(e.target)) {
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
}
};
document.addEventListener("mousedown", this._onClickAway);
this._setupCustomInput();
},
updated() {
this._setupCustomInput();
},
destroyed() {
document.removeEventListener("mousedown", this._onClickAway);
},
_setupCustomInput() {
const input = this.el.querySelector(".colour-picker-custom input");
if (!input || input._cpBound) return;
input._cpBound = true;
const pushColor = () => {
let val = input.value.trim();
if (val && !val.startsWith("#")) val = "#" + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
const event = this.el.dataset.pickEvent;
this.pushEventTo(this.el.dataset.target, event, { color: val });
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
}
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
pushColor();
}
});
input.addEventListener("blur", pushColor);
}
};

View File

@@ -2,6 +2,7 @@ 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 { ColourPicker } from "./colour_picker.js";
import { MenuEditorTree } from "./menu_editor_tree.js";
import { MonacoEditor } from "./monaco_editor.js";
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
@@ -12,6 +13,7 @@ export const Hooks = {
SettingsSectionScroll,
TagsSectionScroll,
ChatSurface,
ColourPicker,
MenuEditorTree,
MonacoEditor,
MonacoDiffEditor

View File

@@ -16,6 +16,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
@tags_sections ~w(cloud manage merge)
@colour_presets ~w(
#ef4444 #f97316 #f59e0b #eab308 #84cc16
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
#ec4899 #64748b
)
@spec colour_presets() :: [String.t()]
def colour_presets, do: @colour_presets
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
@impl true
def update(%{action: :save} = assigns, socket) do
@@ -107,6 +117,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
{:noreply, assign(socket, :tags_editor, tags_editor)}
end
def handle_event("pick_new_tag_color", %{"color" => color}, socket) do
tags_editor =
Map.put(socket.assigns.tags_editor, :new_tag, %{
socket.assigns.tags_editor.new_tag
| "color" => color
})
{:noreply, assign(socket, :tags_editor, tags_editor)}
end
def handle_event("pick_edit_tag_color", %{"color" => color}, socket) do
tags_editor =
Map.put(socket.assigns.tags_editor, :edit_draft, %{
socket.assigns.tags_editor.edit_draft
| "color" => color
})
{:noreply, assign(socket, :tags_editor, tags_editor)}
end
def handle_event("save_tag_editor", _params, socket) do
{:noreply, do_save(socket)}
end
@@ -241,6 +271,55 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
attr :color, :string, default: nil
attr :presets, :list, required: true
attr :pick_event, :string, required: true
attr :target, :any, required: true
defp colour_picker(assigns) do
~H"""
<div
class="colour-picker-wrap"
id={"cp-#{@pick_event}"}
phx-hook="ColourPicker"
data-pick-event={@pick_event}
data-target={if @target, do: @target.cid}
>
<button
type="button"
class="colour-picker-trigger"
style={"background-color: #{if @color in [nil, ""], do: "#3b82f6", else: @color}"}
phx-click={Phoenix.LiveView.JS.toggle(to: "#cp-#{@pick_event} .colour-picker-popover")}
/>
<div class="colour-picker-popover hidden">
<div class="colour-picker-grid">
<%= for preset <- @presets do %>
<button
type="button"
class={"colour-picker-swatch#{if normalize_hex(@color) == normalize_hex(preset), do: " selected", else: ""}"}
style={"background-color: #{preset}"}
phx-click={Phoenix.LiveView.JS.push(@pick_event, value: %{color: preset}, target: if(@target, do: @target.cid)) |> Phoenix.LiveView.JS.add_class("hidden", to: "#cp-#{@pick_event} .colour-picker-popover")}
/>
<% end %>
</div>
<div class="colour-picker-custom">
<label>#</label>
<input
type="text"
maxlength="7"
placeholder="RRGGBB"
value={if @color in [nil, ""], do: "", else: @color}
/>
</div>
</div>
</div>
"""
end
defp normalize_hex(nil), do: nil
defp normalize_hex(""), do: nil
defp normalize_hex(hex), do: String.downcase(hex)
defp load_data(socket) do
project_id = socket.assigns.project_id
@@ -280,7 +359,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
merge_target:
Map.get(socket.assigns, :tags_editor, %{})
|> Map.get(:merge_target, List.first(selected) || ""),
selected_section: selected_section
selected_section: selected_section,
colour_presets: @colour_presets
}
assign(socket, :tags_editor, data)

View File

@@ -38,7 +38,13 @@
<form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}>
<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"])} />
<input type="hidden" name="new_tag[color]" value={@tags_editor.new_tag["color"] || ""} />
<.colour_picker
color={@tags_editor.new_tag["color"]}
presets={@tags_editor.colour_presets}
pick_event="pick_new_tag_color"
target={@myself}
/>
<button class="primary ui-button ui-button-primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button>
</div>
</form>
@@ -47,7 +53,13 @@
<form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}>
<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"])} />
<input type="hidden" name="edit_tag[color]" value={@tags_editor.edit_draft["color"] || ""} />
<.colour_picker
color={@tags_editor.edit_draft["color"]}
presets={@tags_editor.colour_presets}
pick_event="pick_edit_tag_color"
target={@myself}
/>
<select class="ui-input" name="edit_tag[post_template_slug]">
<option value=""><%= dgettext("ui", "No Template") %></option>
<%= for template <- @tags_editor.templates do %>

View File

@@ -241,7 +241,8 @@ defmodule BDS.Desktop.ShellLiveTest do
edit_draft: %{"name" => "news", "color" => "#3b82f6", "post_template_slug" => ""},
selected: ["news", "updates"],
merge_target: "news",
templates: [%{slug: "post-template", title: "Post Template"}]
templates: [%{slug: "post-template", title: "Post Template"}],
colour_presets: BDS.Desktop.ShellLive.TagsEditor.colour_presets()
}
}
end

View File

@@ -229,6 +229,13 @@ defmodule BDS.TagsTest do
] = Jason.decode!(File.read!(tags_path))
end
test "colour_presets contains exactly 17 unique valid hex colours" do
presets = BDS.Desktop.ShellLive.TagsEditor.colour_presets()
assert length(presets) == 17
assert length(Enum.uniq(presets)) == 17
assert Enum.all?(presets, &Regex.match?(~r/^#[0-9a-fA-F]{6}$/, &1))
end
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->