From 19dd970058a44b7564a7ecbf9f79dbd1a96d1f13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:30:45 +0000 Subject: [PATCH 001/170] Add a randomize-portrait button next to the Image URL input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing "Random Name" button pattern: one click fills ::char5e/image-url with a DiceBear seed-based avatar URL. Race is read off built-char and steers the DiceBear style (elf/half-elf → lorelei, dwarf/halfling/gnome → big-ears, everything else → adventurer) so the generated portrait flatters the character. Uses the PNG endpoint so PDFBox can embed the image in the exported sheet, and the URL matches the http(s) regex the PDF route already enforces. Co-Authored-By: Claude Opus 4.7 (1M context) Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- .../orcpub/dnd/e5/character/portrait.cljc | 42 +++++++++++++++++++ src/cljs/orcpub/character_builder.cljs | 14 ++++++- src/cljs/orcpub/dnd/e5/events.cljs | 11 +++++ .../dnd/e5/character/portrait_test.cljc | 41 ++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/cljc/orcpub/dnd/e5/character/portrait.cljc create mode 100644 test/cljc/orcpub/dnd/e5/character/portrait_test.cljc diff --git a/src/cljc/orcpub/dnd/e5/character/portrait.cljc b/src/cljc/orcpub/dnd/e5/character/portrait.cljc new file mode 100644 index 000000000..a3c7e8935 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/character/portrait.cljc @@ -0,0 +1,42 @@ +(ns orcpub.dnd.e5.character.portrait + "Pure helpers for the random-portrait ('jaunty') button. + + The randomize button fills ::char5e/image-url with a DiceBear seed-based + avatar URL. DiceBear is a public avatar service that returns a + deterministic image for a given (style, seed) pair, which keeps QA + reproducible and lets us pick a style that flatters the character's + race without shipping any binary assets. + + The URLs produced here match the http(s) regex used server-side in + orcpub.routes/pdf-response, and the PNG endpoint is used because + PDFBox does not render SVG." + (:require [clojure.string :as s])) + +(def dicebear-host "https://api.dicebear.com/9.x") + +(def default-style "adventurer") + +(def race->style + "Maps a race display-name to a DiceBear style whose look flatters that + race. Anything not listed falls back to `default-style`." + {"Elf" "lorelei" + "Half-Elf" "lorelei" + "Dwarf" "big-ears" + "Halfling" "big-ears" + "Gnome" "big-ears"}) + +(defn style-for-race [race-name] + (get race->style race-name default-style)) + +(defn random-seed [] + #?(:clj (str (java.util.UUID/randomUUID)) + :cljs (str (random-uuid)))) + +(defn random-portrait-url + "Build a DiceBear avatar URL for the given seed, optionally styled to + the character's race. Callers pass a fresh seed each click to get a + new portrait; passing the same seed always yields the same image." + ([seed] + (random-portrait-url seed nil)) + ([seed race-name] + (str dicebear-host "/" (style-for-race race-name) "/png?seed=" seed))) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 4027c1969..deb286bda 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1776,6 +1776,12 @@ (defn set-image-url [v] (dispatch [:set-image-url v])) +(defn set-random-portrait + "Dispatch random portrait generation. Passes built-char so the handler + can read race off it without subscribing outside reactive context." + [built-char] + (dispatch [::char5e/set-random-portrait built-char])) + (defn set-faction-image-url [v] (dispatch [:set-faction-image-url v])) @@ -1857,7 +1863,13 @@ :on-load (when image-url-failed image-loaded)}]) [:div.flex-grow-1 [:span.personality-label.f-s-18 "Image URL (128k max image size for PDF)"] - [character-input entity-values ::char5e/image-url nil set-image-url] + [:div.flex.align-items-c + [:div.flex-grow-1 + [character-input entity-values ::char5e/image-url nil set-image-url]] + [:button.form-button.p-10.m-t-5.m-l-5 + {:on-click #(set-random-portrait built-char) + :title "Randomize portrait"} + [:i.fa.fa-random.main-text-color.f-s-16]]] (when image-url-failed [:div.red.m-t-5 "Image failed to load, please check the URL"])]] [:div.field diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index d1d0a77e3..ef6e1e6bf 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -22,6 +22,7 @@ [orcpub.dnd.e5.party :as party5e] [orcpub.dnd.e5.folder :as folder5e] [orcpub.dnd.e5.character.random :as char-rand5e] + [orcpub.dnd.e5.character.portrait :as char-portrait5e] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.monsters :as monsters] [orcpub.dnd.e5.encounters :as encounters] @@ -1506,6 +1507,16 @@ (fn [_ [_ built-char]] {:dispatch [:update-value-field ::char5e/character-name (generate-random-name built-char)]})) + +;; Roll a fresh DiceBear seed each click; race is read off built-char so +;; the style flatters the character (elf → lorelei, dwarf → big-ears, ...). +(reg-event-fx + ::char5e/set-random-portrait + (fn [_ [_ built-char]] + {:dispatch [:set-image-url + (char-portrait5e/random-portrait-url + (char-portrait5e/random-seed) + (char5e/race built-char))]})) (reg-event-db :select-option character-interceptors diff --git a/test/cljc/orcpub/dnd/e5/character/portrait_test.cljc b/test/cljc/orcpub/dnd/e5/character/portrait_test.cljc new file mode 100644 index 000000000..44e56f2ff --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/character/portrait_test.cljc @@ -0,0 +1,41 @@ +(ns orcpub.dnd.e5.character.portrait-test + (:require [clojure.test :refer [deftest testing is]] + [orcpub.dnd.e5.character.portrait :as portrait])) + +;; Kept in sync with orcpub.routes/pdf-response — a generated URL that +;; fails this regex would silently drop out of the PDF export. +(def pdf-url-regex + #"^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]") + +(deftest style-for-race-defaults + (is (= "adventurer" (portrait/style-for-race nil))) + (is (= "adventurer" (portrait/style-for-race "Kobold"))) + (is (= "adventurer" (portrait/style-for-race ""))) + (is (= "adventurer" (portrait/style-for-race "Human")))) + +(deftest style-for-race-mapped + (is (= "lorelei" (portrait/style-for-race "Elf"))) + (is (= "lorelei" (portrait/style-for-race "Half-Elf"))) + (is (= "big-ears" (portrait/style-for-race "Dwarf"))) + (is (= "big-ears" (portrait/style-for-race "Halfling"))) + (is (= "big-ears" (portrait/style-for-race "Gnome")))) + +(deftest random-portrait-url-format + (testing "produced URL passes the server-side PDF regex" + (is (re-matches pdf-url-regex (portrait/random-portrait-url "abc123")))) + (testing "same seed → same URL" + (is (= (portrait/random-portrait-url "s1") + (portrait/random-portrait-url "s1")))) + (testing "race steers style" + (is (not= (portrait/random-portrait-url "s1" "Elf") + (portrait/random-portrait-url "s1" "Human")))) + (testing "unknown race falls back to default style" + (is (= (portrait/random-portrait-url "s1") + (portrait/random-portrait-url "s1" "Kobold")))) + (testing "PNG endpoint (PDFBox cannot render SVG)" + (is (re-find #"/png\?seed=" (portrait/random-portrait-url "s1"))))) + +(deftest random-seed-shape + (let [s (portrait/random-seed)] + (is (string? s)) + (is (pos? (count s))))) From fbcbef2ebc75423d6f71acc0cc226c0cbe2dfc3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:41:46 +0000 Subject: [PATCH 002/170] Revert "Add a randomize-portrait button next to the Image URL input" This reverts commit 19dd970058a44b7564a7ecbf9f79dbd1a96d1f13. --- .../orcpub/dnd/e5/character/portrait.cljc | 42 ------------------- src/cljs/orcpub/character_builder.cljs | 14 +------ src/cljs/orcpub/dnd/e5/events.cljs | 11 ----- .../dnd/e5/character/portrait_test.cljc | 41 ------------------ 4 files changed, 1 insertion(+), 107 deletions(-) delete mode 100644 src/cljc/orcpub/dnd/e5/character/portrait.cljc delete mode 100644 test/cljc/orcpub/dnd/e5/character/portrait_test.cljc diff --git a/src/cljc/orcpub/dnd/e5/character/portrait.cljc b/src/cljc/orcpub/dnd/e5/character/portrait.cljc deleted file mode 100644 index a3c7e8935..000000000 --- a/src/cljc/orcpub/dnd/e5/character/portrait.cljc +++ /dev/null @@ -1,42 +0,0 @@ -(ns orcpub.dnd.e5.character.portrait - "Pure helpers for the random-portrait ('jaunty') button. - - The randomize button fills ::char5e/image-url with a DiceBear seed-based - avatar URL. DiceBear is a public avatar service that returns a - deterministic image for a given (style, seed) pair, which keeps QA - reproducible and lets us pick a style that flatters the character's - race without shipping any binary assets. - - The URLs produced here match the http(s) regex used server-side in - orcpub.routes/pdf-response, and the PNG endpoint is used because - PDFBox does not render SVG." - (:require [clojure.string :as s])) - -(def dicebear-host "https://api.dicebear.com/9.x") - -(def default-style "adventurer") - -(def race->style - "Maps a race display-name to a DiceBear style whose look flatters that - race. Anything not listed falls back to `default-style`." - {"Elf" "lorelei" - "Half-Elf" "lorelei" - "Dwarf" "big-ears" - "Halfling" "big-ears" - "Gnome" "big-ears"}) - -(defn style-for-race [race-name] - (get race->style race-name default-style)) - -(defn random-seed [] - #?(:clj (str (java.util.UUID/randomUUID)) - :cljs (str (random-uuid)))) - -(defn random-portrait-url - "Build a DiceBear avatar URL for the given seed, optionally styled to - the character's race. Callers pass a fresh seed each click to get a - new portrait; passing the same seed always yields the same image." - ([seed] - (random-portrait-url seed nil)) - ([seed race-name] - (str dicebear-host "/" (style-for-race race-name) "/png?seed=" seed))) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index deb286bda..4027c1969 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -1776,12 +1776,6 @@ (defn set-image-url [v] (dispatch [:set-image-url v])) -(defn set-random-portrait - "Dispatch random portrait generation. Passes built-char so the handler - can read race off it without subscribing outside reactive context." - [built-char] - (dispatch [::char5e/set-random-portrait built-char])) - (defn set-faction-image-url [v] (dispatch [:set-faction-image-url v])) @@ -1863,13 +1857,7 @@ :on-load (when image-url-failed image-loaded)}]) [:div.flex-grow-1 [:span.personality-label.f-s-18 "Image URL (128k max image size for PDF)"] - [:div.flex.align-items-c - [:div.flex-grow-1 - [character-input entity-values ::char5e/image-url nil set-image-url]] - [:button.form-button.p-10.m-t-5.m-l-5 - {:on-click #(set-random-portrait built-char) - :title "Randomize portrait"} - [:i.fa.fa-random.main-text-color.f-s-16]]] + [character-input entity-values ::char5e/image-url nil set-image-url] (when image-url-failed [:div.red.m-t-5 "Image failed to load, please check the URL"])]] [:div.field diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index ef6e1e6bf..d1d0a77e3 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -22,7 +22,6 @@ [orcpub.dnd.e5.party :as party5e] [orcpub.dnd.e5.folder :as folder5e] [orcpub.dnd.e5.character.random :as char-rand5e] - [orcpub.dnd.e5.character.portrait :as char-portrait5e] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.monsters :as monsters] [orcpub.dnd.e5.encounters :as encounters] @@ -1507,16 +1506,6 @@ (fn [_ [_ built-char]] {:dispatch [:update-value-field ::char5e/character-name (generate-random-name built-char)]})) - -;; Roll a fresh DiceBear seed each click; race is read off built-char so -;; the style flatters the character (elf → lorelei, dwarf → big-ears, ...). -(reg-event-fx - ::char5e/set-random-portrait - (fn [_ [_ built-char]] - {:dispatch [:set-image-url - (char-portrait5e/random-portrait-url - (char-portrait5e/random-seed) - (char5e/race built-char))]})) (reg-event-db :select-option character-interceptors diff --git a/test/cljc/orcpub/dnd/e5/character/portrait_test.cljc b/test/cljc/orcpub/dnd/e5/character/portrait_test.cljc deleted file mode 100644 index 44e56f2ff..000000000 --- a/test/cljc/orcpub/dnd/e5/character/portrait_test.cljc +++ /dev/null @@ -1,41 +0,0 @@ -(ns orcpub.dnd.e5.character.portrait-test - (:require [clojure.test :refer [deftest testing is]] - [orcpub.dnd.e5.character.portrait :as portrait])) - -;; Kept in sync with orcpub.routes/pdf-response — a generated URL that -;; fails this regex would silently drop out of the PDF export. -(def pdf-url-regex - #"^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]") - -(deftest style-for-race-defaults - (is (= "adventurer" (portrait/style-for-race nil))) - (is (= "adventurer" (portrait/style-for-race "Kobold"))) - (is (= "adventurer" (portrait/style-for-race ""))) - (is (= "adventurer" (portrait/style-for-race "Human")))) - -(deftest style-for-race-mapped - (is (= "lorelei" (portrait/style-for-race "Elf"))) - (is (= "lorelei" (portrait/style-for-race "Half-Elf"))) - (is (= "big-ears" (portrait/style-for-race "Dwarf"))) - (is (= "big-ears" (portrait/style-for-race "Halfling"))) - (is (= "big-ears" (portrait/style-for-race "Gnome")))) - -(deftest random-portrait-url-format - (testing "produced URL passes the server-side PDF regex" - (is (re-matches pdf-url-regex (portrait/random-portrait-url "abc123")))) - (testing "same seed → same URL" - (is (= (portrait/random-portrait-url "s1") - (portrait/random-portrait-url "s1")))) - (testing "race steers style" - (is (not= (portrait/random-portrait-url "s1" "Elf") - (portrait/random-portrait-url "s1" "Human")))) - (testing "unknown race falls back to default style" - (is (= (portrait/random-portrait-url "s1") - (portrait/random-portrait-url "s1" "Kobold")))) - (testing "PNG endpoint (PDFBox cannot render SVG)" - (is (re-find #"/png\?seed=" (portrait/random-portrait-url "s1"))))) - -(deftest random-seed-shape - (let [s (portrait/random-seed)] - (is (string? s)) - (is (pos? (count s))))) From 9e15b450c4320a90e98f445475a4ec7d375d0ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:00:45 +0000 Subject: [PATCH 003/170] Add the paper-doll portrait compositor (v1 MVP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users open a right-edge slide-in drawer from a "Compose portrait" button next to the Image URL input, pick a layer per category from swatch grids, or roll a seeded random composition, and Save writes the layer selection back to the character as ::char/portrait-layers. The character summary renders the composite in place of the pasted URL when layers are present, falling back to image-url otherwise (existing behavior preserved). MVP scope: one launcher (the button), one adaptive drawer (portrait-left on desktop / portrait-top on mobile via a single 700px breakpoint), 10 layers matching the illustrator's real folder taxonomy (hair bits → bangs), inline SVG placeholder art per layer so the feature is functional today. Real illustrator PNGs plug in by swapping :asset/url values in the registry — no code change. Portrait tab and summary-pencil entry points are deferred to a follow-up. Data model: ::char/portrait-layers rides ::entity/values (Transit-encoded), no Datomic schema patch needed for MVP. Compositor + summary render the composite for the currently-loaded character; list/summary cards for other characters continue to use image-url. Co-Authored-By: Claude Opus 4.7 (1M context) Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- src/cljc/orcpub/dnd/e5/character.cljc | 3 + src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 243 +++++++++++++ src/cljs/orcpub/character_builder.cljs | 10 +- src/cljs/orcpub/dnd/e5/events.cljs | 56 +++ src/cljs/orcpub/dnd/e5/portrait.cljs | 377 ++++++++++++++++++++ src/cljs/orcpub/dnd/e5/subs.cljs | 14 + src/cljs/orcpub/dnd/e5/views.cljs | 17 +- test/cljc/orcpub/dnd/e5/portrait_test.cljc | 95 +++++ 8 files changed, 810 insertions(+), 5 deletions(-) create mode 100644 src/cljc/orcpub/dnd/e5/portrait_assets.cljc create mode 100644 src/cljs/orcpub/dnd/e5/portrait.cljs create mode 100644 test/cljc/orcpub/dnd/e5/portrait_test.cljc diff --git a/src/cljc/orcpub/dnd/e5/character.cljc b/src/cljc/orcpub/dnd/e5/character.cljc index f7de74edb..7b0ed007b 100644 --- a/src/cljc/orcpub/dnd/e5/character.cljc +++ b/src/cljc/orcpub/dnd/e5/character.cljc @@ -681,6 +681,9 @@ (defn image-url [built-char] (get-prop built-char ::image-url)) +(defn portrait-layers [built-char] + (get-prop built-char ::portrait-layers)) + (defn faction-image-url [built-char] (get-prop built-char ::faction-image-url)) diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc new file mode 100644 index 000000000..3d1ba8e71 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -0,0 +1,243 @@ +(ns orcpub.dnd.e5.portrait-assets + "Data-only registry of paper-doll portrait layers, per contributing artist. + + The compositor stacks assets in fixed z-order (`layer-order` below, + bottom → top). Every asset is `{:asset/id … :asset/url …}` — the + compositor renders per selected layer, absolute-positioned + in a common frame. For the MVP the placeholder pack ships inline SVG + data URIs so the feature is functional today; when the real illustrator + commits PNGs under resources/public/image/portraits/, add or replace + entries in `registry` with `{:asset/url \"/image/portraits/…\"}` — no + other change needed. + + Attribution reads directly off this registry: for a composed set of + layers, look up each layer's asset by id, find the artist whose + library contains that asset, list them once with :artist/name and + :artist/link. See `artist-for-asset` and `all-artists-for-layers`." + (:require [clojure.string :as s])) + +(def layer-order + "Layer keys in z-order, bottom (0) → top (9). Mirrors the illustrator's + real folder convention (`layer 0 - hair bits`, `layer 9 - bangs`, …)." + [:hair-bits :hair-back :head :shirt :hair-front :ears :eyes :nose :mouth :bangs]) + +(def layer-labels + {:hair-bits "Hair bits" + :hair-back "Hair back" + :head "Head" + :shirt "Shirt" + :hair-front "Hair front" + :ears "Ears" + :eyes "Eyes" + :nose "Nose" + :mouth "Mouth" + :bangs "Bangs"}) + +(def layer-colors + "Category tint per layer — hair family in amber, features warm flesh + with cool eyes and clay-red mouth, shirt in slate blue. Used in the + picker chrome; the actual composited art of course uses whatever hues + the illustrator drew." + {:hair-bits "#e0a24d" + :hair-back "#c88a4a" + :head "#f2e6d0" + :shirt "#7a94b8" + :hair-front "#e6a040" + :ears "#eab098" + :eyes "#78d0d4" + :nose "#d67c5c" + :mouth "#c85c5c" + :bangs "#f5c46b"}) + +;; ---------- placeholder SVG art (MVP) ---------- + +(defn- svg-uri + "Wrap raw SVG markup as a utf-8 data URI. Only characters that require + escaping in this context are `#` (encoded as `%23`) and the URL-reserved + set — the paths below deliberately avoid the latter." + [markup] + (str "data:image/svg+xml;utf8," + (s/replace markup "#" "%23"))) + +(defn- svg-shape + "Build an SVG placeholder shape at the compositor's common 400x500 + viewBox, filled with the layer's category tint." + [layer-key path-d] + (svg-uri + (str "" + "" + ""))) + +;; Per-layer placeholder paths. Real art will drop into the same layer +;; slots and stack in the same order — the shapes here just occupy the +;; regions the real assets will occupy. +(def ^:private placeholder-shapes + {:hair-bits [{:id :hair-bits-fringe + :label "Fringe" + :d "M120,180 Q200,140 280,180 L280,192 Q200,150 120,192 Z"}] + :hair-back [{:id :hair-back-full + :label "Full" + :d "M110,190 Q110,110 200,100 Q290,110 290,190 L306,340 L94,340 Z"} + {:id :hair-back-short + :label "Short" + :d "M120,190 Q120,120 200,110 Q280,120 280,190 L280,260 L120,260 Z"}] + :head [{:id :head-oval + :label "Oval" + :d "M200,110 C260,110 285,155 285,205 C285,265 250,300 200,300 C150,300 115,265 115,205 C115,155 140,110 200,110 Z M175,290 L175,330 L225,330 L225,290 Z"} + {:id :head-round + :label "Round" + :d "M200,110 Q285,110 285,215 Q285,300 200,300 Q115,300 115,215 Q115,110 200,110 Z M175,290 L175,335 L225,335 L225,290 Z"}] + :shirt [{:id :shirt-tunic + :label "Tunic" + :d "M130,362 Q100,430 76,500 L324,500 Q300,430 268,362 L235,352 L200,378 L165,352 Z"} + {:id :shirt-cloak + :label "Cloak" + :d "M120,360 Q80,410 60,500 L340,500 Q320,410 280,360 L240,340 L200,360 L160,340 Z"}] + :hair-front [{:id :hair-front-swept + :label "Swept" + :d "M118,190 Q108,150 128,124 L200,148 L272,124 Q292,150 282,192 L268,182 Q220,192 200,184 Q180,192 132,182 Z"}] + :ears [{:id :ears-small + :label "Small" + :d "M105,220 Q95,235 102,258 Q112,266 122,258 L122,220 Z M295,220 Q305,235 298,258 Q288,266 278,258 L278,220 Z"}] + :eyes [{:id :eyes-focused + :label "Focused" + :d "M154,196 Q172,182 190,196 M210,196 Q228,182 246,196 M170,200 L174,200 M226,200 L230,200"} + {:id :eyes-wide + :label "Wide" + :d "M154,200 Q172,188 190,200 Q172,212 154,200 Z M210,200 Q228,188 246,200 Q228,212 210,200 Z"}] + :nose [{:id :nose-slim + :label "Slim" + :d "M198,214 L196,246 Q200,254 204,246 L202,214 Z"}] + :mouth [{:id :mouth-small + :label "Small" + :d "M184,268 Q200,282 216,268"}] + :bangs [{:id :bangs-side-swept + :label "Side-swept" + :d "M116,178 Q136,132 200,132 Q188,150 168,158 Q142,170 116,190 Z"}]}) + +(def placeholder-pack + {:artist/id :placeholder-pack + :artist/name "Placeholder Pack" + :artist/link nil + :artist/license "CC0 — geometric MVP stand-ins" + :artist/layers + (reduce-kv + (fn [m layer-key shapes] + (assoc m layer-key + (mapv (fn [{:keys [id label d]}] + {:asset/id id + :asset/label label + :asset/url (svg-shape layer-key d) + :asset/tags #{:placeholder layer-key}}) + shapes))) + {} + placeholder-shapes)}) + +(def registry + "Contributing artists. Later entries layer on top of earlier ones — + pickers show every artist's assets for a category, in registry order." + [placeholder-pack]) + +;; ---------- lookup helpers ---------- + +(defn assets-for-layer + "All assets contributed for `layer-key` across every registered artist." + [layer-key] + (into [] (mapcat #(get-in % [:artist/layers layer-key])) registry)) + +(defn asset-count-for-layer [layer-key] + (count (assets-for-layer layer-key))) + +(defn asset-by-id + "The `{:asset/id … :asset/url … …}` map for a given asset id in `layer-key`, + or nil if not found." + [layer-key asset-id] + (some #(when (= asset-id (:asset/id %)) %) (assets-for-layer layer-key))) + +(defn artist-for-asset + "The `:artist/id` whose library contains `asset-id` in `layer-key`, + or nil." + [layer-key asset-id] + (some (fn [{:keys [:artist/id :artist/layers]}] + (when (some #(= asset-id (:asset/id %)) + (get layers layer-key)) + id)) + registry)) + +(defn artist-info + "Full `{:artist/id … :artist/name … :artist/link …}` map for an id." + [artist-id] + (some #(when (= artist-id (:artist/id %)) %) registry)) + +(defn all-artists-for-layers + "Given the current portrait-layers selection (`{layer-key {:artist/id … + :asset/id …}}`), return the DISTINCT artist ids currently on canvas, + in registry order — the shape the attribution surface renders." + [layers-selection] + (let [in-use? (into #{} + (keep (fn [[_ {:keys [:artist/id]}]] id)) + layers-selection)] + (into [] + (comp (map :artist/id) (filter in-use?)) + registry))) + +;; ---------- pure helpers for seeded randomization ---------- +;; +;; Lives here (cljc) rather than in portrait.cljs so JVM tests can reach it. +;; All bit-ops used are cljc-safe. + +(defn seed->int + "FNV-1a-flavored string hash. Same seed always resolves to the same + integer, in both Clojure and ClojureScript." + [seed] + (reduce (fn [h c] + (bit-and 0xffffffff + (unchecked-multiply (bit-xor h (int c)) 16777619))) + 2166136261 + (str seed))) + +(defn mulberry32 + "Small stateless PRNG. `(mulberry32 seed-int)` returns a fn that yields + a fresh number in [0,1) on each call." + [seed-int] + (let [a (atom (bit-and 0xffffffff seed-int))] + (fn [] + (swap! a #(bit-and 0xffffffff (unchecked-add % 0x6D2B79F5))) + (let [s @a + t1 (bit-and 0xffffffff + (unchecked-multiply (bit-xor s (unsigned-bit-shift-right s 15)) + (bit-or s 1))) + t2 (bit-and 0xffffffff + (unchecked-add t1 + (unchecked-multiply (bit-xor t1 (unsigned-bit-shift-right t1 7)) + (bit-or t1 61))))] + (/ (unsigned-bit-shift-right (bit-xor t2 (unsigned-bit-shift-right t2 14)) 0) + 4294967296.0))))) + +(defn random-seed + "Fresh 8-char alphanumeric seed. Uses (rand-int) which is available on + both platforms." + [] + (let [alpha "abcdefghjkmnpqrstuvwxyz23456789" + n (count alpha)] + (apply str (repeatedly 8 #(nth alpha (rand-int n)))))) + +(defn compose-for-seed + "Deterministic — feed a seed, get a `{layer-key {:artist/id … :asset/id …}}` + map covering every layer whose registry has at least one asset. Layers + with an empty registry are skipped." + [seed] + (let [rand-fn (mulberry32 (seed->int seed))] + (reduce + (fn [acc layer-key] + (let [assets (assets-for-layer layer-key)] + (if (empty? assets) + acc + (let [asset (nth assets (int (Math/floor (* (rand-fn) (count assets))))) + artist-id (artist-for-asset layer-key (:asset/id asset))] + (assoc acc layer-key {:artist/id artist-id :asset/id (:asset/id asset)}))))) + {} + layer-order))) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 4027c1969..75b7c93d7 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -30,6 +30,7 @@ [orcpub.dnd.e5.armor :as armor5e] [orcpub.dnd.e5.magic-items :as mi5e] [orcpub.dnd.e5.display :as disp5e] + [orcpub.dnd.e5.portrait :as portrait5e] [orcpub.dnd.e5.equipment :as equip5e] [orcpub.dnd.e5.skills :as skill5e] [orcpub.dnd.e5.events :as events5e] @@ -1859,7 +1860,8 @@ [:span.personality-label.f-s-18 "Image URL (128k max image size for PDF)"] [character-input entity-values ::char5e/image-url nil set-image-url] (when image-url-failed - [:div.red.m-t-5 "Image failed to load, please check the URL"])]] + [:div.red.m-t-5 "Image failed to load, please check the URL"]) + [portrait5e/launcher-button]]] [:div.field [:span.personality-label.f-s-18 "Faction Name"] [character-input entity-values ::char5e/faction-name]] @@ -2126,7 +2128,9 @@ (not= db/default-character character))] (when print-enabled? (print-char built-char)) (when (not character-changed?) (js/window.scrollTo 0,0)) ;//Force a scroll to top of page only if we are not editing. - [views5e/content-page + [:<> + [portrait5e/drawer] + [views5e/content-page "Character Builder" (into (if character-id @@ -2183,4 +2187,4 @@ [:div.flex.justify-cont-c.p-b-40 [:div.f-s-14.main-text-color.content [:div.flex.w-100-p - [builder-columns]]]]]])) + [builder-columns]]]]]]])) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index d1d0a77e3..36d7b5e43 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -22,6 +22,8 @@ [orcpub.dnd.e5.party :as party5e] [orcpub.dnd.e5.folder :as folder5e] [orcpub.dnd.e5.character.random :as char-rand5e] + [orcpub.dnd.e5.portrait :as portrait5e] + [orcpub.dnd.e5.portrait-assets :as portrait-assets5e] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.monsters :as monsters] [orcpub.dnd.e5.encounters :as encounters] @@ -1535,6 +1537,60 @@ ::char5e/image-url-failed nil))) +;; ---- portrait compositor drawer ---- +;; +;; The drawer's editing state (open/closed, in-progress layer picks, the +;; last-used random seed) lives at the TOP of app-db under :portrait/* +;; keys — it's UI state, not part of the persisted character. Only the +;; :portrait/save handler writes back to the character's +;; ::char5e/portrait-layers value. + +(reg-event-db + :portrait/open + (fn [db _] + (let [current (get-in db [:character ::entity/values ::char5e/portrait-layers])] + (assoc db + :portrait/drawer-open? true + :portrait/draft (or current {}) + :portrait/draft-seed nil)))) + +(reg-event-db + :portrait/close + (fn [db _] + (dissoc db :portrait/drawer-open? :portrait/draft :portrait/draft-seed))) + +(reg-event-db + :portrait/pick-layer + (fn [db [_ layer-key asset-id]] + (update db :portrait/draft + (fn [d] + (if (nil? asset-id) + (dissoc d layer-key) + (let [artist-id (portrait-assets5e/artist-for-asset layer-key asset-id)] + (assoc d layer-key {:artist/id artist-id :asset/id asset-id}))))))) + +(reg-event-db + :portrait/randomize + (fn [db _] + (let [seed (portrait5e/random-seed)] + (assoc db + :portrait/draft-seed seed + :portrait/draft (portrait5e/compose-for-seed seed))))) + +(reg-event-db + :portrait/reset + (fn [db _] + (assoc db :portrait/draft {} :portrait/draft-seed nil))) + +(reg-event-db + :portrait/save + [db-char->local-store] + (fn [db _] + (let [draft (get db :portrait/draft {})] + (-> db + (assoc-in [:character ::entity/values ::char5e/portrait-layers] draft) + (dissoc :portrait/drawer-open? :portrait/draft :portrait/draft-seed))))) + #_ ;; never dispatched from UI (reg-event-db :toggle-public diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs new file mode 100644 index 000000000..66bb88378 --- /dev/null +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -0,0 +1,377 @@ +(ns orcpub.dnd.e5.portrait + "Paper-doll character-portrait compositor. + + The drawer is opened by dispatching `[:portrait/open]`. It reads + `::char5e/portrait-layers` off the current character to seed a draft + in app-db under `:portrait/draft`; every pick / randomize / reset + mutates the draft. On Save the draft is committed back to + `::char5e/portrait-layers` and the drawer closes. On Cancel the + draft is discarded. + + Rendering: layers stack in `portrait-assets/layer-order` as absolute- + positioned s. Same composite is reused wherever a character's + portrait is shown — see `composite` below, called from + `character-summary-2`." + (:require [re-frame.core :refer [dispatch subscribe]] + [orcpub.dnd.e5.portrait-assets :as pa] + [orcpub.dnd.e5.character :as char5e])) + +;; Pure helpers live in portrait-assets (cljc) so JVM tests can reach them. +(def random-seed pa/random-seed) +(def compose-for-seed pa/compose-for-seed) + +;; ---------------- portrait composite (used by drawer AND summary) ---------------- + +(defn composite + "Reagent component: stacked-layer portrait. `layers` is the same shape as + `::char5e/portrait-layers` — `{layer-key {:artist/id … :asset/id …}}`. + Renders inside whatever container the caller provides — sizing/framing + is up to the caller (inline width/height or CSS class). + + `attrs` (optional) merges into the outer
's props so callers can + set className, style, etc." + ([layers] (composite layers nil)) + ([layers attrs] + [:div.portrait-composite + (merge {:style {:position "relative" :width "100%" :height "100%"}} + attrs) + (map-indexed + (fn [z layer-key] + (when-let [asset (some->> (get layers layer-key) + :asset/id + (pa/asset-by-id layer-key))] + ^{:key layer-key} + [:img.portrait-layer + {:src (:asset/url asset) + :style {:position "absolute" :inset 0 + :width "100%" :height "100%" + :object-fit "contain" + :z-index z + :pointer-events "none"}}])) + pa/layer-order)])) + +;; ---------------- drawer chrome ---------------- + +(def drawer-styles " +.pl-backdrop { + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 9998; + animation: pl-fade 160ms ease-out; +} +.pl-drawer { + position: fixed; top: 0; right: 0; bottom: 0; + width: 560px; max-width: 100vw; + background: #131924; + border-left: 1px solid rgba(240, 161, 0, 0.16); + box-shadow: -30px 0 60px -20px rgba(0, 0, 0, 0.6); + display: flex; flex-direction: column; + z-index: 9999; + animation: pl-slide 220ms cubic-bezier(.22, .8, .36, 1); + font-family: 'Open Sans', system-ui, sans-serif; + color: #ebeef4; +} +@keyframes pl-fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes pl-slide { from { transform: translateX(32px); opacity: 0.6; } to { transform: none; opacity: 1; } } + +.pl-drawer-head { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid rgba(255,255,255,0.06); + background: #0e131a; + flex-shrink: 0; +} +.pl-drawer-title { font: 400 18px/1 Georgia, serif; } +.pl-drawer-title-rune { color: #f0a100; font-style: italic; padding-right: 5px; } +.pl-drawer-close { + display: grid; place-items: center; + width: 36px; height: 36px; border-radius: 8px; + background: transparent; border: 1px solid rgba(255,255,255,0.06); + color: #8b95a5; cursor: pointer; + font-size: 16px; line-height: 1; +} +.pl-drawer-close:hover { color: #ffcc5e; border-color: #f0a100; } + +.pl-drawer-body { + flex: 1; overflow: hidden; + display: grid; grid-template-columns: 240px 1fr; +} +@media (max-width: 700px) { + .pl-drawer { width: 100vw; } + .pl-drawer-body { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } + .pl-canvas-side { border-right: none !important; border-bottom: 1px solid rgba(255,255,255,0.06); } + .pl-portrait-frame { width: 200px !important; height: 260px !important; } +} + +.pl-canvas-side { + background: #0e131a; + border-right: 1px solid rgba(255,255,255,0.06); + padding: 16px; + display: flex; flex-direction: column; gap: 10px; +} +.pl-portrait-frame { + width: 208px; height: 260px; + background: radial-gradient(circle at 50% 35%, #202939, #131924 60%, #0f141c); + border: 1px solid rgba(240, 161, 0, 0.16); border-radius: 10px; + align-self: center; position: relative; overflow: hidden; +} +.pl-empty-hint { + position: absolute; inset: 0; + display: grid; place-content: center; + color: #616a7a; font: italic 12px/1.5 Georgia, serif; + text-align: center; padding: 10px; +} +.pl-toolbar { display: flex; flex-direction: column; gap: 8px; } +.pl-btn { + display: inline-flex; align-items: center; justify-content: center; + gap: 7px; padding: 10px 14px; border-radius: 8px; + border: 1px solid transparent; background: transparent; + color: #ebeef4; font: 500 13px/1 inherit; + cursor: pointer; touch-action: manipulation; + min-height: 44px; +} +.pl-btn:focus-visible { outline: 2px solid #ffcc5e; outline-offset: 2px; } +.pl-btn-primary { + background: linear-gradient(to bottom, #f0a100, #d38a00); + color: #15202e; font-weight: 700; + border-color: #b57500; + box-shadow: 0 6px 14px -8px rgba(240,161,0,0.32); +} +.pl-btn-primary:hover { filter: brightness(1.06); } +.pl-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.pl-btn-ghost { border-color: rgba(255,255,255,0.10); color: #8b95a5; } +.pl-btn-ghost:hover { border-color: #f0a100; color: #ffcc5e; } +.pl-seed-row { + display: flex; align-items: center; justify-content: space-between; + color: #616a7a; font: 500 11px/1 inherit; letter-spacing: 0.06em; text-transform: uppercase; + margin-top: 4px; +} +.pl-seed-row code { + font: 500 12px/1 ui-monospace, Menlo, monospace; color: #ffcc5e; + background: rgba(240,161,0,0.08); border: 1px solid rgba(240,161,0,0.16); + padding: 3px 7px; border-radius: 4px; text-transform: none; letter-spacing: 0.02em; +} + +.pl-pickers-side { + overflow-y: auto; padding: 12px 14px; + display: flex; flex-direction: column; gap: 8px; + background: #131924; +} +.pl-picker { + background: #171e29; border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; padding: 10px 12px; +} +.pl-picker-head { + display: flex; align-items: baseline; justify-content: space-between; gap: 8px; + margin-bottom: 8px; + position: sticky; top: -1px; + background: #171e29; + z-index: 2; +} +.pl-cat { + display: inline-flex; align-items: center; gap: 6px; + font: 700 12px/1 inherit; color: #ebeef4; +} +.pl-cat-chip { + width: 10px; height: 10px; border-radius: 2.5px; + box-shadow: 0 0 0 1px rgba(255,255,255,0.08); +} +.pl-cat-z { + font: 500 9.5px/1 ui-monospace, Menlo, monospace; + color: #616a7a; + padding: 2px 5px; background: #131924; border-radius: 3px; + letter-spacing: 0.04em; +} +.pl-picker-sel { + font: italic 12px/1 Georgia, serif; color: #8b95a5; + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 180px; +} +.pl-swatches { + display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 5px; +} +.pl-sw { + aspect-ratio: 1; border-radius: 6px; + background: #131924; + border: 1px solid rgba(255,255,255,0.05); + display: grid; place-items: center; overflow: hidden; + cursor: pointer; padding: 0; + touch-action: manipulation; +} +.pl-sw:focus-visible { outline: 2px solid #ffcc5e; outline-offset: 2px; } +.pl-sw:hover { border-color: #f0a100; } +.pl-sw.selected { + border-color: #f0a100; + box-shadow: 0 0 0 2px rgba(240,161,0,0.32); + background: #1e2635; +} +.pl-sw img { + width: 100%; height: 100%; + object-fit: contain; + pointer-events: none; +} +.pl-sw-none { + background: repeating-linear-gradient(45deg, #131924 0 5px, transparent 5px 10px); + color: #616a7a; font: italic 10px/1 Georgia, serif; +} +.pl-empty-registry { + padding: 8px; color: #616a7a; + font: italic 11px/1.4 Georgia, serif; text-align: center; +} + +.pl-drawer-foot { + padding: 12px 18px 16px; + border-top: 1px solid rgba(255,255,255,0.06); + background: #0e131a; + display: flex; flex-direction: column; gap: 10px; + flex-shrink: 0; +} +.pl-attribution { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; + font-size: 12px; +} +.pl-attribution-label { + font: 700 10px/1 inherit; letter-spacing: 0.18em; text-transform: uppercase; + color: #616a7a; +} +.pl-attribution-empty { + color: #616a7a; font-style: italic; font-family: Georgia, serif; +} +.pl-artist { display: inline-flex; align-items: baseline; gap: 5px; } +.pl-artist-swirl { color: #f0a100; font-family: Georgia, serif; font-style: italic; } +.pl-artist a, .pl-artist span.pl-artist-name { + color: #ebeef4; text-decoration: none; + border-bottom: 1px dotted rgba(240,161,0,0.32); + padding-bottom: 1px; + font: italic 13px/1 Georgia, serif; +} +.pl-artist a:hover { color: #ffcc5e; border-bottom-color: #f0a100; } +.pl-drawer-actions { + display: flex; justify-content: space-between; align-items: center; gap: 10px; +} + +/* the launcher button that sits next to the Image URL input in the builder */ +.pl-launcher { + display: inline-flex; align-items: center; gap: 7px; + padding: 9px 14px; border-radius: 8px; + background: linear-gradient(to bottom, #f0a100, #d38a00); + color: #15202e; + font: 700 13px/1 'Open Sans', system-ui, sans-serif; + border: 1px solid #b57500; + box-shadow: 0 6px 14px -8px rgba(240,161,0,0.32); + cursor: pointer; + margin-top: 5px; +} +.pl-launcher:hover { filter: brightness(1.06); } +") + +(defn- category-picker [layer-key] + (let [draft @(subscribe [:portrait/draft]) + selected (get draft layer-key) + selected-asset (when selected (pa/asset-by-id layer-key (:asset/id selected))) + assets (pa/assets-for-layer layer-key) + z (.indexOf pa/layer-order layer-key)] + [:div.pl-picker + [:div.pl-picker-head + [:span.pl-cat + [:span.pl-cat-chip {:style {:background (pa/layer-colors layer-key)}}] + (pa/layer-labels layer-key) + [:span.pl-cat-z (str "z" (when (< z 10) "0") z)]] + (when selected + [:span.pl-picker-sel (or (:asset/label selected-asset) "picked")])] + (if (empty? assets) + [:div.pl-empty-registry "no art here yet"] + [:div.pl-swatches + [:button.pl-sw.pl-sw-none + {:class (when-not selected "selected") + :on-click #(dispatch [:portrait/pick-layer layer-key nil]) + :title "None"} + "none"] + (for [asset assets + :let [asset-id (:asset/id asset) + is-selected? (= asset-id (:asset/id selected))]] + ^{:key asset-id} + [:button.pl-sw + {:class (when is-selected? "selected") + :on-click #(dispatch [:portrait/pick-layer layer-key asset-id]) + :title (or (:asset/label asset) (name asset-id))} + [:img {:src (:asset/url asset) + :alt (or (:asset/label asset) "")}]])])])) + +(defn- attribution [draft] + (let [artist-ids (pa/all-artists-for-layers draft)] + [:div.pl-attribution + [:span.pl-attribution-label "Art by"] + (if (seq artist-ids) + (for [aid artist-ids + :let [info (pa/artist-info aid)]] + ^{:key aid} + [:span.pl-artist + [:span.pl-artist-swirl "§"] + (if (:artist/link info) + [:a {:href (:artist/link info) :target "_blank" :rel "noopener"} + (:artist/name info)] + [:span.pl-artist-name (:artist/name info)])]) + [:span.pl-attribution-empty "no layers selected yet"])])) + +(defn drawer + "Renders the compositor drawer when `:portrait/drawer-open?` is truthy. + Mount once at the character-builder root; the drawer overlays." + [] + (when @(subscribe [:portrait/drawer-open?]) + (let [draft @(subscribe [:portrait/draft]) + seed @(subscribe [:portrait/draft-seed]) + any? (seq draft)] + [:div + [:style drawer-styles] + [:div.pl-backdrop + {:on-click #(dispatch [:portrait/close])}] + [:div.pl-drawer + [:div.pl-drawer-head + [:div.pl-drawer-title + [:span.pl-drawer-title-rune "§"] "Compose portrait"] + [:button.pl-drawer-close + {:on-click #(dispatch [:portrait/close]) + :aria-label "Close portrait compositor"} + "✕"]] + [:div.pl-drawer-body + [:div.pl-canvas-side + [:div.pl-portrait-frame + (if any? + [composite draft] + [:div.pl-empty-hint + "Pick a layer below, or hit " [:em "Randomize"] "."])] + [:div.pl-toolbar + [:button.pl-btn.pl-btn-primary + {:on-click #(dispatch [:portrait/randomize])} + "🎲 Randomize"] + [:button.pl-btn.pl-btn-ghost + {:on-click #(dispatch [:portrait/reset]) + :disabled (not any?)} + "Reset"] + (when seed + [:div.pl-seed-row + [:span "seed"] + [:code seed]])]] + [:div.pl-pickers-side + (for [layer-key pa/layer-order] + ^{:key layer-key} + [category-picker layer-key])]] + [:div.pl-drawer-foot + [attribution draft] + [:div.pl-drawer-actions + [:button.pl-btn.pl-btn-ghost + {:on-click #(dispatch [:portrait/close])} + "Cancel"] + [:button.pl-btn.pl-btn-primary + {:on-click #(dispatch [:portrait/save])} + "Save portrait"]]]]]))) + +(defn launcher-button + "The 'Compose portrait' button that slots next to the Image URL input + in the character-builder Description tab." + [] + [:button.pl-launcher + {:type "button" + :on-click #(dispatch [:portrait/open]) + :title "Compose portrait from layered art"} + "Compose portrait"]) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index df7b0c5fe..c697c1c8e 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -80,6 +80,19 @@ (fn [db [_]] (get db :temp-email))) +;; ---- portrait compositor drawer state (top-level app-db) ---- +(reg-sub + :portrait/drawer-open? + (fn [db _] (boolean (get db :portrait/drawer-open?)))) + +(reg-sub + :portrait/draft + (fn [db _] (get db :portrait/draft {}))) + +(reg-sub + :portrait/draft-seed + (fn [db _] (get db :portrait/draft-seed))) + (reg-sub :locked (fn [db [_ path]] @@ -707,6 +720,7 @@ ::char5e/actions char5e/actions ::char5e/image-url char5e/image-url ::char5e/image-url-failed char5e/image-url-failed + ::char5e/portrait-layers char5e/portrait-layers ::char5e/faction-image-url char5e/faction-image-url ::char5e/faction-image-url-failed char5e/faction-image-url-failed ::char5e/personality-trait-1 char5e/personality-trait-1 diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 39d03cbac..9e589a31c 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -24,6 +24,7 @@ [orcpub.dnd.e5.folder :as folder] [orcpub.dnd.e5.character.random :as char-random] [orcpub.dnd.e5.character.equipment :as char-equip] + [orcpub.dnd.e5.portrait :as portrait] [orcpub.registration :as registration] [orcpub.dnd.e5 :as e5] [orcpub.dnd.e5.magic-items :as mi] @@ -1666,6 +1667,7 @@ (defn character-summary-2 [{:keys [::char/character-name ::char/image-url + ::char/portrait-layers ::char/race-name ::char/subrace-name ::char/age @@ -1687,8 +1689,17 @@ display-name (when include-name? (character-display-name summary))] [:div.flex.justify-cont-s-b.w-100-p.align-items-c [:div.flex.align-items-c.align-items-t - (when image-url - [:img.m-r-20.m-t-10.m-b-10.image-character-thumbnail {:src image-url }]) + ;; portrait-layers wins over image-url when both are present; falls back + ;; to the pasted URL, then to nothing (existing behavior). + (cond + (seq portrait-layers) + [:div.m-r-20.m-t-10.m-b-10.image-character-thumbnail + {:style {:position "relative" :width "100px" :height "125px" :flex-shrink 0}} + [portrait/composite portrait-layers + {:style {:position "absolute" :inset 0 :width "100%" :height "100%"}}]] + + image-url + [:img.m-r-20.m-t-10.m-b-10.image-character-thumbnail {:src image-url}]) [:div.flex.character-summary.m-t-20.m-b-20 (when display-name [:span.m-r-20.m-b-5 [:span.character-name display-name] @@ -1733,6 +1744,7 @@ eyes @(subscribe [::char/eyes id]) skin @(subscribe [::char/skin id]) image-url @(subscribe [::char/image-url id]) + portrait-layers @(subscribe [::char/portrait-layers id]) race @(subscribe [::char/race id]) subrace @(subscribe [::char/subrace id]) levels @(subscribe [::char/levels id]) @@ -1750,6 +1762,7 @@ ::char/eyes eyes ::char/skin skin ::char/image-url image-url + ::char/portrait-layers portrait-layers ::char/race-name race ::char/subrace-name subrace ::char/alignment alignment diff --git a/test/cljc/orcpub/dnd/e5/portrait_test.cljc b/test/cljc/orcpub/dnd/e5/portrait_test.cljc new file mode 100644 index 000000000..7689e3a45 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/portrait_test.cljc @@ -0,0 +1,95 @@ +(ns orcpub.dnd.e5.portrait-test + "Pure-fn tests for the paper-doll compositor. Everything here runs on + both JVM (via lein test) and cljs — no DOM, no re-frame." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.dnd.e5.portrait-assets :as pa])) + +;; ---------- portrait-assets registry ---------- + +(deftest layer-order-matches-taxonomy + (testing "10 layers in illustrator's real z-order" + (is (= [:hair-bits :hair-back :head :shirt :hair-front + :ears :eyes :nose :mouth :bangs] + pa/layer-order)) + (is (= (count pa/layer-order) (count pa/layer-labels))) + (is (= (count pa/layer-order) (count pa/layer-colors))))) + +(deftest every-layer-has-at-least-one-placeholder-asset + (testing "the MVP feature is functional out of the box" + (doseq [layer-key pa/layer-order] + (is (pos? (pa/asset-count-for-layer layer-key)) + (str "layer " layer-key " has no placeholder assets"))))) + +(deftest asset-by-id-round-trips + (testing "every placeholder asset can be looked up by id" + (doseq [layer-key pa/layer-order + asset (pa/assets-for-layer layer-key)] + (is (= asset (pa/asset-by-id layer-key (:asset/id asset))) + (str "round-trip failed for " layer-key "/" (:asset/id asset)))))) + +(deftest asset-by-id-nil-for-unknown + (is (nil? (pa/asset-by-id :head :no-such-id))) + (is (nil? (pa/asset-by-id :not-a-real-layer :head-oval)))) + +(deftest artist-attribution-includes-only-selected + (let [head-asset (first (pa/assets-for-layer :head)) + selection {:head {:artist/id (:artist/id pa/placeholder-pack) + :asset/id (:asset/id head-asset)}}] + (is (= [(:artist/id pa/placeholder-pack)] + (pa/all-artists-for-layers selection))))) + +(deftest artist-attribution-empty-when-nothing-selected + (is (= [] (pa/all-artists-for-layers {}))) + (is (= [] (pa/all-artists-for-layers nil)))) + +(deftest artist-for-asset-finds-owner + (let [head-asset (first (pa/assets-for-layer :head))] + (is (= (:artist/id pa/placeholder-pack) + (pa/artist-for-asset :head (:asset/id head-asset))))) + (is (nil? (pa/artist-for-asset :head :no-such-asset)))) + +;; ---------- compositor pure helpers (portrait.cljs) ---------- +;; portrait.cljs is cljs-only, but the pure fns (seed hashing, mulberry, +;; compose-for-seed) are written to work cljc-shape. Test the ones we can +;; reach without pulling in the reagent-facing components. + +;; Pure helpers live in portrait-assets (cljc), reachable from both platforms. + +(deftest seed->int-is-deterministic + (is (= (pa/seed->int "abc") (pa/seed->int "abc"))) + (is (not= (pa/seed->int "abc") (pa/seed->int "abd"))) + (is (integer? (pa/seed->int "abc")))) + +(deftest mulberry32-yields-numbers-in-range + (let [rand-fn (pa/mulberry32 42)] + (dotimes [_ 20] + (let [x (rand-fn)] + (is (<= 0 x)) + (is (< x 1)))))) + +(deftest mulberry32-is-deterministic + (let [seq1 (let [r (pa/mulberry32 12345)] (vec (repeatedly 8 r))) + seq2 (let [r (pa/mulberry32 12345)] (vec (repeatedly 8 r))) + seq3 (let [r (pa/mulberry32 12346)] (vec (repeatedly 8 r)))] + (is (= seq1 seq2)) + (is (not= seq1 seq3)))) + +(deftest compose-for-seed-is-deterministic + (let [c1 (pa/compose-for-seed "test-seed") + c2 (pa/compose-for-seed "test-seed") + c3 (pa/compose-for-seed "different-seed")] + (is (= c1 c2) "same seed → same composition") + (is (not= c1 c3) "different seed → different composition"))) + +(deftest compose-for-seed-covers-every-populated-layer + (let [composition (pa/compose-for-seed "coverage-check")] + (doseq [layer-key pa/layer-order] + (when (pos? (pa/asset-count-for-layer layer-key)) + (is (contains? composition layer-key) + (str layer-key " missing from random composition")))))) + +(deftest compose-for-seed-picks-valid-assets + (let [composition (pa/compose-for-seed "validity-check")] + (doseq [[layer-key {:keys [:asset/id]}] composition] + (is (pa/asset-by-id layer-key id) + (str "compose picked unknown asset " id " for " layer-key))))) From efce42eedbc3327aa0d7653729360fe355ab7149 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:15:03 +0000 Subject: [PATCH 004/170] Add character colors to the portrait compositor; fix its save path Ports the color model validated in the interactive prototype: four base color slots (hair / skin / eyes / shirt) that paint every layer mapped to them, plus per-piece shade (-30%..+30% toward white/black) and full override, so one hair asset renders brown at the back, lighter at the bangs, and a dyed streak in front. Layers now render as CSS mask divs tinted by tint-for rather than plain , which is what lets a single asset take any color -- and works unchanged on real line art provided the PNGs are alpha-masked. Randomize and Reset touch layer selection only, so a character's colors survive rerolls. Also fixes a latent P0 in the previous MVP. It wrote ::char/portrait-layers -- a nested map -- straight into ::se/values, but that is a Datomic component ref: every key must be a registered attribute and none can hold a map, so the first Save would have failed the transaction. The portrait is now one registered string attribute ::char5e/portrait holding EDN, parsed back by char5e/parse-portrait (tolerates an already-parsed map, returns nil for blank/garbage rather than throwing). Deploying needs a schema re-transact; attrs upsert by :db/ident so it is idempotent. Placeholder SVG asset URIs switch from raw-utf8 to base64 -- the raw form parses fine in but breaks inside CSS url() for mask-image. Known gaps, tracked but out of scope here: PDF export and the OG share image still read only image-url, so a composed portrait does not appear in either; both need the client to rasterize the composite. Character list cards likewise need ::char5e/portrait added to their pull. The drawer is dark-theme only. Tests: 272 / 1530 assertions green, cljs build clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- src/clj/orcpub/db/schema.clj | 1 + src/cljc/orcpub/dnd/e5/character.cljc | 28 +- src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 110 ++++- src/cljs/orcpub/dnd/e5/events.cljs | 107 +++-- src/cljs/orcpub/dnd/e5/portrait.cljs | 449 +++++++++++++++----- src/cljs/orcpub/dnd/e5/subs.cljs | 10 +- src/cljs/orcpub/dnd/e5/views.cljs | 17 +- test/cljc/orcpub/dnd/e5/portrait_test.cljc | 112 ++++- 8 files changed, 681 insertions(+), 153 deletions(-) diff --git a/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index a7061c1bf..4d88560c8 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -275,6 +275,7 @@ ::char5e/flaws ::char5e/faction-image-url-failed ::char5e/image-url + ::char5e/portrait ::char5e/description ::char5e/personality-trait-1 ::char5e/eyes diff --git a/src/cljc/orcpub/dnd/e5/character.cljc b/src/cljc/orcpub/dnd/e5/character.cljc index 7b0ed007b..11d6be997 100644 --- a/src/cljc/orcpub/dnd/e5/character.cljc +++ b/src/cljc/orcpub/dnd/e5/character.cljc @@ -4,6 +4,8 @@ #?(:clj [clojure.spec.test.alpha :as stest]) #?(:cljs [cljs.spec.test.alpha :as stest]) [clojure.string :as s] + #?(:clj [clojure.edn :as edn]) + #?(:cljs [cljs.reader :as reader]) [orcpub.entity-spec :as es] [orcpub.dice :as dice] [orcpub.common :as common] @@ -681,8 +683,30 @@ (defn image-url [built-char] (get-prop built-char ::image-url)) -(defn portrait-layers [built-char] - (get-prop built-char ::portrait-layers)) +(defn parse-portrait + "Coerce a stored portrait value into {:layers {…} :colors {…} :tweaks {…}} + or nil. + + The portrait is persisted as an EDN STRING: ::se/values is a Datomic + component ref, so every key in it must be a registered attribute and none + can hold a nested map (see db/schema.clj). Tolerates an already-parsed map + (in-memory drafts before a save round-trip) and returns nil for blank or + malformed values rather than throwing — a corrupt portrait should degrade + to 'no portrait', not break the character sheet." + [v] + (cond + (map? v) v + (string? v) + (when-not (s/blank? v) + (let [parsed (try + #?(:clj (edn/read-string v) + :cljs (reader/read-string v)) + (catch #?(:clj Exception :cljs :default) _ nil))] + (when (map? parsed) parsed))) + :else nil)) + +(defn portrait [built-char] + (parse-portrait (get-prop built-char ::portrait))) (defn faction-image-url [built-char] (get-prop built-char ::faction-image-url)) diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc index 3d1ba8e71..154e43d38 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -51,13 +51,19 @@ ;; ---------- placeholder SVG art (MVP) ---------- +(defn- base64 [markup] + #?(:clj (.encodeToString (java.util.Base64/getEncoder) + (.getBytes ^String markup "UTF-8")) + :cljs (js/btoa markup))) + (defn- svg-uri - "Wrap raw SVG markup as a utf-8 data URI. Only characters that require - escaping in this context are `#` (encoded as `%23`) and the URL-reserved - set — the paths below deliberately avoid the latter." + "Wrap raw SVG markup as a base64 data URI. Base64 keeps the URI safe + inside CSS `url(...)` — the compositor uses the asset as a `mask-image`, + and the raw-utf8 form trips CSS parsing on the `<` and quotes in the + markup. Works for too. (The placeholder markup is ASCII, so + js/btoa is safe.)" [markup] - (str "data:image/svg+xml;utf8," - (s/replace markup "#" "%23"))) + (str "data:image/svg+xml;base64," (base64 markup))) (defn- svg-shape "Build an SVG placeholder shape at the compositor's common 400x500 @@ -241,3 +247,97 @@ (assoc acc layer-key {:artist/id artist-id :asset/id (:asset/id asset)}))))) {} layer-order))) + +;; ---------- character colors ---------- +;; +;; A portrait is {:layers {…} :colors {slot hex} :tweaks {layer {:shade n +;; :override hex}}}. `:colors` holds one base color per slot that paints +;; every layer mapped to that slot; `:tweaks` lets a single piece shade +;; lighter/darker than the base or override it outright (bangs highlight, +;; hair-back shadow, dyed streak). Rendering applies the tint via CSS mask, +;; so one asset renders in any color — see portrait.cljs/composite. + +(def empty-portrait {:layers {} :colors {} :tweaks {}}) + +(def color-slots + "Which color slot each layer draws its base tint from. nil = the layer + keeps its category tint (mouth stays clay red; lips in skin tone look + wrong)." + {:hair-bits :hair + :hair-back :hair + :hair-front :hair + :bangs :hair + :head :skin + :ears :skin + :nose :skin + :eyes :eyes + :shirt :shirt + :mouth nil}) + +(def color-slot-order [:hair :skin :eyes :shirt]) + +(def color-slot-labels {:hair "Hair" :skin "Skin" :eyes "Eyes" :shirt "Shirt"}) + +(def color-presets + "One-tap starting points per slot; a native picker covers the rest." + {:hair ["#2b1a10" "#5c3a1e" "#a06430" "#d4a256" "#e6d58f" "#c8c8c8" "#f2f2f2" "#7a3f6e"] + :skin ["#f2ddc4" "#e8c69c" "#c99871" "#a06e46" "#6c4726" "#3d2617" "#c0a693" "#a4b5a0"] + :eyes ["#6b4a2a" "#3d5c8f" "#4a7a4c" "#8a7c3f" "#8a4a4a" "#4a8a8a" "#7a4a8a" "#c4b48a"] + :shirt ["#3a4a5c" "#7a94b8" "#5c3a3a" "#8f5c3a" "#3d5a3a" "#5c3a5c" "#2c2c2c" "#c8c0a8"]}) + +(defn layers-in-slot + "Layer keys mapped to `slot`, in z-order." + [slot] + (filterv #(= slot (color-slots %)) layer-order)) + +(defn- hex-pair->int [s] + #?(:clj (Integer/parseInt s 16) + :cljs (js/parseInt s 16))) + +(defn- int->hex-pair [n] + (let [s #?(:clj (Integer/toHexString (int n)) + :cljs (.toString n 16))] + (if (< (count s) 2) (str "0" s) s))) + +(def ^:private hex-re #"^#[0-9a-fA-F]{6}$") + +(defn shade-hex + "Mix a #rrggbb color toward white (pct > 0) or black (pct < 0); pct is + clamped to [-100, 100]. A zero/nil pct or a non-#rrggbb input returns + `hex` unchanged." + [hex pct] + (if (or (nil? pct) (zero? pct) (not (re-matches hex-re (str hex)))) + hex + (let [p (/ (min 100 (Math/abs pct)) 100.0) + target (if (neg? pct) 0 255) + mix (fn [c] (int (Math/round (+ c (* (- target c) p))))) + [r g b] (map #(hex-pair->int (subs hex % (+ % 2))) [1 3 5])] + (str "#" (int->hex-pair (mix r)) (int->hex-pair (mix g)) (int->hex-pair (mix b)))))) + +(defn base-tint + "What a layer renders in before any per-piece tweak: its slot's chosen + color if set, else the layer's category tint." + [portrait layer-key] + (let [slot (color-slots layer-key)] + (or (and slot (get-in portrait [:colors slot])) + (layer-colors layer-key)))) + +(defn tint-for + "Effective render color for a layer: per-piece override → shaded base → + base." + [portrait layer-key] + (let [{:keys [override shade]} (get-in portrait [:tweaks layer-key])] + (cond + override override + shade (shade-hex (base-tint portrait layer-key) shade) + :else (base-tint portrait layer-key)))) + +(defn tweaked-layers-in-slot + "Layers in `slot` carrying a real per-piece tweak (an override, or a + non-zero shade), in z-order. Drives the tweak badge and sub-dots on the + slot chip." + [portrait slot] + (filterv (fn [k] + (let [{:keys [override shade]} (get-in portrait [:tweaks k])] + (boolean (or override (and shade (not (zero? shade))))))) + (layers-in-slot slot))) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 36d7b5e43..50cd12671 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -22,7 +22,6 @@ [orcpub.dnd.e5.party :as party5e] [orcpub.dnd.e5.folder :as folder5e] [orcpub.dnd.e5.character.random :as char-rand5e] - [orcpub.dnd.e5.portrait :as portrait5e] [orcpub.dnd.e5.portrait-assets :as portrait-assets5e] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.monsters :as monsters] @@ -1539,57 +1538,111 @@ ;; ---- portrait compositor drawer ---- ;; -;; The drawer's editing state (open/closed, in-progress layer picks, the -;; last-used random seed) lives at the TOP of app-db under :portrait/* -;; keys — it's UI state, not part of the persisted character. Only the -;; :portrait/save handler writes back to the character's -;; ::char5e/portrait-layers value. +;; Editing state (open/closed, the in-progress draft, which color panels are +;; expanded) lives at the TOP of app-db under :portrait/* keys — UI state, +;; not part of the persisted character. Only :portrait/save writes back, and +;; it writes an EDN STRING to ::char5e/portrait: ::se/values is a Datomic +;; component ref, so every key in it must be a registered attribute and no +;; attribute can hold a nested map (see db/schema.clj). The char5e/portrait +;; getter parses it back. + +(def ^:private portrait-ui-keys + [:portrait/drawer-open? :portrait/draft :portrait/draft-seed + :portrait/open-slot :portrait/open-layer]) (reg-event-db :portrait/open (fn [db _] - (let [current (get-in db [:character ::entity/values ::char5e/portrait-layers])] - (assoc db - :portrait/drawer-open? true - :portrait/draft (or current {}) - :portrait/draft-seed nil)))) + (assoc db + :portrait/drawer-open? true + :portrait/draft (or (char5e/portrait (:character db)) + portrait-assets5e/empty-portrait) + :portrait/draft-seed nil + :portrait/open-slot nil + :portrait/open-layer nil))) (reg-event-db :portrait/close (fn [db _] - (dissoc db :portrait/drawer-open? :portrait/draft :portrait/draft-seed))) + (apply dissoc db portrait-ui-keys))) (reg-event-db :portrait/pick-layer (fn [db [_ layer-key asset-id]] - (update db :portrait/draft - (fn [d] - (if (nil? asset-id) - (dissoc d layer-key) - (let [artist-id (portrait-assets5e/artist-for-asset layer-key asset-id)] - (assoc d layer-key {:artist/id artist-id :asset/id asset-id}))))))) + (update-in db [:portrait/draft :layers] + (fn [layers] + (if (nil? asset-id) + (dissoc layers layer-key) + (assoc layers layer-key + {:artist/id (portrait-assets5e/artist-for-asset layer-key asset-id) + :asset/id asset-id})))))) +;; Randomize and Reset touch :layers only — the character's colors persist +;; across rerolls, which is what "my character's colors" should mean. (reg-event-db :portrait/randomize (fn [db _] - (let [seed (portrait5e/random-seed)] - (assoc db - :portrait/draft-seed seed - :portrait/draft (portrait5e/compose-for-seed seed))))) + (let [seed (portrait-assets5e/random-seed)] + (-> db + (assoc :portrait/draft-seed seed) + (assoc-in [:portrait/draft :layers] (portrait-assets5e/compose-for-seed seed)))))) (reg-event-db :portrait/reset (fn [db _] - (assoc db :portrait/draft {} :portrait/draft-seed nil))) + (-> db + (assoc-in [:portrait/draft :layers] {}) + (assoc :portrait/draft-seed nil)))) + +(reg-event-db + :portrait/set-slot-color + (fn [db [_ slot hex]] + (assoc-in db [:portrait/draft :colors slot] hex))) + +(reg-event-db + :portrait/clear-slot-color + (fn [db [_ slot]] + (update-in db [:portrait/draft :colors] dissoc slot))) + +;; `tweak` is {:shade n} or {:override hex}, merged into the piece's existing +;; tweak. A zero shade or nil override drops that key; an empty tweak map is +;; removed entirely so tweaked-layers-in-slot / the sub-dots stay honest. +(reg-event-db + :portrait/set-layer-tweak + (fn [db [_ layer-key tweak]] + (update-in db [:portrait/draft :tweaks] + (fn [tweaks] + (let [merged (into {} + (remove (fn [[_ v]] + (or (nil? v) (and (number? v) (zero? v))))) + (merge (get tweaks layer-key) tweak))] + (if (empty? merged) + (dissoc tweaks layer-key) + (assoc tweaks layer-key merged))))))) + +(reg-event-db + :portrait/clear-layer-tweak + (fn [db [_ layer-key]] + (update-in db [:portrait/draft :tweaks] dissoc layer-key))) + +(reg-event-db + :portrait/toggle-slot-panel + (fn [db [_ slot]] + (assoc db :portrait/open-slot (if (= slot (:portrait/open-slot db)) nil slot)))) + +(reg-event-db + :portrait/toggle-layer-panel + (fn [db [_ layer-key]] + (assoc db :portrait/open-layer (if (= layer-key (:portrait/open-layer db)) nil layer-key)))) (reg-event-db :portrait/save [db-char->local-store] (fn [db _] - (let [draft (get db :portrait/draft {})] - (-> db - (assoc-in [:character ::entity/values ::char5e/portrait-layers] draft) - (dissoc :portrait/drawer-open? :portrait/draft :portrait/draft-seed))))) + (let [draft (get db :portrait/draft portrait-assets5e/empty-portrait)] + (as-> db $ + (assoc-in $ [:character ::entity/values ::char5e/portrait] (pr-str draft)) + (apply dissoc $ portrait-ui-keys))))) #_ ;; never dispatched from UI (reg-event-db diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index 66bb88378..0a4bad4f1 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -1,54 +1,61 @@ (ns orcpub.dnd.e5.portrait "Paper-doll character-portrait compositor. - The drawer is opened by dispatching `[:portrait/open]`. It reads - `::char5e/portrait-layers` off the current character to seed a draft - in app-db under `:portrait/draft`; every pick / randomize / reset - mutates the draft. On Save the draft is committed back to - `::char5e/portrait-layers` and the drawer closes. On Cancel the - draft is discarded. + The drawer opens via [:portrait/open], which seeds a draft in app-db + (:portrait/draft) from the character's saved ::char5e/portrait. A draft + is {:layers {layer-key {:artist/id … :asset/id …}} + :colors {slot hex} + :tweaks {layer-key {:shade n :override hex}}} + (see portrait-assets for the color model). Every pick / randomize / + color change mutates the draft; Save writes it back as an EDN string + (events.cljs) and closes; Cancel discards it. - Rendering: layers stack in `portrait-assets/layer-order` as absolute- - positioned s. Same composite is reused wherever a character's - portrait is shown — see `composite` below, called from - `character-summary-2`." + Rendering: each selected layer is a
whose CSS mask is the asset + image and whose background is the layer's effective tint + (portrait-assets/tint-for), so one asset renders in any hair / skin / + eye color. `composite` is shared with the character summary." (:require [re-frame.core :refer [dispatch subscribe]] - [orcpub.dnd.e5.portrait-assets :as pa] - [orcpub.dnd.e5.character :as char5e])) + [clojure.string :as s] + [orcpub.dnd.e5.portrait-assets :as pa])) -;; Pure helpers live in portrait-assets (cljc) so JVM tests can reach them. -(def random-seed pa/random-seed) -(def compose-for-seed pa/compose-for-seed) +(defn- classes [& cs] (s/join " " (remove nil? cs))) -;; ---------------- portrait composite (used by drawer AND summary) ---------------- +(defn- target-value [e] (.. e -target -value)) -(defn composite - "Reagent component: stacked-layer portrait. `layers` is the same shape as - `::char5e/portrait-layers` — `{layer-key {:artist/id … :asset/id …}}`. - Renders inside whatever container the caller provides — sizing/framing - is up to the caller (inline width/height or CSS class). +(defn- z-label [layer-key] + (let [z (.indexOf pa/layer-order layer-key)] + (str "z" (when (< z 10) "0") z))) + +;; ---------------- composite (used by drawer AND summary) ---------------- + +(defn- mask-style [url tint z] + {:position "absolute" :inset 0 :width "100%" :height "100%" + :background-color tint + :-webkit-mask-image (str "url(" url ")") + :mask-image (str "url(" url ")") + :-webkit-mask-size "contain" :mask-size "contain" + :-webkit-mask-repeat "no-repeat" :mask-repeat "no-repeat" + :-webkit-mask-position "center" :mask-position "center" + :z-index z + :pointer-events "none"}) - `attrs` (optional) merges into the outer
's props so callers can - set className, style, etc." - ([layers] (composite layers nil)) - ([layers attrs] - [:div.portrait-composite - (merge {:style {:position "relative" :width "100%" :height "100%"}} - attrs) - (map-indexed - (fn [z layer-key] - (when-let [asset (some->> (get layers layer-key) - :asset/id - (pa/asset-by-id layer-key))] - ^{:key layer-key} - [:img.portrait-layer - {:src (:asset/url asset) - :style {:position "absolute" :inset 0 - :width "100%" :height "100%" - :object-fit "contain" - :z-index z - :pointer-events "none"}}])) - pa/layer-order)])) +(defn composite + "Stacked, tinted portrait for a `portrait` map (see ns doc). `attrs` + (optional) merges into the outer div so callers can size/position it." + ([portrait] (composite portrait nil)) + ([portrait attrs] + (let [layers (:layers portrait)] + [:div.portrait-composite + (merge {:style {:position "relative" :width "100%" :height "100%"}} attrs) + (map-indexed + (fn [z layer-key] + (when-let [asset (some->> (get layers layer-key) + :asset/id + (pa/asset-by-id layer-key))] + ^{:key layer-key} + [:div.portrait-layer + {:style (mask-style (:asset/url asset) (pa/tint-for portrait layer-key) z)}])) + pa/layer-order)]))) ;; ---------------- drawer chrome ---------------- @@ -61,7 +68,7 @@ } .pl-drawer { position: fixed; top: 0; right: 0; bottom: 0; - width: 560px; max-width: 100vw; + width: 600px; max-width: 100vw; background: #131924; border-left: 1px solid rgba(240, 161, 0, 0.16); box-shadow: -30px 0 60px -20px rgba(0, 0, 0, 0.6); @@ -94,26 +101,34 @@ .pl-drawer-body { flex: 1; overflow: hidden; - display: grid; grid-template-columns: 240px 1fr; -} -@media (max-width: 700px) { - .pl-drawer { width: 100vw; } - .pl-drawer-body { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } - .pl-canvas-side { border-right: none !important; border-bottom: 1px solid rgba(255,255,255,0.06); } - .pl-portrait-frame { width: 200px !important; height: 260px !important; } + display: grid; grid-template-columns: 280px 1fr; } - .pl-canvas-side { background: #0e131a; border-right: 1px solid rgba(255,255,255,0.06); padding: 16px; display: flex; flex-direction: column; gap: 10px; + overflow-y: auto; +} +.pl-pickers-side { + overflow-y: auto; padding: 12px 14px; + display: flex; flex-direction: column; gap: 8px; + background: #131924; +} +@media (max-width: 700px) { + .pl-drawer { width: 100vw; } + .pl-drawer-body { display: block; overflow-y: auto; } + .pl-canvas-side { border-right: none; border-bottom: 1px solid rgba(255,255,255,0.06); overflow: visible; } + .pl-pickers-side { overflow: visible; } + .pl-portrait-frame { width: 200px !important; height: 260px !important; } } + .pl-portrait-frame { width: 208px; height: 260px; background: radial-gradient(circle at 50% 35%, #202939, #131924 60%, #0f141c); border: 1px solid rgba(240, 161, 0, 0.16); border-radius: 10px; align-self: center; position: relative; overflow: hidden; + flex-shrink: 0; } .pl-empty-hint { position: absolute; inset: 0; @@ -140,7 +155,8 @@ .pl-btn-primary:hover { filter: brightness(1.06); } .pl-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .pl-btn-ghost { border-color: rgba(255,255,255,0.10); color: #8b95a5; } -.pl-btn-ghost:hover { border-color: #f0a100; color: #ffcc5e; } +.pl-btn-ghost:hover:not(:disabled) { border-color: #f0a100; color: #ffcc5e; } +.pl-btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; } .pl-seed-row { display: flex; align-items: center; justify-content: space-between; color: #616a7a; font: 500 11px/1 inherit; letter-spacing: 0.06em; text-transform: uppercase; @@ -152,17 +168,131 @@ padding: 3px 7px; border-radius: 4px; text-transform: none; letter-spacing: 0.02em; } -.pl-pickers-side { - overflow-y: auto; padding: 12px 14px; - display: flex; flex-direction: column; gap: 8px; +/* ---- character colors ---- */ +.pl-color-strip { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + padding: 8px 10px; + background: #171e29; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; +} +.pl-strip-label, .pl-panel-heading { + font: 700 9.5px/1 inherit; color: #616a7a; + letter-spacing: 0.14em; text-transform: uppercase; +} +.pl-strip-label { margin-right: 4px; } +.pl-slot { + display: inline-flex; align-items: center; background: #131924; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 999px; + padding: 3px 3px 3px 10px; gap: 6px; +} +.pl-slot.on { border-color: #f0a100; box-shadow: 0 0 0 1px rgba(240,161,0,0.24); } +.pl-slot.has-tweaks .pl-slot-swatch { box-shadow: 0 0 0 2px rgba(240,161,0,0.42); } +.pl-slot::after { + content: ''; + display: inline-block; width: 6px; height: 6px; + border-right: 1.5px solid #616a7a; border-bottom: 1.5px solid #616a7a; + transform: rotate(45deg) translateY(-1px); + margin: 0 6px 0 -2px; + transition: transform 120ms, border-color 120ms; +} +.pl-slot.open::after { transform: rotate(-135deg); border-color: #ffcc5e; } +.pl-slot-name { font: 500 11px/1 inherit; color: #ebeef4; } +.pl-slot-tweaks { + display: inline-flex; align-items: center; + background: rgba(240,161,0,0.16); color: #ffcc5e; + font: 700 9.5px/1 inherit; letter-spacing: 0.06em; + padding: 2px 6px 2px 5px; border-radius: 999px; + margin: 0 2px 0 -2px; +} +.pl-slot-swatch { + position: relative; display: inline-block; + width: 28px; height: 28px; border-radius: 50%; + border: 1px solid rgba(255,255,255,0.15); + cursor: pointer; padding: 0; background-clip: padding-box; +} +.pl-slot-swatch.unset { + background: repeating-linear-gradient(45deg, #0e131a 0 4px, rgba(255,255,255,0.05) 4px 8px); +} +.pl-slot-swatch:focus-visible { outline: 2px solid #ffcc5e; outline-offset: 2px; } +.pl-sub-dots { position: absolute; inset: 0; pointer-events: none; } +.pl-sub-dot { + position: absolute; width: 10px; height: 10px; border-radius: 50%; + border: 1.5px solid #171e29; box-shadow: 0 0 0 0.5px rgba(0,0,0,0.35); +} +.pl-sub-dot-0 { top: -4px; left: 50%; transform: translateX(-50%); } +.pl-sub-dot-1 { top: 50%; right: -4px; transform: translateY(-50%); } +.pl-sub-dot-2 { bottom: -4px; left: 50%; transform: translateX(-50%); } +.pl-sub-dot-3 { top: 50%; left: -4px; transform: translateY(-50%); } +.pl-slot-clear, .pl-sub-clear { + display: inline-grid; place-items: center; + width: 22px; height: 22px; border-radius: 50%; + background: transparent; border: 1px solid transparent; + color: #616a7a; cursor: pointer; padding: 0; font-size: 12px; line-height: 1; +} +.pl-slot-clear:hover:not(:disabled), .pl-sub-clear:hover:not(:disabled) { color: #ffcc5e; border-color: rgba(240,161,0,0.16); } +.pl-slot-clear:disabled, .pl-sub-clear:disabled { opacity: 0.25; cursor: default; } + +.pl-slot-panel { + flex-basis: 100%; min-width: 100%; + display: flex; flex-direction: column; gap: 10px; + padding: 10px; margin-top: -2px; + background: #1e2635; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; +} +.pl-panel-heading { margin-bottom: 6px; display: block; } +.pl-presets { display: flex; flex-wrap: wrap; gap: 5px; } +.pl-preset { + width: 26px; height: 26px; border-radius: 50%; + border: 1px solid rgba(255,255,255,0.12); + cursor: pointer; padding: 0; position: relative; overflow: hidden; +} +.pl-preset:focus-visible { outline: 2px solid #ffcc5e; outline-offset: 2px; } +.pl-preset.custom { background: conic-gradient(#f0a100, #78d0d4, #c85c5c, #6fbb5a, #7a94b8, #f0a100); } +.pl-preset.custom input, .pl-sub-chip input { + position: absolute; inset: 0; width: 100%; height: 100%; + opacity: 0; padding: 0; border: none; cursor: pointer; +} +.pl-sublayers { + display: flex; flex-direction: column; gap: 8px; + padding-top: 8px; border-top: 1px dashed rgba(255,255,255,0.06); +} +.pl-sub-row { + display: grid; grid-template-columns: 22px 1fr 40px 22px; + gap: 6px; align-items: center; +} +.pl-sub-name { + grid-column: 1 / -1; + font: 500 11px/1 inherit; color: #ebeef4; +} +.pl-sub-chip { + position: relative; display: inline-block; + width: 22px; height: 22px; border-radius: 50%; + border: 1px solid rgba(255,255,255,0.15); + cursor: pointer; overflow: hidden; +} +.pl-sub-chip.shaded { border-color: #f0a100; border-style: dashed; } +.pl-sub-chip.overridden { border-color: #f0a100; box-shadow: 0 0 0 1px rgba(240,161,0,0.4); } +.pl-sub-row input[type=range] { width: 100%; accent-color: #f0a100; height: 22px; margin: 0; } +.pl-sub-shade-val { + font: 500 10px/1 ui-monospace, Menlo, monospace; color: #8b95a5; + text-align: right; font-variant-numeric: tabular-nums; +} +.pl-layer-panel { + margin-top: 10px; padding: 10px 12px; + background: #1e2635; border: 1px solid rgba(240,161,0,0.16); border-radius: 6px; } + +/* ---- pickers ---- */ .pl-picker { background: #171e29; border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 10px 12px; } .pl-picker-head { - display: flex; align-items: baseline; justify-content: space-between; gap: 8px; + display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; position: sticky; top: -1px; background: #171e29; @@ -182,6 +312,15 @@ padding: 2px 5px; background: #131924; border-radius: 3px; letter-spacing: 0.04em; } +.pl-tint-chip { + width: 18px; height: 18px; border-radius: 50%; + border: 1.5px solid transparent; + cursor: pointer; padding: 0; margin-left: 4px; flex-shrink: 0; +} +.pl-tint-chip:focus-visible { outline: 2px solid #ffcc5e; outline-offset: 2px; } +.pl-tint-chip.shaded { border-color: #f0a100; border-style: dashed; } +.pl-tint-chip.overridden { border-color: #f0a100; } +.pl-tint-chip.open { box-shadow: 0 0 0 2px rgba(240,161,0,0.32); } .pl-picker-sel { font: italic 12px/1 Georgia, serif; color: #8b95a5; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 180px; @@ -204,11 +343,7 @@ box-shadow: 0 0 0 2px rgba(240,161,0,0.32); background: #1e2635; } -.pl-sw img { - width: 100%; height: 100%; - object-fit: contain; - pointer-events: none; -} +.pl-sw img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; } .pl-sw-none { background: repeating-linear-gradient(45deg, #131924 0 5px, transparent 5px 10px); color: #616a7a; font: italic 10px/1 Georgia, serif; @@ -218,6 +353,7 @@ font: italic 11px/1.4 Georgia, serif; text-align: center; } +/* ---- foot ---- */ .pl-drawer-foot { padding: 12px 18px 16px; border-top: 1px solid rgba(255,255,255,0.06); @@ -233,9 +369,7 @@ font: 700 10px/1 inherit; letter-spacing: 0.18em; text-transform: uppercase; color: #616a7a; } -.pl-attribution-empty { - color: #616a7a; font-style: italic; font-family: Georgia, serif; -} +.pl-attribution-empty { color: #616a7a; font-style: italic; font-family: Georgia, serif; } .pl-artist { display: inline-flex; align-items: baseline; gap: 5px; } .pl-artist-swirl { color: #f0a100; font-family: Georgia, serif; font-style: italic; } .pl-artist a, .pl-artist span.pl-artist-name { @@ -264,41 +398,157 @@ .pl-launcher:hover { filter: brightness(1.06); } ") -(defn- category-picker [layer-key] - (let [draft @(subscribe [:portrait/draft]) - selected (get draft layer-key) +;; ---------------- color controls ---------------- + +(defn- sub-row + "One piece's color controls: effective-color chip (native override + picker inside), shade slider, readout, clear. Used both inside the slot + panel (all pieces of a slot) and inline under a picker card." + [portrait layer-key] + (let [{:keys [override shade]} (get-in portrait [:tweaks layer-key]) + shade (or shade 0) + eff (pa/tint-for portrait layer-key) + any? (boolean (or override (not (zero? shade)))) + label (pa/layer-labels layer-key)] + [:div.pl-sub-row + [:span.pl-sub-name label] + [:span.pl-sub-chip + {:class (cond override "overridden" (not (zero? shade)) "shaded") + :style {:background eff} + :title (str "Override — currently " (s/upper-case eff))} + [:input {:type "color" + :value (or override eff) + :aria-label (str "Override " label " color") + :on-change #(dispatch [:portrait/set-layer-tweak layer-key + {:override (target-value %)}])}]] + [:input {:type "range" :min -30 :max 30 :step 5 + :value shade + :disabled (some? override) + :aria-label (str "Shade " label) + :on-change #(dispatch [:portrait/set-layer-tweak layer-key + {:shade (js/parseInt (target-value %) 10)}])}] + [:span.pl-sub-shade-val (str (when (pos? shade) "+") shade "%")] + [:button.pl-sub-clear + {:type "button" :disabled (not any?) + :title (str "Use base color for " label) + :on-click #(dispatch [:portrait/clear-layer-tweak layer-key])} + "×"]])) + +(defn- slot-chip [portrait slot open-slot] + (let [cur (get-in portrait [:colors slot]) + tweaked (pa/tweaked-layers-in-slot portrait slot) + open? (= open-slot slot) + label (pa/color-slot-labels slot)] + [:div.pl-slot + {:class (classes (when cur "on") (when (seq tweaked) "has-tweaks") (when open? "open"))} + [:span.pl-slot-name label] + (when (seq tweaked) + [:span.pl-slot-tweaks + {:title (str (count tweaked) " piece" (when (> (count tweaked) 1) "s") " tweaked")} + (count tweaked)]) + [:button.pl-slot-swatch + {:type "button" + :class (when-not cur "unset") + :style (when cur {:background cur}) + :aria-expanded open? + :title (if cur (str "Base color " (s/upper-case cur)) "Not set — tap to pick") + :on-click #(dispatch [:portrait/toggle-slot-panel slot])} + [:span.pl-sub-dots + (map-indexed + (fn [i k] + ^{:key k} + [:span.pl-sub-dot {:class (str "pl-sub-dot-" i) + :style {:background (pa/tint-for portrait k)} + :title (str (pa/layer-labels k) ": " (s/upper-case (pa/tint-for portrait k)))}]) + (take 4 tweaked))]] + [:button.pl-slot-clear + {:type "button" :disabled (nil? cur) + :title (str "Clear " label) + :on-click #(dispatch [:portrait/clear-slot-color slot])} + "×"]])) + +(defn- slot-panel [portrait slot] + (let [pieces (pa/layers-in-slot slot)] + [:div.pl-slot-panel + [:div + [:span.pl-panel-heading "Presets"] + [:div.pl-presets + (for [c (pa/color-presets slot)] + ^{:key c} + [:button.pl-preset + {:type "button" :style {:background c} :title c + :on-click #(dispatch [:portrait/set-slot-color slot c])}]) + [:span.pl-preset.custom {:title "Custom color"} + [:input {:type "color" + :value (or (get-in portrait [:colors slot]) "#c0a080") + :aria-label (str "Custom " (pa/color-slot-labels slot) " color") + :on-change #(dispatch [:portrait/set-slot-color slot (target-value %)])}]]]] + (when (seq pieces) + [:div.pl-sublayers + [:span.pl-panel-heading (str (pa/color-slot-labels slot) " pieces")] + (for [k pieces] + ^{:key k} [sub-row portrait k])])])) + +(defn- color-strip [portrait open-slot] + [:div.pl-color-strip + [:span.pl-strip-label "colors"] + (for [slot pa/color-slot-order] + ^{:key slot} [slot-chip portrait slot open-slot]) + (when open-slot + [slot-panel portrait open-slot])]) + +(defn- layer-tint-chip [portrait layer-key open-layer] + (let [{:keys [override shade]} (get-in portrait [:tweaks layer-key]) + open? (= open-layer layer-key)] + [:button.pl-tint-chip + {:type "button" + :class (classes (when override "overridden") + (when (and (not override) shade (not (zero? shade))) "shaded") + (when open? "open")) + :style {:background (pa/tint-for portrait layer-key)} + :aria-expanded open? + :title "Adjust this piece's color" + :on-click #(dispatch [:portrait/toggle-layer-panel layer-key])}])) + +;; ---------------- pickers ---------------- + +(defn- category-picker [portrait layer-key open-layer] + (let [selected (get-in portrait [:layers layer-key]) selected-asset (when selected (pa/asset-by-id layer-key (:asset/id selected))) - assets (pa/assets-for-layer layer-key) - z (.indexOf pa/layer-order layer-key)] + assets (pa/assets-for-layer layer-key)] [:div.pl-picker [:div.pl-picker-head [:span.pl-cat [:span.pl-cat-chip {:style {:background (pa/layer-colors layer-key)}}] (pa/layer-labels layer-key) - [:span.pl-cat-z (str "z" (when (< z 10) "0") z)]] + [:span.pl-cat-z (z-label layer-key)] + [layer-tint-chip portrait layer-key open-layer]] (when selected [:span.pl-picker-sel (or (:asset/label selected-asset) "picked")])] (if (empty? assets) [:div.pl-empty-registry "no art here yet"] [:div.pl-swatches [:button.pl-sw.pl-sw-none - {:class (when-not selected "selected") + {:type "button" + :class (when-not selected "selected") :on-click #(dispatch [:portrait/pick-layer layer-key nil]) :title "None"} "none"] (for [asset assets - :let [asset-id (:asset/id asset) - is-selected? (= asset-id (:asset/id selected))]] + :let [asset-id (:asset/id asset)]] ^{:key asset-id} [:button.pl-sw - {:class (when is-selected? "selected") + {:type "button" + :class (when (= asset-id (:asset/id selected)) "selected") :on-click #(dispatch [:portrait/pick-layer layer-key asset-id]) :title (or (:asset/label asset) (name asset-id))} [:img {:src (:asset/url asset) - :alt (or (:asset/label asset) "")}]])])])) + :alt (or (:asset/label asset) "")}]])]) + (when (= open-layer layer-key) + [:div.pl-layer-panel [sub-row portrait layer-key]])])) -(defn- attribution [draft] - (let [artist-ids (pa/all-artists-for-layers draft)] +(defn- attribution [layers] + (let [artist-ids (pa/all-artists-for-layers layers)] [:div.pl-attribution [:span.pl-attribution-label "Art by"] (if (seq artist-ids) @@ -313,62 +563,65 @@ [:span.pl-artist-name (:artist/name info)])]) [:span.pl-attribution-empty "no layers selected yet"])])) +;; ---------------- drawer ---------------- + (defn drawer - "Renders the compositor drawer when `:portrait/drawer-open?` is truthy. - Mount once at the character-builder root; the drawer overlays." + "Renders the compositor drawer when :portrait/drawer-open? is truthy. + Mount once at the character-builder root; it overlays." [] (when @(subscribe [:portrait/drawer-open?]) - (let [draft @(subscribe [:portrait/draft]) - seed @(subscribe [:portrait/draft-seed]) - any? (seq draft)] + (let [portrait @(subscribe [:portrait/draft]) + seed @(subscribe [:portrait/draft-seed]) + open-slot @(subscribe [:portrait/open-slot]) + open-layer @(subscribe [:portrait/open-layer]) + layers (:layers portrait) + any? (boolean (seq layers))] [:div [:style drawer-styles] - [:div.pl-backdrop - {:on-click #(dispatch [:portrait/close])}] + [:div.pl-backdrop {:on-click #(dispatch [:portrait/close])}] [:div.pl-drawer [:div.pl-drawer-head [:div.pl-drawer-title [:span.pl-drawer-title-rune "§"] "Compose portrait"] [:button.pl-drawer-close - {:on-click #(dispatch [:portrait/close]) + {:type "button" + :on-click #(dispatch [:portrait/close]) :aria-label "Close portrait compositor"} "✕"]] [:div.pl-drawer-body [:div.pl-canvas-side [:div.pl-portrait-frame (if any? - [composite draft] + [composite portrait] [:div.pl-empty-hint "Pick a layer below, or hit " [:em "Randomize"] "."])] [:div.pl-toolbar [:button.pl-btn.pl-btn-primary - {:on-click #(dispatch [:portrait/randomize])} + {:type "button" :on-click #(dispatch [:portrait/randomize])} "🎲 Randomize"] [:button.pl-btn.pl-btn-ghost - {:on-click #(dispatch [:portrait/reset]) - :disabled (not any?)} + {:type "button" :on-click #(dispatch [:portrait/reset]) :disabled (not any?)} "Reset"] (when seed - [:div.pl-seed-row - [:span "seed"] - [:code seed]])]] + [:div.pl-seed-row [:span "seed"] [:code seed]])] + [color-strip portrait open-slot]] [:div.pl-pickers-side (for [layer-key pa/layer-order] ^{:key layer-key} - [category-picker layer-key])]] + [category-picker portrait layer-key open-layer])]] [:div.pl-drawer-foot - [attribution draft] + [attribution layers] [:div.pl-drawer-actions [:button.pl-btn.pl-btn-ghost - {:on-click #(dispatch [:portrait/close])} + {:type "button" :on-click #(dispatch [:portrait/close])} "Cancel"] [:button.pl-btn.pl-btn-primary - {:on-click #(dispatch [:portrait/save])} + {:type "button" :on-click #(dispatch [:portrait/save])} "Save portrait"]]]]]))) (defn launcher-button - "The 'Compose portrait' button that slots next to the Image URL input - in the character-builder Description tab." + "The 'Compose portrait' button that slots next to the Image URL input in + the character-builder Description tab." [] [:button.pl-launcher {:type "button" diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index c697c1c8e..64e997926 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -93,6 +93,14 @@ :portrait/draft-seed (fn [db _] (get db :portrait/draft-seed))) +(reg-sub + :portrait/open-slot + (fn [db _] (get db :portrait/open-slot))) + +(reg-sub + :portrait/open-layer + (fn [db _] (get db :portrait/open-layer))) + (reg-sub :locked (fn [db [_ path]] @@ -720,7 +728,7 @@ ::char5e/actions char5e/actions ::char5e/image-url char5e/image-url ::char5e/image-url-failed char5e/image-url-failed - ::char5e/portrait-layers char5e/portrait-layers + ::char5e/portrait char5e/portrait ::char5e/faction-image-url char5e/faction-image-url ::char5e/faction-image-url-failed char5e/faction-image-url-failed ::char5e/personality-trait-1 char5e/personality-trait-1 diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 9e589a31c..adb4ab4d7 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -1667,7 +1667,6 @@ (defn character-summary-2 [{:keys [::char/character-name ::char/image-url - ::char/portrait-layers ::char/race-name ::char/subrace-name ::char/age @@ -1686,16 +1685,18 @@ show-owner? show-follow?] (let [username @(subscribe [:username]) - display-name (when include-name? (character-display-name summary))] + display-name (when include-name? (character-display-name summary)) + ;; parsed {:layers :colors :tweaks} map, or nil — see char/portrait + portrait-data (::char/portrait summary)] [:div.flex.justify-cont-s-b.w-100-p.align-items-c [:div.flex.align-items-c.align-items-t - ;; portrait-layers wins over image-url when both are present; falls back - ;; to the pasted URL, then to nothing (existing behavior). + ;; a composed portrait wins over image-url when both are present; falls + ;; back to the pasted URL, then to nothing (existing behavior). (cond - (seq portrait-layers) + (seq (:layers portrait-data)) [:div.m-r-20.m-t-10.m-b-10.image-character-thumbnail {:style {:position "relative" :width "100px" :height "125px" :flex-shrink 0}} - [portrait/composite portrait-layers + [portrait/composite portrait-data {:style {:position "absolute" :inset 0 :width "100%" :height "100%"}}]] image-url @@ -1744,7 +1745,7 @@ eyes @(subscribe [::char/eyes id]) skin @(subscribe [::char/skin id]) image-url @(subscribe [::char/image-url id]) - portrait-layers @(subscribe [::char/portrait-layers id]) + portrait-data @(subscribe [::char/portrait id]) race @(subscribe [::char/race id]) subrace @(subscribe [::char/subrace id]) levels @(subscribe [::char/levels id]) @@ -1762,7 +1763,7 @@ ::char/eyes eyes ::char/skin skin ::char/image-url image-url - ::char/portrait-layers portrait-layers + ::char/portrait portrait-data ::char/race-name race ::char/subrace-name subrace ::char/alignment alignment diff --git a/test/cljc/orcpub/dnd/e5/portrait_test.cljc b/test/cljc/orcpub/dnd/e5/portrait_test.cljc index 7689e3a45..97c0cf803 100644 --- a/test/cljc/orcpub/dnd/e5/portrait_test.cljc +++ b/test/cljc/orcpub/dnd/e5/portrait_test.cljc @@ -2,9 +2,11 @@ "Pure-fn tests for the paper-doll compositor. Everything here runs on both JVM (via lein test) and cljs — no DOM, no re-frame." (:require [clojure.test :refer [deftest testing is]] + [orcpub.entity :as entity] + [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.portrait-assets :as pa])) -;; ---------- portrait-assets registry ---------- +;; ---------- registry ---------- (deftest layer-order-matches-taxonomy (testing "10 layers in illustrator's real z-order" @@ -12,7 +14,9 @@ :ears :eyes :nose :mouth :bangs] pa/layer-order)) (is (= (count pa/layer-order) (count pa/layer-labels))) - (is (= (count pa/layer-order) (count pa/layer-colors))))) + (is (= (count pa/layer-order) (count pa/layer-colors))) + (is (= (set pa/layer-order) (set (keys pa/color-slots))) + "every layer has a color-slot mapping (nil allowed)"))) (deftest every-layer-has-at-least-one-placeholder-asset (testing "the MVP feature is functional out of the box" @@ -20,12 +24,18 @@ (is (pos? (pa/asset-count-for-layer layer-key)) (str "layer " layer-key " has no placeholder assets"))))) -(deftest asset-by-id-round-trips - (testing "every placeholder asset can be looked up by id" +(deftest placeholder-asset-urls-are-base64-data-uris + (testing "safe inside CSS url() for mask-image as well as " (doseq [layer-key pa/layer-order asset (pa/assets-for-layer layer-key)] - (is (= asset (pa/asset-by-id layer-key (:asset/id asset))) - (str "round-trip failed for " layer-key "/" (:asset/id asset)))))) + (is (re-find #"^data:image/svg\+xml;base64,[A-Za-z0-9+/=]+$" (:asset/url asset)) + (str (:asset/id asset) " is not a base64 data URI"))))) + +(deftest asset-by-id-round-trips + (doseq [layer-key pa/layer-order + asset (pa/assets-for-layer layer-key)] + (is (= asset (pa/asset-by-id layer-key (:asset/id asset))) + (str "round-trip failed for " layer-key "/" (:asset/id asset))))) (deftest asset-by-id-nil-for-unknown (is (nil? (pa/asset-by-id :head :no-such-id))) @@ -48,12 +58,7 @@ (pa/artist-for-asset :head (:asset/id head-asset))))) (is (nil? (pa/artist-for-asset :head :no-such-asset)))) -;; ---------- compositor pure helpers (portrait.cljs) ---------- -;; portrait.cljs is cljs-only, but the pure fns (seed hashing, mulberry, -;; compose-for-seed) are written to work cljc-shape. Test the ones we can -;; reach without pulling in the reagent-facing components. - -;; Pure helpers live in portrait-assets (cljc), reachable from both platforms. +;; ---------- seeded randomize ---------- (deftest seed->int-is-deterministic (is (= (pa/seed->int "abc") (pa/seed->int "abc"))) @@ -93,3 +98,86 @@ (doseq [[layer-key {:keys [:asset/id]}] composition] (is (pa/asset-by-id layer-key id) (str "compose picked unknown asset " id " for " layer-key))))) + +;; ---------- character colors ---------- + +(deftest shade-hex-mixes-and-clamps + (is (= "#ffffff" (pa/shade-hex "#000000" 100)) "full lighten → white") + (is (= "#000000" (pa/shade-hex "#ffffff" -100)) "full darken → black") + (is (= "#808080" (pa/shade-hex "#808080" 0)) "zero is identity") + (is (= "#808080" (pa/shade-hex "#808080" nil)) "nil is identity") + (is (= "#ffffff" (pa/shade-hex "#000000" 250)) "clamped to 100") + (is (= "#808080" (pa/shade-hex "#000000" 50)) "50% toward white") + (is (= "nope" (pa/shade-hex "nope" 20)) "non-hex passthrough") + (is (nil? (pa/shade-hex nil 20)) "nil passthrough") + (is (re-matches #"#[0-9a-f]{6}" (pa/shade-hex "#5c3a1e" 15)) "output stays #rrggbb")) + +(deftest layers-in-slot-groups-by-slot + (is (= [:hair-bits :hair-back :hair-front :bangs] (pa/layers-in-slot :hair))) + (is (= [:head :ears :nose] (pa/layers-in-slot :skin))) + (is (= [:eyes] (pa/layers-in-slot :eyes))) + (is (= [:shirt] (pa/layers-in-slot :shirt))) + (is (= [] (pa/layers-in-slot :no-such-slot)))) + +(deftest tint-for-precedence + (let [base pa/empty-portrait] + (testing "nothing set → category tint" + (is (= (pa/layer-colors :bangs) (pa/tint-for base :bangs))) + (is (= (pa/layer-colors :bangs) (pa/tint-for nil :bangs)) "nil portrait tolerated")) + (testing "slot color paints every layer in the slot, nothing else" + (let [p (assoc-in base [:colors :hair] "#112233")] + (doseq [k (pa/layers-in-slot :hair)] + (is (= "#112233" (pa/tint-for p k)) (str k))) + (is (= (pa/layer-colors :head) (pa/tint-for p :head))))) + (testing "shade applies to one piece against the slot base" + (let [p (-> base + (assoc-in [:colors :hair] "#000000") + (assoc-in [:tweaks :bangs :shade] 100))] + (is (= "#ffffff" (pa/tint-for p :bangs))) + (is (= "#000000" (pa/tint-for p :hair-back)) "sibling piece keeps base"))) + (testing "shade with no slot color shades the category tint" + (let [p (assoc-in base [:tweaks :bangs :shade] -100)] + (is (= "#000000" (pa/tint-for p :bangs))))) + (testing "override beats shade and base" + (let [p (-> base + (assoc-in [:colors :hair] "#000000") + (assoc-in [:tweaks :bangs] {:shade 100 :override "#123456"}))] + (is (= "#123456" (pa/tint-for p :bangs))))) + (testing "mouth has no slot — always its category tint" + (let [p (assoc-in base [:colors :skin] "#ff0000")] + (is (= (pa/layer-colors :mouth) (pa/tint-for p :mouth))))))) + +(deftest tweaked-layers-in-slot-counts-only-real-tweaks + (let [p {:layers {} :colors {} + :tweaks {:bangs {:shade 10} + :hair-back {:shade 0} + :head {:override "#abcdef"} + :ears {}}}] + (is (= [:bangs] (pa/tweaked-layers-in-slot p :hair)) "zero shade doesn't count") + (is (= [:head] (pa/tweaked-layers-in-slot p :skin)) "empty tweak map doesn't count") + (is (= [] (pa/tweaked-layers-in-slot p :eyes))) + (is (= [] (pa/tweaked-layers-in-slot pa/empty-portrait :hair))))) + +;; ---------- persistence (EDN string on the character) ---------- + +(deftest portrait-round-trips-through-edn-string + (let [portrait {:layers {:head {:artist/id :placeholder-pack :asset/id :head-oval} + :bangs {:artist/id :placeholder-pack :asset/id :bangs-side-swept}} + :colors {:hair "#5c3a1e"} + :tweaks {:bangs {:shade 20}}} + stored (pr-str portrait)] + (is (string? stored) "what Datomic receives is a plain string") + (is (= portrait (char5e/parse-portrait stored))) + ;; #5c3a1e shaded +20% toward white: 92→125, 58→97, 30→75 + (is (= "#7d614b" (pa/tint-for (char5e/parse-portrait stored) :bangs)) + "colors survive the round trip and still tint"))) + +(deftest parse-portrait-tolerates-maps-nil-and-garbage + (is (nil? (char5e/parse-portrait nil)) "unset") + (is (nil? (char5e/parse-portrait "")) "blank") + (is (nil? (char5e/parse-portrait " ")) "whitespace") + (is (nil? (char5e/parse-portrait "{not edn")) "garbage") + (is (nil? (char5e/parse-portrait "42")) "non-map edn") + (is (nil? (char5e/parse-portrait 42)) "non-string non-map") + (is (= {:layers {}} (char5e/parse-portrait {:layers {}})) + "already-parsed map passes through")) From d0ba22f97c3177843c55c3e887455096c617ff79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:02:35 +0000 Subject: [PATCH 005/170] Fix portrait draft not rehydrating on reopen; add compositor e2e :portrait/open read the saved portrait via char5e/portrait, but that getter resolves through es/entity-val against a BUILT character while db :character is the raw entity -- so it always returned nil and reopening the drawer silently discarded a saved portrait. Reads the raw ::entity/values path and parses it directly instead. Save and the summary were unaffected: both go through the built-character sub, which is why unit tests and a visual check of the summary both looked fine. Adds test/browser/portrait_compositor_e2e.js, which drives the real builder against lein e2e-server: opens the drawer, randomizes, picks a swatch, opens a color slot, applies a preset and a per-piece shade, saves, and asserts the portrait reaches the summary AND rehydrates on reopen -- the round trip that caught this. Resource failures are tracked by URL so an uncompiled stylesheet or a blocked webfont doesn't fail the run, while a genuinely missing app asset still does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- src/cljs/orcpub/dnd/e5/events.cljs | 7 +- test/browser/portrait_compositor_e2e.js | 190 ++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 test/browser/portrait_compositor_e2e.js diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 5fa076a53..5636960fc 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -1589,7 +1589,12 @@ (fn [db _] (assoc db :portrait/drawer-open? true - :portrait/draft (or (char5e/portrait (:character db)) + ;; Read the RAW value, not char5e/portrait -- that getter resolves + ;; through es/entity-val against a BUILT character, and db :character + ;; is the raw entity. Going through the getter here silently yields + ;; nil, so reopening the drawer would discard a saved portrait. + :portrait/draft (or (char5e/parse-portrait + (get-in db [:character ::entity/values ::char5e/portrait])) portrait-assets5e/empty-portrait) :portrait/draft-seed nil :portrait/open-slot nil diff --git a/test/browser/portrait_compositor_e2e.js b/test/browser/portrait_compositor_e2e.js new file mode 100644 index 000000000..81f680563 --- /dev/null +++ b/test/browser/portrait_compositor_e2e.js @@ -0,0 +1,190 @@ +// Portrait compositor, driven through the real builder UI. +// +// Opens the character builder, goes to the Description tab, clicks +// "Compose portrait", and exercises the drawer: randomize, pick a swatch, +// set a base color, shade one piece, save, and confirm the composed +// portrait survives into the character summary. +// +// lein fig:build +// lein garden once # only needed for the screenshot +// lein e2e-server +// node test/browser/portrait_compositor_e2e.js +// +// Set ORCPUB_SHOT=path.png to save a picture of the open drawer. +// +// Exits non-zero on the first failed check. + +const { chromium } = require('playwright'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const SHOT = process.env.ORCPUB_SHOT; + +let failures = 0; +function check(label, ok, detail) { + if (ok) { + console.log(` ok ${label}`); + } else { + failures++; + console.log(` FAIL ${label}${detail ? ` -- ${detail}` : ''}`); + } +} + +(async () => { + // The image ships a pinned Chromium that may not match the version this + // Playwright build expects; point at it rather than downloading another. + const browser = await chromium.launch( + process.env.ORCPUB_CHROME ? { executablePath: process.env.ORCPUB_CHROME } : {}); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + + // JS exceptions are always the app's problem. Resource failures are tracked + // by URL so environmental ones (uncompiled CSS, blocked webfonts) can be + // told apart from a genuinely missing app asset -- the console message text + // alone doesn't name the resource. + const jsErrors = []; + page.on('pageerror', e => jsErrors.push(String(e))); + + const IGNORABLE = /styles\.css|fonts\.googleapis|fonts\.gstatic|figwheel|favicon/i; + const badResources = []; + page.on('response', r => { + if (r.status() >= 400 && !IGNORABLE.test(r.url())) { + badResources.push(`${r.status()} ${r.url()}`); + } + }); + page.on('requestfailed', r => { + if (!IGNORABLE.test(r.url())) { + badResources.push(`${r.failure().errorText} ${r.url()}`); + } + }); + + console.log(`\nportrait compositor e2e -- ${BASE}\n`); + + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + await page.waitForSelector('#app', { timeout: 30000 }); + + // The cookie banner overlays the drawer's footer buttons; dismiss it first. + const cookieBtn = page.locator('#cookie-btn'); + if (await cookieBtn.count()) { + await cookieBtn.click().catch(() => {}); + await page.waitForTimeout(200); + } + + // --- reach the launcher --------------------------------------------- + const descTab = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await descTab.count()) { + await descTab.click(); + await page.waitForTimeout(300); + } + + const launcher = page.locator('.pl-launcher'); + await launcher.waitFor({ state: 'visible', timeout: 15000 }); + check('"Compose portrait" launcher renders in Description tab', true); + + // --- open the drawer ------------------------------------------------- + await launcher.click(); + const drawer = page.locator('.pl-drawer'); + await drawer.waitFor({ state: 'visible', timeout: 10000 }); + check('drawer opens on launcher click', await drawer.isVisible()); + + const pickerCount = await page.locator('.pl-picker').count(); + check('all 10 layer pickers render', pickerCount === 10, `saw ${pickerCount}`); + + const emptyHint = page.locator('.pl-empty-hint'); + check('empty state shown before anything is picked', await emptyHint.isVisible()); + + // --- randomize ------------------------------------------------------- + await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await page.waitForTimeout(400); + + const layerCount = await page.locator('.pl-portrait-frame .portrait-layer').count(); + check('randomize composes layers', layerCount > 0, `${layerCount} layers`); + check('empty hint gone after randomize', !(await emptyHint.isVisible().catch(() => false))); + + const seed = await page.locator('.pl-seed-row code').textContent().catch(() => null); + check('seed displayed after randomize', !!seed && seed.length > 0, `seed=${seed}`); + + // layers must be mask divs carrying a background tint -- that is the + // mechanic that lets one asset render in any character color + const firstLayerBg = await page.locator('.pl-portrait-frame .portrait-layer').first() + .evaluate(el => getComputedStyle(el).backgroundColor); + check('layer renders as tinted mask div', /^rgb/.test(firstLayerBg), firstLayerBg); + + // --- pick a specific swatch ----------------------------------------- + const headPicker = page.locator('.pl-picker').filter({ hasText: 'Head' }).first(); + const headSwatch = headPicker.locator('.pl-sw:not(.pl-sw-none)').first(); + await headSwatch.click(); + await page.waitForTimeout(200); + check('swatch click marks it selected', + (await headSwatch.getAttribute('class')).includes('selected')); + + // --- base color on a slot ------------------------------------------- + const hairSlot = page.locator('.pl-slot').filter({ hasText: 'Hair' }).first(); + await hairSlot.locator('.pl-slot-swatch').click(); + await page.waitForTimeout(200); + + const panel = page.locator('.pl-slot-panel'); + check('slot panel opens on chip tap (not hijacked by native picker)', + await panel.isVisible()); + + const subRows = await panel.locator('.pl-sub-row').count(); + check('hair panel lists its 4 pieces', subRows === 4, `saw ${subRows}`); + + await panel.locator('.pl-preset').first().click(); + await page.waitForTimeout(200); + check('panel stays open after picking a preset', await panel.isVisible()); + + const hairLayerBg = await page.locator('.pl-portrait-frame .portrait-layer').first() + .evaluate(el => getComputedStyle(el).backgroundColor); + check('base color repaints the portrait', /^rgb/.test(hairLayerBg), hairLayerBg); + + // --- shade one piece -------------------------------------------------- + const shadeSlider = panel.locator('.pl-sub-row input[type=range]').first(); + // React wires onChange for range inputs to the `input` event. + await shadeSlider.evaluate(el => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value').set; + setter.call(el, '30'); + el.dispatchEvent(new Event('input', { bubbles: true })); + }); + await page.waitForTimeout(250); + const tweakBadge = hairSlot.locator('.pl-slot-tweaks'); + check('shading a piece raises the tweak badge', await tweakBadge.count() > 0); + + if (SHOT) { + await page.screenshot({ path: SHOT, fullPage: false }); + console.log(`\n screenshot -> ${SHOT}`); + } + + // --- save and verify it reaches the summary -------------------------- + await page.locator('.pl-btn-primary', { hasText: 'Save portrait' }).click(); + await page.waitForTimeout(600); + check('drawer closes on save', !(await drawer.isVisible().catch(() => false))); + + const summaryComposite = await page.locator('.portrait-composite .portrait-layer').count(); + check('composed portrait renders in the character summary', + summaryComposite > 0, `${summaryComposite} layers in summary`); + + // --- reopen: draft should rehydrate from the saved character --------- + await page.locator('.pl-launcher').click(); + await drawer.waitFor({ state: 'visible', timeout: 10000 }); + const reopened = await page.locator('.pl-portrait-frame .portrait-layer').count(); + check('reopening rehydrates the saved portrait', reopened > 0, `${reopened} layers`); + + const hairSlotAfter = page.locator('.pl-slot').filter({ hasText: 'Hair' }).first(); + check('saved base color survives the round trip', + await hairSlotAfter.locator('.pl-slot-swatch.unset').count() === 0); + check('saved per-piece tweak survives the round trip', + await hairSlotAfter.locator('.pl-slot-tweaks').count() > 0); + + // --- nothing broke underneath ---------------------------------------- + check('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3).join(' | ')); + check('no failed app resources', badResources.length === 0, + badResources.slice(0, 3).join(' | ')); + + await browser.close(); + + console.log(`\n${failures === 0 ? 'PASS' : `FAIL (${failures})`}\n`); + process.exit(failures === 0 ? 0 : 1); +})().catch(e => { + console.error('\nharness error:', e); + process.exit(1); +}); From 89ce91d1e2c69524aa77480dd8c9d208ecf73bcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:23:37 +0000 Subject: [PATCH 006/170] Print composed portraits in the exported PDF A composed portrait lives only as stacked CSS masks in the browser, which PDFBox cannot read, so exports were silently dropping it -- compose a portrait, export a sheet, get an empty portrait box. The client now bakes the layers to a PNG at export time: for each layer it draws the asset, switches to source-in and flood-fills the tint, which paints the color only where the asset is opaque, then composites in z-order. That reproduces exactly what the drawer shows. The base64 goes out with the export spec as :portrait-png, and the server decodes it straight into the existing draw-image-bytes! path -- so it takes precedence over image-url, matching how the sheet and summary already resolve the two, and costs no network time inside an export slot (there is no URL to fetch). Failure is non-fatal at every step: a layer that will not decode is skipped, a bake that throws resolves to nil, and malformed base64 server-side returns nil rather than throwing -- in each case the sheet still prints, just without the picture. Posted PNGs are capped at 2 MB, checked against the encoded length before anything is allocated. Adds test/browser/portrait_pdf_export_e2e.js, which composes through the real UI, exports, and asserts the request carried :portrait-png and the returned PDF embeds an image XObject. It also replays the same POST with the portrait stripped and requires that PDF to be smaller -- without that control the "embeds an image" assertion would pass on artwork already in the sheet template. Chrome renders a PDF navigation in its own viewer, so raw bytes come from replaying the captured body rather than from response.body(). 460 tests / 3579 assertions green; both browser e2e suites pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- src/clj/orcpub/routes.clj | 42 ++++++- src/cljs/orcpub/dnd/e5/portrait.cljs | 65 +++++++++++ src/cljs/orcpub/dnd/e5/views.cljs | 22 +++- test/browser/portrait_pdf_export_e2e.js | 145 ++++++++++++++++++++++++ test/clj/orcpub/portrait_png_test.clj | 34 ++++++ 5 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 test/browser/portrait_pdf_export_e2e.js create mode 100644 test/clj/orcpub/portrait_png_test.clj diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index c82cada40..d9a20e2d6 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -647,6 +647,34 @@ n kind limit))) (take limit cards))) +(def ^:private max-portrait-png-bytes + "Ceiling on a posted composed portrait. The client bakes a 600x750 PNG of + flat-tinted shapes, which lands well under 200 KB; 2 MB is generous + headroom that still refuses a request body pretending to be a picture." + (* 2 1024 1024)) + +(defn decode-portrait-png + "Decode a base64 PNG posted with the export into {:data bytes :jpg? false}, + or nil. + + A composed portrait has no URL to fetch -- the client rasterizes its layers + and sends the bytes -- so this is the counterpart to pdf/fetch-image for + that path. Returns nil rather than throwing for the same reason fetch-image + does: a picture that will not decode must not cost the character their + sheet." + [b64] + (try + (when (and (string? b64) (not (s/blank? b64))) + ;; Base64 is 4 chars per 3 bytes, so the encoded length bounds the decode + ;; before any of it is allocated. + (when (<= (long (* 0.75 (count b64))) max-portrait-png-bytes) + (let [data (.decode (java.util.Base64/getDecoder) ^String b64)] + (when (pos? (alength data)) + {:data data :jpg? false})))) + (catch Exception e + (println "pdf: composed portrait failed to decode -" (.getMessage e)) + nil))) + (defn add-spell-cards! "Appends spell card pages, nine to a sheet, each with its back. @@ -921,7 +949,7 @@ {:error :invalid-pdf-data} e)))) - {:keys [image-url image-url-failed faction-image-url faction-image-url-failed spells-known custom-spells spell-save-dcs spell-attack-mods print-spell-cards? magic-items-known print-magic-item-cards? print-character-sheet-style? print-spell-card-dc-mod? print-card-back-logo? card-back-logo-faded? print-bw? bw-faded? print-spell-annotations? spell-relabels spell-headings character-name class-level player-name flatten?]} fields + {:keys [image-url image-url-failed faction-image-url faction-image-url-failed spells-known custom-spells spell-save-dcs spell-attack-mods print-spell-cards? magic-items-known print-magic-item-cards? print-character-sheet-style? print-spell-card-dc-mod? print-card-back-logo? card-back-logo-faded? print-bw? bw-faded? print-spell-annotations? spell-relabels spell-headings character-name class-level player-name flatten? portrait-png]} fields ;; Printer-friendly mode: monochrome spell-card icons + a forced solid-black ;; card-back logo (no color anywhere on the cards). bw-faded? picks the @@ -1054,8 +1082,16 @@ (re-matches #"^https?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]" url) url)) - portrait (some-> (wanted image-url image-url-failed) - (as-> u (future (pdf/fetch-image u)))) + ;; A composed (paper-doll) portrait arrives already rendered, as + ;; base64 PNG the client baked from its layers -- there is no URL to + ;; fetch, so it costs no network time inside the export slot. It + ;; takes precedence over image-url, matching how the character sheet + ;; and summary resolve the two. Malformed base64 degrades to "no + ;; portrait" rather than failing the export. + composed (when portrait-png (delay (decode-portrait-png portrait-png))) + portrait (or composed + (some-> (wanted image-url image-url-failed) + (as-> u (future (pdf/fetch-image u))))) faction (some-> (wanted faction-image-url faction-image-url-failed) (as-> u (future (pdf/fetch-image u))))] (when-let [{:keys [data jpg?]} (some-> portrait deref)] diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index 0a4bad4f1..7fa3e4ec3 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -57,6 +57,71 @@ {:style (mask-style (:asset/url asset) (pa/tint-for portrait layer-key) z)}])) pa/layer-order)]))) +;; ---------------- rasterization (for PDF export) ---------------- +;; +;; On screen a layer is a CSS mask -- shape from the asset's alpha, color from +;; background-color. Neither PDFBox nor a canvas understands that, so an export +;; has to bake it: for each layer draw the asset, switch to "source-in", and +;; flood-fill the tint, which paints the color only where the asset is opaque. +;; Compositing those in z-order reproduces exactly what the drawer shows. + +(def ^:private raster-width 600) +(def ^:private raster-height 750) ;; 4:5, matching the on-screen frame + +(defn- load-image [url] + (js/Promise. + (fn [resolve _reject] + (let [img (js/Image.)] + (set! (.-onload img) #(resolve img)) + ;; A layer that will not decode is skipped, not fatal -- the rest of + ;; the portrait is still worth printing. + (set! (.-onerror img) #(resolve nil)) + (set! (.-src img) url))))) + +(defn rasterize + "Bake `portrait` into a PNG. Resolves to base64 (no data: prefix), or nil + when there is nothing to draw or the browser cannot do it." + [portrait] + (let [selected (keep (fn [k] + (when-let [asset (some->> (get-in portrait [:layers k]) + :asset/id + (pa/asset-by-id k))] + [k asset])) + pa/layer-order)] + (if (empty? selected) + (js/Promise.resolve nil) + (-> (js/Promise.all (clj->js (map (fn [[_ a]] (load-image (:asset/url a))) selected))) + (.then + (fn [imgs] + (try + (let [canvas (.createElement js/document "canvas") + _ (set! (.-width canvas) raster-width) + _ (set! (.-height canvas) raster-height) + ctx (.getContext canvas "2d") + tmp (.createElement js/document "canvas") + _ (set! (.-width tmp) raster-width) + _ (set! (.-height tmp) raster-height) + tctx (.getContext tmp "2d")] + (doseq [[[layer-key _] img] (map vector selected (array-seq imgs)) + :when img] + (.clearRect tctx 0 0 raster-width raster-height) + (set! (.-globalCompositeOperation tctx) "source-over") + (.drawImage tctx img 0 0 raster-width raster-height) + ;; paint the tint through the asset's alpha + (set! (.-globalCompositeOperation tctx) "source-in") + (set! (.-fillStyle tctx) (pa/tint-for portrait layer-key)) + (.fillRect tctx 0 0 raster-width raster-height) + (.drawImage ctx tmp 0 0)) + (some-> (.toDataURL canvas "image/png") + (s/split #",") + second)) + ;; Never let a failed bake block the export -- the sheet is + ;; worth more than the picture. + (catch :default e + (js/console.warn "portrait rasterize failed" e) + nil)))) + (.catch (fn [_] nil)))))) + ;; ---------------- drawer chrome ---------------- (def drawer-styles " diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 795994f7c..67ba32018 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -128,12 +128,26 @@ (defn export-pdf "Returns an onClick handler that generates and submits the PDF. - plugin-data map is pre-subscribed by the calling component." + plugin-data map is pre-subscribed by the calling component. + + A composed portrait exists only as stacked CSS masks in the browser, so it + is baked to a PNG here and posted with the spec; the server embeds those + bytes directly instead of fetching an image URL. Rasterizing waits on image + decode, hence the async submit -- and a failure resolves to nil so the + sheet still prints, just without the picture." [built-char id plugin-data & [options]] (fn [_] - (let [field (.getElementById js/document "fields-input")] - (aset field "value" (str (pdf-spec/make-spec built-char id options plugin-data))) - (.submit (.getElementById js/document "download-form"))))) + (let [field (.getElementById js/document "fields-input") + spec (pdf-spec/make-spec built-char id options plugin-data) + portrait (char/portrait built-char)] + (-> (if (seq (:layers portrait)) + (portrait/rasterize portrait) + (js/Promise.resolve nil)) + (.then (fn [png-b64] + (aset field "value" + (str (cond-> spec + png-b64 (assoc :portrait-png png-b64)))) + (.submit (.getElementById js/document "download-form")))))))) (defn download-form [built-char] [:form.download-form diff --git a/test/browser/portrait_pdf_export_e2e.js b/test/browser/portrait_pdf_export_e2e.js new file mode 100644 index 000000000..2558af3de --- /dev/null +++ b/test/browser/portrait_pdf_export_e2e.js @@ -0,0 +1,145 @@ +// A composed portrait must reach the exported PDF. +// +// The compositor draws with CSS masks, which PDFBox cannot read, so the client +// bakes the layers to a PNG and posts it with the export spec. This drives the +// real builder: compose a portrait, export, and assert the request carried +// portrait-png and the server answered with a PDF that actually embeds it. +// +// lein fig:build && lein e2e-server +// node test/browser/portrait_pdf_export_e2e.js +// +// Exits non-zero on the first failed check. + +const { chromium } = require('playwright'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; + +let failures = 0; +function check(label, ok, detail) { + if (ok) console.log(` ok ${label}`); + else { failures++; console.log(` FAIL ${label}${detail ? ` -- ${detail}` : ''}`); } +} + +(async () => { + const browser = await chromium.launch( + process.env.ORCPUB_CHROME ? { executablePath: process.env.ORCPUB_CHROME } : {}); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + + console.log(`\nportrait -> pdf export e2e -- ${BASE}\n`); + + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + await page.waitForSelector('#app', { timeout: 30000 }); + const cookieBtn = page.locator('#cookie-btn'); + if (await cookieBtn.count()) { await cookieBtn.click().catch(() => {}); await page.waitForTimeout(200); } + + // --- compose and save a portrait ------------------------------------- + const descTab = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await descTab.count()) { await descTab.click(); await page.waitForTimeout(300); } + + await page.locator('.pl-launcher').click(); + await page.locator('.pl-drawer').waitFor({ state: 'visible', timeout: 10000 }); + await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await page.waitForTimeout(400); + const composed = await page.locator('.pl-portrait-frame .portrait-layer').count(); + check('portrait composed before export', composed > 0, `${composed} layers`); + await page.locator('.pl-btn-primary', { hasText: 'Save portrait' }).click(); + await page.waitForTimeout(600); + + // --- capture the export POST ----------------------------------------- + // The form targets _blank, so the POST belongs to the new tab, not the + // opener -- listen at context level or it is never seen. + // The form targets _blank, so the POST belongs to the new tab, not the + // opener -- listen at context level or it is never seen. The response body + // is captured here too: a POST result cannot be re-fetched by navigating. + let posted = null; + let pdfBuf = null; + ctx.on('request', r => { + if (r.url().includes('/character.pdf') && r.method() === 'POST') { + posted = r.postData() || ''; + } + }); + let pdfContentType = null; + ctx.on('response', r => { + if (r.url().includes('/character.pdf') && r.request().method() === 'POST') { + pdfContentType = r.headers()['content-type'] || ''; + } + }); + + // Same flow the export-busy e2e drives: Export -> pick a sheet style + // (Create PDF stays pointer-events:none until one is chosen) -> Create PDF. + const visible = async (loc) => { + const n = await loc.count(); + for (let i = 0; i < n; i++) { + const el = loc.nth(i); + if (await el.isVisible()) return el; + } + throw new Error('no visible match'); + }; + + (await visible(page.locator('button:has-text("Export")'))).click(); + await page.waitForTimeout(1200); + (await visible(page.locator('select'))).selectOption('1'); + await page.waitForTimeout(600); + + // The form targets _blank, so the PDF lands in a new tab. + const pdfPagePromise = ctx.waitForEvent('page', { timeout: 30000 }); + (await visible(page.locator('button:has-text("Create PDF")'))).click(); + + const pdfPage = await pdfPagePromise.catch(() => null); + await page.waitForTimeout(2500); + + // --- the request carried the baked portrait -------------------------- + check('export POSTed to /character.pdf', posted !== null); + check('request carried :portrait-png', !!posted && posted.includes('portrait-png'), + posted ? `${posted.length} bytes of body` : 'no body'); + + // The body is form-urlencoded EDN, so decode before measuring. + const decoded = posted ? decodeURIComponent(posted.replace(/\+/g, ' ')) : ''; + const b64 = (decoded.match(/:portrait-png\s+"([A-Za-z0-9+/=]+)"/) || [])[1] || ''; + check('baked PNG is a non-trivial payload', b64.length > 1000, `${b64.length} b64 chars`); + + // --- and the server produced a PDF that embeds it -------------------- + // Chrome renders a PDF navigation in its own viewer, so response.body() + // yields the viewer's HTML shell rather than the file. Replay the exact + // body the browser posted to get the real bytes back. + check('response was served as a PDF', /application\/pdf/i.test(pdfContentType || ''), + pdfContentType); + + const replay = await ctx.request.post(`${BASE}/character.pdf`, { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + data: posted, + }).catch(() => null); + pdfBuf = replay ? await replay.body().catch(() => null) : null; + + const pdfBytes = pdfBuf ? pdfBuf.length : 0; + const pdfOk = !!pdfBuf && pdfBuf.slice(0, 4).toString() === '%PDF'; + // PDFBox writes an embedded raster as an XObject with subtype /Image. + const embedsImage = !!pdfBuf && (pdfBuf.includes(Buffer.from('/Subtype /Image')) + || pdfBuf.includes(Buffer.from('/Subtype/Image'))); + check('server returned a PDF', pdfOk, `${pdfBytes} bytes`); + check('PDF embeds an image XObject', embedsImage); + + // Differential check: the same request minus the portrait must come back + // materially smaller. Without this, "embeds an image" could be satisfied by + // artwork already in the sheet template rather than by the posted portrait. + const stripped = posted.replace(/%3Aportrait-png\+%22[A-Za-z0-9%2B/%3D]+%22/, '') + .replace(/:portrait-png\s+"[A-Za-z0-9+/=]+"/, ''); + const bare = await ctx.request.post(`${BASE}/character.pdf`, { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + data: stripped, + }).catch(() => null); + const bareBuf = bare ? await bare.body().catch(() => null) : null; + const bareBytes = bareBuf ? bareBuf.length : 0; + + check('portrait payload was actually stripped for the control', + stripped.length < posted.length, + `${posted.length} -> ${stripped.length}`); + check('PDF without the portrait is materially smaller', + bareBytes > 0 && pdfBytes > bareBytes, + `with=${pdfBytes} without=${bareBytes}`); + + await browser.close(); + console.log(`\n${failures === 0 ? 'PASS' : `FAIL (${failures})`}\n`); + process.exit(failures === 0 ? 0 : 1); +})().catch(e => { console.error('\nharness error:', e); process.exit(1); }); diff --git a/test/clj/orcpub/portrait_png_test.clj b/test/clj/orcpub/portrait_png_test.clj new file mode 100644 index 000000000..cf303c5a8 --- /dev/null +++ b/test/clj/orcpub/portrait_png_test.clj @@ -0,0 +1,34 @@ +(ns orcpub.portrait-png-test + "decode-portrait-png is the composed-portrait counterpart to pdf/fetch-image: + the client bakes its CSS-mask layers to a PNG and posts the bytes, so there + is no URL to fetch. Like fetch-image it must return nil rather than throw -- + a picture that will not decode must not cost the character their sheet." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.routes :as routes])) + +(def ^:private tiny-png-b64 + ;; 1x1 transparent PNG + (str "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")) + +(deftest decodes-valid-base64-png + (let [{:keys [data jpg?]} (routes/decode-portrait-png tiny-png-b64)] + (is (bytes? data)) + (is (pos? (alength data))) + (is (false? jpg?) "PNG bytes are re-encoded by PDFBox, never passed as JPEG") + (testing "decoded bytes carry the PNG magic number" + (is (= [-119 80 78 71] (take 4 (vec data))))))) + +(deftest returns-nil-for-unusable-input + (is (nil? (routes/decode-portrait-png nil)) "absent") + (is (nil? (routes/decode-portrait-png "")) "blank") + (is (nil? (routes/decode-portrait-png " ")) "whitespace") + (is (nil? (routes/decode-portrait-png "!!!not base64!!!")) "malformed") + (is (nil? (routes/decode-portrait-png 42)) "non-string") + (is (nil? (routes/decode-portrait-png (apply str (repeat 8 "=")))) + "decodes to zero bytes")) + +(deftest refuses_oversize_payload + (testing "a body pretending to be a picture is rejected before allocating it" + (let [huge (apply str (repeat (* 4 1024 1024) "A"))] ; ~3 MB decoded + (is (nil? (routes/decode-portrait-png huge)))))) From 150b78f2c0e7b6e4f6baadd227ae2f14643fecce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:40:34 +0000 Subject: [PATCH 007/170] Render composed portraits for og:image; fix empty share cards A share crawler has no browser, so the trick the PDF export uses -- let the client bake the layers with canvas -- is unavailable here. Adds orcpub.portrait-render, which composites server-side with no new dependency: vector assets reuse pdf/svg-path-ops, the same `d`-attribute parser the card icons are drawn with, whose [:move]/[:line]/[:curve]/[:close] output maps straight onto a Java2D Path2D; raster assets (what the real art will be) are tinted with AlphaComposite/SrcIn, the server-side twin of the canvas 'source-in' composite the client uses. GET on the new /dnd/e5/characters/:id/portrait.png serves the PNG, and character-page points og:image at it when a portrait exists, falling back to image-url otherwise -- the same precedence the sheet, summary and PDF already use. Also fixes a pre-existing bug found while wiring this: character-summary-for-id pulled ::se/summary and ::se/values but returned only the summary submap, while character-page went on to destructure both back out of THAT -- so og:title and og:image were nil on every shared character link and crawlers fell back to the site defaults. It now returns the whole pull. There is a regression test for the title specifically. Access matches the character page itself (unauthenticated by id): that page already exposes the same character's name, race and description in its meta tags, so this is not a new exposure class. Missing or unrenderable portraits 404 so a crawler falls back rather than seeing a 500, and a single corrupt layer is skipped rather than losing the whole portrait. Tests: 13 unit tests for the renderer (including differential checks that slot colors and per-piece overrides actually change the pixels, and that a bad tint does not cost the portrait) and 7 integration tests against in-memory Datomic covering the save round trip, the endpoint, and both share-card branches. This path is server-only, so it is tested server-side rather than through a browser. 480 tests / 3625 assertions green; both browser e2e suites still pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- src/clj/orcpub/portrait_render.clj | 157 +++++++++++++++++++++++ src/clj/orcpub/routes.clj | 58 +++++++-- src/cljc/orcpub/route_map.cljc | 6 +- test/clj/orcpub/portrait_og_test.clj | 134 +++++++++++++++++++ test/clj/orcpub/portrait_render_test.clj | 121 +++++++++++++++++ 5 files changed, 467 insertions(+), 9 deletions(-) create mode 100644 src/clj/orcpub/portrait_render.clj create mode 100644 test/clj/orcpub/portrait_og_test.clj create mode 100644 test/clj/orcpub/portrait_render_test.clj diff --git a/src/clj/orcpub/portrait_render.clj b/src/clj/orcpub/portrait_render.clj new file mode 100644 index 000000000..1cefb7308 --- /dev/null +++ b/src/clj/orcpub/portrait_render.clj @@ -0,0 +1,157 @@ +(ns orcpub.portrait-render + "Server-side rasterization of a composed paper-doll portrait. + + The PDF path gets its picture from the browser, which bakes the layers with + canvas before posting them. A share crawler has no browser: it reads + og:image out of the page HTML and fetches that URL, so the image has to be + rendered here. + + No new dependency is needed. Vector assets reuse pdf/svg-path-ops -- the + same `d`-attribute parser the card icons are drawn with -- and its + [:move]/[:line]/[:curve]/[:close] output maps directly onto a Java2D + Path2D. Raster assets (what the real illustrator's art will be) are tinted + with AlphaComposite/SrcIn, which is the exact server-side equivalent of the + canvas 'source-in' trick the client uses. + + Every failure degrades to 'no portrait' rather than throwing: a share card + without a picture is a state the page already handles, and it must not cost + the character their page." + (:require [clojure.string :as s] + [orcpub.pdf :as pdf] + [orcpub.dnd.e5.portrait-assets :as pa]) + (:import [java.awt AlphaComposite BasicStroke Color Graphics2D RenderingHints] + [java.awt.geom Path2D$Double] + [java.awt.image BufferedImage] + [java.io ByteArrayInputStream ByteArrayOutputStream] + [java.util Base64] + [javax.imageio ImageIO])) + +(def ^:private default-width 600) +(def ^:private default-height 750) ;; 4:5, matching the on-screen frame + +;; ---------- data URIs ---------- + +(defn parse-data-uri + "Split a `data:;base64,` URI into {:mime :bytes}, or nil. + + Only base64 data URIs are handled: that is what the asset registry emits, + and the raw-utf8 form would have to be re-parsed for percent-encoding." + [uri] + (when (string? uri) + (when-let [[_ mime b64] (re-matches #"(?s)data:([^;,]+);base64,(.+)" uri)] + (try + {:mime mime :bytes (.decode (Base64/getDecoder) ^String b64)} + (catch Exception _ nil))))) + +(defn- svg-view-box + "The [w h] a path's coordinates are expressed in, defaulting to the + registry's 400x500 when the document does not say." + [svg] + (or (when-let [[_ _ _ w h] (re-find #"viewBox\s*=\s*[\"']([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)[\"']" svg)] + [(Double/parseDouble w) (Double/parseDouble h)]) + [400.0 500.0])) + +;; ---------- colors ---------- + +(defn hex->color + "#rrggbb to a Color, or nil. Unparseable input yields nil so a bad stored + tint skips the layer instead of failing the render." + [hex] + (when (and (string? hex) (re-matches #"#[0-9a-fA-F]{6}" hex)) + (Color. (Integer/parseInt (subs hex 1 3) 16) + (Integer/parseInt (subs hex 3 5) 16) + (Integer/parseInt (subs hex 5 7) 16)))) + +;; ---------- one layer ---------- + +(defn ops->path2d + "Build a Path2D from pdf/svg-path-ops output, scaled by sx/sy." + [ops sx sy] + (let [p (Path2D$Double.)] + (doseq [op ops] + (case (first op) + :move (let [[_ x y] op] (.moveTo p (* x sx) (* y sy))) + :line (let [[_ x y] op] (.lineTo p (* x sx) (* y sy))) + :curve (let [[_ x1 y1 x2 y2 x y] op] + (.curveTo p (* x1 sx) (* y1 sy) (* x2 sx) (* y2 sy) (* x sx) (* y sy))) + :close (.closePath p) + nil)) + p)) + +(defn- draw-vector-layer! [^Graphics2D g svg ^Color color w h] + (when-let [d (pdf/last-svg-path svg)] + (let [[vw vh] (svg-view-box svg) + path (ops->path2d (pdf/svg-path-ops d) (/ w vw) (/ h vh))] + (.setColor g color) + (.fill g path) + ;; The registry's placeholder art strokes as well as fills, in the same + ;; colour -- match it so the silhouette has the same weight on screen + ;; and in a share card. + (.setStroke g (BasicStroke. 2.0 BasicStroke/CAP_ROUND BasicStroke/JOIN_ROUND)) + (.draw g path)))) + +(defn- draw-raster-layer! [^Graphics2D g ^bytes data ^Color color w h] + (when-let [src (ImageIO/read (ByteArrayInputStream. data))] + ;; Tint through the source's alpha: draw it, then flood the colour with + ;; SrcIn so it lands only where the asset is opaque. Server-side twin of + ;; the canvas 'source-in' composite the client uses. + (let [tinted (BufferedImage. w h BufferedImage/TYPE_INT_ARGB) + tg (.createGraphics tinted)] + (try + (.setRenderingHint tg RenderingHints/KEY_INTERPOLATION + RenderingHints/VALUE_INTERPOLATION_BILINEAR) + (.drawImage tg src 0 0 w h nil) + (.setComposite tg AlphaComposite/SrcIn) + (.setColor tg color) + (.fillRect tg 0 0 w h) + (finally (.dispose tg))) + (.drawImage g tinted 0 0 nil)))) + +;; ---------- the portrait ---------- + +(defn render + "Composite `portrait` ({:layers :colors :tweaks}) into a BufferedImage, or + nil when it selects nothing drawable." + ([portrait] (render portrait default-width default-height)) + ([portrait w h] + (let [drawable (keep (fn [k] + (when-let [asset (some->> (get-in portrait [:layers k]) + :asset/id + (pa/asset-by-id k))] + [k asset])) + pa/layer-order)] + (when (seq drawable) + (let [img (BufferedImage. w h BufferedImage/TYPE_INT_ARGB) + g (.createGraphics img)] + (try + (.setRenderingHint g RenderingHints/KEY_ANTIALIASING + RenderingHints/VALUE_ANTIALIAS_ON) + (.setRenderingHint g RenderingHints/KEY_STROKE_CONTROL + RenderingHints/VALUE_STROKE_PURE) + (doseq [[layer-key asset] drawable] + ;; One unreadable layer is skipped, not fatal -- the rest of the + ;; portrait is still worth showing. + (try + (let [{:keys [mime bytes]} (parse-data-uri (:asset/url asset)) + color (hex->color (pa/tint-for portrait layer-key))] + (when (and mime bytes color) + (if (s/includes? mime "svg") + (draw-vector-layer! g (String. ^bytes bytes "UTF-8") color w h) + (draw-raster-layer! g bytes color w h)))) + (catch Exception e + (println "portrait-render: skipped layer" layer-key "-" (.getMessage e))))) + (finally (.dispose g))) + img))))) + +(defn render-png + "PNG bytes for `portrait`, or nil when there is nothing to draw." + ([portrait] (render-png portrait default-width default-height)) + ([portrait w h] + (try + (when-let [img (render portrait w h)] + (let [out (ByteArrayOutputStream.)] + (when (ImageIO/write img "png" out) + (.toByteArray out)))) + (catch Exception e + (println "portrait-render: render failed -" (.getMessage e)) + nil)))) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index d9a20e2d6..e19315081 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -36,6 +36,7 @@ [orcpub.email :as email] [orcpub.index :refer [index-page]] [orcpub.pdf :as pdf] + [orcpub.portrait-render :as portrait-render] [orcpub.config :as config] [orcpub.registration :as registration] [orcpub.entity.strict :as se] @@ -1560,10 +1561,18 @@ {:status 400 :body problems} {:status 200 :body character}))) -(defn character-summary-for-id [db id] - ;; Fixed: bare destructuring outside let silently returned nil - (let [{:keys [::se/summary]} (d/pull db '[::se/summary {::se/values [::char5e/description ::char5e/image-url]}] id)] - summary)) +(defn character-summary-for-id + "The share-card data for a character: {::se/summary … ::se/values …}. + + Returns the WHOLE pull, not just ::se/summary. It used to return the summary + submap while its caller went on to destructure ::se/summary and ::se/values + back out of it -- so every og:title and og:image came out nil and shared + links fell back to the site defaults." + [db id] + (d/pull db + '[::se/summary + {::se/values [::char5e/description ::char5e/image-url ::char5e/portrait]}] + id)) (defn get-character "Retrieves a character by ID. @@ -1782,19 +1791,50 @@ [route-map/unsubscribe-success-route] [route-map/dnd-e5-orcacle-page-route]]) +(defn character-portrait-png + "PNG of a character's composed portrait, for og:image. + + A crawler has no browser, so unlike the PDF path (where the client bakes + the layers with canvas) this is rendered here. Access matches the character + page itself -- unauthenticated by id -- because that page already exposes + the same character's name, race and description in its meta tags. + + 404 when the character has no composed portrait, so a crawler falls back to + whatever og:image the page did declare." + [{:keys [db] {:keys [id]} :path-params}] + (let [portrait (some-> (d/pull db '[{::se/values [::char5e/portrait]}] id) + ::se/values + ::char5e/portrait + char5e/parse-portrait)] + (if-let [png (some-> portrait portrait-render/render-png)] + {:status 200 + :headers {"Content-Type" "image/png" + ;; Portraits change rarely and a crawler may refetch often. + "Cache-Control" "public, max-age=300"} + :body (ByteArrayInputStream. png)} + {:status 404 :body "no composed portrait"}))) + (defn character-page [{:keys [db conn identity headers scheme uri] {:keys [id]} :path-params :as request}] (let [host (headers "host") - {:keys [::se/summary - ::se/values] :as summary-obj} (character-summary-for-id db id) + {:keys [::se/summary ::se/values]} (character-summary-for-id db id) {:keys [::char5e/character-name]} summary {:keys [::char5e/description - ::char5e/image-url]} values] + ::char5e/image-url + ::char5e/portrait]} values + ;; A composed portrait wins over a pasted URL, the same precedence the + ;; sheet, the summary and the PDF use. It is served as a real PNG + ;; because crawlers will not render CSS masks -- or, mostly, SVG. + composed? (seq (:layers (char5e/parse-portrait portrait))) + share-image (if composed? + (str "https://" host + (route-map/path-for route-map/dnd-e5-char-portrait-route :id id)) + image-url)] (index-page-response request {:title character-name :description (str (character-summary-description summary) ". " description) - :image-url image-url} + :image-url share-image} {"X-Frame-Options" "ALLOW-FROM https://www.worldanvil.com/"}))) (def header-style @@ -1906,6 +1946,8 @@ [(route-map/path-for route-map/dnd-e5-char-page-route :id ":id") ^:interceptors [parse-id] {:get `character-page}] + [(route-map/path-for route-map/dnd-e5-char-portrait-route :id ":id") ^:interceptors [parse-id] + {:get `character-portrait-png}] [(route-map/path-for route-map/dnd-e5-char-parties-route) ^:interceptors [check-auth] {:post `party/create-party :get `party/parties}] diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 1264249d8..587655299 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -8,6 +8,9 @@ (def dnd-e5-char-route :char-5e) (def dnd-e5-char-report-route :char-load-report-5e) (def dnd-e5-char-page-route :char-5e-page) +;; Server-rendered PNG of a composed portrait, for og:image -- a share crawler +;; has no browser to bake the layers the way the PDF export does. +(def dnd-e5-char-portrait-route :char-5e-portrait) (def dnd-e5-char-list-page-route :char-list-5e-page) (def dnd-e5-char-summary-list-route :char-summary-list-5e-page) (def dnd-e5-char-parties-route :char-parties-5e) @@ -179,7 +182,8 @@ {"character-builder" dnd-e5-char-builder-route "newb-character-builder" dnd-e5-newb-char-builder-route "characters" {"" dnd-e5-char-list-page-route - ["/" :id] dnd-e5-char-page-route} + ["/" :id] dnd-e5-char-page-route + ["/" :id "/portrait.png"] dnd-e5-char-portrait-route} "orcacle" dnd-e5-orcacle-page-route "parties" dnd-e5-char-parties-page-route "background-builder" dnd-e5-background-builder-page-route diff --git a/test/clj/orcpub/portrait_og_test.clj b/test/clj/orcpub/portrait_og_test.clj new file mode 100644 index 000000000..d69453986 --- /dev/null +++ b/test/clj/orcpub/portrait_og_test.clj @@ -0,0 +1,134 @@ +(ns orcpub.portrait-og-test + "The og:image path for a composed portrait, end to end against a real + in-memory Datomic: save a character carrying a portrait, then check the + share card points at the rendered PNG and that the endpoint serves it. + + This is a server test rather than a browser one because that is where the + whole path lives -- a share crawler never runs the app's JavaScript." + (:require [clojure.test :refer [deftest is testing]] + [datomic.api :as d] + [datomock.core :as dm] + [orcpub.routes :as routes] + [orcpub.db.schema :as schema] + [orcpub.entity.strict :as se] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.portrait-assets :as pa]) + (:import [java.util UUID])) + +(defmacro with-conn [conn-binding & body] + `(let [uri# (str "datomic:mem:orcpub-og-test-" (UUID/randomUUID)) + ~conn-binding (do (d/create-database uri#) (d/connect uri#))] + (try ~@body (finally (d/delete-database uri#))))) + +(defn- a-portrait [] + {:layers (into {} + (keep (fn [k] + (when-let [a (first (pa/assets-for-layer k))] + [k {:artist/id (pa/artist-for-asset k (:asset/id a)) + :asset/id (:asset/id a)}]))) + [:head :shirt :eyes]) + :colors {:hair "#5c3a1e" :skin "#e8c69c"} + :tweaks {:head {:shade 10}}}) + +(defn- character-with [values] + {::se/selections [] + ::se/summary {::char5e/character-name "Sharey" + ::char5e/race-name "Halfling"} + ::se/values values}) + +(defn- save! [conn character] + (:body (routes/do-save-character (d/db conn) conn character {:user "testy"}))) + +(defn- setup [conn] + (let [c (dm/fork-conn conn)] + @(d/transact c schema/all-schemas) + @(d/transact c [{:orcpub.user/username "testy" + :orcpub.user/email "test@test.com"}]) + c)) + +;; ---------- the attribute actually persists ---------- + +(deftest portrait-survives-a-real-save + (testing "::char5e/portrait is a registered string attr, so the transaction + that used to fail on an unregistered nested map now succeeds" + (with-conn conn + (let [c (setup conn) + stored (pr-str (a-portrait)) + saved (save! c (character-with {::char5e/portrait stored})) + id (:db/id saved) + back (-> (d/pull (d/db c) '[{::se/values [::char5e/portrait]}] id) + ::se/values ::char5e/portrait)] + (is (some? id) "character saved") + (is (= stored back) "portrait round-tripped through Datomic verbatim") + (is (= (a-portrait) (char5e/parse-portrait back)) "and parses back"))))) + +;; ---------- the endpoint ---------- + +(deftest endpoint-serves-a-png-for-a-composed-portrait + (with-conn conn + (let [c (setup conn) + id (:db/id (save! c (character-with {::char5e/portrait (pr-str (a-portrait))}))) + resp (routes/character-portrait-png {:db (d/db c) :path-params {:id id}})] + (is (= 200 (:status resp))) + (is (= "image/png" (get-in resp [:headers "Content-Type"]))) + (is (re-find #"max-age" (get-in resp [:headers "Cache-Control"])) + "cached -- a crawler may refetch often") + (let [buf (byte-array 4)] + (.read ^java.io.InputStream (:body resp) buf) + (is (= [-119 80 78 71] (vec buf)) "body starts with the PNG magic number"))))) + +(deftest endpoint-404s-rather-than-500s-without-a-portrait + (with-conn conn + (let [c (setup conn) + id (:db/id (save! c (character-with {::char5e/image-url "https://example.com/a.png"})))] + (is (= 404 (:status (routes/character-portrait-png + {:db (d/db c) :path-params {:id id}}))) + "a pasted URL is not a composed portrait")))) + +(deftest endpoint-404s-for-an-unknown-character + (with-conn conn + (let [c (setup conn)] + (is (= 404 (:status (routes/character-portrait-png + {:db (d/db c) :path-params {:id 999999999}}))))))) + +;; ---------- the share card ---------- + +(defn- og-image-of + "The og:image content of a character's share card. Attribute order is not + assumed -- hiccup emits them alphabetically, so `content` precedes + `property`." + [c id] + (let [html (:body (routes/character-page + {:db (d/db c) :conn c :headers {"host" "example.test"} + :uri "/" :path-params {:id id}})) + tag (re-find #"]*og:image[^>]*>" html)] + (second (some->> tag (re-find #"content=\"([^\"]*)\""))))) + +(deftest share-card-points-at-the-rendered-portrait + (with-conn conn + (let [c (setup conn) + id (:db/id (save! c (character-with {::char5e/portrait (pr-str (a-portrait))}))) + og (og-image-of c id)] + (is (some? og) "og:image present") + (is (re-find (re-pattern (str "/" id "/portrait\\.png")) og) + (str "og:image should be the rendered portrait, got " og))))) + +(deftest share-card-falls-back-to-image-url + (with-conn conn + (let [c (setup conn) + id (:db/id (save! c (character-with {::char5e/image-url "https://example.com/pasted.png"}))) + og (og-image-of c id)] + (is (= "https://example.com/pasted.png" og) + "a character with no composed portrait keeps its pasted URL")))) + +(deftest share-card-carries-the-character-name + (testing "character-summary-for-id used to return only the ::se/summary + submap while character-page destructured ::se/summary back out of + it, so every shared link had an empty title" + (with-conn conn + (let [c (setup conn) + id (:db/id (save! c (character-with {}))) + html (:body (routes/character-page + {:db (d/db c) :conn c :headers {"host" "example.test"} + :uri "/" :path-params {:id id}}))] + (is (re-find #"Sharey" html) "the character's name reaches the share card"))))) diff --git a/test/clj/orcpub/portrait_render_test.clj b/test/clj/orcpub/portrait_render_test.clj new file mode 100644 index 000000000..5a8c92a87 --- /dev/null +++ b/test/clj/orcpub/portrait_render_test.clj @@ -0,0 +1,121 @@ +(ns orcpub.portrait-render-test + "Server-side portrait rasterization, for og:image. A share crawler has no + browser to bake the CSS-mask layers the way the PDF export does, so these + pixels are produced here -- and, like every other picture path, must + degrade to 'no portrait' rather than throw." + (:require [clojure.test :refer [deftest testing is]] + [orcpub.portrait-render :as pr] + [orcpub.dnd.e5.portrait-assets :as pa]) + (:import [java.io ByteArrayInputStream] + [javax.imageio ImageIO])) + +(defn- portrait-with [layer-keys & {:keys [colors tweaks]}] + {:layers (into {} + (keep (fn [k] + (when-let [a (first (pa/assets-for-layer k))] + [k {:artist/id (pa/artist-for-asset k (:asset/id a)) + :asset/id (:asset/id a)}]))) + layer-keys) + :colors (or colors {}) + :tweaks (or tweaks {})}) + +(defn- decode [png] + (ImageIO/read (ByteArrayInputStream. png))) + +(defn- opaque-pixels [img] + (let [w (.getWidth img) h (.getHeight img)] + (count (for [x (range 0 w 4) y (range 0 h 4) + :when (pos? (bit-and (unsigned-bit-shift-right (.getRGB img x y) 24) 0xff))] + true)))) + +;; ---------- data URIs ---------- + +(deftest parses-base64-data-uris + (let [{:keys [mime bytes]} (pr/parse-data-uri "data:image/svg+xml;base64,PHN2Zy8+")] + (is (= "image/svg+xml" mime)) + (is (= "" (String. ^bytes bytes "UTF-8"))))) + +(deftest rejects-unusable-data-uris + (is (nil? (pr/parse-data-uri nil))) + (is (nil? (pr/parse-data-uri ""))) + (is (nil? (pr/parse-data-uri "https://example.com/a.png")) "plain URL") + (is (nil? (pr/parse-data-uri "data:image/svg+xml;utf8,")) "non-base64 form") + (is (nil? (pr/parse-data-uri "data:image/png;base64,!!!!")) "malformed payload")) + +;; ---------- colors ---------- + +(deftest parses-hex-colors + (is (= (java.awt.Color. 240 161 0) (pr/hex->color "#f0a100"))) + (is (= (java.awt.Color. 255 255 255) (pr/hex->color "#FFFFFF")) "case insensitive")) + +(deftest rejects-bad-hex + (is (nil? (pr/hex->color nil))) + (is (nil? (pr/hex->color "#fff")) "shorthand not supported") + (is (nil? (pr/hex->color "red"))) + (is (nil? (pr/hex->color "#gggggg")))) + +;; ---------- path building ---------- + +(deftest builds-a-path-from-svg-ops + (let [p (pr/ops->path2d [[:move 0.0 0.0] [:line 10.0 0.0] [:line 10.0 10.0] [:close]] 2.0 3.0) + b (.getBounds2D p)] + (is (= 0.0 (.getX b))) + (is (= 20.0 (.getWidth b)) "x scaled by sx") + (is (= 30.0 (.getHeight b)) "y scaled by sy"))) + +(deftest ignores-unknown-ops + (testing "a parser that grows a new op must not crash the renderer" + (is (some? (pr/ops->path2d [[:move 0.0 0.0] [:something-new 1.0] [:line 5.0 5.0]] 1.0 1.0))))) + +;; ---------- rendering ---------- + +(deftest renders-a-composed-portrait + (let [png (pr/render-png (portrait-with [:head :shirt :eyes]))] + (is (bytes? png)) + (is (pos? (alength png))) + (testing "output is a real PNG" + (is (= [-119 80 78 71] (take 4 (vec png))))) + (let [img (decode png)] + (is (= 600 (.getWidth img))) + (is (= 750 (.getHeight img))) + (is (pos? (opaque-pixels img)) "something was actually drawn")))) + +(deftest honors-size + (let [img (decode (pr/render-png (portrait-with [:head]) 120 150))] + (is (= 120 (.getWidth img))) + (is (= 150 (.getHeight img))))) + +(deftest more-layers-cover-more-canvas + (testing "layers composite rather than replace one another" + (let [one (opaque-pixels (decode (pr/render-png (portrait-with [:head])))) + two (opaque-pixels (decode (pr/render-png (portrait-with [:head :shirt]))))] + (is (> two one) (str "head=" one " head+shirt=" two))))) + +(deftest character-colors-reach-the-pixels + (testing "a slot color changes the rendered output" + (let [plain (pr/render-png (portrait-with [:head])) + tinted (pr/render-png (portrait-with [:head] :colors {:skin "#ff0000"}))] + (is (not= (vec plain) (vec tinted)))))) + +(deftest per-piece-override-reaches-the-pixels + (let [base (pr/render-png (portrait-with [:head] :colors {:skin "#808080"})) + over (pr/render-png (portrait-with [:head] + :colors {:skin "#808080"} + :tweaks {:head {:override "#00ff00"}}))] + (is (not= (vec base) (vec over))))) + +;; ---------- degradation ---------- + +(deftest nil-when-nothing-to-draw + (is (nil? (pr/render-png {:layers {} :colors {} :tweaks {}}))) + (is (nil? (pr/render-png nil))) + (is (nil? (pr/render-png {:layers {:head {:asset/id :no-such-asset}}})) + "a selection naming an unknown asset draws nothing")) + +(deftest survives-a-corrupt-layer + (testing "one bad tint does not cost the whole portrait" + (let [p (assoc-in (portrait-with [:head :shirt]) + [:tweaks :head :override] "not-a-color") + png (pr/render-png p)] + (is (bytes? png) "still rendered the layers it could") + (is (pos? (opaque-pixels (decode png))))))) From 04e8d9da46e48b825acdd259aba7ff361a453c5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:52:04 +0000 Subject: [PATCH 008/170] Cover schema deploy-safety for ::char5e/portrait I had been describing a manual schema re-transact as a deploy prerequisite for this feature. That was wrong: orcpub.datomic is a system component whose start runs (d/transact conn schema/all-schemas) unconditionally, after create-database and connect, so a live database picks up the new attribute the next time the app boots. There is no separate migration step. Rather than leave that as a claim, these cover what it rests on: that ::char5e/portrait is registered as a cardinality-one string, and that re-transacting all-schemas -- which every boot does -- is idempotent, leaves a portrait written by an earlier version intact, and leaves saving working. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- test/clj/orcpub/portrait_og_test.clj | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/clj/orcpub/portrait_og_test.clj b/test/clj/orcpub/portrait_og_test.clj index d69453986..5cba4aa7d 100644 --- a/test/clj/orcpub/portrait_og_test.clj +++ b/test/clj/orcpub/portrait_og_test.clj @@ -46,6 +46,40 @@ :orcpub.user/email "test@test.com"}]) c)) +;; ---------- deploying the new attribute ---------- +;; +;; orcpub.datomic transacts schema/all-schemas on every start, so a running +;; database picks up ::char5e/portrait when the app restarts -- there is no +;; separate migration step. These cover what that relies on. + +(deftest portrait-attribute-is-registered + (with-conn conn + (let [c (setup conn) + ;; '[*] leaves refs as bare entity ids; ask for the idents. + attr (d/pull (d/db c) + '[{:db/valueType [:db/ident]} {:db/cardinality [:db/ident]}] + ::char5e/portrait)] + (is (= :db.type/string (get-in attr [:db/valueType :db/ident])) + "a string, because ::se/values is a component ref and no Datomic + attribute can hold the portrait's nested map") + (is (= :db.cardinality/one (get-in attr [:db/cardinality :db/ident])))))) + +(deftest re-transacting-schema-is-safe + (testing "every boot re-transacts all-schemas, so it must be idempotent and + must not disturb data written by an earlier version" + (with-conn conn + (let [c (setup conn) + stored (pr-str (a-portrait)) + id (:db/id (save! c (character-with {::char5e/portrait stored})))] + ;; a restart + @(d/transact c schema/all-schemas) + @(d/transact c schema/all-schemas) + (is (= stored (-> (d/pull (d/db c) '[{::se/values [::char5e/portrait]}] id) + ::se/values ::char5e/portrait)) + "the saved portrait survives repeated schema transactions") + (is (some? (:db/id (save! c (character-with {::char5e/portrait stored})))) + "and saving still works afterwards"))))) + ;; ---------- the attribute actually persists ---------- (deftest portrait-survives-a-real-save From a0a8605484a82ca7beb26be47f97188ffe14d2f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:09:56 +0000 Subject: [PATCH 009/170] Light theme for the portrait compositor; keep the launcher styled Follows the app's own light vocabulary rather than inventing one: #363636 text and the #33658A the light theme already uses for .form-button, because that theme recolours .orange to near-black and an amber drawer would look pasted on. Two structural fixes it depends on: The stylesheet was mounted inside the drawer-open? branch, so the launcher button -- which the same sheet styles, and which is on screen precisely when the drawer is NOT -- rendered unstyled most of the time. It is now mounted unconditionally. An earlier screenshot only looked right because the drawer happened to be open in it. The drawer is a sibling of content-page and .app (which carries the theme class) is inside content-page, so .app.light-theme cannot reach the drawer. It now carries its own theme class on a .pl-root wrapper. The launcher is the opposite case -- it does live under .app -- so it hooks off .app.light-theme; scoping it to .pl-root left one amber button in an otherwise blue theme, which a screenshot caught after the e2e had passed. The portrait frame deliberately stays dark in both themes. It holds character colours, and pale skin or blonde hair would vanish on a light ground. e2e now drives the real theme toggle and asserts the drawer repaints, the frame does not, and the launcher recolours -- plus that the launcher is styled while the drawer is closed. An earlier version of this test silently skipped the theme checks when its selector missed, which proved nothing. 482 tests / 3629 assertions green; both browser e2e suites pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GMZoZx7wibZT8AMF7SYmc4 --- src/cljs/orcpub/dnd/e5/portrait.cljs | 142 ++++++++++++++++++++++-- test/browser/portrait_compositor_e2e.js | 47 ++++++++ 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index 7fa3e4ec3..d08142159 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -448,6 +448,112 @@ display: flex; justify-content: space-between; align-items: center; gap: 10px; } +/* ---- light theme ---- + Scoped to .pl-root.light-theme because the drawer is a sibling of + content-page and cannot inherit .app's theme class. Follows the app's own + light vocabulary rather than inventing one: #363636 text and the #33658A + the light theme already uses for .form-button, since it recolours .orange + to near-black and would leave an amber drawer looking pasted on. + + The portrait frame stays dark in BOTH themes on purpose. What it holds is + character colours -- pale skin, blonde hair -- and a light-on-light frame + would swallow them. */ +.pl-root.light-theme .pl-drawer { + background: #f4f4f6; + border-left: 1px solid rgba(0,0,0,0.12); + color: #363636; + box-shadow: -30px 0 60px -24px rgba(0,0,0,0.28); +} +.pl-root.light-theme .pl-backdrop { background: rgba(0,0,0,0.32); } +.pl-root.light-theme .pl-drawer-head, +.pl-root.light-theme .pl-drawer-foot { background: #fff; border-color: rgba(0,0,0,0.10); } +.pl-root.light-theme .pl-drawer-title-rune, +.pl-root.light-theme .pl-artist-swirl { color: #33658A; } +.pl-root.light-theme .pl-drawer-close { + border-color: rgba(0,0,0,0.14); color: #5a5a5a; +} +.pl-root.light-theme .pl-drawer-close:hover { color: #33658A; border-color: #33658A; } +.pl-root.light-theme .pl-canvas-side { background: #fff; border-right-color: rgba(0,0,0,0.10); } +.pl-root.light-theme .pl-pickers-side { background: #f4f4f6; } +.pl-root.light-theme .pl-picker, +.pl-root.light-theme .pl-picker-head { + background: #fff; border-color: rgba(0,0,0,0.10); +} +.pl-root.light-theme .pl-cat, +.pl-root.light-theme .pl-slot-name { color: #363636; } +.pl-root.light-theme .pl-cat-z, +.pl-root.light-theme .pl-sub-shade-val { background: #eceef1; color: #6b6b6b; } +.pl-root.light-theme .pl-picker-sel, +.pl-root.light-theme .pl-empty-hint, +.pl-root.light-theme .pl-empty-registry, +.pl-root.light-theme .pl-attribution-empty, +.pl-root.light-theme .pl-strip-label, +.pl-root.light-theme .pl-panel-heading, +.pl-root.light-theme .pl-attribution-label { color: #6b6b6b; } +.pl-root.light-theme .pl-btn { color: #363636; } +.pl-root.light-theme .pl-btn-primary { + background: linear-gradient(to bottom, #33658A, #2b5677); + color: #fff; border-color: #24485f; + box-shadow: 0 6px 14px -8px rgba(51,101,138,0.5); +} +.pl-root.light-theme .pl-btn-ghost { border-color: rgba(0,0,0,0.16); color: #5a5a5a; } +.pl-root.light-theme .pl-btn-ghost:hover:not(:disabled) { border-color: #33658A; color: #33658A; } +.pl-root.light-theme .pl-seed-row { color: #6b6b6b; } +.pl-root.light-theme .pl-seed-row code { + color: #2b5677; background: rgba(51,101,138,0.10); border-color: rgba(51,101,138,0.28); +} +.pl-root.light-theme .pl-color-strip, +.pl-root.light-theme .pl-slot-panel { background: #fff; border-color: rgba(0,0,0,0.10); } +.pl-root.light-theme .pl-slot { background: #f4f4f6; border-color: rgba(0,0,0,0.10); } +.pl-root.light-theme .pl-slot.on { border-color: #33658A; box-shadow: 0 0 0 1px rgba(51,101,138,0.28); } +.pl-root.light-theme .pl-slot.has-tweaks .pl-slot-swatch { box-shadow: 0 0 0 2px rgba(51,101,138,0.45); } +.pl-root.light-theme .pl-slot::after { border-color: #8a8a8a; } +.pl-root.light-theme .pl-slot.open::after { border-color: #33658A; } +.pl-root.light-theme .pl-slot-tweaks { background: rgba(51,101,138,0.16); color: #2b5677; } +.pl-root.light-theme .pl-slot-swatch.unset { + background: repeating-linear-gradient(45deg, #e7e9ec 0 4px, #f7f8fa 4px 8px); +} +.pl-root.light-theme .pl-sub-dot { border-color: #fff; } +.pl-root.light-theme .pl-slot-clear, +.pl-root.light-theme .pl-sub-clear { color: #8a8a8a; } +.pl-root.light-theme .pl-slot-clear:hover:not(:disabled), +.pl-root.light-theme .pl-sub-clear:hover:not(:disabled) { + color: #33658A; border-color: rgba(51,101,138,0.28); +} +.pl-root.light-theme .pl-sub-name { color: #363636; } +.pl-root.light-theme .pl-sub-row input[type=range], +.pl-root.light-theme .pl-slot-panel input[type=range] { accent-color: #33658A; } +.pl-root.light-theme .pl-sub-chip.shaded, +.pl-root.light-theme .pl-sub-chip.overridden, +.pl-root.light-theme .pl-tint-chip.shaded, +.pl-root.light-theme .pl-tint-chip.overridden { border-color: #33658A; } +.pl-root.light-theme .pl-tint-chip.open { box-shadow: 0 0 0 2px rgba(51,101,138,0.32); } +.pl-root.light-theme .pl-sw { + background: #f7f8fa; border-color: rgba(0,0,0,0.10); +} +.pl-root.light-theme .pl-sw:hover { border-color: #33658A; } +.pl-root.light-theme .pl-sw.selected { + border-color: #33658A; background: #e9eef3; + box-shadow: 0 0 0 2px rgba(51,101,138,0.32); +} +.pl-root.light-theme .pl-sw-none { + background: repeating-linear-gradient(45deg, #eceef1 0 5px, transparent 5px 10px); + color: #8a8a8a; +} +.pl-root.light-theme .pl-artist a, +.pl-root.light-theme .pl-artist span.pl-artist-name { + color: #363636; border-bottom-color: rgba(51,101,138,0.4); +} +.pl-root.light-theme .pl-artist a:hover { color: #33658A; border-bottom-color: #33658A; } +/* The launcher lives in the Description tab, not in the drawer, so it hangs + off .app rather than .pl-root -- scoping it to .pl-root left an amber + button sitting in an otherwise blue light theme. */ +.app.light-theme .pl-launcher { + background: linear-gradient(to bottom, #33658A, #2b5677); + color: #fff; border-color: #24485f; + box-shadow: 0 6px 14px -8px rgba(51,101,138,0.5); +} + /* the launcher button that sits next to the Image URL input in the builder */ .pl-launcher { display: inline-flex; align-items: center; gap: 7px; @@ -632,18 +738,30 @@ (defn drawer "Renders the compositor drawer when :portrait/drawer-open? is truthy. - Mount once at the character-builder root; it overlays." + Mount once at the character-builder root; it overlays. + + The stylesheet is mounted unconditionally, not inside the open? branch -- + it also styles the launcher button, which lives in the Description tab and + is on screen precisely when the drawer is not. + + The theme class goes on this component's own root rather than being + inherited: the drawer is mounted as a SIBLING of content-page, and .app -- + which carries the theme -- is inside content-page, so .app.light-theme + cannot reach it." [] - (when @(subscribe [:portrait/drawer-open?]) - (let [portrait @(subscribe [:portrait/draft]) - seed @(subscribe [:portrait/draft-seed]) - open-slot @(subscribe [:portrait/open-slot]) - open-layer @(subscribe [:portrait/open-layer]) - layers (:layers portrait) - any? (boolean (seq layers))] - [:div - [:style drawer-styles] - [:div.pl-backdrop {:on-click #(dispatch [:portrait/close])}] + (let [open? @(subscribe [:portrait/drawer-open?]) + theme @(subscribe [:theme])] + [:div.pl-root {:class theme} + [:style drawer-styles] + (when open? + (let [portrait @(subscribe [:portrait/draft]) + seed @(subscribe [:portrait/draft-seed]) + open-slot @(subscribe [:portrait/open-slot]) + open-layer @(subscribe [:portrait/open-layer]) + layers (:layers portrait) + any? (boolean (seq layers))] + [:div + [:div.pl-backdrop {:on-click #(dispatch [:portrait/close])}] [:div.pl-drawer [:div.pl-drawer-head [:div.pl-drawer-title @@ -682,7 +800,7 @@ "Cancel"] [:button.pl-btn.pl-btn-primary {:type "button" :on-click #(dispatch [:portrait/save])} - "Save portrait"]]]]]))) + "Save portrait"]]]]]))])) (defn launcher-button "The 'Compose portrait' button that slots next to the Image URL input in diff --git a/test/browser/portrait_compositor_e2e.js b/test/browser/portrait_compositor_e2e.js index 81f680563..d943e3abb 100644 --- a/test/browser/portrait_compositor_e2e.js +++ b/test/browser/portrait_compositor_e2e.js @@ -79,6 +79,12 @@ function check(label, ok, detail) { await launcher.waitFor({ state: 'visible', timeout: 15000 }); check('"Compose portrait" launcher renders in Description tab', true); + // The drawer's