fix: A1-9 replace native color input with 17-preset colour picker popover + custom hex
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
45
assets/js/hooks/colour_picker.js
Normal file
45
assets/js/hooks/colour_picker.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user