From c9286f368c8419e2ec2c9539d42de9d41582c16b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 13:50:28 +0000 Subject: [PATCH 01/91] Plan the artist profile pages A public page per portrait artist at /artists/: their name, the pieces they drew, a few portraits composed from those pieces, and the links they listed. The credit's name keeps linking where the artist chose; the profile is reached by a second, quieter link at the foot of the drawer credit. --- docs/design/artist-profiles/PLAN.md | 102 ++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/design/artist-profiles/PLAN.md diff --git a/docs/design/artist-profiles/PLAN.md b/docs/design/artist-profiles/PLAN.md new file mode 100644 index 000000000..7a3eaa5fc --- /dev/null +++ b/docs/design/artist-profiles/PLAN.md @@ -0,0 +1,102 @@ +# Artist profile pages — plan + +Branch: `claude/artist-profile-pages`, cut from `claude/character-portrait-generator-hOutO`. + +A public page per portrait artist at `/artists/`: their name, the pieces they +drew, a few portraits composed from those pieces, and the links they listed. It is the +site's showcase of them. It is not their home page, so the credit's name link still +goes to wherever they chose (`:artist/link`), and the profile is reached by a second, +quieter link. + +## Routes + +| Path | Server | Client | +|---|---|---| +| `/artists` | index HTML, generic share tags | lists every registered artist | +| `/artists/` | index HTML with that artist's share tags (title, description, `og:image`); **404** for an unknown slug | the profile | +| `/artists//portrait.png` | the first example portrait rendered by `portrait-render/render-png`, for `og:image` | — | + +Declared once in `route-map.cljc` (bidi), served by `routes.clj`, and dispatched in +`core.cljs` like every other page. This follows the character page: an SPA view, with +the share tags set on the server because a crawler never runs the JavaScript. + +**Why the slug is not `:artist/id`.** Fusspot's id is `:house-pack`, and saved portraits +store it, so it cannot be renamed. The registry entry gains `:artist/slug "fusspot"`. +It is deliberately **not** overridable by `set-artist-overrides!`: a URL that moved +whenever a deployment restated a name would break every link already shared. An artist +with no slug falls back to `(name id)`. + +## Data + +Nothing new is stored. There is no database, no account and no user record: the page +reads only the registry (`portrait_assets.cljc`) plus the deployment overrides that +the credit already honours. The pure functions go in a new cljc namespace, +`orcpub.dnd.e5.artist-profile`, so the JVM (share tags, og image) and the browser +(the page) agree and both can be tested: + +- `slug`, `artist-by-slug`, `profile-path`: resolve a URL to an artist and back. +- `pieces-by-layer`, `piece-count`: what they drew, grouped in picker order. The + hidden `:scalp` is left out: `ARTISTS.md` says it is a generated patch, not + anyone's drawing. +- `listed-links`: `:artist/links` (or `:artist/link` alone as a "Site" link when no + labelled set exists), **http(s) only**. A `mailto:` or any other scheme is dropped, + so an email address cannot reach the page even if one is put in the registry. + Nothing is looked up or added: these are the links the artist listed. +- `example-portraits`: a few deterministic portraits. Each layer the artist drew uses + their pieces, rotated so that across the examples every piece appears where + possible; colours come from the existing presets. Layers they did not draw are + filled from other artists so the picture is whole, and `credit-order` then names + those others under that example. Seeded from the slug, so the page, the og image + and every reload show the same portraits. +- `profile-link`: where the credit's secondary link goes: one artist gives their + profile, several give `/artists`. + +## What renders where + +- **Server:** the share tags and the og PNG. Nothing about the page body. +- **Client:** everything else. The examples use `portrait/composite`, the + same CSS-mask compositor as the drawer and the character page, so they cannot + look different from a real portrait. + +## Look + +The drawer's vocabulary, nothing new: Vollkorn italic for names, Open Sans for UI, +the small uppercase `.lk-cap` label, the broken amber rule, the link marks in their own +colours. One centred column like a Carrd page, inside the normal app shell (header, +footer). The portrait frames stay dark in both themes, as in the drawer, because they +hold character colours. Light theme: `#363636` text with slate-blue `#33658A` accents. +Dark theme: `#ebeef4` on the app's navy ground with amber `#f0a100` accents. Scoped to the +page's own root class and the app's theme class, the way the drawer scopes `.pl-root`. + +## The secondary link from the credit + +**Proposal:** in the drawer's credit, a small uppercase line at the foot of the lockup, +mirroring the "ART BY" label at the top: **"About the artist ›"**, or **"About the +artists ›"** when there are several. It opens in a new tab, as the other credit links +do, so an unsaved portrait draft is not left behind. The name, its dotted underline +and its glow, and the link marks are all unchanged. + +Considered and not chosen: +- *A profile mark among the link marks.* Those marks are the artist's own links, and + one of ours among them would blur that. It would also change the mark count, which + sets whether the marks can flank the name. +- *Linking the "ART BY" label.* Nobody would know it was a link. +- *The character page's 100px credit strip.* It is where a viewer, rather than the + person composing, actually sees the credit, so it is the better place in principle. + It is two clamped lines of 9px text today, so adding a line changes that surface. + Left for the owner to decide (below). + +## Tests + +- cljc unit tests for every new pure function (JVM `lein test`; they are cljc so the + cljs runner can take them too), using stand-in artists built with `with-redefs`, as + the credit-order tests do. No placeholder artist is added to the registry. +- JVM route test: the page's share tags, the 404, and the og PNG. +- A browser probe, `test/browser/artist_profile_e2e.js`: the page renders in both themes, + the drawer credit carries the secondary link and the name link is unchanged, the + page never shows an email address or `mailto:`, and screenshots of both themes. + +## Out of scope + +Artist accounts, editing a profile, anything tied to a site user, and any link the +artist did not list. From 450312c9f226ef7b60ed92fa01960c484812ea0e Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 13:56:24 +0000 Subject: [PATCH 02/91] Add public artist profile pages at /artists/ A showcase page per portrait artist: their name on the credit's broken rule, the links they listed, three portraits composed from their pieces, and every piece they drew, grouped most-of-the-picture first. /artists lists everyone with a profile. The pure parts live in artist-profile (cljc) so the server's share tags and og image agree with the page: slug lookup, the listed links (http(s) only, so an email address cannot reach the page), deterministic example portraits seeded from the slug, and where the credit's secondary link goes. The registry entry gains :artist/slug, which deployment overrides cannot move. The drawer credit gains a quiet 'About the artist' line at its foot, mirroring the ART BY label. The name still links wherever the artist chose. Not yet run: the unit tests, route tests and the browser probe. --- scripts/test/run-browser-probes.js | 1 + src/clj/orcpub/routes.clj | 46 ++++ src/cljc/orcpub/dnd/e5/artist_profile.cljc | 179 +++++++++++++ src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 5 + src/cljc/orcpub/route_map.cljc | 11 + src/cljs/orcpub/dnd/e5/portrait.cljs | 21 +- src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 210 +++++++++++++++ test/browser/artist_profile_e2e.js | 246 ++++++++++++++++++ test/clj/orcpub/artist_page_test.clj | 39 +++ .../orcpub/dnd/e5/artist_profile_test.cljc | 199 ++++++++++++++ web/cljs/orcpub/core.cljs | 5 +- 11 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 src/cljc/orcpub/dnd/e5/artist_profile.cljc create mode 100644 src/cljs/orcpub/dnd/e5/views/artist_page.cljs create mode 100644 test/browser/artist_profile_e2e.js create mode 100644 test/clj/orcpub/artist_page_test.clj create mode 100644 test/cljc/orcpub/dnd/e5/artist_profile_test.cljc diff --git a/scripts/test/run-browser-probes.js b/scripts/test/run-browser-probes.js index f31311f9c..ea2b31718 100755 --- a/scripts/test/run-browser-probes.js +++ b/scripts/test/run-browser-probes.js @@ -64,6 +64,7 @@ const PROBES = [ { file: 'portrait_compositor_e2e.js', needs: 'server' }, { file: 'portrait_pdf_export_e2e.js', needs: 'server' }, { file: 'portrait_tab_e2e.js', needs: 'server' }, + { file: 'artist_profile_e2e.js', needs: 'server' }, { file: 'notification_flows_e2e.js', needs: 'standalone' }, { file: 'notifications_acceptance_e2e.js', needs: 'standalone' }, { file: 'spell_help_laziness_e2e.js', needs: 'server' }, diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 99a21fee5..fadcf75ea 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -25,6 +25,7 @@ [orcpub.dnd.e5.skills :as skill5e] [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.portrait-assets :as portrait-assets5e] + [orcpub.dnd.e5.artist-profile :as artist-profile] [orcpub.dnd.e5.spells :as spells] [orcpub.dnd.e5.spell-annotations :as spell-annotations] [orcpub.dnd.e5.magic-items :as mi5e] @@ -2037,6 +2038,45 @@ :image-url share-image} {"X-Frame-Options" "ALLOW-FROM https://www.worldanvil.com/"}))) +(defn artists-page + "The list of portrait artists. The page itself is the SPA's; this only + gives the share card a title of its own." + [request] + (index-page-response request {:title "Portrait artists" + :description "The artists who drew the portrait pieces here."})) + +(defn artist-page + "An artist's profile. The body is the SPA's, like the character page; the + share tags are set here because a crawler never runs the JavaScript. An + unknown slug is a real 404, so a mistyped link is not indexed as a page -- + the SPA still loads and says there is nobody here." + [{:keys [headers] {:keys [slug]} :path-params :as request}] + (if-let [artist (artist-profile/artist-by-slug slug)] + (index-page-response + request + {:title (artist-profile/page-title artist) + :description (artist-profile/share-description artist) + :image-url (str "https://" (headers "host") + (route-map/path-for route-map/artist-portrait-route + :slug (artist-profile/slug artist)))}) + (assoc (index-page-response request {:title "No such artist"}) :status 404))) + +(defn artist-portrait-png + "The artist's first example portrait as a PNG, for og:image. The examples + are seeded from the slug, so this is the same picture the page leads with." + [{{:keys [slug]} :path-params}] + (let [artist (artist-profile/artist-by-slug slug) + png (some-> artist :artist/id artist-profile/example-portraits first + portrait-render/render-png)] + (if png + {:status 200 + :headers {"Content-Type" "image/png" + "Cache-Control" "public, max-age=300" + ;; contributed artwork -- see character-portrait-png + "X-Robots-Tag" "noai, noimageai"} + :body (ByteArrayInputStream. png)} + {:status 404 :body "no such artist"}))) + (def header-style {:style "color:#2c3445"}) @@ -2148,6 +2188,12 @@ {: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/artists-page-route) + {:get `artists-page}] + [(route-map/path-for route-map/artist-page-route :slug ":slug") + {:get `artist-page}] + [(route-map/path-for route-map/artist-portrait-route :slug ":slug") + {:get `artist-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/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc new file mode 100644 index 000000000..fb22c5629 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -0,0 +1,179 @@ +(ns orcpub.dnd.e5.artist-profile + "What an artist's public profile page shows, as data. + + The page is the site's showcase of a portrait artist -- their name, the + pieces they drew, a few portraits made from those pieces, and the links they + listed. It is not their home: the credit's name still links wherever they + chose (:artist/link), and the profile is reached by a second, quieter link. + + Everything here reads the registry in portrait-assets, with the deployment + overrides the credit already honours. Nothing is stored and nothing is tied + to a site account. Shared by the server (share tags and the og image) and + the browser (the page), so the two cannot disagree." + (:require [clojure.string :as s] + [orcpub.route-map :as route-map] + [orcpub.dnd.e5.portrait-assets :as pa])) + +;; ---------- addresses ---------- + +(defn slug + "The artist's address segment: :artist/slug, or the id's name when there is + none. Read from the registry entry, never from the overrides, so a URL does + not move when a deployment restates a name." + [artist] + (or (:artist/slug artist) + (some-> (:artist/id artist) name))) + +(defn- registry-entry [artist-id] + (some #(when (= artist-id (:artist/id %)) %) pa/registry)) + +(defn profile + "The artist, overrides applied, if they have a profile: they are registered + and have said how they want to be named. An artist with no :artist/name is + skipped the way the credit skips them, rather than given a page nobody + chose." + [artist-id] + (when-let [a (pa/artist-info artist-id)] + (when-not (s/blank? (:artist/name a)) + (assoc a :artist/slug (slug (registry-entry artist-id)))))) + +(defn artist-by-slug + "The profile for a URL segment, or nil. Case and surrounding space are + ignored, since people retype these." + [segment] + (when (string? segment) + (let [wanted (s/lower-case (s/trim segment))] + (some #(when (= wanted (some-> (slug %) s/lower-case)) + (profile (:artist/id %))) + pa/registry)))) + +(defn all-profiles + "Every artist with a profile, in registry order." + [] + (into [] (keep (comp profile :artist/id)) pa/registry)) + +(defn profile-path [artist] + (route-map/path-for route-map/artist-page-route :slug (slug artist))) + +(defn index-path [] + (route-map/path-for route-map/artists-page-route)) + +(defn profile-link + "Where the credit's secondary link goes, for the named artists on a + portrait: the one artist's profile, or the list when there are several. + nil when nobody named is on canvas, so the credit can drop the line." + [named-artists] + (let [with-profiles (filter #(profile (:artist/id %)) named-artists)] + (case (count with-profiles) + 0 nil + 1 {:href (profile-path (first with-profiles)) :label "About the artist"} + {:href (index-path) :label "About the artists"}))) + +;; ---------- what they drew ---------- + +(defn pieces-by-layer + "The artist's pieces grouped by layer, most of the picture first -- the + order the credit weighs layers in -- then z-order. The hidden scalp is left + out: it is a generated patch, not anyone's drawing (see ARTISTS.md). + Each group is {:layer k :label \"Head\" :pieces [asset ...]}." + [artist] + (->> pa/pickable-layers + (keep (fn [k] + (when-let [pieces (seq (get-in artist [:artist/layers k]))] + {:layer k :label (pa/layer-labels k) :pieces (vec pieces)}))) + (sort-by #(- (get pa/layer-credit-weight (:layer %) 1))) + vec)) + +(defn piece-count [artist] + (reduce + (map (comp count :pieces) (pieces-by-layer artist)))) + +;; ---------- links ---------- + +(defn web-url? + "Whether `u` is an http(s) address with no userinfo. Everything else -- a + mailto:, a javascript:, a bare address -- is not something this page links + to. `https://example.test/@name` passes: the @ is in the path." + [u] + (boolean (and (string? u) (re-find #"(?i)^https?://[^\s@/?#]+(?:[/?#]\S*)?$" u)))) + +(defn listed-links + "The links the artist listed, and only those: :artist/links, or their single + :artist/link as a site link when they have not given a labelled set. + Anything that is not an http(s) URL is dropped, so an email address cannot + reach the page even if one is put in the registry. Duplicates are dropped, + first one kept." + [artist] + (let [links (or (seq (:artist/links artist)) + (when-let [l (:artist/link artist)] + [{:link/label "Site" :link/icon "site" :link/url l}]))] + (->> links + (filter (comp web-url? :link/url)) + (reduce (fn [[seen out] l] + (if (seen (:link/url l)) + [seen out] + [(conj seen (:link/url l)) (conj out l)])) + [#{} []]) + second))) + +;; ---------- example portraits ---------- + +(def example-count 3) + +(defn- pick [rnd v] + (nth v (int (Math/floor (* (rnd) (count v)))))) + +(defn example-portraits + "A few portraits made from the artist's pieces, the same on every render. + + Each layer they drew uses their pieces, rotated from a per-layer starting + point so the examples differ from one another and, across them, show every + piece a layer has when there are enough examples to. A layer they did not + draw is filled from the registry so the picture is whole; credit-order will + name whoever drew it. Colours come from the picker's presets. + + Seeded from the slug, so the page, the og image and every reload agree." + ([artist-id] (example-portraits artist-id example-count)) + ([artist-id n] + (when-let [artist (profile artist-id)] + (let [base (str "artist-" (slug artist)) + own (:artist/layers artist) + offset (fn [k] (pa/seed->int (str base "/" (name k))))] + (vec + (for [i (range n)] + (let [rnd (pa/mulberry32 (pa/seed->int (str base "/" i))) + layers (reduce + (fn [acc k] + (let [mine (get own k) + asset (if (seq mine) + (nth mine (mod (+ i (offset k)) (count mine))) + (let [any (pa/assets-for-layer k)] + (when (seq any) (pick rnd any))))] + (if asset + (assoc acc k {:artist/id (pa/artist-for-asset k (:asset/id asset)) + :asset/id (:asset/id asset)}) + acc))) + {} pa/layer-order) + colors (into {} (for [slot pa/color-slot-order + :let [presets (pa/color-presets slot)] + :when (seq presets)] + [slot (pick rnd presets)]))] + {:layers layers :colors colors :tweaks {}}))))))) + +(defn others-in + "The other named artists in `portrait`, in credit order -- who an example + borrowed pieces from." + [portrait artist-id] + (into [] + (filter #(and (not= artist-id (:artist/id %)) (:artist/name %))) + (pa/credit-order (:layers portrait)))) + +;; ---------- share tags ---------- + +(defn page-title [artist] + (str (:artist/name artist) " — portrait artist")) + +(defn share-description [artist] + (let [n (piece-count artist)] + (str "Portrait art by " (:artist/name artist) ": " + n " hand-drawn " (if (= 1 n) "piece" "pieces") + ", a few portraits made from them, and where to find more."))) diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc index 74c2b29c0..2eb7462ac 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -235,6 +235,11 @@ lets a fork credit its own contributors while the open-source repo still credits the artist whose work it ships." {:artist/id :house-pack + ;; The profile page's address, /artists/fusspot. Not the id: saved + ;; portraits store :house-pack, so the id cannot change. Not overridable + ;; either -- a URL that moved whenever a deployment restated a name would + ;; break every link already shared. See artist-profile/slug. + :artist/slug "fusspot" :artist/name "Fusspot" ;; The single link, for surfaces that can only hold one -- the character ;; page credit is a 100px strip. Her homepage, because it is the list she diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index fde207f7b..662643de3 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -92,6 +92,13 @@ +;; Public artist profiles: the site's showcase of each portrait artist. Not +;; under /pages/ -- these are addresses people are handed, from a credit. +(def artists-page-route :artists-page) +(def artist-page-route :artist-page) +;; Server-rendered example portrait, for the profile's og:image. +(def artist-portrait-route :artist-portrait) + (def register-route :register) (def register-page-route :register-page) (def my-account-page-route :my-account) @@ -155,6 +162,10 @@ "following/users" {["/" :user] follow-user-route} + "artists" {"" artists-page-route + ["/" :slug] artist-page-route + ["/" :slug "/portrait.png"] artist-portrait-route} + "dnd/" {"5e/" {"characters" {"" dnd-e5-char-list-route ["/" :id] dnd-e5-char-route} diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index 0fc716ab1..1bb35eac7 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -18,6 +18,7 @@ [reagent.core :as r] [clojure.string :as s] [orcpub.dnd.e5.portrait-assets :as pa] + [orcpub.dnd.e5.artist-profile :as artist-profile] [orcpub.dnd.e5.portrait-layout :as layout] [orcpub.fork.branding :as branding])) @@ -709,6 +710,18 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } -webkit-mask-position: center; mask-position: center; } .lk-mark-text { font: 400 11px/1 'Open Sans', system-ui, sans-serif; } +/* The profile link: the foot of the credit, mirroring the ART BY label at its + head -- same size, same tracking, same dim colour -- so it reads as part of + the frame around the names rather than as another name. */ +.lk-about { + align-self: center; margin-top: 1px; + font: 600 8.5px/1 'Open Sans', system-ui, sans-serif; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--lk-dim); text-decoration: none; + padding: 3px 2px; transition: color 160ms ease; +} +.lk-about-arrow { padding-left: 4px; letter-spacing: 0; } +.lk-about:hover, .lk-about:focus-visible { color: var(--lk-glow-text); outline: none; } +.lk-about:focus-visible { text-decoration: underline; text-underline-offset: 3px; } .pl-drawer-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; } @@ -1155,7 +1168,13 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } [:div.lk-marks (for [l below] ^{:key (:link/url l)} [credit-mark name l])])] (when (seq others) - [:div.lk-with "with " (name-list others)])])) + [:div.lk-with "with " (name-list others)]) + ;; The way to the site's own page about them, kept apart from the name, + ;; which goes wherever the artist chose. A new tab, like every link in + ;; the credit, so an unsaved draft is not left behind. + (when-let [{:keys [href label]} (artist-profile/profile-link named)] + [:a.lk-about {:href href :target "_blank" :rel "noopener"} + label [:span.lk-about-arrow {:aria-hidden true} "\u203a"]])])) (defn- attribution "Who drew what is on canvas. diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs new file mode 100644 index 000000000..7cb8e56df --- /dev/null +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -0,0 +1,210 @@ +(ns orcpub.dnd.e5.views.artist-page + "Public artist profiles: /artists and /artists/. + + The site's showcase of a portrait artist, in the drawer's vocabulary -- + the small uppercase label, the name in Vollkorn italic on a broken rule, + the link marks in their own colours, portrait frames that stay dark in both + themes. What the page shows is decided in artist-profile (cljc), which the + server also reads for the share tags. + + The page wears .pl-root so the drawer's stylesheet, its credit face and its + --lk-* theme variables apply unchanged; .ap-* below is only what a page has + and a drawer does not." + (:require [re-frame.core :refer [subscribe]] + [orcpub.dnd.e5.artist-profile :as ap] + [orcpub.dnd.e5.portrait :as portrait] + [orcpub.dnd.e5.views :as views])) + +(def styles " +.ap-root { font-family: 'Open Sans', system-ui, sans-serif; color: #ebeef4; padding: 24px 12px 40px; } +.ap-root.light-theme { color: #363636; } +.ap-card { + max-width: 640px; margin: 0 auto; padding: 28px 28px 24px; + background: #131924; border: 1px solid rgba(240,161,0,0.16); border-radius: 12px; + box-shadow: 0 20px 50px -30px rgba(0,0,0,0.7); + display: flex; flex-direction: column; gap: 30px; +} +.ap-root.light-theme .ap-card { + background: #fff; border-color: rgba(0,0,0,0.10); + box-shadow: 0 20px 50px -34px rgba(0,0,0,0.35); +} +.ap-head { display: flex; flex-direction: column; gap: 12px; text-align: center; } +.ap-name { + margin: 0; font: italic 400 40px/1.1 'Vollkorn', Georgia, serif; + color: var(--lk-name); white-space: normal; text-wrap: balance; +} +.ap-head .lk-row { gap: 16px; } +.ap-lede { margin: 0; font-size: 13px; line-height: 1.5; color: var(--lk-dim); } +.ap-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 4px; } +.ap-link { + display: inline-flex; align-items: center; gap: 8px; + padding: 9px 16px; border-radius: 5px; + border: 1px solid rgba(255,255,255,0.10); background: rgba(255,255,255,0.02); + color: #ebeef4; text-decoration: none; + font: 600 12px/1 'Open Sans', system-ui, sans-serif; letter-spacing: 0.04em; + transition: border-color 160ms ease, background-color 160ms ease; +} +.ap-link:hover, .ap-link:focus-visible { border-color: #f0a100; background: rgba(240,161,0,0.06); outline: none; } +.ap-link .lk-ico { width: 14px; height: 14px; } +.ap-root.light-theme .ap-link { border-color: rgba(0,0,0,0.14); background: #fff; color: #363636; } +.ap-root.light-theme .ap-link:hover, +.ap-root.light-theme .ap-link:focus-visible { border-color: #33658A; background: rgba(51,101,138,0.06); } +.ap-section { display: flex; flex-direction: column; gap: 14px; } +.ap-section > .lk-cap { margin: 0; } +.ap-examples { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } +.ap-example { margin: 0; display: flex; flex-direction: column; gap: 7px; } +/* Dark in both themes, like the drawer's frame: it holds character colours, + and a light frame would swallow pale skin and blonde hair. */ +.ap-frame { + width: 100%; aspect-ratio: 4 / 5; box-sizing: border-box; + position: relative; overflow: hidden; border-radius: 10px; + background: radial-gradient(circle at 50% 35%, #202939, #131924 60%, #0f141c); + border: 1px solid rgba(240,161,0,0.16); +} +.ap-root.light-theme .ap-frame { border-color: rgba(0,0,0,0.12); } +.ap-example figcaption { + font: italic 12px/1.4 'Vollkorn', Georgia, serif; color: var(--lk-dim); text-align: center; +} +.ap-groups { display: flex; flex-direction: column; gap: 14px; } +.ap-group { display: grid; grid-template-columns: 96px 1fr; gap: 12px; align-items: start; } +.ap-group-name { font: 600 12px/1.3 'Open Sans', system-ui, sans-serif; padding-top: 6px; } +.ap-group-count { display: block; font-weight: 400; font-size: 11px; color: var(--lk-dim); } +.ap-pieces { display: grid; grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); gap: 6px; } +.ap-piece { + aspect-ratio: 1; border-radius: 6px; overflow: hidden; + background: #131924; border: 1px solid rgba(255,255,255,0.05); + display: grid; place-items: center; +} +.ap-root.light-theme .ap-piece { border-color: rgba(0,0,0,0.10); } +.ap-root:not(.light-theme) .ap-card .ap-piece { background: #0e131a; } +.ap-piece img { width: 100%; height: 100%; object-fit: contain; } +.ap-foot { + margin: 0; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.06); + font-size: 12px; line-height: 1.55; color: var(--lk-dim); text-align: center; +} +.ap-root.light-theme .ap-foot { border-top-color: rgba(0,0,0,0.08); } +.ap-foot a, .ap-missing a { color: inherit; text-decoration: underline dotted var(--lk-under); text-underline-offset: 3px; } +.ap-foot a:hover, .ap-missing a:hover { color: var(--lk-glow-text); } +.ap-missing { text-align: center; font-size: 13px; color: var(--lk-dim); } +.ap-index { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; } +.ap-index-card { display: flex; flex-direction: column; gap: 8px; text-decoration: none; text-align: center; } +.ap-index-card .lk-name { font-size: 18px; white-space: normal; } +.ap-index-card:hover .lk-name, .ap-index-card:focus-visible .lk-name { + color: var(--lk-glow-text); text-shadow: var(--lk-glow); +} +.ap-index-card:focus-visible { outline: none; } +.ap-index-count { font-size: 11px; color: var(--lk-dim); } +@media (max-width: 560px) { + .ap-card { padding: 22px 16px 18px; gap: 26px; } + .ap-name { font-size: 32px; } + .ap-examples { gap: 8px; } + .ap-group { grid-template-columns: 1fr; gap: 6px; } + .ap-group-name { padding-top: 0; } + .ap-group-count { display: inline; padding-left: 6px; } +} +") + +(defn- shell + "The app's page chrome around a .pl-root, so the drawer's theme variables + and credit face apply here too." + [title & body] + (let [theme @(subscribe [:theme])] + [views/content-page + title + [] + (into [:div.pl-root.ap-root {:class theme} + [:style (str portrait/drawer-styles styles)]] + body)])) + +(defn- link-button [{:link/keys [label url icon color]}] + [:a.ap-link {:href url :target "_blank" :rel "noopener"} + (when icon + [:span.lk-ico {:aria-hidden true + :style {:color color + :WebkitMaskImage (str "url(/image/social/" icon ".svg)") + :maskImage (str "url(/image/social/" icon ".svg)")}}]) + label]) + +(defn- names [artists] + (let [n (count artists)] + (for [[i a] (map-indexed vector artists)] + ^{:key (:artist/id a)} + [:<> + (cond (zero? i) nil (= i (dec n)) " and " :else ", ") + (:artist/name a)]))) + +(defn- example [artist-id i p] + (let [others (ap/others-in p artist-id)] + [:figure.ap-example + [:div.ap-frame {:role "img" :aria-label (str "Example portrait " (inc i))} + [portrait/composite p]] + (when (seq others) + [:figcaption "with pieces by " (names others)])])) + +(defn- pieces [artist] + [:div.ap-groups + (for [{:keys [layer label] ps :pieces} (ap/pieces-by-layer artist)] + ^{:key layer} + [:div.ap-group + [:div.ap-group-name label + [:span.ap-group-count (count ps) (if (= 1 (count ps)) " piece" " pieces")]] + [:div.ap-pieces + (for [{:asset/keys [id url] piece-label :asset/label} ps] + ^{:key id} + [:div.ap-piece {:title piece-label} + [:img {:src url :alt (or piece-label "") :loading "lazy"}]])]])]) + +(defn- profile-card [{:keys [:artist/id :artist/name] :as artist}] + (let [links (ap/listed-links artist) + n (ap/piece-count artist)] + [:article.ap-card + [:header.ap-head + [:div.lk-cap "Portrait artist"] + [:div.lk-row + [:span.lk-rule.l {:aria-hidden true}] + [:h1.ap-name name] + [:span.lk-rule.r {:aria-hidden true}]] + [:p.ap-lede n " hand-drawn " (if (= 1 n) "piece" "pieces") + " in the portrait maker."] + (when (seq links) + [:nav.ap-links {:aria-label (str "Find " name)} + (for [l links] ^{:key (:link/url l)} [link-button l])])] + [:section.ap-section + [:h2.lk-cap "Portraits from these pieces"] + [:div.ap-examples + (map-indexed (fn [i p] ^{:key i} [example id i p]) + (ap/example-portraits id))]] + [:section.ap-section + [:h2.lk-cap "Pieces"] + [pieces artist]] + [:p.ap-foot + "Every portrait made with these pieces credits " name + ", on the character page, the share card and the PDF. " + [:a {:href (ap/index-path)} "All portrait artists"]]])) + +(defn artist-page + "/artists/" + [{:keys [slug]}] + (if-let [artist (ap/artist-by-slug slug)] + [shell "Portrait Artist" [profile-card artist]] + [shell "Portrait Artist" + [:article.ap-card + [:p.ap-missing "There is no portrait artist at this address. " + [:a {:href (ap/index-path)} "See all portrait artists"]]]])) + +(defn artists-page + "/artists: everyone with a profile, in registry order." + [_] + [shell "Portrait Artists" + [:article.ap-card + [:header.ap-head + [:div.lk-cap "Art by"] + [:p.ap-lede "The people who drew the pieces in the portrait maker."]] + [:div.ap-index + (for [{:keys [:artist/id :artist/name] :as a} (ap/all-profiles) + :let [n (ap/piece-count a)]] + ^{:key id} + [:a.ap-index-card {:href (ap/profile-path a)} + [:div.ap-frame [portrait/composite (first (ap/example-portraits id 1))]] + [:span.lk-name name] + [:span.ap-index-count n (if (= 1 n) " piece" " pieces")]])]]]) diff --git a/test/browser/artist_profile_e2e.js b/test/browser/artist_profile_e2e.js new file mode 100644 index 000000000..deeac9915 --- /dev/null +++ b/test/browser/artist_profile_e2e.js @@ -0,0 +1,246 @@ +// Artist profile pages, driven in the running app. +// +// /artists/ is the site's showcase of a portrait artist. This checks the +// page in both themes, the share tags the server sets for it, the 404 for a +// slug nobody has, the /artists list, and the drawer credit's secondary link -- +// and that the credit's NAME still goes to the artist's own link. +// +// lein fig:build +// lein garden once +// lein e2e-server +// node test/browser/artist_profile_e2e.js +// +// Set ORCPUB_SHOTS=dir to save screenshots of each state into dir. +// +// Exits non-zero if any check fails. + +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); +const { suppressOverlays } = require('./lib/orcbrew-import'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const SHOTS = process.env.ORCPUB_SHOTS; + +async function clickIfVisible(locator, { timeout = 2500 } = {}) { + try { await locator.click({ timeout }); return true; } + catch (_) { return false; } +} + +function findChrome() { + if (process.env.ORCPUB_CHROME) return process.env.ORCPUB_CHROME; + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + try { + const dir = fs.readdirSync(base) + .filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + if (dir) { + const p = path.join(base, dir, 'chrome-linux', 'chrome'); + if (fs.existsSync(p)) return p; + } + } catch (_) {} + return undefined; +} + +let failures = 0; +function check(label, ok, detail) { + if (!ok) failures++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail && !ok ? ' — ' + detail : ''}`); +} + +async function shot(page, name, opts = {}) { + if (!SHOTS) return; + fs.mkdirSync(SHOTS, { recursive: true }); + const file = path.join(SHOTS, name); + await page.screenshot({ path: file, ...opts }); + console.log(` screenshot -> ${file}`); +} + +const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/; + +// The theme lives in the `user` localStorage entry, as EDN. Seeding it before +// the app boots is what the builder's own toggle would have written. +async function themedContext(browser, theme, viewport = { width: 1280, height: 900 }) { + const ctx = await browser.newContext({ viewport }); + await suppressOverlays(ctx); + if (theme) { + await ctx.addInitScript(t => { + try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} + }, theme); + } + return ctx; +} + +function watch(page) { + const jsErrors = []; + const bad = []; + const IGNORABLE = /styles\.css|fonts\.googleapis|fonts\.gstatic|figwheel|favicon/i; + page.on('pageerror', e => jsErrors.push(String(e))); + page.on('response', r => { + if (r.status() >= 400 && !IGNORABLE.test(r.url())) bad.push(`${r.status()} ${r.url()}`); + }); + return { jsErrors, bad }; +} + +async function dismissCookies(page) { + const btn = page.locator('#cookie-btn'); + if (await btn.count()) { await clickIfVisible(btn); await page.waitForTimeout(150); } +} + +async function checkProfile(page, themeLabel) { + await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.ap-card', { timeout: 30000 }); + await dismissCookies(page); + // The composite is CSS masks over PNGs; give them a moment to paint. + await page.waitForTimeout(500); + + const name = page.locator('h1.ap-name'); + check(`[${themeLabel}] the artist's name heads the page`, + (await name.textContent()).trim() === 'Fusspot'); + const font = await name.evaluate(el => { + const s = getComputedStyle(el); return `${s.fontStyle} ${s.fontFamily}`; + }); + check(`[${themeLabel}] the name is set in Vollkorn italic, like the credit`, + /italic/.test(font) && /Vollkorn/.test(font), font); + + const hrefs = await page.locator('.ap-link').evaluateAll(as => as.map(a => a.href)); + check(`[${themeLabel}] the links are exactly the two she listed, in her order`, + JSON.stringify(hrefs) === JSON.stringify(['https://fusspot.rip/', 'https://www.twitch.tv/fusspot']), + JSON.stringify(hrefs)); + const targets = await page.locator('.ap-link').evaluateAll(as => as.map(a => a.target)); + check(`[${themeLabel}] they open in a new tab`, targets.every(t => t === '_blank'), JSON.stringify(targets)); + + const examples = page.locator('.ap-example'); + check(`[${themeLabel}] three example portraits`, await examples.count() === 3, + `saw ${await examples.count()}`); + const layered = await examples.evaluateAll(fs => + fs.map(f => f.querySelectorAll('.portrait-layer').length)); + check(`[${themeLabel}] every example is composed`, layered.every(n => n > 0), JSON.stringify(layered)); + check(`[${themeLabel}] a single-artist example credits nobody else`, + await page.locator('.ap-example figcaption').count() === 0); + + const pieces = await page.locator('.ap-piece').count(); + const lede = (await page.locator('.ap-lede').textContent()).trim(); + check(`[${themeLabel}] the piece count in the lede matches the pieces shown`, + pieces > 0 && lede.startsWith(`${pieces} `), `pieces=${pieces} lede=${lede}`); + const groups = await page.locator('.ap-group-name').evaluateAll(g => g.map(e => e.firstChild.textContent)); + check(`[${themeLabel}] pieces are grouped, the head first`, groups[0] === 'Head', JSON.stringify(groups)); + check(`[${themeLabel}] the generated scalp is not claimed as a drawing`, !groups.includes('Scalp')); + + const text = await page.locator('.ap-root').innerText(); + check(`[${themeLabel}] no email address anywhere on the page`, !EMAIL.test(text)); + check(`[${themeLabel}] no mailto: link anywhere on the page`, + await page.locator('a[href^="mailto:"]').count() === 0); + + const root = page.locator('.pl-root.ap-root'); + const isLight = await root.evaluate(el => el.classList.contains('light-theme')); + const cardBg = await page.locator('.ap-card').evaluate(el => getComputedStyle(el).backgroundColor); + const frameBg = await page.locator('.ap-frame').first().evaluate(el => getComputedStyle(el).backgroundImage); + return { isLight, cardBg, frameBg }; +} + +(async () => { + const browser = await chromium.launch({ executablePath: findChrome() }); + console.log(`\nartist profile e2e -- ${BASE}\n`); + + // --- server: share tags, og image, 404 ------------------------------- + { + const ctx = await themedContext(browser, null); + const req = ctx.request; + const html = await (await req.get(`${BASE}/artists/fusspot`)).text(); + check('share card is titled for the artist', html.includes('Fusspot — portrait artist')); + check('og:image is the page\'s lead portrait', + /og:image[^>]+\/artists\/fusspot\/portrait\.png/.test(html)); + const png = await req.get(`${BASE}/artists/fusspot/portrait.png`); + check('the og image is served as a PNG', + png.status() === 200 && png.headers()['content-type'] === 'image/png', + `${png.status()} ${png.headers()['content-type']}`); + const missing = await req.get(`${BASE}/artists/nobody`); + check('a slug nobody has is a 404', missing.status() === 404, `${missing.status()}`); + await ctx.close(); + } + + // --- the profile, dark then light ------------------------------------ + const dark = await themedContext(browser, 'dark-theme'); + const dp = await dark.newPage(); + const dw = watch(dp); + const d = await checkProfile(dp, 'dark'); + check('[dark] the page wears the dark theme', !d.isLight); + await shot(dp, 'profile-dark.png', { fullPage: true }); + + const light = await themedContext(browser, 'light-theme'); + const lp = await light.newPage(); + const lw = watch(lp); + const l = await checkProfile(lp, 'light'); + check('[light] the page wears the light theme', l.isLight); + check('the card repaints between themes', d.cardBg !== l.cardBg, `dark=${d.cardBg} light=${l.cardBg}`); + check('the portrait frames stay dark in both themes', d.frameBg === l.frameBg); + await shot(lp, 'profile-light.png', { fullPage: true }); + + // --- a phone --------------------------------------------------------- + const phone = await themedContext(browser, 'dark-theme', { width: 390, height: 844 }); + const pp = await phone.newPage(); + await pp.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); + await pp.waitForSelector('.ap-card', { timeout: 30000 }); + await dismissCookies(pp); + await pp.waitForTimeout(400); + const overflow = await pp.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + check('no sideways scroll at phone width', overflow <= 0, `overflow=${overflow}px`); + await shot(pp, 'profile-phone.png', { fullPage: true }); + await phone.close(); + + // --- nobody here, and the list --------------------------------------- + await lp.goto(`${BASE}/artists/nobody`, { waitUntil: 'networkidle' }); + await lp.waitForSelector('.ap-card', { timeout: 30000 }); + check('an unknown slug says so and points at the list', + await lp.locator('.ap-missing a[href="/artists"]').count() === 1); + + await dp.goto(`${BASE}/artists`, { waitUntil: 'networkidle' }); + await dp.waitForSelector('.ap-index', { timeout: 30000 }); + await dp.waitForTimeout(400); + const cards = await dp.locator('.ap-index-card').evaluateAll(as => as.map(a => a.getAttribute('href'))); + check('the list links each artist to their profile', + JSON.stringify(cards) === JSON.stringify(['/artists/fusspot']), JSON.stringify(cards)); + await shot(dp, 'index-dark.png', { fullPage: true }); + + // --- the drawer credit ----------------------------------------------- + for (const [label, pg] of [['dark', dp], ['light', lp]]) { + await pg.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + await pg.waitForSelector('#app', { timeout: 30000 }); + await dismissCookies(pg); + const descTab = pg.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await descTab.count()) { await descTab.click(); await pg.waitForTimeout(300); } + await pg.locator('.pl-launcher').click(); + await pg.locator('.pl-drawer').waitFor({ state: 'visible', timeout: 10000 }); + await pg.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await pg.waitForTimeout(500); + + const nameHref = await pg.locator('.pl-attribution a.lk-name').first().getAttribute('href'); + check(`[${label}] the credit's name still goes to the artist's own link`, + nameHref === 'https://fusspot.rip/', nameHref); + const about = pg.locator('.pl-attribution a.lk-about'); + check(`[${label}] the credit carries one link to the profile`, await about.count() === 1); + check(`[${label}] it goes to her profile, in a new tab`, + (await about.getAttribute('href')) === '/artists/fusspot' && + (await about.getAttribute('target')) === '_blank'); + check(`[${label}] it says what it is`, /About the artist/i.test(await about.innerText())); + const aboutColor = await about.evaluate(el => getComputedStyle(el).color); + const capColor = await pg.locator('.pl-attribution .lk-cap').evaluate(el => getComputedStyle(el).color); + check(`[${label}] it is as quiet as the ART BY label above it`, aboutColor === capColor, + `about=${aboutColor} cap=${capColor}`); + await shot(pg, `drawer-credit-${label}.png`, { clip: await pg.locator('.pl-canvas-side').boundingBox() }); + await about.hover(); + await pg.waitForTimeout(250); + await shot(pg, `drawer-credit-${label}-hover.png`, + { clip: await pg.locator('.pl-attribution').boundingBox() }); + } + + check('no page errors', dw.jsErrors.length + lw.jsErrors.length === 0, + [...dw.jsErrors, ...lw.jsErrors].join(' | ')); + // /artists/nobody is SUPPOSED to 404; anything else is not. + const bad = [...dw.bad, ...lw.bad].filter(b => !/\/artists\/nobody$/.test(b)); + check('no failed app resources', bad.length === 0, bad.join(' | ')); + + await browser.close(); + console.log(`\n${failures ? failures + ' FAILED' : 'all passed'}`); + process.exit(failures ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj new file mode 100644 index 000000000..ac64c702f --- /dev/null +++ b/test/clj/orcpub/artist_page_test.clj @@ -0,0 +1,39 @@ +(ns orcpub.artist-page-test + "The server's part of the artist profile: share tags for the SPA page, a real + 404 for a slug nobody has, and the og image. No database -- the page reads + only the registry." + (:require [clojure.test :refer [deftest is testing]] + [clojure.string :as s] + [orcpub.routes :as routes])) + +(defn- req [slug] + {:headers {"host" "example.test"} + :uri (str "/artists/" slug) + :path-params {:slug slug}}) + +(deftest the-profile-carries-its-own-share-tags + (let [{:keys [status body]} (routes/artist-page (req "fusspot"))] + (is (= 200 status)) + (is (s/includes? body "Fusspot — portrait artist") "the title names the artist") + (is (s/includes? body "hand-drawn pieces") "the description says what is there") + (is (s/includes? body "https://example.test/artists/fusspot/portrait.png") + "og:image is the page's lead portrait, rendered on the server"))) + +(deftest an-unknown-slug-is-a-404 + (testing "so a mistyped link is not indexed as a page; the SPA still loads + and says there is nobody at that address" + (is (= 404 (:status (routes/artist-page (req "nobody"))))) + (is (= 404 (:status (routes/artist-portrait-png (req "nobody"))))))) + +(deftest the-og-image-is-a-png + (let [resp (routes/artist-portrait-png (req "fusspot")) + buf (byte-array 4)] + (is (= 200 (:status resp))) + (is (= "image/png" (get-in resp [:headers "Content-Type"]))) + (is (= "noai, noimageai" (get-in resp [:headers "X-Robots-Tag"]))) + (.read ^java.io.InputStream (:body resp) buf) + (is (= [-119 80 78 71] (vec buf))))) + +(deftest the-list-page-is-served + (is (= 200 (:status (routes/artists-page {:headers {"host" "example.test"} + :uri "/artists"}))))) diff --git a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc new file mode 100644 index 000000000..8413f9252 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc @@ -0,0 +1,199 @@ +(ns orcpub.dnd.e5.artist-profile-test + "Pure-fn tests for the artist profile pages. The registry has one artist, so + anything about several splits Fusspot's layers across obvious stand-ins with + with-redefs, as the credit-order tests do. None of them is a registry entry." + (:require [clojure.test :refer [deftest testing is]] + [clojure.string :as s] + [orcpub.route-map :as route-map] + [orcpub.dnd.e5.artist-profile :as ap] + [orcpub.dnd.e5.portrait-assets :as pa])) + +(defn- stand-ins + "Fusspot's layers split across placeholder artists: [[id name layer-keys & more]]." + [owners] + (let [layers (:artist/layers pa/house-pack)] + (mapv (fn [[id nm ks extra]] + (merge (-> pa/house-pack + (dissoc :artist/slug) + (assoc :artist/id id :artist/name nm + :artist/layers (select-keys layers ks))) + extra)) + owners))) + +;; ---------- addresses ---------- + +(deftest fusspot-lives-at-a-readable-address + (let [a (ap/artist-by-slug "fusspot")] + (is (= :house-pack (:artist/id a)) + "the slug, not the id: saved portraits store :house-pack") + (is (= "/artists/fusspot" (ap/profile-path a))) + (is (= "/artists" (ap/index-path))) + (testing "people retype these" + (is (= :house-pack (:artist/id (ap/artist-by-slug " FussPot "))))) + (is (nil? (ap/artist-by-slug "house-pack")) + "the internal id is not a second address") + (is (nil? (ap/artist-by-slug "nobody"))) + (is (nil? (ap/artist-by-slug nil))))) + +(deftest the-route-map-round-trips + (is (= {:handler route-map/artist-page-route :route-params {:slug "fusspot"}} + (route-map/match-route "/artists/fusspot"))) + (is (= route-map/artists-page-route (:handler (route-map/match-route "/artists")))) + (is (= route-map/artist-portrait-route + (:handler (route-map/match-route "/artists/fusspot/portrait.png"))))) + +(deftest every-slug-is-a-clean-path-segment + (doseq [a pa/registry] + (is (re-matches #"[a-z0-9]+(?:-[a-z0-9]+)*" (ap/slug a)) + (str (:artist/id a) " has slug " (pr-str (ap/slug a)))))) + +(deftest an-artist-without-a-slug-falls-back-to-the-id + (with-redefs [pa/registry (stand-ins [[:placeholder-one "Placeholder One" [:eyes]]])] + (is (= "placeholder-one" (ap/slug (first pa/registry)))) + (is (= "/artists/placeholder-one" + (ap/profile-path (ap/artist-by-slug "placeholder-one")))))) + +(deftest a-restated-name-does-not-move-the-address + (try + (pa/set-artist-overrides! {:house-pack {:artist/name "Someone Else" + :artist/slug "someone-else"}}) + (let [a (ap/artist-by-slug "fusspot")] + (is (= "Someone Else" (:artist/name a)) "the page shows the name the deployment set") + (is (= "fusspot" (:artist/slug a)) "but the slug is not overridable")) + (is (nil? (ap/artist-by-slug "someone-else"))) + (finally (pa/set-artist-overrides! {})))) + +(deftest an-unnamed-artist-gets-no-page + (with-redefs [pa/registry (stand-ins [[:placeholder-named "Placeholder Named" [:head]] + [:placeholder-unnamed nil [:eyes]]])] + (is (nil? (ap/artist-by-slug "placeholder-unnamed")) + "they have not said how they want to be named; a page would invent it") + (is (= [:placeholder-named] (mapv :artist/id (ap/all-profiles)))))) + +;; ---------- the credit's secondary link ---------- + +(deftest the-credit-points-at-one-profile-or-the-list + (with-redefs [pa/registry (stand-ins [[:placeholder-a "Placeholder A" [:head]] + [:placeholder-b "Placeholder B" [:eyes]]])] + (let [[a b] (ap/all-profiles)] + (is (= {:href "/artists/placeholder-a" :label "About the artist"} + (ap/profile-link [a]))) + (is (= {:href "/artists" :label "About the artists"} + (ap/profile-link [a b]))) + (is (nil? (ap/profile-link [])) "nobody named, no line") + (testing "an artist on canvas without a profile does not count" + (is (= "/artists/placeholder-a" + (:href (ap/profile-link [a {:artist/id :not-registered :artist/name "X"}])))))))) + +(deftest the-credit-name-still-goes-where-the-artist-chose + (testing "the profile is a second link; :artist/link is untouched by it" + (is (= "https://fusspot.rip/" (:artist/link (ap/artist-by-slug "fusspot")))))) + +;; ---------- what they drew ---------- + +(deftest pieces-are-grouped-most-of-the-picture-first + (let [groups (ap/pieces-by-layer (ap/artist-by-slug "fusspot"))] + (is (= :head (:layer (first groups))) "the head carries the most weight") + (is (not-any? #{:scalp} (map :layer groups)) + "the generated scalp is nobody's drawing") + (is (= (map :layer groups) + (sort-by #(- (get pa/layer-credit-weight % 1)) (map :layer groups)))) + (is (every? (comp seq :pieces) groups) "no empty groups") + (is (= "Head" (:label (first groups)))) + (is (= (ap/piece-count (ap/artist-by-slug "fusspot")) + (- (reduce + (map count (vals pa/asset-inventory))) + (count (:scalp pa/asset-inventory))))))) + +(deftest a-partial-artist-shows-only-their-pieces + (with-redefs [pa/registry (stand-ins [[:placeholder-a "Placeholder A" [:head :shirt :scalp]] + [:placeholder-b "Placeholder B" [:eyes :nose]]])] + (let [b (ap/artist-by-slug "placeholder-b")] + (is (= #{:eyes :nose} (set (map :layer (ap/pieces-by-layer b))))) + (is (= 6 (ap/piece-count b)))))) + +;; ---------- links ---------- + +(deftest only-web-links-reach-the-page + (is (ap/web-url? "https://fusspot.rip/")) + (is (ap/web-url? "https://www.twitch.tv/fusspot")) + (is (ap/web-url? "https://mastodon.example/@placeholder") "an @ in the path is a handle") + (is (not (ap/web-url? "mailto:someone@example.test"))) + (is (not (ap/web-url? "someone@example.test"))) + (is (not (ap/web-url? "https://someone@example.test/")) "userinfo is an address in disguise") + (is (not (ap/web-url? "javascript:alert(1)"))) + (is (not (ap/web-url? "https://example.test/ spaced"))) + (is (not (ap/web-url? nil)))) + +(deftest the-links-are-the-ones-listed-and-nothing-else + (let [fusspot (ap/artist-by-slug "fusspot")] + (is (= ["https://fusspot.rip/" "https://www.twitch.tv/fusspot"] + (mapv :link/url (ap/listed-links fusspot))) + "the two she asked for, in her order")) + (testing "an email address is dropped even if one is put in the registry" + (is (= ["Site"] + (mapv :link/label + (ap/listed-links + {:artist/links [{:link/label "Site" :link/url "https://example.test/"} + {:link/label "Email" :link/url "mailto:placeholder@example.test"}]}))))) + (testing "a single :artist/link stands in when no labelled set is given" + (is (= [{:link/label "Site" :link/icon "site" :link/url "https://example.test/"}] + (ap/listed-links {:artist/link "https://example.test/"})))) + (testing "duplicates collapse, first one kept" + (is (= ["A"] + (mapv :link/label + (ap/listed-links {:artist/links [{:link/label "A" :link/url "https://example.test/"} + {:link/label "B" :link/url "https://example.test/"}]}))))) + (is (= [] (ap/listed-links {})) "no links, no buttons")) + +;; ---------- examples ---------- + +(deftest examples-are-stable + (is (= (ap/example-portraits :house-pack) (ap/example-portraits :house-pack)) + "the page, the og image and every reload show the same pictures") + (is (= (first (ap/example-portraits :house-pack)) + (first (ap/example-portraits :house-pack 1))) + "the first example is the same however many are asked for, so the og + image is the picture the page and the list lead with") + (is (= ap/example-count (count (ap/example-portraits :house-pack))))) + +(deftest examples-are-whole-drawable-and-different + (let [xs (ap/example-portraits :house-pack)] + (is (every? pa/drawable? xs)) + (is (every? #(= (set pa/layer-order) (set (keys (:layers %)))) xs) + "every layer that has art is filled") + (is (apply distinct? (map :layers xs)) "no two examples are the same picture") + (testing "a layer with at least as many pieces as examples never repeats one" + (doseq [k pa/layer-order + :when (>= (count (get pa/asset-inventory k)) ap/example-count)] + (is (apply distinct? (map #(get-in % [:layers k :asset/id]) xs)) (str k)))) + (is (every? #(every? (fn [slot] (re-matches #"#[0-9a-f]{6}" (get-in % [:colors slot]))) + [:hair :skin :eyes :shirt]) + xs) + "coloured from the presets"))) + +(deftest examples-use-the-artists-own-pieces-and-credit-the-rest + (with-redefs [pa/registry (stand-ins [[:placeholder-a "Placeholder A" + [:hair-bits :hair-back :head :scalp :shirt :hair-front :ears :mouth :bangs]] + [:placeholder-b "Placeholder B" [:eyes :nose]]])] + (let [xs (ap/example-portraits :placeholder-b)] + (is (every? #(= :placeholder-b (pa/artist-for-asset :eyes (get-in % [:layers :eyes :asset/id]))) xs)) + (is (every? #(= :placeholder-b (pa/artist-for-asset :nose (get-in % [:layers :nose :asset/id]))) xs)) + (is (every? pa/drawable? xs) "filled out from the others so the picture is whole") + (is (= [["Placeholder A"]] (distinct (map #(mapv :artist/name (ap/others-in % :placeholder-b)) xs))) + "and each example says whose pieces it borrowed") + (is (= [["Placeholder B"]] + (distinct (map #(mapv :artist/name (ap/others-in % :placeholder-a)) + (ap/example-portraits :placeholder-a)))) + "the other way round too, and an artist is never listed as borrowing + from themself")))) + +(deftest no-examples-for-nobody + (is (nil? (ap/example-portraits :not-registered)))) + +;; ---------- share tags ---------- + +(deftest share-tags-say-who-and-how-much + (let [a (ap/artist-by-slug "fusspot")] + (is (= "Fusspot — portrait artist" (ap/page-title a))) + (is (s/includes? (ap/share-description a) (str (ap/piece-count a) " hand-drawn pieces"))) + (is (not (s/includes? (ap/share-description a) "@"))))) diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index f5ea5383a..4acd3f0ea 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -8,6 +8,7 @@ [orcpub.dnd.e5.views-2 :as views-2] [orcpub.dnd.e5.views.conflict-resolution :as conflict-views] [orcpub.dnd.e5.views.whats-new :as whats-new-view] + [orcpub.dnd.e5.views.artist-page :as artist-page] [orcpub.route-map :as routes] [cljs-http.client :as http] [clojure.string :as s] @@ -74,7 +75,9 @@ routes/password-reset-success-route views/password-reset-success routes/password-reset-expired-route views/password-reset-expired-page routes/password-reset-used-route views/password-reset-used-page - routes/unsubscribe-success-route views/unsubscribe-success}) + routes/unsubscribe-success-route views/unsubscribe-success + routes/artists-page-route artist-page/artists-page + routes/artist-page-route artist-page/artist-page}) (defn handle-url-change [_] (let [route (when js/window.location From b17e66f22448540eab9a5a51354f49fa8ecf7956 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 14:05:07 +0000 Subject: [PATCH 03/91] Let an artist turn their profile page off :artist/profile? false removes the page entirely: the address and its preview image 404 like a slug nobody has, the artist drops out of /artists, and the credit loses its 'About the artist' line. The credit's name still links where the artist chose. It is honoured through set-artist-overrides!, beside the other credit fields, so the artist-account work can flip it from a setting without touching the registry. The account and its toggle are not built here. --- src/cljc/orcpub/dnd/e5/artist_profile.cljc | 19 +++++++++---- src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 11 ++++++-- test/clj/orcpub/artist_page_test.clj | 13 ++++++++- .../orcpub/dnd/e5/artist_profile_test.cljc | 28 +++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc index fb22c5629..fa0056064 100644 --- a/src/cljc/orcpub/dnd/e5/artist_profile.cljc +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -27,14 +27,23 @@ (defn- registry-entry [artist-id] (some #(when (= artist-id (:artist/id %)) %) pa/registry)) +(defn profile-enabled? + "Whether the artist wants a profile page. On unless :artist/profile? is + explicitly false -- the switch an artist's account setting will flip. Off + means no page at all, not a page saying it is off: the address 404s like a + slug nobody has, they drop out of /artists, and the credit loses its + 'About the artist' line. The credit's name still goes to their own link." + [artist] + (not (false? (:artist/profile? artist)))) + (defn profile - "The artist, overrides applied, if they have a profile: they are registered - and have said how they want to be named. An artist with no :artist/name is - skipped the way the credit skips them, rather than given a page nobody - chose." + "The artist, overrides applied, if they have a profile: they are registered, + have said how they want to be named, and have not turned the page off. An + artist with no :artist/name is skipped the way the credit skips them, + rather than given a page nobody chose." [artist-id] (when-let [a (pa/artist-info artist-id)] - (when-not (s/blank? (:artist/name a)) + (when (and (not (s/blank? (:artist/name a))) (profile-enabled? a)) (assoc a :artist/slug (slug (registry-entry artist-id)))))) (defn artist-by-slug diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc index 2eb7462ac..5dd04cc93 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -324,7 +324,13 @@ (defn set-artist-overrides! "Replace the runtime artist overrides. `m` is {artist-id {field value}}; - only :artist/name, :artist/link and :artist/license are honoured." + only :artist/name, :artist/link, :artist/links, :artist/license and + :artist/profile? are honoured. + + :artist/profile? false turns the artist's public profile page off (see + artist-profile/profile). It is here, beside the credit fields, so whatever + ends up owning the artist's choice -- a deployment's config today, the + artist's own account setting later -- can flip it without touching code." [m] (reset! artist-overrides (into {} @@ -332,7 +338,8 @@ :let [keep-fields (select-keys fields [:artist/name :artist/link :artist/links - :artist/license])] + :artist/license + :artist/profile?])] :when (seq keep-fields)] [id keep-fields])))) diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj index ac64c702f..b7b1ef5d9 100644 --- a/test/clj/orcpub/artist_page_test.clj +++ b/test/clj/orcpub/artist_page_test.clj @@ -4,7 +4,8 @@ only the registry." (:require [clojure.test :refer [deftest is testing]] [clojure.string :as s] - [orcpub.routes :as routes])) + [orcpub.routes :as routes] + [orcpub.dnd.e5.portrait-assets :as pa])) (defn- req [slug] {:headers {"host" "example.test"} @@ -37,3 +38,13 @@ (deftest the-list-page-is-served (is (= 200 (:status (routes/artists-page {:headers {"host" "example.test"} :uri "/artists"}))))) + +(deftest a-page-the-artist-turned-off-is-a-404 + (try + (pa/set-artist-overrides! {:house-pack {:artist/profile? false}}) + (is (= 404 (:status (routes/artist-page (req "fusspot"))))) + (is (= 404 (:status (routes/artist-portrait-png (req "fusspot")))) + "the preview image goes with it") + (is (not (s/includes? (:body (routes/artist-page (req "fusspot"))) "Fusspot")) + "and the 404's share tags do not name them") + (finally (pa/set-artist-overrides! {})))) diff --git a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc index 8413f9252..6d13f5174 100644 --- a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc +++ b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc @@ -197,3 +197,31 @@ (is (= "Fusspot — portrait artist" (ap/page-title a))) (is (s/includes? (ap/share-description a) (str (ap/piece-count a) " hand-drawn pieces"))) (is (not (s/includes? (ap/share-description a) "@"))))) + +;; ---------- the artist can turn the page off ---------- + +(deftest an-artist-can-turn-their-page-off + (is (ap/profile-enabled? (ap/artist-by-slug "fusspot")) "on unless turned off") + (try + (pa/set-artist-overrides! {:house-pack {:artist/profile? false}}) + (testing "off means no page at all, not a page saying it is off" + (is (nil? (ap/artist-by-slug "fusspot"))) + (is (= [] (ap/all-profiles)) "and they drop out of the list") + (is (nil? (ap/example-portraits :house-pack))) + (is (nil? (ap/profile-link [(pa/artist-info :house-pack)])) + "the credit loses its 'About the artist' line")) + (testing "but the credit itself is untouched: the name still goes where + the artist chose" + (is (= "https://fusspot.rip/" (:artist/link (pa/artist-info :house-pack)))) + (is (= "Art: Fusspot" (pa/credit-line {:layers (pa/compose-for-seed "off-check")})))) + (finally (pa/set-artist-overrides! {}))) + (is (some? (ap/artist-by-slug "fusspot")) "and turning it back on restores it")) + +(deftest one-artist-off-leaves-the-others + (with-redefs [pa/registry (stand-ins [[:placeholder-a "Placeholder A" [:head]] + [:placeholder-b "Placeholder B" [:eyes] + {:artist/profile? false}]])] + (is (= [:placeholder-a] (mapv :artist/id (ap/all-profiles)))) + (is (= "/artists/placeholder-a" + (:href (ap/profile-link (map pa/artist-info [:placeholder-a :placeholder-b])))) + "with only one profile left among two artists, the credit links that one"))) From c17705d23587e80b420f537cc67a7cef937f0158 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 14:09:05 +0000 Subject: [PATCH 04/91] Verify the artist pages in the running app, and record what was built Browser probe passes 51/51 against lein e2e-server in both themes and at phone width, including the drawer credit's new profile link. Adds it to the probe baseline, fixes the probe's og:image match and moves the mouse off the credit before judging its resting colour. Screenshots of every state, placeholder silhouettes only. PLAN.md gains the off switch, what was verified, and the open decisions. --- docs/design/artist-profiles/PLAN.md | 74 +++++++++++++++++++++++++++++ scripts/test/probe-baseline.json | 4 ++ test/browser/artist_profile_e2e.js | 5 +- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/design/artist-profiles/PLAN.md b/docs/design/artist-profiles/PLAN.md index 7a3eaa5fc..1dd9562be 100644 --- a/docs/design/artist-profiles/PLAN.md +++ b/docs/design/artist-profiles/PLAN.md @@ -100,3 +100,77 @@ Considered and not chosen: Artist accounts, editing a profile, anything tied to a site user, and any link the artist did not list. + +--- + +## Added after the plan: the artist can turn their page off + +The owner asked for a way for an artist to turn their page off from their account. Artist +accounts are being built elsewhere, so this branch adds only the switch the page obeys: +`:artist/profile?`, on unless it is set to `false`. It can be set through +`set-artist-overrides!` next to the other credit fields, so the account setting only has +to write it. When it is off: + +- `/artists/` and its `portrait.png` return 404, the same as for a name nobody + has, and the 404's share tags don't name the artist; +- the artist drops out of `/artists`; +- the credit loses its "About the artist" line; +- the credit's name still links to the artist's own `:artist/link`. + +## What was built + +| Piece | Where | +|---|---| +| Pure functions: slug, lookup, pieces, listed links, examples, profile link, the off switch | `src/cljc/orcpub/dnd/e5/artist_profile.cljc` | +| `:artist/slug "fusspot"`; `:artist/profile?` accepted as an override | `portrait_assets.cljc` | +| Routes `/artists`, `/artists/:slug`, `/artists/:slug/portrait.png` | `route_map.cljc`, `routes.clj` | +| The pages (profile, list, not-found) | `src/cljs/orcpub/dnd/e5/views/artist_page.cljs`, registered in `web/cljs/orcpub/core.cljs` | +| "About the artist ›" at the foot of the drawer credit | `portrait.cljs` (`credit-lockup`, `.lk-about`) | + +## What was verified + +- `lein test`: the new `artist-profile-test` (19 tests) and `artist-page-test` (5 tests) + pass, as do the existing `portrait-test`, `portrait-og-test` and `routes-test`. + Other artists in the tests are obvious placeholders set up with `with-redefs`. No + registry entry was added. +- `lein fig:build` compiles clean. `lein garden once` runs. +- `test/browser/artist_profile_e2e.js` passes all 51 checks against `lein e2e-server`. It + is now in `run-browser-probes.js` and the baseline. It covers: + - the share tags, the og PNG and the 404; + - both themes: the page repaints and the portrait frames stay dark; + - Vollkorn italic for the name; + - exactly the two listed links, opening in new tabs; + - no email address or `mailto:` anywhere on the page; + - no sideways scroll at 390px; + - the list page; + - in both themes, the drawer credit's name still going to `https://fusspot.rip/` and its + new profile link sitting at the same dim colour as "ART BY". + + The probe once failed on that last check because the mouse was resting on the link; + the probe now moves the mouse away first. +- Screenshots, taken with the placeholder silhouettes only: `shots/profile-dark.png`, + `profile-light.png`, `profile-phone.png`, `index-dark.png`, `drawer-credit-{dark,light}.png` + and their `-hover` versions. +- Not run: the full `lein test` suite and the other browser probes. + +## For the owner to decide + +1. **The credit on the character page.** The profile link is only in the drawer credit. + The 100px credit under the portrait on the character page is where viewers see a + credit, but adding a line there changes that strip. +2. **Piece tiles.** Every piece is drawn in the full portrait frame, so small pieces (nose, + mouth, eyes) are specks in their tiles. Cropping each tile to the piece's own bounds + would fix it. That needs either bounds stored in the registry or measuring at load. + It is worth doing once the real art is in. +3. **Example colours** are picked from the picker's presets by a seed. An artist may want + to choose their own showcase portraits instead, which could be a registry field or an + account feature later. +4. **When a page is turned off**, the address simply 404s. A short "this artist has made + their page private" message would be kinder, but it would also confirm that someone + is registered there. +5. **The slug cannot be overridden** by a deployment, so a fork that renames Fusspot keeps + `/artists/fusspot`. That's deliberate, so shared links keep working, but a fork might + want its own. +6. **Signing:** commits are GPG-signed as codeGlaze with a key generated in this + container. GitHub will show them as Unverified until the public key is added to the + account. diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index edbfad8be..22024226b 100644 --- a/scripts/test/probe-baseline.json +++ b/scripts/test/probe-baseline.json @@ -75,5 +75,9 @@ "portrait_pdf_export_e2e.js": { "checks": 10, "seconds": 45 + }, + "artist_profile_e2e.js": { + "checks": 51, + "seconds": 60 } } diff --git a/test/browser/artist_profile_e2e.js b/test/browser/artist_profile_e2e.js index deeac9915..f8bebbe0b 100644 --- a/test/browser/artist_profile_e2e.js +++ b/test/browser/artist_profile_e2e.js @@ -149,7 +149,7 @@ async function checkProfile(page, themeLabel) { const html = await (await req.get(`${BASE}/artists/fusspot`)).text(); check('share card is titled for the artist', html.includes('Fusspot — portrait artist')); check('og:image is the page\'s lead portrait', - /og:image[^>]+\/artists\/fusspot\/portrait\.png/.test(html)); + /]*property="og:image")(?=[^>]*content="[^"]*\/artists\/fusspot\/portrait\.png")[^>]*>/.test(html)); const png = await req.get(`${BASE}/artists/fusspot/portrait.png`); check('the og image is served as a PNG', png.status() === 200 && png.headers()['content-type'] === 'image/png', @@ -212,6 +212,9 @@ async function checkProfile(page, themeLabel) { await pg.locator('.pl-launcher').click(); await pg.locator('.pl-drawer').waitFor({ state: 'visible', timeout: 10000 }); await pg.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + // Composing grows the credit by a line, which slides the new link under + // the cursor left where Randomize was -- and a hovered link is lit. + await pg.mouse.move(5, 5); await pg.waitForTimeout(500); const nameHref = await pg.locator('.pl-attribution a.lk-name').first().getAttribute('href'); From 9e87f14a007d3a9854fbd14e83e427750af3ad2d Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 15:12:22 +0000 Subject: [PATCH 05/91] Add a mockup of the artist page options The built page, a recommended Carrd-style version with limits, and the everything version, plus the credit link, cropped tiles, pinned showcases and the off switch. Placeholder busts and a placeholder artist throughout; nothing invents accounts for Fusspot. --- docs/design/artist-profiles/mockup.html | 447 ++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/design/artist-profiles/mockup.html diff --git a/docs/design/artist-profiles/mockup.html b/docs/design/artist-profiles/mockup.html new file mode 100644 index 000000000..de7a79084 --- /dev/null +++ b/docs/design/artist-profiles/mockup.html @@ -0,0 +1,447 @@ +Artist Pages Mockup + + + + +
+
+
+
OrcPub · portrait artists
+

Artist pages, and what to add

+

The page as built today, a recommended Carrd-style version, and the everything version, followed by the four open questions from the last round. Anything not named Fusspot is placeholder data.

+
+
+ + +
+
+ +
+

1. How much of a Carrd should it be?

+

You're right that artists will expect to link whatever they like. My suggestion is to let them link anything, but keep the page itself the site's own: their words and links in our layout, rather than a page they design. The tabs show the three levels.

+
+ + + +
+ +
+
+ + + + +
+
+
Portrait artist
+

Example Artist

+ Commissions open +

Placeholder bio, plain text, up to 280 characters. Draws fantasy busts, streams line work on weeknights, and takes commissions twice a year.

+ Commission info example-artist.test +
+ Twitchtwitch.tv + Blueskybsky.app + Ko-fiko-fi.com + P Patreonpatreon.com + S Print shopshop.example.test +
+
+
Portraits the artist picked
+
+
Pieces
+

Every portrait made with these pieces credits Example Artist, on the character page, the share card and the PDF.

+
+ + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + +
OptionCallWhy
Links to anything on the webYesAny http(s) address, in the artist's order. Known services get their mark; the rest get a letter badge and the site's name.
How many linksUp to 8That's enough for a site, a stream, socials, a shop and a tip jar. Past that, the page becomes a list nobody reads, and the featured link stops standing out.
One featured linkYesShould be the same link as the credit's name link, so the artist sets where people go in one place.
Show the destination under each linkYesVisitors can see where they're going. Cheap protection if an account is ever taken over.
Short bioYesPlain text, 280 characters. No formatting means nothing to sanitise.
Commission statusYesOpen, closed or waitlist. It's the thing people most want to know, and it doesn't take a link.
Artist picks the showcase portraitsYesThree pinned portraits, built in the compositor with their pieces. Seeded examples remain the fallback.
Email, phone, link shortenersNoYou said no email on the page. Shorteners hide where a link goes.
Uploaded images (banner, gallery)NoWe'd be hosting and moderating other art. Their site already does that.
Custom colours and fontsNoThe page is ours, and it has to read in both themes. Their own site is where they style things.
Embedded playersLater, maybeThe site's security policy blocks third-party frames. A plain "Live now" badge could come later.
Guestbook or commentsNoIt needs moderation and gives a way to harass someone.
+
+
+ +
+

2. Where the profile link goes in a credit

+

The name always goes to the artist's own link. These are the options for a second, quieter link to the page. The drawer version is already built.

+
+
+

Drawer Built

+
+
Art by
+
Fusspot
+
About the artist ›
+
+

Matches the ART BY label, so it reads as part of the credit, not as another name.

+
+
+

Character page · as now

+
Art: Fusspot
+

No way to reach the page from here.

+
+
+

Character page · second line Suggest

+
Art: FusspotAbout the artist ›
+

Same wording as the drawer, one extra line of 7.5px caps. Readers see this credit more than any other.

+
+
+

Character page · small mark

+
Art: Fusspoti
+

No height added, but a lone circled “i” is easy to miss and hard to tap on a phone.

+
+
+
+ +
+

3. Piece tiles

+

Every piece is drawn in the full portrait frame, so small pieces are specks in their tiles. Cropping each tile to the piece fixes that.

+
+

Full frame · as now

Faithful to the art, but the nose and mouth tiles look empty.

+

Cropped to the piece Suggest

Needs a bounding box per piece: measured once when the art is imported and stored in the registry, so the page never has to load images to measure them.

+
+
+ +
+

4. Who picks the example portraits

+
+

Seeded · as now

Automatic and stable, but the colours are whatever the seed picks.

+

Artist pins three Suggest

Built in the portrait compositor they already know, then pinned to the page. Falls back to seeded examples until they pin any.

+
+
+ +
+

5. When the artist turns the page off

+

The switch is built on this branch; the account screen that flips it belongs to the artist-account work. Try the switch.

+
+
+

In their account

+
+
Public profile page/artists/example-artist
+
Credit name links toCommission info · example-artist.test
+
+

On: the page is public, and credits show “About the artist”.

+
+
+

A visitor sees · plain 404 Built

+
Example ArtistPage and links as in section 1.
+

Off looks exactly like an address nobody has, so it doesn't confirm the person is registered.

+
+
+

A visitor sees · “private” note

+
Example ArtistPage and links as in section 1.
+

Friendlier, but it confirms the name, which is what someone hiding from a harasser doesn't want.

+
+
+
+
+ + From e7443c011554df2afb2114c2a6d2b34296b3c635 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 16:17:17 +0000 Subject: [PATCH 06/91] Capture three artist card layouts from the running site capture-cards.js loads the real /artists/fusspot page and injects each layout at screenshot time, as the portrait-credit exploration did, so nothing in the source changes. The examples are shrunk to 240x300, carry a tiled watermark and are saved as quality-60 JPEGs, to show what a visitor or scraper would actually get. Placeholder silhouettes only. --- docs/design/artist-profiles/capture-cards.js | 190 +++++++++++++++++++ docs/design/artist-profiles/cards.html | 121 ++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 docs/design/artist-profiles/capture-cards.js create mode 100644 docs/design/artist-profiles/cards.html diff --git a/docs/design/artist-profiles/capture-cards.js b/docs/design/artist-profiles/capture-cards.js new file mode 100644 index 000000000..f7ef10148 --- /dev/null +++ b/docs/design/artist-profiles/capture-cards.js @@ -0,0 +1,190 @@ +// Artist card layouts, captured from the running app. +// +// Loads the real /artists/fusspot page, then replaces the card's markup with +// each candidate layout just before the screenshot -- the method +// docs/design/portrait-credit used -- so what is captured is the site, not a +// drawing of it. Nothing in the source changes. +// +// The examples are what the proposal would serve: one flattened picture per +// portrait, shrunk to 240x300, a watermark tiled across the whole image, saved +// as a quality-0.6 JPEG. Made here in the browser from the page's own +// composites; the real version would render them on the server. +// +// lein fig:build && lein e2e-server +// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-cards.js + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const OUT = process.argv[2] || 'card-shots'; + +function findChrome() { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +} + +// Shrink, watermark, compress. Runs in the page. +async function degrade(page, pngBuffers) { + return page.evaluate(async (b64s) => { + const out = []; + for (const b64 of b64s) { + const img = new Image(); + img.src = 'data:image/png;base64,' + b64; + await img.decode(); + const w = 240, h = 300; + const c = document.createElement('canvas'); + c.width = w; c.height = h; + const g = c.getContext('2d'); + g.drawImage(img, 0, 0, w, h); + // Tiled across the whole picture, through the figure, so no single crop + // or small inpaint removes it. + g.save(); + g.translate(w / 2, h / 2); + g.rotate(-Math.PI / 6); + g.font = '600 11px "Open Sans", sans-serif'; + g.textAlign = 'center'; + const mark = 'fusspot · orcpub'; + for (let y = -h; y < h; y += 34) { + for (let x = -w; x < w; x += 118) { + const dx = x + ((y / 34) % 2 ? 59 : 0); + g.lineWidth = 2; g.strokeStyle = 'rgba(0,0,0,0.18)'; g.strokeText(mark, dx, y); + g.fillStyle = 'rgba(255,255,255,0.26)'; g.fillText(mark, dx, y); + } + } + g.restore(); + out.push(c.toDataURL('image/jpeg', 0.6)); + } + return out; + }, pngBuffers.map(b => b.toString('base64'))); +} + +const CSS = ` +.mk-card { max-width: 560px; margin: 0 auto; padding: 28px 28px 22px; background: #131924; + border: 1px solid rgba(240,161,0,0.16); border-radius: 12px; display: flex; flex-direction: column; gap: 20px; } +.ap-root.light-theme .mk-card { background: #fff; border-color: rgba(0,0,0,0.10); } +.mk-head { display: flex; flex-direction: column; gap: 10px; text-align: center; } +.mk-bio { margin: 0; font: italic 13px/1.5 'Open Sans', sans-serif; color: var(--lk-dim); } +.mk-pill { align-self: center; font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .12em; text-transform: uppercase; + color: var(--lk-dim); border: 1px dashed var(--lk-rule); padding: 5px 9px; border-radius: 99px; } +.mk-links { display: flex; flex-direction: column; gap: 8px; } +.mk-link { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 5px; + border: 1px solid rgba(255,255,255,0.10); color: #ebeef4; text-decoration: none; + font: 600 13px/1 'Open Sans', sans-serif; } +.mk-link .host { margin-left: auto; font-weight: 400; font-size: 11px; color: var(--lk-dim); } +.mk-link.feature { border-color: #f0a100; background: rgba(240,161,0,0.07); } +.ap-root.light-theme .mk-link { border-color: rgba(0,0,0,0.14); color: #363636; } +.ap-root.light-theme .mk-link.feature { border-color: #33658A; background: rgba(51,101,138,0.06); } +.mk-link .lk-ico { width: 15px; height: 15px; } +.mk-ex { display: flex; justify-content: center; gap: 10px; } +.mk-ex img { width: 150px; height: 188px; border-radius: 8px; display: block; border: 1px solid rgba(240,161,0,0.16); } +.ap-root.light-theme .mk-ex img { border-color: rgba(0,0,0,0.12); } +.mk-excap { text-align: center; font: italic 12px/1.4 'Vollkorn', Georgia, serif; color: var(--lk-dim); margin-top: -8px; } +.mk-pieces { text-align: center; font-size: 12px; color: var(--lk-dim); margin: 0; } +.mk-pieces a { color: inherit; text-decoration: underline dotted var(--lk-under); text-underline-offset: 3px; } +.mk-foot { margin: 0; padding-top: 14px; border-top: 1px solid rgba(255,255,255,0.06); font-size: 12px; color: var(--lk-dim); text-align: center; } +.ap-root.light-theme .mk-foot { border-top-color: rgba(0,0,0,0.08); } +/* split */ +.mk-split { display: grid; grid-template-columns: 150px 1fr; gap: 22px; align-items: start; } +.mk-split .mk-head { text-align: left; } +.mk-split .mk-pill { align-self: flex-start; } +.mk-split .lk-row .lk-rule.l { display: none; } +.mk-split .ap-name { font-size: 34px; } +@media (max-width: 560px) { + .mk-card { padding: 22px 16px 18px; } + .mk-split { grid-template-columns: 1fr; } + .mk-split .mk-ex { justify-content: center; } + .mk-split .mk-head { text-align: center; } .mk-split .mk-pill { align-self: center; } + .mk-split .lk-row .lk-rule.l { display: block; } +}`; + +function ico(icon, color) { + return ``; +} + +const HEAD = ` +
Portrait artist
+

Fusspot

+

[The artist's own short bio, up to 280 characters.]

+ [Commission status]`; + +const LINKS = ` + `; + +const PIECES = `

28 pieces in the portrait maker: heads, hair, eyes and 7 more layers. + Try them in a portrait ›

`; + +const FOOT = `

Every portrait made with these pieces credits Fusspot.

`; + +const LAYOUTS = { + // A: one column, links first, two small examples after + column: (ex) => ` +
${HEAD}
+ ${LINKS} +
Example portraitExample portrait
+
Made with Fusspot's pieces
+ ${FOOT}`, + // B: a Carrd-style split card, one example beside the name and links + split: (ex) => ` +
+
Example portrait
+
+
${HEAD}
+ ${LINKS} +
+
+ ${FOOT}`, + // C: no pictures at all; the pieces are seen in use + minimal: () => ` +
${HEAD}
+ ${LINKS} + ${PIECES} + ${FOOT}` +}; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark-theme', 'light-theme']) { + for (const [vw, vh, tag] of [[1280, 900, 'desktop'], [390, 844, 'phone']]) { + const ctx = await browser.newContext({ viewport: { width: vw, height: vh }, deviceScaleFactor: 1 }); + await ctx.addInitScript(t => { + try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} + }, theme); + const page = await ctx.newPage(); + await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.ap-example .portrait-layer'); + await page.waitForTimeout(600); + // The cookie banner sits over the lower half of the card; accept it. + const cookie = page.locator('#cookie-btn'); + if (await cookie.count()) { await cookie.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(300); } + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + const frames = page.locator('.ap-example .ap-frame'); + const shots = [await frames.nth(0).screenshot(), await frames.nth(1).screenshot()]; + const ex = await degrade(page, shots); + if (theme === 'dark-theme' && tag === 'desktop') { + ex.forEach((d, i) => fs.writeFileSync(path.join(OUT, `example-${i}.jpg`), Buffer.from(d.split(',')[1], 'base64'))); + } + await page.addStyleTag({ content: CSS }); + for (const [name, render] of Object.entries(LAYOUTS)) { + await page.evaluate(html => { + const card = document.querySelector('.ap-card, .mk-card'); + card.className = 'mk-card'; + card.innerHTML = html; + }, render(ex)); + await page.waitForTimeout(250); + const box = await page.locator('.ap-root').boundingBox(); + const file = path.join(OUT, `${name}-${theme.replace('-theme', '')}-${tag}.png`); + await page.screenshot({ path: file, fullPage: true, clip: { x: 0, y: box.y, width: vw, height: box.height } }); + console.log(' ' + file); + } + await ctx.close(); + } + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/cards.html b/docs/design/artist-profiles/cards.html new file mode 100644 index 000000000..651f4173d --- /dev/null +++ b/docs/design/artist-profiles/cards.html @@ -0,0 +1,121 @@ +Artist Card Options + + + +
+
+
+
OrcPub · portrait artists
+

Artist card options

+

Screenshots of the running site. Each layout was injected into the real /artists/fusspot page at capture time, so the header, fonts, colours and art are the site's own, and no source code changed. Bracketed text is a placeholder for what the artist would write.

+
+
+ + +
+
+ +
+

A · Column, links first Suggest

+

Name, bio and status, then the links, which are the point of the page. Two small watermarked examples come after, as a taste rather than a catalogue.

+
+
Layout A on desktop
Desktop
+
Layout A on a phone
Phone, 390px
+
+
+ +
+

B · Split card

+

Closest to a Carrd profile: one example beside the name and links. Compact on desktop. On a phone it stacks into A with one picture.

+
+
Layout B on desktop
Desktop
+
Layout B on a phone
Phone, 390px
+
+
+ +
+

C · No pictures

+

Nothing to scrape on the page. The pieces are described in a line of text, with a link to try them in the portrait maker, where they're seen in use.

+
+
Layout C on desktop
Desktop
+
Layout C on a phone
Phone, 390px
+
+
+ +
+

The examples at actual size

+

This is the file a visitor or a scraper would get: 240×300, a quality-60 JPEG, with "fusspot · orcpub" tiled across the whole image, including the figure. The page shows it at 150px wide.

+
+
First example, reduced and watermarked
example-0.jpg
+
Second example, reduced and watermarked
example-1.jpg
+
+
+ +
+

Worth deciding

+
    +
  • Which layout. A is my pick; C is the safest if you'd rather show no art at all.
  • +
  • Watermark strength. This one is faint (26% white with a thin dark outline). A stronger one survives more cleanup but competes with the art.
  • +
  • Watermark text. Artist name plus site, or the artist's name alone.
  • +
  • Whether the link-preview image should get the same watermark. I'd say yes, since that's the copy that travels.
  • +
+
+
+ + From 432785ffbdc8a0948a3dfb2b860d336b1a76faaa Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 16:24:04 +0000 Subject: [PATCH 07/91] Capture link-icon candidates in the running credit and card Fifteen candidates for an artist's own-site link, from game-icons.net, Lucide, Tabler and Phosphor, each swapped into the drawer credit and the profile card's featured link at capture time and shot in both themes at 2x. Plus two full link lists, one per icon family. No source changes. --- docs/design/artist-profiles/capture-icons.js | 146 +++++++++++++++++ docs/design/artist-profiles/icons.html | 160 +++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 docs/design/artist-profiles/capture-icons.js create mode 100644 docs/design/artist-profiles/icons.html diff --git a/docs/design/artist-profiles/capture-icons.js b/docs/design/artist-profiles/capture-icons.js new file mode 100644 index 000000000..cc6b6e577 --- /dev/null +++ b/docs/design/artist-profiles/capture-icons.js @@ -0,0 +1,146 @@ +// Link-icon candidates, captured in the running credit and card. +// +// For each candidate, swaps the icon on the artist's site link -- the left +// mark in the drawer credit, and the featured row on the profile card -- and +// screenshots both in both themes. The icons go in as data-URI masks at +// capture time; nothing in the source changes. +// +// lein fig:build && lein e2e-server +// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-icons.js +// +// holds the candidate SVGs, normalised to black shapes on nothing +// (game-icons ship a black square behind a white glyph; strip it first). + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'icon-shots']; + +function findChrome() { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +} + +const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f)).toString('base64'); + +// Candidates for the artist's own site: [file, label] +const SITE = [ + ['now-site.svg', 'Globe (current)'], + ['lu-globe.svg', 'Globe, Lucide'], + ['lu-house.svg', 'House, Lucide'], + ['ph-house-line.svg', 'House, Phosphor'], + ['tb-world-www.svg', 'WWW, Tabler'], + ['lu-link.svg', 'Chain link, Lucide'], + ['gi-lorc-quill-ink.svg', 'Quill and ink, game-icons'], + ['gi-lorc-quill.svg', 'Quill, game-icons'], + ['ph-pen-nib.svg', 'Pen nib, Phosphor'], + ['ph-feather.svg', 'Feather, Phosphor'], + ['gi-delapouite-palette.svg', 'Palette, game-icons'], + ['lu-palette.svg', 'Palette, Lucide'], + ['gi-delapouite-paint-brush.svg', 'Paint brush, game-icons'], + ['ph-paint-brush.svg', 'Paint brush, Phosphor'], + ['gi-lorc-castle.svg', 'Castle, game-icons'] +]; + +// A full link list in two families, to judge consistency. +const FAMILIES = { + gameicons: [ + ['gi-lorc-quill-ink.svg', '#f0a100', 'Site', 'fusspot.rip'], + ['now-twitch.svg', '#9146ff', 'Twitch', 'twitch.tv/fusspot'], + ['gi-delapouite-paint-brush.svg', '#e0a24d', 'Portfolio', 'example.test/work'], + ['gi-lorc-scroll-unfurled.svg', '#c9a36a', 'Commissions', 'example.test/commissions'], + ['gi-lorc-open-book.svg', '#7a94b8', 'Blog', 'example.test/blog'], + ['lu-link.svg', '#8b95a5', 'Anything else', 'example.test'] + ], + lucide: [ + ['lu-house.svg', '#f0a100', 'Site', 'fusspot.rip'], + ['now-twitch.svg', '#9146ff', 'Twitch', 'twitch.tv/fusspot'], + ['lu-palette.svg', '#e0a24d', 'Portfolio', 'example.test/work'], + ['lu-scroll-text.svg', '#c9a36a', 'Commissions', 'example.test/commissions'], + ['lu-book-open.svg', '#7a94b8', 'Blog', 'example.test/blog'], + ['lu-link.svg', '#8b95a5', 'Anything else', 'example.test'] + ] +}; + +const CARD_CSS = ` +.mk-links { display: flex; flex-direction: column; gap: 8px; width: 420px; padding: 14px; } +.mk-link { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 5px; + border: 1px solid rgba(255,255,255,0.10); color: #ebeef4; text-decoration: none; font: 600 13px/1 'Open Sans', sans-serif; } +.mk-link .host { margin-left: auto; font-weight: 400; font-size: 11px; color: var(--lk-dim); } +.mk-link.feature { border-color: #f0a100; background: rgba(240,161,0,0.07); } +.ap-root.light-theme .mk-link { border-color: rgba(0,0,0,0.14); color: #363636; } +.ap-root.light-theme .mk-link.feature { border-color: #33658A; background: rgba(51,101,138,0.06); } +.mk-link .lk-ico { width: 16px; height: 16px; }`; + +const icoStyle = (u, color) => + `color:${color};-webkit-mask-image:url(${u});mask-image:url(${u})`; + +async function newPage(browser, theme) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme); + const page = await ctx.newPage(); + return { ctx, page }; +} + +async function dismiss(page) { + const cookie = page.locator('#cookie-btn'); + if (await cookie.count()) { await cookie.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(250); } + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); +} + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark', 'light']) { + // --- the drawer credit --- + const { ctx, page } = await newPage(browser, theme + '-theme'); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + await dismiss(page); + const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } + await page.locator('.pl-launcher').click(); + await page.locator('.pl-drawer').waitFor({ state: 'visible' }); + await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await page.mouse.move(5, 5); + await page.waitForTimeout(500); + for (const [file] of SITE) { + await page.evaluate(s => { + const ico = document.querySelector('.pl-attribution .lk-mark .lk-ico'); + ico.setAttribute('style', s); + }, icoStyle(uri(file), '#f0a100')); + await page.waitForTimeout(80); + await page.locator('.pl-attribution').screenshot({ path: path.join(OUT, `credit-${theme}-${file.replace('.svg', '')}.png`) }); + } + await ctx.close(); + + // --- the card's link rows --- + const p2 = await newPage(browser, theme + '-theme'); + await p2.page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); + await dismiss(p2.page); + await p2.page.addStyleTag({ content: CARD_CSS }); + const row = (u, color, label, host, feature) => + `${label}${host}`; + for (const [file] of SITE) { + await p2.page.evaluate(html => { + const card = document.querySelector('.ap-card, .mk-links'); + card.className = 'mk-links'; card.innerHTML = html; + }, row(uri(file), '#f0a100', 'Site', 'fusspot.rip', true)); + await p2.page.waitForTimeout(80); + await p2.page.locator('.mk-links').screenshot({ path: path.join(OUT, `card-${theme}-${file.replace('.svg', '')}.png`) }); + } + for (const [fam, rows] of Object.entries(FAMILIES)) { + await p2.page.evaluate(html => { + const card = document.querySelector('.ap-card, .mk-links'); + card.className = 'mk-links'; card.innerHTML = html; + }, rows.map(([f, c, l, h], i) => row(uri(f), c, l, h, i === 0)).join('')); + await p2.page.waitForTimeout(120); + await p2.page.locator('.mk-links').screenshot({ path: path.join(OUT, `family-${theme}-${fam}.png`) }); + } + await p2.ctx.close(); + console.log(' ' + theme + ' done'); + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/icons.html b/docs/design/artist-profiles/icons.html new file mode 100644 index 000000000..55055929d --- /dev/null +++ b/docs/design/artist-profiles/icons.html @@ -0,0 +1,160 @@ +Link Icon Options + + + + +
+
+
+
OrcPub · portrait artists
+

Link icon options

+

Each icon captured in the running site at real size: as the artist’s site mark in the drawer credit (12px, icon only, next to Twitch), and on the profile card’s featured link (16px, with its label). Amber at rest, like the current globe. Switch themes at the top right.

+
+
+ + +
+
+ +
+

For the artist’s own site

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IconIn the creditOn the card
Globe (current)the one in the credit todayGlobe (current) in the drawer creditGlobe (current) on the card's site link
GlobeLucideGlobe in the drawer creditGlobe on the card's site link
HouseLucideHouse in the drawer creditHouse on the card's site link
HousePhosphorHouse in the drawer creditHouse on the card's site link
WWWTablerWWW in the drawer creditWWW on the card's site link
Chain linkLucideChain link in the drawer creditChain link on the card's site link
Quill and inkgame-icons, the site’s own icon familyQuill and ink in the drawer creditQuill and ink on the card's site link
Quillgame-iconsQuill in the drawer creditQuill on the card's site link
Pen nibPhosphorPen nib in the drawer creditPen nib on the card's site link
FeatherPhosphorFeather in the drawer creditFeather on the card's site link
Palettegame-iconsPalette in the drawer creditPalette on the card's site link
PaletteLucidePalette in the drawer creditPalette on the card's site link
Paint brushgame-iconsPaint brush in the drawer creditPaint brush on the card's site link
Paint brushPhosphorPaint brush in the drawer creditPaint brush on the card's site link
Castlegame-iconsCastle in the drawer creditCastle on the card's site link
+
+ +
+

A whole link list, two families

+

The card with several link types, each with its own icon and colour. game-icons is filled and chunky, like the site’s header icons. Lucide is hairline, closer to the credit’s rule. Twitch stays its own mark in both.

+
+
Link list with game-icons
game-icons: quill and ink, paint brush, scroll, open book, chain
+
Link list with Lucide icons
Lucide: house, palette, scroll, open book, chain
+
+
+ +
+

Licences

+
    +
  • game-icons.net: CC BY 3.0. Needs a credit, which the site’s footer already gives for its header icons (Lorc, Delapouite).
  • +
  • Lucide (ISC), Tabler (MIT), Phosphor (MIT): free, no credit required.
  • +
+
+
+ + From 3b6be906c2bb3345aef26305f2528ddc99f6ca15 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 23:33:06 +0000 Subject: [PATCH 08/91] Capture site-mark icon and colour options in the running credit Seven icons (current globe, browser window, app window, house, compass, sparkle, quill and ink) in five colours, plus a white-on-tile badge for three of them, each swapped into the drawer credit beside the real Twitch mark at capture time. Both themes, 2x. No source changes. --- .../artist-profiles/capture-site-mark.js | 73 ++++++++++++ docs/design/artist-profiles/marks.html | 112 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 docs/design/artist-profiles/capture-site-mark.js create mode 100644 docs/design/artist-profiles/marks.html diff --git a/docs/design/artist-profiles/capture-site-mark.js b/docs/design/artist-profiles/capture-site-mark.js new file mode 100644 index 000000000..facd1f342 --- /dev/null +++ b/docs/design/artist-profiles/capture-site-mark.js @@ -0,0 +1,73 @@ +// The artist's site mark in the credit: filled icons x colours, plus a badge +// treatment, each captured beside the real Twitch mark in the running drawer. +// +// Swaps the credit's left mark at capture time with a data-URI mask (or, for +// the badge, a coloured tile holding a white glyph). Nothing in the source +// changes. +// +// lein fig:build && lein e2e-server +// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-site-mark.js + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'mark-shots']; + +function findChrome() { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +} +const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f)).toString('base64'); + +const ICON_SET = ['now-site', 'ph-browser-fill', 'ph-app-window-fill', 'ph-house-fill', + 'ph-compass-fill', 'ph-sparkle-fill', 'gi-lorc-quill-ink']; +const BADGE_SET = ['ph-browser-fill', 'ph-house-fill', 'gi-lorc-quill-ink']; +const COLORS = { amber: '#f0a100', coral: '#ff6b57', teal: '#1fb5a3', rose: '#e6488f', green: '#43b85c' }; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark', 'light']) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); + const page = await ctx.newPage(); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + const cookie = page.locator('#cookie-btn'); + if (await cookie.count()) { await cookie.click({ timeout: 3000 }).catch(() => {}); } + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } + await page.locator('.pl-launcher').click(); + await page.locator('.pl-drawer').waitFor({ state: 'visible' }); + await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await page.mouse.move(5, 5); + await page.waitForTimeout(500); + + const setMark = (html) => page.evaluate(h => { + document.querySelector('.pl-attribution .lk-mark').innerHTML = h; + }, html); + const shoot = (name) => page.locator('.pl-attribution .lk-row').screenshot({ path: path.join(OUT, `${theme}-${name}.png`) }); + + for (const icon of ICON_SET) { + for (const [cname, color] of Object.entries(COLORS)) { + const u = uri(icon + '.svg'); + await setMark(``); + await shoot(`${icon}-${cname}`); + } + } + for (const icon of BADGE_SET) { + for (const [cname, color] of Object.entries(COLORS)) { + const u = uri(icon + '.svg'); + await setMark(`` + + ``); + await shoot(`badge-${icon}-${cname}`); + } + } + await ctx.close(); + console.log(' ' + theme + ' done'); + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/marks.html b/docs/design/artist-profiles/marks.html new file mode 100644 index 000000000..674e5369a --- /dev/null +++ b/docs/design/artist-profiles/marks.html @@ -0,0 +1,112 @@ +Site Mark Options + + + + +
+
+
+
OrcPub · portrait artists
+

Site mark options

+

The artist’s site mark in the real drawer credit, at actual size, beside the real Twitch mark. Each row is an icon, each column a colour. The bottom rows are the badge treatment: a white icon on a coloured tile, closer to Twitch’s weight. Switch themes at the top right.

+
+
+ + +
+
+
+

Filled icon, coloured

+
+ + + + + + + +
Iconambercoraltealrosegreen
Globe (current)outline
Browser windowfilled
App windowfilled
Housefilled
Compassfilled
Sparklefilled
Quill and inkgame-icons
+
+
+

Badge

+
+ + + +
Iconambercoraltealrosegreen
Browser windowwhite on a tile
Housewhite on a tile
Quill and inkwhite on a tile
+
+
+

What to look for

+
    +
  • Whether the site mark holds its own against Twitch without shouting over the name.
  • +
  • Amber disappears into the dark theme’s amber rule. Coral, teal and rose don’t.
  • +
  • One option not shown: let each artist pick their site colour from a short list, so the mark is theirs.
  • +
+
+
+ From 8a06f6ae1940c15195a7442bc145373bf901928b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 29 Sep 2026 23:40:04 +0000 Subject: [PATCH 09/91] Capture the four pickable site icons in coral and teal Browser window, house, compass and sparkle, each in coral and teal, in the drawer credit and on the profile card, both themes. Injected at capture time; no source changes. --- docs/design/artist-profiles/capture-picks.js | 90 ++++++++++++++++ docs/design/artist-profiles/picks.html | 106 +++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 docs/design/artist-profiles/capture-picks.js create mode 100644 docs/design/artist-profiles/picks.html diff --git a/docs/design/artist-profiles/capture-picks.js b/docs/design/artist-profiles/capture-picks.js new file mode 100644 index 000000000..8a4498dda --- /dev/null +++ b/docs/design/artist-profiles/capture-picks.js @@ -0,0 +1,90 @@ +// The four site icons an artist could pick, in coral and teal, captured in the +// drawer credit and on the profile card. Injected at capture time; no source +// changes. +// +// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-picks.js + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'pick-shots']; +const findChrome = () => { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +}; +const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f + '.svg')).toString('base64'); +const mask = (u, color, size) => + ``; + +const PICKS = ['ph-browser-fill', 'ph-house-fill', 'ph-compass-fill', 'ph-sparkle-fill']; +const COLORS = { coral: '#ff6b57', teal: '#1fb5a3' }; + +const CARD_CSS = ` +.mk-card { max-width: 520px; margin: 0 auto; padding: 26px 26px 20px; background: #131924; + border: 1px solid rgba(240,161,0,0.16); border-radius: 12px; display: flex; flex-direction: column; gap: 18px; } +.ap-root.light-theme .mk-card { background: #fff; border-color: rgba(0,0,0,0.10); } +.mk-head { display: flex; flex-direction: column; gap: 10px; text-align: center; } +.mk-bio { margin: 0; font: italic 13px/1.5 'Open Sans', sans-serif; color: var(--lk-dim); } +.mk-links { display: flex; flex-direction: column; gap: 8px; } +.mk-link { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 5px; + border: 1px solid rgba(255,255,255,0.10); color: #ebeef4; text-decoration: none; font: 600 13px/1 'Open Sans', sans-serif; } +.mk-link .host { margin-left: auto; font-weight: 400; font-size: 11px; color: var(--lk-dim); } +.mk-link.feature { border-color: #f0a100; background: rgba(240,161,0,0.07); } +.ap-root.light-theme .mk-link { border-color: rgba(0,0,0,0.14); color: #363636; } +.ap-root.light-theme .mk-link.feature { border-color: #33658A; background: rgba(51,101,138,0.06); }`; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark', 'light']) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); + const page = await ctx.newPage(); + const settle = async () => { + const c = page.locator('#cookie-btn'); + if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + }; + + // credit + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + await settle(); + const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } + await page.locator('.pl-launcher').click(); + await page.locator('.pl-drawer').waitFor({ state: 'visible' }); + await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await page.mouse.move(5, 5); + await page.waitForTimeout(500); + for (const icon of PICKS) for (const [cn, color] of Object.entries(COLORS)) { + await page.evaluate(h => { document.querySelector('.pl-attribution .lk-mark').innerHTML = h; }, mask(uri(icon), color, 12)); + await page.locator('.pl-attribution').screenshot({ path: path.join(OUT, `credit-${theme}-${icon}-${cn}.png`) }); + } + + // card + await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); + await settle(); + await page.addStyleTag({ content: CARD_CSS }); + for (const icon of PICKS) for (const [cn, color] of Object.entries(COLORS)) { + const html = ` +
+
Portrait artist
+

Fusspot

+

[The artist's own short bio, up to 280 characters.]

+
+ `; + await page.evaluate(h => { const c = document.querySelector('.ap-card, .mk-card'); c.className = 'mk-card'; c.innerHTML = h; }, html); + await page.waitForTimeout(100); + await page.locator('.mk-card').screenshot({ path: path.join(OUT, `card-${theme}-${icon}-${cn}.png`) }); + } + await ctx.close(); + console.log(' ' + theme + ' done'); + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/picks.html b/docs/design/artist-profiles/picks.html new file mode 100644 index 000000000..31af86cb4 --- /dev/null +++ b/docs/design/artist-profiles/picks.html @@ -0,0 +1,106 @@ +Artist Site Mark Picks + + + + +
+
+
+
OrcPub · portrait artists
+

Site mark picks

+

The four icons an artist could choose for their own site, each in coral and teal. Every picture is the running site: the drawer credit at actual size, then the profile card’s links. Switch themes at the top right.

+
+
+ + +
+
+
+

Browser window

+
coral
Credit with ph-browser-fill in coralCard with ph-browser-fill in coral
teal
Credit with ph-browser-fill in tealCard with ph-browser-fill in teal
+
+
+

House

+
coral
Credit with ph-house-fill in coralCard with ph-house-fill in coral
teal
Credit with ph-house-fill in tealCard with ph-house-fill in teal
+
+
+

Compass

+
coral
Credit with ph-compass-fill in coralCard with ph-compass-fill in coral
teal
Credit with ph-compass-fill in tealCard with ph-compass-fill in teal
+
+
+

Sparkle

+
coral
Credit with ph-sparkle-fill in coralCard with ph-sparkle-fill in coral
teal
Credit with ph-sparkle-fill in tealCard with ph-sparkle-fill in teal
+
+
+

How the artist’s choice would work

+
    +
  • Each link in the registry already has its own icon and colour fields. That’s how Twitch gets its purple. Letting the artist pick just means those two fields come from their account, limited to an approved list.
  • +
  • Icons for their own site: browser window (the default if they never pick), house, compass, sparkle.
  • +
  • Colours: a short list checked against both themes, starting with coral and teal. A free colour picker could land on something unreadable on one of the backgrounds.
  • +
  • Twitch, Bluesky, Ko-fi and the rest keep their own brand mark and colour. Only the artist’s own site link is theirs to style.
  • +
+
+
+ From 0cdb95f86b1c9991fe713b9632293e3b532b995d Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 00:23:53 +0000 Subject: [PATCH 10/91] Capture themed colour pairs and a split-swatch picker for the site mark Five colours, each a dark-theme and light-theme pair at 4.3:1 or better on its own ground, including the site's warning-yellow. A picker with split swatches drawn inside the running site, and every icon and pair in the real credit, both themes. Injected at capture time; no source changes. --- .../artist-profiles/capture-swatches.js | 114 ++++++++++++++++ docs/design/artist-profiles/swatches.html | 125 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 docs/design/artist-profiles/capture-swatches.js create mode 100644 docs/design/artist-profiles/swatches.html diff --git a/docs/design/artist-profiles/capture-swatches.js b/docs/design/artist-profiles/capture-swatches.js new file mode 100644 index 000000000..fd6474021 --- /dev/null +++ b/docs/design/artist-profiles/capture-swatches.js @@ -0,0 +1,114 @@ +// Themed colour pairs for the artist's site mark, and a picker with split +// swatches. The picker is drawn inside the real site (on /artists/fusspot, in +// place of the card) because the account page it belongs on doesn't exist +// yet. The credit rows are the real drawer credit. Injected at capture time; +// no source changes. +// +// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-swatches.js + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'swatch-shots']; +const findChrome = () => { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +}; +const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f + '.svg')).toString('base64'); + +// [dark-theme colour, light-theme colour]; each >= 4.3:1 on its own ground +const PAIRS = { + coral: ['#ff6b57', '#d9432f'], + teal: ['#1fb5a3', '#0f8577'], + yellow: ['#ffd21a', '#9a7400'], + rose: ['#f06aa6', '#c2386f'], + green: ['#43b85c', '#2f8a44'] +}; +const ICON_SET = [['ph-browser-fill', 'Browser'], ['ph-house-fill', 'House'], ['ph-compass-fill', 'Compass'], ['ph-sparkle-fill', 'Sparkle']]; + +const mark = (u, color, size) => + ``; + +const PANEL_CSS = ` +.pk { max-width: 520px; margin: 0 auto; padding: 22px 24px; background: #131924; border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; display: flex; flex-direction: column; gap: 18px; font-family: 'Open Sans', sans-serif; color: #ebeef4; } +.ap-root.light-theme .pk { background: #fff; border-color: rgba(0,0,0,0.10); color: #363636; } +.pk h2 { margin: 0; font: 700 16px/1.3 'Open Sans', sans-serif; } +.pk .row { display: flex; flex-direction: column; gap: 8px; } +.pk .lbl { font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--lk-dim); } +.pk .icons, .pk .swatches { display: flex; flex-wrap: wrap; gap: 8px; } +.pk .ib { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 72px; padding: 10px 0 8px; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.10); font: 600 11px/1 'Open Sans', sans-serif; color: var(--lk-dim); } +.ap-root.light-theme .pk .ib { border-color: rgba(0,0,0,0.14); } +.pk .ib.on { border-color: #f0a100; color: #ebeef4; box-shadow: 0 0 0 2px rgba(240,161,0,0.25); } +.ap-root.light-theme .pk .ib.on { border-color: #33658A; color: #363636; box-shadow: 0 0 0 2px rgba(51,101,138,0.2); } +.pk .sw { width: 30px; height: 30px; border-radius: 50%; position: relative; border: 1px solid rgba(255,255,255,0.25); } +.ap-root.light-theme .pk .sw { border-color: rgba(0,0,0,0.2); } +.pk .sw.on { box-shadow: 0 0 0 2px #131924, 0 0 0 4px #f0a100; } +.ap-root.light-theme .pk .sw.on { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #33658A; } +.pk .swl { display: flex; flex-direction: column; align-items: center; gap: 6px; font: 600 10px/1 'Open Sans', sans-serif; color: var(--lk-dim); width: 46px; } +.pk .note { margin: 0; font-size: 12px; color: var(--lk-dim); } +.pk .prev { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 14px; display: flex; flex-direction: column; gap: 7px; } +.ap-root.light-theme .pk .prev { border-top-color: rgba(0,0,0,0.08); } +.pk .lk-name { text-decoration: underline dotted var(--lk-under); text-decoration-thickness: 1px; text-underline-offset: 3px; }`; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark', 'light']) { + const ti = theme === 'dark' ? 0 : 1; + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); + const page = await ctx.newPage(); + const settle = async () => { + const c = page.locator('#cookie-btn'); + if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + }; + + // --- the picker, inside the site --- + await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); + await settle(); + await page.addStyleTag({ content: PANEL_CSS }); + for (const [selIcon, selColor] of [['ph-browser-fill', 'teal'], ['ph-sparkle-fill', 'yellow']]) { + const c = PAIRS[selColor][ti]; + const html = ` +

Your artist page

+
Icon for your site
+ ${ICON_SET.map(([f, l]) => `
${mark(uri(f), f === selIcon ? c : 'currentColor', 18)}${l}
`).join('')} +
+
Colour
+ ${Object.entries(PAIRS).map(([n, [d, l]]) => `
${n}
`).join('')} +
+

Each colour has a version for the dark theme and one for the light theme. Visitors see whichever matches their theme.

+ `; + await page.evaluate(h => { const el = document.querySelector('.ap-card, .pk'); el.className = 'pk'; el.innerHTML = h; }, html); + await page.waitForTimeout(120); + await page.locator('.pk').screenshot({ path: path.join(OUT, `panel-${theme}-${selIcon}-${selColor}.png`) }); + } + + // --- the real credit, each pair --- + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + await settle(); + const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } + await page.locator('.pl-launcher').click(); + await page.locator('.pl-drawer').waitFor({ state: 'visible' }); + await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await page.mouse.move(5, 5); + await page.waitForTimeout(500); + for (const [f] of ICON_SET) for (const [n, pair] of Object.entries(PAIRS)) { + await page.evaluate(h => { document.querySelector('.pl-attribution .lk-mark').innerHTML = h; }, mark(uri(f), pair[ti], 12)); + await page.locator('.pl-attribution .lk-row').screenshot({ path: path.join(OUT, `credit-${theme}-${f}-${n}.png`) }); + } + await ctx.close(); + console.log(' ' + theme + ' done'); + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/swatches.html b/docs/design/artist-profiles/swatches.html new file mode 100644 index 000000000..e2d59026e --- /dev/null +++ b/docs/design/artist-profiles/swatches.html @@ -0,0 +1,125 @@ +Site Mark Colour Pairs + + + + +
+
+
+
OrcPub · portrait artists
+

Site mark colour pairs

+

Each colour the artist can pick is a pair: one version for the dark theme, one for the light, shown as a split swatch. Visitors see whichever matches their theme. Everything below is captured from the running site. Switch themes at the top right to see the other half of each pair.

+
+
+ + +
+
+ +
+

The picker

+

Drawn inside the site in place of the profile card, since the account page it belongs on doesn’t exist yet. The preview at the bottom is the real credit markup.

+
+
Picker with browser window in teal
Browser window, teal
+
Picker with sparkle in yellow
Sparkle, yellow
+
+
+ +
+

In the credit

+
+ + + + +
Iconcoraltealyellowrosegreen
Browser window
Browser window in coral
Browser window in teal
Browser window in yellow
Browser window in rose
Browser window in green
House
House in coral
House in teal
House in yellow
House in rose
House in green
Compass
Compass in coral
Compass in teal
Compass in yellow
Compass in rose
Compass in green
Sparkle
Sparkle in coral
Sparkle in teal
Sparkle in yellow
Sparkle in rose
Sparkle in green
+
+ +
+

The pairs

+

Every colour is at least 4.3:1 on its own background (icons need 3:1). The dark versions alone fail on white; yellow is 1.4:1. That’s why each colour is a pair.

+
+ + + + + +
ColourDark themeLight theme
coral#ff6b57#d9432f
teal#1fb5a3#0f8577
yellow#ffd21a#9a7400
rose#f06aa6#c2386f
green#43b85c#2f8a44
+
+ +
+

One thing about the yellow

+
    +
  • It’s the site’s warning-yellow (#ffd21a), which also marks unresolved conflicts and missing fields in the library health bar. As a small icon in a credit it doesn’t read as a warning, but if the health bar ever sits near a credit, the two could be confused.
  • +
+
+
+ From 4e74f7c34359ffbf657ae3f3930460867a3ccb13 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 09:38:26 +0000 Subject: [PATCH 11/91] Capture a paired-colour set for the site mark Each light-theme half is roughly the opposite of its dark half, as the site pairs amber with slate blue: gold/slate, ember/deep teal, tide/rust, bloom/forest, moss/plum. All at 5:1 or better on their own ground. The comparison page switches between this set and the deepened one. capture-swatches.js takes PAIRS_JSON and SELECTED_JSON to capture another set. --- .../artist-profiles/capture-swatches.js | 7 +- docs/design/artist-profiles/swatches.html | 82 +++++++++++-------- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/docs/design/artist-profiles/capture-swatches.js b/docs/design/artist-profiles/capture-swatches.js index fd6474021..cd5cabed7 100644 --- a/docs/design/artist-profiles/capture-swatches.js +++ b/docs/design/artist-profiles/capture-swatches.js @@ -20,13 +20,16 @@ const findChrome = () => { const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f + '.svg')).toString('base64'); // [dark-theme colour, light-theme colour]; each >= 4.3:1 on its own ground -const PAIRS = { +// PAIRS_JSON and SELECTED_JSON override these, to capture another set. +const PAIRS = process.env.PAIRS_JSON ? JSON.parse(process.env.PAIRS_JSON) : { coral: ['#ff6b57', '#d9432f'], teal: ['#1fb5a3', '#0f8577'], yellow: ['#ffd21a', '#9a7400'], rose: ['#f06aa6', '#c2386f'], green: ['#43b85c', '#2f8a44'] }; +const SELECTED = process.env.SELECTED_JSON ? JSON.parse(process.env.SELECTED_JSON) + : [['ph-browser-fill', 'teal'], ['ph-sparkle-fill', 'yellow']]; const ICON_SET = [['ph-browser-fill', 'Browser'], ['ph-house-fill', 'House'], ['ph-compass-fill', 'Compass'], ['ph-sparkle-fill', 'Sparkle']]; const mark = (u, color, size) => @@ -73,7 +76,7 @@ const PANEL_CSS = ` await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); await settle(); await page.addStyleTag({ content: PANEL_CSS }); - for (const [selIcon, selColor] of [['ph-browser-fill', 'teal'], ['ph-sparkle-fill', 'yellow']]) { + for (const [selIcon, selColor] of SELECTED) { const c = PAIRS[selColor][ti]; const html = `

Your artist page

diff --git a/docs/design/artist-profiles/swatches.html b/docs/design/artist-profiles/swatches.html index e2d59026e..1bb42d05d 100644 --- a/docs/design/artist-profiles/swatches.html +++ b/docs/design/artist-profiles/swatches.html @@ -57,69 +57,79 @@ .crop img { display: block; width: 250px; max-width: none; margin-left: -50px; height: auto; } .panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; } .panels img { border: 0; width: 100%; max-width: 570px; height: auto; } +.controls { display: flex; flex-wrap: wrap; gap: 10px; }
OrcPub · portrait artists

Site mark colour pairs

-

Each colour the artist can pick is a pair: one version for the dark theme, one for the light, shown as a split swatch. Visitors see whichever matches their theme. Everything below is captured from the running site. Switch themes at the top right to see the other half of each pair.

+

Each colour an artist can pick has a dark-theme half and a light-theme half, shown as a split swatch. Visitors see the half that matches their theme. Two ways to pair them are shown. Everything is captured from the running site.

-
- - +
+
+ + +
+
+ + +
+

The picker

Drawn inside the site in place of the profile card, since the account page it belongs on doesn’t exist yet. The preview at the bottom is the real credit markup.

-
-
Picker with browser window in teal
Browser window, teal
-
Picker with sparkle in yellow
Sparkle, yellow
-
+

In the credit

-
- - - - -
Iconcoraltealyellowrosegreen
Browser window
Browser window in coral
Browser window in teal
Browser window in yellow
Browser window in rose
Browser window in green
House
House in coral
House in teal
House in yellow
House in rose
House in green
Compass
Compass in coral
Compass in teal
Compass in yellow
Compass in rose
Compass in green
Sparkle
Sparkle in coral
Sparkle in teal
Sparkle in yellow
Sparkle in rose
Sparkle in green
+

The pairs

-

Every colour is at least 4.3:1 on its own background (icons need 3:1). The dark versions alone fail on white; yellow is 1.4:1. That’s why each colour is a pair.

-
- - - - - -
ColourDark themeLight theme
coral#ff6b57#d9432f
teal#1fb5a3#0f8577
yellow#ffd21a#9a7400
rose#f06aa6#c2386f
green#43b85c#2f8a44
+

Every colour is at least 4.3:1 on its own background (icons need 3:1).

+
ColourDark themeLight theme
-

One thing about the yellow

+

Trade-off

    -
  • It’s the site’s warning-yellow (#ffd21a), which also marks unresolved conflicts and missing fields in the library health bar. As a small icon in a credit it doesn’t read as a warning, but if the health bar ever sits near a credit, the two could be confused.
  • +
  • Paired colours give each theme its best colour, the way the site pairs amber with slate blue. But the artist’s mark changes colour when a visitor switches themes, and gold on light is the site’s own slate blue.
  • +
  • Same colour, deeper keeps the mark recognisably the same in both themes, the way Twitch stays purple, but the light theme gets muddier versions.
  • +
  • The yellow in both sets is the site’s warning-yellow, which also marks problems in the library health bar.
From 397173c103ab3f29b81133316bdbe35ab1e0cdbe Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 09:46:25 +0000 Subject: [PATCH 12/91] Capture a two-step site-mark picker offering both colour sets Five colours, then a choice of how the colour looks in the light theme: a deeper shade of itself or a paired colour. Covers both earlier sets without ten near-duplicate swatches, since the two sets share their dark halves. Drawn inside the running site; no source changes. --- docs/design/artist-profiles/capture-picker.js | 105 ++++++++++++++++++ docs/design/artist-profiles/picker.html | 95 ++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 docs/design/artist-profiles/capture-picker.js create mode 100644 docs/design/artist-profiles/picker.html diff --git a/docs/design/artist-profiles/capture-picker.js b/docs/design/artist-profiles/capture-picker.js new file mode 100644 index 000000000..b7b3528c8 --- /dev/null +++ b/docs/design/artist-profiles/capture-picker.js @@ -0,0 +1,105 @@ +// The two-step colour picker: five colours, then how the colour looks in the +// light theme -- a deeper shade of itself, or a paired colour. Drawn inside +// the running site in place of the profile card, since the account page it +// belongs on doesn't exist yet. Injected at capture time; no source changes. +// +// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-picker.js + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'picker-shots']; +const findChrome = () => { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +}; +const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f + '.svg')).toString('base64'); +const mark = (u, color, size) => + ``; + +// name: [dark, light as a deeper shade, light as a paired colour] +const COLORS = { + yellow: ['#ffd21a', '#9a7400', '#33658A'], + coral: ['#ff6b57', '#d9432f', '#0f7c80'], + teal: ['#1fb5a3', '#0f8577', '#b4532a'], + rose: ['#f06aa6', '#c2386f', '#1f7a4a'], + green: ['#43b85c', '#2f8a44', '#8e3a78'] +}; +const ICON_SET = [['ph-browser-fill', 'Browser'], ['ph-house-fill', 'House'], ['ph-compass-fill', 'Compass'], ['ph-sparkle-fill', 'Sparkle']]; + +const CSS = ` +.pk { max-width: 520px; margin: 0 auto; padding: 22px 24px; background: #131924; border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; display: flex; flex-direction: column; gap: 18px; font-family: 'Open Sans', sans-serif; color: #ebeef4; } +.ap-root.light-theme .pk { background: #fff; border-color: rgba(0,0,0,0.10); color: #363636; } +.pk h2 { margin: 0; font: 700 16px/1.3 'Open Sans', sans-serif; } +.pk .row { display: flex; flex-direction: column; gap: 9px; } +.pk .lbl { font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--lk-dim); } +.pk .icons, .pk .swatches { display: flex; flex-wrap: wrap; gap: 8px; } +.pk .ib { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 72px; padding: 10px 0 8px; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.10); font: 600 11px/1 'Open Sans', sans-serif; color: var(--lk-dim); } +.ap-root.light-theme .pk .ib { border-color: rgba(0,0,0,0.14); } +.pk .ib.on { border-color: #f0a100; color: #ebeef4; box-shadow: 0 0 0 2px rgba(240,161,0,0.25); } +.ap-root.light-theme .pk .ib.on { border-color: #33658A; color: #363636; box-shadow: 0 0 0 2px rgba(51,101,138,0.2); } +.pk .sw { width: 30px; height: 30px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.25); } +.ap-root.light-theme .pk .sw { border-color: rgba(0,0,0,0.2); } +.pk .sw.on { box-shadow: 0 0 0 2px #131924, 0 0 0 4px #f0a100; } +.ap-root.light-theme .pk .sw.on { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #33658A; } +.pk .swl { display: flex; flex-direction: column; align-items: center; gap: 6px; font: 600 10px/1 'Open Sans', sans-serif; color: var(--lk-dim); width: 46px; } +.pk .mode { display: inline-flex; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; overflow: hidden; align-self: flex-start; } +.ap-root.light-theme .pk .mode { border-color: rgba(0,0,0,0.14); } +.pk .mode span { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; font: 600 12px/1 'Open Sans', sans-serif; color: var(--lk-dim); } +.pk .mode span.on { background: rgba(240,161,0,0.10); color: #ffcc5e; } +.ap-root.light-theme .pk .mode span.on { background: rgba(51,101,138,0.08); color: #33658A; } +.pk .mode i { width: 14px; height: 14px; border-radius: 50%; display: inline-block; } +.pk .note { margin: 0; font-size: 12px; color: var(--lk-dim); } +.pk .prev { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 14px; display: flex; flex-direction: column; gap: 7px; } +.ap-root.light-theme .pk .prev { border-top-color: rgba(0,0,0,0.08); } +.pk .lk-name { text-decoration: underline dotted var(--lk-under); text-decoration-thickness: 1px; text-underline-offset: 3px; }`; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark', 'light']) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); + const page = await ctx.newPage(); + await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); + const c = page.locator('#cookie-btn'); + if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' + CSS }); + for (const [icon, color, mode] of [['ph-browser-fill', 'teal', 'shade'], ['ph-browser-fill', 'teal', 'paired'], + ['ph-sparkle-fill', 'yellow', 'shade'], ['ph-sparkle-fill', 'yellow', 'paired']]) { + const li = mode === 'shade' ? 1 : 2; + const [d, s, p] = COLORS[color]; + const shown = theme === 'dark' ? d : (mode === 'shade' ? s : p); + const html = ` +

Your artist page

+
Icon for your site
+ ${ICON_SET.map(([f, l]) => `
${mark(uri(f), f === icon ? shown : 'currentColor', 18)}${l}
`).join('')} +
+
Colour
+ ${Object.entries(COLORS).map(([n, v]) => `
${n}
`).join('')} +
+
In the light theme
+
+ A deeper shade + A paired colour +
+

The left half of each swatch is the dark theme; the right half is the light theme. Visitors see whichever matches theirs.

+
+ `; + await page.evaluate(h => { const el = document.querySelector('.ap-card, .pk'); el.className = 'pk'; el.innerHTML = h; }, html); + await page.waitForTimeout(120); + await page.locator('.pk').screenshot({ path: path.join(OUT, `picker-${theme}-${icon}-${color}-${mode}.png`) }); + } + await ctx.close(); + console.log(' ' + theme + ' done'); + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/picker.html b/docs/design/artist-profiles/picker.html new file mode 100644 index 000000000..10f89a0ff --- /dev/null +++ b/docs/design/artist-profiles/picker.html @@ -0,0 +1,95 @@ +Site Mark Picker + + + + +
+
+
+
OrcPub · portrait artists
+

Site mark picker

+

The artist picks an icon, one of five colours, and how that colour looks in the light theme: a deeper shade of itself or a paired colour. That covers both sets from last round without showing ten swatches. Drawn inside the running site in place of the profile card; the preview is the real credit.

+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+

What the account stores

+
    +
  • An icon name, a colour name and a light-theme mode. The site turns those into the dark or light colour to match each visitor.
  • +
  • In the dark theme the two modes look the same. The mode only changes what light-theme visitors see, which is why it’s a second, smaller choice.
  • +
+
+
+ From c51737f897bd3823f8c1375f53a98eae69483a45 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 10:11:50 +0000 Subject: [PATCH 13/91] Add a style guide for the site, from the Garden stylesheet Palette with roles, surfaces, the light theme, type, spacing, radius, focus and buttons, each fact re-read from core.clj rather than taken from the raw inventory, which is kept alongside. Records rules for new work (look first, extend not copy, no inline styles, name colours, pair them per theme, keep focus visible) and six open decisions, including two found while writing it: Open Sans is loaded at 400 only, so all 600/700 UI text is faux bold, and a global outline:0 removes keyboard focus everywhere. Proposes one button base with roles in place of the four near-copies that exist. --- docs/design/style-guide.md | 212 +++++++++++++++ docs/design/style-inventory.md | 465 +++++++++++++++++++++++++++++++++ 2 files changed, 677 insertions(+) create mode 100644 docs/design/style-guide.md create mode 100644 docs/design/style-inventory.md diff --git a/docs/design/style-guide.md b/docs/design/style-guide.md new file mode 100644 index 000000000..156e3f52e --- /dev/null +++ b/docs/design/style-guide.md @@ -0,0 +1,212 @@ +# OrcPub style guide + +What the site's styles are, which ones are canonical, and the rules for adding more. +Written 2026-09-30 from the Garden stylesheet, `src/clj/orcpub/styles/core.clj`. + +**Which branch this describes.** `core.clj` on `integration` (2026-09-20) and on the portrait +branch differ by 9 lines, all in `.builder-tabs` (the portrait branch wraps the tabs for a +fourth one). So this guide holds for both. Line numbers are the portrait branch's; on +`integration` anything after line 1304 sits 7 lines earlier. The portrait drawer +(`portrait.cljs`) exists only on the portrait branch. + +**How it was made.** A cheap agent inventoried every colour, size and spacing value with +file:line references (`style-inventory.md`, alongside this file). Its counts are approximate +and it misread one deliberate choice as a contradiction (the two reds, see below). Everything +this guide states as fact was re-read from the source. + +Decisions marked **Proposed** are the owner's to make. Everything else describes what exists. + +--- + +## 1. Rules for new work + +1. **Look before you add.** Search this guide and `core.clj` for a class that already does the + job. If one is close, extend it with a modifier (`.btn.quiet`) rather than copying it with + small changes. Section 5 lists what near-copies have already cost. +2. **No inline styles.** Styles go in Garden. No `:style` maps in views, no CSS strings in + `[:style]` tags. Values that vary at runtime come from a fixed list, so each value gets a + class (see the artist site-mark colours, section 6). +3. **Name colours before using them.** A new colour becomes a `def` at the top of `core.clj` + with a comment saying what it's for, like `warning-yellow`. No new hex literals inside rules. +4. **Every colour works in both themes.** If one value can't, define a pair and let + `.app.light-theme` swap it, the way the two reds already do (section 2). +5. **Keyboard focus stays visible.** Anything clickable gets a `:focus-visible` style. See + section 4 for why this currently needs doing by hand. + +--- + +## 2. Colour + +### Named colours (`core.clj:8-21`) + +These are the palette. Use them by name. + +| Name | Value | Role | +|---|---|---| +| `orange` / `button-color` | `#f0a100` | The accent: links, buttons, highlights, the dark theme's signature | +| `red` | `#9a031e` | Errors in the **light** theme | +| `red-on-dark` | `#ff6b6b` | Errors in the **dark** theme | +| `amber-on-dark` | `#f5b942` | Field warnings, dark theme | +| `muted-on-dark` | `#9fb0c3` | Secondary text, dark theme | +| `warning-yellow` | `#ffd21a` | "Needs attention": unresolved conflicts, missing fields | +| `broken-red` | `#e5637a` | "Broken": invalid or unexportable data | +| `green` | `#70a800` | Conflict resolution: keep existing | +| `cyan` | `#47eaf8` | Import log; conflict rename option | +| `purple` | `#8b7ec8` | Conflict skip option | + +**The two reds are the model for theme pairs.** The comment at `core.clj:11` explains it: `#9a031e` +reads about 9:1 on white and about 2:1 on the dark ground. So the dark theme uses +`red-on-dark`, and `.app.light-theme` swaps `red` back in. Any colour that can't serve both +themes should follow this pattern. + +### Surfaces (unnamed today) + +These recur as literals. **Proposed:** give each a name. + +| Value | Where | Role | +|---|---|---| +| `#313A4D` → `#080A0D` | `index.clj:174` (`#app` gradient); `#080A0D` alone at `index.clj:172` (body) | Page ground | +| `#313A4D` | `core.clj:1215`, `1710`; top of the page gradient | Header bar, stuck sticky header | +| `#1a1e28` | `core.clj:1199`, `1753`, `1938`, `2278` | Panels and modals (dark in **both** themes, per `core.clj:1960`) | +| `#2c3445` | `core.clj:665`, `1950`, `2169`, `2291` | Raised surfaces inside panels: dropdowns, modal headers | + +### Light theme (`.app.light-theme`, from `core.clj:1778`) + +| Value | Role | +|---|---| +| `linear-gradient(182deg, #FFFFFF, #DDDDDD)` | Page ground | +| `#363636` | Main text, links | +| `#282828` | Input borders, option text | +| `#33658A` | The light theme's accent: `.form-button`, focus of attention. Stands in for amber | +| `#8a5a00` | Warning text (light counterpart of `amber-on-dark`) | +| `#55637a` | Note text | + +`#33658A` does for the light theme what `orange` does for the dark one, but it has no name. +**Proposed:** `accent-light`. + +### Near-duplicates to fold together + +| Values | Where | Proposed | +|---|---|---| +| `#f0a100` and `#f1a20f` | `orange`; the `.form-button` gradient top (`core.clj:1428`) and builder tabs (`1330`, `1343`) | Use `orange`; the difference is invisible | +| `#dbab50` | `.form-button` gradient bottom | Name it as the gradient's second stop, or drop the gradient | + +--- + +## 3. Type + +| Face | Loaded | Used for | +|---|---|---| +| **Open Sans** | Google Fonts, `index.clj:265`, **regular weight only** | Everything in the app | +| **Vollkorn** italic | `@font-face` in the portrait drawer's CSS string, a Latin-1 subset | Artist names in credits; the baked portrait caption | +| `ui-monospace, Menlo, monospace` | system | Code and seed values in the portrait drawer | + +**Faux bold.** Only Open Sans 400 is loaded (`css?family=Open+Sans`), but buttons, tabs and labels +ask for 600 and 700. Browsers synthesise those by smearing the regular weight, which is why bold +UI text looks slightly soft. **Proposed:** load `Open+Sans:400,600,700`. It's a one-line change +that sharpens every button and heading. + +Sizes come from the `.f-s-*` utilities (`core.clj:66`): 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, +36, 48 px. UI chrome is 12px/600 uppercase (buttons, tabs); body text is 14px. + +--- + +## 4. Spacing, radius, focus + +**Spacing** comes from generated utilities (`px-prop`, `core.clj:34`), not individual rules: +- `m-l-*`: −1 to 9, then 10 to 50 in 5s. +- `m-t-*`: 0 to 9, **21**, then 10 to 25 in 5s. 21 is a one-off for a single header. +- `w-*`: a fixed list (`core.clj:53`). + +A class that isn't in these lists compiles without error and **produces no CSS**. The +garden-inline-styles harvest hit this with `.m-t-100` and `.min-w-53` (see +`docs/kb/garden-inline-styles-harvest.md` on `agents/develop`). Check the list before using one. + +**Radius.** 5px for buttons, inputs and panels (`.b-rad-5`); 10px for large frames; 50% or 999px +for pills and dots. The inventory also found 3, 4, 6 and 8px one-offs. **Proposed:** 5 and 10 only, +plus pills. + +**Focus.** `*:focus {outline: 0}` (`core.clj:1202`) removes the keyboard focus ring from +everything. Only the dev-mode toggle adds one back (`core.clj:2217`, a 2px orange outline, offset +3px). **Proposed:** replace the global rule with `:focus-visible` rings on interactive +elements (orange in dark, `#33658A` in light), using the dev-mode toggle's style as the +standard. Until then, new components must add their own. + +--- + +## 5. Buttons + +### What exists + +| Class | What it is | Where | +|---|---|---| +| `.form-button` | The primary button: amber gradient, white 12px/600 uppercase, 5px radius, shadow on hover. Flat `#33658A` in light theme | `core.clj:1419`; light at `1831` | +| `.link-button` | A text action: amber uppercase underlined text, no fill | `core.clj:1647`; light at `1807` | +| `.mc-btn` | **A copy of `.form-button`** plus icon spacing (`inline-flex`, `gap: 7px`) and no wrap | `core.clj:1500` | +| `.roll-button` | **A copy of `.form-button`** with 14px text, 2px radius and different padding | `core.clj:1462` | +| `.pl-btn` (portrait branch) | The drawer's own button set, "matched to `.form-button`" per its comment, in an inline CSS string | `portrait.cljs` | +| `.ap-link` (portrait branch) | The artist page's link rows, written fresh | `views/artist_page.cljs` | +| `.remove-item-button`, `.add-item-button`, `.expand-collapse-button`, `.close-button`, `.inv-picker-btn` | Single-purpose controls | `core.clj` | + +The `port/redesign-on-refactor` branch adds six more (`theme-switch-btn`, `theme-mode-btn`, +`select-menu-btn`, `ability-stepper-btn`, `opt-info-btn`, `form-submit-btn`). No branch has a +shared base. + +### What's wrong with reusing them as-is + +- No focus style (section 4). +- `.form-button` has one weight only. There's no quiet or outlined version, so pages that need + one invent it, which is how `.pl-btn`'s ghost button and `.ap-link` happened. +- Three of the classes are the same button with small differences, so a fix to one misses the + others. + +### Proposed: one base, a few roles + +``` +.btn shared: Open Sans 12px/600 uppercase, 5px radius, padding 10px 15px, + cursor, focus-visible ring, disabled state +.btn.primary today's .form-button (amber; #33658A in light) +.btn.quiet outlined, transparent fill: the drawer's ghost button, secondary actions +.btn.row full-width row with an icon, a label and a trailing detail: link lists +.btn.icon inline-flex with a gap, for icon + label (what .mc-btn adds) +.btn.small the compact size .roll-button needs +``` + +`.form-button` stays as an alias of `.btn.primary` until its call sites move, so nothing breaks +at once. `.mc-btn`, `.roll-button` and `.pl-btn` then become the base plus a role instead of +copies. + +**Where:** a small branch off `integration`. Not the portrait branch, which shares no history +with `integration`, and not `refactor/garden-inline-styles`, which is being harvested for parts +and never touched buttons. + +--- + +## 6. Colours chosen by users + +The artist's site-mark picker (`docs/design/artist-profiles/picker.html`) is the first place users +pick colours. It follows rule 2: every choice is a named preset with a dark and a light value, so +each one compiles to a class and nothing is set inline. + +| Preset | Dark theme | Light: deeper shade | Light: paired colour | +|---|---|---|---| +| yellow | `#ffd21a` (`warning-yellow`) | `#9a7400` | `#33658A` | +| coral | `#ff6b57` | `#d9432f` | `#0f7c80` | +| teal | `#1fb5a3` | `#0f8577` | `#b4532a` | +| rose | `#f06aa6` | `#c2386f` | `#1f7a4a` | +| green | `#43b85c` | `#2f8a44` | `#8e3a78` | + +Each value is at least 4.3:1 against its own theme's background. `port/redesign-on-refactor` +treats themes as data with an `--accent` CSS variable. If that model is adopted, these presets +belong in it as tokens. + +--- + +## 7. Open decisions + +1. Load Open Sans 600 and 700 (section 3). +2. Replace the global `outline: 0` with focus-visible rings (section 4). +3. Name the surface colours and the light accent (section 2). +4. Build the shared button base on a branch off `integration` (section 5). +5. Radius: 5 and 10 only, plus pills (section 4). +6. Whether the redesign branch's token model is the direction for themes (section 6). diff --git a/docs/design/style-inventory.md b/docs/design/style-inventory.md new file mode 100644 index 000000000..d0c50f985 --- /dev/null +++ b/docs/design/style-inventory.md @@ -0,0 +1,465 @@ +# OrcPub Style Inventory + +**As of 2026-09-29** + +## Summary + +- **Distinct colours**: 38 (dark theme, light theme, accents, portrait drawer) +- **Font families**: 3 (Open Sans primary, Vollkorn for portrait credit, ui-monospace for code) +- **Font sizes**: 15 (10px to 48px) +- **Spacing values**: 35+ (1px to 80px margins/padding) +- **Top 3 contradictions**: + 1. #f0a100 vs #f1a20f (orange button gradient; near-identical variants) + 2. Two different red definitions: #9a031e (light theme) and #ff6b6b (dark theme default) + 3. 21px margin-top is an isolated odd value in an otherwise 5/10/20/30/40 sequence + +--- + +## 1. Colours + +### Dark Theme (Default) + +| Value | Name in Code | Where | Count | Usage | +|-------|--------------|-------|-------|-------| +| #f0a100 | `orange` / `button-color` | core.clj:8,9,241,548; portrait.cljs:311,330,338,441,451,485 | 13+ | Primary accent: buttons, borders, text highlights, UI focus states | +| #f1a20f | (unnamed) | core.clj:1330,1343 | 2 | Button gradient lighter shade (builder tabs) | +| #dbab50 | (unnamed) | core.cljs:1428,1475 | 2 | Button gradient darker shade, text in portrait drawer | +| #ff6b6b | `red-on-dark` | core.clj:14,359,718 | 3+ | Error/alert text in dark theme, conflict modal rail | +| #9a031e | `red` | core.clj:10,678 | 2 | Alert backgrounds; overridden in light theme | +| #ffcc5e | (unnamed) | portrait.cljs:338,416,425,451,469,485,503,527,528,571,572 | 11+ | Focus states, hover highlights in portrait drawer | +| #ffd21a | `warning-yellow` | core.clj:20,717,1592,1596,2000,2001,2005 | 7+ | Library health warnings, export unfilled fields, conflict attention | +| #e5637a | `broken-red` | core.clj:21,601,1601 | 3+ | Severity indicator for broken data (darker red) | +| #f5b942 | `amber-on-dark` | core.clj:15,594 | 2 | Field notice warnings in dark theme | +| #70a800 | `green` | core.clj:17,352,2050 | 3+ | Conflict "keep existing" action, text color | +| #47eaf8 | `cyan` | core.clj:18,1046,2099,2124 | 4+ | Import log, conflict rename option, code blocks | +| #8b7ec8 | `purple` | core.clj:19,2113 | 2 | Conflict skip option | +| #9fb0c3 | `muted-on-dark` | core.clj:16,308,595,2370 | 4+ | Muted text, secondary info ("What's New" subtitle) | +| #ebeef4 | (unnamed) | portrait.cljs:317,382,393,452,519,553 | 6+ | Main text color in portrait drawer | +| #8b95a5 | (unnamed) | portrait.cljs:335,407,575 | 3 | Secondary text in portrait drawer, disabled state | +| #616a7a | (unnamed) | portrait.cljs:382,430,483 | 3 | Label text, muted elements in portrait drawer | +| #131924 | (unnamed) | portrait.cljs:310,354,436,525,548,562 | 6+ | Dark background, portrait picker base | +| #0e131a | (unnamed) | portrait.cljs:326,345,467 | 3 | Darker background sections (portrait drawer header, canvas side) | +| #1a1e28 | (unnamed) | core.clj:1753,2212,2278,2490 | 7+ | Panel/modal background (conflict, export, what's new) | +| #1b2230 | (unnamed) | portrait.cljs:256 | 1 | Empty portrait frame gradient start | +| #141a25 | (unnamed) | portrait.cljs:256 | 1 | Empty portrait frame gradient end | +| #0f141c | (unnamed) | portrait.cljs:374 | 1 | Portrait frame radial gradient end | +| #202939 | (unnamed) | portrait.cljs:374 | 1 | Portrait frame radial gradient middle | +| #1e2635 | (unnamed) | portrait.cljs:492,536,548 | 3 | Portrait panel background, layer panel | +| #171e29 | (unnamed) | portrait.cljs:425,473,548 | 3+ | Portrait color strip background, picker background | +| #2c3445 | (unnamed) | core.clj:1710,2212,2291 | 3+ | Dropdown/filter background, sticky header stuck state, what's new header | +| #313A4D | (unnamed) | core.clj:1215,1054 | 3 | App header background, app gradient base | +| #080A0D | (unnamed) | core.clj:2427,index.clj:172,174 | 2+ | Base body/app background, export busy page background | +| #191919 | (unnamed) | core.clj:344 | 1 | Black text class (`.black`) | +| #15202e | (unnamed) | portrait.cljs:401 | 1 | Portrait button text color (primary) | +| #b57500 | (unnamed) | portrait.cljs:402 | 1 | Portrait button border color | +| #6fbb5a | (unnamed) | portrait.cljs:504 | 1 | Green in custom color preset gradient | +| #78d0d4 | (unnamed) | portrait.cljs:504 | 1 | Cyan in custom color preset gradient | +| #c85c5c | (unnamed) | portrait.cljs:504 | 1 | Red in custom color preset gradient | +| #7a94b8 | (unnamed) | portrait.cljs:504 | 1 | Blue in custom color preset gradient | +| #33658A | (unnamed) | core.clj:1832,1856,1459 | 3+ | Light theme form button background, library badge (light mode) | + +### Light Theme (`.app.light-theme`) + +| Value | Name in Code | Where | Count | Usage | +|-------|--------------|-------|-------|-------| +| #9a031e | `red` | core.clj:1784 | 1 | Error text color in light theme (same as dark `red` def) | +| #363636 | (unnamed) | core.clj:1814,1815,1817,1808 | 4+ | Main text, link color in light theme | +| #282828 | (unnamed) | core.clj:1802,1822,1863,1871 | 4+ | Input/form border, option text in light theme | +| white | keyword | core.clj:1801,1870,1874 | 3+ | Option/sticky header background in light | +| #8a5a00 | (unnamed) | core.clj:1791,1858 | 2+ | Warning notice color, library badge text (light mode) | +| #55637a | (unnamed) | core.clj:1792 | 1 | Note notice color in light theme | +| #b47800 | (unnamed) | core.clj:1859 | 1 | Library badge dot color (compat badge, light) | +| #2b567a | (unnamed) | core.clj:1855 | 1 | Library badge text (benign, light) | +| white (rgba) | (unnamed) | core.clj:1779 | 1 | Light theme background gradient | +| #DDDDDD | (unnamed) | core.clj:1779 | 1 | Light theme background gradient end | + +### Shared / Borders / Backgrounds + +| Value | Name in Code | Where | Count | Usage | +|-------|--------------|-------|-------|-------| +| rgba(255,255,255,0.05) | (unnamed) | core.clj:370; portrait.cljs:263 | 2+ | Subtle white overlay, gradient masks | +| rgba(255,255,255,0.2) | (unnamed) | core.clj:460 | 1 | Transparent border color (gray) | +| rgba(72,72,72,0.2) | (unnamed) | core.clj:672 | 1 | Light background opacity | +| rgba(0,0,0,0.15) | (unnamed) | core.clj:674 | 1 | Lighter background opacity | +| rgba(255,255,255,0.06) | (unnamed) | portrait.cljs:325,346,359,426,437,493 | 6+ | Subtle borders, backgrounds in portrait drawer | +| rgba(240,161,0,0.16) | (unnamed) | portrait.cljs:311,417,441,441,467 | 5+ | Accent tint backgrounds, hover states | +| rgba(0,0,0,0.55) | (unnamed) | portrait.cljs:303 | 1 | Backdrop opacity for portrait drawer | + +### Portrait Drawer Specific (`.lk-*` classes) + +| Value | Name in Code | Where | Count | Usage | +|-------|--------------|-------|-------|-------| +| (See drawer-styles) | `.pl-*` classes | portrait.cljs:278+ | 50+ | All portrait drawer styling; uses #f0a100, #0e131a, #ebeef4, #131924, etc. | + +--- + +## 2. Typefaces + +| Font Family | Where Loaded | Where Used | Notes | +|-------------|-------------|-----------|-------| +| **Open Sans, sans-serif** | Google Fonts URL in index.clj:265 | core.clj:64 (def font-family); used in .sans class; tooltip, form elements, portrait drawer | Primary typeface for entire app. Loaded from `https://fonts.googleapis.com/css?family=Open+Sans` | +| **Vollkorn** (italic) | drawer-styles in portrait.cljs:293-299 | Portrait credit text overlay (canvas rendering) | Subset WOFF2 + TTF fallback; used only for portrait export captions; loaded on-demand for PDF export | +| **ui-monospace, Menlo, monospace** | Inline in drawer-styles | portrait.cljs:416,560 (code blocks, seed values) | Used for monospace text in portrait drawer (seed row code display) | + +--- + +## 3. Type Sizes and Weights + +### Font Sizes + +| Size | Utility Class | Count | Usage | +|------|--------------|-------|-------| +| 10px | `.f-s-10` | 1 | Option help circle | +| 11px | `.f-s-11` | 1 | Option group title, portrait labels | +| 12px | `.f-s-12` | 1 | Form button, export edit label, portrait drawer text | +| 14px | `.f-s-14` | 1+ | Form field label, filter dropdown, portrait text | +| 16px | `.f-s-16` | 1 | Display section text | +| 18px | `.f-s-18` | 1+ | Selection stepper title, portrait drawer title | +| 20px | `.f-s-20` | 1 | Page headings | +| 24px | `.f-s-24` | 1 | What's New panel title | +| 28px | `.f-s-28` | 1 | (unused in included excerpts) | +| 32px | `.f-s-32` | 1 | Large headings | +| 36px | `.f-s-36` | 1 | Major section headings | +| 48px | `.f-s-48` | 1 | Hero/splash text | +| 13px | (unnamed) | 1 | Export bug toggle | +| 15px | (unnamed) | 1 | Message title | + +### Font Weights + +| Weight | Utility Class | Where | Count | Notes | +|--------|--------------|-------|-------|-------| +| bold | `.f-w-bold` or `:bold` | core.clj:108,126,1421,1465 | 4+ | Headings, button text, labels | +| 600 | `.f-w-600` | core.clj:128,1317,1442,1465,1502,1531 | 6+ | Bold variant for buttons, headers | +| 700 | (inline value) | core.clj:545,1523,1594,1727 | 4+ | Extra bold for badges, labels | +| normal | `.f-w-n` | core.clj:123 | 1 | Body text default | +| 500 | (inline value) | portrait.cljs:412,519,560 | 3+ | Medium weight in portrait drawer | +| 400 | (inline value) | portrait.cljs:297,500 | 2+ | Normal weight, font-face fallback | + +### Text Transform & Spacing + +| Property | Values | Count | Usage | +|----------|--------|-------|-------| +| text-transform | uppercase | 6+ | `.uppercase`, option group title, builder tabs, buttons | +| letter-spacing | 0.08em, 0.5px, 1px, 1.2px, 0.04em, 0.06em, 0.14em | 8+ | PDF option titles, portrait drawer labels, uppercase text emphasis | +| line-height | 1, 1.3, 1.4, 1.45, 1.5, 1.6, 19px, 20px | 8+ | Text readability, message boxes | + +--- + +## 4. Spacing (Margins & Padding) + +### Margin Values + +| Value | Class | Count | Usage | +|-------|-------|-------|-------| +| 2px | `.m-t-2`, `.m-b-2`, `.m-r-2` | 3 | Fine adjustments | +| 5px | `.m-5`, `.m-r-5`, `.m-t-5` (note: `.m-t-5` is in range) | 3+ | Subtle spacing | +| 10px | `.m-t--10`, `.m-b-10`, `.m-r-10`, `.m-l--10` | 4+ | Standard small gap | +| 18px | `.m-r-18` | 1 | Item spacing | +| 19px | `.m-b-19`, `.m-t-21` (see contradiction) | 1+ | Character builder header (odd value) | +| 20px | `.m-t-20`, `.m-b-20`, `.m-r-20`, `.m-l-30` | 4+ | Standard medium gap | +| 21px | `.m-t-21` | 1 | **Contradiction**: Isolated odd value in sequence | +| 30px | `.m-t-30`, `.m-b-30`, `.m-r-30`, `.m-l-30` | 4+ | Larger content spacing | +| 40px | `.m-t-40`, `.m-b-40` | 2 | Large section spacing | +| 80px | `.m-r-80` | 1 | Header buttons | +| Negative values | `.m-t--5`, `.m-t--10`, `.m-t--20`, `.m-b--1`, `.m-b--2` | 5+ | Overlapping elements, visual adjustments | + +### Padding Values + +| Value | Class | Count | Usage | +|-------|-------|-------|-------| +| 0px | `.p-0` | 1 | Reset padding | +| 1px | `.p-1` | 1 | Minimal padding | +| 2px | `.p-2` | 1 | Subtle padding | +| 3px | (inline) | 2 | Fine adjustments | +| 4px | (inline) | 1+ | Small control padding | +| 5px | `.p-5`, `.p-t-5`, `.p-b-5`, `.p-r-5`, `.p-l-5` | 5+ | Base unit padding | +| 10px | `.p-10`, `.p-t-10`, `.p-b-10`, `.p-r-10`, `.p-l-10` | 5+ | Standard padding | +| 15px | `.p-l-15` | 1 | Left padding | +| 20px | `.p-20`, `.p-t-20`, `.p-b-20`, `.p-r-20`, `.p-l-20` | 5+ | Larger padding | +| 30px | `.p-30` | 1 | Large padding | +| 40px | `.p-b-40` | 1 | Bottom padding | +| 5px 10px | `.p-5-10` | 1 | Vertical/horizontal combo | + +--- + +## 5. Radii, Borders, Shadows + +### Border Radius + +| Value | Class | Count | Usage | +|-------|-------|-------|-------| +| 50% | `.b-rad-50-p` | 1+ | Circular elements (buttons, badges) | +| 5px | `.b-rad-5` | 4+ | Buttons, panels, standard rounding | +| 10px | `.b-rad-10` | 1+ | Larger panels, portrait frame | +| 3px | (inline) | 2+ | Small controls, code blocks | +| 4px | (inline) | 3+ | Input fields, controls | +| 6px | (inline) | 2+ | Panels, modals | +| 8px | (inline) | 3+ | Portrait drawer controls, panels | +| 999px | (inline) | 4+ | Pills, badges, fully rounded | + +### Borders + +| Value | Class | Count | Usage | +|-------|-------|-------|-------| +| 1px solid | `.b-1` | 1 | Standard border | +| 3px solid | `.b-3` | 1 | Thick border | +| 2px solid | `.b-b-2` | 1 | Bottom border | +| 1px dashed | (inline) | 2+ | Dashed borders in conflict modal | +| Color-specific | `.b-orange`, `.b-red`, `.b-gray` | 3+ | Borders using accent colors | +| rgba(255,255,255,0.2) | (inline) | 3+ | Subtle light borders | + +### Box Shadows + +| Value | Class/Context | Count | Usage | +|-------|--------------|-------|-------| +| 0 2px 6px rgba(0,0,0,0.5) | `.hover-shadow:hover`, `.form-button:hover` | 4+ | Hover/elevation effect | +| 0 1px 0 0 #f0a100 | `.orange-shadow`, `.checkbox` | 2+ | Accent underline shadow | +| 1px 2px 1px black | `.text-shadow` | 1 | Text shadow effect | +| 1px 2px 1px white | `.white-text-shadow` | 1 | White text shadow | +| 1px 1px 1px rgba(0,0,0,0.8) | `.slight-text-shadow` | 1 | Subtle text shadow | +| 0 4px 12px rgba(0,0,0,0.4) | Dropdown filter menu | 1 | Dropdown shadow | +| 0 8px 24px rgba(0,0,0,0.55) | Inventory picker popover | 1 | Larger shadow | +| 0 14px 34px rgba(0,0,0,0.55) | Conflict modal, combo dropdown | 2+ | Deep shadow for modals | +| 0 0 0 2px rgba(241,162,15,0.35) | `.mc-primary` | 1+ | Accent ring for primary actions | +| inset + external | Portrait controls | 3+ | Combination shadows for depth | + +--- + +## 6. Components + +### Buttons (`.form-button`, `.roll-button`, `.link-button`, `.mc-btn`) + +**Dark Theme (Default)** +``` +.form-button: + color: white + font-weight: 600 + font-size: 12px + border: none + border-radius: 5px + padding: 10px 15px + background: linear-gradient(to bottom, #f1a20f, #dbab50) + cursor: pointer +``` +| Location | Count | Variant | +|----------|-------|---------| +| core.clj:1419-1428 | 1 | Primary (main call-to-action) | +| core.clj:1477 | 1 | :hover shadow | +| core.clj:1480 | 1 | .disabled (opacity 0.5) | + +**Light Theme Override** +``` +.app.light-theme .form-button: + background: linear-gradient(to bottom, #33658A, #33658A) +``` +| core.clj:1831-1832 | 1 | Solid blue (no gradient) | + +### Links (`.orange`, `.a-white`, `.link-button`) + +| Class | Dark Theme | Light Theme | File:Line | +|-------|-----------|------------|-----------| +| `.orange` a:visited | color: #f0a100 | color: rgba(0,0,0,0.8) | core.clj:345-348, 1834-1835 | +| `.a-white` | color: white !important | unchanged | core.clj:349-351 | +| `.link-button` | color: #f0a100; text-decoration: underline | color: #363636 | core.clj:1647-1656, 1807-1808 | + +### Inputs (`.input`, select, option) + +**Dark Theme** +``` +.input: + background-color: transparent + color: white + border: 1px solid white + border-radius: 5px + padding: 10px + font-size: 14px + +select: + background-color: transparent + color: white + color-scheme: dark + +option: + background-color: #1a1e28 + color: rgba(255,255,255,0.9) +``` +| Location | File:Line | +|----------|-----------| +| Input styles | core.clj:1671-1681 | +| Select/option | core.clj:1192-1200 | + +**Light Theme Override** +``` +.app.light-theme .input: + color: black + border: 1px solid #282828 + +.app.light-theme select: + color: black + color-scheme: light + +.app.light-theme option: + background-color: white + color: #282828 +``` +| Location | File:Line | +|----------|-----------| +| Input/select/option | core.clj:1819-1802 | + +### Cards/Panels (`.builder-option`, `.selection-stepper-main`, `.conflict-modal`) + +| Component | Dark Theme | File:Line | Notes | +|-----------|-----------|-----------|-------| +| `.builder-option` | border: 1px solid rgba(255,255,255,0.5); border-radius: 5px; padding: 10px | core.clj:1298-1305 | Light border on transparent | +| `.selection-stepper-main` | background: #1a1e28; border: 1px solid white; border-radius: 5px; box-shadow: 0 2px 6px rgba(0,0,0,0.5) | core.clj:1748-1754 | Dark panel with shadow | +| `.conflict-modal` | background: #1a1e28; border-radius: 5px; max-width: 860px; box-shadow: 0 2px 6px rgba(0,0,0,0.5) | core.clj:1938-1945 | Conflict resolution modal | +| `.whats-new-panel` | background: #1a1e28; color: rgba(255,255,255,0.85); border-radius: 6px; box-shadow: 0 4px 18px rgba(0,0,0,0.6) | core.clj:2278-2287 | What's New overlay panel | + +### Header (`.app-header`, `.sticky-header`) + +| Element | Style | File:Line | +|---------|-------|-----------| +| `.app-header` | background-color: black; background-image: url(/../../image/header-background.jpg); background-size: cover | core.clj:1224-1229 | +| `.header-tab` | background: rgba(0,0,0,0.5); backdrop-filter: blur(5px); border-radius: 5px | core.clj:1231-1235 | +| `.sticky-header` | position: sticky; top: 0; z-index: 100 | core.clj:1208-1211 | +| `.sticky-header.stuck` | background-color: #313A4D; box-shadow: 0 2px 6px rgba(0,0,0,0.5) | core.clj:1213-1215 | + +**Light Theme Override** +``` +.app.light-theme .sticky-header.stuck: + background-color: white +``` +| core.clj:1873-1874 | 1 | + +### Tabs (`.builder-tab`, `.builder-tabs`, `.selected-builder-tab`) + +| Element | Style | File:Line | +|---------|-------|-----------| +| `.builder-tabs` | display: flex; gap: 10px; padding: 10px; text-transform: uppercase; font-weight: 600 | core.clj:1311-1317 | +| `.builder-tab` | border-bottom: 5px solid rgba(72,72,72,0.37); padding-bottom: 13px; text-align: center; cursor: pointer | core.clj:1319-1327 | +| `.selected-builder-tab` | border-bottom-color: #f1a20f | core.clj:1329-1332 | + +### Theme Toggle (`.dev-mode-switch`) + +| State | Style | File:Line | +|-------|-------|-----------| +| Default | background-color: rgba(255,255,255,0.16); width: 34px; height: 18px; border-radius: 9px; cursor: pointer | core.clj:2194-2203 | +| .on | background-color: #f0a100; `&:after` transforms to right | core.clj:2214-2216 | +| :focus-visible | outline: 2px solid #f0a100; outline-offset: 3px | core.clj:2217-2219 | + +### Portrait Drawer (`.pl-*` classes) + +See full drawer-styles CSS in portrait.cljs:278-577. Key styles: + +| Class | Style | File:Line | +|-------|-------|-----------| +| `.pl-drawer` | position: fixed; width: 600px; background: #131924; border-left: 1px solid rgba(240,161,0,0.16); animation: pl-slide 220ms | portrait.cljs:307-318 | +| `.pl-btn-primary` | background: linear-gradient(to bottom, #f1a20f, #dbab50); color: #15202e; font-weight: 700; box-shadow: 0 6px 14px -8px rgba(240,161,0,0.32) | portrait.cljs:399-404 | +| `.pl-btn-ghost` | border: 1px solid rgba(255,255,255,0.10); color: #8b95a5 | portrait.cljs:407-409 | +| `.pl-slot` | background: #131924; border: 1px solid rgba(255,255,255,0.06); border-radius: 999px | portrait.cljs:434-440 | +| `.pl-slot.on` | border-color: #f0a100; box-shadow: 0 0 0 1px rgba(240,161,0,0.24) | portrait.cljs:441 | +| `.pl-preset.custom` | background: conic-gradient(#f0a100, #78d0d4, #c85c5c, #6fbb5a, #7a94b8, #f0a100) | portrait.cljs:504 | + +--- + +## 7. Themes + +### How Theme Switching Works + +**Dark Theme (Default)** +- Applied to root: `
` or `
` (dark is default, no class needed) +- Defined throughout core.clj lines 1-2000+ +- All color values use dark tints and high-contrast text + +**Light Theme** +- Applied by adding class: `
` +- Overrides defined in core.clj:1778-1879 (`.app.light-theme { ... }`) +- Primary selector: `.app.light-theme` affects background, text colors, form elements, badges + +### Properties That Change Between Themes + +| Property | Dark Theme | Light Theme | File | +|----------|-----------|-------------|------| +| Background | `linear-gradient(182deg, #313A4D, #080A0D)` | `linear-gradient(182deg, #FFFFFF, #DDDDDD)` | core.clj:1054, 1779 | +| Body text | `.main-text-color: white` | `.main-text-color: #363636` | core.clj:336-338, 1813-1815 | +| Input border | `1px solid white` | `1px solid #282828` | core.clj:1674, 1822 | +| Input text | `color: white` | `color: black` | core.clj:1673, 1821 | +| Select color-scheme | `dark` | `light` | core.clj:1195, 1797 | +| Option background | `#1a1e28` | `white` | core.clj:1199, 1801 | +| Option text | `rgba(255,255,255,0.9)` | `#282828` | core.clj:1200, 1802 | +| Form button gradient | `#f1a20f → #dbab50` | `#33658A → #33658A` (solid blue) | core.clj:1428, 1832 | +| `.red` text | `#ff6b6b` | `#9a031e` | core.clj:359, 1784 | +| `.orange` text | `#f0a100` | `rgba(0,0,0,0.8)` | core.clj:346, 1834 | +| Link button | `#f0a100` | `#363636` | core.clj:1648, 1808 | +| Field notice bg | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.05)` | core.clj:573, 1787 | +| Warning notice color | `#f5b942` | `#8a5a00` | core.clj:594, 1791 | +| Field remedy border | `rgba(255,255,255,0.12)` | `rgba(0,0,0,0.18)` | core.clj:607, 1789 | +| Sticky header | `#313A4D` | `white` | core.clj:1215, 1874 | +| Item list borders | `rgba(255,255,255,0.5)` | `rgba(0,0,0,0.5)` | core.clj:1246, 1805 | +| Builder dropdown | `transparent bg` | `white bg, #282828 text` | core.clj:1358, 1861-1864 | +| Library badges (benign) | `rgba(110,168,220,0.18)` text `#9ec7ea` | `rgba(51,101,138,0.14)` text `#2b567a` | core.clj:1450, 1854 | +| Library badges (compat) | `rgba(217,165,32,0.20)` text `#e5c169` | `rgba(180,120,0,0.16)` text `#8a5a00` | core.clj:1453, 1857 | +| Striped table rows | `rgba(255,255,255,0.1)` | `rgba(0,0,0,0.1)` | core.clj:1296, 1878 | + +--- + +## 8. Contradictions + +### Explicit Contradictions + +1. **Orange Button Gradient Variants** (core.clj:1428 vs 1330) + - `#f1a20f` (lighter) used in button gradient (core.clj:1428, 1475) and selected builder tab (core.clj:1330) + - `#f0a100` (defined as `button-color`, core.clj:9) is the primary accent + - Visually near-identical; unclear if intentional or artifact of gradient mathematics + +2. **Two Red Definitions with No Documented Fallback** (core.clj:10,14) + - `red: #9a031e` (dark/saturated) — documented as "one red cannot serve both themes" (line 11-13) + - `red-on-dark: #ff6b6b` (bright) — used in dark theme by default (line 359) + - Light theme explicitly overrides to use `#9a031e` (line 1784) + - This forces the `.red` class to be re-scoped in light-theme block, making dark-theme-only code depend on late override + +3. **Margin-Top 21px Isolation** (core.clj:51, 169-170) + - Defined in range generator: `(concat (range 0 10) [21] (range 10 30 5))` + - Only odd value in margin sequence (all others are 0,1–9, 10,15,20,25 etc.) + - Used once in `.character-builder-header` (line 1150) and `.m-t-21` class (line 169-170) + - No explanation for why 21 instead of 20 or 25 + +### Minor Inconsistencies + +4. **Button Styling Spread Across Variants** (core.clj:1419, 1462, 1647, portrait.cljs:389) + - `.form-button`: gradient, uppercase, 12px, 600 weight + - `.roll-button`: identical gradient and layout but separate definition (1462) + - `.link-button`: similar padding/radius but different color handling (1647) + - `.pl-btn` (portrait): 600 12px uppercase, matches `.form-button` but defined separately (portrait.cljs:389) + - No shared mixin or variable; each duplicates the same values + +5. **Text Color Opacity Inconsistency** + - Input text: `#484848` (dark gray, views.cljs:68) + - Main dark-theme text: `white` (core.clj:32) + - Muted text: `#9fb0c3`, `#8b95a5` (various opacities) + - No consistent opacity scale defined; values chosen per-context + +6. **Border Radius Variety** (15+ distinct values) + - Buttons: 5px (core.clj:1424, portrait.cljs:391) + - Portrait frame: 10px (portrait.cljs:375) + - Pills/badges: 999px (portrait.cljs:438) + - Small controls: 3px, 4px, 8px (inline values) + - No established scale; each component picks a value + +7. **Box Shadow Inconsistency** + - Standard hover shadow: `0 2px 6px 0 rgba(0,0,0,0.5)` (core.clj:232, 1478) + - Dropdown: `0 4px 12px rgba(0,0,0,0.4)` (core.clj:1721) — different alpha + - Modal: `0 2px 6px 0 rgba(0,0,0,0.5)` vs deeper `0 14px 34px rgba(0,0,0,0.55)` (core.clj:1945, 2287) + - Portrait drawer: `0 6px 14px -8px rgba(240,161,0,0.32)` — accent-colored shadow (portrait.cljs:403) + +--- + +## Notes on Sources + +- **core.clj**: 2676 lines of Garden CSS; main source for utility classes, theme definitions, component styles +- **index.clj**: Server-rendered HTML head; inline base styles, font loads, meta tags +- **views_2.cljc**: Splash page; limited inline styles for buttons (uses `#f0a100`) +- **portrait.cljs**: Portrait drawer component; ~350 lines of plain CSS strings (drawer-styles + empty-slot-styles); self-contained design language +- **views.cljs**: Not fully read (2,000+ lines); sampled for color constants at top; found 21 hex color references, most duplicating core.clj values + +All color names and hex values are exactly as written in source; no inference or normalization applied. Counts reflect grep matches across specified source files only. From 9ade9b26c06342ce75eb479b0063aeda16b7fca1 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 13:28:40 +0000 Subject: [PATCH 14/91] Show Open Sans before and after loading 600/700, and correct the finding Captured from the running site: in Chromium the requested bold does not render at all (regular weight, not faux bold), and loading 600/700 fixes it. Also found that + +
+
+ + +
+
+ +
+
+
Character builder header
+
+
+

What changes

+
    +
  • Header tabs, page title, class name: the stylesheet asks for bold, but in Chrome and Edge they render at regular weight, because only regular is loaded. After the fix they’re bold as intended. Firefox and Safari may fake the bold instead, which looks smeared.
  • +
  • The amber buttons don’t change. They aren’t in Open Sans at all. Buttons get the browser’s own default font (Arial), and nothing in the stylesheet tells them to use the site font. That’s a second one-line fix: button { font-family: inherit }.
  • +
  • The fix itself: in index.clj, load Open+Sans:wght@400;600;700 instead of plain Open+Sans. For a modern browser that’s one more font file, about 48 KB for Latin text, shared by both weights.
  • +
+
+
+ diff --git a/docs/design/style-guide/capture-bold.js b/docs/design/style-guide/capture-bold.js new file mode 100644 index 000000000..c97f32699 --- /dev/null +++ b/docs/design/style-guide/capture-bold.js @@ -0,0 +1,71 @@ +// Faux bold vs real bold, captured from the running site. +// +// The site loads Open Sans at weight 400 only (index.clj), so 600/700 text is +// synthesised by the browser. "after" adds the real 600 and 700 faces from +// Google Fonts at capture time -- the one-line fix -- and nothing else. +// +// lein fig:build && lein e2e-server +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt NODE_PATH=$(npm root -g) \ +// node docs/design/style-guide/capture-bold.js +// +// In the Claude Code sandbox the browser cannot verify the proxy's certificate +// for Google Fonts, so the page silently falls back to another sans. Font +// requests are therefore fetched on the Node side, which trusts the bundle in +// NODE_EXTRA_CA_CERTS, and handed to the page. Verification stays on. + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const OUT = process.argv[2] || 'bold-shots'; +const findChrome = () => { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +}; +const FIX = 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@600;700&display=block'; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark', 'light']) { + for (const state of ['before', 'after']) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async route => { + await route.fulfill({ response: await route.fetch() }); + }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + const c = page.locator('#cookie-btn'); + if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + if (state === 'after') { + await page.evaluate(href => new Promise((ok, no) => { + const l = document.createElement('link'); + l.rel = 'stylesheet'; l.href = href; l.onload = ok; l.onerror = no; + document.head.appendChild(l); + }), FIX); + await page.evaluate(() => Promise.all(['600', '700'].map(w => document.fonts.load(`${w} 12px "Open Sans"`)))); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(400); + } + await page.evaluate(() => document.fonts.ready); + const weights = await page.evaluate(() => + ([...document.fonts].filter(f => /Open Sans/.test(f.family) && f.status === 'loaded').map(f => f.weight))); + console.log(` ${theme} ${state}: loaded Open Sans weights ${JSON.stringify([...new Set(weights)])}`); + // the builder's tab row and the buttons above it: 600-weight uppercase UI + const tabs = page.locator('.builder-tabs').first(); + await tabs.scrollIntoViewIfNeeded(); + const box = await tabs.boundingBox(); + await page.screenshot({ path: path.join(OUT, `${theme}-${state}-builder.png`), + clip: { x: 0, y: Math.max(0, box.y - 220), width: 1280, height: box.height + 260 } }); + // one button up close + const btn = page.locator('.form-button').first(); + if (await btn.count()) await btn.screenshot({ path: path.join(OUT, `${theme}-${state}-button.png`) }); + await ctx.close(); + } + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); From 6f68cb8824004d1a6487bbdc01fc74dd0d6ab807 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 13:31:05 +0000 Subject: [PATCH 15/91] Capture header tab and page title weights in real Open Sans Tabs at 400/500/600/700 and the page title at 400/600/700, both themes, from the running site with the weights loaded at capture time. Both are marked bold in code but have always rendered at 400 in Chrome, so 400 is what people know. No source changes. --- docs/design/style-guide/capture-weights.js | 61 ++++++++++++ docs/design/style-guide/weights.html | 104 +++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 docs/design/style-guide/capture-weights.js create mode 100644 docs/design/style-guide/weights.html diff --git a/docs/design/style-guide/capture-weights.js b/docs/design/style-guide/capture-weights.js new file mode 100644 index 000000000..3153dc7b9 --- /dev/null +++ b/docs/design/style-guide/capture-weights.js @@ -0,0 +1,61 @@ +// Header tab and page-title weights, captured from the running site with real +// Open Sans 400-700 loaded. Each variant only overrides font-weight on the +// element in question; nothing else changes. No source changes. +// +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt NODE_PATH=$(npm root -g) \ +// node docs/design/style-guide/capture-weights.js +// (see capture-bold.js for why fonts are fetched on the Node side) + +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +const OUT = process.argv[2] || 'weight-shots'; +const findChrome = () => { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; + const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); + return path.join(base, dir, 'chrome-linux', 'chrome'); +}; +const FONTS = 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=block'; +const VARIANTS = { + tabs: { sel: '.header-tab', weights: [400, 500, 600, 700], clip: 'header' }, + title: { sel: 'h1.f-s-36', weights: [400, 600, 700], clip: 'title' } +}; + +(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ executablePath: findChrome() }); + for (const theme of ['dark', 'light']) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => { try { const resp = await r.fetch(); if (resp.status() !== 200) console.log("font", resp.status(), r.request().url()); await r.fulfill({ response: resp }); } catch (e) { console.log("fontfetch", String(e).slice(0,160)); await r.abort(); } }); + const page = await ctx.newPage(); + await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + const c = page.locator('#cookie-btn'); + if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await page.evaluate(href => new Promise((ok, no) => { + const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.onload = ok; l.onerror = no; + document.head.appendChild(l); + }), FONTS); + await page.evaluate(() => Promise.all(['400', '500', '600', '700'].map(w => document.fonts.load(`${w} 14px "Open Sans"`)))); + await page.mouse.move(5, 890); + for (const [name, { sel, weights, clip }] of Object.entries(VARIANTS)) { + for (const w of weights) { + await page.evaluate(([s, css]) => { document.querySelectorAll('style[data-w]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.w = '1'; e.textContent = css; document.head.appendChild(e); }, + [null, `${sel}, ${sel} * { font-weight: ${w} !important; }`]); + await page.waitForTimeout(150); + const box = clip === 'header' + ? await page.locator('.header-tab').first().evaluate(el => { const r = el.parentElement.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }) + : await page.locator('h1.f-s-36').first().boundingBox(); + await page.screenshot({ path: path.join(OUT, `${theme}-${name}-${w}.png`), + clip: { x: Math.max(0, box.x - 10), y: Math.max(0, box.y - 6), width: Math.min(1280, box.width + 20), height: box.height + 12 } }); + } + } + await ctx.close(); + console.log(' ' + theme + ' done'); + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/weights.html b/docs/design/style-guide/weights.html new file mode 100644 index 000000000..2dcdc7836 --- /dev/null +++ b/docs/design/style-guide/weights.html @@ -0,0 +1,104 @@ +Header Weights + + + + +
+
+
+
OrcPub · style guide
+

Header weights

+

The header tabs and the page title from the running site, with real Open Sans at several weights. Both are marked bold in the code, but Chrome has always shown them at 400 because bold was never loaded. With the weights loaded, each can be set to whatever looks right.

+
+
+ + +
+
+
+

Header tabs

+
+
400 · what people see today
tabs at weight 400
+
500
tabs at weight 500
+
600
tabs at weight 600
+
700 · what the stylesheet asks for
tabs at weight 700
+
+
+
+

Page title

+
+
400 · what people see today
title at weight 400
+
600
title at weight 600
+
700 · what the stylesheet asks for
title at weight 700
+
+
+
+

How this would be set

+
    +
  • Both use the generic bold class .f-w-b. The fix gives the tab titles and the page title their own weight in the Garden stylesheet, so loading the real bold doesn’t change how they look unless you choose to.
  • +
  • 500 needs that weight loaded too. Open Sans comes as one variable file, so 400, 500, 600 and 700 cost the same as 600 and 700 alone.
  • +
+
+
+ From bdfc7b762d9ee27b896d2d38a9f68f21d28a1f87 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 13:45:05 +0000 Subject: [PATCH 16/91] Record header weights: tabs 500, page title 600 Chosen by the owner from the pair captured together in context, next to today's 400/400 and a 700/600 variant, both themes. The tabs held up at 700 alone, but beside the title the pair balanced only at 500/600, so the guide notes that weights here are judged as a pair. --- docs/design/style-guide.md | 6 ++++++ docs/design/style-guide/capture-weights.js | 11 +++++++++++ docs/design/style-guide/weights.html | 9 +++++++++ 3 files changed, 26 insertions(+) diff --git a/docs/design/style-guide.md b/docs/design/style-guide.md index d6cd9554e..91be61575 100644 --- a/docs/design/style-guide.md +++ b/docs/design/style-guide.md @@ -107,6 +107,12 @@ browser doesn't even fake the bold. Firefox and Safari may fake it by smearing t Loading `Open+Sans:wght@400;600;700` fixes both. For a modern browser that's one more file, about 48 KB for Latin text. Before/after, captured from the site: `style-guide/bold.html`. +**Decided (2026-09-30): header tabs at 500, page title at 600.** Both currently share the generic +`.f-w-b` bold class, so each needs its own weight in Garden: `.header-tab` 500, the page title +(`h1.f-s-36` in `views/header`) 600. Chosen by the owner from captures of the pair together +(`style-guide/weights.html`). The tabs held up at 700 on their own, but beside the title the +pair only balanced at 500/600, so weights on this page get judged in context. + **Buttons aren't in Open Sans.** `.form-button` and the other ` +
+

Together, in context

+

Weight reads relative to what’s beside it, so the pair has to be judged as a pair. Chosen: tabs 500, title 600.

+
+
Tabs 500 · title 600 · chosen
Tabs 500, title 600
+
Tabs 400 · title 400 · what people see today
Tabs and title at 400
+
Tabs 700 · title 600
Tabs 700, title 600
+
+

Header tabs

From f3dc53db7fc941cee346fcf4af8355fa37f73019 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 13:45:29 +0000 Subject: [PATCH 17/91] Correct the header weights: title 600, tabs not decided The owner did not pick 500 for the tabs; they said the tabs look fine at 700 while the title only looks right at 600. Record that, and the reason the two behave differently: size and case. --- docs/design/style-guide.md | 10 +++++----- docs/design/style-guide/weights.html | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/design/style-guide.md b/docs/design/style-guide.md index 91be61575..7baff157e 100644 --- a/docs/design/style-guide.md +++ b/docs/design/style-guide.md @@ -107,11 +107,11 @@ browser doesn't even fake the bold. Firefox and Safari may fake it by smearing t Loading `Open+Sans:wght@400;600;700` fixes both. For a modern browser that's one more file, about 48 KB for Latin text. Before/after, captured from the site: `style-guide/bold.html`. -**Decided (2026-09-30): header tabs at 500, page title at 600.** Both currently share the generic -`.f-w-b` bold class, so each needs its own weight in Garden: `.header-tab` 500, the page title -(`h1.f-s-36` in `views/header`) 600. Chosen by the owner from captures of the pair together -(`style-guide/weights.html`). The tabs held up at 700 on their own, but beside the title the -pair only balanced at 500/600, so weights on this page get judged in context. +**Header weights (owner, 2026-09-30).** The page title looks right at **600**: at 700 its large +mixed-case letters turn plump. The header tabs look fine at **700**; they are small, all caps and +sit on a busy banner, which carries a heavier weight. Tab weight not yet confirmed. Both share the +generic `.f-w-b` bold class today, so each needs its own weight in Garden: `.header-tab` and the +page title (`h1.f-s-36` in `views/header`). Captures: `style-guide/weights.html`. **Buttons aren't in Open Sans.** `.form-button` and the other ` + + + +
+
+ + +
+
+ + +
+ + +
+
+
Page capture
+
+
+

Two things to decide before this merges

+
    +
  • Bold everywhere. Spell and monster names, section headings and the builder’s intro text all get heavier, because the code has always asked for bold there and it never showed. If that’s too much, the generic bold (.f-w-b, :bold) could be set to 600 across the site, with only the header tabs at 700.
  • +
  • “My Content” wraps. At 700 the header tab no longer fits one line at 1280px wide. Options: accept the wrap (it already wraps on narrower screens), tighten the tab’s letter spacing, or take the tabs to 600.
  • +
+
+ + From 40db743faba5fbbd12e91c1e809626d7de6beb8c Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 14:15:15 +0000 Subject: [PATCH 20/91] Capture a proposed weight rule across four pages, and the survey behind it The owner found three elements too plump after bold loaded: the builder's panel heading, its intro sentence, and My Content's import heading. A survey of every bold element on four pages shows the pattern: big mixed-case headings at 700 and a sentence at 700, while small capitals at 700 read well. Proposed rule, captured as a third state: 700 small capitals, 600 headings and names, 400 sentences. --- docs/design/style-guide/sitewide.html | 12 ++++++- docs/design/style-guide/survey-weights.js | 41 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 docs/design/style-guide/survey-weights.js diff --git a/docs/design/style-guide/sitewide.html b/docs/design/style-guide/sitewide.html index 9ef8a179e..94ee25ce0 100644 --- a/docs/design/style-guide/sitewide.html +++ b/docs/design/style-guide/sitewide.html @@ -66,6 +66,7 @@

Bold across the site

+
@@ -77,6 +78,15 @@

Bold across the site

Page capture
+
+

The proposed rule (third state)

+
    +
  • 700 for small capitals labels, 14px and under: header tabs, sheet tabs.
  • +
  • 600 for headings and names 18px and up: page and panel headings (“Race”, “Import Option Source”), spell and monster names. Monster names are already 600; spell names were 700.
  • +
  • 400 for sentences, such as the builder’s intro text.
  • +
  • Applied here by a quick script at capture time, which also turned the two “CLICK HERE” links in the intro box regular. The real change would keep those as they are if wanted.
  • +
+

Two things to decide before this merges

    @@ -92,7 +102,7 @@

    Two things to decide before this merges

    function render() { const t = theme(); document.getElementById('shot').src = `sitewide/${t}-${s}-${pg}.png`; - document.getElementById('lbl').textContent = s === 'before' ? 'Before: integration today' : 'After: feature/style-guide'; + document.getElementById('lbl').textContent = {before: 'Before: integration today', after: 'After: feature/style-guide', rule: 'After + proposed rule: 700 small capitals, 600 headings and names, 400 sentences (applied at capture time)'}[s]; document.querySelectorAll('[data-p]').forEach(b => b.setAttribute('aria-pressed', b.dataset.p === pg)); document.querySelectorAll('[data-s]').forEach(b => b.setAttribute('aria-pressed', b.dataset.s === s)); document.querySelectorAll('[data-t]').forEach(b => b.setAttribute('aria-pressed', b.dataset.t === t)); diff --git a/docs/design/style-guide/survey-weights.js b/docs/design/style-guide/survey-weights.js new file mode 100644 index 000000000..af3d1f365 --- /dev/null +++ b/docs/design/style-guide/survey-weights.js @@ -0,0 +1,41 @@ +// For each page, every visible text element rendered at weight >= 600: its text, size, +// weight, and where the weight comes from (class, inline style, or a stylesheet rule). +const { chromium } = require('playwright'); +const PAGES = [['/pages/dnd/5e/character-builder','builder'], ['/dnd/5e/my-content','mycontent'], + ['/pages/dnd/5e/spells','spells'], ['/pages/dnd/5e/monsters','monsters']]; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + for (const [url, name] of PAGES) { + await p.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }); + await p.evaluate(() => document.fonts.ready); + const rows = await p.evaluate(() => { + const out = new Map(); + for (const el of document.querySelectorAll('body *')) { + const own = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent.trim()).join(' ').trim(); + if (!own || !el.offsetParent) continue; + const s = getComputedStyle(el); const w = +s.fontWeight; if (w < 600) continue; + const r = el.getBoundingClientRect(); if (r.top > 900 || r.width === 0) continue; + // where does the weight come from? + let src = 'inherited'; + for (let e = el; e && e !== document.body; e = e.parentElement) { + if (e.style && e.style.fontWeight) { src = `inline on <${e.tagName.toLowerCase()}>`; break; } + const cls = [...e.classList].find(c => /^f-w-/.test(c)); + if (cls) { src = `.${cls} on <${e.tagName.toLowerCase()}>`; break; } + if (/^H[1-6]$/.test(e.tagName)) { src = `<${e.tagName.toLowerCase()}> default`; break; } + if (e.tagName === 'BUTTON' || e.tagName === 'B' || e.tagName === 'STRONG') { src = `<${e.tagName.toLowerCase()}>`; break; } + } + const key = `${parseFloat(s.fontSize)}px ${w} ${s.textTransform === 'uppercase' ? 'CAPS' : 'mixed'} | ${src}`; + if (!out.has(key)) out.set(key, []); + const list = out.get(key); if (list.length < 3) list.push(own.slice(0, 40)); + } + return [...out.entries()].sort((a, b) => parseFloat(b[0]) - parseFloat(a[0])); + }); + console.log(`== ${name}`); + for (const [k, ex] of rows) console.log(` ${k} e.g. ${JSON.stringify(ex)}`); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); From 8f63ea0810e951260f37a5dee4bfd26e3d813031 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 14:28:59 +0000 Subject: [PATCH 21/91] Capture feature/style-guide with the weight rule built Four pages before, first pass and now; the yellow buttons in Arial Bold, Open Sans 600, 700, and 700 with dark text; and five options for the My Content header tab wrap. Both themes. --- docs/design/style-guide/capture-round3.js | 56 ++++++++++ docs/design/style-guide/round3.html | 125 ++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 docs/design/style-guide/capture-round3.js create mode 100644 docs/design/style-guide/round3.html diff --git a/docs/design/style-guide/capture-round3.js b/docs/design/style-guide/capture-round3.js new file mode 100644 index 000000000..05f80b66a --- /dev/null +++ b/docs/design/style-guide/capture-round3.js @@ -0,0 +1,56 @@ +const { chromium } = require('playwright'); +const PAGES = [['/pages/dnd/5e/character-builder','builder'], ['/dnd/5e/my-content','mycontent'], + ['/pages/dnd/5e/spells','spells'], ['/pages/dnd/5e/monsters','monsters']]; +const BTN = { + arial: 'button { font-family: Arial, sans-serif !important; } .form-button { font-weight: 700 !important; }', + os600: '.form-button { font-weight: 600 !important; }', + os700: '', + dark: '.app:not(.light-theme) .form-button { color: #15202e !important; }' +}; +const WRAP = { + asis: '', + ls02: '.header-tab .title { letter-spacing: -0.02em; }', + ls04: '.header-tab .title { letter-spacing: -0.04em; }', + s13: '.header-tab { font-size: 13px !important; }', + w600: '.header-tab { font-weight: 600 !important; }' +}; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const theme of ['dark','light']) { + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + const prep = async url => { + await p.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); + }; + for (const [url, name] of PAGES) { + await prep(url); + await p.screenshot({ path: `sg3/${theme}-v2-${name}.png`, clip: { x: 0, y: 150, width: 1280, height: 750 } }); + } + // buttons, 2x close-up of the builder's button row + await prep('/pages/dnd/5e/character-builder'); + const set = css => p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); + for (const [k, css] of Object.entries(BTN)) { + await set(css); await p.waitForTimeout(150); + const first = await p.locator('.form-button').first().boundingBox(); + const last = await p.locator('.form-button').nth(4).boundingBox(); + await p.screenshot({ path: `sg3/${theme}-btn-${k}.png`, scale: 'device', + clip: { x: first.x - 6, y: first.y - 6, width: last.x + last.width - first.x + 12, height: first.height + 12 } }); + } + await set(''); + // header wrap options + for (const [k, css] of Object.entries(WRAP)) { + await set(css); await p.waitForTimeout(150); + const r = await p.locator('.header-tab').first().evaluate(el => { const q = el.parentElement.getBoundingClientRect(); return { x: q.x, y: q.y, width: q.width, height: q.height }; }); + await p.screenshot({ path: `sg3/${theme}-wrap-${k}.png`, clip: { x: r.x - 4, y: r.y - 4, width: Math.min(1280 - r.x + 4, r.width + 8), height: r.height + 8 } }); + } + await ctx.close(); console.log(theme, 'done'); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round3.html b/docs/design/style-guide/round3.html new file mode 100644 index 000000000..c753d1d38 --- /dev/null +++ b/docs/design/style-guide/round3.html @@ -0,0 +1,125 @@ +Weight Rule Built + + + + +
    +
    +
    +
    OrcPub · feature/style-guide
    +

    The weight rule, built

    +

    The branch running with the rule in place: 700 for small capitals (tabs, buttons), 600 for bold text 18px and up (one stylesheet rule covering 155 call sites), 400 for the builder’s info sentences, with the CLICK HERE links kept bold.

    +
    +
    + + +
    +
    + +
    +

    Four pages

    +
    + + + + +
    +
    + + + +
    +
    Page capture
    +
    + +
    +

    The yellow buttons

    +

    Shown at twice actual size so the strokes are visible. You were right: they got lighter. They used to be Arial Bold; the first pass made them Open Sans 600. They’re now Open Sans 700. The underlying problem is contrast: white on this amber is 2.1:1, well under the 4.5:1 readable minimum, and thin strokes expose it. Dark text on amber is 7.8:1, and the portrait drawer’s buttons already use it. It’s shown last for comparison and is not built.

    +
    +
    Before: Arial Bold, white
    Buttons in Arial
    +
    First pass: Open Sans 600, white
    Buttons at 600
    +
    Now: Open Sans 700, white
    Buttons at 700
    +
    Not built: Open Sans 700, dark text (dark theme only; the light theme’s slate buttons already pass with white)
    Buttons with dark text
    +
    +
    + +
    +

    “My Content” wrapping

    +

    The header at 1280px wide with each option.

    +
    +
    As is: 700, wraps
    Header as is
    +
    Letter spacing −2%: 700, fits on one line
    Letter spacing -2%
    +
    Letter spacing −4%
    Letter spacing -4%
    +
    13px instead of 14px
    13px
    +
    600 weight
    Weight 600
    +
    +
    +
    + From 68a3e0771598b2f0a5e0ff1cb3ae5f09415595d2 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 14:42:13 +0000 Subject: [PATCH 22/91] Try dark text on the yellow buttons, and measure room for My Content Dark text (#15202e) on every amber button across four pages, dark theme, applied at capture time and not built. And the room each header option leaves for MY CONTENT: tabs are a fixed 110px, so screen width does not matter; letter spacing -2% fits with 0.3px spare, -4% with 3.1px. The 13px option never applied (.f-s-14 is !important). --- docs/design/style-guide/capture-round4.js | 63 +++++++++++ docs/design/style-guide/measure-tab-room.js | 36 +++++++ docs/design/style-guide/round4.html | 113 ++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 docs/design/style-guide/capture-round4.js create mode 100644 docs/design/style-guide/measure-tab-room.js create mode 100644 docs/design/style-guide/round4.html diff --git a/docs/design/style-guide/capture-round4.js b/docs/design/style-guide/capture-round4.js new file mode 100644 index 000000000..c961d4a75 --- /dev/null +++ b/docs/design/style-guide/capture-round4.js @@ -0,0 +1,63 @@ +const { chromium } = require('playwright'); +const WRAP = { + 'as is (700)': '', + 'letter spacing -2%': '.header-tab .title { letter-spacing: -0.02em; }', + 'letter spacing -4%': '.header-tab .title { letter-spacing: -0.04em; }', + '13px': '.header-tab { font-size: 13px !important; }', + '600 weight': '.header-tab { font-weight: 600 !important; }', + 'before (400, integration)': '.header-tab, .header-tab * { font-weight: 400 !important; }' +}; +const PAGES = [['/pages/dnd/5e/character-builder','builder'], ['/dnd/5e/my-content','mycontent'], + ['/pages/dnd/5e/spells','spells'], ['/pages/dnd/5e/monsters','monsters']]; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + await p.evaluate(() => document.fonts.ready); + const oneLine = () => p.evaluate(() => { + const t = [...document.querySelectorAll('.header-tab .title')].find(e => /my content/i.test(e.textContent)); + if (!t || !t.offsetParent) return null; + const lh = parseFloat(getComputedStyle(t).lineHeight) || parseFloat(getComputedStyle(t).fontSize) * 1.4; + return t.getBoundingClientRect().height < lh * 1.5; + }); + // A: narrowest width that keeps MY CONTENT on one line, per option + for (const [name, css] of Object.entries(WRAP)) { + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); + let fits = null; + for (let w = 1600; w >= 700; w -= 10) { + await p.setViewportSize({ width: w, height: 900 }); await p.waitForTimeout(40); + const r = await oneLine(); + if (r === null) { fits = fits ?? `labels hidden below ${w}`; break; } + if (!r) break; + fits = w; + } + console.log(`${name.padEnd(28)} one line down to ${fits}px`); + } + await p.setViewportSize({ width: 1280, height: 900 }); + // B: dark text on every amber button, dark theme, four pages + const DARK = `(() => { for (const el of document.querySelectorAll('*')) { + const bg = getComputedStyle(el).backgroundImage; + if (/rgb\\(241, 162, 15\\)/.test(bg)) { el.style.setProperty('color', '#15202e', 'important'); + el.querySelectorAll('*').forEach(c => c.style.setProperty('color', '#15202e', 'important')); } } })()`; + const ctx2 = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); + await ctx2.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx2.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const q = await ctx2.newPage(); + for (const [url, name] of PAGES) for (const state of ['white', 'dark']) { + await q.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }); + const c = q.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await q.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await q.evaluate(() => document.fonts.ready); + if (state === 'dark') await q.evaluate(DARK); + await q.waitForTimeout(300); + const n = await q.evaluate(() => [...document.querySelectorAll('*')].filter(el => /rgb\(241, 162, 15\)/.test(getComputedStyle(el).backgroundImage)).length); + if (state === 'dark') console.log(`${name}: ${n} amber elements`); + await q.screenshot({ path: `sg4/dark-${state}-${name}.png`, clip: { x: 0, y: 150, width: 1280, height: 750 } }); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/measure-tab-room.js b/docs/design/style-guide/measure-tab-room.js new file mode 100644 index 000000000..1796d3cc7 --- /dev/null +++ b/docs/design/style-guide/measure-tab-room.js @@ -0,0 +1,36 @@ +const { chromium } = require('playwright'); +const WRAP = { + 'before (400, integration)': '.header-tab, .header-tab * { font-weight: 400 !important; }', + 'as is (700)': '', + 'letter spacing -2%': '.header-tab .title { letter-spacing: -0.02em; }', + 'letter spacing -4%': '.header-tab .title { letter-spacing: -0.04em; }', + '13px': '.header-tab { font-size: 13px !important; }', + '600 weight': '.header-tab, .header-tab * { font-weight: 600 !important; }' +}; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + await p.evaluate(() => document.fonts.ready); + for (const [name, css] of Object.entries(WRAP)) { + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); + await p.waitForTimeout(80); + const r = await p.evaluate(() => { + const t = [...document.querySelectorAll('.header-tab .title')].find(e => /my content/i.test(e.textContent)); + const box = t.parentElement; // the padded area the title sits in + const cs = getComputedStyle(box); + const avail = box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + // natural one-line width of the label + const probe = t.cloneNode(true); probe.style.cssText += ';position:absolute;visibility:hidden;white-space:nowrap;width:auto'; + t.parentElement.appendChild(probe); const need = probe.getBoundingClientRect().width; probe.remove(); + const tab = t.closest('.header-tab').getBoundingClientRect().width; + return { tab: Math.round(tab), avail: Math.round(avail * 10) / 10, need: Math.round(need * 10) / 10 }; + }); + console.log(`${name.padEnd(27)} tab ${r.tab}px, room ${r.avail}px, label needs ${r.need}px -> ${r.avail - r.need >= 0 ? 'fits, ' + (r.avail - r.need).toFixed(1) + 'px spare' : 'wraps, ' + (r.need - r.avail).toFixed(1) + 'px short'}`); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round4.html b/docs/design/style-guide/round4.html new file mode 100644 index 000000000..53542183b --- /dev/null +++ b/docs/design/style-guide/round4.html @@ -0,0 +1,113 @@ +Dark Button Text + + + + +
    +
    +
    +
    OrcPub · feature/style-guide
    +

    Dark text on the yellow buttons

    +

    Every amber button on four pages of the running branch, dark theme, with white text (as built) and dark text (#15202e, applied at capture time, not built). White on this amber is 2.1:1; dark is 7.8:1. The light theme’s buttons are slate blue with white text and already pass, so they’d stay as they are.

    +
    +
    +
    + + + + +
    +
    + + +
    +
    +
    +
    +
    Page capture
    +
    +
    +

    Up close, at twice actual size

    +
    White text
    +
    Dark text
    +
    +
    +

    Room for “MY CONTENT”

    +

    The header tabs are a fixed 110px at every desktop width, so screen width doesn’t change this. The label gets 90px inside the tab.

    +
    + + + + + + + + + +
    OptionLabel needsResult
    Before: 400 (integration today)88.0pxfits, 2.0px spare
    700, as built92.5pxwraps, 2.5px short
    700, letter spacing −2%89.7pxfits, 0.3px spare (fragile)
    700, letter spacing −4%86.9pxfits, 3.1px spare
    60090.3pxwraps, 0.3px short
    700, tab padding 8px instead of 10px (94px room)92.5pxfits, 1.5px spare (calculated, not captured)
    +
    +
    + From a54969aa046ce05e804e8c960e24613481039ab1 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 15:06:46 +0000 Subject: [PATCH 23/91] Capture five treatments for the yellow button text Today's white, white with a faint dark shadow, white on a deeper amber, both, and dark text, at 2x from the running branch. Scored with APCA, which matches the owner's read that dark text is swallowed by the bright amber better than the WCAG 2 ratio did. --- docs/design/style-guide/capture-round5.js | 29 ++++++++++ docs/design/style-guide/round5.html | 67 +++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 docs/design/style-guide/capture-round5.js create mode 100644 docs/design/style-guide/round5.html diff --git a/docs/design/style-guide/capture-round5.js b/docs/design/style-guide/capture-round5.js new file mode 100644 index 000000000..9d5f62b3a --- /dev/null +++ b/docs/design/style-guide/capture-round5.js @@ -0,0 +1,29 @@ +const { chromium } = require('playwright'); +const V = { + '1-today': '', + '2-shadow': '.form-button { text-shadow: 0 1px 1px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.25); }', + '3-deeper': '.form-button { background-image: linear-gradient(to bottom, #c98300, #b8792a) !important; }', + '4-deeper-shadow': '.form-button { background-image: linear-gradient(to bottom, #c98300, #b8792a) !important; text-shadow: 0 1px 1px rgba(0,0,0,0.3); }', + '5-dark': '.form-button { color: #15202e !important; }' +}; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); + for (const [k, css] of Object.entries(V)) { + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); + await p.waitForTimeout(150); + const f = await p.locator('.form-button').first().boundingBox(); + const l = await p.locator('.form-button').nth(4).boundingBox(); + await p.screenshot({ path: `sg6/${k}.png`, clip: { x: f.x - 8, y: f.y - 8, width: l.x + l.width - f.x + 16, height: f.height + 16 } }); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round5.html b/docs/design/style-guide/round5.html new file mode 100644 index 000000000..c46f3eca4 --- /dev/null +++ b/docs/design/style-guide/round5.html @@ -0,0 +1,67 @@ +Button Text Options + + + + +
    +
    +
    +
    OrcPub · feature/style-guide
    +

    Button text options

    +

    The builder’s buttons in the running branch, dark theme, at twice actual size. Scores use APCA, the newer contrast model built to match perception, where dark text on bright colour doesn’t get the inflated score the older WCAG 2 ratio gives it. For small bold capitals it suggests about Lc 60 to 75.

    +
    +
    +
    +
    Today: white on amber · APCA Lc 46
    Today: white on amber
    +
    White with a faint dark shadow · Same colours; the shadow sharpens the letter edges (APCA can’t score shadows)
    White with a faint dark shadow
    +
    White on a deeper amber · Lc about 66 to 73: clearly readable, but a shade more bronze
    White on a deeper amber
    +
    Deeper amber and the shadow · Both
    Deeper amber and the shadow
    +
    Dark text (the toggle as built) · Lc 60, but reads as swallowed on the bright amber
    Dark text (the toggle as built)
    +
    +
    From 993663482e549b432cf9f95363342be1fb944b15 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 15:18:16 +0000 Subject: [PATCH 24/91] Capture dark button text with a light edge, the inverse of the white shadow Dark text alone, with a 1px light edge below, with a soft light glow, and with a stronger edge, next to white with a dark shadow. 2x, dark theme. --- docs/design/style-guide/capture-round6.js | 29 ++++++++++ docs/design/style-guide/round6.html | 67 +++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 docs/design/style-guide/capture-round6.js create mode 100644 docs/design/style-guide/round6.html diff --git a/docs/design/style-guide/capture-round6.js b/docs/design/style-guide/capture-round6.js new file mode 100644 index 000000000..a0604f906 --- /dev/null +++ b/docs/design/style-guide/capture-round6.js @@ -0,0 +1,29 @@ +const { chromium } = require('playwright'); +const V = { + '6-white-shadow': '.form-button { text-shadow: 0 1px 1px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.25); }', + '7-dark': '.form-button { color: #15202e !important; }', + '8-dark-emboss': '.form-button { color: #15202e !important; text-shadow: 0 1px 0 rgba(255,255,255,0.35); }', + '9-dark-glow': '.form-button { color: #15202e !important; text-shadow: 0 0 2px rgba(255,255,255,0.45), 0 1px 0 rgba(255,255,255,0.3); }', + '10-dark-emboss-strong': '.form-button { color: #15202e !important; text-shadow: 0 1px 0 rgba(255,255,255,0.55), 0 0 1px rgba(255,255,255,0.3); }' +}; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); + for (const [k, css] of Object.entries(V)) { + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); + await p.waitForTimeout(150); + const f = await p.locator('.form-button').first().boundingBox(); + const l = await p.locator('.form-button').nth(4).boundingBox(); + await p.screenshot({ path: `sg7/${k}.png`, clip: { x: f.x - 8, y: f.y - 8, width: l.x + l.width - f.x + 16, height: f.height + 16 } }); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round6.html b/docs/design/style-guide/round6.html new file mode 100644 index 000000000..57d4aa585 --- /dev/null +++ b/docs/design/style-guide/round6.html @@ -0,0 +1,67 @@ +Button Text Shadows + + + + +
    +
    +
    +
    OrcPub · feature/style-guide
    +

    Button text shadows

    +

    The inverse of the dark shadow: a faint light edge under dark text, so the strokes separate from the bright amber. The builder’s buttons in the running branch, dark theme, at twice actual size, with the white-with-shadow default at the top for comparison.

    +
    +
    +
    +
    White with a faint dark shadow · the default you liked
    White with a faint dark shadow
    +
    Dark text, no edge · the toggle as built: letters sink into the amber
    Dark text, no edge
    +
    Dark text, light edge below · a 1px highlight under each letter, 35% white (letterpress)
    Dark text, light edge below
    +
    Dark text, soft light glow · a faint light halo all round plus the edge below
    Dark text, soft light glow
    +
    Dark text, stronger light edge · 55% white below; the most pronounced
    Dark text, stronger light edge
    +
    +
    From db3c24027d0b26591e0cb49d9aaad10791e8d7c8 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 15:24:11 +0000 Subject: [PATCH 25/91] Capture the built text lift, and the survey of text on amber The survey finds every element with text on an amber surface across 11 pages in both themes: .roll-button, .form-button, .mc-btn and the active header tab. Captures show the lift as built in each state and the header tab with and without it. --- docs/design/style-guide/capture-round7.js | 36 ++++++++++++ docs/design/style-guide/round7.html | 69 +++++++++++++++++++++++ docs/design/style-guide/survey-amber.js | 48 ++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 docs/design/style-guide/capture-round7.js create mode 100644 docs/design/style-guide/round7.html create mode 100644 docs/design/style-guide/survey-amber.js diff --git a/docs/design/style-guide/capture-round7.js b/docs/design/style-guide/capture-round7.js new file mode 100644 index 000000000..d0a3babb6 --- /dev/null +++ b/docs/design/style-guide/capture-round7.js @@ -0,0 +1,36 @@ +const { chromium } = require('playwright'); +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const theme of ['dark-theme', 'light-theme']) for (const dark of [false, true]) { + if (theme === 'light-theme' && dark) continue; + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(([t, dk]) => { try { localStorage.setItem('user', `{:theme "${t}" :dark-button-text? ${dk}}`); } catch (_) {} }, [theme, dark]); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + const sh = await p.evaluate(() => ['.form-button', '.roll-button'].map(s => { + const e = document.querySelector(s); const cs = getComputedStyle(e); return `${s}: ${cs.color} / ${cs.textShadow.slice(0, 40)}`; })); + const tag = `${theme.replace('-theme','')}${dark ? '-darktext' : ''}`; + console.log(tag, sh.join(' | ')); + const f = await p.locator('.form-button').first().boundingBox(); const l = await p.locator('.form-button').nth(4).boundingBox(); + await p.screenshot({ path: `sg8/${tag}-buttons.png`, clip: { x: f.x - 8, y: f.y - 8, width: l.x + l.width - f.x + 16, height: f.height + 16 } }); + const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); + await p.screenshot({ path: `sg8/${tag}-roll.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); + if (!dark) { + // header tab: as built, and with the dark lift (not built) + for (const [k, css] of [['tab-now', ''], ['tab-lift', `.header-tab { text-shadow: 0 1px 1px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.25); }`]]) { + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); + await p.waitForTimeout(100); + const t = await p.locator('.header-tab').first().evaluate(el => { const q = el.parentElement.getBoundingClientRect(); return { x: q.x, y: q.y, width: q.width, height: q.height }; }); + await p.screenshot({ path: `sg8/${tag}-${k}.png`, clip: { x: t.x - 4, y: t.y - 4, width: Math.min(1280 - t.x + 4, 470), height: t.height + 8 } }); + } + } + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round7.html b/docs/design/style-guide/round7.html new file mode 100644 index 000000000..44124c82c --- /dev/null +++ b/docs/design/style-guide/round7.html @@ -0,0 +1,69 @@ +Text Lift Built + + + + +
    +
    +
    +
    OrcPub · feature/style-guide
    +

    Text lift, built

    +

    The running branch at twice actual size. White text on amber has a faint dark edge; the opt-in dark text has a faint light glow.

    +
    +
    +

    Action buttons

    +
    Dark theme · white with the dark edge
    Dark theme
    Dark theme, Dark Button Text on · dark with the light glow
    Dark theme, Dark Button Text on
    Light theme · slate blue, unchanged
    Light theme
    +
    +

    Ability buttons

    +
    Dark theme · white with the dark edge
    Dark theme
    Dark Button Text on · dark with the light glow
    Dark Button Text on
    Light theme · still amber: the copied button never got the light-theme slate, so it keeps the edge
    Light theme
    +
    +

    Header tabs (proposed, not built)

    +
    As built · no edge
    As built
    With the dark edge on all tabs · the active amber tab, and the grey labels on the banner, both firm up
    With the dark edge on all tabs
    +
    +
    diff --git a/docs/design/style-guide/survey-amber.js b/docs/design/style-guide/survey-amber.js new file mode 100644 index 000000000..1114dd962 --- /dev/null +++ b/docs/design/style-guide/survey-amber.js @@ -0,0 +1,48 @@ +// Every element with text sitting on an amber/orange surface (solid or gradient), +// grouped by class, across the main pages, in both themes. +const { chromium } = require('playwright'); +const PAGES = ['/pages/dnd/5e/character-builder', '/dnd/5e/my-content', '/pages/dnd/5e/spells', + '/pages/dnd/5e/monsters', '/pages/dnd/5e/magic-items', '/pages/dnd/5e/characters', + '/pages/dnd/5e/encounter-builder', '/pages/dnd/5e/combat-tracker', '/pages/login-page', '/pages/register-page', '/']; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const theme of ['dark-theme', 'light-theme']) { + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme); + const p = await ctx.newPage(); + const found = new Map(); + for (const url of PAGES) { + await p.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }).catch(() => {}); + await p.waitForTimeout(800); + const rows = await p.evaluate(() => { + const amber = s => { + const m = s.match(/rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)/g) || []; + return m.some(c => { const [r,g,b,a] = c.match(/[\d.]+/g).map(Number); + return (a === undefined || a > 0.6) && r > 190 && g > 120 && g < 200 && b < 110; }); + }; + const out = []; + for (const el of document.querySelectorAll('body *')) { + if (!el.offsetParent) continue; + const s = getComputedStyle(el); + if (!(amber(s.backgroundColor) || amber(s.backgroundImage))) continue; + const text = (el.innerText || '').trim().replace(/\s+/g, ' '); + if (!text) continue; + const tc = getComputedStyle(el).color; + out.push({ key: `${el.tagName.toLowerCase()}.${[...el.classList].join('.') || '(no class)'}${el.getAttribute('style') ? ' +inline style' : ''}`, + text: text.slice(0, 24), color: tc }); + } + return out; + }); + for (const r of rows) { + const k = r.key; if (!found.has(k)) found.set(k, { n: 0, pages: new Set(), ex: r.text, color: r.color }); + const f = found.get(k); f.n++; f.pages.add(url); + } + } + console.log(`== ${theme}`); + for (const [k, f] of [...found.entries()].sort((a, b) => b[1].n - a[1].n)) + console.log(` ${String(f.n).padStart(3)} ${k.slice(0, 70)} text ${f.color} e.g. "${f.ex}" on ${[...f.pages].join(' ')}`); + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); From a9ecc4864b79f9366554e825a30ad16d59360669 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 15:35:57 +0000 Subject: [PATCH 26/91] Capture the header tab lift and the ability-button options --- docs/design/style-guide/round8.html | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/design/style-guide/round8.html diff --git a/docs/design/style-guide/round8.html b/docs/design/style-guide/round8.html new file mode 100644 index 000000000..4135a6094 --- /dev/null +++ b/docs/design/style-guide/round8.html @@ -0,0 +1,73 @@ +Tabs and Ability Buttons + + + + +
    +
    +
    +
    OrcPub · feature/style-guide · 844c15ca
    +

    Tabs and ability buttons

    +

    Captured from the running branch in the dark theme at twice actual size.

    +
    +
    +

    Header tabs, built Built

    +

    The dark lift on every tab, and titles 4% tighter. MY CONTENT now fits on one line, with 3px to spare.

    +
    Built · dark lift, −4% titles
    Built
    +
    +

    Ability buttons, Dark Button Text on

    +

    The dark text was weaker here than on the full-width buttons because a label of one or two characters has so few strokes. I tried the options below and built (d). White is at the bottom for comparison.

    +
    (a) Before · 600, standard light glow
    (a) Before
    +
    (b) 700 · heavier strokes, standard glow
    (b) 700
    +
    (c) Stronger glow · 600, 70% white glow
    (c) Stronger glow
    +
    (d) 700 + stronger glow · built
    (d) 700 + stronger glow
    +
    Built · (d) on the running branch
    Built
    +
    Default white · now 700, with the dark lift
    Default white
    +
    +
    From 451e5010e6472560273b5e1f62d38bee7037ddb4 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 15:38:03 +0000 Subject: [PATCH 27/91] Capture outline options for dark text on the ability buttons --- docs/design/style-guide/capture-outline.js | 39 ++++++++++++ docs/design/style-guide/round9.html | 69 ++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 docs/design/style-guide/capture-outline.js create mode 100644 docs/design/style-guide/round9.html diff --git a/docs/design/style-guide/capture-outline.js b/docs/design/style-guide/capture-outline.js new file mode 100644 index 000000000..d67745747 --- /dev/null +++ b/docs/design/style-guide/capture-outline.js @@ -0,0 +1,39 @@ +// Outline options for dark text on the ability buttons (round9.html). Needs the app on :8890 (prod build). +// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline.js +const { chromium } = require('playwright'); +const SEL = '.app.dark-button-text .roll-button'; +const OPT = { + 'w-white': null, + 'd-built': '', + 'e-shadow-ring': `${SEL} { text-shadow: 1px 0 0 rgba(255,255,255,.55), -1px 0 0 rgba(255,255,255,.55), 0 1px 0 rgba(255,255,255,.55), 0 -1px 0 rgba(255,255,255,.55) !important; }`, + 'f-stroke-hair': `${SEL} { -webkit-text-stroke: 1px rgba(255,255,255,.75); paint-order: stroke fill; }`, + 'g-stroke-hair-glow': `${SEL} { -webkit-text-stroke: 1px rgba(255,255,255,.75); paint-order: stroke fill; }`, + 'h-stroke-full': `${SEL} { -webkit-text-stroke: 2px rgba(255,255,255,.85); paint-order: stroke fill; text-shadow: none !important; }`, + 'i-stroke-full-glow': `${SEL} { -webkit-text-stroke: 2px rgba(255,255,255,.85); paint-order: stroke fill; }`, +}; +// f: stroke only, no glow +OPT['f-stroke-hair'] += ` ${SEL} { text-shadow: none !important; }`; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const dark of [false, true]) { + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + for (const [k, css] of Object.entries(OPT)) { + if ((k === 'w-white') !== !dark) continue; + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c || ''; document.head.appendChild(e); }, css); + await p.waitForTimeout(120); + const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); + await p.screenshot({ path: `round9/roll-${k}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); + } + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round9.html b/docs/design/style-guide/round9.html new file mode 100644 index 000000000..dbea5f650 --- /dev/null +++ b/docs/design/style-guide/round9.html @@ -0,0 +1,69 @@ +Dark Text Outlines + + + + +
    +
    +
    +
    OrcPub · feature/style-guide · options, not built
    +

    Dark text outlines

    +

    The ability buttons with Dark Button Text on, dark theme, from the running branch at twice actual size. The strokes draw behind the letters (paint-order: stroke fill) so the dark letters keep their full thickness.

    +
    +
    +
    +
    (d) As built · 700, 70% light glow
    (d) As built
    +
    (e) Shadow ring · four 1px light shadows (up, down, left, right), 55% white, no blur
    (e) Shadow ring
    +
    (f) Hairline stroke · a true outline, about 0.5px showing outside each letter, 75% white, no glow
    (f) Hairline stroke
    +
    (g) Hairline stroke + glow · (f) with the built glow behind it
    (g) Hairline stroke + glow
    +
    (h) 1px stroke · about 1px showing outside each letter, 85% white, no glow: a sticker look
    (h) 1px stroke
    +
    (i) 1px stroke + glow · (h) with the glow
    (i) 1px stroke + glow
    +
    Default white · for comparison
    Default white
    +
    +
    From 88eff8394d3987f7f18a2128804ac3ab1bcab372 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 16:24:17 +0000 Subject: [PATCH 28/91] Capture coloured outline options for dark text on the ability buttons --- .../style-guide/capture-outline-colour.js | 38 ++++++++++ docs/design/style-guide/round10.html | 70 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 docs/design/style-guide/capture-outline-colour.js create mode 100644 docs/design/style-guide/round10.html diff --git a/docs/design/style-guide/capture-outline-colour.js b/docs/design/style-guide/capture-outline-colour.js new file mode 100644 index 000000000..e072cf6cb --- /dev/null +++ b/docs/design/style-guide/capture-outline-colour.js @@ -0,0 +1,38 @@ +// Coloured outline options for dark text on the ability buttons (round10.html). Needs the app on :8890 (prod build). +// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline.js +const { chromium } = require('playwright'); +const SEL = '.app.dark-button-text .roll-button'; +const S = (w, c) => `${SEL} { -webkit-text-stroke: ${w}px ${c}; paint-order: stroke fill; }`; +const OPT = { + 'w-white': null, + 'i-white-2': S(2, 'rgba(255,255,255,.85)'), + 'j-yellow-1': S(1, '#ffd21a'), + 'k-yellow-2': S(2, '#ffd21a'), + 'l-paleyellow-2': S(2, '#ffe680'), + 'm-orange-2': S(2, '#ff8a00'), + 'n-amber-2': S(2, '#f5b942'), +}; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const dark of [false, true]) { + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); + await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + for (const [k, css] of Object.entries(OPT)) { + if ((k === 'w-white') !== !dark) continue; + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c || ''; document.head.appendChild(e); }, css); + await p.waitForTimeout(120); + const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); + await p.screenshot({ path: `round10/roll-${k}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); + } + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round10.html b/docs/design/style-guide/round10.html new file mode 100644 index 000000000..1ae6ca9e1 --- /dev/null +++ b/docs/design/style-guide/round10.html @@ -0,0 +1,70 @@ +Coloured Outlines + + + + +
    +
    +
    +
    OrcPub · feature/style-guide · options, not built
    +

    Coloured outlines

    +

    Dark Button Text on the ability buttons, dark theme, from the running branch at twice actual size. Every option keeps the built light glow; the outline draws behind the letters.

    +
    +
    +
    +
    (i) White 1px, for reference · 85% white, the one that popped most last round
    (i)
    +
    (j) Yellow hairline · #ffd21a, the stylesheet’s warning-yellow, about 0.5px showing
    (j)
    +
    (k) Yellow 1px · #ffd21a: a gold rim that reads as part of the button
    (k)
    +
    (l) Pale yellow 1px · #ffe680, not in the palette yet: nearly as crisp as white, but warmer
    (l)
    +
    (n) Light amber 1px · #f5b942, the stylesheet’s amber-on-dark: close to the button, almost disappears
    (n)
    +
    (m) Bright orange 1px · #ff8a00, not in the palette: same brightness as the button, so it barely separates
    (m)
    +
    Default white · for comparison
    Default white)
    +
    +

    Why the orange fails

    An outline separates the letter only if it is lighter or darker than the button behind it. Orange and amber are about as light as the button itself, so they blend in. The yellows are lighter, so they show.

    +
    From 87d1a2233cb2cc27081f610b3b1a0eeeb32cc74a Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 16:33:51 +0000 Subject: [PATCH 29/91] Capture gradient outline options for dark text on the ability buttons --- .../style-guide/capture-outline-gradient.js | 47 +++++++++++ docs/design/style-guide/round11.html | 77 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 docs/design/style-guide/capture-outline-gradient.js create mode 100644 docs/design/style-guide/round11.html diff --git a/docs/design/style-guide/capture-outline-gradient.js b/docs/design/style-guide/capture-outline-gradient.js new file mode 100644 index 000000000..71ab628bd --- /dev/null +++ b/docs/design/style-guide/capture-outline-gradient.js @@ -0,0 +1,47 @@ +// Gradient outline options for dark text on the ability buttons (round11.html). Needs the app on +// :8890 (prod build). Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline-gradient.js +// A stroke can't take a gradient, so each option draws a copy of the label behind the real one +// (::before) with a transparent stroke and a gradient clipped to the text; Chrome paints the clip +// into the stroke area too. The real label keeps its dark fill and the built light glow. +const { chromium } = require('playwright'); +const SEL = '.app.dark-button-text .roll-button'; +const G = grad => ` + ${SEL} { position: relative; isolation: isolate; -webkit-text-stroke: 0 !important; } + ${SEL}::before { content: attr(data-label); position: absolute; inset: 0; z-index: -1; + display: flex; align-items: center; justify-content: center; + -webkit-text-stroke: 2px transparent; color: transparent; text-shadow: none; + background: ${grad}; -webkit-background-clip: text; background-clip: text; }`; +const OPT = { + 'w-white': null, + 'k-built': '', + 'o-reverse': G('linear-gradient(to bottom, #dbab50 40%, #f1a20f 62%)'), + 'p-bottom-solid': G('#dbab50'), + 'q-yellow-reverse': G('linear-gradient(to bottom, #ffd21a 40%, #dbab50 62%)'), + 'r-bevel': G('linear-gradient(to bottom, #ffd21a 40%, #b37a00 62%)'), +}; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const dark of [false, true]) { + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 3 }); + await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.querySelectorAll('.roll-button').forEach(e => e.dataset.label = e.textContent)); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + for (const [k, css] of Object.entries(OPT)) { + if ((k === 'w-white') !== !dark) continue; + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c || ''; document.head.appendChild(e); }, css); + await p.waitForTimeout(120); + const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); + await p.screenshot({ path: `round11/roll-${k}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); + await p.screenshot({ path: `round11/zoom-${k}.png`, clip: { x: r.x, y: r.y, width: r.width, height: r.height } }); + } + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round11.html b/docs/design/style-guide/round11.html new file mode 100644 index 000000000..6fe4ca812 --- /dev/null +++ b/docs/design/style-guide/round11.html @@ -0,0 +1,77 @@ +Gradient Outlines + + + + + +
    +
    +
    +
    OrcPub · feature/style-guide · options, not built
    +

    Gradient outlines

    +

    Dark Button Text on the ability buttons, dark theme, from the running branch at three times actual size. Every option keeps the built light glow. The gradient runs over the height of the letters, not the whole button.

    +
    +
    +

    One button, close up

    (k)
    (k) As built
    (o)
    (o) Reverse of the button
    (p)
    (p) Bottom colour, solid
    (q)
    (q) Yellow to bottom colour
    (r)
    (r) Bevel
    Default white)
    Default white
    +

    The row

    +
    (k) As built · solid yellow rim, #ffd21a
    (k)
    +
    (o) Reverse of the button · #dbab50 at the top of the letters to #f1a20f at the bottom: the button’s own two colours, flipped
    (o)
    +
    (p) Bottom colour, solid · #dbab50 all round, the colour at the foot of the button
    (p)
    +
    (q) Yellow to bottom colour · #ffd21a at the top of the letters fading to #dbab50: lit from above
    (q)
    +
    (r) Bevel · #ffd21a at the top to a darker amber #b37a00 at the bottom: light edge above, shadow edge below
    (r)
    +
    Default white · for comparison
    Default white)
    +
    +

    What the captures show

    +

    The button’s own colours, reversed (o) or solid (p), blend in: #dbab50 and #f1a20f are about as light as the button, so all that shows is the glow. It takes a colour lighter than the button at the top to “stand it up”: (q) reads as lit from above, and (r) adds a darker edge below, which reads as raised.

    +

    Building a gradient outline needs a markup change: a stroke can’t take a gradient, so the label is drawn twice (a copy behind it carries the gradient). The ability button would need its label in a data-label attribute for the stylesheet to copy. The solid rim (k) needs no markup change.

    +
    From c3db1ddc5ef327e01ce7ec2605a05d8c04c5a78c Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 16:40:16 +0000 Subject: [PATCH 30/91] Show the gradient outline options at actual size and smaller --- .../style-guide/capture-outline-gradient.js | 7 +++-- docs/design/style-guide/round11.html | 27 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/design/style-guide/capture-outline-gradient.js b/docs/design/style-guide/capture-outline-gradient.js index 71ab628bd..73104eae8 100644 --- a/docs/design/style-guide/capture-outline-gradient.js +++ b/docs/design/style-guide/capture-outline-gradient.js @@ -1,5 +1,5 @@ // Gradient outline options for dark text on the ability buttons (round11.html). Needs the app on -// :8890 (prod build). Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline-gradient.js +// :8890 (prod build). DPR=1 or 2 sets the pixel density. Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline-gradient.js // A stroke can't take a gradient, so each option draws a copy of the label behind the real one // (::before) with a transparent stroke and a gradient clipped to the text; Chrome paints the clip // into the stroke area too. The real label keeps its dark fill and the built light glow. @@ -23,7 +23,7 @@ const OPT = { const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); for (const dark of [false, true]) { - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 3 }); + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: +(process.env.DPR || 2) }); await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); const p = await ctx.newPage(); @@ -38,8 +38,7 @@ const OPT = { const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c || ''; document.head.appendChild(e); }, css); await p.waitForTimeout(120); const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); - await p.screenshot({ path: `round11/roll-${k}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); - await p.screenshot({ path: `round11/zoom-${k}.png`, clip: { x: r.x, y: r.y, width: r.width, height: r.height } }); + await p.screenshot({ path: `round11/roll-${k}${process.env.DPR ? "@" + process.env.DPR : ""}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); } await ctx.close(); } diff --git a/docs/design/style-guide/round11.html b/docs/design/style-guide/round11.html index 6fe4ca812..f6d878143 100644 --- a/docs/design/style-guide/round11.html +++ b/docs/design/style-guide/round11.html @@ -50,27 +50,26 @@ figcaption b { color: var(--fg); }
    OrcPub · feature/style-guide · options, not built
    -

    Gradient outlines

    -

    Dark Button Text on the ability buttons, dark theme, from the running branch at three times actual size. Every option keeps the built light glow. The gradient runs over the height of the letters, not the whole button.

    +

    Gradient outlines, actual size

    +

    Dark Button Text on the ability buttons, dark theme, from the running branch. Every option keeps the light glow. The rows are stacked so you can compare them straight down.

    -

    One button, close up

    (k)
    (k) As built
    (o)
    (o) Reverse of the button
    (p)
    (p) Bottom colour, solid
    (q)
    (q) Yellow to bottom colour
    (r)
    (r) Bevel
    Default white)
    Default white
    -

    The row

    -
    (k) As built · solid yellow rim, #ffd21a
    (k)
    -
    (o) Reverse of the button · #dbab50 at the top of the letters to #f1a20f at the bottom: the button’s own two colours, flipped
    (o)
    -
    (p) Bottom colour, solid · #dbab50 all round, the colour at the foot of the button
    (p)
    -
    (q) Yellow to bottom colour · #ffd21a at the top of the letters fading to #dbab50: lit from above
    (q)
    -
    (r) Bevel · #ffd21a at the top to a darker amber #b37a00 at the bottom: light edge above, shadow edge below
    (r)
    -
    Default white · for comparison
    Default white)
    -
    +

    Actual size, standard screen

    Rendered at 1 pixel per CSS pixel, as on a typical desktop monitor.

    (k) As built · solid yellow(k) As built
    (o) Reversed gradient · button colours flipped(o) Reversed gradient
    (p) Bottom colour · solid #dbab50(p) Bottom colour
    (q) Lit from above · yellow → bottom colour(q) Lit from above
    (r) Bevel · yellow → dark amber(r) Bevel
    Default white · for comparisonDefault white
    +

    Actual size, sharp screen

    Rendered at 2×, shown at the same size, as on a laptop Retina display or a phone.

    (k) As built · solid yellow(k) As built
    (o) Reversed gradient · button colours flipped(o) Reversed gradient
    (p) Bottom colour · solid #dbab50(p) Bottom colour
    (q) Lit from above · yellow → bottom colour(q) Lit from above
    (r) Bevel · yellow → dark amber(r) Bevel
    Default white · for comparisonDefault white
    +

    Smaller, 75%

    The sharp render shrunk to three quarters.

    (k) As built · solid yellow(k) As built
    (o) Reversed gradient · button colours flipped(o) Reversed gradient
    (p) Bottom colour · solid #dbab50(p) Bottom colour
    (q) Lit from above · yellow → bottom colour(q) Lit from above
    (r) Bevel · yellow → dark amber(r) Bevel
    Default white · for comparisonDefault white

    What the captures show

    The button’s own colours, reversed (o) or solid (p), blend in: #dbab50 and #f1a20f are about as light as the button, so all that shows is the glow. It takes a colour lighter than the button at the top to “stand it up”: (q) reads as lit from above, and (r) adds a darker edge below, which reads as raised.

    Building a gradient outline needs a markup change: a stroke can’t take a gradient, so the label is drawn twice (a copy behind it carries the gradient). The ability button would need its label in a data-label attribute for the stylesheet to copy. The solid rim (k) needs no markup change.

    From ed32cb3fe22b40eefe6c9ea23eadbaf6a4f761f3 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 23:02:50 +0000 Subject: [PATCH 31/91] Capture ability button label sizes at 14, 15 and 16px --- docs/design/style-guide/capture-size.js | 50 ++++++++++++++++ docs/design/style-guide/round12.html | 78 +++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 docs/design/style-guide/capture-size.js create mode 100644 docs/design/style-guide/round12.html diff --git a/docs/design/style-guide/capture-size.js b/docs/design/style-guide/capture-size.js new file mode 100644 index 000000000..bae55c6cc --- /dev/null +++ b/docs/design/style-guide/capture-size.js @@ -0,0 +1,50 @@ +// Ability-button label size options (round12.html): 14px (as built), 15px and 16px, each with +// white text, dark text with the glow only, the built yellow rim, and the lit-from-above rim. +// Needs the app on :8890 (prod build). DPR=1 or 2 sets the pixel density. +// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt DPR=1 node capture-size.js +const { chromium } = require('playwright'); +const SEL = '.app.dark-button-text .roll-button'; +const LIT = ` + ${SEL} { position: relative; isolation: isolate; -webkit-text-stroke: 0 !important; } + ${SEL}::before { content: attr(data-label); position: absolute; inset: 0; z-index: -1; + display: flex; align-items: center; justify-content: center; + -webkit-text-stroke: 2px transparent; color: transparent; text-shadow: none; + background: linear-gradient(to bottom, #ffd21a 40%, #dbab50 62%); + -webkit-background-clip: text; background-clip: text; }`; +const STYLES = { + white: null, + glow: `${SEL} { -webkit-text-stroke: 0 !important; }`, + rim: '', + lit: LIT, +}; +const DPR = +(process.env.DPR || 2); +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const dark of [false, true]) { + const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: DPR }); + await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.querySelectorAll('.roll-button').forEach(e => e.dataset.label = e.textContent)); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + for (const size of [14, 15, 16]) { + for (const [k, css] of Object.entries(STYLES)) { + if ((k === 'white') !== !dark) continue; + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, + `.roll-button { font-size: ${size}px !important; } ${css || ''}`); + await p.waitForTimeout(120); + const got = await p.locator('.roll-button').first().evaluate(e => `${getComputedStyle(e).fontSize} h=${e.getBoundingClientRect().height}`); + if (DPR === 1) console.log(size, k, got); + const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); + await p.screenshot({ path: `round12/${size}-${k}@${DPR}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); + } + } + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round12.html b/docs/design/style-guide/round12.html new file mode 100644 index 000000000..da0ef8d42 --- /dev/null +++ b/docs/design/style-guide/round12.html @@ -0,0 +1,78 @@ +Ability Button Sizes + + + + + + +
    +
    +
    +
    OrcPub · feature/style-guide · options, not built
    +

    Ability button sizes

    +

    The ability buttons’ labels at 14px (as built), 15px and 16px, dark theme, from the running branch, shown at actual size. Each group keeps one style so only the size changes.

    +
    +
    +

    Actual size, standard screen

    1 pixel per CSS pixel, as on a typical desktop monitor.

    Dark, glow only
    14px · as builtDark, glow only, 14px
    15pxDark, glow only, 15px
    16pxDark, glow only, 16px
    Dark, yellow rim (built)
    14px · as builtDark, yellow rim (built), 14px
    15pxDark, yellow rim (built), 15px
    16pxDark, yellow rim (built), 16px
    Dark, lit from above
    14px · as builtDark, lit from above, 14px
    15pxDark, lit from above, 15px
    16pxDark, lit from above, 16px
    Default white
    14px · as builtDefault white, 14px
    15pxDefault white, 15px
    16pxDefault white, 16px
    +

    Actual size, sharp screen

    Rendered at 2×, shown at the same size, as on a Retina laptop or a phone.

    Dark, glow only
    14px · as builtDark, glow only, 14px
    15pxDark, glow only, 15px
    16pxDark, glow only, 16px
    Dark, yellow rim (built)
    14px · as builtDark, yellow rim (built), 14px
    15pxDark, yellow rim (built), 15px
    16pxDark, yellow rim (built), 16px
    Dark, lit from above
    14px · as builtDark, lit from above, 14px
    15pxDark, lit from above, 15px
    16pxDark, lit from above, 16px
    Default white
    14px · as builtDefault white, 14px
    15pxDefault white, 15px
    16pxDefault white, 16px
    +

    Button height

    With the padding unchanged the buttons grow with the text: 31px at 14px, 32px at 15px, 34px at 16px. If the row shouldn’t get taller, the vertical padding can shrink to match (6px to 4.5px at 16px) so the button stays 31px and only the label grows.

    +
    From 03e47c51e492150acc81329b7101381b07ad23cd Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 23:07:13 +0000 Subject: [PATCH 32/91] Capture ability button label sizes on a phone --- docs/design/style-guide/capture-size-phone.js | 33 ++++++++ docs/design/style-guide/round13.html | 79 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 docs/design/style-guide/capture-size-phone.js create mode 100644 docs/design/style-guide/round13.html diff --git a/docs/design/style-guide/capture-size-phone.js b/docs/design/style-guide/capture-size-phone.js new file mode 100644 index 000000000..8efa3b5d0 --- /dev/null +++ b/docs/design/style-guide/capture-size-phone.js @@ -0,0 +1,33 @@ +// Ability buttons on a phone (round13.html): 14px as built, and 15px and 16px with the vertical +// padding trimmed so the button keeps its 31px height. Needs the app on :8890 (prod build). +// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-size-phone.js +const { chromium } = require('playwright'); +const SIZES = { 14: '', 15: '.roll-button { font-size: 15px !important; padding-top: 5.5px !important; padding-bottom: 5.5px !important; }', + 16: '.roll-button { font-size: 16px !important; padding-top: 4.5px !important; padding-bottom: 4.5px !important; }' }; +(async () => { + const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const dark of [false, true]) { + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); + await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + for (const [size, css] of Object.entries(SIZES)) { + await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); + await p.waitForTimeout(120); + const btn = p.locator('.roll-button').first(); + await btn.scrollIntoViewIfNeeded(); + const info = await btn.evaluate(e => { const r = e.getBoundingClientRect(); return `${getComputedStyle(e).fontSize} ${Math.round(r.width)}x${r.height}`; }); + console.log(dark ? 'dark' : 'white', size, info); + const r = await btn.boundingBox(); + await p.screenshot({ path: `round13/${size}-${dark ? 'rim' : 'white'}.png`, clip: { x: 0, y: Math.max(0, r.y - 60), width: 390, height: 150 } }); + } + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round13.html b/docs/design/style-guide/round13.html new file mode 100644 index 000000000..ade4f9b92 --- /dev/null +++ b/docs/design/style-guide/round13.html @@ -0,0 +1,79 @@ +Ability Buttons on a Phone + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · options, not built
    +

    Ability buttons on a phone

    +

    The builder on a 390px-wide phone, dark theme, shown at actual size. The buttons are 68×31 on phones and desktops alike; the larger labels keep that size by trimming the top and bottom padding.

    +
    +

    Dark text, yellow rim (built)

    14px · as built
    Dark text, yellow rim (built), 14px
    15px · padding trimmed, still 68×31
    Dark text, yellow rim (built), 15px
    16px · padding trimmed, still 68×31
    Dark text, yellow rim (built), 16px
    +

    Default white

    14px · as built
    Default white, 14px
    15px · padding trimmed, still 68×31
    Default white, 15px
    16px · padding trimmed, still 68×31
    Default white, 16px
    +
    From 0c339a6d83c873e30ce563a71befd77f497788c2 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 30 Sep 2026 23:37:02 +0000 Subject: [PATCH 33/91] Recapture the phone views as a real phone, with the layout fixes --- docs/design/style-guide/capture-size-phone.js | 48 +++++++++++++------ docs/design/style-guide/round13.html | 20 +++++--- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/docs/design/style-guide/capture-size-phone.js b/docs/design/style-guide/capture-size-phone.js index 8efa3b5d0..91d882840 100644 --- a/docs/design/style-guide/capture-size-phone.js +++ b/docs/design/style-guide/capture-size-phone.js @@ -1,21 +1,39 @@ -// Ability buttons on a phone (round13.html): 14px as built, and 15px and 16px with the vertical -// padding trimmed so the button keeps its 31px height. Needs the app on :8890 (prod build). +// The builder on a phone (round13.html), at 320px and 390px: the toggle row, and the ability +// buttons at 14px (as built), 15px and 16px, with the vertical padding trimmed so the button +// keeps its 31px height. Uses an iPhone user agent: the app picks its phone layout from the +// user agent, not the window width. Needs the app on :8890 (prod build). // Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-size-phone.js -const { chromium } = require('playwright'); +const { chromium, devices } = require('playwright'); const SIZES = { 14: '', 15: '.roll-button { font-size: 15px !important; padding-top: 5.5px !important; padding-bottom: 5.5px !important; }', 16: '.roll-button { font-size: 16px !important; padding-top: 4.5px !important; padding-bottom: 4.5px !important; }' }; +const OUT = process.env.OUT || 'round13'; +async function open(b, width, darkText) { + const ctx = await b.newContext({ ...devices['iPhone 13'], viewport: { width, height: 844 }, deviceScaleFactor: 3 }); + await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, darkText); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + return { ctx, p }; +} (async () => { const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const dark of [false, true]) { - const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); - await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + for (const width of [320, 390]) { + const { ctx, p } = await open(b, width, false); + const row = p.locator('div.pointer', { hasText: 'Light Theme' }).first(); + const r = await row.boundingBox(); + const right = await p.locator('div.pointer', { hasText: 'Dark Button Text' }).first().evaluate(e => e.getBoundingClientRect().right); + console.log(`toggles at ${width}px: right edge ${Math.round(right)}, gap ${Math.round(width - right)}px`); + await p.screenshot({ path: `${OUT}/toggles-${width}.png`, clip: { x: 0, y: r.y - 90, width, height: 150 } }); + await ctx.close(); + } + for (const width of [320, 390]) for (const darkText of [false, true]) { + const { ctx, p } = await open(b, width, darkText); + await p.getByText(/^details$/i).first().click(); + await p.waitForTimeout(400); for (const [size, css] of Object.entries(SIZES)) { await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); @@ -23,9 +41,9 @@ const SIZES = { 14: '', 15: '.roll-button { font-size: 15px !important; padding- const btn = p.locator('.roll-button').first(); await btn.scrollIntoViewIfNeeded(); const info = await btn.evaluate(e => { const r = e.getBoundingClientRect(); return `${getComputedStyle(e).fontSize} ${Math.round(r.width)}x${r.height}`; }); - console.log(dark ? 'dark' : 'white', size, info); - const r = await btn.boundingBox(); - await p.screenshot({ path: `round13/${size}-${dark ? 'rim' : 'white'}.png`, clip: { x: 0, y: Math.max(0, r.y - 60), width: 390, height: 150 } }); + console.log(width, darkText ? 'dark' : 'white', size, info); + const a = await p.locator('.roll-button').first().boundingBox(); + await p.screenshot({ path: `${OUT}/${size}-${darkText ? 'rim' : 'white'}-${width}.png`, clip: { x: 0, y: Math.max(0, a.y - 110), width, height: 160 } }); } await ctx.close(); } diff --git a/docs/design/style-guide/round13.html b/docs/design/style-guide/round13.html index ade4f9b92..fffa87c8d 100644 --- a/docs/design/style-guide/round13.html +++ b/docs/design/style-guide/round13.html @@ -65,15 +65,23 @@ .stack { display: flex; flex-direction: column; gap: 10px; }
    -
    OrcPub · feature/style-guide · options, not built
    +
    OrcPub · feature/style-guide · 6ce56c00

    Ability buttons on a phone

    -

    The builder on a 390px-wide phone, dark theme, shown at actual size. The buttons are 68×31 on phones and desktops alike; the larger labels keep that size by trimming the top and bottom padding.

    +

    The builder’s Details tab on an iPhone-sized screen, dark theme, from the running branch at actual size. The labels are shown at 14px (as built), 15px and 16px; the larger sizes trim the top and bottom padding so the buttons stay 31px tall.

    -

    Dark text, yellow rim (built)

    14px · as built
    Dark text, yellow rim (built), 14px
    15px · padding trimmed, still 68×31
    Dark text, yellow rim (built), 15px
    16px · padding trimmed, still 68×31
    Dark text, yellow rim (built), 16px
    -

    Default white

    14px · as built
    Default white, 14px
    15px · padding trimmed, still 68×31
    Default white, 15px
    16px · padding trimmed, still 68×31
    Default white, 16px
    +

    Dark text, yellow rim, 390px phone

    14px · as built
    Dark text, yellow rim, 14px, 390px phone
    15px
    Dark text, yellow rim, 15px, 390px phone
    16px
    Dark text, yellow rim, 16px, 390px phone
    +

    Default white, 390px phone

    14px · as built
    Default white, 14px, 390px phone
    15px
    Default white, 15px, 390px phone
    16px
    Default white, 16px, 390px phone
    +

    Dark text, yellow rim, 320px phone

    14px · as built
    Dark text, yellow rim, 14px, 320px phone
    15px
    Dark text, yellow rim, 15px, 320px phone
    16px
    Dark text, yellow rim, 16px, 320px phone
    +

    Default white, 320px phone

    14px · as built
    Default white, 14px, 320px phone
    15px
    Default white, 15px, 320px phone
    16px
    Default white, 16px, 320px phone
    +

    Toggles

    390px
    Toggles at 390px
    320px
    Toggles at 320px
    +

    Fixed on the branch to get here

    +

    The six ability buttons needed 432px, so on phones the sixth (CHA) was cut off at the right edge. They now share the row: 58px wide on a 390px phone, 48px on a 320px one. The Light Theme and Dark Button Text toggles now sit 10px in from the edge instead of touching it.

    From 81395010f786f33a8249618a9d81257673028586 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 02:56:16 +0000 Subject: [PATCH 34/91] Capture the builder laid out by window width --- .../style-guide/capture-layout-width.js | 41 +++++++++ docs/design/style-guide/round14.html | 87 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 docs/design/style-guide/capture-layout-width.js create mode 100644 docs/design/style-guide/round14.html diff --git a/docs/design/style-guide/capture-layout-width.js b/docs/design/style-guide/capture-layout-width.js new file mode 100644 index 000000000..17dba6487 --- /dev/null +++ b/docs/design/style-guide/capture-layout-width.js @@ -0,0 +1,41 @@ +// Layout follows the window width (round14.html): one desktop browser at 1280, then the +// same window narrowed to 390, then a real phone. Needs the app on :8890 (prod build) from +// feature/style-guide. Run from this folder: +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-layout-width.js +const { chromium, devices } = require('playwright'); +async function open(b, context) { + const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); + await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); + await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); + return { ctx, p }; +} +const tab = async (p, name) => { await p.locator('.builder-tab', { hasText: name }).click(); await p.waitForTimeout(400); }; +(async () => { + const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + { + const { ctx, p } = await open(b, { viewport: { width: 1280, height: 800 } }); + await p.screenshot({ path: 'round14/desktop-1280.png' }); + await p.setViewportSize({ width: 390, height: 844 }); await p.waitForTimeout(500); + await p.evaluate(() => scrollTo(0, 0)); + await p.screenshot({ path: 'round14/desktop-narrowed-options.png' }); + await tab(p, 'Details'); + const a = await p.locator('.roll-button').first().boundingBox(); + await p.evaluate(y => scrollTo(0, y - 300), a.y + await p.evaluate(() => scrollY)); + await p.screenshot({ path: 'round14/desktop-narrowed-details.png' }); + await ctx.close(); + } + { + const { ctx, p } = await open(b, { ...devices['iPhone 13'] }); + await p.screenshot({ path: 'round14/phone-options.png' }); + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round14.html b/docs/design/style-guide/round14.html new file mode 100644 index 000000000..9f2f86384 --- /dev/null +++ b/docs/design/style-guide/round14.html @@ -0,0 +1,87 @@ +Layout by Window Width + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · 77fcb2e6
    +

    Layout by window width

    +

    The running branch, dark theme. One desktop browser at 1280px, then the same window narrowed to 390px, then a real phone for comparison.

    +
    +

    Desktop browser, 1280px

    Desktop at 1280px
    +

    The same window narrowed to 390px

    +

    It now switches to the phone layout. Before, it kept the desktop layout squeezed into the narrow window.

    +
    +
    Options
    Narrowed desktop, Options tab
    +
    Details · the ability row fits
    Narrowed desktop, Details tab
    +
    +

    A real phone, 390px

    Options
    iPhone, Options tab
    +
    From ce5e3a6fdd8a196dc3c22678042e8cc28419fced Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 03:19:05 +0000 Subject: [PATCH 35/91] Capture the tidied phone header, and refresh the phone layout shots --- .../style-guide/capture-phone-header.js | 31 +++++++ docs/design/style-guide/round15.html | 85 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 docs/design/style-guide/capture-phone-header.js create mode 100644 docs/design/style-guide/round15.html diff --git a/docs/design/style-guide/capture-phone-header.js b/docs/design/style-guide/capture-phone-header.js new file mode 100644 index 000000000..757dcb93e --- /dev/null +++ b/docs/design/style-guide/capture-phone-header.js @@ -0,0 +1,31 @@ +// The phone header (round15.html): 390px and 320px phones, and desktop at 1280 to show it is +// unchanged. Needs the app on :8890 (prod build) from feature/style-guide. Run from this folder: +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-phone-header.js +const { chromium, devices } = require('playwright'); +const SHOTS = [ + ['phone-390', { ...devices['iPhone 13'] }, 300], + ['phone-320', { ...devices['iPhone SE'] }, 300], + ['desktop-1280', { viewport: { width: 1280, height: 800 } }, 520], +]; +(async () => { + const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const [name, context, height] of SHOTS) { + const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); + await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); + await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); + const w = p.viewportSize().width; + const over = await p.evaluate(() => document.documentElement.scrollWidth - innerWidth); + console.log(name, 'sideways overflow', over); + await p.screenshot({ path: `round15/${name}.png`, clip: { x: 0, y: 0, width: w, height } }); + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round15.html b/docs/design/style-guide/round15.html new file mode 100644 index 000000000..df2f5a400 --- /dev/null +++ b/docs/design/style-guide/round15.html @@ -0,0 +1,85 @@ +Phone Header + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · b58d2af3
    +

    Phone header

    +

    The running branch, dark theme, as a real phone at 390px and 320px, and a desktop at 1280px to show it is unchanged. The tabs share the row evenly with small gaps, the login button and the title stay off the edge, and nothing scrolls sideways.

    +
    +

    Phones

    +
    390px · iPhone 13
    Phone header at 390px
    +
    320px · iPhone SE
    Phone header at 320px
    +
    +

    Desktop, 1280px

    Desktop header at 1280px
    +
    From 00199adaabb404c8f74c4c0a9d5f1c87f7afbf19 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 03:34:47 +0000 Subject: [PATCH 36/91] Capture the phone header's left edges, built and aligned, and the centred buttons --- docs/design/style-guide/capture-left-edges.js | 61 +++++++++ docs/design/style-guide/round16.html | 117 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 docs/design/style-guide/capture-left-edges.js create mode 100644 docs/design/style-guide/round16.html diff --git a/docs/design/style-guide/capture-left-edges.js b/docs/design/style-guide/capture-left-edges.js new file mode 100644 index 000000000..3dc816ef6 --- /dev/null +++ b/docs/design/style-guide/capture-left-edges.js @@ -0,0 +1,61 @@ +// The phone header's left edges (round16.html): a line at the left edge of each element down the +// page, as built and with a preview (CSS injected here only) that puts them all on 10px; and the +// bar with the search and login boxes. Needs the app on :8890 (prod build) from +// feature/style-guide. Run from this folder: +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-left-edges.js +const { chromium, devices } = require('playwright'); +(async () => { + const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + const ctx = await b.newContext({ ...devices['iPhone 13'], deviceScaleFactor: 3 }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); + await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); + await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); + await p.screenshot({ path: 'round16/bar.png', clip: { x: 0, y: 0, width: 390, height: 52 } }); + const ALIGN = ` + .app-header-bar .w-100-p { padding-left: 10px !important; padding-right: 10px !important; } + .app-header-menu { padding-left: 8px !important; padding-right: 8px !important; } + .flex-wrap.edge-align-buttons.edge-align-buttons { margin-left: 5px !important; }`; + for (const state of ['now', 'aligned']) { + if (state === 'aligned') { + await p.evaluate(css => { + document.querySelector('h1').closest('.flex-wrap').children[1].classList.add('edge-align-buttons'); + const s = document.createElement('style'); s.textContent = css; document.head.appendChild(s); + }, ALIGN); + await p.waitForTimeout(300); + await p.screenshot({ path: 'round16/bar-aligned.png', clip: { x: 0, y: 0, width: 390, height: 52 } }); + } + const edges = await p.evaluate(() => { + document.querySelectorAll('.edge-layer').forEach(e => e.remove()); + // the ink, not the box: a text's left edge is where its first glyph starts + const ink = e => { const r = document.createRange(); r.selectNodeContents(e); return r.getBoundingClientRect().left; }; + const list = [ + ['logo', document.querySelector('.app-header-bar img').getBoundingClientRect().left, '#ff5c5c'], + ['tabs', document.querySelector('.header-tab').getBoundingClientRect().left, '#3ddc84'], + ['title', ink(document.querySelector('h1')), '#4da3ff'], + ['buttons', document.querySelector('h1').closest('.flex-wrap').children[1].firstElementChild.getBoundingClientRect().left, '#c084fc'], + ['panel', document.querySelector('.builder-tabs').parentElement.getBoundingClientRect().left, '#ff8fd1'], + ]; + const layer = document.createElement('div'); + layer.className = 'edge-layer'; + layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99999'; + list.forEach(([, x, color]) => { + const line = document.createElement('div'); + line.style.cssText = `position:absolute;top:0;bottom:0;left:${x - 0.5}px;width:1px;background:${color}`; + layer.append(line); + }); + document.body.appendChild(layer); + return list.map(([n, x]) => `${n} ${Math.round(x)}`); + }); + console.log(state, edges.join(', ')); + await p.screenshot({ path: `round16/edges-${state}.png`, clip: { x: 0, y: 0, width: 390, height: 600 } }); + await p.screenshot({ path: `round16/strip-${state}.png`, clip: { x: 0, y: 0, width: 70, height: 600 } }); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round16.html b/docs/design/style-guide/round16.html new file mode 100644 index 000000000..194b7cbcd --- /dev/null +++ b/docs/design/style-guide/round16.html @@ -0,0 +1,117 @@ +Phone Left Edges + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · 942c177d
    +

    Phone left edges

    +

    The builder on a 390px phone, dark theme. On the left is the branch as built; on the right is a preview with every left edge on 10px. The preview is CSS injected for the screenshot only; nothing is built yet.

    +
    + +

    The search and login buttons, fixed

    +

    Built: the magnifier is centred in a 36px square, LOGIN is centred in its button, and the two are the same height.

    +
    Phone header bar with the search and login buttons
    + +

    Left edges

    +
      +
    • Logo
    • Header tabs
    • +
    • Page title
    • Yellow buttons
    • +
    • Builder panel
    • +
    + + + + +
    LogoTabsTitleButtonsPanel
    As built20px12px10px15px10px
    Preview10px10px10px10px10px
    +
    +
    As built · four different edges
    As built, with a line at each left edge
    +
    Preview · one edge
    Preview with all left edges on 10px
    +
    +

    The left edge, magnified

    +
    +
    As built
    As built, left 70px magnified
    +
    Preview
    Preview, left 70px magnified
    +
    + +

    What the preview changes, on phones only

    +

    The top bar's side padding goes from 20px to 10px, so the logo moves 10px left and LOGIN 10px right. The tab row's margin goes from 10px to 8px, so the tabs' own 2px puts them on 10px. The yellow buttons' row loses 5px, because each button already carries its own 5px.

    +
    From 96da146d9b98f73a21d448fe3ed6eff6c90231cd Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 16:24:09 +0000 Subject: [PATCH 37/91] Capture the phone builder on 10px and 16px gutters beside the built one --- docs/design/style-guide/capture-gutters.js | 67 +++++++++++++++ docs/design/style-guide/round17.html | 94 ++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 docs/design/style-guide/capture-gutters.js create mode 100644 docs/design/style-guide/round17.html diff --git a/docs/design/style-guide/capture-gutters.js b/docs/design/style-guide/capture-gutters.js new file mode 100644 index 000000000..c6564aadc --- /dev/null +++ b/docs/design/style-guide/capture-gutters.js @@ -0,0 +1,67 @@ +// Phone gutters (round17.html): the builder on a 390px phone as built, and two previews (CSS +// injected here only) with every row on one gutter: 10px and 16px. Lines mark the gutter on both +// sides. Needs the app on :8890 (prod build) from feature/style-guide. Run from this folder: +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-gutters.js +const { chromium, devices } = require('playwright'); +// Each row's own spacing, set so its visible edge lands on the gutter G. +const css = G => ` + .app-header-bar .w-100-p { padding-left: ${G}px !important; padding-right: ${G}px !important; } + .app-header-menu { padding-left: ${G - 2}px !important; padding-right: ${G - 2}px !important; } + h1[data-g] { margin-left: ${G}px !important; } + [data-g=buttons].flex-wrap { margin-left: ${G - 5}px !important; margin-right: ${G - 5}px !important; } + [data-g=toggles].flex-wrap { padding-right: ${G}px !important; } + [data-g=builder-in].flex { padding-left: ${G}px !important; } + [data-g=builder-out].w-100-p { padding-right: ${G}px !important; }`; +(async () => { + const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const [name, G] of [['built', null], ['g10', 10], ['g16', 16]]) { + const ctx = await b.newContext({ ...devices['iPhone 13'], deviceScaleFactor: 3 }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); + await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); + await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); + if (G) await p.evaluate(rules => { + const h1 = document.querySelector('h1'); h1.dataset.g = 'title'; + h1.closest('.flex-wrap').children[1].dataset.g = 'buttons'; + [...document.querySelectorAll('div.pointer')].find(e => /Dark Button Text/.test(e.textContent) && e.children.length < 4).parentElement.dataset.g = 'toggles'; + const inner = document.querySelector('.builder-tabs').parentElement.parentElement; + inner.dataset.g = 'builder-in'; inner.parentElement.dataset.g = 'builder-out'; + const s = document.createElement('style'); s.textContent = rules; document.head.appendChild(s); + }, css(G)); + await p.waitForTimeout(400); + const edges = await p.evaluate(G => { + const W = innerWidth, r = e => e.getBoundingClientRect(); + const ink = e => { const x = document.createRange(); x.selectNodeContents(e); return x.getBoundingClientRect().left; }; + const bar = document.querySelector('.app-header-bar .w-100-p'), tabs = [...document.querySelectorAll('.header-tab')]; + const panel = document.querySelector('.builder-tabs').parentElement; + const toggles = [...document.querySelectorAll('div.pointer')].find(e => /Dark Button Text/.test(e.textContent) && e.children.length < 4); + const firstBtn = document.querySelector('h1').closest('.flex-wrap').children[1].firstElementChild; + const m = { + logo: r(bar.querySelector('img')).left, tabs: r(tabs[0]).left, title: ink(document.querySelector('h1')), + buttons: r(firstBtn).left, panel: r(panel).left, + 'login R': W - r(bar.lastElementChild).right, 'tabs R': W - r(tabs[tabs.length - 1]).right, + 'toggles R': W - r(toggles).right, 'panel R': W - r(panel).right }; + if (G) { + const layer = document.createElement('div'); + layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99999'; + for (const x of [G, W - G]) { + const line = document.createElement('div'); + line.style.cssText = `position:absolute;top:0;bottom:0;left:${x - 0.5}px;width:1px;background:#4da3ff`; + layer.append(line); + } + document.body.appendChild(layer); + } + return Object.entries(m).map(([k, v]) => `${k} ${Math.round(v)}`).join(', '); + }, G); + console.log(name, edges); + await p.screenshot({ path: `round17/${name}.png`, clip: { x: 0, y: 0, width: 390, height: 640 } }); + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round17.html b/docs/design/style-guide/round17.html new file mode 100644 index 000000000..f205f4ff3 --- /dev/null +++ b/docs/design/style-guide/round17.html @@ -0,0 +1,94 @@ +Phone Gutters + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · previews, not built
    +

    Phone gutters

    +

    The builder on a 390px phone, dark theme, at actual size. On the left, as built; then every row on a 10px gutter; then every row on 16px. The blue lines mark the gutter on both sides. Both previews are CSS injected for the screenshot only.

    +
    +
    +
    As built · edges at 20, 12, 10 and 15px
    As built
    +
    10px gutter
    Every row on a 10px gutter
    +
    16px gutter
    Every row on a 16px gutter
    +
    + + + + + +
    Left edgesRight edgesWidth for content
    As built20, 12, 10, 1520, 12, 10370px
    10px1010370px
    16px1616358px
    +

    What stays nested either way

    +

    Everything inside the builder panel keeps its own indent from the panel's border: the Options / Description / Details bar, the race list and its text. In the 16px version the whole panel moves in 6px, so its contents have 12px less width and lines wrap slightly sooner.

    +
    From 223f728f9ef64c106b2b0005fbf0b7750ffa7476 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 18:16:58 +0000 Subject: [PATCH 38/91] Capture the phone page on its 10px gutter --- .../style-guide/capture-phone-header.js | 4 +- docs/design/style-guide/round18.html | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 docs/design/style-guide/round18.html diff --git a/docs/design/style-guide/capture-phone-header.js b/docs/design/style-guide/capture-phone-header.js index 757dcb93e..5670a2e06 100644 --- a/docs/design/style-guide/capture-phone-header.js +++ b/docs/design/style-guide/capture-phone-header.js @@ -1,6 +1,6 @@ // The phone header (round15.html): 390px and 320px phones, and desktop at 1280 to show it is // unchanged. Needs the app on :8890 (prod build) from feature/style-guide. Run from this folder: -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-phone-header.js +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-phone-header.js (OUT=dir to save elsewhere) const { chromium, devices } = require('playwright'); const SHOTS = [ ['phone-390', { ...devices['iPhone 13'] }, 300], @@ -24,7 +24,7 @@ const SHOTS = [ const w = p.viewportSize().width; const over = await p.evaluate(() => document.documentElement.scrollWidth - innerWidth); console.log(name, 'sideways overflow', over); - await p.screenshot({ path: `round15/${name}.png`, clip: { x: 0, y: 0, width: w, height } }); + await p.screenshot({ path: `${process.env.OUT || 'round15'}/${name}.png`, clip: { x: 0, y: 0, width: w, height } }); await ctx.close(); } await b.close(); diff --git a/docs/design/style-guide/round18.html b/docs/design/style-guide/round18.html new file mode 100644 index 000000000..a38660cdc --- /dev/null +++ b/docs/design/style-guide/round18.html @@ -0,0 +1,85 @@ +Phone Gutter Built + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · bea36ada
    +

    Phone gutter, built

    +

    The running branch, dark theme. On both phones the logo, header tabs, title, yellow buttons, toggles and builder now start 10px from the edge on both sides. Desktop at 1280px is unchanged.

    +
    +

    Phones

    +
    390px · iPhone 13
    Phone at 390px on a 10px gutter
    +
    320px · iPhone SE
    Phone at 320px on a 10px gutter
    +
    +

    Desktop, 1280px

    Desktop at 1280px, unchanged
    +
    From 90b4f20a08cff7bc2b342c8d896f77659fb7aeb5 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 1 Oct 2026 19:30:35 +0000 Subject: [PATCH 39/91] Capture the roll buttons at 16px --- docs/design/style-guide/capture-roll-16.js | 63 +++++++++++++++ docs/design/style-guide/round19.html | 91 ++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 docs/design/style-guide/capture-roll-16.js create mode 100644 docs/design/style-guide/round19.html diff --git a/docs/design/style-guide/capture-roll-16.js b/docs/design/style-guide/capture-roll-16.js new file mode 100644 index 000000000..caaf38acc --- /dev/null +++ b/docs/design/style-guide/capture-roll-16.js @@ -0,0 +1,63 @@ +// Roll buttons at 16px (round19.html): the builder's ability row and skills on 390px and 320px +// phones and on desktop, white and dark text, and the sheet's skills and saves. Reports each button's size. +// Needs the app on :8890 (prod build) from feature/style-guide. Run from this folder: +// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-roll-16.js +const { chromium, devices } = require('playwright'); +const BASE = 'http://localhost:8890'; +async function open(b, context, darkText, path) { + const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); + await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, darkText); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto(BASE + path, { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); + await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); + await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + return { ctx, p }; +} +async function report(p, label) { + const info = await p.evaluate(() => { + const bs = [...document.querySelectorAll('.roll-button')].filter(b => b.offsetParent); + const over = bs.filter(b => b.scrollWidth > b.clientWidth + 1).length; + const hs = [...new Set(bs.map(b => Math.round(b.getBoundingClientRect().height)))]; + return `${bs.length} buttons, ${getComputedStyle(bs[0]).fontSize}, heights ${hs.join('/')}, labels overflowing ${over}`; + }); + console.log(label, info); +} +async function shoot(p, name, selector, above, h) { + const el = p.locator(selector).first(); + await p.mouse.move(1, 1); + await el.scrollIntoViewIfNeeded(); + await p.evaluate(() => scrollBy(0, -120)); await p.waitForTimeout(200); + const r = await el.boundingBox(); + await p.screenshot({ path: `round19/${name}.png`, clip: { x: 0, y: Math.max(0, r.y - above), width: p.viewportSize().width, height: h } }); +} +(async () => { + const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + for (const [dev, ctxOpts] of [['390', devices['iPhone 13']], ['320', devices['iPhone SE']]]) { + for (const dark of [false, true]) { + const { ctx, p } = await open(b, ctxOpts, dark, '/pages/dnd/5e/character-builder'); + await p.getByText(/^details$/i).first().click(); await p.waitForTimeout(400); + await report(p, `phone ${dev} ${dark ? 'dark' : 'white'}`); + await shoot(p, `phone-${dev}-${dark ? 'dark' : 'white'}`, '.roll-button', 120, 560); + await ctx.close(); + } + } + { + const { ctx, p } = await open(b, { viewport: { width: 1280, height: 900 } }, false, '/pages/dnd/5e/character-builder'); + await report(p, 'desktop'); + await shoot(p, 'desktop', '.roll-button', 90, 420); + await ctx.close(); + } + { + // The sheet's skills and saving throws: wider buttons, same label size. + const { ctx, p } = await open(b, devices['iPhone 13'], false, '/pages/dnd/5e/character-builder'); + await p.getByText(/^details$/i).first().click(); await p.waitForTimeout(400); + await shoot(p, 'phone-390-skills', 'text=Acrobatics', 60, 520); + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/round19.html b/docs/design/style-guide/round19.html new file mode 100644 index 000000000..5ac44af64 --- /dev/null +++ b/docs/design/style-guide/round19.html @@ -0,0 +1,91 @@ +Roll Buttons at 16px + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · 1e677a3f
    +

    Roll buttons at 16px

    +

    The running branch, dark theme. Roll-button labels are 16px instead of 14px; the buttons stay 31px tall. On every capture: 30 buttons, all 31px, no clipped labels.

    +
    +

    390px phone

    +
    White
    390px, white text
    +
    Dark Button Text
    390px, dark text
    +
    +

    320px phone

    +
    White
    320px, white text
    +
    Dark Button Text
    320px, dark text
    +
    +

    Skills and saving throws, 390px

    The dimmed skill buttons are skills the character isn't proficient in; that dimming was already there.

    +
    Skills and saves
    +

    Desktop, 1280px

    Desktop ability row
    +
    From c52292cb698167ed67050a949de8ce315d751d49 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 2 Oct 2026 02:22:47 +0000 Subject: [PATCH 40/91] Capture the style tidy before and after --- docs/design/style-guide/capture-roll-16.js | 2 +- docs/design/style-guide/round20.html | 157 +++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 docs/design/style-guide/round20.html diff --git a/docs/design/style-guide/capture-roll-16.js b/docs/design/style-guide/capture-roll-16.js index caaf38acc..43d8fed4a 100644 --- a/docs/design/style-guide/capture-roll-16.js +++ b/docs/design/style-guide/capture-roll-16.js @@ -32,7 +32,7 @@ async function shoot(p, name, selector, above, h) { await el.scrollIntoViewIfNeeded(); await p.evaluate(() => scrollBy(0, -120)); await p.waitForTimeout(200); const r = await el.boundingBox(); - await p.screenshot({ path: `round19/${name}.png`, clip: { x: 0, y: Math.max(0, r.y - above), width: p.viewportSize().width, height: h } }); + await p.screenshot({ path: `${process.env.OUT || 'round19'}/${name}.png`, clip: { x: 0, y: Math.max(0, r.y - above), width: p.viewportSize().width, height: h } }); } (async () => { const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); diff --git a/docs/design/style-guide/round20.html b/docs/design/style-guide/round20.html new file mode 100644 index 000000000..0959485c1 --- /dev/null +++ b/docs/design/style-guide/round20.html @@ -0,0 +1,157 @@ +Style Tidy Before and After + + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · 1e677a3f → 521d0682
    +

    Style tidy, before and after

    +

    The three cleanups, plus the header's last inline styles. The code is tighter and the page is unchanged: before-and-after screenshots at 320, 390 and 1280px are byte-for-byte identical, and every header measurement matches.

    +
    +

    1. The ability buttons’ strong glow is a named value

    +
    Before
    ;; core.clj, dark-text rule
    +[:.roll-button
    + {:text-shadow "0 0 2px rgba(255,255,255,0.7),
    +                0 1px 0 rgba(255,255,255,0.55)"
    +  ...}]
    +
    After
    ;; core.clj, beside the other two lifts
    +(def text-lift-light-strong
    +  "0 0 2px rgba(255,255,255,0.7), 0 1px 0 rgba(255,255,255,0.55)")
    +
    +[:.roll-button
    + {:text-shadow text-lift-light-strong
    +  ...}]
    +

    2. Header tabs spaced by a gap; the !important override is gone

    +
    Before
    ;; views.cljs, each tab
    +[:div ... .header-tab.m-l-2.m-r-2 ...]
    +;; the tab row on a phone
    +{:class (when mobile? "p-l-10 p-r-10 phone-tabs")}
    +
    +;; core.clj, undoing the tab margins on a phone
    +[:.phone-tabs
    + [:.header-tab:first-child {:margin-left "0 !important"}]
    + [:.header-tab:last-child {:margin-right "0 !important"}]]
    +
    After
    ;; views.cljs, each tab
    +[:div ... .header-tab ...]
    +;; the tab row on a phone
    +{:class (when mobile? "phone-tabs")}
    +
    +;; core.clj
    +[:.app-header-menu
    + {:gap "4px"
    +  :padding "0 2px"}
    + [:&.phone-tabs {:padding "0 10px"}]]
    +

    3. No inline styles in the header

    +
    Before
    ;; views.cljs
    +(def login-style-menu
    +  {:background-color "rgba(0,0,0,0.4)"})
    +(def search-input-parent-style
    +  {:background-color "rgba(0,0,0,0.3)"})
    +
    +[:div.b-rad-5 ... {:style login-style-menu ...}]
    +[:div.b-rad-5 ... {:style search-input-parent-style ...}]
    +[:div {:style (when (not mobile?) {:min-width "53px"})}]
    +
    After
    ;; views.cljs (both style maps deleted)
    +[:div.header-login-box.b-rad-5 ...]
    +[:div.header-search-box.b-rad-5 ...]
    +[:div {:class (when (not mobile?) "supporter-slot")}]
    +
    +;; core.clj
    +[:.header-search-box {:background-color "rgba(0,0,0,0.3)"}]
    +[:.header-login-box {:background-color "rgba(0,0,0,0.4)"}]
    +[:.supporter-slot {:min-width "53px"}]
    + +

    The running page

    Nine screenshots taken before and after; all nine are byte-identical. Five are shown.

    Header, 390px phone identical

    +
    Before
    Header, 390px phone, before
    +
    After
    Header, 390px phone, after

    Header, 320px phone identical

    +
    Before
    Header, 320px phone, before
    +
    After
    Header, 320px phone, after

    Ability row, Dark Button Text, 390px identical

    +
    Before
    Ability row, Dark Button Text, 390px, before
    +
    After
    Ability row, Dark Button Text, 390px, after

    Ability row, Dark Button Text, 320px identical

    +
    Before
    Ability row, Dark Button Text, 320px, before
    +
    After
    Ability row, Dark Button Text, 320px, after

    Header, desktop 1280px identical

    +
    Before
    Header, desktop 1280px, before
    +
    After
    Header, desktop 1280px, after
    +

    Inline styles still in the header

    The active tab's amber and the tab flyout menus are still inline maps in the tab component (active-style, header-menu-item-style). They predate this branch and change with state, so they would move together with the shared button work rather than here.

    +
    From 3d8e30d9da92ccc1f6c8665c3c2f8955910ffee0 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Fri, 2 Oct 2026 03:57:38 +0000 Subject: [PATCH 41/91] Capture the header menus moved into Garden, and the phone menu fix --- .../style-guide/capture-header-menus.js | 62 +++++++ .../design/style-guide/capture-phone-menus.js | 42 +++++ docs/design/style-guide/round21.html | 157 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 docs/design/style-guide/capture-header-menus.js create mode 100644 docs/design/style-guide/capture-phone-menus.js create mode 100644 docs/design/style-guide/round21.html diff --git a/docs/design/style-guide/capture-header-menus.js b/docs/design/style-guide/capture-header-menus.js new file mode 100644 index 000000000..f1cad7753 --- /dev/null +++ b/docs/design/style-guide/capture-header-menus.js @@ -0,0 +1,62 @@ +// Header menus (round21.html): the active tab, the Characters flyout open on desktop and on a +// phone with its current item highlighted, and the logged-in user menu. Prints each one's +// computed position and colours. Needs the app on :8890 (prod build, seeded user kaylee). +// Run from this folder: OUT=round21/before NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-header-menus.js +const { chromium, devices } = require('playwright'); +const BASE = 'http://localhost:8890', OUT = process.env.OUT || 'round21'; +async function open(b, context, login) { + const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); + await ctx.addInitScript(() => { try { if (!localStorage.getItem('user')) localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + if (login) { + await p.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); + await p.waitForSelector('input'); + await p.locator('input').nth(0).fill('kaylee'); await p.locator('input').nth(1).fill('serenity99'); + await p.locator('button.form-button').first().click(); + await p.waitForFunction(() => /:token/.test(localStorage.getItem('user') || ''), null, { timeout: 20000 }); + } + await p.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); + await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); + await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); + return { ctx, p }; +} +const desc = (p, sel) => p.evaluate(sel => { + const e = document.querySelector(sel); if (!e) return 'missing'; + const s = getComputedStyle(e), r = e.getBoundingClientRect(); + return `${sel}: ${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)} bg ${s.backgroundColor} pos ${s.position} z ${s.zIndex} display ${s.display}`; +}, sel); +(async () => { + const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + { + const { ctx, p } = await open(b, { viewport: { width: 1280, height: 900 } }); + const tab = p.locator('.header-tab').first(); + console.log('desktop', await desc(p, '.header-tab')); + await tab.hover(); await p.waitForTimeout(400); + console.log('desktop', await desc(p, '.header-tab .header-flyout')); + console.log('desktop', await desc(p, '.header-tab .header-flyout > div:nth-child(2)')); + await p.screenshot({ path: `${OUT}/desktop-flyout.png`, clip: { x: 560, y: 170, width: 720, height: 330 } }); + await ctx.close(); + } + { + const { ctx, p } = await open(b, devices['iPhone 13']); + await p.locator('.header-tab').first().focus(); await p.waitForTimeout(400); + console.log('phone', await desc(p, '.header-tab .header-flyout')); + await p.screenshot({ path: `${OUT}/phone-flyout.png`, clip: { x: 0, y: 0, width: 390, height: 300 } }); + await ctx.close(); + } + { + const { ctx, p } = await open(b, { viewport: { width: 1280, height: 900 } }, true); + await p.locator('#user-header').hover(); await p.waitForTimeout(400); + console.log('user', await desc(p, '#user-menu')); + await p.screenshot({ path: `${OUT}/user-menu.png`, clip: { x: 980, y: 0, width: 300, height: 180 } }); + await p.mouse.move(400, 600); await p.waitForTimeout(400); + console.log('user closed', await desc(p, '#user-menu')); + await ctx.close(); + } + await b.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-phone-menus.js b/docs/design/style-guide/capture-phone-menus.js new file mode 100644 index 000000000..78473e1dd --- /dev/null +++ b/docs/design/style-guide/capture-phone-menus.js @@ -0,0 +1,42 @@ +// Every header tab's menu on a phone (round21.html): opens it, checks it is fully on screen and +// not clipped by the header, and saves a screenshot. Needs the app on :8890 (prod build). +// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-phone-menus.js +const { chromium, devices } = require('playwright'); +(async () => { + const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); + const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); + let bad = 0; + for (const [w, dev] of [[390, 'iPhone 13'], [320, 'iPhone SE']]) { + const ctx = await b.newContext({ ...devices[dev], deviceScaleFactor: 2 }); + await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const p = await ctx.newPage(); + await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); + const c = p.locator('#cookie-btn'); + await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); + await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); + await p.evaluate(() => document.fonts.ready); + const n = await p.locator('.header-tab').count(); + for (let i = 0; i < n; i++) { + const tab = p.locator('.header-tab').nth(i); + if (!(await tab.locator('.header-flyout').count())) continue; + await tab.focus(); await p.waitForTimeout(350); + const r = await tab.locator('.header-flyout').evaluate(f => { + const b = f.getBoundingClientRect(); + // the point at the menu's bottom-centre must be the menu itself, not what's behind it + const hit = document.elementFromPoint(b.left + b.width / 2, b.bottom - 4); + return { l: Math.round(b.left), r: Math.round(innerWidth - b.right), bottom: Math.round(b.bottom), + visible: !!hit && f.contains(hit), over: document.documentElement.scrollWidth - innerWidth }; + }); + const ok = r.l >= 0 && r.r >= 0 && r.visible && r.over <= 0; + if (!ok) bad++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${w}px tab ${i + 1}: left ${r.l}, right ${r.r}, bottom ${r.bottom}, fully shown ${r.visible}`); + await p.screenshot({ path: `round21/menus/${w}-tab${i + 1}.png`, clip: { x: 0, y: 0, width: w, height: 420 } }); + await p.evaluate(() => document.activeElement.blur()); await p.waitForTimeout(150); + } + await ctx.close(); + } + await b.close(); + process.exit(bad ? 1 : 0); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/docs/design/style-guide/round21.html b/docs/design/style-guide/round21.html new file mode 100644 index 000000000..86b8767e2 --- /dev/null +++ b/docs/design/style-guide/round21.html @@ -0,0 +1,157 @@ +Header Menus in Garden + + + + + + + + +
    +
    +
    OrcPub · feature/style-guide · 521d0682 → 21375a5c → 66ccbcb6
    +

    Header menus in Garden

    +

    The header has no inline style maps left. The move changed nothing on screen. Checking the phone menus for it turned up a real bug, which is fixed in a separate commit.

    +
    +

    1. Active tab, flyouts and user menu move to Garden

    +
    Before
    ;; views.cljs: five style maps
    +(def active-style {:background-color "rgba(240, 161, 0, 0.7)"})
    +(def menu-color "#2c3445")
    +(def header-menu-item-style
    +  {:position :absolute :background-color "#2c3445"
    +   :z-index 10000 :top 84 :right 0})
    +(def mobile-header-menu-item-style
    +  (assoc header-menu-item-style :top 46))
    +(def user-menu-style {... :display :none})
    +
    +;; and inline on the elements
    +{:style (when active active-style) ...}
    +[:div.header-flyout {:style (if mobile? mobile-... header-...)}]
    +[:div {:style (when current-page? active-style)} name]
    +[:div#user-menu {:style user-menu-style}]
    +(set! (.-display style) "block")   ; opening the user menu
    +
    After
    ;; views.cljs: classes only
    +{:class (str ... (when active " active"))}
    +[:div.header-flyout ...]
    +[:div {:class (when current-page? "active")} name]
    +[:div#user-menu.user-menu ...]
    +(.add (.-classList user-menu) "open")
    +
    +;; core.clj
    +[:.header-flyout :.user-menu
    + {:position :absolute :right 0 :z-index 10000
    +  :background-color "#2c3445"}]
    +[:.header-flyout {:top "84px"}]
    +[:.phone-tabs [:.header-flyout {:top "46px"}]]
    +[:.user-menu {:display :none}
    + [:&.open {:display :block}]]
    +[:.header-tab.active :.header-flyout>.active
    + {:background-color "rgba(240, 161, 0, 0.7)"}]
    + +

    The move, on screen

    Before and after are byte-identical for the desktop menu, the user menu and the closed header at 320, 390 and 1280px, and the menus' computed position, colour and stacking match.

    Characters menu, desktop identical

    +
    Before
    Characters menu, desktop, before
    +
    After
    Characters menu, desktop, after

    User menu, logged in, desktop identical

    +
    Before
    User menu, logged in, desktop, before
    +
    After
    User menu, logged in, desktop, after
    +

    2. The phone menus open on screen

    +
    Before
    ;; core.clj, phone header
    +[:.app-header
    + {...
    +  :overflow-x :hidden}]   ; also clips vertically
    +
    +;; every flyout right-aligned to its tab
    +
    After
    ;; core.clj, phone header
    +[:.app-header
    + {...
    +  :overflow-x :clip}]     ; sideways only
    +
    +[:.phone-tabs
    + [:.header-flyout {:top "46px"}]
    + [".header-tab:nth-child(-n+3) .header-flyout"
    +  {:left 0 :right :auto}]]
    + +

    The fix, on screen

    Before, a tapped tab showed nothing: all 12 tab menus across a 390px and a 320px phone were clipped below the tab row, and the first tab's menu also started 34px off the left edge. After, all 12 open fully on screen. Nothing scrolls sideways, and desktop is unchanged.

    Characters menu, 390px phone

    +
    Before
    Characters menu, 390px phone, before
    +
    After
    Characters menu, 390px phone, after

    My Content menu, 390px phone

    +
    Before
    My Content menu, 390px phone, before
    +
    After
    My Content menu, 390px phone, after

    Characters menu, 320px phone

    +
    Before
    Characters menu, 320px phone, before
    +
    After
    Characters menu, 320px phone, after
    +

    Same bug on f1852203-accounts

    The clipping rule came from 0a089349 on f1852203-accounts, so that branch has the same broken phone menus. It isn't changed here.

    +
    From c1c70f6feaa3016c85e19f15de095283368eb185 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sat, 3 Oct 2026 08:22:02 +0000 Subject: [PATCH 42/91] Bring the portrait and artist comments to the comment rule The comment check reached this branch with the integration merge and found 64 comments and docstrings over length or carrying history. Each is rewritten to spec: what it does, its arguments and return, and a short GOTCHA where a reader would otherwise write a bug. The reasons, measurements and history they carried move to docs/PORTRAIT-COMPOSITOR.md (new), docs/PORTRAIT-TINTING.md (the lips' gamma) and the artist plan ("Fusspot's links"), and each docstring names the section it came from. Docstrings found wrong against the code are corrected rather than shortened: decode-artwork-bytes named fit-for-sheet where it calls fit-artwork, fit-artwork named the upload ceiling instead of max-artwork-embedded, and four places still described tinting by CSS mask or SrcIn after the move to multiply. The @font-face in a docstring that the cljs compiler read as a JSDoc tag is reworded, which clears that build warning. Only comment and docstring text changes: with comments stripped and strings masked, every file tokenizes as before. --- docs/PORTRAIT-COMPOSITOR.md | 221 ++++++++++++++++++ docs/PORTRAIT-TINTING.md | 7 + docs/README.md | 2 + docs/design/artist-profiles/PLAN.md | 19 +- src/clj/orcpub/fork/branding.clj | 21 +- src/clj/orcpub/index.clj | 8 +- src/clj/orcpub/pdf.clj | 49 +--- src/clj/orcpub/portrait_render.clj | 70 ++---- src/clj/orcpub/routes.clj | 120 +++------- src/cljc/orcpub/dnd/e5/artist_profile.cljc | 30 +-- src/cljc/orcpub/dnd/e5/character.cljc | 25 +- src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 210 +++++------------ src/cljc/orcpub/dnd/e5/portrait_layout.cljc | 62 ++--- src/cljs/orcpub/dnd/e5/events.cljs | 86 ++----- src/cljs/orcpub/dnd/e5/portrait.cljs | 150 +++--------- src/cljs/orcpub/dnd/e5/views.cljs | 12 +- src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 15 +- 17 files changed, 471 insertions(+), 636 deletions(-) create mode 100644 docs/PORTRAIT-COMPOSITOR.md diff --git a/docs/PORTRAIT-COMPOSITOR.md b/docs/PORTRAIT-COMPOSITOR.md new file mode 100644 index 000000000..42cd4c3e8 --- /dev/null +++ b/docs/PORTRAIT-COMPOSITOR.md @@ -0,0 +1,221 @@ +# Portrait compositor: why the code is the way it is + +The paper-doll portrait is drawn by three renderers, credited on every surface it reaches, +and stored as client-supplied EDN. Most of its code guards against something that went wrong +once. The docstrings say what each function does; this page keeps the reasons and the +measurements, so a change can be checked against them. Tinting has its own page, +[PORTRAIT-TINTING.md](PORTRAIT-TINTING.md). + +## Three renderers, one geometry + +The drawer stacks tinted divs in the DOM, the PDF export bakes a canvas in the browser, and +the share card is composed in Java2D because a crawler runs no JavaScript. They cannot share +drawing code, so `portrait_layout.cljc` holds every number they share and each renderer asks +it rather than deriving its own. Before it existed, each difference was a bug or a visible +mismatch: + +- Both rasterizers stretched each layer to fill the frame while the DOM composites with + `mask-size: contain`, so a shared portrait and a printed sheet came out 10% wider than the + face in the drawer (8:11 art in a 4:5 frame). `contain-rect` is the DOM's behaviour, + because the DOM is what a person looks at while choosing. +- The credit was sized with `round` in the browser and `int` on the server: at a 750px frame + it was 20px with a 2px halo in a PDF and 19px with a 1px halo on a share card. The layout + rounds, as the browser did. + +## Rendering on the server + +`portrait_render.clj` exists for the share card: a crawler reads `og:image` from the page +HTML and fetches it, with no browser to bake the layers. It needs no new dependency. Vector +assets reuse `pdf/svg-path-ops`, whose output maps directly onto a Java2D `Path2D`, and +raster assets are read off the classpath and tinted by `multiply!`. + +- **Every failure means no portrait, never an exception.** A share card without a picture is + a state the page already handles; a throw would cost the character their page. +- **`asset-source` reads only data URIs and site-absolute paths.** An off-site URL returns + nil, so rendering a share card can never make the server fetch an arbitrary URL. It reads + both forms because the registry moved from inline data URIs to files under + `resources/public` when the illustrator's inventory arrived. +- **The credit faces are Vollkorn files shipped with the app**, not + `java.awt.Font/SANS_SERIF`. That is a logical family resolved through the host's + fontconfig, so a slim container renders whatever it has, or boxes. + +## The baked credit and the site mark + +The page and the sheet can each carry a credit beside the portrait, but the composed image +is what people pass around: saved off a share card, lifted out of a PDF. It arrives detached +from both, so the credit is drawn into the pixels and travels with it. + +- **Dark fill under a light outline.** The PNG is transparent, so it may land on a white + page or a dark chat client; one of the two always reads. +- **It is a credit, not a watermark.** A crop removes it. It survives being right-click-saved. +- **The site mark runs up the right edge, away from the credit.** Two marks in one caption + band would give anyone who wants the advertising gone a reason to crop the artist's name + off with it. On opposite edges the cheap crop takes the site mark and leaves the artist. +- **The PDF prints no separate credit line.** The credit is already in the baked PNG; a + second line landed 0.12in under the first and said the same thing. +- **The PDF's document info is overwritten** (`stamp-document-info!`). The sheet templates + are third-party InDesign files, so a fresh export claimed to be made by "Adobe InDesign CS6 + (Macintosh)" with an empty author, which is untrue and is what an asset tool or a search + index reads. It also puts the credit somewhere besides the picture. This is provenance, + not protection: metadata strips in seconds. +- **The credit is stamped only when the composed portrait decoded.** A `portrait-png` that + fails to decode falls back to the pasted image URL, and crediting the illustrator for + somebody else's photograph is the one thing this feature must not do. + +## Who the credit names + +- **Order is by `layer-credit-weight`, then piece count, then registry order.** Weight comes + first because count misleads: someone who drew the ears, nose and hair bits has three + pieces, someone who drew the head and shirt has two, and the second drew the face. The + weights are fixed numbers rather than measured pixel area, so the browser and the server + agree without loading an image, the numbers can be read and tuned, and a large mostly + transparent asset cannot climb the list. Registry order last means the credit never + reshuffles between two renders of the same portrait. +- **Link marks flank the name only when they balance**: an even count, at most four. One + link, three, or more than four go on a centred line below. The credit's broken rule is + symmetric, and a lone mark on one side of it reads as a mistake. Two a side fits the + 247px drawer column beside a long name. +- **The lead artist keeps the marks**, because the lead is who most people looking at the + picture want to find. Everyone else goes on one "with A, B and C" line, so the lockup + stays three or four lines high however many artists there are. +- **The character page links each name** (`linked-credit`). A share link, a PDF and a baked + PNG can only carry the name as text, so the character page is the one surface after the + builder that can send someone to the artist. +- **An artist with no `:artist/name` gets no byline.** The drawer says "artist credit + pending" rather than showing an empty slot or inventing a name; `credit-line` returns nil. +- **The drawer's credit sits directly under the art.** It used to sit in the footer beside + Save Portrait, which put a credit in a row of actions and made the drawer disagree with + the character summary, where it sits under the thumbnail. + +The credit's design was settled from captures stashed at `docs/design/portrait-credit` on +`agents/develop`. + +## Deployment overrides + +The registry in `portrait_assets.cljc` holds the structure, which artist drew which asset, +and that has to be public because the app cannot resolve an asset without it. How a credit +is presented is a deployment concern: a fork may ship the same art under a different +arrangement with the artist, or add artists its own users contributed. So `:artist/name`, +`:artist/link`, `:artist/links`, `:artist/license` and `:artist/profile?` are overridable, +and nothing else is. A fork can credit its own contributors without editing shared code, and +cannot silently re-attribute someone's art to a different pack. + +- **`PORTRAIT_ARTISTS` that does not parse is ignored with a warning.** A broken credit + should cost the credit, not the site. +- **Both runtimes read the same overrides**: the server from env at startup, the client + from the config the page injects. The drawer and the PDF render in the browser and the + share card on the server, and a credit that disagreed between them would be worse than + none. +- **`:artist/profile?` sits with the credit fields** so whatever owns the artist's choice, + a deployment's config now and the artist's account setting later, can flip it without + touching code. + +## Saved portraits are untrusted + +A portrait is client-supplied EDN, stored as a string because `::se/values` is a Datomic +component ref and no attribute in it can hold a nested map. + +- **`sane-portrait` checks the containers, not just the outer map.** A stored + `{:layers "abc"}` is valid EDN and passed an outer-map check. The non-map `:layers` then + reached `credit-line`, which walks it: a string, a vector and a number all threw, and + `character-page` calls `credit-line` unguarded, so a crafted save took the public + character page down with a 500. Entry-level junk is already harmless, since + `(:asset/id 42)` is nil. +- **Artists are resolved through the registry by asset id first**, and only then from the + `:artist/id` the saved portrait names. Trusting the saved key meant deleting it detached + the credit from art plainly in the registry: on the page, on the sheet and in the picture. +- **`drawable?`, not `(seq :layers)`, decides whether a portrait exists.** A selection naming + only assets this deployment lacks is non-empty and draws nothing. The share card pointed + at `/portrait.png`, the renderer returned 404, and the link previewed broken even when the + character had a usable image URL to fall back on. + +## Artwork in the PDF + +- **Composed artwork may weigh 320k in the PDF, not the 128k upload limit.** The 128k is + what the builder advertises beside the Image URL field, a promise about what a user may + upload. A composed portrait is generated by the app at a size chosen to print well. + Fitted to 128k, a 600x750 portrait came back 367x459: 156 dpi in the 2.35in box the sheet + prints it at, under the 200 dpi the assets are built for. 320k keeps the full raster and + still bounds the document. +- **`decode-artwork-bytes` fits rather than refuses.** `decode-image-bytes` refuses anything + over the ceiling because the browser fits an uploaded image before sending it. A composed + portrait has no such stage and lands at about twice the ceiling, so refusing it would drop + the picture for being the size the app chose to make it. +- **`fit-artwork` keeps alpha; `fit-for-sheet` does not.** `fit-for-sheet` scales into + `TYPE_INT_RGB` and re-encodes as JPEG, right for a photograph. A composed portrait put + through it came back with its transparent ground black, a black box around the + character. Artwork gives up pixels instead, stays PNG, and steps its longest edge down 15% + a try, so the first attempt always shrinks; a fixed ladder starting above the image's size + re-encoded it unchanged. +- **The composed portrait is decoded eagerly, not in a `delay`.** It is local CPU, and a + delay that derefs to nil is still truthy, so a portrait that failed to decode would have + suppressed the pasted image URL that should have taken over. + +## Caching the share-card PNG + +- **`artwork-epoch` is mixed into the ETag.** The validator hashes what the character chose, + which means the same selections but not the same pixels: the layer files and the registry + are classpath resources and change with a deployment, not with the database. A restart + invalidates every portrait validator, which is when the pixels can have changed. +- **The ETag is route-local.** The global etag interceptor derives a validator from the + response body and has no method for a stream; teaching it one would hash every exported + PDF, which shares that body type. Here the key is the stored EDN, in hand before anything + is rendered, so a 304 costs a pull and a hash instead of a render. +- **The `If-None-Match` wildcard is the whole header or nothing.** RFC 7232 s3.2 gives the + grammar as `"*" / 1#entity-tag`, so `*` is never a list member. Splitting on commas could + turn a quoted tag containing a comma into a bare `*` and answer 304 to a client holding a + stale picture. Tags are matched out, not split; comparison is weak, as the RFC specifies, + and a `--gzip` suffix is dropped as the global interceptor drops it. +- **A wildcard request renders first.** `*` asks whether a representation exists, and a + stored portrait selecting nothing drawable has none and 404s. Answering 304 there would + call a cache current when there is no current copy. The saving is lost on wildcard + requests and kept on every request that names a tag. + +## The draft and the character on screen + +The portrait draft lives at the top of app-db, beside the character rather than in it. + +- **`:set-character` drops the draft; `:character-updated` keeps it.** Three review rounds + went into guards that tried to work out after the fact whether a replacement was really a + switch, and each was wrong in a way the next found: a first save looks like a new id, and + two never-saved characters share a nil one. The information is in which event the caller + meant, so callers choose. Re-opening the same saved character keeps the draft as a + courtesy; two different characters cannot share a non-nil id, so that comparison cannot + be wrong, and nothing about a save reaches it. +- **`:character-epoch` counts replacements.** A save is dispatched at one epoch and answered + at another, which is the only way the answer can tell whether its character is still on + screen. An id cannot: a first save is where the id appears. +- **A save replaces the character on screen only when it was about it.** It used to replace + it with whatever came back. The autosave queue is throttled by 7.5s and every sheet + control feeds it, so adjusting hit points on one character and opening another inside + that window swapped the builder out from under you, taking the unsaved edits in db + `:character` with it. The portrait draft was the loudest symptom. The manual save passes + the epoch it was dispatched at; the autosave posts a character by id, so it passes that + id. Neither given answers false, which updates the character map and leaves the screen + alone. +- **`:character-save-success` takes the response last.** The `:http` effect conjoins the + response onto the caller's `:on-success` vector. Reading the arguments the other way + round bound the save context as the response, so every successful save silently failed to + install the character it got back, including a first save's new `:db/id`. + +## Opting out of AI training + +The page carries ``, the convention DeviantArt +started: only the `noai` tokens, which do not imply noindex. The portrait PNG carries the +same tokens as `X-Robots-Tag`, because a crawler fetching the image directly never parses +the HTML. Both are voluntary, honoured by some crawlers and ignored by others, so they sit +beside robots.txt and the art licence rather than replacing either. + +## The drawer's chrome + +- **The empty portrait slot shows the site logo, debossed**: masked to 5% white over a + shallow top-down gradient, with a one-pixel dark shadow, so it reads as a detail in the + wall rather than a picture. It replaced a cool radial spotlight, which belonged to some + other screen: the app's panels are flat or top-down and its accents are amber. The mark + comes from `branding/logo-path`, so a fork gets its own. Only the empty states carry it, + so a portrait never shows a logo through its transparent parts. +- **The drawer sets the theme class on its own root.** It is mounted as a sibling of + `content-page`, and `.app`, which carries the theme, is inside `content-page`. The + Portrait tab has its own `.pl-root` too, so the stylesheet has one theme hook, not two. +- **The drawer's stylesheet mounts whether the drawer is open or not.** It also styles the + launcher button and the Portrait tab, which are on screen when the drawer is not. diff --git a/docs/PORTRAIT-TINTING.md b/docs/PORTRAIT-TINTING.md index 7f607eda3..36a1e9b15 100644 --- a/docs/PORTRAIT-TINTING.md +++ b/docs/PORTRAIT-TINTING.md @@ -99,6 +99,13 @@ very little mean and costs spread quickly; dropping the gamma below ~0.4 starts to look harsh. Both numbers are tuned to art with near-black irises and should be re-measured if a second illustrator's work comes in with lighter ones. +**The lips need the opposite gamma.** They are colorized too, since an asset that +names a conditional slot is colorized, but they are drawn light: 75% of the lipped +mouth sits above the ramp's midpoint. A gamma below 1 washes them out, so they +take 2.2 to pull them down into the colour. One value cannot serve both ends of +the tonal range, so `tint-gamma` picks by slot and an asset can set its own +`:asset/gamma`. + ## Colour slot presets The eye slot is the one that needed the most saturation: at portrait size an diff --git a/docs/README.md b/docs/README.md index dbbce5925..09231cf41 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,8 @@ Guides for developers and power users working with OrcPub's homebrew content sys - [🚨 Error Handling](ERROR_HANDLING.md) - Error handling utilities - [🗡️ Language Selection Fix](LANGUAGE_SELECTION_FIX.md) - Ranger favored enemy language corruption (#296) - [🐳 Docker User Management](docker-user-management.md) - Verified user setup for Docker deployments +- [Portrait Compositor](PORTRAIT-COMPOSITOR.md) - Why the paper-doll portrait code is shaped as it is: shared geometry, the baked credit, untrusted saves, PDF sizing, ETags, the draft +- [Portrait Tinting](PORTRAIT-TINTING.md) - How character colours reach the line art: multiply, the untinted mouth, colorized eyes and lips, floor and gamma **For Operators:** - [🖼️ Character Image Fetch](CHARACTER-IMAGE-FETCH.md) - Why a portrait is missing from a PDF, the limits on the fetch, and how nginx/Compose/Swarm/egress proxies affect it diff --git a/docs/design/artist-profiles/PLAN.md b/docs/design/artist-profiles/PLAN.md index 1dd9562be..14b9b15ea 100644 --- a/docs/design/artist-profiles/PLAN.md +++ b/docs/design/artist-profiles/PLAN.md @@ -55,9 +55,26 @@ the credit already honours. The pure functions go in a new cljc namespace, - **Server:** the share tags and the og PNG. Nothing about the page body. - **Client:** everything else. The examples use `portrait/composite`, the - same CSS-mask compositor as the drawer and the character page, so they cannot + same compositor as the drawer and the character page, so they cannot look different from a real portrait. +## Fusspot's links + +The registry carries exactly the links she asked for, her site and her Twitch, and no +others: not every handle she has, the ones she wants here. Twitch is where she does most of +the work, so someone who liked the art can go and watch it being made. + +- **`:artist/link` is her homepage**, the one link for surfaces that hold one, such as the + character page's 100px credit strip. It is the list she maintains, so it cannot go stale + the way a copied handle can. +- **A link with no `:link/icon` shows its label as text**, so a service nobody has drawn a + mark for degrades to a word, not an empty box. The bluesky and kofi marks stay in + `/image/social` for the next artist who wants them. +- **`:link/color` is worn at rest, not only on hover.** A credit in the same quiet grey as + everything around it is designed to be skipped; the eye finds a purple Twitch mark and + slides past a grey globe. Site takes the app's amber because her homepage has no mark of + its own and it is the primary link. + ## Look The drawer's vocabulary, nothing new: Vollkorn italic for names, Open Sans for UI, diff --git a/src/clj/orcpub/fork/branding.clj b/src/clj/orcpub/fork/branding.clj index 2e4d313b5..3ed526dc5 100644 --- a/src/clj/orcpub/fork/branding.clj +++ b/src/clj/orcpub/fork/branding.clj @@ -139,22 +139,11 @@ ;; ─── Portrait Artist Credits ─────────────────────────────────────── (def portrait-artists - "Deployment overrides for how portrait artists are credited, as - {artist-id {:artist/name .. :artist/link .. :artist/license ..}}. - - PORTRAIT_ARTISTS is JSON keyed by artist id: - - {\"house-pack\": {\"name\": \"Someone\", \"link\": \"https://...\"}} - - The registry in portrait-assets.cljc holds the public default and the - structure -- which artist drew which asset -- because the app cannot - resolve an asset without it. Only the presentation of the credit is - overridable, so a fork can credit its own contributors without editing - shared code, and cannot silently re-attribute someone's art to a - different pack. - - A malformed value is ignored with a warning rather than taking down the - page: a broken credit should cost the credit, not the site." + "Deployment overrides for portrait artist credits, {artist-id {:artist/name :artist/link + :artist/links :artist/license}}, from PORTRAIT_ARTISTS: JSON keyed by artist id, e.g. + {\"house-pack\": {\"name\": \"Someone\", \"link\": \"https://...\"}}. Which artist drew + which asset is not overridable. See PORTRAIT-COMPOSITOR.md, \"Deployment overrides\". + GOTCHA: a value that does not parse is ignored with a warning, not thrown." (or (when-let [raw (not-empty (or (env :portrait-artists) (System/getenv "PORTRAIT_ARTISTS")))] (try diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index c2178160d..302605378 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -140,11 +140,9 @@ (meta-tag "twitter:title" title) (meta-tag "twitter:description" description) (meta-tag "twitter:image" image) - ;; Opt out of generative-AI training and image reuse. This is the - ;; DeviantArt-originated convention: a `robots` meta carrying only the - ;; `noai`/`noimageai` tokens, which does NOT imply noindex. Voluntary -- - ;; honoured by some crawlers, ignored by others -- so it sits alongside - ;; robots.txt and the art licence rather than replacing either. + ;; Opts out of generative-AI training and image reuse. Only the `noai` tokens: + ;; this does NOT imply noindex. Voluntary; see PORTRAIT-COMPOSITOR.md, + ;; "Opting out of AI training". [:meta {:name "robots" :content "noai, noimageai"}] [:meta {:charset "UTF-8"}] [:meta {:name "viewport" diff --git a/src/clj/orcpub/pdf.clj b/src/clj/orcpub/pdf.clj index 6f4385e88..a2f67f67e 100644 --- a/src/clj/orcpub/pdf.clj +++ b/src/clj/orcpub/pdf.clj @@ -1678,18 +1678,9 @@ nil))) (def ^:private max-artwork-embedded - "What generated artwork may weigh in the PDF. - - Deliberately not max-embedded-bytes. That 128k is the number the builder - advertises beside the Image URL field -- a promise about what a user may - UPLOAD. A composed portrait is neither uploaded nor untrusted: the app - rasterizes it, at a size the app chose to print well. - - Holding it to the upload limit costs resolution, and measurably: a 600x750 - portrait fitted to 128k comes back 367x459, which is 156 dpi in the 2.35in - box the sheet prints it at -- under the 200 dpi the assets are built for. - 320k keeps the full raster and still bounds the document; the fit below is - the backstop for anything larger." + "Bytes generated artwork may weigh in the PDF. Not max-embedded-bytes, which is the upload + limit: at 128k a composed portrait prints under 200 dpi. Measurements in + PORTRAIT-COMPOSITOR.md, \"Artwork in the PDF\"." (* 320 1024)) (defn- scaled-copy-argb @@ -1715,18 +1706,9 @@ (.toByteArray out))) (defn- fit-artwork - "Shrink generated artwork until it fits max-embedded-bytes, keeping alpha. - - NOT fit-for-sheet. That one scales into TYPE_INT_RGB and re-encodes as - JPEG, which is right for a photograph and wrong for anything transparent: - a composed portrait put through it comes back with its transparent ground - turned black, which prints as a black box around the character. Artwork - gives up pixels instead of transparency, and stays PNG. - - Steps the longest edge down by 15% a time rather than walking a fixed - ladder, so the first attempt always actually shrinks something -- a ladder - starting above the image's own size burns iterations re-encoding it - unchanged." + "PNG `data` fitted to max-artwork-embedded as {:data bytes :jpg? false}, or nil if it will + not decode or will not fit in 12 tries. Shrinks the longest edge 15% a try, keeping alpha. + GOTCHA: not fit-for-sheet, which re-encodes as JPEG and turns transparency black." [^bytes data] (when-let [img (ImageIO/read (java.io.ByteArrayInputStream. data))] (if (<= (alength data) max-artwork-embedded) @@ -1746,21 +1728,10 @@ (+ 4 (quot (* 4 (* 2 1024 1024)) 3))) (defn decode-artwork-bytes - "Artwork the app itself produced, fitted to the sheet rather than refused. - - decode-image-bytes turns away anything over max-embedded-bytes, and rightly - so: the browser is expected to have done the fitting already, because - orcpub.image-capture measures against the same print edge before it sends. - - A composed portrait has no such stage. It is rasterized from CSS-mask layers - at the frame's own size and lands about twice the ceiling -- more once the - layers carry real line art rather than silhouettes. Refusing it would drop - the picture from the sheet for being the size we chose to make it, so it - gets what an oversized FETCHED image gets: fit-for-sheet. The ceiling still - belongs on what goes into the document. - - Guards before the decode are unchanged -- encoded length bounds the - allocation, and the header still has to declare a sane canvas." + "Base64 artwork the app generated (a composed portrait), decoded and passed through + fit-artwork; {:data :jpg?} or nil. Unlike decode-image-bytes it fits an oversized image + instead of refusing it. The encoded length and the declared canvas are bounded before the + decode. See PORTRAIT-COMPOSITOR.md, \"Artwork in the PDF\"." [b64] (try (when (and (string? b64) diff --git a/src/clj/orcpub/portrait_render.clj b/src/clj/orcpub/portrait_render.clj index 5cb6c8d25..6df3d4aeb 100644 --- a/src/clj/orcpub/portrait_render.clj +++ b/src/clj/orcpub/portrait_render.clj @@ -1,22 +1,9 @@ (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 -- the illustrator's own layer art, read off the - classpath under resources/public -- 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." + "Server-side rendering of a composed portrait to PNG, for the share card (og:image). + Entry points: `render-png`, `site-mark`. Vector assets are drawn with pdf/svg-path-ops; + raster assets are read off the classpath and tinted by `multiply!`. + GOTCHA: every failure returns nil (no portrait), never throws. + Why: PORTRAIT-COMPOSITOR.md, \"Rendering on the server\"." (:require [clojure.java.io :as io] [clojure.string :as s] [orcpub.fork.branding :as branding] @@ -48,14 +35,9 @@ (catch Exception _ nil))))) (defn asset-source - "Bytes for an asset URL, plus the mime that decides how to draw it. - - The registry moved from inline data URIs to real files under - resources/public once the illustrator's inventory landed, so this resolves - both: a data URI is decoded in place, and a site-absolute path is read off - the classpath. Anything else -- an off-site URL especially -- returns nil, - because rendering a share card must never become a way to make the server - fetch arbitrary URLs." + "{:mime :bytes} for an asset URL: a base64 data URI decoded in place, or a site-absolute + path read off the classpath under public/. nil for anything else. + GOTCHA: never fetches an off-site URL; a share card must not make the server fetch one." [uri] (or (parse-data-uri uri) (when (and (string? uri) (s/starts-with? uri "/") (not (s/includes? uri ".."))) @@ -136,16 +118,9 @@ out)) (defn- multiply! - "out.rgb = colour.rgb * art.rgb, out.a = art.a, in place. - - Masking -- which is what this did -- reads the asset's ALPHA and throws its - RGB away, so a drawing with black lines over a white fill came out as one - flat colour, indistinguishable from its own silhouette. Multiply keeps the - lines: white fill takes the colour exactly, black lines stay black, a grey - darkens the colour by however grey it is. - - Java2D has no multiply -- AlphaComposite is Porter-Duff only -- so this is a - pixel pass over the frame's backing int[]." + "Tints `img` in place: out.rgb = colour.rgb * art.rgb, out.a = art.a. A pixel pass over + the backing int[], because Java2D's AlphaComposite has no multiply. + Why multiply and not a mask: PORTRAIT-TINTING.md." [^BufferedImage img ^Color colour] (let [dst (.. img getRaster getDataBuffer getData) tr (.getRed colour) tg (.getGreen colour) tb (.getBlue colour) @@ -177,13 +152,8 @@ (def mark-face "public/fonts/Vollkorn-Regular.ttf") (def ^:private load-face - "Vollkorn off the classpath, parsed once. - - Not java.awt.Font/SANS_SERIF: that is a *logical* family the JVM resolves - through the host's fontconfig, so a slim container renders the marks in - whatever it happens to have, or in a fallback full of boxes. This is the - same face the PDF is set in, shipped with the app, so the picture looks the - same wherever it is rendered." + "The java.awt.Font at classpath `path`, parsed once per path; nil if it will not load. + GOTCHA: ship the face; a logical family like SANS_SERIF depends on the host's fonts." (memoize (fn [path] (try @@ -202,17 +172,9 @@ (java.awt.Font. java.awt.Font/SANS_SERIF java.awt.Font/PLAIN (int size)))) (defn- draw-credit! - "Burn the artist credit into the picture itself. - - The page and the sheet can both carry a credit beside the portrait, but the - composed image is what people actually pass around -- saved off a share - card, pulled out of a PDF -- and it arrives detached from either. A caption - in the pixels travels with it. - - Dark fill under a white outline, because the background is unknowable: the - PNG is transparent, so it may land on a white page or a dark chat client, - and one of the two always reads. Not a watermark -- a crop removes it -- - just a credit that survives being right-click-saved." + "Draws `text` centred along the bottom of a `w`x`h` frame, outlined then filled, at + layout/credit-layout's metrics. Why it is in the pixels: PORTRAIT-COMPOSITOR.md, + \"The baked credit and the site mark\"." [^Graphics2D g text w h] (let [{:keys [size halo baseline] [or* og ob oa] :outline diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 6193e3353..6c0f2e78a 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -669,15 +669,10 @@ (as-> t (when (seq t) (if (> (count t) 78) (str (subs t 0 75) "...") t))))) (defn stamp-document-info! - "Set the exported sheet's own metadata. - - The templates are third-party InDesign files, so a fresh export inherits - their info dictionary and claims to have been made by 'Adobe InDesign CS6 - (Macintosh)' with an empty Author -- which is simply untrue, and is what a - digital-asset tool or a search index reads. Overwriting it costs nothing - and puts the art credit somewhere besides the picture. - - This is provenance, not protection: metadata strips in seconds." + "Overwrites `doc`'s info dictionary: title from `character-name` (or the default page + title), creator and producer the app name, and `credit` as subject and keywords when it + is set. Replaces the InDesign template's own metadata. Logs and continues on failure. + See PORTRAIT-COMPOSITOR.md, \"The baked credit and the site mark\"." [doc {:keys [character-name credit]}] (try (let [info (.getDocumentInformation doc)] @@ -1135,15 +1130,10 @@ ;; cached. (some-> (wanted url failed?) (as-> u (future (:image (probed-outcome u))))))) - ;; A composed (paper-doll) portrait has no URL that could produce - ;; it -- the client bakes its CSS-mask layers -- and it arrives - ;; larger than an uploaded picture may be, so it goes through - ;; decode-artwork-bytes, which fits it instead of refusing it. - ;; - ;; Decoded eagerly rather than in a delay: it is local CPU with no - ;; network in it, and a delay that derefs to nil would be truthy - ;; here, so a portrait that failed to decode would suppress the - ;; pasted image-url that should have taken over. + ;; A composed portrait arrives baked by the client, often over the upload + ;; limit, so decode-artwork-bytes fits it rather than refusing it. + ;; GOTCHA: decoded eagerly, not in a delay: a delay of nil is truthy and + ;; would suppress the pasted image-url fallback. composed (pdf/decode-artwork-bytes portrait-png) portrait (if composed (delay composed) @@ -1155,10 +1145,7 @@ 2 (pdf/draw-image-bytes! doc (pdf/get-page doc 1) data jpg? 0.45 1.75 2.35 3.15) 3 (pdf/draw-image-bytes! doc (pdf/get-page doc 1) data jpg? 0.45 1.75 2.35 3.15) 4 (pdf/draw-image-bytes! doc (pdf/get-page doc 0) data jpg? 0.50 0.85 2.35 3.15)) - ;; The credit is baked into the composed PNG itself now (see - ;; portrait/draw-credit!), so there is nothing to print here: a - ;; second drawn line landed 0.12in under the first and said the - ;; same thing. + ;; No credit line: portrait/draw-credit! bakes it into the composed PNG. ) (when-let [{:keys [data jpg?]} (some-> faction deref)] (case print-character-sheet-style? @@ -1166,11 +1153,8 @@ 2 (pdf/draw-image-bytes! doc (pdf/get-page doc 1) data jpg? 5.88 2.4 1.905 1.52) 3 (pdf/draw-image-bytes! doc (pdf/get-page doc 1) data jpg? 5.88 2.0 1.905 1.52) 4 nil)) - ;; Inside this let, so it can see whether the composed portrait was - ;; actually used. Keyed on the decode, not on the field being present: - ;; a portrait-png that fails to decode falls back to the pasted - ;; image-url above, and crediting the illustrator for somebody else's - ;; photograph is the one thing this feature must not do. + ;; Credit only when the composed portrait DECODED: one that did not fell back to + ;; the pasted image-url, and that picture is not the illustrator's. (stamp-document-info! doc {:character-name character-name :credit (when composed portrait-credit)})) (.save doc output)) @@ -1588,12 +1572,9 @@ {:status 200 :body character}))) (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." + "The share-card data for a character: the whole pull, {::se/summary … ::se/values …}. + GOTCHA: character-page destructures both keys, so returning only the summary submap + blanks every og:title and og:image." [db id] (d/pull db '[::se/summary @@ -1799,50 +1780,29 @@ [route-map/dnd-e5-orcacle-page-route]]) (def ^:private artwork-epoch - "Identifies THIS PROCESS's copy of the portrait art. - - The validator below hashes what the character chose, which says \"the same - selections\" but not \"the same pixels\": the layer PNGs and the registry that - names them are classpath resources, so they change with a DEPLOYMENT and not - with anything in the database. Mixing the epoch in means a restart - invalidates every portrait validator, which is exactly when the pixels can - have changed underneath one." + "This process's start time, mixed into portrait-etag so a restart, which is when the + classpath art can change, invalidates every portrait validator. See + PORTRAIT-COMPOSITOR.md, \"Caching the share-card PNG\"." (str (System/currentTimeMillis))) (defn portrait-etag - "A validator for a composed portrait: everything its pixels depend on. - - Route-local on purpose. The app's global etag-interceptor cannot do this one, - because it derives a validator from the response BODY and has no method for a - stream -- and teaching it one would hash every exported PDF, which shares that - body type, on every export. Here the key is the stored EDN, which is already - in hand before anything is rendered." + "A quoted strong ETag over everything a composed portrait's pixels depend on: the + artwork epoch, the `stored` EDN, the `credit` and the site `mark`. Computable before + rendering. Route-local; see PORTRAIT-COMPOSITOR.md, \"Caching the share-card PNG\"." [stored credit mark] (str "\"" (sha1 (str artwork-epoch "|" stored "|" credit "|" mark)) "\"")) (defn- any-representation-requested? - "Whether If-None-Match is the wildcard. - - RFC 7232 s3.2 gives the grammar as `\"*\" / 1#entity-tag`, so the wildcard is - the ENTIRE header value and can never be a list member. Reading it as one - meant a tag containing commas -- which the grammar permits inside the quotes - -- could be split into a bare `*` and fabricate a match, answering 304 to a - client holding a stale picture. A miss costs a render; this costs - correctness, so the two are not symmetrical." + "Whether the If-None-Match `header` is the wildcard. + GOTCHA: `*` is the whole header value, never a list member (RFC 7232 s3.2); splitting a + quoted tag on commas can invent one and answer 304 to a stale client." [header] (= "*" (some-> header s/trim))) (defn- covered-by-if-none-match? - "Whether an If-None-Match header lists `tag`. - - Entity tags are quoted strings and the grammar does not forbid a comma inside - one, so the tags are MATCHED OUT rather than split on -- splitting is what - invented a wildcard above. Comparison is the weak one RFC 7232 s3.2 specifies - for If-None-Match, so W/\"x\" covers \"x\"; the --gzip suffix some re-encoding - proxies append is dropped the way the app's own etag-interceptor drops it. - - The wildcard is deliberately NOT handled here: it asks whether a - representation exists at all, which cannot be answered before rendering one." + "Whether an If-None-Match `header` lists `tag`, by weak comparison (W/\"x\" covers \"x\"), + ignoring a --gzip suffix. Tags are matched out of the header, not split on commas. + The wildcard is not handled here; see any-representation-requested?." [header tag] (boolean (when (and (not (s/blank? header)) tag) @@ -1851,15 +1811,9 @@ (contains? offered (bare tag)))))) (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." + "Ring handler: PNG of character :id's composed portrait, for og:image. Unauthenticated, + like the character page, whose meta tags already expose the character. 304 when + If-None-Match covers portrait-etag; 404 when nothing composed is drawable." [{:keys [db headers] {:keys [id]} :path-params}] (let [stored (some-> (d/pull db '[{::se/values [::char5e/portrait]}] id) ::se/values @@ -1885,12 +1839,8 @@ {:status 304 :headers (dissoc png-headers "Content-Type")} :else - ;; The wildcard cannot take the path above. `*` asks whether a - ;; representation EXISTS, and a stored portrait selecting nothing drawable - ;; has none -- it 404s. Answering 304 there would tell a client its cached - ;; copy is current when the resource has no current copy at all. So it - ;; renders first and lets the outcome decide, which costs the saving on a - ;; wildcard request and keeps it on every request that names a tag. + ;; The wildcard is answered only after rendering: a portrait that draws nothing + ;; has no representation and must 404, not 304. (if-let [png (portrait-render/render-png portrait)] (if (any-representation-requested? (get headers "if-none-match")) {:status 304 :headers (dissoc png-headers "Content-Type")} @@ -1908,11 +1858,9 @@ ;; sheet, the summary and the PDF use. It is served as a real PNG ;; because crawlers will not render CSS masks -- or, mostly, SVG. parsed-portrait (char5e/parse-portrait portrait) - ;; drawable?, not (seq :layers). A selection naming only assets this - ;; deployment does not have is non-empty and draws nothing, so the card - ;; pointed at /portrait.png, the renderer returned 404, and the link - ;; previewed broken -- even when the character had a usable image-url - ;; to fall back on. + ;; drawable?, not (seq :layers): a selection of assets this deployment lacks is + ;; non-empty, draws nothing, and would point the card at a 404 instead of + ;; falling back to image-url. composed? (portrait-assets5e/drawable? parsed-portrait) share-image (if composed? (str "https://" host diff --git a/src/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc index fa0056064..6a6fbb999 100644 --- a/src/cljc/orcpub/dnd/e5/artist_profile.cljc +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -1,15 +1,10 @@ (ns orcpub.dnd.e5.artist-profile - "What an artist's public profile page shows, as data. - - The page is the site's showcase of a portrait artist -- their name, the - pieces they drew, a few portraits made from those pieces, and the links they - listed. It is not their home: the credit's name still links wherever they - chose (:artist/link), and the profile is reached by a second, quieter link. - - Everything here reads the registry in portrait-assets, with the deployment - overrides the credit already honours. Nothing is stored and nothing is tied - to a site account. Shared by the server (share tags and the og image) and - the browser (the page), so the two cannot disagree." + "What an artist's public profile page (/artists/) shows, as data: name, pieces, + example portraits and listed links. Reads only the portrait-assets registry and its + overrides; nothing is stored or tied to a site account. Used by the server (share tags, + og image) and the browser (the page). Entry points: `profile`, `artist-by-slug`, + `example-portraits`, `listed-links`, `profile-link`. + Plan and decisions: docs/design/artist-profiles/PLAN.md." (:require [clojure.string :as s] [orcpub.route-map :as route-map] [orcpub.dnd.e5.portrait-assets :as pa])) @@ -132,15 +127,10 @@ (nth v (int (Math/floor (* (rnd) (count v)))))) (defn example-portraits - "A few portraits made from the artist's pieces, the same on every render. - - Each layer they drew uses their pieces, rotated from a per-layer starting - point so the examples differ from one another and, across them, show every - piece a layer has when there are enough examples to. A layer they did not - draw is filled from the registry so the picture is whole; credit-order will - name whoever drew it. Colours come from the picker's presets. - - Seeded from the slug, so the page, the og image and every reload agree." + "`n` (default example-count) portraits built from `artist-id`'s pieces, or nil when the + profile is off or unknown. Each of their layers rotates through their pieces; other + layers are filled from the registry; colours come from the presets. Seeded from the + slug, so every render agrees." ([artist-id] (example-portraits artist-id example-count)) ([artist-id n] (when-let [artist (profile artist-id)] diff --git a/src/cljc/orcpub/dnd/e5/character.cljc b/src/cljc/orcpub/dnd/e5/character.cljc index 1f8fee9f6..88124bb89 100644 --- a/src/cljc/orcpub/dnd/e5/character.cljc +++ b/src/cljc/orcpub/dnd/e5/character.cljc @@ -699,15 +699,10 @@ (get-prop built-char ::image-url)) (defn- sane-portrait - "Drop sub-values that are not the shape the compositor reads. - - Checking only that the OUTER value is a map was not enough. A stored - `{:layers \"abc\"}` is valid EDN and passed, and the non-map :layers then - reached credit-line, which walks it as a selection: three of four malformed - shapes threw (a string, a vector and a number), and character-page calls - credit-line unguarded, so a crafted save took the PUBLIC character page down - with a 500. Entry-level junk is already harmless -- (:asset/id 42) is nil -- - so only the containers need checking." + "`m` with any non-map :layers, :colors or :tweaks removed; nil when `m` is not a map. + GOTCHA: the containers must be checked, not just `m`: a stored {:layers \"abc\"} made + credit-line throw on the public character page. See PORTRAIT-COMPOSITOR.md, + \"Saved portraits are untrusted\"." [m] (when (map? m) (cond-> m @@ -716,15 +711,9 @@ (not (map? (:tweaks m))) (dissoc :tweaks)))) (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." + "A stored portrait, an EDN string or an in-memory map, as {:layers {…} :colors {…} + :tweaks {…}} through sane-portrait; nil for blank or malformed input, never a throw. + Stored as a string because ::se/values cannot hold a nested map (db/schema.clj)." [v] (cond (map? v) (sane-portrait v) diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc index 5dd04cc93..9fc76c7d0 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -1,20 +1,10 @@ (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 each selected layer as a CSS mask tinted by the - character's colours, absolute-positioned in a common frame. - - `asset-inventory` is the illustrator's real set: ten layers, twenty-eight - pieces, their own filenames. The files under - resources/public/image/portraits/ are currently silhouettes at the paths - the finished art will occupy; a production run overwrites them in place. - - 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`." + "Registry of portrait layer art per contributing artist, the colour model, and the credit. + Assets are {:asset/id … :asset/url …}, stacked in `layer-order` and tinted per + `render-mode`. Attribution resolves each selected asset to its artist through + `registry`: see `credit-order` and `credit-line`, shared by every surface. + GOTCHA: the files in the repo are silhouettes; the real art is never committed. + Why the code is shaped this way: PORTRAIT-COMPOSITOR.md, PORTRAIT-TINTING.md." (:require [clojure.string :as s])) (def layer-order @@ -23,13 +13,9 @@ [:hair-bits :hair-back :head :scalp :shirt :hair-front :ears :eyes :nose :mouth :bangs]) (def hidden-layers - "Layers that render but are not offered in the picker. - - :scalp is a plain blob that sits between the head and the hair and takes the - hair colour. Some hair-front and bangs pairs do not meet, leaving an island - of skin on the crown with hair all round it; this fills it. There is nothing - to choose, so putting it in the picker would only add a control with one - option that changes nothing." + "Layers that render but are not offered in the picker. :scalp is one hair-coloured blob + between head and hair that fills the skin gap where some hair-front and bangs pairs do + not meet; with one option there is nothing to pick." #{:scalp}) (def pickable-layers @@ -74,14 +60,10 @@ "/image/portraits/") (def asset-inventory - "The illustrator's real inventory: ten layers, twenty-eight pieces, their - own filenames and counts. - - The files currently on disk are SILHOUETTES -- the Loom's 48px alpha - shapes, un-squashed back to the source proportions and written to the paths - the finished art will occupy. Real shapes and real arrangement, no line - detail. Running scripts/build-portrait-assets.py over the originals - overwrites these same paths and nothing here changes." + "{layer-key [{:asset/id :asset/label :asset/file}]}: the illustrator's ten layers and + twenty-eight pieces under their own filenames, plus the generated :scalp. + GOTCHA: the files in the repo are silhouettes at the real art's paths; + scripts/build-portrait-assets.py overwrites them from the originals." { :hair-bits [{:asset/id :l0-hair-bits-pony-long @@ -189,16 +171,9 @@ :asset/file "l9_bangs_03.png"}]}) (def gap-inventory - "Pieces the illustrator has planned but not drawn yet, per layer. - - Her convention is a `no .txt` file sitting in the layer folder beside - the art. Both asset scripts already read those and report them, so filling - this in is a transcription job, not a guess -- run either one and it prints - the gaps it found. - - The picker shows these as inert \"coming\" swatches. Empty is fine and means - only that nothing is marked pending; it is not a claim that the set is - finished." + "{layer-key [{:gap/id :gap/label}]}: pieces planned but not drawn, shown in the picker as + inert \"soon\" swatches. Transcribe from the `no .txt` files the asset scripts + report. Empty means nothing is marked pending, not that the set is finished." {} ;; shape, when there is something to record: ;; {:head [{:gap/id :l2-head-04 :gap/label "Head 04"}] @@ -241,26 +216,11 @@ ;; break every link already shared. See artist-profile/slug. :artist/slug "fusspot" :artist/name "Fusspot" - ;; The single link, for surfaces that can only hold one -- the character - ;; page credit is a 100px strip. Her homepage, because it is the list she - ;; maintains, so it cannot go stale the way a copied handle can. + ;; The one link for surfaces that hold one, such as the character page's credit strip. :artist/link "https://fusspot.rip/" - ;; The full set, for the builder, which has room. Chosen with her rather - ;; than harvested: not every handle she has, the ones she wants on this. - ;; Twitch is where she does most of the work, so it is not an afterthought - ;; here -- someone who liked the art can go and watch it being made. - ;; :link/icon names a mark in /image/social. A link with no icon falls back - ;; to its label as text, so adding a service nobody has drawn yet degrades - ;; to a word rather than to an empty box. - ;; Her site and her Twitch, which is what she asked for. The bluesky and - ;; kofi marks stay in /image/social -- they cost nothing sitting there and - ;; the next artist may want them. - ;; :link/color is the mark's own brand colour, and it is worn at REST, not - ;; only on hover. A credit rendered in the same quiet grey as everything - ;; around it is a credit designed to be skipped; the eye finds a purple - ;; Twitch mark and slides straight past a grey globe. Site takes the app's - ;; amber because her homepage has no mark of its own and it is the primary - ;; link. + ;; Exactly the links she asked for; add none she did not list. :link/icon names a mark + ;; in /image/social (no icon shows the label as text); :link/color is the mark's colour, + ;; worn at rest. Reasons: docs/design/artist-profiles/PLAN.md, "Fusspot's links". :artist/links [{:link/label "Site" :link/icon "site" :link/color "#f0a100" :link/url "https://fusspot.rip/"} @@ -323,14 +283,10 @@ (defonce ^:private artist-overrides (atom {})) (defn set-artist-overrides! - "Replace the runtime artist overrides. `m` is {artist-id {field value}}; - only :artist/name, :artist/link, :artist/links, :artist/license and - :artist/profile? are honoured. - - :artist/profile? false turns the artist's public profile page off (see - artist-profile/profile). It is here, beside the credit fields, so whatever - ends up owning the artist's choice -- a deployment's config today, the - artist's own account setting later -- can flip it without touching code." + "Replace the runtime artist overrides. `m` is {artist-id {field value}}; only + :artist/name, :artist/link, :artist/links, :artist/license and :artist/profile? are + kept. :artist/profile? false turns the artist's profile page off (artist-profile/profile). + See PORTRAIT-COMPOSITOR.md, \"Deployment overrides\"." [m] (reset! artist-overrides (into {} @@ -361,13 +317,9 @@ (if-not (map? layers-selection) [] (let [in-use? (into #{} - ;; Resolve through the registry by asset id first, and - ;; only then fall back to whatever artist the saved - ;; portrait names. A portrait is client-supplied data - ;; stored as EDN: trusting its :artist/id meant deleting - ;; that one key detached the credit from art that is - ;; plainly in the registry -- on the page, on the sheet, - ;; and in the picture itself. + ;; Registry by asset id first, the saved :artist/id only as + ;; a fallback: a saved portrait is client data. See + ;; PORTRAIT-COMPOSITOR.md, "Saved portraits are untrusted". (keep (fn [[layer-key sel]] (or (artist-for-asset layer-key (:asset/id sel)) (:artist/id sel)))) @@ -377,12 +329,9 @@ registry)))) (defn drawable-layers - "The entries of a selection whose asset ids actually resolve in the registry. - - A selection can be non-empty and still draw nothing: an id that no longer - exists, or one from a pack this deployment does not carry. `seq` on the raw - selection cannot tell the difference, which is how a share card came to - advertise a portrait the renderer then 404ed on." + "The entries of a selection whose asset ids resolve in the registry; nil for a non-map. + GOTCHA: a non-empty selection can draw nothing (a removed id, a pack this deployment + lacks), so test drawable?, not `seq`." [layers-selection] (when (map? layers-selection) (into {} @@ -396,17 +345,9 @@ (boolean (seq (drawable-layers (:layers portrait))))) (def layer-credit-weight - "How much of a portrait each layer is, for ordering the credit. - - Roughly by area and by how much the piece defines the character: the head - is the foundation everything else sits on, the hair is most of the - silhouette, a nose is a few strokes. Fixed numbers rather than measured - pixel area, so the browser and the server agree without loading a single - image, the numbers can be read and tuned, and a large mostly transparent - asset cannot climb the list. - - The scalp is 0: it is a generated patch under the hair, not a drawing. - A layer missing from this map counts 1." + "How much of a portrait each layer is, for credit-order: roughly area and how much the + piece defines the character. :scalp is 0 (generated, not drawn); a layer missing from + the map counts 1. Why fixed numbers: PORTRAIT-COMPOSITOR.md, \"Who the credit names\"." {:head 10 :hair-front 6 :hair-back 6 @@ -420,20 +361,10 @@ :scalp 0}) (defn credit-order - "The artists on canvas, in the order a credit should name them: most of the - picture first, like the ingredients on a label. - - Ranked by the total `layer-credit-weight` of the pieces each drew, then by - how many pieces, then by registry order so the credit never reshuffles - between two renders of the same portrait. Weight comes before count because - count misleads: someone who drew the ears, nose and hair bits has three - pieces and one who drew the head and shirt has two, and the second drew the - face. - - Pieces are attributed the way `all-artists-for-layers` attributes them -- - through the registry by asset id first -- and an artist not in the registry - is left out, as there. Each artist map gains :credit/weight and - :credit/pieces." + "Artist maps (artist-info) for the artists on canvas, most of the picture first: by total + layer-credit-weight, then piece count, then registry order. Each gains :credit/weight + and :credit/pieces. Attribution as all-artists-for-layers; artists not in the registry + are left out. [] for a non-map selection." [layers-selection] (if-not (map? layers-selection) [] @@ -472,15 +403,9 @@ 4) (defn credit-mark-layout - "Where an artist's link marks sit around their name in the credit. - - Marks flank the name only when they can do it symmetrically -- an even - count, up to `max-flanking-marks`, split evenly. Anything else (one link, - three, more than four) goes on a centred line under the name instead. Both - outcomes balance on the name; the credit's broken rule is symmetric, and a - lone mark on one side of it reads as a mistake. - - Returns {:left [...] :right [...] :below [...]}, links in their given order." + "Where an artist's link marks sit around their name: {:left [...] :right [...] :below + [...]}, links in their given order. An even count up to max-flanking-marks is split + either side of the name; any other count goes below, so the credit always balances." [links] (let [links (vec links) n (count links)] @@ -497,13 +422,9 @@ (str "Art: " (s/join ", " names)))) (defn credit-line - "One-line attribution for a composed portrait. - - nil when nothing is composed, or when no artist on canvas has said how they - want to be credited -- an unnamed artist is skipped rather than given an - invented byline. Every caller can `when-let` and drop the surface entirely. - Shared by the PDF export, the share card and the character summary so all - three credit the same people the same way." + "\"Art: A, B\" for a composed portrait's named artists in credit order, or nil when none + is named. Artists without :artist/name are skipped, never given a byline. Shared by the + PDF export, the share card and the character summary." [portrait] (format-credit (into [] (keep :artist/name) (artists-for-layers (:layers portrait))))) @@ -567,23 +488,16 @@ ;; ---------- 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. +;; A portrait is {:layers {…} :colors {slot hex} :tweaks {layer {:shade n :override hex}}}. +;; :colors paints every layer in a slot; :tweaks shades one piece or overrides its colour. +;; Each renderer applies `tint-for` as `render-mode` says: PORTRAIT-TINTING.md. (def empty-portrait {:layers {} :colors {} :tweaks {}}) (def color-slots - "Which color slot each LAYER draws its base tint from. nil = the layer has no - slot of its own and falls back to its category tint. - - A layer-wide rule is not enough for every layer. The mouth carries three - assets that want three different things -- a bare line, lips, and teeth -- - so the lipped one names its own slot with :asset/slot and slot-for-asset - prefers that. See `lips` in color-slot-order." + "The colour slot each LAYER draws its base tint from; nil means no slot (category tint). + An asset's own :asset/slot wins over its layer's (slot-for-asset): the mouth's lipped + piece names :lips while its line and teeth have none." {:hair-bits :hair :hair-back :hair :hair-front :hair @@ -714,17 +628,10 @@ (layer-colors layer-key)))) (defn render-mode - "How a renderer should apply `base-tint` to this asset. - - :multiply -- the default. Lay the colour down through the art's alpha and - multiply the drawing back over it, so lines stay lines. - :colorize -- map the art's own luminance through the colour. For art drawn - as a hueless ramp: the irises, and the lips. - :as-drawn -- do not tint at all. Teeth are not lips. - - Assets that name a conditional slot are colorized, because that is what - naming one is for; an asset with no slot at all is left alone. Returning a - MODE rather than a boolean is what lets the mouth's three pieces differ." + "How a renderer tints `asset` in `layer-key`: :multiply (the default; lines stay lines), + :colorize (luminance mapped through the colour, for the eyes slot and conditional slots + such as :lips), or :as-drawn (no slot: left as the illustrator drew it, like the teeth). + See PORTRAIT-TINTING.md." [layer-key asset] (let [slot (slot-for-asset layer-key asset)] (cond @@ -734,14 +641,9 @@ :else :multiply))) (defn tint-gamma - "The luminance gamma for a :colorize asset. - - Not one number, because the art it applies to sits at opposite ends of the - tonal range: the irises are drawn near-black and need a gamma below 1 to - lift them off the floor, while the lips are drawn light -- 75% of that - asset is above the ramp's midpoint -- and need one above 1 to pull them - down into the colour. A single value washes out whichever it was not - chosen for." + "The luminance gamma for a :colorize asset: its :asset/gamma, else 2.2 for lips (drawn + light) and 0.5 otherwise (irises, drawn near-black). See PORTRAIT-TINTING.md, \"Floor + and gamma\"." [layer-key asset] (or (:asset/gamma asset) (if (= :lips (slot-for-asset layer-key asset)) 2.2 0.5))) diff --git a/src/cljc/orcpub/dnd/e5/portrait_layout.cljc b/src/cljc/orcpub/dnd/e5/portrait_layout.cljc index ea194fc08..91d964c79 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_layout.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_layout.cljc @@ -1,30 +1,13 @@ (ns orcpub.dnd.e5.portrait-layout - "Where the pieces of a composed portrait land. - - THREE renderers draw the same picture and cannot share drawing code: the - drawer stacks CSS-masked divs in the DOM, the export bakes a canvas in the - browser, and the share card is composed in Java2D because a crawler runs no - JavaScript. What they can share is every NUMBER, and until this namespace - existed they did not. - - Each divergence cost a bug or a visible difference: - - * both rasterizers stretched each layer to fill the frame while the DOM - composites with `mask-size: contain`, so a shared portrait and a printed - sheet came out 10% wider than the face the drawer showed; - * the credit was sized with `round` in the browser and `int` on the server, - so at a 750px frame it was set at 20px with a 2px halo in a PDF and 19px - with a 1px halo on a share card -- the same caption, tuned twice. - - So the geometry lives here once, and each renderer asks rather than derives. - Nothing in here draws; it returns numbers, in frame pixels.") + "The geometry shared by the three portrait renderers (DOM drawer, browser canvas export, + Java2D share card): where a layer lands and where the credit and site mark sit, in frame + pixels. Nothing here draws. GOTCHA: a renderer must ask here, never derive its own + numbers. See PORTRAIT-COMPOSITOR.md, \"Three renderers, one geometry\".") (defn contain-rect - "Where a `sw`x`sh` asset lands in a `w`x`h` frame under `mask-size: contain` - -- scaled to fit, centred, aspect kept. Returns [x y w h]. - - This is the DOM's behaviour, and the DOM is the version a person is looking - at while they choose, so it is the one the other two have to match." + "[x y w h] where a `sw`x`sh` asset lands in a `w`x`h` frame: scaled to fit, centred, + aspect kept, as the DOM's `mask-size: contain` places it. The whole frame for a + zero-sized asset." [sw sh w h] (if (or (zero? sw) (zero? sh)) [0 0 w h] @@ -37,21 +20,14 @@ dh]))) (def credit-font-family - "The family the baked credit is set in. The browser reaches it through an - @font-face and the server loads the same file off the classpath, but the name - is one fact." + "The family the baked credit is set in: a webfont in the browser, the same file off the + classpath on the server." "Vollkorn") (defn credit-layout - "Metrics for the artist credit burned along the bottom of a `w`x`h` frame. - - `:outline` and `:fill` are [r g b a] with a out of 255, because Java2D wants - it that way and a canvas can divide. Dark fill under a light outline: the PNG - is transparent, so it may land on a white page or a dark chat client, and one - of the two always reads. - - Rounded, not truncated -- the browser rounded and the server truncated, and - the browser is what someone watched while they picked." + "Metrics for the credit along the bottom of a `w`x`h` frame: {:size :halo :center-x + :baseline :outline :fill}. Colours are [r g b a] with a out of 255 (alpha->unit for a + canvas): dark fill under a light outline. Sizes are rounded, never truncated." [w h] (let [size (max 9 (Math/round (* h 0.026)))] {:size size @@ -62,16 +38,10 @@ :fill [20 20 20 235]})) (defn site-mark-layout - "Metrics for the site's own mark running up the RIGHT edge of a `w`x`h` frame. - - Deliberately on a different edge from the artist credit: two marks in one - caption band would give anyone who wants the advertising gone a reason to - crop her name off with it. On opposite edges the cheap crop takes this and - leaves her. - - `:x` is the baseline's distance from the left. Where it starts vertically - depends on the rendered text width, which only a renderer with font metrics - knows, so that stays with the renderer." + "Metrics for the site mark running up the RIGHT edge of a `w`x`h` frame: {:size :x + :outline :fill}, `:x` the baseline's distance from the left. The vertical start needs + font metrics, so the renderer computes it. Kept off the credit's edge: see + PORTRAIT-COMPOSITOR.md, \"The baked credit and the site mark\"." [w h] {:size (max 8 (Math/round (* h 0.020))) :x (- w (max 4 (Math/round (* w 0.022)))) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 86b668fa0..1cbc922ae 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -223,13 +223,10 @@ :portrait/open-slot :portrait/open-layer]) (defn drop-portrait-draft - "Clear the draft and count the change. - - :character-epoch is bumped in step, so it counts how many times the character - being edited has been REPLACED. A save is dispatched at one epoch and answered - at another, and that is the only way the answer can tell whether the character - it is about is still the one on screen -- an id cannot, because a first save is - where the id appears." + "`db` without the portrait draft keys, with :character-epoch incremented. The epoch + counts replacements of the character on screen; saves compare it (see + saved-character-is-the-one-being-edited?) because an id cannot tell a first save from + a switch." [db] (-> (apply dissoc db portrait-ui-keys) (update :character-epoch (fnil inc 0)))) @@ -539,26 +536,10 @@ (def dnd-5e-characters-path [:dnd :e5 :characters]) (defn saved-character-is-the-one-being-edited? - "Whether a save that has just come back is about the character on screen. - - It is decided from what the save was dispatched WITH, because nothing in the - response can answer it. A save is asynchronous, and the autosave queue is - throttled, so the answer routinely arrives after the builder has moved on -- - toggle a prepared spell on one character from a list and the queued save for - it lands while another is open in the builder. - - The two save paths know different things, so they say different things: - - * the manual save posts db :character, so it passes the :epoch it was - dispatched at. A first save is exactly where the id appears, so an id - cannot distinguish 'the same character, now saved' from 'a different - character'; the epoch can, because it counts replacements. - * the autosave posts a character from the map BY ID, which may never have - been the one on screen, so it passes that :for-id to be compared. - - Neither given (a bare re-dispatch) answers false, which is the safe way to be - wrong: a stale answer then updates the character map and leaves the screen - alone." + "Whether a returning save is about the character on screen, from the context it was + dispatched with: `:for-id` (autosave, by id) is compared to the on-screen id, `:epoch` + (manual save) to :character-epoch. Neither given answers false, which leaves the screen + alone. Why: PORTRAIT-COMPOSITOR.md, \"The draft and the character on screen\"." [db {:keys [epoch for-id]}] (cond (some? for-id) (= for-id (:db/id (:character db))) @@ -567,12 +548,9 @@ (reg-event-fx :character-save-success - ;; ARGUMENT ORDER IS THE EFFECT'S, NOT THE CALLER'S. The :http effect - ;; dispatches (conj on-success response), so the response is APPENDED -- it - ;; arrives LAST, after whatever the caller put in its :on-success vector. - ;; Reading them the other way round bound the save context as the response, - ;; so (:body ...) was nil and EVERY successful save silently failed to install - ;; the character it got back, the first save's new :db/id included. + ;; GOTCHA: the :http effect APPENDS the response to the :on-success vector, so it + ;; arrives last, after the save context. Swapped, every save silently fails to + ;; install the character it got back. (fn [{:keys [db]} [_ save-context response]] (let [strict-character (:body response) character (char5e/from-strict strict-character) @@ -585,18 +563,13 @@ (str "Saved “" char-name "”") "Your character has been saved.")]] - ;; The character on screen is only replaced when the save was ABOUT it. - ;; It used to be replaced by whatever came back: the autosave queue is - ;; throttled by 7.5s and every sheet control feeds it, so adjusting hit - ;; points on one character and opening another inside that window - ;; swapped the builder out from under you -- and db :character is where - ;; unsaved edits live, so they went with it. The portrait draft was only - ;; the loudest symptom. + ;; Only a save ABOUT the character on screen replaces it: a throttled autosave + ;; can land after the builder has opened another one, whose unsaved edits + ;; would go with it. current? (conj [:character-updated character]) ;; Always: the saved character belongs in the map whoever is on screen. - ;; This is the half of the old behaviour that was right. true (conj [::char5e/set-character id character]))}))) @@ -2160,22 +2133,11 @@ :character-healed (when (seq rewrote) {:rewrote rewrote}))) (defn set-character - "Put a DIFFERENT character on screen: opening one, cloning, randomizing, New. - - The portrait draft is dropped, unconditionally and without asking anything - about ids. Three review rounds went into guards that tried to work out - afterwards whether a replacement was really a switch, and each guard was - wrong in a way the next one found: a first save looks like a new id, two - never-saved characters share a nil one. The information was never in the - characters -- it is in which event the caller meant, so that is now what - they choose. Modifying the character on screen is :character-updated. - - The one exception is a courtesy and nothing depends on it: re-opening the - SAME saved character (Edit, from a list, on the one already in the builder) - keeps the draft, so that is not a way to lose work. Unlike the guards it - replaced, this comparison cannot be wrong -- two different characters cannot - share a non-nil id, and a nil one is not a match. Nothing about a save - reaches it any more, so please do not make it load-bearing again." + "The :set-character db fn: put a DIFFERENT character on screen (open, clone, randomize, + New), reconciled, dropping the portrait draft. Modifying it is :character-updated. + Re-opening the same saved (non-nil) id keeps the draft, as a courtesy only. + GOTCHA: do not infer a switch from ids; the caller's choice of event decides. + See PORTRAIT-COMPOSITOR.md, \"The draft and the character on screen\"." [db [_ character]] (let [id (:db/id character) same-saved-character? (and (some? id) (= id (:db/id (:character db))))] @@ -2354,13 +2316,9 @@ ;; ---- portrait compositor drawer ---- ;; -;; 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. +;; Drawer state lives at the top of app-db under :portrait/*, not in the character. +;; Only :portrait/save writes back: an EDN string to ::char5e/portrait (see +;; char5e/parse-portrait for why a string). (defn- seed-portrait-draft "Reset the draft to whatever the character currently has saved. diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index 1bb35eac7..8da3d770d 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -1,19 +1,9 @@ (ns orcpub.dnd.e5.portrait - "Paper-doll character-portrait compositor. - - 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: 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." + "The portrait compositor UI: `drawer`, the builder's `tab-panel`, `composite` (also used + by the character summary) and `rasterize` (the PNG for PDF export). Edits a draft at + app-db :portrait/draft, in portrait-assets' shape; :portrait/save writes it back. + Each layer is a div tinted by background-blend multiply, clipped to the asset by a mask. + See PORTRAIT-COMPOSITOR.md and PORTRAIT-TINTING.md." (:require [re-frame.core :refer [dispatch subscribe]] [reagent.core :as r] [clojure.string :as s] @@ -46,17 +36,10 @@ :pointer-events "none"}) (defn- mask-style - "A tinted layer, in CSS. - - The art is laid down as a background IMAGE with the colour behind it and - `background-blend-mode: multiply`, rather than as a mask over a flat colour. - A mask keeps only the asset's alpha and discards its drawing, so every piece - of line art rendered as a flat silhouette of itself. Multiply keeps the - lines and matches what the export and the share card now do. - - The mask is still there underneath, clipping the blend to the asset's own - shape -- without it the colour would fill the whole box, because a blend - mode applies everywhere the background does." + "Style map for asset `url` tinted `tint` at z-index `z`: the art multiplied over the + colour by `background-blend-mode`, then masked by the same image. + GOTCHA: the mask is what clips the colour to the asset; without it the tint fills the + whole box. Why multiply: PORTRAIT-TINTING.md." [url tint z] {:position "absolute" :inset 0 :width "100%" :height "100%" :background-color tint @@ -96,11 +79,8 @@ ;; ---------------- 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. +;; The export bakes the layers on a canvas with the same placement (layout/contain-rect) +;; and tint (multiply, then the asset's alpha restored) as the drawer. (def ^:private raster-width 600) (def ^:private raster-height 750) ;; 4:5, matching the on-screen frame @@ -116,14 +96,9 @@ (set! (.-src img) url))))) (defn- draw-credit! - "Burn the artist credit into the baked picture. - - The sheet and the page can each carry a credit beside the portrait, but the - composed image is what gets passed around -- saved off a share card, lifted - out of a PDF -- and it arrives detached from both. A caption in the pixels - travels with it. Dark fill under a white outline, because a transparent PNG - may land on a white page or a dark chat client and one of the two always - reads." + "Draws `text` on canvas context `ctx` along the bottom of a `w`x`h` frame, stroked then + filled, at layout/credit-layout's metrics. The face must already be loaded. + Why it is in the pixels: PORTRAIT-COMPOSITOR.md, \"The baked credit and the site mark\"." [ctx text w h] (let [{:keys [size halo center-x baseline outline fill]} (layout/credit-layout w h) rgba (fn [[r g b :as c]] @@ -191,15 +166,8 @@ (.-naturalHeight img) raster-width raster-height)] (.drawImage tctx img x y dw dh)) - ;; Tint by MULTIPLY, not by masking. source-in reads the - ;; asset's alpha and throws its RGB away, so line art came - ;; out as one flat colour -- the same picture as its own - ;; silhouette. Multiply keeps the lines: white fill takes - ;; the colour, black lines stay black. - ;; - ;; :as-drawn skips it entirely. Teeth are white because - ;; they were drawn white; tinting them through the mouth's - ;; category colour is what made them red. + ;; Tint by multiply so the lines survive (PORTRAIT-TINTING.md); + ;; :as-drawn assets, such as the teeth, are not tinted at all. (when-not (= :as-drawn (pa/render-mode layer-key asset)) (set! (.-globalCompositeOperation tctx) "multiply") (set! (.-fillStyle tctx) (pa/tint-for portrait layer-key)) @@ -230,27 +198,10 @@ ;; ---------------- drawer chrome ---------------- (def empty-slot-styles - "The empty portrait slot. - - It used to be lit by a radial gradient -- a spotlight centred at 50% 35% -- - which read as a different design language from everything around it: the app - panels are flat or top-down, and the accents are amber. A centred glow in a - cool slate belongs to some other screen. - - So the ground is a shallow top-down gradient like the app's other panels, and - what fills the space is the site's own logo, debossed: masked to 5% white with - a one-pixel dark shadow under it, so it reads as a detail lifted out of the - wall rather than a picture in its own right. Loud enough that the slot is not - empty-looking, quiet enough that it never competes with the art that replaces - it. - - The mark comes from branding/logo-path, not a literal, so a fork gets its own - rather than ours -- the same reason the site mark on a shared portrait is read - from branding. - - Only the EMPTY states carry it: .pl-empty-hint and .pl-thumb-empty both render - solely when there is nothing composed, so a portrait never has a logo showing - through its transparent parts." + "CSS for the empty portrait slot: a top-down gradient ground with the site logo + (branding/logo-path) debossed at 5% white. Only .pl-empty-hint and .pl-thumb-empty + carry the logo, and both render only when nothing is composed. + Why: PORTRAIT-COMPOSITOR.md, \"The drawer's chrome\"." (str " .pl-portrait-frame, .pl-thumb-empty { background: linear-gradient(180deg, #1b2230 0%, #141a25 100%); @@ -1077,15 +1028,9 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } [:div.pl-layer-panel [sub-row portrait layer-key]])])) (defn- linked-credit - "The credit line with each named artist turned into a link where one is - known. - - The drawer has done this since it was built; this is the character page, - which is where the art actually travels. A share link, a PDF and a baked - PNG can only carry the name as text -- an image cannot hold a link and a - social unfurl renders its description as plain text -- so this is the one - surface downstream of the builder that can send someone to the artist, and - it was printing a dead string." + "The character page's credit: \"Art: \" and each named artist, linked to :artist/link + where they have one. Plain `credit` when nobody has a link. The one surface after the + builder that can link; see PORTRAIT-COMPOSITOR.md, \"Who the credit names\"." [portrait credit] (let [named (filter :artist/name (pa/artists-for-layers (:layers portrait))) linked (filter :artist/link named)] @@ -1106,10 +1051,8 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } ;; ---------------- the artist credit ---------------- ;; -;; Settled after an exploration whose screenshots are stashed at -;; docs/design/portrait-credit on agents/develop: a small label, then each -;; artist's name on a broken rule, the words in one typeface and nothing else -;; decorated but the rule and the link marks' own colours. +;; A small label, then each name on a broken rule, decorated only by the rule and the +;; link marks' colours. Design captures: docs/design/portrait-credit on agents/develop. (defn- credit-mark "One of an artist's links, as its service's mark in the link's own colour. @@ -1145,13 +1088,9 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } [credit-name a]]))) (defn- credit-lockup - "Small label, then the artists in credit order (pa/credit-order): whoever - drew most of the picture on the rule, with their link marks as its end caps - when those balance and centred below when not (pa/credit-mark-layout); then - everyone else on one line -- 'with A, B and C' -- each name its artist's - link. Height stays at three or four lines however many artists there are, - and the lead keeps the marks because the lead is who most people looking - at the picture want to find." + "The credit for `named` artists in credit order: an \"Art by\" label; the lead on the + rule with their marks placed by pa/credit-mark-layout; \"with A, B and C\" for the rest; + then the profile link. Three or four lines however many artists there are." [named] (let [[{:keys [:artist/name :artist/links] :as lead} & others] named {:keys [left right below]} (pa/credit-mark-layout links)] @@ -1211,10 +1150,7 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } [composite portrait] [:div.pl-empty-hint "Pick a layer below, or hit " [:em "Randomize"] "."])] - ;; Directly under the art it describes. It used to sit in the footer - ;; beside Save Portrait, which put a credit in a row of actions and made - ;; the drawer disagree with the character summary, where it has always - ;; sat under the thumbnail. + ;; Directly under the art, as on the character summary; not among the actions. [attribution (:layers portrait)] [:div.pl-toolbar [:button.pl-btn.pl-btn-primary @@ -1232,17 +1168,10 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } [category-picker portrait layer-key open-layer])]])) (defn drawer - "Renders the compositor drawer when :portrait/drawer-open? is truthy. - 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 and the inline Portrait tab, both of - which are 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." + "The compositor drawer, an overlay shown while :portrait/drawer-open?. Mount once at the + character-builder root. Its stylesheet mounts even when closed (it also styles the + launcher and the Portrait tab), and its root carries the theme class itself because it + sits outside .app. See PORTRAIT-COMPOSITOR.md, \"The drawer's chrome\"." [] (let [open? @(subscribe [:portrait/drawer-open?]) theme @(subscribe [:theme])] @@ -1275,16 +1204,9 @@ a.lk-name:active { text-shadow: var(--lk-glow), var(--lk-glow); } "Save portrait"]]]]]))])) (defn tab-panel - "The compositor rendered in place, as a builder tab. - - Same body as the drawer, different chrome: there is nothing to cancel back - to, so instead of Cancel/Save it carries Save with a dirty marker and a way - to pop the focused overlay -- which is the nicer place to work on a phone, - where the two columns stack into a long scroll. - - Needs its own .pl-root because the drawer stylesheet scopes the light theme - to that class; here it also happens to sit inside .app, but relying on that - would mean two different theme hooks for one stylesheet." + "The compositor in place, as the builder's Portrait tab: the drawer's body, with Full + screen (opens the drawer) and a Save that reads Saved when clean, instead of Cancel. + Ensures a draft on mount. Has its own .pl-root, the stylesheet's one theme hook." [] (r/create-class {:display-name "portrait-tab-panel" diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 33a9a14ed..c82bd528f 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -129,14 +129,10 @@ (when @blurred? (validation-messages messages))]))) (defn export-pdf - "Returns an onClick handler that generates and submits the PDF. - 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." + "Returns an onClick handler that generates and submits the PDF. plugin-data is + pre-subscribed by the calling component. A composed portrait is first baked with + portrait/rasterize and posted with its credit as :portrait-png and :portrait-credit, so + the submit is async. GOTCHA: a failed bake still submits, without the picture." [built-char id plugin-data & [options]] (fn [_] (let [field (.getElementById js/document "fields-input") diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs index 7cb8e56df..10358e338 100644 --- a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -1,15 +1,8 @@ (ns orcpub.dnd.e5.views.artist-page - "Public artist profiles: /artists and /artists/. - - The site's showcase of a portrait artist, in the drawer's vocabulary -- - the small uppercase label, the name in Vollkorn italic on a broken rule, - the link marks in their own colours, portrait frames that stay dark in both - themes. What the page shows is decided in artist-profile (cljc), which the - server also reads for the share tags. - - The page wears .pl-root so the drawer's stylesheet, its credit face and its - --lk-* theme variables apply unchanged; .ap-* below is only what a page has - and a drawer does not." + "Views for the public artist profiles, /artists and /artists/. What they show is + decided in artist-profile (cljc). The page wears .pl-root so the drawer's stylesheet, + credit face and --lk-* theme variables apply; .ap-* styles only what the drawer lacks. + Plan and look: docs/design/artist-profiles/PLAN.md." (:require [re-frame.core :refer [subscribe]] [orcpub.dnd.e5.artist-profile :as ap] [orcpub.dnd.e5.portrait :as portrait] From daebf75f7142df62bcceda8dcc6af3887a7d4ba8 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sat, 3 Oct 2026 08:33:31 +0000 Subject: [PATCH 43/91] Move the artist pages' stylesheet into Garden The .ap-* rules were a CSS string injected in a + +
    +
    +

    The artist pages, moved into Garden

    +

    The page's stylesheet was a CSS string in a style tag. It is now the artist-pages rules in styles/core.clj. Each pair is the same page captured before and after the move.

    + All 8 pairs are byte-identical +
    + +
    +
    + + From 858c4192c0f4ea5ed2ca2b751c2ad6331fd56e84 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 4 Oct 2026 02:57:18 +0000 Subject: [PATCH 45/91] Stop listing every art piece on the artist page The page is easy to scrape, and the owner asked that it not offer the art piece by piece. The Pieces grid, a full-size tile per asset, is removed with its Garden rules and the colour only it used. The lede still gives the count. The browser probe now checks that no piece is shown on its own as an image. Its baseline said 51 checks, but every check in it runs unconditionally and the probe as written makes 47; with the grid checks replaced it makes 45, the number recorded now. The examples still load the full-size layer files; PLAN.md records that as the open step, with the card layout choice. --- docs/design/artist-profiles/PLAN.md | 28 +++++++++++++------ docs/design/style-guide.md | 3 +- scripts/test/probe-baseline.json | 2 +- src/clj/orcpub/styles/core.clj | 22 +-------------- src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 16 ----------- test/browser/artist_profile_e2e.js | 12 ++++---- 6 files changed, 29 insertions(+), 54 deletions(-) diff --git a/docs/design/artist-profiles/PLAN.md b/docs/design/artist-profiles/PLAN.md index 14b9b15ea..c85766fa2 100644 --- a/docs/design/artist-profiles/PLAN.md +++ b/docs/design/artist-profiles/PLAN.md @@ -2,7 +2,7 @@ Branch: `claude/artist-profile-pages`, cut from `claude/character-portrait-generator-hOutO`. -A public page per portrait artist at `/artists/`: their name, the pieces they +A public page per portrait artist at `/artists/`: their name, how many pieces they drew, a few portraits composed from those pieces, and the links they listed. It is the site's showcase of them. It is not their home page, so the credit's name link still goes to wherever they chose (`:artist/link`), and the profile is reached by a second, @@ -35,9 +35,10 @@ the credit already honours. The pure functions go in a new cljc namespace, (the page) agree and both can be tested: - `slug`, `artist-by-slug`, `profile-path`: resolve a URL to an artist and back. -- `pieces-by-layer`, `piece-count`: what they drew, grouped in picker order. The - hidden `:scalp` is left out: `ARTISTS.md` says it is a generated patch, not - anyone's drawing. +- `pieces-by-layer`, `piece-count`: what they drew, grouped in picker order, for the + piece count in the lede and the share description. The hidden `:scalp` is left out: + `ARTISTS.md` says it is a generated patch, not anyone's drawing. The page does not + list the pieces (below). - `listed-links`: `:artist/links` (or `:artist/link` alone as a "Site" link when no labelled set exists), **http(s) only**. A `mailto:` or any other scheme is dropped, so an email address cannot reach the page even if one is put in the registry. @@ -175,10 +176,7 @@ to write it. When it is off: 1. **The credit on the character page.** The profile link is only in the drawer credit. The 100px credit under the portrait on the character page is where viewers see a credit, but adding a line there changes that strip. -2. **Piece tiles.** Every piece is drawn in the full portrait frame, so small pieces (nose, - mouth, eyes) are specks in their tiles. Cropping each tile to the piece's own bounds - would fix it. That needs either bounds stored in the registry or measuring at load. - It is worth doing once the real art is in. +2. **Piece tiles.** Withdrawn: the page no longer lists pieces (below). 3. **Example colours** are picked from the picker's presets by a seed. An artist may want to choose their own showcase portraits instead, which could be a registry field or an account feature later. @@ -191,3 +189,17 @@ to write it. When it is off: 6. **Signing:** commits are GPG-signed as codeGlaze with a key generated in this container. GitHub will show them as Unverified until the public key is added to the account. + +## Decided: the page does not list the pieces + +The owner: the page is easy to scrape and many AI crawlers ignore robots.txt, so it should +not offer every asset; examples, if shown at all, should be small, low quality and +watermarked. The Pieces grid, a tile per asset at full size, is removed. The lede keeps +the count. + +**Still open:** the three examples are composed in the browser from the full-size layer +files, and they rotate through every piece, so the page still downloads all of them. Making +the examples small and watermarked means rendering each one on the server as a single +flattened image (`portrait-render` already composes one for the share card) instead of +composing it from the layers. That goes with the card layout choice (cards.html). + diff --git a/docs/design/style-guide.md b/docs/design/style-guide.md index 8d73b3e74..1d02c23b2 100644 --- a/docs/design/style-guide.md +++ b/docs/design/style-guide.md @@ -40,7 +40,7 @@ Decisions marked **Proposed** are the owner's to make. Everything else describes ## 2. Colour -### Named colours (`core.clj:8-31`) +### Named colours (`core.clj:8-30`) These are the palette. Use them by name. @@ -61,7 +61,6 @@ These are the palette. Use them by name. | `orange-hairline` | `rgba(240,161,0,0.16)` | Orange border on dark portrait panels | | `portrait-panel` | `#131924` | Artist card and tile ground, dark theme; also the drawer's | | `portrait-panel-text` | `#ebeef4` | Text on `portrait-panel` | -| `portrait-piece` | `#0e131a` | A piece tile inside a dark artist card | | `portrait-frame-glow` / `-edge` | `#202939` / `#0f141c` | The portrait frame's radial gradient | The portrait names are used by the artist pages' Garden rules (`artist-pages`). The drawer still diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index 22024226b..dcbfcb4fe 100644 --- a/scripts/test/probe-baseline.json +++ b/scripts/test/probe-baseline.json @@ -77,7 +77,7 @@ "seconds": 45 }, "artist_profile_e2e.js": { - "checks": 51, + "checks": 45, "seconds": 60 } } diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index e6ab67ab7..fe798a08d 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -26,7 +26,6 @@ ;; Portrait surfaces: the artist pages here, the compositor drawer in portrait.cljs. (def portrait-panel "#131924") ; card and tile ground, dark theme (def portrait-panel-text "#ebeef4") ; text on portrait-panel -(def portrait-piece "#0e131a") ; a piece tile inside a dark card (def portrait-frame-glow "#202939") ; portrait frame gradient, centre (def portrait-frame-edge "#0f141c") ; portrait frame gradient, edge @@ -1261,22 +1260,6 @@ [(str light " .ap-frame") {:border-color "rgba(0,0,0,0.12)"}] [".ap-example figcaption" {:font (str "italic 12px/1.4 " vollkorn) :color "var(--lk-dim)" :text-align :center}] - [:.ap-groups {:display :flex :flex-direction :column :gap "14px"}] - [:.ap-group - {:display :grid :grid-template-columns "96px 1fr" :gap "12px" :align-items :start}] - [:.ap-group-name {:font (str "600 12px/1.3 " open-sans) :padding-top "6px"}] - [:.ap-group-count - {:display :block :font-weight 400 :font-size "11px" :color "var(--lk-dim)"}] - [:.ap-pieces - {:display :grid :grid-template-columns "repeat(auto-fill, minmax(52px, 1fr))" - :gap "6px"}] - [:.ap-piece - {:aspect-ratio 1 :border-radius "6px" :overflow :hidden - :background portrait-panel :border "1px solid rgba(255,255,255,0.05)" - :display :grid :place-items :center}] - [(str light " .ap-piece") {:border-color "rgba(0,0,0,0.10)"}] - [".ap-root:not(.light-theme) .ap-card .ap-piece" {:background portrait-piece}] - [".ap-piece img" {:width "100%" :height "100%" :object-fit :contain}] [:.ap-foot {:margin 0 :padding-top "16px" :border-top "1px solid rgba(255,255,255,0.06)" :font-size "12px" :line-height 1.55 :color "var(--lk-dim)" :text-align :center}] @@ -1300,10 +1283,7 @@ (at-media {:max-width "560px"} [:.ap-card {:padding "22px 16px 18px" :gap "26px"}] [:.ap-name {:font-size "32px"}] - [:.ap-examples {:gap "8px"}] - [:.ap-group {:grid-template-columns "1fr" :gap "6px"}] - [:.ap-group-name {:padding-top 0}] - [:.ap-group-count {:display :inline :padding-left "6px"}])])) + [:.ap-examples {:gap "8px"}])])) (def app (concat diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs index a78d579e2..1fd326f5d 100644 --- a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -46,19 +46,6 @@ (when (seq others) [:figcaption "with pieces by " (names others)])])) -(defn- pieces [artist] - [:div.ap-groups - (for [{:keys [layer label] ps :pieces} (ap/pieces-by-layer artist)] - ^{:key layer} - [:div.ap-group - [:div.ap-group-name label - [:span.ap-group-count (count ps) (if (= 1 (count ps)) " piece" " pieces")]] - [:div.ap-pieces - (for [{:asset/keys [id url] piece-label :asset/label} ps] - ^{:key id} - [:div.ap-piece {:title piece-label} - [:img {:src url :alt (or piece-label "") :loading "lazy"}]])]])]) - (defn- profile-card [{:keys [:artist/id :artist/name] :as artist}] (let [links (ap/listed-links artist) n (ap/piece-count artist)] @@ -79,9 +66,6 @@ [:div.ap-examples (map-indexed (fn [i p] ^{:key i} [example id i p]) (ap/example-portraits id))]] - [:section.ap-section - [:h2.lk-cap "Pieces"] - [pieces artist]] [:p.ap-foot "Every portrait made with these pieces credits " name ", on the character page, the share card and the PDF. " diff --git a/test/browser/artist_profile_e2e.js b/test/browser/artist_profile_e2e.js index f8bebbe0b..cb5e5d907 100644 --- a/test/browser/artist_profile_e2e.js +++ b/test/browser/artist_profile_e2e.js @@ -118,13 +118,13 @@ async function checkProfile(page, themeLabel) { check(`[${themeLabel}] a single-artist example credits nobody else`, await page.locator('.ap-example figcaption').count() === 0); - const pieces = await page.locator('.ap-piece').count(); + // The page is easy to scrape, so it never lists the art piece by piece: the only + // pictures are the composed examples, drawn as CSS layers rather than files. + const pieceImgs = await page.locator('.ap-root img[src*="/image/portraits/"]').count(); + check(`[${themeLabel}] no piece of art is listed on its own`, pieceImgs === 0, `img=${pieceImgs}`); const lede = (await page.locator('.ap-lede').textContent()).trim(); - check(`[${themeLabel}] the piece count in the lede matches the pieces shown`, - pieces > 0 && lede.startsWith(`${pieces} `), `pieces=${pieces} lede=${lede}`); - const groups = await page.locator('.ap-group-name').evaluateAll(g => g.map(e => e.firstChild.textContent)); - check(`[${themeLabel}] pieces are grouped, the head first`, groups[0] === 'Head', JSON.stringify(groups)); - check(`[${themeLabel}] the generated scalp is not claimed as a drawing`, !groups.includes('Scalp')); + check(`[${themeLabel}] the lede still says how many pieces she drew`, + /^\d+ hand-drawn pieces? in the portrait maker\.$/.test(lede), lede); const text = await page.locator('.ap-root').innerText(); check(`[${themeLabel}] no email address anywhere on the page`, !EMAIL.test(text)); From 501d18c94e4cbaedc7379da19aa18a492b410f6b Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 4 Oct 2026 03:24:22 +0000 Subject: [PATCH 46/91] Render the artist pages' examples once, small and watermarked The three examples were composed in the browser from the full-size layer files, and they rotate through every piece, so the profile page handed out all 28 pieces at full size. The server now renders each example once per start and keeps it: flattened onto the frame's own gradient at 240x300, with the artist's host and the site's mark tiled diagonally across it, as a lossy JPEG at /artists//examples/. The page shows them as plain images, and the first is the og:image, replacing the full-size portrait.png. They cannot be generated into the repo: they are made from the real art, which is never committed. The probe now checks that neither artist page fetches any layer file. A unit test checks the three examples between them use every piece. seed->int and mulberry32 gave different numbers in the browser than on the JVM: a cljs character read as 0 in the hash, and 32-bit multiplies lost their low bits in JavaScript floats. So the browser's examples never matched the server's share image. Both now use 32-bit helpers (Math.imul in cljs) and match the reference JavaScript, and mulberry32 now XORs where it added, as the reference does. Values are pinned in artist_profile_test, which the cljs runner now runs too. Saved portraits store their layers, not the seed, so none change. --- docs/PORTRAIT-COMPOSITOR.md | 4 + docs/design/artist-profiles/PLAN.md | 37 +++-- scripts/test/probe-baseline.json | 2 +- src/clj/orcpub/portrait_render.clj | 126 ++++++++++++++++-- src/clj/orcpub/routes.clj | 41 +++--- src/clj/orcpub/styles/core.clj | 2 + src/cljc/orcpub/dnd/e5/artist_profile.cljc | 18 +++ src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 40 +++--- src/cljc/orcpub/dnd/e5/portrait_layout.cljc | 25 ++++ src/cljc/orcpub/route_map.cljc | 7 +- src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 16 ++- test/browser/artist_profile_e2e.js | 38 ++++-- test/clj/orcpub/artist_page_test.clj | 54 +++++--- .../orcpub/dnd/e5/artist_profile_test.cljc | 34 ++++- test/cljs/orcpub/test_runner.cljs | 3 + 15 files changed, 349 insertions(+), 98 deletions(-) diff --git a/docs/PORTRAIT-COMPOSITOR.md b/docs/PORTRAIT-COMPOSITOR.md index 42cd4c3e8..e5db06ba6 100644 --- a/docs/PORTRAIT-COMPOSITOR.md +++ b/docs/PORTRAIT-COMPOSITOR.md @@ -39,6 +39,10 @@ raster assets are read off the classpath and tinted by `multiply!`. `java.awt.Font/SANS_SERIF`. That is a logical family resolved through the host's fontconfig, so a slim container renders whatever it has, or boxes. +- **The artist pages' examples are rendered here once and kept**, small, flattened and + watermarked, so those pages never send layer files to the browser. See + `docs/design/artist-profiles/PLAN.md`, "the examples are rendered once". + ## The baked credit and the site mark The page and the sheet can each carry a credit beside the portrait, but the composed image diff --git a/docs/design/artist-profiles/PLAN.md b/docs/design/artist-profiles/PLAN.md index c85766fa2..b5a990607 100644 --- a/docs/design/artist-profiles/PLAN.md +++ b/docs/design/artist-profiles/PLAN.md @@ -14,7 +14,7 @@ quieter link. |---|---|---| | `/artists` | index HTML, generic share tags | lists every registered artist | | `/artists/` | index HTML with that artist's share tags (title, description, `og:image`); **404** for an unknown slug | the profile | -| `/artists//portrait.png` | the first example portrait rendered by `portrait-render/render-png`, for `og:image` | — | +| `/artists//examples/` | example `n` (1 to 3) as a small watermarked JPEG, rendered once per server start; the first is the `og:image` | shown as plain images | Declared once in `route-map.cljc` (bidi), served by `routes.clj`, and dispatched in `core.cljs` like every other page. This follows the character page: an SPA view, with @@ -54,10 +54,9 @@ the credit already honours. The pure functions go in a new cljc namespace, ## What renders where -- **Server:** the share tags and the og PNG. Nothing about the page body. -- **Client:** everything else. The examples use `portrait/composite`, the - same compositor as the drawer and the character page, so they cannot - look different from a real portrait. +- **Server:** the share tags and the example images. +- **Client:** everything else. The examples are plain ``s of the server's images, so + no layer file reaches the browser from these pages. ## Fusspot's links @@ -129,7 +128,7 @@ accounts are being built elsewhere, so this branch adds only the switch the page `set-artist-overrides!` next to the other credit fields, so the account setting only has to write it. When it is off: -- `/artists/` and its `portrait.png` return 404, the same as for a name nobody +- `/artists/` and its example images return 404, the same as for a name nobody has, and the 404's share tags don't name the artist; - the artist drops out of `/artists`; - the credit loses its "About the artist" line; @@ -141,7 +140,7 @@ to write it. When it is off: |---|---| | Pure functions: slug, lookup, pieces, listed links, examples, profile link, the off switch | `src/cljc/orcpub/dnd/e5/artist_profile.cljc` | | `:artist/slug "fusspot"`; `:artist/profile?` accepted as an override | `portrait_assets.cljc` | -| Routes `/artists`, `/artists/:slug`, `/artists/:slug/portrait.png` | `route_map.cljc`, `routes.clj` | +| Routes `/artists`, `/artists/:slug`, `/artists/:slug/examples/:n` | `route_map.cljc`, `routes.clj` | | The pages (profile, list, not-found) | `src/cljs/orcpub/dnd/e5/views/artist_page.cljs`, registered in `web/cljs/orcpub/core.cljs` | | "About the artist ›" at the foot of the drawer credit | `portrait.cljs` (`credit-lockup`, `.lk-about`) | @@ -197,9 +196,23 @@ not offer every asset; examples, if shown at all, should be small, low quality a watermarked. The Pieces grid, a tile per asset at full size, is removed. The lede keeps the count. -**Still open:** the three examples are composed in the browser from the full-size layer -files, and they rotate through every piece, so the page still downloads all of them. Making -the examples small and watermarked means rendering each one on the server as a single -flattened image (`portrait-render` already composes one for the share card) instead of -composing it from the layers. That goes with the card layout choice (cards.html). +## Decided: the examples are rendered once, small and watermarked + +The owner: pre-generate three examples that between them use every piece, and serve the +same three every time. They were composed in the browser from the full-size layer files, +so the page downloaded every piece at full size. + +Now the server renders each example once per start (`routes/example-jpeg`, memoized), +flattened onto the frame's own gradient at 240x300, about the size the page shows it, with +the artist's host and the site's mark tiled diagonally across it, as a JPEG at quality 0.72. +The first is also the `og:image`. The browser probe checks that neither artist page fetches +any layer file, and a unit test that the three examples use all 28 pieces. + +They cannot be generated into the repo: they are made from the real art, which is never +committed. A server start makes them from whatever art the deployment carries. + +**Found on the way:** `seed->int` and `mulberry32` gave different numbers in the browser +than on the JVM (a cljs character read as 0, and 32-bit multiplies lost precision in +floats), so the browser's examples never matched the server's share image. Both now match +the reference JavaScript on both platforms, pinned by a test that runs on both. diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index dcbfcb4fe..3fb03a36d 100644 --- a/scripts/test/probe-baseline.json +++ b/scripts/test/probe-baseline.json @@ -77,7 +77,7 @@ "seconds": 45 }, "artist_profile_e2e.js": { - "checks": 45, + "checks": 46, "seconds": 60 } } diff --git a/src/clj/orcpub/portrait_render.clj b/src/clj/orcpub/portrait_render.clj index 6df3d4aeb..8775cb3d8 100644 --- a/src/clj/orcpub/portrait_render.clj +++ b/src/clj/orcpub/portrait_render.clj @@ -10,12 +10,12 @@ [orcpub.pdf :as pdf] [orcpub.dnd.e5.portrait-assets :as pa] [orcpub.dnd.e5.portrait-layout :as layout]) - (:import [java.awt AlphaComposite BasicStroke Color Graphics2D RenderingHints] - [java.awt.geom Path2D$Double] + (:import [java.awt AlphaComposite BasicStroke Color Graphics2D RadialGradientPaint RenderingHints] + [java.awt.geom Path2D$Double Point2D$Double] [java.awt.image BufferedImage] [java.io ByteArrayInputStream ByteArrayOutputStream] [java.util Base64] - [javax.imageio ImageIO])) + [javax.imageio IIOImage ImageIO ImageWriteParam])) (def ^:private default-width 600) (def ^:private default-height 750) ;; 4:5, matching the on-screen frame @@ -243,10 +243,12 @@ (finally (.setTransform g saved))))) (defn render - "Composite `portrait` ({:layers :colors :tweaks}) into a BufferedImage, or - nil when it selects nothing drawable." + "Composite `portrait` ({:layers :colors :tweaks}) into a `w`x`h` ARGB BufferedImage, or + nil when it selects nothing drawable. The credit and site mark are drawn in unless + `marks?` is false." ([portrait] (render portrait default-width default-height)) - ([portrait w h] + ([portrait w h] (render portrait w h {:marks? true})) + ([portrait w h {:keys [marks?]}] (let [drawable (keep (fn [k] (when-let [asset (some->> (get-in portrait [:layers k]) :asset/id @@ -283,13 +285,14 @@ (draw-raster-layer! g bytes color w h)))) (catch Exception e (println "portrait-render: skipped layer" layer-key "-" (.getMessage e))))) - (try - (when-let [credit (pa/credit-line portrait)] - (draw-credit! g credit w h)) - (when-let [mark (site-mark)] - (draw-site-mark! g mark w h)) - (catch Exception e - (println "portrait-render: marks skipped -" (.getMessage e)))) + (when marks? + (try + (when-let [credit (pa/credit-line portrait)] + (draw-credit! g credit w h)) + (when-let [mark (site-mark)] + (draw-site-mark! g mark w h)) + (catch Exception e + (println "portrait-render: marks skipped -" (.getMessage e))))) (finally (.dispose g))) img))))) @@ -305,3 +308,100 @@ (catch Exception e (println "portrait-render: render failed -" (.getMessage e)) nil)))) + +;; ---------- example images for the artist pages ---------- + +(defn- halve-toward + "`img` scaled by bilinear steps of at most half until it is `w`x`h`, so a large + reduction does not skip pixels the way one bilinear step would." + ^BufferedImage [^BufferedImage img w h] + (loop [^BufferedImage src img] + (if (and (= w (.getWidth src)) (= h (.getHeight src))) + src + (let [nw (max w (quot (.getWidth src) 2)) + nh (max h (quot (.getHeight src) 2)) + dst (BufferedImage. nw nh BufferedImage/TYPE_INT_ARGB) + g (.createGraphics dst)] + (try + (.setRenderingHint g RenderingHints/KEY_INTERPOLATION + RenderingHints/VALUE_INTERPOLATION_BILINEAR) + (.drawImage g src 0 0 (int nw) (int nh) nil) + (finally (.dispose g))) + (recur dst))))) + +(defn- rgb [[r g b]] (Color. (int r) (int g) (int b))) +(defn- rgba [[r g b a]] (Color. (int r) (int g) (int b) (int a))) + +(defn- paint-ground! + "Fills a `w`x`h` frame with layout/example-ground, the portrait frame's gradient." + [^Graphics2D g w h] + (let [{:keys [centre mid edge]} layout/example-ground + cx (/ w 2.0) cy (* h 0.35) + radius (Math/hypot (max cx (- w cx)) (max cy (- h cy)))] + (.setPaint g (RadialGradientPaint. (Point2D$Double. cx cy) (float radius) + (float-array [0.0 0.6 1.0]) + (into-array Color [(rgb centre) (rgb mid) (rgb edge)]))) + (.fillRect g 0 0 (int w) (int h)))) + +(defn- draw-watermark! + "Tiles `text` across the whole `w`x`h` frame in diagonal rows, at + layout/example-watermark-layout's metrics, alternate rows offset by half a repeat." + [^Graphics2D g ^String text w h] + (let [{:keys [size angle step-x step-y fill outline]} (layout/example-watermark-layout w h) + font (face mark-face size) + repeat-w (+ (.stringWidth (.getFontMetrics g font) text) step-x) + reach (int (Math/hypot w h)) + saved (.getTransform g)] + (try + (.setFont g font) + (.rotate g (double angle) (/ w 2.0) (/ h 2.0)) + (doseq [[row y] (map-indexed vector (range (- reach) (* 2 reach) step-y)) + x (range (- (- reach) (if (odd? row) (quot repeat-w 2) 0)) (* 2 reach) repeat-w)] + (.setColor g (rgba outline)) + (.drawString g text (int (inc x)) (int (inc y))) + (.setColor g (rgba fill)) + (.drawString g text (int x) (int y))) + (finally (.setTransform g saved))))) + +(defn- jpeg-bytes + "`img` as JPEG bytes at `quality` (0 to 1)." + ^bytes [^BufferedImage img quality] + (let [writer (.next (ImageIO/getImageWritersByFormatName "jpeg")) + param (doto (.getDefaultWriteParam writer) + (.setCompressionMode ImageWriteParam/MODE_EXPLICIT) + (.setCompressionQuality (float quality))) + out (ByteArrayOutputStream.)] + (with-open [ios (ImageIO/createImageOutputStream out)] + (.setOutput writer ios) + (.write writer nil (IIOImage. img nil nil) param) + (.dispose writer)) + (.toByteArray out))) + +(def example-quality + "JPEG quality of an artist page's example: lossy on purpose." + 0.72) + +(defn render-example-jpeg + "JPEG bytes of `portrait` as an artist page shows it: flattened onto the frame's ground + at layout/example-size, with `watermark` tiled across it and no credit line. nil when + there is nothing to draw or the render fails." + [portrait watermark] + (try + (when-let [full (render portrait default-width default-height {:marks? false})] + (let [[w h] layout/example-size + out (BufferedImage. w h BufferedImage/TYPE_INT_RGB) + g (.createGraphics out)] + (try + (doto g + (.setRenderingHint RenderingHints/KEY_ANTIALIASING RenderingHints/VALUE_ANTIALIAS_ON) + (.setRenderingHint RenderingHints/KEY_TEXT_ANTIALIASING + RenderingHints/VALUE_TEXT_ANTIALIAS_ON)) + (paint-ground! g w h) + (.drawImage g (halve-toward full w h) 0 0 nil) + (when-not (s/blank? watermark) (draw-watermark! g watermark w h)) + (finally (.dispose g))) + (jpeg-bytes out example-quality))) + (catch Exception e + (println "portrait-render: example failed -" (.getMessage e)) + nil))) + diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 6c0f2e78a..0f93782c4 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -1897,25 +1897,36 @@ {:title (artist-profile/page-title artist) :description (artist-profile/share-description artist) :image-url (str "https://" (headers "host") - (route-map/path-for route-map/artist-portrait-route - :slug (artist-profile/slug artist)))}) + (artist-profile/example-path artist 1))}) (assoc (index-page-response request {:title "No such artist"}) :status 404))) -(defn artist-portrait-png - "The artist's first example portrait as a PNG, for og:image. The examples - are seeded from the slug, so this is the same picture the page leads with." - [{{:keys [slug]} :path-params}] +(def ^:private example-jpeg + "JPEG bytes of example `n` (1-based) for `artist-id`, watermarked with `text`; nil when + there is none. Rendered once per process and then served from memory." + (memoize + (fn [artist-id n text] + (some-> (artist-profile/example-portraits artist-id) + (get (dec n)) + (portrait-render/render-example-jpeg text))))) + +(defn artist-example-jpeg + "Ring handler: example image :n (1 to example-count) of the artist at :slug, small, + flattened and watermarked. 404 for an unknown slug, a page turned off, or any other :n." + [{{:keys [slug n]} :path-params}] (let [artist (artist-profile/artist-by-slug slug) - png (some-> artist :artist/id artist-profile/example-portraits first - portrait-render/render-png)] - (if png + n (some->> n (re-matches #"[1-9]") parse-long) + jpeg (when (and artist n (<= n artist-profile/example-count)) + (example-jpeg (:artist/id artist) n + (artist-profile/watermark-text + artist (or (portrait-render/site-mark) branding/app-name))))] + (if jpeg {:status 200 - :headers {"Content-Type" "image/png" - "Cache-Control" "public, max-age=300" + :headers {"Content-Type" "image/jpeg" + "Cache-Control" "public, max-age=86400" ;; contributed artwork -- see character-portrait-png "X-Robots-Tag" "noai, noimageai"} - :body (ByteArrayInputStream. png)} - {:status 404 :body "no such artist"}))) + :body (ByteArrayInputStream. jpeg)} + {:status 404 :body "no such example"}))) (def header-style {:style "color:#2c3445"}) @@ -2032,8 +2043,8 @@ {:get `artists-page}] [(route-map/path-for route-map/artist-page-route :slug ":slug") {:get `artist-page}] - [(route-map/path-for route-map/artist-portrait-route :slug ":slug") - {:get `artist-portrait-png}] + [(route-map/path-for route-map/artist-example-route :slug ":slug" :n ":n") + {:get `artist-example-jpeg}] [(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/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index fe798a08d..bf6bde6cc 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1258,6 +1258,8 @@ portrait-panel " 60%, " portrait-frame-edge ")") :border (str "1px solid " orange-hairline)}] [(str light " .ap-frame") {:border-color "rgba(0,0,0,0.12)"}] + [:.ap-example-img + {:display :block :width "100%" :height "100%" :object-fit :cover}] [".ap-example figcaption" {:font (str "italic 12px/1.4 " vollkorn) :color "var(--lk-dim)" :text-align :center}] [:.ap-foot diff --git a/src/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc index 6a6fbb999..9d270783f 100644 --- a/src/cljc/orcpub/dnd/e5/artist_profile.cljc +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -62,6 +62,24 @@ (defn index-path [] (route-map/path-for route-map/artists-page-route)) +(defn example-path + "The address of `artist`'s example image `n`, counted from 1." + [artist n] + (route-map/path-for route-map/artist-example-route :slug (slug artist) :n (str n))) + +(defn link-host + "The host of `url` without scheme, www. or trailing path: \"fusspot.rip\". nil if none." + [url] + (some->> url (re-find #"(?i)^https?://(?:www\.)?([^/?#]+)") second not-empty)) + +(defn watermark-text + "What is tiled across `artist`'s example images: the host of their own link (else their + name), then `site` when given, joined by a middle dot." + [artist site] + (s/join " \u00b7 " (remove s/blank? [(or (link-host (:artist/link artist)) + (:artist/name artist)) + site]))) + (defn profile-link "Where the credit's secondary link goes, for the named artists on a portrait: the one artist's profile, or the list when there are several. diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc index 9fc76c7d0..6bdd35364 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -433,32 +433,40 @@ ;; Lives here (cljc) rather than in portrait.cljs so JVM tests can reach it. ;; All bit-ops used are cljc-safe. +(defn- u32 + "`x` wrapped to an unsigned 32-bit integer." + [x] + #?(:clj (bit-and 0xffffffff x) :cljs (unsigned-bit-shift-right x 0))) + +(defn- mul32 + "`a` times `b` modulo 2^32, unsigned. + GOTCHA: in cljs a plain multiply loses the low bits past 2^53; Math/imul does not." + [a b] + #?(:clj (bit-and 0xffffffff (unchecked-multiply a b)) + :cljs (unsigned-bit-shift-right (js/Math.imul a b) 0))) + +(defn- char-code [c] + #?(:clj (int c) :cljs (.charCodeAt c 0))) + (defn seed->int - "FNV-1a-flavored string hash. Same seed always resolves to the same - integer, in both Clojure and ClojureScript." + "32-bit FNV-1a hash of `(str seed)`, the same integer in Clojure and ClojureScript." [seed] - (reduce (fn [h c] - (bit-and 0xffffffff - (unchecked-multiply (bit-xor h (int c)) 16777619))) + (reduce (fn [h c] (mul32 (u32 (bit-xor h (char-code 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." + a fresh number in [0,1) on each call, the same sequence on both platforms." [seed-int] - (let [a (atom (bit-and 0xffffffff seed-int))] + (let [a (atom (u32 seed-int))] (fn [] - (swap! a #(bit-and 0xffffffff (unchecked-add % 0x6D2B79F5))) + (swap! a #(u32 (+ % 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) + t1 (mul32 (u32 (bit-xor s (unsigned-bit-shift-right s 15))) (u32 (bit-or s 1))) + t2 (u32 (bit-xor t1 (u32 (+ t1 (mul32 (u32 (bit-xor t1 (unsigned-bit-shift-right t1 7))) + (u32 (bit-or t1 61)))))))] + (/ (u32 (bit-xor t2 (unsigned-bit-shift-right t2 14))) 4294967296.0))))) (defn random-seed diff --git a/src/cljc/orcpub/dnd/e5/portrait_layout.cljc b/src/cljc/orcpub/dnd/e5/portrait_layout.cljc index 91d964c79..863d1f86e 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_layout.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_layout.cljc @@ -52,3 +52,28 @@ "An [r g b a] alpha, as a canvas wants it." [[_ _ _ a]] (/ (double a) 255)) + +;; ---------- example images on the artist pages ---------- + +(def example-size + "[w h] of an artist page's example image: about the size the page shows it, so the file + is no better to scrape than what is on screen." + [240 300]) + +(def example-ground + "The opaque ground an example is flattened onto, as [r g b] stops of a radial gradient + centred at 50% 35%: the portrait frame's own colours, so the image meets its frame." + {:centre [32 41 57] :mid [19 25 36] :edge [15 20 28]}) + +(defn example-watermark-layout + "Metrics for the watermark tiled across a `w`x`h` example: {:size :angle :step-x + :step-y :fill :outline}. :angle is in radians, rows rise to the right; colours are + [r g b a] with a out of 255. Faint, but repeated over the whole image." + [w h] + (let [size (max 9 (Math/round (* h 0.04)))] + {:size size + :angle (- (/ Math/PI 6)) + :step-x (Math/round (* size 1.5)) + :step-y (Math/round (* size 3.2)) + :fill [255 255 255 46] + :outline [0 0 0 40]})) diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 662643de3..7da4553ed 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -96,8 +96,9 @@ ;; under /pages/ -- these are addresses people are handed, from a credit. (def artists-page-route :artists-page) (def artist-page-route :artist-page) -;; Server-rendered example portrait, for the profile's og:image. -(def artist-portrait-route :artist-portrait) +;; An artist's example portraits (1-based :n), rendered once on the server, small and +;; watermarked. The first is also the profile's og:image. +(def artist-example-route :artist-example) (def register-route :register) (def register-page-route :register-page) @@ -164,7 +165,7 @@ "artists" {"" artists-page-route ["/" :slug] artist-page-route - ["/" :slug "/portrait.png"] artist-portrait-route} + ["/" :slug "/examples/" :n] artist-example-route} "dnd/" {"5e/" {"characters" {"" dnd-e5-char-list-route diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs index 1fd326f5d..cce5836dc 100644 --- a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -38,11 +38,15 @@ (cond (zero? i) nil (= i (dec n)) " and " :else ", ") (:artist/name a)]))) -(defn- example [artist-id i p] - (let [others (ap/others-in p artist-id)] +(defn- example-img + "Example `n` of `artist` (from 1), the small watermarked image the server renders once." + [artist n alt] + [:img.ap-example-img {:src (ap/example-path artist n) :alt alt :width 240 :height 300}]) + +(defn- example [artist i p] + (let [others (ap/others-in p (:artist/id artist))] [:figure.ap-example - [:div.ap-frame {:role "img" :aria-label (str "Example portrait " (inc i))} - [portrait/composite p]] + [:div.ap-frame [example-img artist (inc i) (str "Example portrait " (inc i))]] (when (seq others) [:figcaption "with pieces by " (names others)])])) @@ -64,7 +68,7 @@ [:section.ap-section [:h2.lk-cap "Portraits from these pieces"] [:div.ap-examples - (map-indexed (fn [i p] ^{:key i} [example id i p]) + (map-indexed (fn [i p] ^{:key i} [example artist i p]) (ap/example-portraits id))]] [:p.ap-foot "Every portrait made with these pieces credits " name @@ -94,6 +98,6 @@ :let [n (ap/piece-count a)]] ^{:key id} [:a.ap-index-card {:href (ap/profile-path a)} - [:div.ap-frame [portrait/composite (first (ap/example-portraits id 1))]] + [:div.ap-frame [example-img a 1 (str "A portrait from " name "'s pieces")]] [:span.lk-name name] [:span.ap-index-count n (if (= 1 n) " piece" " pieces")]])]]]) diff --git a/test/browser/artist_profile_e2e.js b/test/browser/artist_profile_e2e.js index cb5e5d907..0bcef7e89 100644 --- a/test/browser/artist_profile_e2e.js +++ b/test/browser/artist_profile_e2e.js @@ -87,6 +87,10 @@ async function dismissCookies(page) { } async function checkProfile(page, themeLabel) { + page.layerRequests = []; + page.on('request', r => { + if (r.url().includes('/image/portraits/')) page.layerRequests.push(r.url()); + }); await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); await page.waitForSelector('.ap-card', { timeout: 30000 }); await dismissCookies(page); @@ -112,16 +116,20 @@ async function checkProfile(page, themeLabel) { const examples = page.locator('.ap-example'); check(`[${themeLabel}] three example portraits`, await examples.count() === 3, `saw ${await examples.count()}`); - const layered = await examples.evaluateAll(fs => - fs.map(f => f.querySelectorAll('.portrait-layer').length)); - check(`[${themeLabel}] every example is composed`, layered.every(n => n > 0), JSON.stringify(layered)); + const sizes = await examples.evaluateAll(fs => + fs.map(f => { + const i = f.querySelector('img'); + return i ? `${i.naturalWidth}x${i.naturalHeight}` : 'none'; + })); + check(`[${themeLabel}] every example is a small image the server made`, + sizes.every(s => s === '240x300'), JSON.stringify(sizes)); check(`[${themeLabel}] a single-artist example credits nobody else`, await page.locator('.ap-example figcaption').count() === 0); - // The page is easy to scrape, so it never lists the art piece by piece: the only - // pictures are the composed examples, drawn as CSS layers rather than files. - const pieceImgs = await page.locator('.ap-root img[src*="/image/portraits/"]').count(); - check(`[${themeLabel}] no piece of art is listed on its own`, pieceImgs === 0, `img=${pieceImgs}`); + // The page is easy to scrape, so it never hands out the layer art: the examples are + // flattened on the server, and no layer file is fetched. + check(`[${themeLabel}] no layer art is downloaded`, page.layerRequests.length === 0, + page.layerRequests.slice(0, 3).join(' | ')); const lede = (await page.locator('.ap-lede').textContent()).trim(); check(`[${themeLabel}] the lede still says how many pieces she drew`, /^\d+ hand-drawn pieces? in the portrait maker\.$/.test(lede), lede); @@ -148,12 +156,12 @@ async function checkProfile(page, themeLabel) { const req = ctx.request; const html = await (await req.get(`${BASE}/artists/fusspot`)).text(); check('share card is titled for the artist', html.includes('Fusspot — portrait artist')); - check('og:image is the page\'s lead portrait', - /]*property="og:image")(?=[^>]*content="[^"]*\/artists\/fusspot\/portrait\.png")[^>]*>/.test(html)); - const png = await req.get(`${BASE}/artists/fusspot/portrait.png`); - check('the og image is served as a PNG', - png.status() === 200 && png.headers()['content-type'] === 'image/png', - `${png.status()} ${png.headers()['content-type']}`); + check('og:image is the page\'s first example', + /]*property="og:image")(?=[^>]*content="[^"]*\/artists\/fusspot\/examples\/1")[^>]*>/.test(html)); + const jpg = await req.get(`${BASE}/artists/fusspot/examples/1`); + check('the examples are served as JPEGs', + jpg.status() === 200 && jpg.headers()['content-type'] === 'image/jpeg', + `${jpg.status()} ${jpg.headers()['content-type']}`); const missing = await req.get(`${BASE}/artists/nobody`); check('a slug nobody has is a 404', missing.status() === 404, `${missing.status()}`); await ctx.close(); @@ -194,12 +202,16 @@ async function checkProfile(page, themeLabel) { check('an unknown slug says so and points at the list', await lp.locator('.ap-missing a[href="/artists"]').count() === 1); + const listLayers = []; + dp.on('request', r => { if (r.url().includes('/image/portraits/')) listLayers.push(r.url()); }); await dp.goto(`${BASE}/artists`, { waitUntil: 'networkidle' }); await dp.waitForSelector('.ap-index', { timeout: 30000 }); await dp.waitForTimeout(400); const cards = await dp.locator('.ap-index-card').evaluateAll(as => as.map(a => a.getAttribute('href'))); check('the list links each artist to their profile', JSON.stringify(cards) === JSON.stringify(['/artists/fusspot']), JSON.stringify(cards)); + check('the list downloads no layer art either', listLayers.length === 0, + listLayers.slice(0, 3).join(' | ')); await shot(dp, 'index-dark.png', { fullPage: true }); // --- the drawer credit ----------------------------------------------- diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj index b7b1ef5d9..e33950686 100644 --- a/test/clj/orcpub/artist_page_test.clj +++ b/test/clj/orcpub/artist_page_test.clj @@ -1,39 +1,59 @@ (ns orcpub.artist-page-test - "The server's part of the artist profile: share tags for the SPA page, a real - 404 for a slug nobody has, and the og image. No database -- the page reads - only the registry." + "The server's part of the artist profile: share tags for the SPA page, a real 404 for a + slug nobody has, and the example images. No database; the page reads only the registry." (:require [clojure.test :refer [deftest is testing]] [clojure.string :as s] [orcpub.routes :as routes] - [orcpub.dnd.e5.portrait-assets :as pa])) + [orcpub.dnd.e5.portrait-assets :as pa]) + (:import (javax.imageio ImageIO))) (defn- req [slug] {:headers {"host" "example.test"} :uri (str "/artists/" slug) :path-params {:slug slug}}) +(defn- example [slug n] + (routes/artist-example-jpeg {:path-params {:slug slug :n n}})) + +(defn- body-bytes [resp] + (.readAllBytes ^java.io.InputStream (:body resp))) + (deftest the-profile-carries-its-own-share-tags (let [{:keys [status body]} (routes/artist-page (req "fusspot"))] (is (= 200 status)) (is (s/includes? body "Fusspot — portrait artist") "the title names the artist") (is (s/includes? body "hand-drawn pieces") "the description says what is there") - (is (s/includes? body "https://example.test/artists/fusspot/portrait.png") - "og:image is the page's lead portrait, rendered on the server"))) + (is (s/includes? body "https://example.test/artists/fusspot/examples/1") + "og:image is the page's first example"))) (deftest an-unknown-slug-is-a-404 (testing "so a mistyped link is not indexed as a page; the SPA still loads and says there is nobody at that address" (is (= 404 (:status (routes/artist-page (req "nobody"))))) - (is (= 404 (:status (routes/artist-portrait-png (req "nobody"))))))) + (is (= 404 (:status (example "nobody" "1")))))) + +(deftest the-examples-are-small-watermarked-jpegs + (doseq [n ["1" "2" "3"]] + (let [resp (example "fusspot" n) + bytes (body-bytes resp) + img (ImageIO/read (java.io.ByteArrayInputStream. bytes))] + (is (= 200 (:status resp)) n) + (is (= "image/jpeg" (get-in resp [:headers "Content-Type"])) n) + (is (= "noai, noimageai" (get-in resp [:headers "X-Robots-Tag"])) n) + (is (= [-1 -40] (vec (take 2 bytes))) (str n " is a JPEG")) + (is (= [240 300] [(.getWidth img) (.getHeight img)]) + (str n " is about the size the page shows it, not larger"))))) + +(deftest each-example-is-rendered-once + (testing "the same bytes, not a fresh render, on every request" + (is (identical? (#'routes/example-jpeg :house-pack 1 "x") + (#'routes/example-jpeg :house-pack 1 "x"))) + (is (= (seq (body-bytes (example "fusspot" "2"))) + (seq (body-bytes (example "fusspot" "2"))))))) -(deftest the-og-image-is-a-png - (let [resp (routes/artist-portrait-png (req "fusspot")) - buf (byte-array 4)] - (is (= 200 (:status resp))) - (is (= "image/png" (get-in resp [:headers "Content-Type"]))) - (is (= "noai, noimageai" (get-in resp [:headers "X-Robots-Tag"]))) - (.read ^java.io.InputStream (:body resp) buf) - (is (= [-119 80 78 71] (vec buf))))) +(deftest there-are-only-three-examples + (doseq [n ["0" "4" "x" "1.jpg" nil]] + (is (= 404 (:status (example "fusspot" n))) (pr-str n)))) (deftest the-list-page-is-served (is (= 200 (:status (routes/artists-page {:headers {"host" "example.test"} @@ -43,8 +63,8 @@ (try (pa/set-artist-overrides! {:house-pack {:artist/profile? false}}) (is (= 404 (:status (routes/artist-page (req "fusspot"))))) - (is (= 404 (:status (routes/artist-portrait-png (req "fusspot")))) - "the preview image goes with it") + (is (= 404 (:status (example "fusspot" "1"))) + "the example images go with it") (is (not (s/includes? (:body (routes/artist-page (req "fusspot"))) "Fusspot")) "and the 404's share tags do not name them") (finally (pa/set-artist-overrides! {})))) diff --git a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc index 6d13f5174..355130997 100644 --- a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc +++ b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc @@ -39,8 +39,8 @@ (is (= {:handler route-map/artist-page-route :route-params {:slug "fusspot"}} (route-map/match-route "/artists/fusspot"))) (is (= route-map/artists-page-route (:handler (route-map/match-route "/artists")))) - (is (= route-map/artist-portrait-route - (:handler (route-map/match-route "/artists/fusspot/portrait.png"))))) + (is (= {:handler route-map/artist-example-route :route-params {:slug "fusspot" :n "1"}} + (route-map/match-route "/artists/fusspot/examples/1")))) (deftest every-slug-is-a-clean-path-segment (doseq [a pa/registry] @@ -147,6 +147,14 @@ ;; ---------- examples ---------- +(deftest the-seeded-randomness-is-the-same-on-both-platforms + (testing "the examples are drawn on the server and captioned in the browser, so the + hash and the generator must agree; values from the reference JavaScript" + (is (= 440920331 (pa/seed->int "abc"))) + (is (= 993024918 (pa/seed->int "artist-fusspot/0"))) + (let [r (pa/mulberry32 12345)] + (is (= [0.9797282677609473 0.3067522644996643 0.484205421525985] [(r) (r) (r)]))))) + (deftest examples-are-stable (is (= (ap/example-portraits :house-pack) (ap/example-portraits :house-pack)) "the page, the og image and every reload show the same pictures") @@ -187,6 +195,28 @@ "the other way round too, and an artist is never listed as borrowing from themself")))) +(deftest between-them-the-examples-use-every-piece + (let [drawn (for [{ps :pieces} (ap/pieces-by-layer (ap/artist-by-slug "fusspot")) + p ps] + (:asset/id p)) + used (set (for [x (ap/example-portraits :house-pack) + [_ sel] (:layers x)] + (:asset/id sel)))] + (is (= 28 (count drawn))) + (is (every? used drawn) "every piece she drew is in at least one example"))) + +(deftest the-examples-are-watermarked-with-her-site-and-ours + (let [artist (ap/artist-by-slug "fusspot")] + (is (= "fusspot.rip \u00b7 dungeonmastersvault.com" + (ap/watermark-text artist "dungeonmastersvault.com"))) + (is (= "fusspot.rip" (ap/watermark-text artist nil)) "no site configured, her host alone") + (is (= "Someone" (ap/watermark-text {:artist/name "Someone"} "")) "no link, her name") + (is (= "example.org" (ap/link-host "https://www.example.org/shop?x=1"))) + (is (nil? (ap/link-host "mailto:someone@example.org"))))) + +(deftest examples-have-their-own-addresses + (is (= "/artists/fusspot/examples/2" (ap/example-path (ap/artist-by-slug "fusspot") 2)))) + (deftest no-examples-for-nobody (is (nil? (ap/example-portraits :not-registered)))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 700e00a36..d95fc632c 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -8,6 +8,8 @@ ;; The spell page packer and row annotations run in the browser -- ;; the builder decides the layout -- so their tests run here too. [orcpub.dnd.e5.portrait-layout-test] + ;; the artist pages' examples are drawn on the server and captioned here + [orcpub.dnd.e5.artist-profile-test] [orcpub.dnd.e5.spell-packing-test] [orcpub.image-url-test] [orcpub.whats-new-test] @@ -38,6 +40,7 @@ 'orcpub.dnd.e5.compute-test 'orcpub.dnd.e5.hunter-evasion-test 'orcpub.dnd.e5.portrait-layout-test + 'orcpub.dnd.e5.artist-profile-test 'orcpub.dnd.e5.spell-packing-test 'orcpub.image-url-test 'orcpub.whats-new-test From c986211075e5d7b3bd663610fa2bff2754f62aed Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 4 Oct 2026 03:24:53 +0000 Subject: [PATCH 47/91] Page showing the watermarked artist examples at actual size --- .../artist-profiles/examples/index.html | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/design/artist-profiles/examples/index.html diff --git a/docs/design/artist-profiles/examples/index.html b/docs/design/artist-profiles/examples/index.html new file mode 100644 index 000000000..dc5f3961f --- /dev/null +++ b/docs/design/artist-profiles/examples/index.html @@ -0,0 +1,75 @@ +Watermarked Artist Examples + + + +
    +
    +

    Fusspot's examples, rendered once and watermarked

    +

    The server renders these three once per start and serves the same files every time. Between them they use all 28 pieces. The page shows them as plain images, so no layer art reaches a visitor's browser.

    +
    + +
    +

    The files, actual size

    +

    240 × 300 JPEG at quality 0.72, about 8KB each. The watermark reads "fusspot.rip · OrcPub": OrcPub is the dev server's name, and a deployment with its site address set shows that domain instead. The art is the repo's placeholder silhouettes; a deployment renders its own art.

    +
    +
    Example 1: blonde side ponytail, green top
    examples/1 · also the share image · 8.2KB
    +
    Example 2: long red hair, pale blue top
    examples/2 · 7.4KB
    +
    Example 3: orange side ponytail, brown top
    examples/3 · 7.6KB
    +
    +
    + +
    +

    On the profile page, desktop

    +
    +
    Profile page, dark theme, desktop
    Dark theme
    +
    Profile page, light theme, desktop
    Light theme
    +
    +
    + +
    +

    On a phone

    +
    +
    Profile page, dark theme, phone
    Dark theme, iPhone 13
    +
    Profile page, light theme, phone
    Light theme, iPhone 13
    +
    +
    + +
    +

    The artist list

    +
    +
    Artist list, dark theme, desktop
    Uses example 1
    +
    +
    +
    From 1834a3da03a6fc3ee0d7115270d79504991e5bed Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 11 Oct 2026 02:23:06 +0000 Subject: [PATCH 48/91] Docstrings to the current rule: every function has one, GOTCHA only where it earns it AGENTS.md now asks for a docstring on every function and keeps GOTCHA for code that is easy to break by accident. Fourteen functions in the artist and portrait code had none and now do. Of the eighteen GOTCHA lines added on this branch, seven stay (the eager decode, the whole pull, the If-None-Match wildcard, the container check, Math/imul, the :http argument order, the mask that clips the tint); the other eleven were ordinary facts and are plain spec sentences now. Text only: with strings dropped, every file reads as before. --- src/clj/orcpub/fork/branding.clj | 2 +- src/clj/orcpub/pdf.clj | 2 +- src/clj/orcpub/portrait_render.clj | 17 +++++++---- src/cljc/orcpub/dnd/e5/artist_profile.cljc | 28 ++++++++++++++----- src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 12 ++++---- src/cljc/orcpub/dnd/e5/portrait_layout.cljc | 4 +-- src/cljs/orcpub/dnd/e5/events.cljs | 2 +- src/cljs/orcpub/dnd/e5/views.cljs | 2 +- src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 18 +++++++++--- 9 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/clj/orcpub/fork/branding.clj b/src/clj/orcpub/fork/branding.clj index 3ed526dc5..f306c721c 100644 --- a/src/clj/orcpub/fork/branding.clj +++ b/src/clj/orcpub/fork/branding.clj @@ -143,7 +143,7 @@ :artist/links :artist/license}}, from PORTRAIT_ARTISTS: JSON keyed by artist id, e.g. {\"house-pack\": {\"name\": \"Someone\", \"link\": \"https://...\"}}. Which artist drew which asset is not overridable. See PORTRAIT-COMPOSITOR.md, \"Deployment overrides\". - GOTCHA: a value that does not parse is ignored with a warning, not thrown." + A value that does not parse is ignored with a warning." (or (when-let [raw (not-empty (or (env :portrait-artists) (System/getenv "PORTRAIT_ARTISTS")))] (try diff --git a/src/clj/orcpub/pdf.clj b/src/clj/orcpub/pdf.clj index a2f67f67e..34596010c 100644 --- a/src/clj/orcpub/pdf.clj +++ b/src/clj/orcpub/pdf.clj @@ -1708,7 +1708,7 @@ (defn- fit-artwork "PNG `data` fitted to max-artwork-embedded as {:data bytes :jpg? false}, or nil if it will not decode or will not fit in 12 tries. Shrinks the longest edge 15% a try, keeping alpha. - GOTCHA: not fit-for-sheet, which re-encodes as JPEG and turns transparency black." + Unlike fit-for-sheet, which re-encodes as JPEG, the result stays PNG with its alpha." [^bytes data] (when-let [img (ImageIO/read (java.io.ByteArrayInputStream. data))] (if (<= (alength data) max-artwork-embedded) diff --git a/src/clj/orcpub/portrait_render.clj b/src/clj/orcpub/portrait_render.clj index 8775cb3d8..853a31843 100644 --- a/src/clj/orcpub/portrait_render.clj +++ b/src/clj/orcpub/portrait_render.clj @@ -2,7 +2,7 @@ "Server-side rendering of a composed portrait to PNG, for the share card (og:image). Entry points: `render-png`, `site-mark`. Vector assets are drawn with pdf/svg-path-ops; raster assets are read off the classpath and tinted by `multiply!`. - GOTCHA: every failure returns nil (no portrait), never throws. + Every failure returns nil (no portrait) rather than throwing. Why: PORTRAIT-COMPOSITOR.md, \"Rendering on the server\"." (:require [clojure.java.io :as io] [clojure.string :as s] @@ -37,7 +37,7 @@ (defn asset-source "{:mime :bytes} for an asset URL: a base64 data URI decoded in place, or a site-absolute path read off the classpath under public/. nil for anything else. - GOTCHA: never fetches an off-site URL; a share card must not make the server fetch one." + An off-site URL is never fetched." [uri] (or (parse-data-uri uri) (when (and (string? uri) (s/starts-with? uri "/") (not (s/includes? uri ".."))) @@ -153,7 +153,7 @@ (def ^:private load-face "The java.awt.Font at classpath `path`, parsed once per path; nil if it will not load. - GOTCHA: ship the face; a logical family like SANS_SERIF depends on the host's fonts." + Faces ship with the app, so rendering does not depend on the host's fonts." (memoize (fn [path] (try @@ -329,8 +329,15 @@ (finally (.dispose g))) (recur dst))))) -(defn- rgb [[r g b]] (Color. (int r) (int g) (int b))) -(defn- rgba [[r g b a]] (Color. (int r) (int g) (int b) (int a))) +(defn- rgb + "An [r g b] vector as an opaque java.awt.Color." + [[r g b]] + (Color. (int r) (int g) (int b))) + +(defn- rgba + "An [r g b a] vector, a out of 255, as a java.awt.Color." + [[r g b a]] + (Color. (int r) (int g) (int b) (int a))) (defn- paint-ground! "Fills a `w`x`h` frame with layout/example-ground, the portrait frame's gradient." diff --git a/src/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc index 9d270783f..f1c4639c0 100644 --- a/src/cljc/orcpub/dnd/e5/artist_profile.cljc +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -19,7 +19,9 @@ (or (:artist/slug artist) (some-> (:artist/id artist) name))) -(defn- registry-entry [artist-id] +(defn- registry-entry + "The registry entry for `artist-id`, without deployment overrides, or nil." + [artist-id] (some #(when (= artist-id (:artist/id %)) %) pa/registry)) (defn profile-enabled? @@ -56,10 +58,14 @@ [] (into [] (keep (comp profile :artist/id)) pa/registry)) -(defn profile-path [artist] +(defn profile-path + "The address of `artist`'s profile page, /artists/." + [artist] (route-map/path-for route-map/artist-page-route :slug (slug artist))) -(defn index-path [] +(defn index-path + "The address of the list of artists, /artists." + [] (route-map/path-for route-map/artists-page-route)) (defn example-path @@ -106,7 +112,9 @@ (sort-by #(- (get pa/layer-credit-weight (:layer %) 1))) vec)) -(defn piece-count [artist] +(defn piece-count + "How many pieces `artist` drew, the generated scalp not counted." + [artist] (reduce + (map (comp count :pieces) (pieces-by-layer artist)))) ;; ---------- links ---------- @@ -141,7 +149,9 @@ (def example-count 3) -(defn- pick [rnd v] +(defn- pick + "An element of `v` chosen by `rnd`, a fn returning numbers in [0,1)." + [rnd v] (nth v (int (Math/floor (* (rnd) (count v)))))) (defn example-portraits @@ -186,10 +196,14 @@ ;; ---------- share tags ---------- -(defn page-title [artist] +(defn page-title + "The share-card title of `artist`'s profile: \" — portrait artist\"." + [artist] (str (:artist/name artist) " — portrait artist")) -(defn share-description [artist] +(defn share-description + "The share-card description of `artist`'s profile, naming them and their piece count." + [artist] (let [n (piece-count artist)] (str "Portrait art by " (:artist/name artist) ": " n " hand-drawn " (if (= 1 n) "piece" "pieces") diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc index 6bdd35364..71b2c0237 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -3,7 +3,7 @@ Assets are {:asset/id … :asset/url …}, stacked in `layer-order` and tinted per `render-mode`. Attribution resolves each selected asset to its artist through `registry`: see `credit-order` and `credit-line`, shared by every surface. - GOTCHA: the files in the repo are silhouettes; the real art is never committed. + The files in the repo are silhouettes; the real art is never committed. Why the code is shaped this way: PORTRAIT-COMPOSITOR.md, PORTRAIT-TINTING.md." (:require [clojure.string :as s])) @@ -62,7 +62,7 @@ (def asset-inventory "{layer-key [{:asset/id :asset/label :asset/file}]}: the illustrator's ten layers and twenty-eight pieces under their own filenames, plus the generated :scalp. - GOTCHA: the files in the repo are silhouettes at the real art's paths; + The files in the repo are silhouettes at the real art's paths; scripts/build-portrait-assets.py overwrites them from the originals." { :hair-bits @@ -330,8 +330,8 @@ (defn drawable-layers "The entries of a selection whose asset ids resolve in the registry; nil for a non-map. - GOTCHA: a non-empty selection can draw nothing (a removed id, a pack this deployment - lacks), so test drawable?, not `seq`." + A non-empty selection can draw nothing (a removed id, a pack this deployment lacks); + drawable? tells the two apart." [layers-selection] (when (map? layers-selection) (into {} @@ -445,7 +445,9 @@ #?(:clj (bit-and 0xffffffff (unchecked-multiply a b)) :cljs (unsigned-bit-shift-right (js/Math.imul a b) 0))) -(defn- char-code [c] +(defn- char-code + "The UTF-16 code unit of character `c`, the same number on both platforms." + [c] #?(:clj (int c) :cljs (.charCodeAt c 0))) (defn seed->int diff --git a/src/cljc/orcpub/dnd/e5/portrait_layout.cljc b/src/cljc/orcpub/dnd/e5/portrait_layout.cljc index 863d1f86e..42dc4afaa 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_layout.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_layout.cljc @@ -1,8 +1,8 @@ (ns orcpub.dnd.e5.portrait-layout "The geometry shared by the three portrait renderers (DOM drawer, browser canvas export, Java2D share card): where a layer lands and where the credit and site mark sit, in frame - pixels. Nothing here draws. GOTCHA: a renderer must ask here, never derive its own - numbers. See PORTRAIT-COMPOSITOR.md, \"Three renderers, one geometry\".") + pixels. Nothing here draws; each renderer takes its numbers from here. + See PORTRAIT-COMPOSITOR.md, \"Three renderers, one geometry\".") (defn contain-rect "[x y w h] where a `sw`x`sh` asset lands in a `w`x`h` frame: scaled to fit, centred, diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 1cbc922ae..bdb2472c6 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -2136,7 +2136,7 @@ "The :set-character db fn: put a DIFFERENT character on screen (open, clone, randomize, New), reconciled, dropping the portrait draft. Modifying it is :character-updated. Re-opening the same saved (non-nil) id keeps the draft, as a courtesy only. - GOTCHA: do not infer a switch from ids; the caller's choice of event decides. + Whether it is a switch is the caller's choice of event, never inferred from ids. See PORTRAIT-COMPOSITOR.md, \"The draft and the character on screen\"." [db [_ character]] (let [id (:db/id character) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index c82bd528f..70cb31baa 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -132,7 +132,7 @@ "Returns an onClick handler that generates and submits the PDF. plugin-data is pre-subscribed by the calling component. A composed portrait is first baked with portrait/rasterize and posted with its credit as :portrait-png and :portrait-credit, so - the submit is async. GOTCHA: a failed bake still submits, without the picture." + the submit is async. A failed bake still submits, without the picture." [built-char id plugin-data & [options]] (fn [_] (let [field (.getElementById js/document "fields-input") diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs index cce5836dc..949d4d29b 100644 --- a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -21,7 +21,10 @@ [:style portrait/drawer-styles]] body)])) -(defn- link-button [{:link/keys [label url icon color]}] +(defn- link-button + "One of the artist's listed links as a button: its mark, if it has one, and its label. + Opens in a new tab." + [{:link/keys [label url icon color]}] [:a.ap-link {:href url :target "_blank" :rel "noopener"} (when icon [:span.lk-ico {:aria-hidden true @@ -30,7 +33,9 @@ :maskImage (str "url(/image/social/" icon ".svg)")}}]) label]) -(defn- names [artists] +(defn- names + "The names of `artists` as prose: A / A and B / A, B and C." + [artists] (let [n (count artists)] (for [[i a] (map-indexed vector artists)] ^{:key (:artist/id a)} @@ -43,14 +48,19 @@ [artist n alt] [:img.ap-example-img {:src (ap/example-path artist n) :alt alt :width 240 :height 300}]) -(defn- example [artist i p] +(defn- example + "Example `i` (from 0) of `artist`: its server-made image and, when portrait `p` borrows + pieces from other artists, a caption naming them." + [artist i p] (let [others (ap/others-in p (:artist/id artist))] [:figure.ap-example [:div.ap-frame [example-img artist (inc i) (str "Example portrait " (inc i))]] (when (seq others) [:figcaption "with pieces by " (names others)])])) -(defn- profile-card [{:keys [:artist/id :artist/name] :as artist}] +(defn- profile-card + "The profile of `artist`: name, piece count, listed links, the examples and the footer." + [{:keys [:artist/id :artist/name] :as artist}] (let [links (ap/listed-links artist) n (ap/piece-count artist)] [:article.ap-card From f3f2ca5f0a0525af32174f795983b66d280f0cc2 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Sun, 11 Oct 2026 02:48:00 +0000 Subject: [PATCH 49/91] Prove the artist tests fail, and check the watermark is drawn AGENTS.md now asks that a test be seen to fail before it is committed. Every test added for the artist examples and the pieces-grid removal was run against a break of what it guards; each failed on the expected checks only, and passed again with the source restored. The record is in docs/design/artist-profiles/test-fail-states.md for the PR. The example test never checked the watermark, so dropping it passed. It now compares each served image with the same example rendered without one, and fails when the watermark is not drawn. --- .../artist-profiles/test-fail-states.md | 47 +++++++++++++++++++ test/clj/orcpub/artist_page_test.clj | 10 +++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 docs/design/artist-profiles/test-fail-states.md diff --git a/docs/design/artist-profiles/test-fail-states.md b/docs/design/artist-profiles/test-fail-states.md new file mode 100644 index 000000000..7e228973b --- /dev/null +++ b/docs/design/artist-profiles/test-fail-states.md @@ -0,0 +1,47 @@ +# Artist pages: tests seen failing + +`AGENTS.md`, "Tests prove their fail state": every test added for the artist pages' examples +and the pieces-grid removal was run against a deliberate break of what it guards, seen to fail +on the expected checks only, and passed again once the source was restored with +`git checkout HEAD -- src`. This is the record for the PR description. Run 2026-10-11 on +`claude/artist-profile-pages` at `4db0f040`, plus the strengthened watermark assertion below. + +One test was weak and was strengthened first: `the-examples-are-small-watermarked-jpegs` +never checked the watermark, so dropping it would have passed. It now compares each served +image with the same example rendered without one. + +## JVM (`lein test`) + +| Break | Test | Failed | +|---|---|---| +| Examples use each layer's first piece instead of rotating | `between-them-the-examples-use-every-piece` | 1, plus 9 in `examples-are-whole-drawable-and-different`, which guards the same rotation | +| `mulberry32` adds where the reference XORs (the old code) | `the-seeded-randomness-is-the-same-on-both-platforms` | 1 (the generator pin) | +| Watermark leads with the name instead of the link host | `the-examples-are-watermarked-with-her-site-and-ours` | 2 | +| `example-path` counts from 2 | `examples-have-their-own-addresses` | 1 | +| Route is `/example/` instead of `/examples/` | `the-route-map-round-trips`, `examples-have-their-own-addresses` | 2 | +| Examples rendered at 600×750 | `the-examples-are-small-watermarked-jpegs` | 3 (one per example) | +| Served as `image/png` | `the-examples-are-small-watermarked-jpegs` | 3 | +| Watermark not drawn | `the-examples-are-small-watermarked-jpegs` | 3 | +| Cache removed (`memoize` → `identity`) | `each-example-is-rendered-once` | 1 | +| `example-count` 4 | `there-are-only-three-examples` | 1 | +| Handler ignores the slug and the off switch | `a-page-the-artist-turned-off-is-a-404`, and `an-unknown-slug-is-a-404` | 2 | +| `og:image` points at example 2 | `the-profile-carries-its-own-share-tags` | 1 | + +## ClojureScript (`node scripts/test/run-cljs-tests.js`) + +| Break | Test | Failed | +|---|---|---| +| `seed->int` as it was: `(int c)` and a plain multiply | `the-seeded-randomness-is-the-same-on-both-platforms` | 2 (both hash pins). The JVM passes this break: the bug was browser-only. | + +## Browser (`test/browser/artist_profile_e2e.js`) + +| Break | Failed checks | +|---|---| +| Profile examples composed in the browser again | `[dark]`/`[light] every example is a small image the server made`; `[dark]`/`[light] no layer art is downloaded` | +| The artist list's card composed in the browser again | `the list downloads no layer art either` | +| Lede without the count | `[dark]`/`[light] the lede still says how many pieces she drew` | +| `og:image` points at example 2 | `og:image is the page's first example` | +| Served as `image/png` | `the examples are served as JPEGs` | + +After every break: source restored, worktree clean, and the full run green (JVM 708 tests, +cljs 542, browser 46 checks). diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj index e33950686..3dec79af9 100644 --- a/test/clj/orcpub/artist_page_test.clj +++ b/test/clj/orcpub/artist_page_test.clj @@ -4,7 +4,9 @@ (:require [clojure.test :refer [deftest is testing]] [clojure.string :as s] [orcpub.routes :as routes] - [orcpub.dnd.e5.portrait-assets :as pa]) + [orcpub.dnd.e5.artist-profile :as ap] + [orcpub.dnd.e5.portrait-assets :as pa] + [orcpub.portrait-render :as render]) (:import (javax.imageio ImageIO))) (defn- req [slug] @@ -42,7 +44,11 @@ (is (= "noai, noimageai" (get-in resp [:headers "X-Robots-Tag"])) n) (is (= [-1 -40] (vec (take 2 bytes))) (str n " is a JPEG")) (is (= [240 300] [(.getWidth img) (.getHeight img)]) - (str n " is about the size the page shows it, not larger"))))) + (str n " is about the size the page shows it, not larger")) + (is (not= (seq bytes) + (seq (render/render-example-jpeg + (get (ap/example-portraits :house-pack) (dec (parse-long n))) ""))) + (str n " carries a watermark: it differs from the same picture without one"))))) (deftest each-example-is-rendered-once (testing "the same bytes, not a fresh render, on every request" From 05e2dfb72f21202b207973faad4ef34b5910cf80 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 00:32:54 +0000 Subject: [PATCH 50/91] Review items: images recorded, failed examples retried, What's New drafted - test/reviewed-images.edn records the 354 artist-profiles images and the 249 style-guide captures, as owner-reviewed: silhouette composites, CSS renders, icons and site UI screenshots. None holds the licensed art. - The image guard built its failure message on every pass too, scoring each image for linework, so the test took over 30 minutes; it now scores only a miss and takes under 30 seconds. With one entry removed it fails on that image alone. - A failed example render was kept until restart. Only a success is kept now; a-failed-render-is-tried-again failed before the fix (the cached nil was served, no second render) and passes after. - What's New: one entry for the portrait builder and the artist pages, held in `unreleased` until the owner names a release. Its test failed with the entry moved into a release. - PLAN.md: the committed example images were made from the silhouettes. --- docs/design/artist-profiles/PLAN.md | 6 +- src/clj/orcpub/routes.clj | 20 +- src/cljc/orcpub/whats_new.cljc | 12 + test/clj/orcpub/artist_page_test.clj | 13 + test/clj/orcpub/portrait_art_guard_test.clj | 14 +- test/cljc/orcpub/whats_new_test.cljc | 10 + test/reviewed-images.edn | 1810 ++++++++++++++++++- 7 files changed, 1870 insertions(+), 15 deletions(-) diff --git a/docs/design/artist-profiles/PLAN.md b/docs/design/artist-profiles/PLAN.md index b5a990607..157c9f304 100644 --- a/docs/design/artist-profiles/PLAN.md +++ b/docs/design/artist-profiles/PLAN.md @@ -208,8 +208,10 @@ the artist's host and the site's mark tiled diagonally across it, as a JPEG at q The first is also the `og:image`. The browser probe checks that neither artist page fetches any layer file, and a unit test that the three examples use all 28 pieces. -They cannot be generated into the repo: they are made from the real art, which is never -committed. A server start makes them from whatever art the deployment carries. +They are not generated into the repo: a server start makes them from whatever art the +deployment carries. In this repository that is the silhouettes, and every example image or +capture committed under `docs/design/artist-profiles/` was made from them; the licensed art is +only on the private deployment branch. **Found on the way:** `seed->int` and `mulberry32` gave different numbers in the browser than on the JVM (a cljs character read as 0, and 32-bit multiplies lost precision in diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 2f91d443b..ae090d7f7 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -2543,14 +2543,20 @@ (artist-profile/example-path artist 1))}) (assoc (index-page-response request {:title "No such artist"}) :status 404))) -(def ^:private example-jpeg +(defonce ^:private example-jpegs (atom {})) + +(defn- example-jpeg "JPEG bytes of example `n` (1-based) for `artist-id`, watermarked with `text`; nil when - there is none. Rendered once per process and then served from memory." - (memoize - (fn [artist-id n text] - (some-> (artist-profile/example-portraits artist-id) - (get (dec n)) - (portrait-render/render-example-jpeg text))))) + there is none. A success is rendered once per process and then served from memory; a + failure is not kept, so the next request tries again." + [artist-id n text] + (let [k [artist-id n text]] + (or (get @example-jpegs k) + (when-let [jpeg (some-> (artist-profile/example-portraits artist-id) + (get (dec n)) + (portrait-render/render-example-jpeg text))] + (swap! example-jpegs assoc k jpeg) + jpeg)))) (defn artist-example-jpeg "Ring handler: example image :n (1 to example-count) of the artist at :slug, small, diff --git a/src/cljc/orcpub/whats_new.cljc b/src/cljc/orcpub/whats_new.cljc index 99167deda..9912c85a7 100644 --- a/src/cljc/orcpub/whats_new.cljc +++ b/src/cljc/orcpub/whats_new.cljc @@ -8,6 +8,18 @@ Items carrying the same `:group` are rendered under one heading, in the order they appear here.") +(def unreleased + "What's New items written ahead of a release that is not named yet. The panel reads only + `releases`; when the owner names the release, these move into its entry." + [{:group "Characters" + :icon "fa-user-circle" + :headline "Build a portrait for your character" + :detail "Pick or randomize layered art by a real illustrator, colour hair, skin, eyes and clothing, and add an ombré, freckles, glowing eyes or a torch-lit edge. The portrait shows on the sheet, the summary and shared links, credited to the artist."} + {:group "Characters" + :icon "fa-palette" + :headline "Meet the artists" + :detail "Each portrait artist has a page with their name, how many pieces they drew, a few example portraits and the links they chose. The credit under a portrait takes you there."}]) + (def releases [{:id "summer-patch-2026" :title "Summer Patch" diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj index 3dec79af9..62eb16ac5 100644 --- a/test/clj/orcpub/artist_page_test.clj +++ b/test/clj/orcpub/artist_page_test.clj @@ -57,6 +57,19 @@ (is (= (seq (body-bytes (example "fusspot" "2"))) (seq (body-bytes (example "fusspot" "2"))))))) +(deftest a-failed-render-is-tried-again + (testing "a render that fails is not kept: the next request renders again, and a success is kept" + (let [calls (atom 0)] + (with-redefs [ap/example-portraits (fn [_] [{:layers {}}]) + render/render-example-jpeg (fn [_ _] + (swap! calls inc) + (when (> @calls 1) (byte-array [1 2 3])))] + (is (nil? (#'routes/example-jpeg :cache-test-artist 1 "t")) "the first render fails") + (is (= [1 2 3] (seq (#'routes/example-jpeg :cache-test-artist 1 "t"))) + "the next request renders again instead of serving the failure") + (#'routes/example-jpeg :cache-test-artist 1 "t") + (is (= 2 @calls) "and the success is kept"))))) + (deftest there-are-only-three-examples (doseq [n ["0" "4" "x" "1.jpg" nil]] (is (= 404 (:status (example "fusspot" n))) (pr-str n)))) diff --git a/test/clj/orcpub/portrait_art_guard_test.clj b/test/clj/orcpub/portrait_art_guard_test.clj index 29f8be1de..57b9a9748 100644 --- a/test/clj/orcpub/portrait_art_guard_test.clj +++ b/test/clj/orcpub/portrait_art_guard_test.clj @@ -117,11 +117,15 @@ (println "recorded" (count found) "images to" reviewed-file "-- describe each new one")) (testing "every image is one a person has looked at: the licensed art must never be in this repository, in docs/ or anywhere else, and no measure tells it from a logo reliably" - (doseq [[path digest] found] - (is (and (= digest (get-in reviewed [path :sha256])) - (not (str/starts-with? (str (get-in reviewed [path :what])) "DESCRIBE"))) - (str path " (linework " (linework path) "%) is not a reviewed image. Look at it: if it" + ;; The message is built only on a miss: `is` evaluates it on a pass too, and scoring + ;; every image for linework takes seconds each. + (doseq [[path digest] found + :let [ok? (and (= digest (get-in reviewed [path :sha256])) + (not (str/starts-with? (str (get-in reviewed [path :what])) "DESCRIBE")))]] + (is ok? + (when-not ok? + (str path " (linework " (linework path) "%) is not a reviewed image. Look at it: if it" " contains the licensed portrait art, it must not be committed here (it belongs only" " on the private deployment branch). A silhouette composite, screenshot, logo or" " photo: REVIEWED_IMAGES=record lein test :only orcpub.portrait-art-guard-test," - " then say what it is in " reviewed-file))))))) + " then say what it is in " reviewed-file)))))))) diff --git a/test/cljc/orcpub/whats_new_test.cljc b/test/cljc/orcpub/whats_new_test.cljc index 7fc7e9edc..083ee32c7 100644 --- a/test/cljc/orcpub/whats_new_test.cljc +++ b/test/cljc/orcpub/whats_new_test.cljc @@ -53,3 +53,13 @@ (is (whats-new/unseen? "some-earlier-release"))) (testing "the current stamp is not shown it again" (is (not (whats-new/unseen? whats-new/current-release-id))))) + +(deftest unreleased-items-wait-for-a-release + (testing "they are ready to move into a release" + (doseq [{:keys [icon headline detail]} whats-new/unreleased] + (is (not (str/blank? headline))) + (is (not (str/blank? detail)) headline) + (is (and (str/starts-with? (str icon) "fa-") (not (contains? renamed-in-fa5 icon))) headline))) + (testing "and no release shows them until the owner names one" + (let [shown (set (for [r whats-new/releases i (:items r)] (:headline i)))] + (is (not-any? (comp shown :headline) whats-new/unreleased))))) diff --git a/test/reviewed-images.edn b/test/reviewed-images.edn index a7d623517..279c07ef4 100644 --- a/test/reviewed-images.edn +++ b/test/reviewed-images.edn @@ -131,4 +131,1812 @@ "resources/public/image/world-anvil.jpeg" {:sha256 "2ded7ce0c3fcd5cefb6758e1904dae4654ce23e9cc9e20811416652a92e67c18" :what "site background / partner image, added 2020-11-25, before the portrait art"} -} + "docs/design/artist-profiles/cards/column-dark-desktop.png" + {:sha256 "03fb84c8a3e26f625f593bdd783981f312052362a2693736a138ea5b84027253" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/column-dark-phone.png" + {:sha256 "e775aaba29a921e1e55ea2128c9b49f2f84b7523087252bec77e19ac15436d22" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/column-light-desktop.png" + {:sha256 "dff328ff1fd9f36c2cdea8877d82efeda541ef6ea21118cce8e1ac931df7d665" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/column-light-phone.png" + {:sha256 "0bddc007f750369049a875d7a0aaeab6a5ae4e11a60e57e1baf26d20f8dfd4c4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/example-0.jpg" + {:sha256 "78ce9e6b8dbd4e0e2365f5633c488f1210e81a343500c9a8da07886614f6a95b" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/example-1.jpg" + {:sha256 "6fcd50d02c02f673d4ac54af02abcf3b7308d3465e1af233a97c9734b44000ee" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/minimal-dark-desktop.png" + {:sha256 "f85d56fded51e42c73cf0ed0ab020cbbbd962b36076fb193224a220cc6133b37" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/minimal-dark-phone.png" + {:sha256 "725d0a1d40238c4ecd3e8ab80fdfd8d918263eb324e973abf65a060b92b7ce36" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/minimal-light-desktop.png" + {:sha256 "93aadd65d7ec8f12ae7d8f1debae3d6801b5cc98efd57f959688d146876c77c8" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/minimal-light-phone.png" + {:sha256 "0b7f17547ac33fda8f2645e854b4a72d84976869b591604ceecf609a24130d47" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/split-dark-desktop.png" + {:sha256 "09553589c78a0097b8de60bca008b098d906214e278ab14ba5dda40617cc55a3" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/split-dark-phone.png" + {:sha256 "c723456db7889673912b1aea0fe742a840bd6caa81edec3c3e439d3d8fa2d7a9" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/split-light-desktop.png" + {:sha256 "df54ab19dfcb184b1fb5a7ecb56f983bf4fd3f8dcce051e1e88fa94f8ee27ca8" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/cards/split-light-phone.png" + {:sha256 "f204669f29524425c1f29f40d7c529bc46578c13694fb05c86e6a0f1599ded8f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/example-1.jpg" + {:sha256 "b004b1e290c7774ffec181641e9946c26dae75cd0d18d8de8485f271929abf13" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/example-2.jpg" + {:sha256 "5cb5ca987b1467f45caddb5fe13476222d01aff0a6509b25c2f95360e02638e5" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/example-3.jpg" + {:sha256 "2c54ae40f135e11cec53e1d391ab2eab8cf8d1ca91ed57f338abd28434958691" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/page-index-dark-desktop.png" + {:sha256 "863f2a1e965d0d89e922e4ec129591b5a6c020b3ce322e3bac1a2bd4155587d9" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/page-profile-dark-desktop.png" + {:sha256 "bbcb011f7a0b3464be35c85ff47b7efdbc6ce54e5e6f241ab9cbbf8fc01934ba" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/page-profile-dark-phone.png" + {:sha256 "4efd9fdbf0191ff5c3f5e997883166975efe48071d898a97a5ebedf427312a69" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/page-profile-light-desktop.png" + {:sha256 "88dec8f608955878c36a5a52119a69b5fe12b725d0e19e51b0ce2dfa7f23e908" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/examples/page-profile-light-phone.png" + {:sha256 "f211b116d60486ace50333702fd932ed90af56e8514923e4deba72acaa8a2cea" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-index-dark-desktop.png" + {:sha256 "ab1e2d38ddc50819739ee63e7abe89cfadfac1528dcafa73b8c3a828b2cf833f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-index-dark-phone.png" + {:sha256 "49c8946ca5d982ea369037f07e448ea468d75cd5599bc0b6b1474f37116331d6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-index-light-desktop.png" + {:sha256 "f08190c28709a8955a629df60e59e1ac8d828b347cdb04e1cd3491f3058fa96d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-index-light-phone.png" + {:sha256 "7115af2a7f6680c6369308c8d7a95d046df86e64b23eb2620e4e671454180fd6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-profile-dark-desktop.png" + {:sha256 "fdb926f45716acac225a1e0de42061418b696a2e1b2a6285fc2fd68a929af2fe" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-profile-dark-phone.png" + {:sha256 "35e27a87d81abf1f3708b07eddc022400514da59a86b6f700f5bc45949564ea1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-profile-light-desktop.png" + {:sha256 "1ad087d4eaabf2b8a3ce027a0c24d51db47ab402a70c60f7f8db6b32b9825a64" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/after-profile-light-phone.png" + {:sha256 "0a6fc68592a227e7b6f067eea2b54e1e9398c4d3e9730c8dba525ff35d5ccf08" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-index-dark-desktop.png" + {:sha256 "ab1e2d38ddc50819739ee63e7abe89cfadfac1528dcafa73b8c3a828b2cf833f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-index-dark-phone.png" + {:sha256 "49c8946ca5d982ea369037f07e448ea468d75cd5599bc0b6b1474f37116331d6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-index-light-desktop.png" + {:sha256 "f08190c28709a8955a629df60e59e1ac8d828b347cdb04e1cd3491f3058fa96d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-index-light-phone.png" + {:sha256 "7115af2a7f6680c6369308c8d7a95d046df86e64b23eb2620e4e671454180fd6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-profile-dark-desktop.png" + {:sha256 "fdb926f45716acac225a1e0de42061418b696a2e1b2a6285fc2fd68a929af2fe" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-profile-dark-phone.png" + {:sha256 "35e27a87d81abf1f3708b07eddc022400514da59a86b6f700f5bc45949564ea1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-profile-light-desktop.png" + {:sha256 "1ad087d4eaabf2b8a3ce027a0c24d51db47ab402a70c60f7f8db6b32b9825a64" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/before-profile-light-phone.png" + {:sha256 "0a6fc68592a227e7b6f067eea2b54e1e9398c4d3e9730c8dba525ff35d5ccf08" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-index-dark-desktop.png" + {:sha256 "863f2a1e965d0d89e922e4ec129591b5a6c020b3ce322e3bac1a2bd4155587d9" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-index-dark-phone.png" + {:sha256 "e2865af58ac9f24fc3c0163572fe96a37c6c120562311db6a13e74d3bebdcaa0" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-index-light-desktop.png" + {:sha256 "325fd502f025cc6fdd4255c9f120c90176bfb7d3a6eb912e2f4b03668dbe815e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-index-light-phone.png" + {:sha256 "bdb842ff5ee5fb3f7c7f9fc075cab8f911b43c84b1a813088d48d92d7bdcd8f8" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-profile-dark-desktop.png" + {:sha256 "bbcb011f7a0b3464be35c85ff47b7efdbc6ce54e5e6f241ab9cbbf8fc01934ba" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-profile-dark-phone.png" + {:sha256 "4efd9fdbf0191ff5c3f5e997883166975efe48071d898a97a5ebedf427312a69" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-profile-light-desktop.png" + {:sha256 "88dec8f608955878c36a5a52119a69b5fe12b725d0e19e51b0ce2dfa7f23e908" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/examples-profile-light-phone.png" + {:sha256 "f211b116d60486ace50333702fd932ed90af56e8514923e4deba72acaa8a2cea" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-index-dark-desktop.png" + {:sha256 "ab1e2d38ddc50819739ee63e7abe89cfadfac1528dcafa73b8c3a828b2cf833f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-index-dark-phone.png" + {:sha256 "49c8946ca5d982ea369037f07e448ea468d75cd5599bc0b6b1474f37116331d6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-index-light-desktop.png" + {:sha256 "f08190c28709a8955a629df60e59e1ac8d828b347cdb04e1cd3491f3058fa96d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-index-light-phone.png" + {:sha256 "7115af2a7f6680c6369308c8d7a95d046df86e64b23eb2620e4e671454180fd6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-profile-dark-desktop.png" + {:sha256 "e2e159ea4322b33f6063e94ce374d726f77924664f2ca62dbb11e2c5faf8c1c9" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-profile-dark-phone.png" + {:sha256 "4660a4d8ad936ed26d7d8fbce2fa5359ad88f197e30d59fb52d4e8cb076c6f13" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-profile-light-desktop.png" + {:sha256 "6d5545e6559e8e04476a12a07cd3ac292f6e5878b3621184a4f82b864354763f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/garden/no-pieces-profile-light-phone.png" + {:sha256 "b708c4fccf15d8eee03e13582abaa18ab6ac9b8f1797be95d2c0f79e9c77d374" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-gi-delapouite-paint-brush.png" + {:sha256 "d5e8ef6cc7c518206a135d8d92b6abd9e0d499557ef7386a996e509f824344f9" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-gi-delapouite-palette.png" + {:sha256 "e137209ffb5e1b091643882eaa78c72a8160f95466cb514fe71644119b61fa9c" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-gi-lorc-castle.png" + {:sha256 "5c54a3d96c7573070085fee63269e94bd013ae8d08bb4e18822f4ae6981b9310" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-gi-lorc-quill-ink.png" + {:sha256 "9090727f6dd2432c05b79cbad17a3a7d218f3b35263bfa6a5a61a1cfd248332e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-gi-lorc-quill.png" + {:sha256 "5156b8ef423974e76a9d65465b6d5e83a392aa45d53fd43cd3c4dd4dfab2cc45" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-lu-globe.png" + {:sha256 "79e34e39df0ad99b4d1155828f502c53922a8d3f7a50d9ed9f61b9915dc9ef09" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-lu-house.png" + {:sha256 "966b52c0e0b606466f7852e997498e7199de1785c50d6a9fd7e3f301d9369000" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-lu-link.png" + {:sha256 "cc770f9f21760939cc7034ea6c891120eed358c6279c65053a73f16b6725eb1a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-lu-palette.png" + {:sha256 "aed7b4680b6c05c9d73504574a8db89565965a4f4f96cbbe71baa49454be6d0c" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-now-site.png" + {:sha256 "e9b002b7c771dcb14900e726e75775d1474f96e9436b27a2f5f840f747031b1f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-ph-feather.png" + {:sha256 "51ad0cdeee52363417e7cf2f9874b10f1ddf2778c611740475993118efe6928e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-ph-house-line.png" + {:sha256 "22fcc8ac9470df3027b48f773785b66bafbe9766a505a94bbe7a43add08a13bf" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-ph-paint-brush.png" + {:sha256 "3c33d0d9f790dd089d88df21ea00e09e20cdb69459415cad6ee09ce644f8c8c3" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-ph-pen-nib.png" + {:sha256 "9b467202227b86e9cc557bf2a526569d1c58797316661fcb9e3790512a12d5b1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-dark-tb-world-www.png" + {:sha256 "f01286a7f4ecb8fcef2ce1c2a71d50c925af5e0a12b91f8770394a086c1b905d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-gi-delapouite-paint-brush.png" + {:sha256 "fd6bd80f445422564dcd95ef82ba12e7d7d9c3215a6d32cc2ead3774c6cd34ca" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-gi-delapouite-palette.png" + {:sha256 "6aa3d91f2e6e2c8fa78aad8c18c9f5234f5f0cd4f8ffdbbdda9cb7bba0368e35" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-gi-lorc-castle.png" + {:sha256 "0683bb4f65982c0e2977b106a1597b718a41582aa5133a4b2dd5d69739555d2a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-gi-lorc-quill-ink.png" + {:sha256 "13b2e6fe4ad271b21ba707e009b30a855e3f813ee6d761bee774bbe74ac0cdd5" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-gi-lorc-quill.png" + {:sha256 "2b8d7693fbc746a1f2adda374946aa46b674c9c055e6f3dfb2e5837e660104d8" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-lu-globe.png" + {:sha256 "84097e5fb62d6774b3b48d9f9803580bd4589e07dda43d14f34b597d19724f82" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-lu-house.png" + {:sha256 "53b084cab338795e707815846eeebce21e17c898e7b443418c53dff6a19bb9ba" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-lu-link.png" + {:sha256 "24992a49c5414088843218c10c02e003a907d4b9fe22403283233ac09cdda8c1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-lu-palette.png" + {:sha256 "c0a0115d182b14cf5afc3e2a4b2f06fb4baa5e8fdd53d5ce810529ffcc467f24" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-now-site.png" + {:sha256 "31affe110a720955ef77a655b77279146e6d1a7b56ddf586c0617cb93ea27152" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-ph-feather.png" + {:sha256 "5d3aadec0d5cc82372b3aca2b939601cf846ad3bd20a4e1f913e0cd281b8d6d9" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-ph-house-line.png" + {:sha256 "4485ff7c3a4d10bc139107c97cc693095d6f50816fc208636d0a9113ca8cc9b0" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-ph-paint-brush.png" + {:sha256 "a277c73f2dfecf6b0aaecaf5de34770c66394375fa9ae877ee8bd7baa2985574" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-ph-pen-nib.png" + {:sha256 "fc29a9a8af41cd01c05ddf4c3950cce3188ad4c575369507b308914636e3593c" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/card-light-tb-world-www.png" + {:sha256 "5594c633f5f7a261fc8f4da0a977adb70b148c52a946bf9036f81bdef39c3a70" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-gi-delapouite-paint-brush.png" + {:sha256 "663b75fe7bedce1c9893427791e09e4a9cdff8c997f0d5817ca28ff6afb72217" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-gi-delapouite-palette.png" + {:sha256 "372968074671a74b194937b5ad51b1a684eb13c146c21c4b3fc6c65917444944" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-gi-lorc-castle.png" + {:sha256 "fadbf828274021b7cb2343315adb1dafb7952b354f67e771d1e2315bce21abbe" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-gi-lorc-quill-ink.png" + {:sha256 "d61c6f7ffd0ac7ac4977314224df39f55107e87cbc477edd6871bb97eeb1e17c" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-gi-lorc-quill.png" + {:sha256 "73578808267eaa905870680a279b624b6f020f55eee7edaf1b99416142101a4c" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-lu-globe.png" + {:sha256 "8f0a741125b498f8f5da155acc4f3f80a1df0419f7b9add46ef03d4f2608e48c" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-lu-house.png" + {:sha256 "1b622ca3f23e41eaebd564b475c93c031add2c356099c69d610ca777eb983ca7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-lu-link.png" + {:sha256 "f3f95c8685ca52774667ae69c637e2e9bb8b65e48bcf71eebcc08d98172e1bf8" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-lu-palette.png" + {:sha256 "7a7c718cbc90fd0317387c7218ab330298ee8c6d942878503a03bed55cb22408" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-now-site.png" + {:sha256 "e9ef7e6801a66b6a537912cb070b62930e8d912917ed77bce4bcc48c723a3da7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-ph-feather.png" + {:sha256 "0f0aec7d3b5aeadb9cb3e86313461a0970c641983191075f6762cb115ff7401a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-ph-house-line.png" + {:sha256 "8d64738a1187cc490e6d327a42d4a0aea85ab237d91b8ab2fbfd0c43591db8fe" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-ph-paint-brush.png" + {:sha256 "19f4979fe2efc75462876cc35eda72d923307a0458deed0eb7159f447ccbee52" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-ph-pen-nib.png" + {:sha256 "18ecbbd7af344d45fc416395ad3a9b267a759e264308877bf12f7dda8167769d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-dark-tb-world-www.png" + {:sha256 "2d45821878687ec88044aa2f28bb9c9cd847d823f50572c70059424466f8224f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-gi-delapouite-paint-brush.png" + {:sha256 "ea6ac56ba01d488b8b5b8f520c8482777af39fb31b1014caa5155c819b63bf13" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-gi-delapouite-palette.png" + {:sha256 "2be272632a069139010c82aa2c5be803d0a8e133780d60df094564b5cdb15c24" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-gi-lorc-castle.png" + {:sha256 "d07f6598743cf100e0f96e06ed92f6c535fd2b9470f14c191d8a52fd49ac2652" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-gi-lorc-quill-ink.png" + {:sha256 "a98b62ba32e393df613b7b7c2e2429094038dc8b7336892cb57b112d25d89c92" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-gi-lorc-quill.png" + {:sha256 "12924d805634f44b29564ca40f98326a4a44e393675c508eb17ab0823c4a0ea4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-lu-globe.png" + {:sha256 "1303bff6e3294a6b9d077e3ebdf46111d00ff590281f1b323d8982f4740dc4bd" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-lu-house.png" + {:sha256 "cc766cd781083a5b464cf9b72bea41b722f7a4b5926a720914f508b4dc7fd25b" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-lu-link.png" + {:sha256 "90049fbe435f40301eebdbefffe6ebae2a0e3fbec62de73213a55573282f66a3" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-lu-palette.png" + {:sha256 "1db96101fb5226520a2064131f1731d672b98a5f63176df3a79b8e45991fc5ca" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-now-site.png" + {:sha256 "9aaf4ef821c89cdb825f9d933fe9fe889bbe3fff4be3901897c3829561c7ec3d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-ph-feather.png" + {:sha256 "239dfa427fa7d38af4e91b77042e92b0198fc8986b7ea6478e9e6a1d1d1808be" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-ph-house-line.png" + {:sha256 "453cd398103bce92dd7472cb06b4b889c5f995a2e22c40fc64eb3f6df7ec4e26" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-ph-paint-brush.png" + {:sha256 "e447f0a4c6bafcbcca334766d513bebe729a67f3a557b4f71610352da5dccfd3" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-ph-pen-nib.png" + {:sha256 "5c077183b89360491680ac7502a91a3083da905b1f19434a9904200fb0ccc8ab" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/credit-light-tb-world-www.png" + {:sha256 "ba74c1ee19f89fef0b4e2988dd494949358f20ea9ca61fb8711a9c48eafdde6a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/family-dark-gameicons.png" + {:sha256 "5e68da0f456125c020f2cb1c647ec275b2232af70233f9b2cf533135978d8084" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/family-dark-lucide.png" + {:sha256 "8cded82f9686b6cf811d4e3c053bcdf9b9cf868ed6c6e0375dd70136e7d39748" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/family-light-gameicons.png" + {:sha256 "eaf71a60bf5e8f9264c79b6bc5d805a39b55718f554c0517fbcdf20d2d1ad548" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/icons/family-light-lucide.png" + {:sha256 "e2c237ea3c12896b044ee4a14494c1b0ba7a3115de1a87841810b2568060e212" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-amber.png" + {:sha256 "014d31053aa1be3684b66db030585db5e4cbaa6b2b9221c727af893c013282a7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-coral.png" + {:sha256 "dd2cdd2e4dee92770bcd030d5151d61c97af9be9fd1d9873abcc9a0396d35af6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-green.png" + {:sha256 "1270b547835a18f439c07905aab54c753e73a4f73887098b82a2249ff583e449" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-rose.png" + {:sha256 "ac2a1f3b7a95c566cae1bf48827ce43f0af6cbf3208fe43bdca3f187f385627f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-teal.png" + {:sha256 "44925ba53cc76ab409da3cbaa72b9dd14970abe8f4e35198e62cd83f32d8cebe" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-amber.png" + {:sha256 "4456b960ce76a8030ef2a785794cf34c08ce3b3e37ac542ed53389651171f305" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-coral.png" + {:sha256 "754ab8258d5cb33b5d879a4404a8e1035993221e41d059b1640cdff7c7aa2b3e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-green.png" + {:sha256 "bc97bf56e34119e94c897aecd552bd4f2a38f363ad3d9b053cf4c0c2ec529a92" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-rose.png" + {:sha256 "a7dfa13d088f7fbe091aca908693f0e1a680891a5e2e617289d710daf417588e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-teal.png" + {:sha256 "65f58257797c4ae915f592cf8bfe743041ec59f73634bed2f90a9f68cca35bac" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-amber.png" + {:sha256 "c50b072bb967cc3fc494924c043060a35b56109620f96c2a95c9b53e4dc8ad77" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-coral.png" + {:sha256 "4ab1fdbf9d45aaaff1a4402b4cfafc371306ced511b754e1dddbc4a2b846ee9d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-green.png" + {:sha256 "474ace6fb7ba87f2fae841751106bf6d8375df24dec5ee881069a7dae2c23206" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-rose.png" + {:sha256 "30f204b6b4a6808c4fae70f25c03df70450d3994b066fbcf5bc2499db977af0e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-teal.png" + {:sha256 "624799e305445633e607d456a91bf1835c23aea3e2c551a4b23ab30c2a0ae5bd" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-amber.png" + {:sha256 "91118d52c00de8fd2015ed94782a6bb3bc50a1e69e4a5e35164c34c0bd57c17e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-coral.png" + {:sha256 "d7e5c2fb904766089d652c6fd6e939bcbb1c841d54a88263f0235ddd21bb7392" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-green.png" + {:sha256 "18a71ef7806a2ec7a123a9c6291603a97ed055034c53ddb155940a416a435c24" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-rose.png" + {:sha256 "0abc937f26aa6f763a345490d98ac6b5a10557956c1f5eb7b46d453249303744" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-teal.png" + {:sha256 "019a991d36a902e054618b495dd934cf2936eb9e8365482970eabff6d26015b3" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-now-site-amber.png" + {:sha256 "2f61cb70749e80a09dc19b64b84719a35c3564ab6113acf0add6e07b24c2d69a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-now-site-coral.png" + {:sha256 "e0632e7ba5a43d31152ec5957991c4ad58266ee9ee2f9e5971ae56526d2eb416" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-now-site-green.png" + {:sha256 "98560b9132d06a2dcdb19dc48368fd998ae7d06f07c1498e4f96363a18f2ccb1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-now-site-rose.png" + {:sha256 "edb19cc09d37cf95b6a19c312ec5b4d35cb21455300c33f2b2ea5d1eed64c9be" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-now-site-teal.png" + {:sha256 "4bc5721a728243b1c2f2b0c590f5311932eddfd467e5c21c4f5cceb33f813fb4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-app-window-fill-amber.png" + {:sha256 "85a9a74cd0c80da79560f1c1593b8f4e65ed8f17f681003be4eb17565e3fccb0" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-app-window-fill-coral.png" + {:sha256 "1630db2e57a6abaa0aaa4b186638dc6ae677886e3033bda08eed544925018020" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-app-window-fill-green.png" + {:sha256 "05fc2a07c2226cce26998d9baee50d06a062e29a0f016bda9a2efc3eafaa5738" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-app-window-fill-rose.png" + {:sha256 "28f74d35929abc0221f487724ed2af4cea9c76ca2164a7b29506a925ccaf7b98" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-app-window-fill-teal.png" + {:sha256 "3966e1b0e1b77e102622069180b8e29446d705176dbd2f9306edc952aded77ff" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-browser-fill-amber.png" + {:sha256 "f5f9c134d2d4e0ec4cc5d95d98b8f1eae7e4939d9bd1d08de5eee2ae1ff15e0d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-browser-fill-coral.png" + {:sha256 "bc66992845dfd5b36d2ebc1752a9dcb936e5ddbf1d4fd0fa0b224845957313ad" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-browser-fill-green.png" + {:sha256 "7ec4c727f4274160c7325687e828f3ce26e81ff07608dc7afda3c0acb27946d2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-browser-fill-rose.png" + {:sha256 "cae740f2476290ac83275c4c9df1849e293ccf74cb9c4b61ee7b4ffd48f877a2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-browser-fill-teal.png" + {:sha256 "31bf21368c1acae8b780859841dfe2e8db341b6daaa07b52f99716a1eb47a82f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-compass-fill-amber.png" + {:sha256 "0226a3552df77f7ed71b4164d8652ddb15bc8f695c3869efb5315d470e10dc35" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-compass-fill-coral.png" + {:sha256 "7ac2638d5304386fccc70bd5a61e3084e1ed859015ca88e5cdc4a8f48970b1bc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-compass-fill-green.png" + {:sha256 "106a39549967284fd86cf0dfcba07a0dce5794e122aa698b762d81e07797298f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-compass-fill-rose.png" + {:sha256 "a970475005a190f2c014854d0534009eb647db42d57d8b806df0797e25dfc88a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-compass-fill-teal.png" + {:sha256 "93c0d40f2f537eed523e2f529f51a83635419969894d35cbd8d82e64b393dd61" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-house-fill-amber.png" + {:sha256 "c33368c7445b6aca5113f5f05cc035706d658f33bc4b1aad97de00ac86939686" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-house-fill-coral.png" + {:sha256 "7032cd677f6df7946b93c270b40cfb4d866577122835f306d8a74e200f95fb4f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-house-fill-green.png" + {:sha256 "f860ed70ff4346f75192d2da30b9f3871bbb57635cf6571ec6d3f13d12153f79" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-house-fill-rose.png" + {:sha256 "6baf007bbf1aa51415624a262737bc9b5d649930a67097362adb3b8529772894" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-house-fill-teal.png" + {:sha256 "7e7520f39785138e3469635972a85188a233b407ed6e92945d8818264297d5cc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-amber.png" + {:sha256 "8e0164d27e66d3c0aedea101fa44e51433369006f4a0c0ca71f7501d05609e45" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-coral.png" + {:sha256 "fd89218e12b3a0e12d3e4bf4d232fad67d253db5b21d9f0188e1f20b1f5fa204" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-green.png" + {:sha256 "6fc7f9c0e267d893f7be17058d2f9a3784f8ed4f78eebd3305ff5469c7e9d01f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-rose.png" + {:sha256 "db7c7b9a3e384148617b01794f97100649813818f21a319082d23c2b6eccc5d6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-teal.png" + {:sha256 "88ca6936ab1d3218a8e74b8eb29556dad3c3f8e0ada35df23d18ecaebacd2440" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-amber.png" + {:sha256 "9ed64d112cd087d80779afac2d7e60052158ac8ad2347d3830b1bc351287c8cc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-coral.png" + {:sha256 "ca31b3f0ff10ac7e456020d6f560daa72db9fbd49e2744cbd5b836f37489503b" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-green.png" + {:sha256 "a7e98729542d622b2694d7ee080e8a6b53d96516dc8117d255918d14b64f1024" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-rose.png" + {:sha256 "15b945d34473ea3d57f0ad3731403b5da5f9d9a65be591ee6139342c5498bbdb" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-teal.png" + {:sha256 "2de8d775447b30386e5f92dc572aebfc80ad6a06319144e6077b7694f97edb01" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-amber.png" + {:sha256 "3bdaafd320d6e7f960ef4e6c74e926d0a698965dd4ec676ba4de7ee0a74c1db0" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-coral.png" + {:sha256 "577e1620c018745237b9d0b09a14e42c7708b0285bee643824c64ce1760d4626" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-green.png" + {:sha256 "996a2ab3248588e2e8a77847d3c2249ed325fa0d302bd908659971a965769d94" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-rose.png" + {:sha256 "e73869e4739d0867c506e30c80746908096c67e1bdadadfb79c25923848f7945" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-teal.png" + {:sha256 "19a130ee11427e1850dc8b9f67d8f4a334bd88950ed4726ae5ac3672dd05b0d0" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-house-fill-amber.png" + {:sha256 "84e2afe13c1bb7c7c9c18cb830ca24638020280964095f3014a23d2fea1ff31f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-house-fill-coral.png" + {:sha256 "488a36279a1bca6bc53b88d31d3cd3225774100ae857d03bfa67205a28a34974" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-house-fill-green.png" + {:sha256 "430649ddb46e17b4f8b7c7fdeae51395fd3b17df5d5f9b25ea05012bacb6a5ef" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-house-fill-rose.png" + {:sha256 "e33ea621ce090be1d585f27c22b4253b2551b542e489396cec0eefea40321b72" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-badge-ph-house-fill-teal.png" + {:sha256 "11bac0b8b100786aa43c62b5374f0d7391383d2042c4c6797c35dca1d241ead5" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-amber.png" + {:sha256 "c5c843cc15bbff3f20fe516b6f69b2d07ecc92ab3def83a5511416549d23085d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-coral.png" + {:sha256 "d6f3e05b4b8f06f8a111771f1b47ef15567aeb7bae4c6a8bb5833d09ba78cd69" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-green.png" + {:sha256 "8fec823b8cdcb1ce615737d2c2971335b9e37b9f1885b7dfd9f85af737cf31b5" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-rose.png" + {:sha256 "18036d38996025715b02d4e9fc2b7cb83fc0f3fba45851097bacecc295a90829" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-teal.png" + {:sha256 "ca617380087521034e66b913bd8bbee0aff7e2ee0e49741688b0bc611e760938" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-now-site-amber.png" + {:sha256 "35df027699b0a5a01e4e19b4d246a91b9a6accf88c3e35a319e4da0946ee8aec" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-now-site-coral.png" + {:sha256 "7a7a7cec6a3c1828aa64e6d666df86f6fa263ba10732aafe24cfdf16273e3b25" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-now-site-green.png" + {:sha256 "047ec1796f178183c49cb768f769b11d9ccf2b7dba480efae07fb44a1fef78b4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-now-site-rose.png" + {:sha256 "0962f591aa7d0e7709cc2c896dc627e5a9a6882baeadb736f58b0cd3257aaf8b" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-now-site-teal.png" + {:sha256 "5c54de2063ff8faf7541beefab8ee9a2de1d06d7ef20e348e0df51ed92f7b5ae" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-app-window-fill-amber.png" + {:sha256 "86326769f455d88e87c93271969a1bf4be73ac3c9d0ce8c5cca2cb19c5482666" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-app-window-fill-coral.png" + {:sha256 "58f60de0d3de2ed7ef1a38424adffb42e930d0f4299846b688fd3fba9662efa1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-app-window-fill-green.png" + {:sha256 "632375be7330c8577da3df7f678e05bd0132553017ee138921a117d54a8a43e2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-app-window-fill-rose.png" + {:sha256 "e67d031befca1ed5acf5774852e368c36efacde3b736fd8a053593ae40ae77d7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-app-window-fill-teal.png" + {:sha256 "f4067c5136547ef427786cfa5eece5f47295033a6e85628b47d61abebe634c95" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-browser-fill-amber.png" + {:sha256 "6337e1c2107e9493eb7178f68b0bbd65d1f9c8527b35392a59a1214b5b174ecd" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-browser-fill-coral.png" + {:sha256 "fa909b396ab5c617169661d807edfb7b38b39bc997aaebe28e41a78dbc05f791" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-browser-fill-green.png" + {:sha256 "69aca7781910877b3f68ab27153ed5beda13576cb2b4b6d1eb15a0a5a74b945b" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-browser-fill-rose.png" + {:sha256 "d1474f467a70f9916ece5d90cdc96adb35e45387046dcbf61dced12b71229106" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-browser-fill-teal.png" + {:sha256 "4f4af564f22834511b205911cc67805a9cabd257ac3f432b902e707ec896fad2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-compass-fill-amber.png" + {:sha256 "948271eab09a379f7f58c905db205e103c1d5f854aa9e35819f37c5c6bf4040a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-compass-fill-coral.png" + {:sha256 "d5124525f926396975bf8eea7d458f96cf3de8ae54eae8d1f53c26bbca057d00" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-compass-fill-green.png" + {:sha256 "4d528f59dcda2e91428716214c6c063012c9887d591371efdc329094897dfd67" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-compass-fill-rose.png" + {:sha256 "3c053a61a0d321709f7ee3f178fdfa39aca68ea45eb8f1e82fc8870589aefb10" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-compass-fill-teal.png" + {:sha256 "5888a4b6f9e96aa46107809a7f3667fde7b13353f010c8469df49e27d02779ea" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-house-fill-amber.png" + {:sha256 "f5005b3bd4a4247933afc3bb6672b246d9824a774820f4a36606ec8aa79741a5" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-house-fill-coral.png" + {:sha256 "7027d0f99e62b14aed14f5687715a81184f5af31977712d024619cc5c376dbc1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-house-fill-green.png" + {:sha256 "79718f194e2785b6d64e3820c1a768056075024027767b43d81b760b106ad5c0" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-house-fill-rose.png" + {:sha256 "885cab6522d35ab1f457bba2095dae756cc3ec7445c0f281c801dfee9a19e6dc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-house-fill-teal.png" + {:sha256 "c5caa9bb705d6eeda53b4cba976ac2a7f9b176e21f40e930a2e787799c5df054" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-sparkle-fill-amber.png" + {:sha256 "8c18f3f1b34965da0ee386bea01f5471d5ce7519e11e7507644a52e505f4e722" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-sparkle-fill-coral.png" + {:sha256 "d3bd43c6f3ce5470a2200e575a4ab2c9626e0cf62ce20875a86e6c4452cd0514" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-sparkle-fill-green.png" + {:sha256 "4d6dc65795ab317a173d7d77ac20ac598c96d961db2829a30f98a88f903dddb2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-sparkle-fill-rose.png" + {:sha256 "e8b5e982997862409313ff0847bcd3ba39b9bc3d5d8a8a6ef9b2f7323a48f12e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/marks/light-ph-sparkle-fill-teal.png" + {:sha256 "abece46efc4952b233def90401e41d4fa4acbd3e1416ee3e885770c4440e39de" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-dark-ph-browser-fill-teal-paired.png" + {:sha256 "992fe5dafe1b4aca51cb38b9bc5222aee91b986a91623d53eaf1167838000a61" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-dark-ph-browser-fill-teal-shade.png" + {:sha256 "b3e9be779c6a197f75abd951c2b8523d1a2c8ce8e1370e962d7f4e0d23b3a394" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-dark-ph-sparkle-fill-yellow-paired.png" + {:sha256 "9df1bf9f2ca1f056ee3564f56d8375e3180c434754b648a93c2dbe32602666ba" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-dark-ph-sparkle-fill-yellow-shade.png" + {:sha256 "f0b3649c25ab3ac3702e94f4ac3e9b796e9b8f83c06aec96464f58f46255242e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-light-ph-browser-fill-teal-paired.png" + {:sha256 "5df6072ce2ef9fb8d15f15722c433d6599467666b6529b7494d527b5809ecd74" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-light-ph-browser-fill-teal-shade.png" + {:sha256 "810c4ad186c6bdcbf9cef77dd1be505f4dd637575cec9d9611012f61f76bdbfc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-light-ph-sparkle-fill-yellow-paired.png" + {:sha256 "a25cbcd1c30c2b2d967209f7124c95a2fb4d02386027f516222554293fe42d24" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picker/picker-light-ph-sparkle-fill-yellow-shade.png" + {:sha256 "f532007ac5b6d82dc9e4eeca305284e6c3405d86308f0e72d6dadf51f8ce56a6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-browser-fill-coral.png" + {:sha256 "14aff43bd8061ca81349e17cc6ded020ba6c3eb964871a0a1b22d0a75956f5aa" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-browser-fill-teal.png" + {:sha256 "9d0dc6f208b3473669251697dc111868ad610e14f0f34c5948b88540627dc0db" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-compass-fill-coral.png" + {:sha256 "84961050b58226fbfffee1ca1026411c14a44aa320e32f0b47b06bcd21fe7a27" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-compass-fill-teal.png" + {:sha256 "f78200d6b745febe7d13f6f0ed4e9655aebb9a62de191d61e0afc594fb0cf398" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-house-fill-coral.png" + {:sha256 "6a18dcf3891754e6445ece408747c0fad9303471602b166463a2206be1fb0955" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-house-fill-teal.png" + {:sha256 "e31993d23c8df66189062189d9d2f2ad0c41c21518f71b9c0416e75baf73290d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-sparkle-fill-coral.png" + {:sha256 "165aefb3e488f6d7dd7db64b8b8b5e1ed406c4f00c67de0c2670d2a3ede9cad5" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-dark-ph-sparkle-fill-teal.png" + {:sha256 "37b0e4333d490fe746afb73cdad839e56726d556902e7e4ffc09d07767949e10" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-browser-fill-coral.png" + {:sha256 "93675572cc90bae292d6f934eeefdcc87a0cc9d6531aa1f03eefcf44c3e33cac" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-browser-fill-teal.png" + {:sha256 "c880acf668a9d82726ab103b18dee95edb5c88df463fcf495c85a15817d22ac0" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-compass-fill-coral.png" + {:sha256 "417346465e13045fa3c56c1914a8ae2d62a3762f01607fbbaa86ac50a0df1262" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-compass-fill-teal.png" + {:sha256 "89934efc6d799648758822900afa735fd598bc3fc94b03a601965430871cb372" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-house-fill-coral.png" + {:sha256 "82b88c0d81eb27be7d7f9af1b354f4ee635814ffa167ae2cf8abb83f918c640a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-house-fill-teal.png" + {:sha256 "37c12e7b5e89b5e8cf9debddf101d569ba32f1df574c807d59960371021526ca" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-sparkle-fill-coral.png" + {:sha256 "d08524222cea17718148ec45aa142baadcbc753a4174996c6239634903600ffa" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/card-light-ph-sparkle-fill-teal.png" + {:sha256 "1cf992d9669feb101aa338136a7d5cc2d2108f604d1e36e7d305019477c53e7f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-browser-fill-coral.png" + {:sha256 "4ad71dd07519aa4c63263a1786aa35b4aa69b3abdb71939804aa85508911c11d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-browser-fill-teal.png" + {:sha256 "5c2a807b181f85c27c303a69d228a2454feb4dd7fb2eb6c0ef96f291588d66e8" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-compass-fill-coral.png" + {:sha256 "ad9cc773f14e51739c01dbb713d4e9e5305c882d046353fb69d07eb26a631ad7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-compass-fill-teal.png" + {:sha256 "5ef0f323f6f52edbbf4f5ffd1b3f4e3bfa2bb4cac3bd266eb1808e896d278976" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-house-fill-coral.png" + {:sha256 "0f8ffb59a577533ff384e6362b95bf604106a26aa5dab6421597c0b40758372e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-house-fill-teal.png" + {:sha256 "53d2197d2f1c2c8b6b8930d9d38ac1df52f89813e68ccfe50277dc84344684eb" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-sparkle-fill-coral.png" + {:sha256 "8678dcf96a9a1fcc66d90f7057dfbd8f42542ffb1044f410f6e054fc92f57bf6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-dark-ph-sparkle-fill-teal.png" + {:sha256 "2ff75b7ebd314fa1f240a58f59a31339537c9ab6f22dd7158bac091fc8c345f7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-browser-fill-coral.png" + {:sha256 "5ec10484eaff48da44f2e048eb80da6966da0b5777855d8932c8a8cdf6d33ee4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-browser-fill-teal.png" + {:sha256 "9c56328796a1f3179b0f2e9cd0ae7e6194a48c83275938b558fce07ad6732f60" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-compass-fill-coral.png" + {:sha256 "f1b4cb5d8183da6c971000c221d0db7858b9a6b5596e7f5d95d9ce94939f178e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-compass-fill-teal.png" + {:sha256 "ecf959dc3f085bacd33a7c0f54cffd7214fd8366b8750c8aa140ac5c15018e07" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-house-fill-coral.png" + {:sha256 "c5c38e3172e68da746e785595a3d722a9cd0fe62710447bd7566654622973e16" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-house-fill-teal.png" + {:sha256 "23a62f3dfec430beceb369c5224187a9ac71b1768b22536c6da495090c9642e3" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-sparkle-fill-coral.png" + {:sha256 "b24cb6ef63e4e3ec7fb75383c1cffaa7e10cfa1d37b0de279dbcedfb08d7cd20" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/picks/credit-light-ph-sparkle-fill-teal.png" + {:sha256 "e570cf7b53e601c83395f29e171e04f36c81bf218e9df2b4a017949a28c2f3ac" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/drawer-credit-dark-hover.png" + {:sha256 "4d5259ca37ceefdba9de0d040a9d52ee6617ad44d7b1f57e7b5dac0ada13b095" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/drawer-credit-dark.png" + {:sha256 "f917b143968ebd7edc0e4596da596a064927f31121351a2c416b990e15ab7219" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/drawer-credit-light-hover.png" + {:sha256 "62ca89ac8139cb79f59f43e3eae589d4cb82e3cb1af304e5174a24764601c425" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/drawer-credit-light.png" + {:sha256 "d41a6b56e8ce9fa0a58a8de0358b4fae5d3d3a1f823dd00cd2c6ae7a9352e82e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/index-dark.png" + {:sha256 "71cb7d51e46aec4bf21c9195440b8194cd68db5bd0f8e5a1d197e7fc3985e4fa" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/profile-dark.png" + {:sha256 "a8125b2e3a6ca40e6dd94ce707b926a514e5213e1762d38ec895c4988dba4e68" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/profile-light.png" + {:sha256 "69e579024d00023cafbf1be9c69e93183411280f4beb6c103a4e26889e4406fa" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/shots/profile-phone.png" + {:sha256 "063dd00472a7ab788bfb4d18de885c1757e40b00a3c3d0767576c25f3aaf0f9a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-bloom.png" + {:sha256 "99070dbba3fde99e92ec71042f9778eb025d9507c32279e6701d00339c9955fc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-ember.png" + {:sha256 "bc66992845dfd5b36d2ebc1752a9dcb936e5ddbf1d4fd0fa0b224845957313ad" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-gold.png" + {:sha256 "a2551b0b457bea39734f6a375c08c8ff3e7dc412578c08335a5ff87a6e47465a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-moss.png" + {:sha256 "7ec4c727f4274160c7325687e828f3ce26e81ff07608dc7afda3c0acb27946d2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-tide.png" + {:sha256 "31bf21368c1acae8b780859841dfe2e8db341b6daaa07b52f99716a1eb47a82f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-bloom.png" + {:sha256 "ae27daa1e085c41d09c4a107f4dc5065ffde63375477580fd59ac022a5db6815" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-ember.png" + {:sha256 "7ac2638d5304386fccc70bd5a61e3084e1ed859015ca88e5cdc4a8f48970b1bc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-gold.png" + {:sha256 "aed271fde5a18718491b01cfb71c1b916160be69fbcd21807be9840201523f14" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-moss.png" + {:sha256 "106a39549967284fd86cf0dfcba07a0dce5794e122aa698b762d81e07797298f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-tide.png" + {:sha256 "93c0d40f2f537eed523e2f529f51a83635419969894d35cbd8d82e64b393dd61" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-bloom.png" + {:sha256 "310a20cec2e5cf973b8753f40e8717fc538bd89024301f84d95f3f812c2c3061" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-ember.png" + {:sha256 "7032cd677f6df7946b93c270b40cfb4d866577122835f306d8a74e200f95fb4f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-gold.png" + {:sha256 "40f660abf0029ffa4800851d513948b55a9b750a4b4e1ce3d5a2a57345715791" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-moss.png" + {:sha256 "f860ed70ff4346f75192d2da30b9f3871bbb57635cf6571ec6d3f13d12153f79" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-tide.png" + {:sha256 "7e7520f39785138e3469635972a85188a233b407ed6e92945d8818264297d5cc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-bloom.png" + {:sha256 "79541762ed7fee04cb53d345d3e477c3e75894f7ba53620cc31e9965f39209c2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-ember.png" + {:sha256 "fd89218e12b3a0e12d3e4bf4d232fad67d253db5b21d9f0188e1f20b1f5fa204" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-gold.png" + {:sha256 "35f01e2d89bd0d66e62da9b91161cd8e347bc0a93bd3cbcaa00443486ff115c7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-moss.png" + {:sha256 "6fc7f9c0e267d893f7be17058d2f9a3784f8ed4f78eebd3305ff5469c7e9d01f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-tide.png" + {:sha256 "88ca6936ab1d3218a8e74b8eb29556dad3c3f8e0ada35df23d18ecaebacd2440" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-bloom.png" + {:sha256 "249a46e88da3891fb449294865910f145eeeb97625d7d9d29525c0f6e11ade87" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-ember.png" + {:sha256 "f179b8c4a56f3405da94a1ca43a09a90796b7dbc0532e3d17b5376a69e43e317" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-gold.png" + {:sha256 "ad0f6bef130058ca14124787dd2d176f099de58ecf2e21bcf8c22d5396b074e8" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-moss.png" + {:sha256 "3e288e5c95c781a29c46b9ef3027cb8835b3266d54bd8e1794d77f7c2cfe96af" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-tide.png" + {:sha256 "c7245e5af658ca36ba039e634115d8475277a98aee48f56f20ce3db1dba07afa" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-bloom.png" + {:sha256 "6e408fbb81070a509474c63a2db9141fdd26d8e11989c70c83cbf475a6c38083" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-ember.png" + {:sha256 "119bd5ab4b471e068b8d0da33907226b11d45296de6cbfc88115d89433b559a4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-gold.png" + {:sha256 "b07941b9c1cc6d3080d8f5664a36295243cd0e45173369e99db7e3286976c421" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-moss.png" + {:sha256 "91d00b4d2cecf7060019e6895ac6e84a2ba91a714e0348210427eb9d38bd21c4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-tide.png" + {:sha256 "66dcdc21d527379e01aaffdce4c14c5d7493f142bc83805c16713cf00edbf4ee" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-bloom.png" + {:sha256 "06278b2caed1e69f4b5132fa6a4ff298fe93821463fb371df728e08b6cfc822d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-ember.png" + {:sha256 "bd4119ca024935eb7a62015d54f67ac45a9cff28c1c9b37f686800c2f25e4ad9" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-gold.png" + {:sha256 "43c22cc0bb7b58c112e13f0f5815a4038dfcf60fd83795c73e853b616fea95b6" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-moss.png" + {:sha256 "889a6f590ee4fce244cae4cec0a3d31422b1b737885e10a50be0d6646ae81e50" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-tide.png" + {:sha256 "8b58d4e74ae72ec154c96f67bc6f9a74a44bb8f6394f2b9cde253841bfb71424" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-bloom.png" + {:sha256 "faf506ed1e95fc5c6adcc5646b9cbca783a09769c3805fdc35c286071550ef12" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-ember.png" + {:sha256 "f8aa1eb0b56f1e269a4fb5e1a9732af8378d478873d84940108f492154a6b33c" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-gold.png" + {:sha256 "e6478003249f0c163622cbae523d931319208f6317d7d060b4eaa5e18405458f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-moss.png" + {:sha256 "07572c87c2e49efbe1b0a5b8210c5fd64685a05b7e182548298f3d3758c58bd4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-tide.png" + {:sha256 "83b48d32a44604686e6dbb0a4e0fd1d36b22f1af6b577e6d3fbfd469967e2820" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/panel-dark-ph-browser-fill-tide.png" + {:sha256 "501595599e37ca4063006adc469379f0b5c3e1c87684a91202e396881c7a589b" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/panel-dark-ph-sparkle-fill-gold.png" + {:sha256 "4231cda84004b721fba789cefab983961edd713e53990aa47cf2be70ee45f7ae" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/panel-light-ph-browser-fill-tide.png" + {:sha256 "846f4892f73ac2c51c0fbfbec2bf37ebd57eaaaf1c08d05a8e1c965c4d975a93" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches-rehue/panel-light-ph-sparkle-fill-gold.png" + {:sha256 "bb6a9314f5fc982d0d1a5a1dded3c596d5edad274d500d8720187238ef2a0cdb" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-coral.png" + {:sha256 "bc66992845dfd5b36d2ebc1752a9dcb936e5ddbf1d4fd0fa0b224845957313ad" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-green.png" + {:sha256 "7ec4c727f4274160c7325687e828f3ce26e81ff07608dc7afda3c0acb27946d2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-rose.png" + {:sha256 "99070dbba3fde99e92ec71042f9778eb025d9507c32279e6701d00339c9955fc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-teal.png" + {:sha256 "31bf21368c1acae8b780859841dfe2e8db341b6daaa07b52f99716a1eb47a82f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-yellow.png" + {:sha256 "a2551b0b457bea39734f6a375c08c8ff3e7dc412578c08335a5ff87a6e47465a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-coral.png" + {:sha256 "7ac2638d5304386fccc70bd5a61e3084e1ed859015ca88e5cdc4a8f48970b1bc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-green.png" + {:sha256 "106a39549967284fd86cf0dfcba07a0dce5794e122aa698b762d81e07797298f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-rose.png" + {:sha256 "ae27daa1e085c41d09c4a107f4dc5065ffde63375477580fd59ac022a5db6815" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-teal.png" + {:sha256 "93c0d40f2f537eed523e2f529f51a83635419969894d35cbd8d82e64b393dd61" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-yellow.png" + {:sha256 "aed271fde5a18718491b01cfb71c1b916160be69fbcd21807be9840201523f14" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-coral.png" + {:sha256 "7032cd677f6df7946b93c270b40cfb4d866577122835f306d8a74e200f95fb4f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-green.png" + {:sha256 "f860ed70ff4346f75192d2da30b9f3871bbb57635cf6571ec6d3f13d12153f79" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-rose.png" + {:sha256 "310a20cec2e5cf973b8753f40e8717fc538bd89024301f84d95f3f812c2c3061" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-teal.png" + {:sha256 "7e7520f39785138e3469635972a85188a233b407ed6e92945d8818264297d5cc" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-yellow.png" + {:sha256 "40f660abf0029ffa4800851d513948b55a9b750a4b4e1ce3d5a2a57345715791" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-coral.png" + {:sha256 "fd89218e12b3a0e12d3e4bf4d232fad67d253db5b21d9f0188e1f20b1f5fa204" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-green.png" + {:sha256 "6fc7f9c0e267d893f7be17058d2f9a3784f8ed4f78eebd3305ff5469c7e9d01f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-rose.png" + {:sha256 "79541762ed7fee04cb53d345d3e477c3e75894f7ba53620cc31e9965f39209c2" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-teal.png" + {:sha256 "88ca6936ab1d3218a8e74b8eb29556dad3c3f8e0ada35df23d18ecaebacd2440" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-yellow.png" + {:sha256 "35f01e2d89bd0d66e62da9b91161cd8e347bc0a93bd3cbcaa00443486ff115c7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-coral.png" + {:sha256 "42d84f3ce361fb81fd64ebf7a2ef1a9124df559c43c51e9d2b6cbfcc24f031be" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-green.png" + {:sha256 "a25b0ef213f138a25f4a9abc326e15826659e66a9888b12bef17bfcac6671032" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-rose.png" + {:sha256 "14d51e136c32b71cc174b3dd219f93cc9ae5b3cfa2bcce78c487612d2b57c6b7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-teal.png" + {:sha256 "711971daaf3ae1268e14bdda0bb08866317428fb52efb2c206caaca0c20984f7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-yellow.png" + {:sha256 "c6a2944edb2003d9f512490e76a6febe15735b67d3dca066e4be3533e569b94d" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-coral.png" + {:sha256 "c8c3ee597101a950086dedb3602d8b3e72e94edc35b2fbbe394ae205075cde46" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-green.png" + {:sha256 "c9a22ed41c50eb3dda48a2b4ea1f5ede03c788efe1b84266e27a9a69127b2b0f" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-rose.png" + {:sha256 "feaff0fb4b222f0a31f8bb9e0ebf1710c6a9fa6f33740b11ce6cecaa03a32e57" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-teal.png" + {:sha256 "495de7e23203f4202120509f022d46248455e25757d6b89344e4ab8fb5360fda" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-yellow.png" + {:sha256 "f2b7c8433cd3bc3a9f3c0faa8010a7c560763780e7c1b0deab9b9673b6713ba1" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-coral.png" + {:sha256 "7dcfb7ef4fb771f8a742e08809c477b1d17dffad18ab324f87c34f2a38fe8599" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-green.png" + {:sha256 "808d9852cd2e3d0c23d2a9507f6b3a55f71bf90dfb7b835e4de67a354ff704a4" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-rose.png" + {:sha256 "7222beb5d0fc417e8b2b97263d7c387746346361da1db182ca5d56d650a68ba7" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-teal.png" + {:sha256 "b4d3d6bc92952184096be95162bc737a0b419dd1134b259250360915df8c3fec" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-yellow.png" + {:sha256 "56ba1a186e9ecab99c7ae3b3addc3b1f4c9860a7b8c26ab6b5f3730528792996" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-coral.png" + {:sha256 "4d890b346dd28f445a4718937a125d785666817663ae46c038bafae6f12ffb72" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-green.png" + {:sha256 "aa4b24ac5d6a7ec7510f5752190d45a9886b5ed41d92949f1fae17e5c1e24957" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-rose.png" + {:sha256 "ae801f4e3182ee33092ca9ce50d2ff28437ca64e1c69e5d8eade99eebfa11bdb" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-teal.png" + {:sha256 "d9b20a6d75ebc79026d2cdd1ecd12ae852ee1bedd1db0372f31f731963fe3333" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-yellow.png" + {:sha256 "99743f1e74c378e397d2704be9240a8f213ff7e8382475520dc8245183b5d0bb" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/panel-dark-ph-browser-fill-teal.png" + {:sha256 "7fbc8bb106de526be7996a632420388d92936bbc1cbbee682657723edbcea70e" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/panel-dark-ph-sparkle-fill-yellow.png" + {:sha256 "a9641791195539ffd6f6822f6847dafaf03ba470b94872eed7193e9239d5b44a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/panel-light-ph-browser-fill-teal.png" + {:sha256 "a375b9eac0c6e8ea5f7e3833fef5afd5e431ec0a0bececbfd7e2f851e965b795" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/artist-profiles/swatches/panel-light-ph-sparkle-fill-yellow.png" + {:sha256 "e6cb1987098e5badfb2331a76b88d27ba24337d9e767d7619b3b201067b89f8a" + :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} + "docs/design/style-guide/bold/dark-after-builder.png" + {:sha256 "de0f77e4eb4e172e176e48e7e8998ab958305e5bce8bae893308f6401a1d188a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/bold/dark-after-button.png" + {:sha256 "46ac76c6431a9fad830ff75fc19705edf136870d0113157a58bb47bae96e2f61" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/bold/dark-before-builder.png" + {:sha256 "37979b43879694ed303491ca3318dfd00f0f2ae3d2b356efa892446ed5c03dc4" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/bold/dark-before-button.png" + {:sha256 "46ac76c6431a9fad830ff75fc19705edf136870d0113157a58bb47bae96e2f61" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/bold/light-after-builder.png" + {:sha256 "98f1908796a930761d6b6f31156c4845d3fac734ec56515a54159c527c79147a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/bold/light-after-button.png" + {:sha256 "6fac77aefb9cdc62e3e89cd61d17a1543b37dca533a090f9666c2f73c0f8d4e7" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/bold/light-before-builder.png" + {:sha256 "be558b5001a2e4c8d719c0fd2af8f5990ed7575eddeaa6c81f15db557e1e56d4" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/bold/light-before-button.png" + {:sha256 "6fac77aefb9cdc62e3e89cd61d17a1543b37dca533a090f9666c2f73c0f8d4e7" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round10/roll-i-white-2.png" + {:sha256 "5be9f1137dfb4b60f8d6cc9ef690ec61ecf09015e32dc8bc239fca1f7af7776a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round10/roll-j-yellow-1.png" + {:sha256 "08b94d3806d3756c4154d03a0c69d781534effcc4c42653adbbb4ecb6c24dc7f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round10/roll-k-yellow-2.png" + {:sha256 "dce25b6e06b7cf0810386f62b02dfdeaaf617b27471fb9bf3819159392156223" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round10/roll-l-paleyellow-2.png" + {:sha256 "c7d36ba5ea4e2cea87acb0472fb051ddb86bf12b1d0c6966d896d07b2199d8ca" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round10/roll-m-orange-2.png" + {:sha256 "7a651a83a79a44df7b80063d44cf54bff48d0d6fcdd3f40ac3707fcc0ef9747b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round10/roll-n-amber-2.png" + {:sha256 "82c0ac0ce2e75054bdaed66444209ca8fcf659434b523deffce63b01f0649994" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round10/roll-w-white.png" + {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-k-built@1.png" + {:sha256 "c7646581d90a9791c1795045412cf469d66412ab368541e8c932b836984d1e05" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-k-built@2.png" + {:sha256 "dce25b6e06b7cf0810386f62b02dfdeaaf617b27471fb9bf3819159392156223" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-o-reverse@1.png" + {:sha256 "0796b7ee362346d79dae26de957b812876e721ee286d81d8da61de5668f2a863" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-o-reverse@2.png" + {:sha256 "4a9b8afda0aecd5a2d98df222eb2e9660a72a7565a2b1b5ec6f08e4ddb725a67" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-p-bottom-solid@1.png" + {:sha256 "3aef6b118fa67ced340c4c0c7126f096292197fa3a60e3714509c38bdaed7b58" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-p-bottom-solid@2.png" + {:sha256 "71f9b5c4a335ad24a037a6d1e599624558154787d42930907877369c07b30b1b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-q-yellow-reverse@1.png" + {:sha256 "4599315add23bb6eeba571ad402efeb1b2bdf6cb7252b2bc11acf5fad250cbcf" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-q-yellow-reverse@2.png" + {:sha256 "c505cd51edc907958c53faf6f0b53f4e017be60c47356df4e4f0271e812f769f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-r-bevel@1.png" + {:sha256 "5306058589376d2bcfd8a84c7e6292daf08ba3ffab1ec033f28f1be9c84c93d2" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-r-bevel@2.png" + {:sha256 "1c10c06b6b6b822f53192c3c355c7c41c6a767105b6c3ac4d3f9fa819dd3eee0" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-w-white@1.png" + {:sha256 "1db680a30bc118e0f9fe5bcc9f50f76db838d114ed6f21831e3f1b6e095ec017" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round11/roll-w-white@2.png" + {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-glow@1.png" + {:sha256 "230103e0e39ed5c552cb9d1cc9847ca113f898ca1f20c972b5538f1379ce39c6" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-glow@2.png" + {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-lit@1.png" + {:sha256 "4599315add23bb6eeba571ad402efeb1b2bdf6cb7252b2bc11acf5fad250cbcf" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-lit@2.png" + {:sha256 "c505cd51edc907958c53faf6f0b53f4e017be60c47356df4e4f0271e812f769f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-rim@1.png" + {:sha256 "c7646581d90a9791c1795045412cf469d66412ab368541e8c932b836984d1e05" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-rim@2.png" + {:sha256 "dce25b6e06b7cf0810386f62b02dfdeaaf617b27471fb9bf3819159392156223" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-white@1.png" + {:sha256 "1db680a30bc118e0f9fe5bcc9f50f76db838d114ed6f21831e3f1b6e095ec017" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/14-white@2.png" + {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-glow@1.png" + {:sha256 "2c1e5c330f1cb15efb51fee6f6b113f6464859d97624b178004e35500cfa2085" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-glow@2.png" + {:sha256 "4d6bcefba5a2d7efd4410fa0e1797ce67614ee370f4f1168b28b3a39d81f5872" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-lit@1.png" + {:sha256 "837f261e9d32f4cd01187bc054476bee9c5517202f1671deb8da1fc7060c37fb" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-lit@2.png" + {:sha256 "2c2dc54a03d89d83e694c900a86d589d6cec4d7257c816ca1686a506b97c9d8a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-rim@1.png" + {:sha256 "60440bbbdf8719c2ddd421be3adff02945a0b7111ec80170c98aac56595b1341" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-rim@2.png" + {:sha256 "ba862973ca399dc32ecfeaaf7c40b5d332c4b13d4c92d94247a80391e9e405a7" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-white@1.png" + {:sha256 "af36e8b0fe95e815ab06f67e77f592a3f9d90afaa0a1abce80ad2361bf1e3530" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/15-white@2.png" + {:sha256 "45ed0ebcc0cd2c6e638e697c0c1c17918d238f2766e8140922d58d0079022914" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-glow@1.png" + {:sha256 "5b7ddf0bedd4d98c2da5e357a0be1a8b92196774b9577843f045b1957229b673" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-glow@2.png" + {:sha256 "249b81e7507d119fa70edff8a01f3e0713bc8be7a2c7b633f755ce69c5389415" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-lit@1.png" + {:sha256 "f7dbb0f3254e9f6c0c333ed1e7929d0e703a8914e15dd26a929c4b7a9ab7d538" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-lit@2.png" + {:sha256 "d6744d48fee7027044455543d163fa780a677afbbef8e69b2b02d65122730008" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-rim@1.png" + {:sha256 "73c49c0cdb0af314d95e25252baa7df7f024eae964fafc0f704bb8eaad5568e7" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-rim@2.png" + {:sha256 "fd32fb16e87b7c11c6cb4f122fd835be7e4b494543875a51852111b279004c29" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-white@1.png" + {:sha256 "4b22b7da0e15253981da33d26d3370e76bd6d7b2a672055a1a6ea44328de9797" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round12/16-white@2.png" + {:sha256 "eda8ef7a556f46ad428125cfc01c4af4bc71441313f5f67a521aad783b00392d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/14-rim-320.png" + {:sha256 "e2ef434f98c6c8703bc47e10ed12bdbeeb050811895fb10972ba53874053e778" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/14-rim-390.png" + {:sha256 "41a2500d0a43fd657e374a3d38277c99905bb5baed4434324eb1f5547a60e5d4" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/14-white-320.png" + {:sha256 "8af766b17d67c96547d5dbba3cf63159f16dff15563015a8edb86015c888104e" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/14-white-390.png" + {:sha256 "21cc50a7071feaaf1d856182c6e68c02bceef8a194055f8cec4dec8477b41bdc" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/15-rim-320.png" + {:sha256 "d2e26262f9a8d298e859fd003361ed3156ce7e98cbee7133fbd2ca4bd6977086" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/15-rim-390.png" + {:sha256 "d1143c0725ca522619c570572f7f009045ae3a1e809fddd3af144e5a832fff4d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/15-white-320.png" + {:sha256 "48990873edba43380653dbb6e876a1756abbb48f65d09b19a997a8c5728465f1" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/15-white-390.png" + {:sha256 "c279d1ebb4d7efcee46c2300371aa11c5bf0586be288d99100ef8c70ce2e00ab" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/16-rim-320.png" + {:sha256 "4831c088a4d840856b0b0f58581be1952db05b3a233eaee9368f55163796014f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/16-rim-390.png" + {:sha256 "49d7cd0785c0503c581f73442809f289bdddec3264a7316c3a9c4d8a767fb369" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/16-white-320.png" + {:sha256 "ea907c50e38993ca7fb147ffa63fe99b5d1d57168dcc51edbd8ecaafe0b222dd" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/16-white-390.png" + {:sha256 "30587842bfe68cf4b980c1c32c0936232dc24610460bea24268c04d9b1d59c53" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/toggles-320.png" + {:sha256 "39fd0fd4fff0e1c120752b6f8ea8d3ed40ba232c5343ddb3ae3ed29070cb4fe0" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round13/toggles-390.png" + {:sha256 "282a703aec2edb17f94209d59dcdaf02e4391437643043563826681d81c8b14d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round14/desktop-1280.png" + {:sha256 "19da08bd4c7842c9ff24e587146902a730e542d63cf378072846337331b4ba1d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round14/desktop-narrowed-details.png" + {:sha256 "36528d83f2df44a9211a220c1ea19a0f266fe2e34eb5f0d844f3282114137148" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round14/desktop-narrowed-options.png" + {:sha256 "061d1982ca5eeab4a184f3bc615dcc6c669758510398106e408e497a22835b69" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round14/phone-options.png" + {:sha256 "c8121c2c6637e7a2511ee0a56d826f0a63054a32dda16c2738b3f217ab6c9a6a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round15/desktop-1280.png" + {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round15/phone-320.png" + {:sha256 "13487adedda621ec46e4c7281dc2de5a9728e92d94c6b7e8dbec5ec00ba7b011" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round15/phone-390.png" + {:sha256 "fab77f673f30f0a3af32acc8c2c54298df9a4f905b17997f4e0bf47f91b601bb" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round16/bar.png" + {:sha256 "2fe942021102a81ba044bab84fe8aec26036f3575d480751fd67b216af9749e7" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round16/edges-aligned.png" + {:sha256 "6cb921265360187666171aded48196f3441427f5d80a471555ffce9390b5b279" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round16/edges-now.png" + {:sha256 "664cd6083a3d58a5dc8b5355b44866219c4fa981a818361515fc0fc4a932f404" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round16/strip-aligned.png" + {:sha256 "8c3588cd5a80cb98ced416f57eb53e85072ddf88536990a968c988afdc44f42d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round16/strip-now.png" + {:sha256 "033da9068729108f0455235c197bfb0432316883d99b45bc1dfbf78b642b3ee9" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round17/built.png" + {:sha256 "15c1cf422d7debd6991935c3edf51f8f697823d7d8dd5dff5babc1e678d29362" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round17/g10.png" + {:sha256 "f38a8a5f17717a1ee65fc107921fb9aa5eb7b48356f12c4e90e8803bd5968044" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round17/g16.png" + {:sha256 "47ca02f9f649dbeb2e300f733cb96d8d8c67950e11ae465f53349cff49f8209f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round18/desktop-1280.png" + {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round18/phone-320.png" + {:sha256 "3c8d12b8785da47a2222907eb8ae8bcc89fa39b7dc9ca070adfd0f0f6ca22f43" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round18/phone-390.png" + {:sha256 "cbe0eb33597126a65866e3f04f95cbcdbc6bf94403abf567086b3b82f3764535" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round19/desktop.png" + {:sha256 "0993ced3491c25e664e7a671d88e5355c07e34522f4becf85318a786590a193a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round19/phone-320-dark.png" + {:sha256 "559e6fca94692099d415b660a42d353e67141e21bc637dc64997a67a0b46e399" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round19/phone-320-white.png" + {:sha256 "0c9531796f2950736147dd318f9d3c222e39f5721e8e73daf567782d373f69b9" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round19/phone-390-dark.png" + {:sha256 "296c733e31487dbd0b81c7c9304fccc0923532865a799ff4ffa41b7ad87a8f9e" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round19/phone-390-skills.png" + {:sha256 "22eb8ac9629b482d85c32c693e2f27b822eab11113e721e1e601a02edd14fc71" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round19/phone-390-white.png" + {:sha256 "bc0502ea83fd0c85b2c1609a5a3e2926aca38a8598c301a2530a1437b488bfc2" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/desktop-1280.png" + {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/desktop.png" + {:sha256 "0993ced3491c25e664e7a671d88e5355c07e34522f4becf85318a786590a193a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/phone-320-dark.png" + {:sha256 "559e6fca94692099d415b660a42d353e67141e21bc637dc64997a67a0b46e399" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/phone-320-white.png" + {:sha256 "0c9531796f2950736147dd318f9d3c222e39f5721e8e73daf567782d373f69b9" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/phone-320.png" + {:sha256 "3c8d12b8785da47a2222907eb8ae8bcc89fa39b7dc9ca070adfd0f0f6ca22f43" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/phone-390-dark.png" + {:sha256 "296c733e31487dbd0b81c7c9304fccc0923532865a799ff4ffa41b7ad87a8f9e" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/phone-390-skills.png" + {:sha256 "22eb8ac9629b482d85c32c693e2f27b822eab11113e721e1e601a02edd14fc71" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/phone-390-white.png" + {:sha256 "bc0502ea83fd0c85b2c1609a5a3e2926aca38a8598c301a2530a1437b488bfc2" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/after/phone-390.png" + {:sha256 "cbe0eb33597126a65866e3f04f95cbcdbc6bf94403abf567086b3b82f3764535" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/desktop-1280.png" + {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/desktop.png" + {:sha256 "0993ced3491c25e664e7a671d88e5355c07e34522f4becf85318a786590a193a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/phone-320-dark.png" + {:sha256 "559e6fca94692099d415b660a42d353e67141e21bc637dc64997a67a0b46e399" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/phone-320-white.png" + {:sha256 "0c9531796f2950736147dd318f9d3c222e39f5721e8e73daf567782d373f69b9" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/phone-320.png" + {:sha256 "3c8d12b8785da47a2222907eb8ae8bcc89fa39b7dc9ca070adfd0f0f6ca22f43" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/phone-390-dark.png" + {:sha256 "296c733e31487dbd0b81c7c9304fccc0923532865a799ff4ffa41b7ad87a8f9e" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/phone-390-skills.png" + {:sha256 "22eb8ac9629b482d85c32c693e2f27b822eab11113e721e1e601a02edd14fc71" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/phone-390-white.png" + {:sha256 "bc0502ea83fd0c85b2c1609a5a3e2926aca38a8598c301a2530a1437b488bfc2" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round20/before/phone-390.png" + {:sha256 "cbe0eb33597126a65866e3f04f95cbcdbc6bf94403abf567086b3b82f3764535" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/after/desktop-flyout.png" + {:sha256 "efb802d9a70eebafa5476fdb8ae31779a931c42f2bd20feca60b269bf5b6312b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/after/phone-flyout.png" + {:sha256 "340e72d1210e435f128ca4c399cdb56f730857d1e60fa0ce7b2ed69ff041cd23" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/after/user-menu.png" + {:sha256 "2e6de9a6b31de8024e06dc01ff1c7a7d73f650bc739d58a271e370dac3a84633" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/before/desktop-flyout.png" + {:sha256 "efb802d9a70eebafa5476fdb8ae31779a931c42f2bd20feca60b269bf5b6312b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/before/phone-flyout.png" + {:sha256 "6186335d44cf48be49ccadfe6f8998564d45bd9b550a09c1a723d59e93a354d2" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/before/user-menu.png" + {:sha256 "2e6de9a6b31de8024e06dc01ff1c7a7d73f650bc739d58a271e370dac3a84633" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/320-tab1.png" + {:sha256 "669559a578e7b2d0e39d105a6df12af39d37b1f69371c72d56a2dd8147191707" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/320-tab2.png" + {:sha256 "8c06398d342bd794cff03d10536fd25c981b8f5f2aaa01a9d1f1e51e21b9cc87" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/320-tab3.png" + {:sha256 "7bd282ac9c99ccf3500974efc8ad6bbdfd5d53f52f8328986b670e8332db51d0" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/320-tab4.png" + {:sha256 "fe263deb46fcfe48bce346f5a4475a4c90a6d0bf764d1cffa203557878ab5c71" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/320-tab5.png" + {:sha256 "643bfdaa87fad27fc6523bee0c24ff2363f79a0394d89237bfcce19613e75272" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/320-tab6.png" + {:sha256 "6104fa7f2e75374f9af442f32c731dbed33435faa8aaa123f699e7c0dc9ff78e" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/390-tab1.png" + {:sha256 "b3acd6b112c25e0f5cbf5627e2311786c755bda88778077821d09cb32519e56e" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/390-tab2.png" + {:sha256 "9e32f8056eb14464664bf8c8c34cfb18ef5748e858ee437985f02d14d65ffd7f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/390-tab3.png" + {:sha256 "09792904ed48c873ca00a34de261af3ceb12519b13d0a73579312734e63309d1" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/390-tab4.png" + {:sha256 "0c7d6953f1e3d71dc5c97a2df7c16bcd49a3beeb02f8e65ea90e66edb2abafa6" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/390-tab5.png" + {:sha256 "a01b6959a6310cc25c4376a42d19d13509b059c465330e338638af0013ef6f7b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus-before/390-tab6.png" + {:sha256 "ab588299f28e59955e6af1c0d79a53a45b674f361c0abd776adff2e22075db20" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/320-tab1.png" + {:sha256 "5d2f4e6729738d634bfc85208fc8805587526986f1133fadff6f5b7184255d8f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/320-tab2.png" + {:sha256 "5f17b4cae5cb64bb6d7421dd6e7df4357cb5ea9670cf54ae1e0b43beee0eafe4" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/320-tab3.png" + {:sha256 "a5c9c2119460f7c7bf2a8d866229fb8d89c31fbad04f98068ed637fb70e7cff1" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/320-tab4.png" + {:sha256 "315ad3aa7e2a23b3a9c98c61c9b40a81b27d2788fad039b7e401588e59d71dc6" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/320-tab5.png" + {:sha256 "d1bdc069b899c613a538c413311017c40d21d06b530a10f3b7dbbd09f0c0e7ac" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/320-tab6.png" + {:sha256 "b141e1e02f3f421ca42aabaf5b72baac1595e9e78a8236bc1c9c2866070e802c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/390-tab1.png" + {:sha256 "58b46737ca7ae69c9000ec11fc15d1fc3199b93c988f22aed9cdbfefbaca3971" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/390-tab2.png" + {:sha256 "6a4921443e2820aaf90deef4283b084d3e24023a5873b40b6b4c34d63b1ac61d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/390-tab3.png" + {:sha256 "18177fb40283cedae55968b5301867bc5db7b9dc16d8c3c18a44a3a81be82674" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/390-tab4.png" + {:sha256 "6794b80d7168bae1cfb67ac9966dab96fe2e680d30664401d36bb3da28acbcee" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/390-tab5.png" + {:sha256 "c62f9096ab965f15d1bef0e51cb6d7b5a377e4883ce88ce4fb5d144c6ea424aa" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round21/menus/390-tab6.png" + {:sha256 "85249509fa0cd45448da657a3e95a302b9363daaa95cb22c2b091d4471b1cd54" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-btn-arial.png" + {:sha256 "54962fcdc2e9bfe4dbc40f5c54de1647571fa01fbb14bccf44f54b8ced2eab30" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-btn-dark.png" + {:sha256 "70450b173c5fb011be9d06dba9cce628ce6a10ebd044ddcbf466df4041e3ce20" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-btn-os600.png" + {:sha256 "e5b15847bcfdc1533abf65d1b68d3012f99c4947eaae0c056f7a5fde3f8d3b54" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-btn-os700.png" + {:sha256 "5d00fae8a84ab96d94be8195009e8488b6c7a6a070131013061fb49ddde460fd" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-v2-builder.png" + {:sha256 "eb59d124cf7e926b7c9659f19bfd679f57f1d9dc595baae77731a1da5f65cdee" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-v2-monsters.png" + {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-v2-mycontent.png" + {:sha256 "8d866f643d18bd247360164c05909e9468191c39aebf4a05c673a29edb7e7698" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-v2-spells.png" + {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-wrap-asis.png" + {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-wrap-ls02.png" + {:sha256 "4e97d2a100331d46770be7a14f7097e0b72ca0c834908ec45e1f05e44a30de4b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-wrap-ls04.png" + {:sha256 "68c3d46839a9187f748c69a12ad0afe8d3ccf200f7f8ed16e7f3b2506ec8d563" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-wrap-s13.png" + {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/dark-wrap-w600.png" + {:sha256 "f416cf3a968defebc4149f0f2c6a1f0c369ab44c7e32a8ac9e2cd36a6d6ae9cf" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-btn-arial.png" + {:sha256 "957f7701a0d2a717095a4531ebd3343afd70932df2477e939afe097711beebfa" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-btn-dark.png" + {:sha256 "26b411c5bc5c046a2cbe6080f7c08066250b3f93f6ef8fd180ca35f1c4fa42bd" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-btn-os600.png" + {:sha256 "3d652d57e9401f6d20ffbb9318f905cb142959f997819ec6381cc40eb3a1e08c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-btn-os700.png" + {:sha256 "26b411c5bc5c046a2cbe6080f7c08066250b3f93f6ef8fd180ca35f1c4fa42bd" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-v2-builder.png" + {:sha256 "d8e7577d09922ea6c73d15ad165b1fde7a9c3e9ccfdfc5448cd6c69ad7336766" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-v2-monsters.png" + {:sha256 "f15c88956182732c2390052771aaf8899c374a639221626872b1870ff9b368bf" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-v2-mycontent.png" + {:sha256 "2f9cd672064c9f7fdd41e3f857acf3ed5b3b065af0eccde1c17a53732e9cec9c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-v2-spells.png" + {:sha256 "131c5e3949c25e8ba54a197596a5ce059db22f1bcdda4ed70b10c9eaea9603ce" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-wrap-asis.png" + {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-wrap-ls02.png" + {:sha256 "4e97d2a100331d46770be7a14f7097e0b72ca0c834908ec45e1f05e44a30de4b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-wrap-ls04.png" + {:sha256 "68c3d46839a9187f748c69a12ad0afe8d3ccf200f7f8ed16e7f3b2506ec8d563" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-wrap-s13.png" + {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round3/light-wrap-w600.png" + {:sha256 "f416cf3a968defebc4149f0f2c6a1f0c369ab44c7e32a8ac9e2cd36a6d6ae9cf" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-dark-builder.png" + {:sha256 "659a00f01ac53b9e881076bf6287cc9de698023e2f39fab899ff9c7fcbc01d28" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-dark-monsters.png" + {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-dark-mycontent.png" + {:sha256 "53a656687d15dc6bbd3cb8aa81a8ce064119a895fbf295b4c3a972c62ea6721d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-dark-spells.png" + {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-white-builder.png" + {:sha256 "eb59d124cf7e926b7c9659f19bfd679f57f1d9dc595baae77731a1da5f65cdee" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-white-monsters.png" + {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-white-mycontent.png" + {:sha256 "8d866f643d18bd247360164c05909e9468191c39aebf4a05c673a29edb7e7698" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round4/dark-white-spells.png" + {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round5/1-today.png" + {:sha256 "63234e4b5c2b96de968176b9265cc49b97c097987b8b5569a187b259b9b055ab" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round5/2-shadow.png" + {:sha256 "042fbc6474b340638e356b9fc73838a1fb5f3612645f68a8e5c3cb9ab8688eba" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round5/3-deeper.png" + {:sha256 "4baa474dca0f27e198ea830990f8aaccd6ecd1cdd72ce5e6e27db7cf09c5786c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round5/4-deeper-shadow.png" + {:sha256 "2348b2c22b8df8522ddbd04a67d4799af3e5c5c7c1504fcc87f3fa99d22d8c0d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round5/5-dark.png" + {:sha256 "dae94bebf2c6418ab31ed3d6fabae3724362294e28809568d38d4e393fb813f4" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round6/10-dark-emboss-strong.png" + {:sha256 "5afc7f629340ae8ae6829e29ab9be23e19520a84a27fb8deb36ed8a8ebb78c47" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round6/6-white-shadow.png" + {:sha256 "042fbc6474b340638e356b9fc73838a1fb5f3612645f68a8e5c3cb9ab8688eba" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round6/7-dark.png" + {:sha256 "dae94bebf2c6418ab31ed3d6fabae3724362294e28809568d38d4e393fb813f4" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round6/8-dark-emboss.png" + {:sha256 "6638bf5596541dde6cf8db45317a5c1ecd7080d4c9ca1d0238e0ca7a055bd4b5" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round6/9-dark-glow.png" + {:sha256 "d4244f1cbfb6c600f9808b7bc56a6b7312e1b1791cd13ef1c6f043404eb4ef61" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/dark-buttons.png" + {:sha256 "042fbc6474b340638e356b9fc73838a1fb5f3612645f68a8e5c3cb9ab8688eba" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/dark-darktext-buttons.png" + {:sha256 "d4244f1cbfb6c600f9808b7bc56a6b7312e1b1791cd13ef1c6f043404eb4ef61" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/dark-darktext-roll.png" + {:sha256 "2c3960fd090716bed7149f399139113219e2ab83be00e627e2c18d6223348e2c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/dark-roll.png" + {:sha256 "90abaa97721559e12814ed281f69f6ca3667319bc73e512435abc557ec5a3eb1" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/dark-tab-lift.png" + {:sha256 "9f74a4f0b62aa40f479dc1d4ce6740be5c8325c174cc560e8a20aa70ba59c9a8" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/dark-tab-now.png" + {:sha256 "f93950b65a25131b28308a09abfaaf9b836f58b17b0d77df0561e1876d80bd4c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/light-buttons.png" + {:sha256 "178e4682ad30fd6568d02fdf5e12b12f6216f04fc1b1cb9e01adeab2cf1984cd" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/light-roll.png" + {:sha256 "fb37515425dc17adc5f8393d174a5b40c61b15bbf7fc4bc2aa8e6e9e579ae3c8" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/light-tab-lift.png" + {:sha256 "9f74a4f0b62aa40f479dc1d4ce6740be5c8325c174cc560e8a20aa70ba59c9a8" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round7/light-tab-now.png" + {:sha256 "f93950b65a25131b28308a09abfaaf9b836f58b17b0d77df0561e1876d80bd4c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round8/header-built.png" + {:sha256 "78a5efb8a8a40d13d186c116271a013e6348560ea379c6ce13d2cd181df4d624" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round8/roll-a-before.png" + {:sha256 "2c3960fd090716bed7149f399139113219e2ab83be00e627e2c18d6223348e2c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round8/roll-b-700.png" + {:sha256 "1f0c29ee8b18a4820966696ca902861e2e40bdfdee5a940fcb8b46bfed5ce1fd" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round8/roll-built.png" + {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round8/roll-c-glow.png" + {:sha256 "3e669a5614954291f8868fb472d27f2ccde9686d770d23c1c7edc516fa8a2714" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round8/roll-d-700-glow.png" + {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round8/roll-w-white.png" + {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round9/roll-d-built.png" + {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round9/roll-e-shadow-ring.png" + {:sha256 "e87995488c563408d38eb64c55e4ed18329b7b885e0da6293425cad2b721fd30" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round9/roll-f-stroke-hair.png" + {:sha256 "ab8e0926f1dc74a6cb1d868775b0e92d0d2956d6af5427e3e5289911cafb4109" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round9/roll-g-stroke-hair-glow.png" + {:sha256 "78323baa58deb5087768c97846369880056ccab880b875842b48a58593da9325" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round9/roll-h-stroke-full.png" + {:sha256 "7851f962a1f6eb40ad5ca2eab39121849c3131b9739103c98f154d4f06ecca17" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round9/roll-i-stroke-full-glow.png" + {:sha256 "5be9f1137dfb4b60f8d6cc9ef690ec61ecf09015e32dc8bc239fca1f7af7776a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/round9/roll-w-white.png" + {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-after-builder.png" + {:sha256 "e78d209816bdf54893f3d5a9297cc53f4015159fe9e313c26f6b96d6ca1f1ec2" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-after-monsters.png" + {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-after-mycontent.png" + {:sha256 "56ef087f7e42392787925a0122070c5ff26c756c2f71323f2f367c58fa64da4a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-after-spells.png" + {:sha256 "d4e1c851290e8a53afdeef8b8f15b0259c713d2c3cf003c2fc42abed8682f970" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-before-builder.png" + {:sha256 "0adc3532bab7f6f0fa12d3f93b76a356e95001ad22f60e899f9156f713cceed3" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-before-monsters.png" + {:sha256 "05a53fdf04486f08c97bbb160bc5edb4f5edaf5fe89a2ac6719eb4bfee555865" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-before-mycontent.png" + {:sha256 "f400e196ee1f1a3793fadf64bcdef33c0c499f10039e870b64f1fe0aeaf83bd9" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-before-spells.png" + {:sha256 "002febee16487914747ec2e2fb3c1404a0fa2fad7e9e0547514b9076b52d4bf0" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-rule-builder.png" + {:sha256 "c398fd986babcd570b44bb4efc6dca22f7b568c1b7e3e8a2582fcad1fd4def05" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-rule-monsters.png" + {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-rule-mycontent.png" + {:sha256 "9635e95f2ac5679d9978d261230ee439e5edec473cdb8d6a5b31530959bc6058" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/dark-rule-spells.png" + {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-after-builder.png" + {:sha256 "3413eca2cdcd4375b4176e84c02cabe95859c1761b9048db5e9d39a2206fe7d0" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-after-monsters.png" + {:sha256 "f15c88956182732c2390052771aaf8899c374a639221626872b1870ff9b368bf" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-after-mycontent.png" + {:sha256 "b4b697ab4871657ac3e17dae778ad34b82a12332ed8040fd05dc54f26e7bd55d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-after-spells.png" + {:sha256 "696f637efd328155a60ec63b0223674db31ff62d29ca01cc4f24687b0b63c26a" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-before-builder.png" + {:sha256 "c6f627b103fa72eefb5f03743144de2f284e643d42cdf0b9de931456df3e9566" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-before-monsters.png" + {:sha256 "301b7c289b369956efe4f98febff8919741d0bedbde9e5083aab1c1286dc2138" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-before-mycontent.png" + {:sha256 "0c2ba28175684a3647ba2333fb72f9d8bb0a7605a475db870f317d068a5a075e" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-before-spells.png" + {:sha256 "774bd95b085eedbd667f29572ce6df4ad931a0357512d0489c7129d43de7f9d5" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-rule-builder.png" + {:sha256 "00b373460eea7e59b0efa0861d2e94c271d1b521847031a7bf3bb5fbb8c0622f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-rule-monsters.png" + {:sha256 "f15c88956182732c2390052771aaf8899c374a639221626872b1870ff9b368bf" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-rule-mycontent.png" + {:sha256 "220916999702580235c9926401e515d23dd774bfbb70c43a5a05b854733a1db7" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/sitewide/light-rule-spells.png" + {:sha256 "131c5e3949c25e8ba54a197596a5ce059db22f1bcdda4ed70b10c9eaea9603ce" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-pair-400-400.png" + {:sha256 "1c95f2e6b12b811259a75147111f52414c96833a31e127362ee05333dfccc160" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-pair-500-600.png" + {:sha256 "c06ea2abd9c998c788b5927dd21711069c7c80a60b9a75c5cc903bd1c791dadd" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-pair-700-600.png" + {:sha256 "0c0114a53dc18e4b9745c5b727090e165a3808fcb565f6310085e427421a3a9b" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-tabs-400.png" + {:sha256 "1c7daaacd0aa822098fa071c9ad3e629a834700af15e97dae10a2339fddc7a5d" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-tabs-500.png" + {:sha256 "c4904977c323ff7264251a9c3282a7c7bc37f3917f803d4a4a34d7e3d8417972" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-tabs-600.png" + {:sha256 "e8607924fc459588ceb492d1e80c2eb61e5421742eed7e1feca690541d7bbd25" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-tabs-700.png" + {:sha256 "b0e1ca23463c0819324389e654691824461bf9156cc40064d3e8245a99492305" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-title-400.png" + {:sha256 "04ab9c464774f0f16cda29c07931b4fb9305f536f0e7ecb1edb30c65e67eb001" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-title-600.png" + {:sha256 "ea9a2ed8f90757f97011769af504f048d5efc5946cae1e8c0acd05db565d2510" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/dark-title-700.png" + {:sha256 "f457ec2fb555360de9412493c6a7349a0e6c068684f44a2d57ee293264c4d697" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-pair-400-400.png" + {:sha256 "d17439d7e320810a3aa70bf30e2f7d1fa01e951503611c9c5e82ece79b874c3c" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-pair-500-600.png" + {:sha256 "1573364f3b7d2dd2d7dc0836ff096ae86786129bafd5e3c548d35b9475b8eea2" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-pair-700-600.png" + {:sha256 "cc0550e44e24072aa3426f4d888ce5d838799f58269d298bcbda2ac87e711aa3" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-tabs-400.png" + {:sha256 "1232d90c86e7d30fd9ee9814b5cf5c80e64c4ec8644547cc99ad83ab7fb57072" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-tabs-500.png" + {:sha256 "ba9704a99616ca296e271eda5fcb3364fab1061b1bfcdc8da19a549aba86f612" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-tabs-600.png" + {:sha256 "c29869b307cd80a8adb94480535ec4222b7dcf3597bdfc81353f7692c42a44f3" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-tabs-700.png" + {:sha256 "149da8c7441a6c23d8167f5bbc6062a585205c9a8d43f19e41a4ac1f761a294f" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-title-400.png" + {:sha256 "e04feb68eba1dde905fc147f275709d99bb22c4102355c7a8c12ae78bb152fd3" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-title-600.png" + {:sha256 "9e4f40d663fd95ff1042c437147b737bcede32e29c7baab938c5563872487b08" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"} + "docs/design/style-guide/weights/light-title-700.png" + {:sha256 "56cdb204d3f1a44d43f399f7825d3f0f4113b961abd7736925aa916a68398cd1" + :what "owner-reviewed 2026-10-13: site UI screenshot from the style-guide rounds; portraits in it are silhouettes"}} From ad0504145b435a65760e725b55c6823b17d4dcc9 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 01:17:20 +0000 Subject: [PATCH 51/91] cljs test runner: wait for the summary, not for the page's load event The suite runs while the test page loads, so goto's load wait covered the whole run and hit Playwright's 30s default once the suite grew past it (about 35s on the portrait branch). goto now returns when navigation commits, and the existing 10-minute wait for the "Ran N tests" summary bounds the run. Fail state: with a temporary 40-second test added, the old runner exits 1 with a goto TimeoutError; the fixed one passes 656 tests. The temporary test is not committed. --- scripts/test/run-cljs-tests.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test/run-cljs-tests.js b/scripts/test/run-cljs-tests.js index c150948ee..f926c6ed2 100644 --- a/scripts/test/run-cljs-tests.js +++ b/scripts/test/run-cljs-tests.js @@ -68,7 +68,9 @@ const server = http.createServer((req, res) => { // open a websocket back to a figwheel server that is not running here. That // is noise, not a test failure; anything else on the page is reported. page.on('pageerror', e => { if (!/WebSocket|figwheel/i.test(e.message)) pageErrors.push(e.message); }); - await page.goto(`http://localhost:${port}/`); + // The tests run while the page loads, so `load` waits for the whole suite and outgrew goto's + // 30s default. Return once navigation commits; the loop below waits for the summary. + await page.goto(`http://localhost:${port}/`, { waitUntil: 'commit' }); // The first summary is orcpub.test-runner/-main's, over the namespaces the // project names, and its own failure and error counts decide the exit code. // figwheel's auto-testing sweep runs every test namespace under test/ as well, From b2c7196e760bd95af01b5f2afa0c91bff14038d1 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 03:37:04 +0000 Subject: [PATCH 52/91] Mark every image drawn from licensed art, JPEG included, through one API The artist-page examples are JPEGs and left the server unmarked: the mark only had a PNG writer, and each producer stamped it its own way. The mark now reads and writes in PNG (iTXt) and JPEG (COM segment), refuses any other format rather than skipping it, and every producer goes through carry-marks with the marks of what it drew from (portrait-render/ source-marks for the server renders, the strand field, the browser rasterizer). Names now read as one family: read-mark, write-mark, combine-marks, carry-marks, marked?, mark-keyword (were mark-of, with-mark, composed, key-word). The image guard checks JPEGs too. Fail states, each seen failing before the fix was restored: - JPEG write branch removed: ERROR in a-jpeg-carries-the-mark-in-a-comment - read without the JPEG reader: 2 FAILs - unsupported format returned unmarked: FAIL in an-unmarkable-format-is-refused-not-skipped - example JPEG without carry-marks: FAIL in an-artist-page-example-drawn-from-marked-pieces-is-marked lein test: 1007 tests, 0 failures. cljs: 660 tests, 0 failures. --- dev/orcpub/portrait_gallery.clj | 6 +- src/clj/orcpub/portrait_pack/strands.clj | 3 +- src/clj/orcpub/portrait_render.clj | 18 ++-- src/clj/orcpub/routes.clj | 4 +- src/cljc/orcpub/art_mark.cljc | 98 +++++++++++++++++---- src/cljs/orcpub/dnd/e5/portrait.cljs | 5 +- test/clj/orcpub/art_mark_drawing_test.clj | 25 ++++-- test/clj/orcpub/portrait_art_guard_test.clj | 8 +- test/cljc/orcpub/art_mark_test.cljc | 34 +++++-- 9 files changed, 146 insertions(+), 55 deletions(-) diff --git a/dev/orcpub/portrait_gallery.clj b/dev/orcpub/portrait_gallery.clj index 3f88a6c7a..7d85671b2 100644 --- a/dev/orcpub/portrait_gallery.clj +++ b/dev/orcpub/portrait_gallery.clj @@ -145,10 +145,10 @@ "The licensed-art mark for what is drawn from the pack at ORCPUB_PACK: composed from its pieces' marks, or nil when none is marked (silhouettes)." (delay (when-let [pack (pack-dir)] - (art-mark/composed + (art-mark/combine-marks (for [^File f (file-seq pack) :when (and (.isFile f) (re-find #"(?i)\.png$" (.getName f)))] - (art-mark/mark-of (java.nio.file.Files/readAllBytes (.toPath f)))))))) + (art-mark/read-mark (java.nio.file.Files/readAllBytes (.toPath f)))))))) (defn- write! "Write `img` as .png to ORCPUB_PACK_OUT, carrying the pack's licensed-art mark when it has @@ -158,7 +158,7 @@ (.mkdirs (File. dir)) (let [out (java.io.ByteArrayOutputStream.)] (ImageIO/write img "png" out) - (io/copy (cond-> (.toByteArray out) @pack-mark (art-mark/with-mark @pack-mark)) + (io/copy (cond-> (.toByteArray out) @pack-mark (art-mark/write-mark @pack-mark)) (File. dir (str nm ".png")))) (println (format " wrote %s/%s.png" dir nm)))) diff --git a/src/clj/orcpub/portrait_pack/strands.clj b/src/clj/orcpub/portrait_pack/strands.clj index 51b352d12..77d87587b 100644 --- a/src/clj/orcpub/portrait_pack/strands.clj +++ b/src/clj/orcpub/portrait_pack/strands.clj @@ -48,8 +48,7 @@ ^bytes [^bytes art layer-key] (let [out (ByteArrayOutputStream.)] (ImageIO/write (field->image (field-of (ImageIO/read (java.io.ByteArrayInputStream. art)) layer-key)) "png" out) - (cond-> (.toByteArray out) - (art-mark/marked? art) (art-mark/with-mark (art-mark/composed [(art-mark/mark-of art)]))))) + (art-mark/carry-marks (.toByteArray out) [(art-mark/read-mark art)]))) (def ^:private worked-out "Strand PNGs worked out at runtime for art deployed without its file, by art URL." diff --git a/src/clj/orcpub/portrait_render.clj b/src/clj/orcpub/portrait_render.clj index 7a592f966..4ee5e59d5 100644 --- a/src/clj/orcpub/portrait_render.clj +++ b/src/clj/orcpub/portrait_render.clj @@ -654,6 +654,15 @@ (finally (.dispose g))) img))))) +(defn source-marks + "The licensed-art marks of the pieces `portrait` is drawn from, one per selected layer (nil for + an unmarked piece): what anything drawn from it must carry, via art-mark/carry-marks." + [portrait] + (for [k pa/layer-order + :let [url (some-> (pa/selected-asset portrait k) :asset/url)] + :when url] + (some-> (asset-source url) :bytes art-mark/read-mark))) + (defn render-png "PNG bytes for `portrait`, or nil when there is nothing to draw. Carries the licensed-art mark (orcpub.art-mark) when any piece drawn from carries one." @@ -663,12 +672,7 @@ (when-let [img (render portrait w h)] (let [out (ByteArrayOutputStream.)] (when (ImageIO/write img "png" out) - (let [marks (for [k pa/layer-order - :let [url (some-> (pa/selected-asset portrait k) :asset/url)] - :when url] - (some-> (asset-source url) :bytes art-mark/mark-of))] - (cond-> (.toByteArray out) - (some some? marks) (art-mark/with-mark (art-mark/composed marks))))))) + (art-mark/carry-marks (.toByteArray out) (source-marks portrait))))) (catch Exception e (println "portrait-render: render failed -" (.getMessage e)) nil)))) @@ -771,7 +775,7 @@ (.drawImage g (halve-toward full w h) 0 0 nil) (when-not (s/blank? watermark) (draw-watermark! g watermark w h)) (finally (.dispose g))) - (jpeg-bytes out example-quality))) + (art-mark/carry-marks (jpeg-bytes out example-quality) (source-marks portrait)))) (catch Exception e (println "portrait-render: example failed -" (.getMessage e)) nil))) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index c0523c52d..8a8bf1c7b 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -1133,7 +1133,7 @@ (try (let [info (.getDocumentInformation doc)] ;; drawn from the licensed art: the sheet says so where the repository's guard looks - (when art-mark (.setCustomMetadataValue info art-mark/key-word art-mark)) + (when art-mark (.setCustomMetadataValue info art-mark/mark-keyword art-mark)) (.setTitle info (or (some-> character-name s/trim not-empty) branding/default-page-title)) (.setCreator info branding/app-name) @@ -1674,7 +1674,7 @@ :art-mark (when composed (some-> (try (.decode (java.util.Base64/getDecoder) ^String portrait-png) (catch Exception _ nil)) - art-mark/mark-of))})) + art-mark/read-mark))})) (.save doc output)) (let [a (.toByteArray output)] {:status 200 diff --git a/src/cljc/orcpub/art_mark.cljc b/src/cljc/orcpub/art_mark.cljc index 0ea3aa314..7a039beae 100644 --- a/src/cljc/orcpub/art_mark.cljc +++ b/src/cljc/orcpub/art_mark.cljc @@ -1,10 +1,11 @@ (ns orcpub.art-mark - "The licensed-art mark: a PNG iTXt chunk, keyword \"orcpub-art\", that the Silhouette Loom writes - into every exported piece of the real art. Anything drawn from marked pieces is marked too, and - the repository's guard refuses any file carrying it. Silhouettes have none. + "The licensed-art mark, text under the keyword \"orcpub-art\" that the Silhouette Loom writes into + every exported piece of the real art: a PNG iTXt chunk, or a JPEG comment segment. Anything drawn + from marked pieces carries it (`carry-marks`), and the repository's guard refuses any marked file. + Entry points: `read-mark`, `write-mark`, `combine-marks`, `carry-marks`, `marked?`. GOTCHA: provenance, not protection: a screenshot or any outside tool drops it.") -(def key-word "orcpub-art") +(def mark-keyword "orcpub-art") (defn- u8 "Byte `i` of `b`, 0..255." @@ -30,9 +31,14 @@ [b] (and (>= (alength b) 33) (= [137 80 78 71 13 10 26 10] (mapv #(u8 b %) (range 8))))) -(defn mark-of - "The mark's text in PNG bytes `b` (byte[] or Uint8Array), or nil. Reads only the chunks before - the image data, where the mark is written." +(defn- jpeg? + "Whether `b` starts with the JPEG start-of-image marker." + [b] + (and (>= (alength b) 4) (= 0xFF (u8 b 0)) (= 0xD8 (u8 b 1)))) + +(defn- png-mark + "The mark's text in PNG bytes `b`, or nil. Reads only the chunks before the image data, where the + mark is written." [b] (when (png? b) (loop [i 8] @@ -41,18 +47,42 @@ (cond (#{"IDAT" "IEND"} type) nil (and (= "iTXt" type) (<= end (alength b)) - (= (str key-word "\u0000") (ascii b start (min end (+ start (count key-word) 1))))) + (= (str mark-keyword "\u0000") (ascii b start (min end (+ start (count mark-keyword) 1))))) ;; keyword NUL, compression flag, method, language NUL, translated keyword NUL, text - (let [after-kw (+ start (count key-word) 1 2) + (let [after-kw (+ start (count mark-keyword) 1 2) lang-end (loop [j after-kw] (if (and (< j end) (pos? (u8 b j))) (recur (inc j)) j)) trans-end (loop [j (inc lang-end)] (if (and (< j end) (pos? (u8 b j))) (recur (inc j)) j))] (utf8 b (min end (inc trans-end)) end)) :else (recur (+ end 4)))))))) +(defn- jpeg-mark + "The mark's text in JPEG bytes `b`, or nil: a comment segment holding the keyword, NUL, then the + text. Reads the segments before the image data." + [b] + (when (jpeg? b) + (let [prefix (str mark-keyword "\u0000")] + (loop [i 2] + (when (and (<= (+ i 4) (alength b)) (= 0xFF (u8 b i))) + (let [kind (u8 b (inc i)) + len (+ (* 256 (u8 b (+ i 2))) (u8 b (+ i 3))) + start (+ i 4) end (+ i 2 len)] + (cond + (#{0xDA 0xD9} kind) nil ; start of scan, end of image + (and (= 0xFE kind) (<= end (alength b)) + (= prefix (ascii b start (min end (+ start (count prefix)))))) + (utf8 b (+ start (count prefix)) end) + :else (recur end)))))))) + +(defn read-mark + "The mark's text in image bytes `b` (byte[] or Uint8Array), or nil: PNG or JPEG; any other + format has no mark to read." + [b] + (or (png-mark b) (jpeg-mark b))) + (defn marked? - "Whether PNG bytes `b` carry the mark." + "Whether image bytes `b` carry the mark." [b] - (some? (mark-of b))) + (some? (read-mark b))) #?(:cljs (def ^:private crc-table @@ -94,13 +124,11 @@ #?(:clj (System/arraycopy src (int src-from) dst (int dst-from) (int n)) :cljs (.set dst (.subarray src src-from (+ src-from n)) dst-from))) -(defn with-mark - "PNG bytes `b` with the mark `text` added as an iTXt chunk straight after IHDR, so every byte of - the image data, and every pixel, is unchanged. Bytes already marked come back as they are." +(defn- png-with-mark + "PNG bytes `b` with the mark `text` as an iTXt chunk straight after IHDR, so every byte of the + image data, and every pixel, is unchanged." [b text] - (if (or (not (png? b)) (marked? b)) - b - (let [kw (text-bytes key-word) tx (text-bytes text) + (let [kw (text-bytes mark-keyword) tx (text-bytes text) data-len (+ (alength kw) 5 (alength tx)) chunk-bytes (new-bytes (+ 12 data-len)) ihdr-end 33 ; signature, then IHDR: always first, always 13 bytes of data @@ -118,11 +146,43 @@ (copy! b 0 out 0 ihdr-end) (copy! chunk-bytes 0 out ihdr-end (alength chunk-bytes)) (copy! b ihdr-end out (+ ihdr-end (alength chunk-bytes)) (- (alength b) ihdr-end)) - out))) + out)) -(defn composed +(defn- jpeg-with-mark + "JPEG bytes `b` with the mark `text` as a comment segment straight after the start-of-image + marker; the compressed image data is untouched." + [b text] + (let [payload (text-bytes (str mark-keyword "\u0000" text)) + len (+ 2 (alength payload)) + _ (when (> len 0xFFFF) (throw (ex-info "art mark too long for a JPEG comment" {:length len}))) + out (new-bytes (+ (alength b) 2 len))] + (copy! b 0 out 0 2) + (doseq [[i v] (map-indexed vector [0xFF 0xFE (bit-and (bit-shift-right len 8) 0xff) (bit-and len 0xff)])] + (put! out (+ 2 i) v)) + (copy! payload 0 out 6 (alength payload)) + (copy! b 2 out (+ 6 (alength payload)) (- (alength b) 2)) + out)) + +(defn write-mark + "Image bytes `b` carrying the mark `text`: a PNG iTXt chunk or a JPEG comment segment, the image + data untouched. Bytes already marked come back as they are. + GOTCHA: any other format throws rather than going out unmarked." + [b text] + (cond + (marked? b) b + (png? b) (png-with-mark b text) + (jpeg? b) (jpeg-with-mark b text) + :else (throw (ex-info "the art mark has no place in this image format" {})))) + +(defn combine-marks "The mark for a picture drawn from pieces whose marks are `sources` (nils ignored), or nil when none was marked." [sources] (when-let [s (seq (distinct (remove nil? sources)))] (str "licensed; composed from " (apply str (interpose " | " s))))) + +(defn carry-marks + "Image bytes `b`, drawn from pieces whose marks are `sources`, carrying their combined mark; `b` + unchanged when none of them was marked." + [b sources] + (if-let [m (combine-marks sources)] (write-mark b m) b)) diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index 95bf16fec..aa84579f1 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -780,7 +780,7 @@ :when url] (-> (js/fetch url) (.then #(.arrayBuffer %)) - (.then #(art-mark/mark-of (js/Uint8Array. %))) + (.then #(art-mark/read-mark (js/Uint8Array. %))) (.catch (constantly nil))))))) (defn- rasterize* @@ -931,8 +931,7 @@ [portrait] (-> (js/Promise.all #js [(rasterize* portrait) (piece-marks portrait)]) (.then (fn [[b64 marks]] - (let [m (art-mark/composed (array-seq marks))] - (if (and b64 m) (bytes->b64 (art-mark/with-mark (b64->bytes b64) m)) b64)))))) + (when b64 (bytes->b64 (art-mark/carry-marks (b64->bytes b64) (array-seq marks)))))))) ;; Portraits baked ahead of export, by value: {:promise p} while baking, then {:png b64-or-nil}. (defonce ^:private baked (atom {})) diff --git a/test/clj/orcpub/art_mark_drawing_test.clj b/test/clj/orcpub/art_mark_drawing_test.clj index bf6824f09..96d710782 100644 --- a/test/clj/orcpub/art_mark_drawing_test.clj +++ b/test/clj/orcpub/art_mark_drawing_test.clj @@ -55,33 +55,40 @@ (cond-> src (and src (str/includes? (:mime src) "png")) (assoc :bytes (or (when marked-pack (pack-bytes url)) - (mark/with-mark (:bytes src) "licensed; pack test; artist test"))))))] + (mark/write-mark (:bytes src) "licensed; pack test; artist test"))))))] (f)))) (deftest marking-leaves-every-pixel-as-it-was (doseq [url (take 4 (map :asset/url (mapcat val (:artist/layers pa/house-pack)))) :let [plain (resource-bytes (str "public" url)) - marked (mark/with-mark plain "licensed; pack test; artist test")]] - (is (nil? (mark/mark-of plain)) "a silhouette carries no mark") - (is (= "licensed; pack test; artist test" (mark/mark-of marked))) + marked (mark/write-mark plain "licensed; pack test; artist test")]] + (is (nil? (mark/read-mark plain)) "a silhouette carries no mark") + (is (= "licensed; pack test; artist test" (mark/read-mark marked))) (is (= (pixels plain) (pixels marked)) url) (is (= (vec (drop 33 plain)) (vec (drop (- (alength marked) (- (alength plain) 33)) marked))) "the image data is byte for byte the same") - (is (identical? marked (mark/with-mark marked "again")) "marking twice adds nothing"))) + (is (identical? marked (mark/write-mark marked "again")) "marking twice adds nothing"))) (deftest a-picture-drawn-from-marked-pieces-is-marked - (is (nil? (mark/mark-of (pr/render-png (portrait)))) "drawn from silhouettes: no mark") + (is (nil? (mark/read-mark (pr/render-png (portrait)))) "drawn from silhouettes: no mark") (with-marked-art #(let [png (pr/render-png (portrait))] - (is (str/starts-with? (str (mark/mark-of png)) "licensed; composed from ") + (is (str/starts-with? (str (mark/read-mark png)) "licensed; composed from ") (str "drawn from marked pieces: marked" (when marked-pack " (the real pack)")))))) +(deftest an-artist-page-example-drawn-from-marked-pieces-is-marked + (is (nil? (mark/read-mark (pr/render-example-jpeg (portrait) "w"))) "drawn from silhouettes: no mark") + (with-marked-art + #(is (str/starts-with? (str (mark/read-mark (pr/render-example-jpeg (portrait) "w"))) + "licensed; composed from ") + "the small watermarked JPEG carries the mark like every other drawing"))) + (deftest strand-files-from-marked-art-are-marked (let [dir (.toFile (Files/createTempDirectory "strands" (make-array java.nio.file.attribute.FileAttribute 0))) f (io/file dir "bangs" "l9_bangs_01.png")] (try (io/make-parents f) - (io/copy (mark/with-mark (resource-bytes "public/image/portraits/bangs/l9_bangs_01.png") "licensed; pack test; artist test") f) + (io/copy (mark/write-mark (resource-bytes "public/image/portraits/bangs/l9_bangs_01.png") "licensed; pack test; artist test") f) (strands/write-missing! (.getPath dir) true) (is (mark/marked? (Files/readAllBytes (.toPath (io/file dir "bangs" "l9_bangs_01.strands.png")))) "a strand file worked out from marked art carries the mark") @@ -104,7 +111,7 @@ (try (.mkdirs docs) (io/copy (or (when marked-pack (pack-bytes "/image/portraits/hair-front/l4_hair_front_01.png")) - (mark/with-mark (resource-bytes "public/image/portraits/hair-front/l4_hair_front_01.png") "licensed; pack test; artist test")) + (mark/write-mark (resource-bytes "public/image/portraits/hair-front/l4_hair_front_01.png") "licensed; pack test; artist test")) piece) (with-marked-art #(io/copy (pr/render-png (portrait)) composite)) (let [found (set (guard/marked-files))] diff --git a/test/clj/orcpub/portrait_art_guard_test.clj b/test/clj/orcpub/portrait_art_guard_test.clj index 4b08a6c63..0246088d5 100644 --- a/test/clj/orcpub/portrait_art_guard_test.clj +++ b/test/clj/orcpub/portrait_art_guard_test.clj @@ -90,8 +90,8 @@ there; the file's own compression hides it from a plain byte search)." [^bytes b] (try (with-open [doc (org.apache.pdfbox.Loader/loadPDF b)] - (some? (.getCustomMetadataValue (.getDocumentInformation doc) art-mark/key-word))) - (catch Exception _ (str/includes? (String. b "ISO-8859-1") art-mark/key-word)))) + (some? (.getCustomMetadataValue (.getDocumentInformation doc) art-mark/mark-keyword))) + (catch Exception _ (str/includes? (String. b "ISO-8859-1") art-mark/mark-keyword)))) (defn marked-files "Paths git tracks or would commit that carry the licensed-art mark: a PNG's orcpub-art chunk, or @@ -104,9 +104,9 @@ (.isFile (io/file path)) (not (and private? (str/starts-with? path (str art-dir "/"))))) :let [b (Files/readAllBytes (.toPath (io/file path)))] - :when (cond (re-find #"(?i)\.png$" path) (art-mark/marked? b) + :when (cond (re-find #"(?i)\.(png|jpe?g)$" path) (art-mark/marked? b) (re-find #"(?i)\.pdf$" path) (pdf-marked? b) - :else (str/includes? (String. ^bytes b "ISO-8859-1") art-mark/key-word))] + :else (str/includes? (String. ^bytes b "ISO-8859-1") art-mark/mark-keyword))] path))) (defn linework diff --git a/test/cljc/orcpub/art_mark_test.cljc b/test/cljc/orcpub/art_mark_test.cljc index 194901dd6..5f244a2c5 100644 --- a/test/cljc/orcpub/art_mark_test.cljc +++ b/test/cljc/orcpub/art_mark_test.cljc @@ -22,13 +22,35 @@ [b] #?(:clj (mapv #(bit-and % 0xff) b) :cljs (vec (array-seq (js/Array.from b))))) (deftest the-mark-is-the-same-bytes-everywhere - (let [marked (mark/with-mark (bytes-of tiny-png) "licensed; pack v1; artist house-pack")] + (let [marked (mark/write-mark (bytes-of tiny-png) "licensed; pack v1; artist house-pack")] (is (= expected (ints-of marked)) "chunk, CRC and position identical on both platforms") - (is (= "licensed; pack v1; artist house-pack" (mark/mark-of marked))) - (is (nil? (mark/mark-of (bytes-of tiny-png)))) - (is (= expected (ints-of (mark/with-mark marked "other"))) "marking twice adds nothing"))) + (is (= "licensed; pack v1; artist house-pack" (mark/read-mark marked))) + (is (nil? (mark/read-mark (bytes-of tiny-png)))) + (is (= expected (ints-of (mark/write-mark marked "other"))) "marking twice adds nothing"))) + +(def ^:private tiny-jpeg + "The smallest JPEG framing: start of image, end of image." + [255 216 255 217]) + +(deftest a-jpeg-carries-the-mark-in-a-comment + (let [marked (mark/write-mark (bytes-of tiny-jpeg) "licensed; pack v1; artist house-pack")] + (is (= [255, 216, 255, 254, 0, 49, 111, 114, 99, 112, 117, 98, 45, 97, 114, 116, 0, 108, 105, 99, 101, 110, 115, 101, 100, 59, 32, 112, 97, 99, 107, 32, 118, 49, 59, 32, 97, 114, 116, 105, 115, 116, 32, 104, 111, 117, 115, 101, 45, 112, 97, 99, 107, 255, 217] (ints-of marked)) + "a comment segment straight after start-of-image, the same bytes on both platforms") + (is (= "licensed; pack v1; artist house-pack" (mark/read-mark marked))) + (is (nil? (mark/read-mark (bytes-of tiny-jpeg)))) + (is (= (ints-of marked) (ints-of (mark/write-mark marked "other"))) "marking twice adds nothing"))) + +(deftest an-unmarkable-format-is-refused-not-skipped + (is (thrown? #?(:clj Exception :cljs js/Error) (mark/write-mark (bytes-of [71 73 70 56 57 97]) "x")) + "a GIF cannot hold the mark, so writing one fails loudly")) + +(deftest carrying-marks-marks-only-what-was-drawn-from-marked-pieces + (let [png (bytes-of tiny-png)] + (is (identical? png (mark/carry-marks png [nil nil])) "no marked piece: the bytes as they were") + (is (= "licensed; composed from licensed; pack v1; artist house-pack" + (mark/read-mark (mark/carry-marks png [nil "licensed; pack v1; artist house-pack"])))))) (deftest the-mark-of-a-composite-names-its-sources - (is (nil? (mark/composed [nil nil])) "no marked piece, no mark") + (is (nil? (mark/combine-marks [nil nil])) "no marked piece, no mark") (is (= "licensed; composed from licensed; pack v1; artist a" - (mark/composed [nil "licensed; pack v1; artist a" "licensed; pack v1; artist a"])))) + (mark/combine-marks [nil "licensed; pack v1; artist a" "licensed; pack v1; artist a"])))) From 9564d9bf66e8897a99e0aa63b1ec47582491a85f Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 03:50:48 +0000 Subject: [PATCH 53/91] Consolidate the 29 design capture scripts into one library and one list of rounds Every capture script repeated the same browser lookup, theme, font routing, overlay dismissal and variant-style injection. docs/design/capture/ now holds lib.js (a Capture that opens themed Views; a View restyles, clips, shoots and describes), rounds.js (each former script as a named round writing beside the review page that shows it) and capture.js (run one or more rounds, or list them). It reuses the probes' find-chrome and overlay suppression and the e2e suites' login. Every round was run against the dev server: all complete. phone-menus fails as the old script did (the My Content menu overruns a 320px phone by 22px), a finding about the site, not the port. The card round now uses the server's own example JPEGs instead of faking them in the page. --- docs/design/artist-profiles/capture-cards.js | 190 ---- docs/design/artist-profiles/capture-garden.js | 46 - docs/design/artist-profiles/capture-icons.js | 146 --- docs/design/artist-profiles/capture-picker.js | 105 --- docs/design/artist-profiles/capture-picks.js | 90 -- .../artist-profiles/capture-site-mark.js | 73 -- .../artist-profiles/capture-swatches.js | 117 --- docs/design/capture/capture.js | 22 + docs/design/capture/lib.js | 156 ++++ docs/design/capture/rounds.js | 849 ++++++++++++++++++ docs/design/style-guide.md | 4 +- docs/design/style-guide/capture-bold.js | 71 -- docs/design/style-guide/capture-gutters.js | 67 -- .../style-guide/capture-header-menus.js | 62 -- .../style-guide/capture-layout-width.js | 41 - docs/design/style-guide/capture-left-edges.js | 61 -- .../style-guide/capture-outline-colour.js | 38 - .../style-guide/capture-outline-gradient.js | 46 - docs/design/style-guide/capture-outline.js | 39 - .../style-guide/capture-phone-header.js | 31 - .../design/style-guide/capture-phone-menus.js | 42 - docs/design/style-guide/capture-roll-16.js | 63 -- docs/design/style-guide/capture-round3.js | 56 -- docs/design/style-guide/capture-round4.js | 63 -- docs/design/style-guide/capture-round5.js | 29 - docs/design/style-guide/capture-round6.js | 29 - docs/design/style-guide/capture-round7.js | 36 - docs/design/style-guide/capture-size-phone.js | 51 -- docs/design/style-guide/capture-size.js | 50 -- docs/design/style-guide/capture-weights.js | 72 -- docs/design/style-guide/measure-tab-room.js | 36 - docs/design/style-guide/survey-amber.js | 48 - docs/design/style-guide/survey-weights.js | 41 - 33 files changed, 1029 insertions(+), 1841 deletions(-) delete mode 100644 docs/design/artist-profiles/capture-cards.js delete mode 100644 docs/design/artist-profiles/capture-garden.js delete mode 100644 docs/design/artist-profiles/capture-icons.js delete mode 100644 docs/design/artist-profiles/capture-picker.js delete mode 100644 docs/design/artist-profiles/capture-picks.js delete mode 100644 docs/design/artist-profiles/capture-site-mark.js delete mode 100644 docs/design/artist-profiles/capture-swatches.js create mode 100644 docs/design/capture/capture.js create mode 100644 docs/design/capture/lib.js create mode 100644 docs/design/capture/rounds.js delete mode 100644 docs/design/style-guide/capture-bold.js delete mode 100644 docs/design/style-guide/capture-gutters.js delete mode 100644 docs/design/style-guide/capture-header-menus.js delete mode 100644 docs/design/style-guide/capture-layout-width.js delete mode 100644 docs/design/style-guide/capture-left-edges.js delete mode 100644 docs/design/style-guide/capture-outline-colour.js delete mode 100644 docs/design/style-guide/capture-outline-gradient.js delete mode 100644 docs/design/style-guide/capture-outline.js delete mode 100644 docs/design/style-guide/capture-phone-header.js delete mode 100644 docs/design/style-guide/capture-phone-menus.js delete mode 100644 docs/design/style-guide/capture-roll-16.js delete mode 100644 docs/design/style-guide/capture-round3.js delete mode 100644 docs/design/style-guide/capture-round4.js delete mode 100644 docs/design/style-guide/capture-round5.js delete mode 100644 docs/design/style-guide/capture-round6.js delete mode 100644 docs/design/style-guide/capture-round7.js delete mode 100644 docs/design/style-guide/capture-size-phone.js delete mode 100644 docs/design/style-guide/capture-size.js delete mode 100644 docs/design/style-guide/capture-weights.js delete mode 100644 docs/design/style-guide/measure-tab-room.js delete mode 100644 docs/design/style-guide/survey-amber.js delete mode 100644 docs/design/style-guide/survey-weights.js diff --git a/docs/design/artist-profiles/capture-cards.js b/docs/design/artist-profiles/capture-cards.js deleted file mode 100644 index f7ef10148..000000000 --- a/docs/design/artist-profiles/capture-cards.js +++ /dev/null @@ -1,190 +0,0 @@ -// Artist card layouts, captured from the running app. -// -// Loads the real /artists/fusspot page, then replaces the card's markup with -// each candidate layout just before the screenshot -- the method -// docs/design/portrait-credit used -- so what is captured is the site, not a -// drawing of it. Nothing in the source changes. -// -// The examples are what the proposal would serve: one flattened picture per -// portrait, shrunk to 240x300, a watermark tiled across the whole image, saved -// as a quality-0.6 JPEG. Made here in the browser from the page's own -// composites; the real version would render them on the server. -// -// lein fig:build && lein e2e-server -// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-cards.js - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const OUT = process.argv[2] || 'card-shots'; - -function findChrome() { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -} - -// Shrink, watermark, compress. Runs in the page. -async function degrade(page, pngBuffers) { - return page.evaluate(async (b64s) => { - const out = []; - for (const b64 of b64s) { - const img = new Image(); - img.src = 'data:image/png;base64,' + b64; - await img.decode(); - const w = 240, h = 300; - const c = document.createElement('canvas'); - c.width = w; c.height = h; - const g = c.getContext('2d'); - g.drawImage(img, 0, 0, w, h); - // Tiled across the whole picture, through the figure, so no single crop - // or small inpaint removes it. - g.save(); - g.translate(w / 2, h / 2); - g.rotate(-Math.PI / 6); - g.font = '600 11px "Open Sans", sans-serif'; - g.textAlign = 'center'; - const mark = 'fusspot · orcpub'; - for (let y = -h; y < h; y += 34) { - for (let x = -w; x < w; x += 118) { - const dx = x + ((y / 34) % 2 ? 59 : 0); - g.lineWidth = 2; g.strokeStyle = 'rgba(0,0,0,0.18)'; g.strokeText(mark, dx, y); - g.fillStyle = 'rgba(255,255,255,0.26)'; g.fillText(mark, dx, y); - } - } - g.restore(); - out.push(c.toDataURL('image/jpeg', 0.6)); - } - return out; - }, pngBuffers.map(b => b.toString('base64'))); -} - -const CSS = ` -.mk-card { max-width: 560px; margin: 0 auto; padding: 28px 28px 22px; background: #131924; - border: 1px solid rgba(240,161,0,0.16); border-radius: 12px; display: flex; flex-direction: column; gap: 20px; } -.ap-root.light-theme .mk-card { background: #fff; border-color: rgba(0,0,0,0.10); } -.mk-head { display: flex; flex-direction: column; gap: 10px; text-align: center; } -.mk-bio { margin: 0; font: italic 13px/1.5 'Open Sans', sans-serif; color: var(--lk-dim); } -.mk-pill { align-self: center; font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .12em; text-transform: uppercase; - color: var(--lk-dim); border: 1px dashed var(--lk-rule); padding: 5px 9px; border-radius: 99px; } -.mk-links { display: flex; flex-direction: column; gap: 8px; } -.mk-link { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 5px; - border: 1px solid rgba(255,255,255,0.10); color: #ebeef4; text-decoration: none; - font: 600 13px/1 'Open Sans', sans-serif; } -.mk-link .host { margin-left: auto; font-weight: 400; font-size: 11px; color: var(--lk-dim); } -.mk-link.feature { border-color: #f0a100; background: rgba(240,161,0,0.07); } -.ap-root.light-theme .mk-link { border-color: rgba(0,0,0,0.14); color: #363636; } -.ap-root.light-theme .mk-link.feature { border-color: #33658A; background: rgba(51,101,138,0.06); } -.mk-link .lk-ico { width: 15px; height: 15px; } -.mk-ex { display: flex; justify-content: center; gap: 10px; } -.mk-ex img { width: 150px; height: 188px; border-radius: 8px; display: block; border: 1px solid rgba(240,161,0,0.16); } -.ap-root.light-theme .mk-ex img { border-color: rgba(0,0,0,0.12); } -.mk-excap { text-align: center; font: italic 12px/1.4 'Vollkorn', Georgia, serif; color: var(--lk-dim); margin-top: -8px; } -.mk-pieces { text-align: center; font-size: 12px; color: var(--lk-dim); margin: 0; } -.mk-pieces a { color: inherit; text-decoration: underline dotted var(--lk-under); text-underline-offset: 3px; } -.mk-foot { margin: 0; padding-top: 14px; border-top: 1px solid rgba(255,255,255,0.06); font-size: 12px; color: var(--lk-dim); text-align: center; } -.ap-root.light-theme .mk-foot { border-top-color: rgba(0,0,0,0.08); } -/* split */ -.mk-split { display: grid; grid-template-columns: 150px 1fr; gap: 22px; align-items: start; } -.mk-split .mk-head { text-align: left; } -.mk-split .mk-pill { align-self: flex-start; } -.mk-split .lk-row .lk-rule.l { display: none; } -.mk-split .ap-name { font-size: 34px; } -@media (max-width: 560px) { - .mk-card { padding: 22px 16px 18px; } - .mk-split { grid-template-columns: 1fr; } - .mk-split .mk-ex { justify-content: center; } - .mk-split .mk-head { text-align: center; } .mk-split .mk-pill { align-self: center; } - .mk-split .lk-row .lk-rule.l { display: block; } -}`; - -function ico(icon, color) { - return ``; -} - -const HEAD = ` -
    Portrait artist
    -

    Fusspot

    -

    [The artist's own short bio, up to 280 characters.]

    - [Commission status]`; - -const LINKS = ` - `; - -const PIECES = `

    28 pieces in the portrait maker: heads, hair, eyes and 7 more layers. - Try them in a portrait ›

    `; - -const FOOT = `

    Every portrait made with these pieces credits Fusspot.

    `; - -const LAYOUTS = { - // A: one column, links first, two small examples after - column: (ex) => ` -
    ${HEAD}
    - ${LINKS} -
    Example portraitExample portrait
    -
    Made with Fusspot's pieces
    - ${FOOT}`, - // B: a Carrd-style split card, one example beside the name and links - split: (ex) => ` -
    -
    Example portrait
    -
    -
    ${HEAD}
    - ${LINKS} -
    -
    - ${FOOT}`, - // C: no pictures at all; the pieces are seen in use - minimal: () => ` -
    ${HEAD}
    - ${LINKS} - ${PIECES} - ${FOOT}` -}; - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark-theme', 'light-theme']) { - for (const [vw, vh, tag] of [[1280, 900, 'desktop'], [390, 844, 'phone']]) { - const ctx = await browser.newContext({ viewport: { width: vw, height: vh }, deviceScaleFactor: 1 }); - await ctx.addInitScript(t => { - try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} - }, theme); - const page = await ctx.newPage(); - await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); - await page.waitForSelector('.ap-example .portrait-layer'); - await page.waitForTimeout(600); - // The cookie banner sits over the lower half of the card; accept it. - const cookie = page.locator('#cookie-btn'); - if (await cookie.count()) { await cookie.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(300); } - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - const frames = page.locator('.ap-example .ap-frame'); - const shots = [await frames.nth(0).screenshot(), await frames.nth(1).screenshot()]; - const ex = await degrade(page, shots); - if (theme === 'dark-theme' && tag === 'desktop') { - ex.forEach((d, i) => fs.writeFileSync(path.join(OUT, `example-${i}.jpg`), Buffer.from(d.split(',')[1], 'base64'))); - } - await page.addStyleTag({ content: CSS }); - for (const [name, render] of Object.entries(LAYOUTS)) { - await page.evaluate(html => { - const card = document.querySelector('.ap-card, .mk-card'); - card.className = 'mk-card'; - card.innerHTML = html; - }, render(ex)); - await page.waitForTimeout(250); - const box = await page.locator('.ap-root').boundingBox(); - const file = path.join(OUT, `${name}-${theme.replace('-theme', '')}-${tag}.png`); - await page.screenshot({ path: file, fullPage: true, clip: { x: 0, y: box.y, width: vw, height: box.height } }); - console.log(' ' + file); - } - await ctx.close(); - } - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/capture-garden.js b/docs/design/artist-profiles/capture-garden.js deleted file mode 100644 index db2467106..000000000 --- a/docs/design/artist-profiles/capture-garden.js +++ /dev/null @@ -1,46 +0,0 @@ -// Captures the artist pages in both themes, desktop and phone, for comparing the page -// before and after its stylesheet moved into Garden. Needs the app at :8890. -// Run: NODE_EXTRA_CA_CERTS= \ -// NODE_OPTIONS=--require=../../../test/browser/lib/suppress-overlays-preload.js \ -// node capture-garden.js -// The preload keeps the cookie notice and What's New panel out of the shots. -const { chromium, devices } = require('playwright'); -const path = require('path'); - -const BASE = 'http://localhost:8890'; -const tag = process.argv[2] || 'shot'; -const OUT = path.join(__dirname, 'garden'); -const pages = [['profile', '/artists/fusspot'], ['index', '/artists']]; -const sizes = [['desktop', { viewport: { width: 1280, height: 900 } }], - ['phone', devices['iPhone 13']]]; - -(async () => { - const browser = await chromium.launch(); - for (const theme of ['dark', 'light']) { - for (const [size, device] of sizes) { - const ctx = await browser.newContext({ ...device, reducedMotion: 'reduce' }); - // Google Fonts through Node, which trusts the sandbox's proxy CA; the browser does not. - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - await ctx.addInitScript(t => { - try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} - }, theme === 'light' ? 'light-theme' : 'dark-theme'); - const page = await ctx.newPage(); - for (const [name, url] of pages) { - await page.goto(BASE + url, { waitUntil: 'networkidle' }); - await page.waitForSelector('.ap-card'); - await page.evaluate(() => document.fonts.ready); - await page.waitForTimeout(500); - // A full-page shot clipped to the page body: an element shot scrolls, and the - // sticky header then lands on top of the card. - const box = await page.evaluate(() => { - const r = document.querySelector('.ap-root').getBoundingClientRect(); - return { x: r.left + scrollX, y: r.top + scrollY, width: r.width, height: r.height }; - }); - await page.screenshot({ path: path.join(OUT, `${tag}-${name}-${theme}-${size}.png`), - fullPage: true, clip: box }); - } - await ctx.close(); - } - } - await browser.close(); -})(); diff --git a/docs/design/artist-profiles/capture-icons.js b/docs/design/artist-profiles/capture-icons.js deleted file mode 100644 index cc6b6e577..000000000 --- a/docs/design/artist-profiles/capture-icons.js +++ /dev/null @@ -1,146 +0,0 @@ -// Link-icon candidates, captured in the running credit and card. -// -// For each candidate, swaps the icon on the artist's site link -- the left -// mark in the drawer credit, and the featured row on the profile card -- and -// screenshots both in both themes. The icons go in as data-URI masks at -// capture time; nothing in the source changes. -// -// lein fig:build && lein e2e-server -// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-icons.js -// -// holds the candidate SVGs, normalised to black shapes on nothing -// (game-icons ship a black square behind a white glyph; strip it first). - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'icon-shots']; - -function findChrome() { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -} - -const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f)).toString('base64'); - -// Candidates for the artist's own site: [file, label] -const SITE = [ - ['now-site.svg', 'Globe (current)'], - ['lu-globe.svg', 'Globe, Lucide'], - ['lu-house.svg', 'House, Lucide'], - ['ph-house-line.svg', 'House, Phosphor'], - ['tb-world-www.svg', 'WWW, Tabler'], - ['lu-link.svg', 'Chain link, Lucide'], - ['gi-lorc-quill-ink.svg', 'Quill and ink, game-icons'], - ['gi-lorc-quill.svg', 'Quill, game-icons'], - ['ph-pen-nib.svg', 'Pen nib, Phosphor'], - ['ph-feather.svg', 'Feather, Phosphor'], - ['gi-delapouite-palette.svg', 'Palette, game-icons'], - ['lu-palette.svg', 'Palette, Lucide'], - ['gi-delapouite-paint-brush.svg', 'Paint brush, game-icons'], - ['ph-paint-brush.svg', 'Paint brush, Phosphor'], - ['gi-lorc-castle.svg', 'Castle, game-icons'] -]; - -// A full link list in two families, to judge consistency. -const FAMILIES = { - gameicons: [ - ['gi-lorc-quill-ink.svg', '#f0a100', 'Site', 'fusspot.rip'], - ['now-twitch.svg', '#9146ff', 'Twitch', 'twitch.tv/fusspot'], - ['gi-delapouite-paint-brush.svg', '#e0a24d', 'Portfolio', 'example.test/work'], - ['gi-lorc-scroll-unfurled.svg', '#c9a36a', 'Commissions', 'example.test/commissions'], - ['gi-lorc-open-book.svg', '#7a94b8', 'Blog', 'example.test/blog'], - ['lu-link.svg', '#8b95a5', 'Anything else', 'example.test'] - ], - lucide: [ - ['lu-house.svg', '#f0a100', 'Site', 'fusspot.rip'], - ['now-twitch.svg', '#9146ff', 'Twitch', 'twitch.tv/fusspot'], - ['lu-palette.svg', '#e0a24d', 'Portfolio', 'example.test/work'], - ['lu-scroll-text.svg', '#c9a36a', 'Commissions', 'example.test/commissions'], - ['lu-book-open.svg', '#7a94b8', 'Blog', 'example.test/blog'], - ['lu-link.svg', '#8b95a5', 'Anything else', 'example.test'] - ] -}; - -const CARD_CSS = ` -.mk-links { display: flex; flex-direction: column; gap: 8px; width: 420px; padding: 14px; } -.mk-link { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 5px; - border: 1px solid rgba(255,255,255,0.10); color: #ebeef4; text-decoration: none; font: 600 13px/1 'Open Sans', sans-serif; } -.mk-link .host { margin-left: auto; font-weight: 400; font-size: 11px; color: var(--lk-dim); } -.mk-link.feature { border-color: #f0a100; background: rgba(240,161,0,0.07); } -.ap-root.light-theme .mk-link { border-color: rgba(0,0,0,0.14); color: #363636; } -.ap-root.light-theme .mk-link.feature { border-color: #33658A; background: rgba(51,101,138,0.06); } -.mk-link .lk-ico { width: 16px; height: 16px; }`; - -const icoStyle = (u, color) => - `color:${color};-webkit-mask-image:url(${u});mask-image:url(${u})`; - -async function newPage(browser, theme) { - const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme); - const page = await ctx.newPage(); - return { ctx, page }; -} - -async function dismiss(page) { - const cookie = page.locator('#cookie-btn'); - if (await cookie.count()) { await cookie.click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(250); } - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); -} - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark', 'light']) { - // --- the drawer credit --- - const { ctx, page } = await newPage(browser, theme + '-theme'); - await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); - await dismiss(page); - const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); - if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } - await page.locator('.pl-launcher').click(); - await page.locator('.pl-drawer').waitFor({ state: 'visible' }); - await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); - await page.mouse.move(5, 5); - await page.waitForTimeout(500); - for (const [file] of SITE) { - await page.evaluate(s => { - const ico = document.querySelector('.pl-attribution .lk-mark .lk-ico'); - ico.setAttribute('style', s); - }, icoStyle(uri(file), '#f0a100')); - await page.waitForTimeout(80); - await page.locator('.pl-attribution').screenshot({ path: path.join(OUT, `credit-${theme}-${file.replace('.svg', '')}.png`) }); - } - await ctx.close(); - - // --- the card's link rows --- - const p2 = await newPage(browser, theme + '-theme'); - await p2.page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); - await dismiss(p2.page); - await p2.page.addStyleTag({ content: CARD_CSS }); - const row = (u, color, label, host, feature) => - `${label}${host}`; - for (const [file] of SITE) { - await p2.page.evaluate(html => { - const card = document.querySelector('.ap-card, .mk-links'); - card.className = 'mk-links'; card.innerHTML = html; - }, row(uri(file), '#f0a100', 'Site', 'fusspot.rip', true)); - await p2.page.waitForTimeout(80); - await p2.page.locator('.mk-links').screenshot({ path: path.join(OUT, `card-${theme}-${file.replace('.svg', '')}.png`) }); - } - for (const [fam, rows] of Object.entries(FAMILIES)) { - await p2.page.evaluate(html => { - const card = document.querySelector('.ap-card, .mk-links'); - card.className = 'mk-links'; card.innerHTML = html; - }, rows.map(([f, c, l, h], i) => row(uri(f), c, l, h, i === 0)).join('')); - await p2.page.waitForTimeout(120); - await p2.page.locator('.mk-links').screenshot({ path: path.join(OUT, `family-${theme}-${fam}.png`) }); - } - await p2.ctx.close(); - console.log(' ' + theme + ' done'); - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/capture-picker.js b/docs/design/artist-profiles/capture-picker.js deleted file mode 100644 index b7b3528c8..000000000 --- a/docs/design/artist-profiles/capture-picker.js +++ /dev/null @@ -1,105 +0,0 @@ -// The two-step colour picker: five colours, then how the colour looks in the -// light theme -- a deeper shade of itself, or a paired colour. Drawn inside -// the running site in place of the profile card, since the account page it -// belongs on doesn't exist yet. Injected at capture time; no source changes. -// -// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-picker.js - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'picker-shots']; -const findChrome = () => { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -}; -const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f + '.svg')).toString('base64'); -const mark = (u, color, size) => - ``; - -// name: [dark, light as a deeper shade, light as a paired colour] -const COLORS = { - yellow: ['#ffd21a', '#9a7400', '#33658A'], - coral: ['#ff6b57', '#d9432f', '#0f7c80'], - teal: ['#1fb5a3', '#0f8577', '#b4532a'], - rose: ['#f06aa6', '#c2386f', '#1f7a4a'], - green: ['#43b85c', '#2f8a44', '#8e3a78'] -}; -const ICON_SET = [['ph-browser-fill', 'Browser'], ['ph-house-fill', 'House'], ['ph-compass-fill', 'Compass'], ['ph-sparkle-fill', 'Sparkle']]; - -const CSS = ` -.pk { max-width: 520px; margin: 0 auto; padding: 22px 24px; background: #131924; border: 1px solid rgba(255,255,255,0.08); - border-radius: 12px; display: flex; flex-direction: column; gap: 18px; font-family: 'Open Sans', sans-serif; color: #ebeef4; } -.ap-root.light-theme .pk { background: #fff; border-color: rgba(0,0,0,0.10); color: #363636; } -.pk h2 { margin: 0; font: 700 16px/1.3 'Open Sans', sans-serif; } -.pk .row { display: flex; flex-direction: column; gap: 9px; } -.pk .lbl { font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--lk-dim); } -.pk .icons, .pk .swatches { display: flex; flex-wrap: wrap; gap: 8px; } -.pk .ib { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 72px; padding: 10px 0 8px; border-radius: 6px; - border: 1px solid rgba(255,255,255,0.10); font: 600 11px/1 'Open Sans', sans-serif; color: var(--lk-dim); } -.ap-root.light-theme .pk .ib { border-color: rgba(0,0,0,0.14); } -.pk .ib.on { border-color: #f0a100; color: #ebeef4; box-shadow: 0 0 0 2px rgba(240,161,0,0.25); } -.ap-root.light-theme .pk .ib.on { border-color: #33658A; color: #363636; box-shadow: 0 0 0 2px rgba(51,101,138,0.2); } -.pk .sw { width: 30px; height: 30px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.25); } -.ap-root.light-theme .pk .sw { border-color: rgba(0,0,0,0.2); } -.pk .sw.on { box-shadow: 0 0 0 2px #131924, 0 0 0 4px #f0a100; } -.ap-root.light-theme .pk .sw.on { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #33658A; } -.pk .swl { display: flex; flex-direction: column; align-items: center; gap: 6px; font: 600 10px/1 'Open Sans', sans-serif; color: var(--lk-dim); width: 46px; } -.pk .mode { display: inline-flex; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; overflow: hidden; align-self: flex-start; } -.ap-root.light-theme .pk .mode { border-color: rgba(0,0,0,0.14); } -.pk .mode span { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; font: 600 12px/1 'Open Sans', sans-serif; color: var(--lk-dim); } -.pk .mode span.on { background: rgba(240,161,0,0.10); color: #ffcc5e; } -.ap-root.light-theme .pk .mode span.on { background: rgba(51,101,138,0.08); color: #33658A; } -.pk .mode i { width: 14px; height: 14px; border-radius: 50%; display: inline-block; } -.pk .note { margin: 0; font-size: 12px; color: var(--lk-dim); } -.pk .prev { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 14px; display: flex; flex-direction: column; gap: 7px; } -.ap-root.light-theme .pk .prev { border-top-color: rgba(0,0,0,0.08); } -.pk .lk-name { text-decoration: underline dotted var(--lk-under); text-decoration-thickness: 1px; text-underline-offset: 3px; }`; - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark', 'light']) { - const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); - const page = await ctx.newPage(); - await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); - const c = page.locator('#cookie-btn'); - if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' + CSS }); - for (const [icon, color, mode] of [['ph-browser-fill', 'teal', 'shade'], ['ph-browser-fill', 'teal', 'paired'], - ['ph-sparkle-fill', 'yellow', 'shade'], ['ph-sparkle-fill', 'yellow', 'paired']]) { - const li = mode === 'shade' ? 1 : 2; - const [d, s, p] = COLORS[color]; - const shown = theme === 'dark' ? d : (mode === 'shade' ? s : p); - const html = ` -

    Your artist page

    -
    Icon for your site
    - ${ICON_SET.map(([f, l]) => `
    ${mark(uri(f), f === icon ? shown : 'currentColor', 18)}${l}
    `).join('')} -
    -
    Colour
    - ${Object.entries(COLORS).map(([n, v]) => `
    ${n}
    `).join('')} -
    -
    In the light theme
    -
    - A deeper shade - A paired colour -
    -

    The left half of each swatch is the dark theme; the right half is the light theme. Visitors see whichever matches theirs.

    -
    - `; - await page.evaluate(h => { const el = document.querySelector('.ap-card, .pk'); el.className = 'pk'; el.innerHTML = h; }, html); - await page.waitForTimeout(120); - await page.locator('.pk').screenshot({ path: path.join(OUT, `picker-${theme}-${icon}-${color}-${mode}.png`) }); - } - await ctx.close(); - console.log(' ' + theme + ' done'); - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/capture-picks.js b/docs/design/artist-profiles/capture-picks.js deleted file mode 100644 index 8a4498dda..000000000 --- a/docs/design/artist-profiles/capture-picks.js +++ /dev/null @@ -1,90 +0,0 @@ -// The four site icons an artist could pick, in coral and teal, captured in the -// drawer credit and on the profile card. Injected at capture time; no source -// changes. -// -// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-picks.js - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'pick-shots']; -const findChrome = () => { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -}; -const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f + '.svg')).toString('base64'); -const mask = (u, color, size) => - ``; - -const PICKS = ['ph-browser-fill', 'ph-house-fill', 'ph-compass-fill', 'ph-sparkle-fill']; -const COLORS = { coral: '#ff6b57', teal: '#1fb5a3' }; - -const CARD_CSS = ` -.mk-card { max-width: 520px; margin: 0 auto; padding: 26px 26px 20px; background: #131924; - border: 1px solid rgba(240,161,0,0.16); border-radius: 12px; display: flex; flex-direction: column; gap: 18px; } -.ap-root.light-theme .mk-card { background: #fff; border-color: rgba(0,0,0,0.10); } -.mk-head { display: flex; flex-direction: column; gap: 10px; text-align: center; } -.mk-bio { margin: 0; font: italic 13px/1.5 'Open Sans', sans-serif; color: var(--lk-dim); } -.mk-links { display: flex; flex-direction: column; gap: 8px; } -.mk-link { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 5px; - border: 1px solid rgba(255,255,255,0.10); color: #ebeef4; text-decoration: none; font: 600 13px/1 'Open Sans', sans-serif; } -.mk-link .host { margin-left: auto; font-weight: 400; font-size: 11px; color: var(--lk-dim); } -.mk-link.feature { border-color: #f0a100; background: rgba(240,161,0,0.07); } -.ap-root.light-theme .mk-link { border-color: rgba(0,0,0,0.14); color: #363636; } -.ap-root.light-theme .mk-link.feature { border-color: #33658A; background: rgba(51,101,138,0.06); }`; - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark', 'light']) { - const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); - const page = await ctx.newPage(); - const settle = async () => { - const c = page.locator('#cookie-btn'); - if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - }; - - // credit - await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); - await settle(); - const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); - if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } - await page.locator('.pl-launcher').click(); - await page.locator('.pl-drawer').waitFor({ state: 'visible' }); - await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); - await page.mouse.move(5, 5); - await page.waitForTimeout(500); - for (const icon of PICKS) for (const [cn, color] of Object.entries(COLORS)) { - await page.evaluate(h => { document.querySelector('.pl-attribution .lk-mark').innerHTML = h; }, mask(uri(icon), color, 12)); - await page.locator('.pl-attribution').screenshot({ path: path.join(OUT, `credit-${theme}-${icon}-${cn}.png`) }); - } - - // card - await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); - await settle(); - await page.addStyleTag({ content: CARD_CSS }); - for (const icon of PICKS) for (const [cn, color] of Object.entries(COLORS)) { - const html = ` -
    -
    Portrait artist
    -

    Fusspot

    -

    [The artist's own short bio, up to 280 characters.]

    -
    - `; - await page.evaluate(h => { const c = document.querySelector('.ap-card, .mk-card'); c.className = 'mk-card'; c.innerHTML = h; }, html); - await page.waitForTimeout(100); - await page.locator('.mk-card').screenshot({ path: path.join(OUT, `card-${theme}-${icon}-${cn}.png`) }); - } - await ctx.close(); - console.log(' ' + theme + ' done'); - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/capture-site-mark.js b/docs/design/artist-profiles/capture-site-mark.js deleted file mode 100644 index facd1f342..000000000 --- a/docs/design/artist-profiles/capture-site-mark.js +++ /dev/null @@ -1,73 +0,0 @@ -// The artist's site mark in the credit: filled icons x colours, plus a badge -// treatment, each captured beside the real Twitch mark in the running drawer. -// -// Swaps the credit's left mark at capture time with a data-URI mask (or, for -// the badge, a coloured tile holding a white glyph). Nothing in the source -// changes. -// -// lein fig:build && lein e2e-server -// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-site-mark.js - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'mark-shots']; - -function findChrome() { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -} -const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f)).toString('base64'); - -const ICON_SET = ['now-site', 'ph-browser-fill', 'ph-app-window-fill', 'ph-house-fill', - 'ph-compass-fill', 'ph-sparkle-fill', 'gi-lorc-quill-ink']; -const BADGE_SET = ['ph-browser-fill', 'ph-house-fill', 'gi-lorc-quill-ink']; -const COLORS = { amber: '#f0a100', coral: '#ff6b57', teal: '#1fb5a3', rose: '#e6488f', green: '#43b85c' }; - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark', 'light']) { - const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); - const page = await ctx.newPage(); - await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); - const cookie = page.locator('#cookie-btn'); - if (await cookie.count()) { await cookie.click({ timeout: 3000 }).catch(() => {}); } - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); - if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } - await page.locator('.pl-launcher').click(); - await page.locator('.pl-drawer').waitFor({ state: 'visible' }); - await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); - await page.mouse.move(5, 5); - await page.waitForTimeout(500); - - const setMark = (html) => page.evaluate(h => { - document.querySelector('.pl-attribution .lk-mark').innerHTML = h; - }, html); - const shoot = (name) => page.locator('.pl-attribution .lk-row').screenshot({ path: path.join(OUT, `${theme}-${name}.png`) }); - - for (const icon of ICON_SET) { - for (const [cname, color] of Object.entries(COLORS)) { - const u = uri(icon + '.svg'); - await setMark(``); - await shoot(`${icon}-${cname}`); - } - } - for (const icon of BADGE_SET) { - for (const [cname, color] of Object.entries(COLORS)) { - const u = uri(icon + '.svg'); - await setMark(`` + - ``); - await shoot(`badge-${icon}-${cname}`); - } - } - await ctx.close(); - console.log(' ' + theme + ' done'); - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/artist-profiles/capture-swatches.js b/docs/design/artist-profiles/capture-swatches.js deleted file mode 100644 index cd5cabed7..000000000 --- a/docs/design/artist-profiles/capture-swatches.js +++ /dev/null @@ -1,117 +0,0 @@ -// Themed colour pairs for the artist's site mark, and a picker with split -// swatches. The picker is drawn inside the real site (on /artists/fusspot, in -// place of the card) because the account page it belongs on doesn't exist -// yet. The credit rows are the real drawer credit. Injected at capture time; -// no source changes. -// -// NODE_PATH=$(npm root -g) node docs/design/artist-profiles/capture-swatches.js - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const [ICONS, OUT] = [process.argv[2], process.argv[3] || 'swatch-shots']; -const findChrome = () => { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -}; -const uri = f => 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(ICONS, f + '.svg')).toString('base64'); - -// [dark-theme colour, light-theme colour]; each >= 4.3:1 on its own ground -// PAIRS_JSON and SELECTED_JSON override these, to capture another set. -const PAIRS = process.env.PAIRS_JSON ? JSON.parse(process.env.PAIRS_JSON) : { - coral: ['#ff6b57', '#d9432f'], - teal: ['#1fb5a3', '#0f8577'], - yellow: ['#ffd21a', '#9a7400'], - rose: ['#f06aa6', '#c2386f'], - green: ['#43b85c', '#2f8a44'] -}; -const SELECTED = process.env.SELECTED_JSON ? JSON.parse(process.env.SELECTED_JSON) - : [['ph-browser-fill', 'teal'], ['ph-sparkle-fill', 'yellow']]; -const ICON_SET = [['ph-browser-fill', 'Browser'], ['ph-house-fill', 'House'], ['ph-compass-fill', 'Compass'], ['ph-sparkle-fill', 'Sparkle']]; - -const mark = (u, color, size) => - ``; - -const PANEL_CSS = ` -.pk { max-width: 520px; margin: 0 auto; padding: 22px 24px; background: #131924; border: 1px solid rgba(255,255,255,0.08); - border-radius: 12px; display: flex; flex-direction: column; gap: 18px; font-family: 'Open Sans', sans-serif; color: #ebeef4; } -.ap-root.light-theme .pk { background: #fff; border-color: rgba(0,0,0,0.10); color: #363636; } -.pk h2 { margin: 0; font: 700 16px/1.3 'Open Sans', sans-serif; } -.pk .row { display: flex; flex-direction: column; gap: 8px; } -.pk .lbl { font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--lk-dim); } -.pk .icons, .pk .swatches { display: flex; flex-wrap: wrap; gap: 8px; } -.pk .ib { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 72px; padding: 10px 0 8px; border-radius: 6px; - border: 1px solid rgba(255,255,255,0.10); font: 600 11px/1 'Open Sans', sans-serif; color: var(--lk-dim); } -.ap-root.light-theme .pk .ib { border-color: rgba(0,0,0,0.14); } -.pk .ib.on { border-color: #f0a100; color: #ebeef4; box-shadow: 0 0 0 2px rgba(240,161,0,0.25); } -.ap-root.light-theme .pk .ib.on { border-color: #33658A; color: #363636; box-shadow: 0 0 0 2px rgba(51,101,138,0.2); } -.pk .sw { width: 30px; height: 30px; border-radius: 50%; position: relative; border: 1px solid rgba(255,255,255,0.25); } -.ap-root.light-theme .pk .sw { border-color: rgba(0,0,0,0.2); } -.pk .sw.on { box-shadow: 0 0 0 2px #131924, 0 0 0 4px #f0a100; } -.ap-root.light-theme .pk .sw.on { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #33658A; } -.pk .swl { display: flex; flex-direction: column; align-items: center; gap: 6px; font: 600 10px/1 'Open Sans', sans-serif; color: var(--lk-dim); width: 46px; } -.pk .note { margin: 0; font-size: 12px; color: var(--lk-dim); } -.pk .prev { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 14px; display: flex; flex-direction: column; gap: 7px; } -.ap-root.light-theme .pk .prev { border-top-color: rgba(0,0,0,0.08); } -.pk .lk-name { text-decoration: underline dotted var(--lk-under); text-decoration-thickness: 1px; text-underline-offset: 3px; }`; - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark', 'light']) { - const ti = theme === 'dark' ? 0 : 1; - const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); - const page = await ctx.newPage(); - const settle = async () => { - const c = page.locator('#cookie-btn'); - if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - }; - - // --- the picker, inside the site --- - await page.goto(`${BASE}/artists/fusspot`, { waitUntil: 'networkidle' }); - await settle(); - await page.addStyleTag({ content: PANEL_CSS }); - for (const [selIcon, selColor] of SELECTED) { - const c = PAIRS[selColor][ti]; - const html = ` -

    Your artist page

    -
    Icon for your site
    - ${ICON_SET.map(([f, l]) => `
    ${mark(uri(f), f === selIcon ? c : 'currentColor', 18)}${l}
    `).join('')} -
    -
    Colour
    - ${Object.entries(PAIRS).map(([n, [d, l]]) => `
    ${n}
    `).join('')} -
    -

    Each colour has a version for the dark theme and one for the light theme. Visitors see whichever matches their theme.

    - `; - await page.evaluate(h => { const el = document.querySelector('.ap-card, .pk'); el.className = 'pk'; el.innerHTML = h; }, html); - await page.waitForTimeout(120); - await page.locator('.pk').screenshot({ path: path.join(OUT, `panel-${theme}-${selIcon}-${selColor}.png`) }); - } - - // --- the real credit, each pair --- - await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); - await settle(); - const desc = page.locator('.builder-tab', { hasText: /^Description$/ }).first(); - if (await desc.count()) { await desc.click(); await page.waitForTimeout(300); } - await page.locator('.pl-launcher').click(); - await page.locator('.pl-drawer').waitFor({ state: 'visible' }); - await page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); - await page.mouse.move(5, 5); - await page.waitForTimeout(500); - for (const [f] of ICON_SET) for (const [n, pair] of Object.entries(PAIRS)) { - await page.evaluate(h => { document.querySelector('.pl-attribution .lk-mark').innerHTML = h; }, mark(uri(f), pair[ti], 12)); - await page.locator('.pl-attribution .lk-row').screenshot({ path: path.join(OUT, `credit-${theme}-${f}-${n}.png`) }); - } - await ctx.close(); - console.log(' ' + theme + ' done'); - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/capture/capture.js b/docs/design/capture/capture.js new file mode 100644 index 000000000..694928eee --- /dev/null +++ b/docs/design/capture/capture.js @@ -0,0 +1,22 @@ +// Run design captures against the app on ORCPUB_BASE (default http://localhost:8890): +// NODE_PATH=$(npm root -g) NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node docs/design/capture/capture.js ... +// With no round, lists them. OUT=dir writes there instead of beside the round's review page. +const path = require('path'); +const { Capture } = require('./lib'); +const rounds = require('./rounds'); + +(async () => { + const names = process.argv.slice(2); + if (!names.length) { + for (const [name, r] of Object.entries(rounds)) console.log(`${name.padEnd(26)} ${r.what}`); + return; + } + const unknown = names.filter(n => !rounds[n]); + if (unknown.length) throw new Error(`no round ${unknown.join(', ')}; run with no arguments for the list`); + for (const name of names) { + const out = process.env.OUT || path.join(__dirname, '..', rounds[name].out); + console.log(`== ${name} -> ${path.relative(process.cwd(), out) || '.'}`); + const cap = await Capture.launch(out); + try { await rounds[name].run(cap); } finally { await cap.close(); } + } +})().catch(e => { console.error(e.message || e); process.exit(1); }); diff --git a/docs/design/capture/lib.js b/docs/design/capture/lib.js new file mode 100644 index 000000000..843a48f95 --- /dev/null +++ b/docs/design/capture/lib.js @@ -0,0 +1,156 @@ +// What every design capture shares: a browser, and a view of one app page that is themed, has +// its fonts and no overlays, and can be restyled, measured and shot. Rounds (rounds.js) use only +// this. Shots go under OUT (default docs/design/capture/out/), which git ignores. +require('../../../test/browser/lib/suppress-overlays-preload.js'); +const { chromium, devices } = require('playwright'); +const { findChrome } = require('../../../test/browser/lib/find-chrome.js'); +const { login } = require('../../../scripts/e2e/lib.js'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; + +// A view of one page. Restyle swaps the round's variant CSS; clips are page rectangles for shoot. +class View { + constructor(ctx, page, out) { this.ctx = ctx; this.page = page; this.out = out; } + + async go(url) { + await this.page.goto(BASE + url, { waitUntil: 'networkidle' }); + await this.page.evaluate(() => document.fonts.ready); + await this.page.waitForTimeout(300); + } + + async addStyle(css) { await this.page.addStyleTag({ content: css }); } + + async restyle(css) { + await this.page.evaluate(c => { + document.querySelectorAll('style[data-variant]').forEach(e => e.remove()); + const e = document.createElement('style'); e.dataset.variant = ''; e.textContent = c || ''; + document.head.appendChild(e); + }, css); + await this.page.waitForTimeout(120); + } + + // Load extra Open Sans weights from Google Fonts (the site ships 400 only). + async addFontWeights(weights) { + const href = `https://fonts.googleapis.com/css2?family=Open+Sans:wght@${weights.join(';')}&display=block`; + await this.page.evaluate(h => new Promise((ok, no) => { + const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = h; l.onload = ok; l.onerror = no; + document.head.appendChild(l); + }), href); + await this.page.evaluate(ws => Promise.all(ws.map(w => document.fonts.load(`${w} 14px "Open Sans"`))), weights); + } + + // The rectangle around `target` (a selector, a locator or a box), grown by `pad`. + async clipAround(target, pad = 0) { + const box = typeof target === 'string' ? await this.page.locator(target).first().boundingBox() + : target.boundingBox ? await target.boundingBox() : target; + return { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.width + 2 * pad, height: box.height + 2 * pad }; + } + + // The rectangle from the first `selector` match to its `last`-th (0-based), grown by `pad`. + async clipSpan(selector, last, pad = 8) { + const a = await this.page.locator(selector).first().boundingBox(); + const b = await this.page.locator(selector).nth(last).boundingBox(); + return { x: a.x - pad, y: a.y - pad, width: b.x + b.width - a.x + 2 * pad, height: a.height + 2 * pad }; + } + + // The rectangle of the parent of the first `selector` match: the header's tab row, say. + async clipParent(selector, pad = 0) { + const r = await this.page.locator(selector).first().evaluate(el => { + const q = el.parentElement.getBoundingClientRect(); return { x: q.x, y: q.y, width: q.width, height: q.height }; + }); + return this.clipAround(r, pad); + } + + // The rectangle of the first `selector` match in page coordinates, for a shot taller than the + // window (an element shot scrolls, and the sticky header then lands on it). + async clipPage(selector) { + const r = await this.page.evaluate(s => { + const q = document.querySelector(s).getBoundingClientRect(); + return { x: q.left + scrollX, y: q.top + scrollY, width: q.width, height: q.height }; + }, selector); + return { ...r, page: true }; + } + + // Save a shot as .png: of a clip, of an element (selector or locator), or of the viewport. + async shoot(name, what) { + const file = path.join(this.out, `${name}.png`); + fs.mkdirSync(path.dirname(file), { recursive: true }); + if (typeof what === 'string') await this.page.locator(what).first().screenshot({ path: file }); + else if (what && what.screenshot) await what.screenshot({ path: file }); + else if (what) { + const { page, ...clip } = what; + await this.page.screenshot({ path: file, clip, fullPage: !!page }); + } else await this.page.screenshot({ path: file }); + return file; + } + + // One line on the first `selector` match: box, background, position, stacking and display. + async describe(selector) { + return this.page.evaluate(sel => { + const e = document.querySelector(sel); if (!e) return `${sel}: missing`; + const s = getComputedStyle(e), r = e.getBoundingClientRect(); + return `${sel}: ${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)} ` + + `bg ${s.backgroundColor} pos ${s.position} z ${s.zIndex} display ${s.display}`; + }, selector); + } + + // Replace the content of the first `selector` match with `html`, under class `className`. + async replace(selector, className, html) { + await this.page.evaluate(([s, c, h]) => { + const el = document.querySelector(s); el.className = c; el.innerHTML = h; + }, [selector, className, html]); + await this.page.waitForTimeout(120); + } + + // The builder's portrait drawer open on a random portrait, so its artist credit shows. + async openCredit() { + await this.go('/pages/dnd/5e/character-builder'); + const desc = this.page.locator('.builder-tab', { hasText: /^Description$/ }).first(); + if (await desc.count()) { await desc.click(); await this.page.waitForTimeout(300); } + await this.page.locator('.pl-launcher').click(); + await this.page.locator('.pl-drawer').waitFor({ state: 'visible' }); + await this.page.locator('.pl-btn-primary', { hasText: 'Randomize' }).click(); + await this.page.mouse.move(5, 5); + await this.page.waitForTimeout(500); + } + + async close() { await this.ctx.close(); } +} + +// A browser for one round, writing under `out`. open() gives a View of `url`; options: +// theme ('dark' | 'light'), darkText, device (a Playwright device name), viewport, dpr, login. +class Capture { + static async launch(out) { + const c = new Capture(); + c.browser = await chromium.launch({ executablePath: findChrome() }); + c.out = out; + return c; + } + + async open(url, { theme = 'dark', darkText, device, viewport, dpr = 2, login: asUser = false } = {}) { + const ctx = await this.browser.newContext({ + ...(device ? devices[device] : {}), ...(viewport ? { viewport } : {}), + ...(device || viewport ? {} : { viewport: { width: 1280, height: 900 } }), deviceScaleFactor: dpr }); + const user = `{:theme "${theme}-theme"${darkText === undefined ? '' : ` :dark-button-text? ${darkText}`}}`; + // Only when unset, so a login's token survives the next page load. + await ctx.addInitScript(u => { try { if (!localStorage.getItem('user')) localStorage.setItem('user', u); } catch (_) {} }, user); + // Google Fonts through Node, which trusts the sandbox proxy's CA; the browser does not. + await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); + const view = new View(ctx, await ctx.newPage(), this.out); + if (asUser) await login(view.page, 'kaylee', 'serenity99'); // seeded by dev/e2e_boot.clj + if (url) await view.go(url); + return view; + } + + async close() { await this.browser.close(); } +} + +// A data: URI for `.svg` in the candidate-icon folder ICONS (kept outside the repo). +function iconUri(name) { + if (!process.env.ICONS) throw new Error('set ICONS to the folder of candidate icon SVGs'); + return 'data:image/svg+xml;base64,' + fs.readFileSync(path.join(process.env.ICONS, name + '.svg')).toString('base64'); +} + +module.exports = { Capture, View, iconUri, BASE }; diff --git a/docs/design/capture/rounds.js b/docs/design/capture/rounds.js new file mode 100644 index 000000000..18427b88c --- /dev/null +++ b/docs/design/capture/rounds.js @@ -0,0 +1,849 @@ +// Every design capture, by round. Each writes where its review page reads its pictures (`out`, +// under docs/design/, where git ignores images) and prints what it measured. Run with capture.js. +const fs = require('fs'); +const path = require('path'); +const { iconUri, BASE } = require('./lib'); + +const PAGES = [['/pages/dnd/5e/character-builder', 'builder'], ['/dnd/5e/my-content', 'mycontent'], + ['/pages/dnd/5e/spells', 'spells'], ['/pages/dnd/5e/monsters', 'monsters']]; +const BUILDER = '/pages/dnd/5e/character-builder'; +const ROLL = '.app.dark-button-text .roll-button'; +const LIFT = 'text-shadow: 0 1px 1px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.25);'; +const DARK_INK = '#15202e'; +const both = ['dark', 'light']; + +// The five form buttons, and the six ability buttons, as one clip each. +const formButtons = v => v.clipSpan('.form-button', 4); +const rollButtons = v => v.clipSpan('.roll-button', 5); +const stroke = (w, c) => `${ROLL} { -webkit-text-stroke: ${w}px ${c}; paint-order: stroke fill; }`; +// A stroke can't take a gradient: a copy of the label behind the real one (::before) takes a +// transparent stroke and a gradient clipped to the text, which Chrome paints into the stroke too. +const gradientRim = grad => ` + ${ROLL} { position: relative; isolation: isolate; -webkit-text-stroke: 0 !important; } + ${ROLL}::before { content: attr(data-label); position: absolute; inset: 0; z-index: -1; + display: flex; align-items: center; justify-content: center; + -webkit-text-stroke: 2px transparent; color: transparent; text-shadow: none; + background: ${grad}; -webkit-background-clip: text; background-clip: text; }`; +const labelRollButtons = v => v.page.evaluate(() => document.querySelectorAll('.roll-button').forEach(e => { e.dataset.label = e.textContent; })); + +// Variants of the ability-button rim: white text (`white`, null) on the plain page, the rest with +// dark text on. Shot as roll-. +async function rimRound(cap, variants, suffix = '') { + for (const darkText of [false, true]) { + const v = await cap.open(BUILDER, { darkText }); + await labelRollButtons(v); + for (const [k, css] of Object.entries(variants)) { + if ((css === null) === darkText) continue; + await v.restyle(css); + await v.shoot(`roll-${k}${suffix}`, await rollButtons(v)); + } + await v.close(); + } +} + +// One line per element on the page: a gutter or edge marker, drawn over everything. +const drawLines = (v, lines) => v.page.evaluate(ls => { + document.querySelectorAll('.capture-lines').forEach(e => e.remove()); + const layer = document.createElement('div'); layer.className = 'capture-lines'; + layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99999'; + for (const [x, color] of ls) { + const line = document.createElement('div'); + line.style.cssText = `position:absolute;top:0;bottom:0;left:${x - 0.5}px;width:1px;background:${color}`; + layer.append(line); + } + document.body.appendChild(layer); +}, lines); + +// --- artist-page mock-ups: proposals drawn inside the real /artists page and drawer credit --- +const icon = (uri, color, size) => + ``; +const LINK_CSS = ` +.mk-links { display: flex; flex-direction: column; gap: 8px; } +.mk-link { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 5px; + border: 1px solid rgba(255,255,255,0.10); color: #ebeef4; text-decoration: none; font: 600 13px/1 'Open Sans', sans-serif; } +.mk-link .host { margin-left: auto; font-weight: 400; font-size: 11px; color: var(--lk-dim); } +.mk-link.feature { border-color: #f0a100; background: rgba(240,161,0,0.07); } +.ap-root.light-theme .mk-link { border-color: rgba(0,0,0,0.14); color: #363636; } +.ap-root.light-theme .mk-link.feature { border-color: #33658A; background: rgba(51,101,138,0.06); }`; +const CARD_CSS = LINK_CSS + ` +.mk-card { max-width: 560px; margin: 0 auto; padding: 28px 28px 22px; background: #131924; + border: 1px solid rgba(240,161,0,0.16); border-radius: 12px; display: flex; flex-direction: column; gap: 20px; } +.ap-root.light-theme .mk-card { background: #fff; border-color: rgba(0,0,0,0.10); } +.mk-head { display: flex; flex-direction: column; gap: 10px; text-align: center; } +.mk-bio { margin: 0; font: italic 13px/1.5 'Open Sans', sans-serif; color: var(--lk-dim); }`; +const PANEL_CSS = ` +.pk { max-width: 520px; margin: 0 auto; padding: 22px 24px; background: #131924; border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; display: flex; flex-direction: column; gap: 18px; font-family: 'Open Sans', sans-serif; color: #ebeef4; } +.ap-root.light-theme .pk { background: #fff; border-color: rgba(0,0,0,0.10); color: #363636; } +.pk h2 { margin: 0; font: 700 16px/1.3 'Open Sans', sans-serif; } +.pk .row { display: flex; flex-direction: column; gap: 9px; } +.pk .lbl { font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .14em; text-transform: uppercase; color: var(--lk-dim); } +.pk .icons, .pk .swatches { display: flex; flex-wrap: wrap; gap: 8px; } +.pk .ib { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 72px; padding: 10px 0 8px; border-radius: 6px; + border: 1px solid rgba(255,255,255,0.10); font: 600 11px/1 'Open Sans', sans-serif; color: var(--lk-dim); } +.ap-root.light-theme .pk .ib { border-color: rgba(0,0,0,0.14); } +.pk .ib.on { border-color: #f0a100; color: #ebeef4; box-shadow: 0 0 0 2px rgba(240,161,0,0.25); } +.ap-root.light-theme .pk .ib.on { border-color: #33658A; color: #363636; box-shadow: 0 0 0 2px rgba(51,101,138,0.2); } +.pk .sw { width: 30px; height: 30px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.25); } +.ap-root.light-theme .pk .sw { border-color: rgba(0,0,0,0.2); } +.pk .sw.on { box-shadow: 0 0 0 2px #131924, 0 0 0 4px #f0a100; } +.ap-root.light-theme .pk .sw.on { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #33658A; } +.pk .swl { display: flex; flex-direction: column; align-items: center; gap: 6px; font: 600 10px/1 'Open Sans', sans-serif; color: var(--lk-dim); width: 46px; } +.pk .mode { display: inline-flex; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; overflow: hidden; align-self: flex-start; } +.ap-root.light-theme .pk .mode { border-color: rgba(0,0,0,0.14); } +.pk .mode span { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; font: 600 12px/1 'Open Sans', sans-serif; color: var(--lk-dim); } +.pk .mode span.on { background: rgba(240,161,0,0.10); color: #ffcc5e; } +.ap-root.light-theme .pk .mode span.on { background: rgba(51,101,138,0.08); color: #33658A; } +.pk .mode i { width: 14px; height: 14px; border-radius: 50%; display: inline-block; } +.pk .note { margin: 0; font-size: 12px; color: var(--lk-dim); } +.pk .prev { border-top: 1px solid rgba(255,255,255,0.06); padding-top: 14px; display: flex; flex-direction: column; gap: 7px; } +.ap-root.light-theme .pk .prev { border-top-color: rgba(0,0,0,0.08); } +.pk .lk-name { text-decoration: underline dotted var(--lk-under); text-decoration-thickness: 1px; text-underline-offset: 3px; }`; +const CARD = '.ap-card, .mk-card, .mk-links, .pk'; +const setCreditMark = (v, html) => v.page.evaluate(h => { document.querySelector('.pl-attribution .lk-mark').innerHTML = h; }, html); +const SITE_ICONS = [['ph-browser-fill', 'Browser'], ['ph-house-fill', 'House'], ['ph-compass-fill', 'Compass'], ['ph-sparkle-fill', 'Sparkle']]; + +// The artist's picker panel: icon, colour (split swatches), and how it reads in the light theme. +function pickerPanel({ chosenIcon, chosenColor, shown, colours, half, modes, note }) { + return ` +

    Your artist page

    +
    Icon for your site
    + ${SITE_ICONS.map(([f, l]) => `
    ${icon(iconUri(f), f === chosenIcon ? shown : 'currentColor', 18)}${l}
    `).join('')} +
    +
    Colour
    + ${Object.entries(colours).map(([n, c]) => `
    ${n}
    `).join('')} +
    ${modes ? '' : `

    ${note}

    `}
    + ${modes ? `
    In the light theme
    ${modes}

    ${note}

    ` : ''} + `; +} + +module.exports = { + 'buttons-and-tabs': { + out: 'style-guide/round3', what: 'four pages per theme; form-button fonts; header-tab wrap options', + async run(cap) { + const BTN = { arial: 'button { font-family: Arial, sans-serif !important; } .form-button { font-weight: 700 !important; }', + os600: '.form-button { font-weight: 600 !important; }', os700: '', + dark: `.app:not(.light-theme) .form-button { color: ${DARK_INK} !important; }` }; + const WRAP = { asis: '', ls02: '.header-tab .title { letter-spacing: -0.02em; }', ls04: '.header-tab .title { letter-spacing: -0.04em; }', + s13: '.header-tab { font-size: 13px !important; }', w600: '.header-tab { font-weight: 600 !important; }' }; + for (const theme of both) { + const v = await cap.open(null, { theme, dpr: 1 }); + for (const [url, name] of PAGES) { await v.go(url); await v.shoot(`${theme}-v2-${name}`, { x: 0, y: 150, width: 1280, height: 750 }); } + await v.go(BUILDER); + for (const [k, css] of Object.entries(BTN)) { await v.restyle(css); await v.shoot(`${theme}-btn-${k}`, await v.clipSpan('.form-button', 4, 6)); } + for (const [k, css] of Object.entries(WRAP)) { await v.restyle(css); await v.shoot(`${theme}-wrap-${k}`, await v.clipParent('.header-tab', 4)); } + await v.close(); + } + } + }, + + 'tab-wrap-and-amber': { + out: 'style-guide/round4', what: 'narrowest window keeping MY CONTENT on one line, per option; dark text on every amber surface', + async run(cap) { + const WRAP = { 'as is (700)': '', 'letter spacing -2%': '.header-tab .title { letter-spacing: -0.02em; }', + 'letter spacing -4%': '.header-tab .title { letter-spacing: -0.04em; }', '13px': '.header-tab { font-size: 13px !important; }', + '600 weight': '.header-tab { font-weight: 600 !important; }', 'before (400, integration)': '.header-tab, .header-tab * { font-weight: 400 !important; }' }; + const v = await cap.open(BUILDER, { viewport: { width: 1600, height: 900 }, dpr: 1 }); + const oneLine = () => v.page.evaluate(() => { + const t = [...document.querySelectorAll('.header-tab .title')].find(e => /my content/i.test(e.textContent)); + if (!t || !t.offsetParent) return null; + const lh = parseFloat(getComputedStyle(t).lineHeight) || parseFloat(getComputedStyle(t).fontSize) * 1.4; + return t.getBoundingClientRect().height < lh * 1.5; + }); + for (const [name, css] of Object.entries(WRAP)) { + await v.restyle(css); + let fits = null; + for (let w = 1600; w >= 700; w -= 10) { + await v.page.setViewportSize({ width: w, height: 900 }); await v.page.waitForTimeout(40); + const r = await oneLine(); + if (r === null) { fits = fits ?? `labels hidden below ${w}`; break; } + if (!r) break; + fits = w; + } + console.log(`${name.padEnd(28)} one line down to ${fits}px`); + } + await v.close(); + const amber = /rgb\(241, 162, 15\)/.source; + const q = await cap.open(null, { dpr: 1 }); + for (const [url, name] of PAGES) for (const state of ['white', 'dark']) { + await q.go(url); + if (state === 'dark') { + const n = await q.page.evaluate(([re, ink]) => { + const hits = [...document.querySelectorAll('*')].filter(el => new RegExp(re).test(getComputedStyle(el).backgroundImage)); + for (const el of hits) [el, ...el.querySelectorAll('*')].forEach(c => c.style.setProperty('color', ink, 'important')); + return hits.length; + }, [amber, DARK_INK]); + console.log(`${name}: ${n} amber elements`); + } + await q.shoot(`dark-${state}-${name}`, { x: 0, y: 150, width: 1280, height: 750 }); + } + await q.close(); + } + }, + + 'tab-room': { + out: 'style-guide/round4', what: 'the room MY CONTENT has in its tab, per option (prints only)', + async run(cap) { + const WRAP = { 'before (400, integration)': '.header-tab, .header-tab * { font-weight: 400 !important; }', 'as is (700)': '', + 'letter spacing -2%': '.header-tab .title { letter-spacing: -0.02em; }', 'letter spacing -4%': '.header-tab .title { letter-spacing: -0.04em; }', + '13px': '.header-tab { font-size: 13px !important; }', '600 weight': '.header-tab, .header-tab * { font-weight: 600 !important; }' }; + const v = await cap.open(BUILDER, { dpr: 1 }); + for (const [name, css] of Object.entries(WRAP)) { + await v.restyle(css); + const r = await v.page.evaluate(() => { + const t = [...document.querySelectorAll('.header-tab .title')].find(e => /my content/i.test(e.textContent)); + const box = t.parentElement, cs = getComputedStyle(box); + const avail = box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + const probe = t.cloneNode(true); probe.style.cssText += ';position:absolute;visibility:hidden;white-space:nowrap;width:auto'; + box.appendChild(probe); const need = probe.getBoundingClientRect().width; probe.remove(); + return { tab: Math.round(t.closest('.header-tab').getBoundingClientRect().width), avail: Math.round(avail * 10) / 10, need: Math.round(need * 10) / 10 }; + }); + const spare = r.avail - r.need; + console.log(`${name.padEnd(27)} tab ${r.tab}px, room ${r.avail}px, label needs ${r.need}px -> ${spare >= 0 ? `fits, ${spare.toFixed(1)}px spare` : `wraps, ${(-spare).toFixed(1)}px short`}`); + } + await v.close(); + } + }, + + 'button-text': { + out: 'style-guide/round5', what: 'form-button text: shadow, deeper amber, dark text', + async run(cap) { + const V = { '1-today': '', '2-shadow': `.form-button { ${LIFT} }`, + '3-deeper': '.form-button { background-image: linear-gradient(to bottom, #c98300, #b8792a) !important; }', + '4-deeper-shadow': '.form-button { background-image: linear-gradient(to bottom, #c98300, #b8792a) !important; text-shadow: 0 1px 1px rgba(0,0,0,0.3); }', + '5-dark': `.form-button { color: ${DARK_INK} !important; }` }; + const v = await cap.open(BUILDER); + for (const [k, css] of Object.entries(V)) { await v.restyle(css); await v.shoot(k, await formButtons(v)); } + await v.close(); + } + }, + + 'button-dark-text': { + out: 'style-guide/round6', what: 'form-button white text with a shadow, and dark text with embosses and glows', + async run(cap) { + const V = { '6-white-shadow': `.form-button { ${LIFT} }`, '7-dark': `.form-button { color: ${DARK_INK} !important; }`, + '8-dark-emboss': `.form-button { color: ${DARK_INK} !important; text-shadow: 0 1px 0 rgba(255,255,255,0.35); }`, + '9-dark-glow': `.form-button { color: ${DARK_INK} !important; text-shadow: 0 0 2px rgba(255,255,255,0.45), 0 1px 0 rgba(255,255,255,0.3); }`, + '10-dark-emboss-strong': `.form-button { color: ${DARK_INK} !important; text-shadow: 0 1px 0 rgba(255,255,255,0.55), 0 0 1px rgba(255,255,255,0.3); }` }; + const v = await cap.open(BUILDER); + for (const [k, css] of Object.entries(V)) { await v.restyle(css); await v.shoot(k, await formButtons(v)); } + await v.close(); + } + }, + + 'dark-button-text-setting': { + out: 'style-guide/round7', what: 'the built dark-button-text setting per theme: form and ability buttons, header tab with and without a lift', + async run(cap) { + for (const [theme, darkText] of [['dark', false], ['dark', true], ['light', false]]) { + const v = await cap.open(BUILDER, { theme, darkText }); + const tag = `${theme}${darkText ? '-darktext' : ''}`; + console.log(tag, (await Promise.all(['.form-button', '.roll-button'].map(s => v.page.evaluate(s => { + const cs = getComputedStyle(document.querySelector(s)); return `${s}: ${cs.color} / ${cs.textShadow.slice(0, 40)}`; + }, s)))).join(' | ')); + await v.shoot(`${tag}-buttons`, await formButtons(v)); + await v.shoot(`${tag}-roll`, await rollButtons(v)); + if (!darkText) for (const [k, css] of [['tab-now', ''], ['tab-lift', `.header-tab { ${LIFT} }`]]) { + await v.restyle(css); + const c = await v.clipParent('.header-tab', 4); + await v.shoot(`${tag}-${k}`, { ...c, width: Math.min(1280 - c.x, 470) }); + } + await v.close(); + } + } + }, + + 'rim-outline': { + out: 'style-guide/round9', what: 'outline options for dark text on the ability buttons', + run: cap => rimRound(cap, { + 'w-white': null, 'd-built': '', + 'e-shadow-ring': `${ROLL} { text-shadow: 1px 0 0 rgba(255,255,255,.55), -1px 0 0 rgba(255,255,255,.55), 0 1px 0 rgba(255,255,255,.55), 0 -1px 0 rgba(255,255,255,.55) !important; }`, + 'f-stroke-hair': `${stroke(1, 'rgba(255,255,255,.75)')} ${ROLL} { text-shadow: none !important; }`, + 'g-stroke-hair-glow': stroke(1, 'rgba(255,255,255,.75)'), + 'h-stroke-full': `${stroke(2, 'rgba(255,255,255,.85)')} ${ROLL} { text-shadow: none !important; }`, + 'i-stroke-full-glow': stroke(2, 'rgba(255,255,255,.85)') }) + }, + + 'rim-colour': { + out: 'style-guide/round10', what: 'coloured outline options for dark text on the ability buttons', + run: cap => rimRound(cap, { + 'w-white': null, 'i-white-2': stroke(2, 'rgba(255,255,255,.85)'), 'j-yellow-1': stroke(1, '#ffd21a'), + 'k-yellow-2': stroke(2, '#ffd21a'), 'l-paleyellow-2': stroke(2, '#ffe680'), 'm-orange-2': stroke(2, '#ff8a00'), + 'n-amber-2': stroke(2, '#f5b942') }) + }, + + 'rim-gradient': { + out: 'style-guide/round11', what: 'gradient outline options for dark text on the ability buttons (DPR=1|2)', + run: cap => rimRound(cap, { + 'w-white': null, 'k-built': '', + 'o-reverse': gradientRim('linear-gradient(to bottom, #dbab50 40%, #f1a20f 62%)'), + 'p-bottom-solid': gradientRim('#dbab50'), + 'q-yellow-reverse': gradientRim('linear-gradient(to bottom, #ffd21a 40%, #dbab50 62%)'), + 'r-bevel': gradientRim('linear-gradient(to bottom, #ffd21a 40%, #b37a00 62%)') }, process.env.DPR ? `@${process.env.DPR}` : '') + }, + + 'label-size': { + out: 'style-guide/round12', what: 'ability-button labels at 14, 15 and 16px: white, glow only, the built rim, the lit rim (DPR=1|2)', + async run(cap) { + const dpr = +(process.env.DPR || 2); + const STYLES = { white: null, glow: `${ROLL} { -webkit-text-stroke: 0 !important; }`, rim: '', + lit: gradientRim('linear-gradient(to bottom, #ffd21a 40%, #dbab50 62%)') }; + for (const darkText of [false, true]) { + const v = await cap.open(BUILDER, { darkText, dpr }); + await labelRollButtons(v); + for (const size of [14, 15, 16]) for (const [k, css] of Object.entries(STYLES)) { + if ((css === null) === darkText) continue; + await v.restyle(`.roll-button { font-size: ${size}px !important; } ${css || ''}`); + if (dpr === 1) console.log(size, k, await v.page.locator('.roll-button').first().evaluate(e => `${getComputedStyle(e).fontSize} h=${e.getBoundingClientRect().height}`)); + await v.shoot(`${size}-${k}@${dpr}`, await rollButtons(v)); + } + await v.close(); + } + } + }, + + 'label-size-phone': { + out: 'style-guide/round13', what: 'the builder on 320 and 390px phones: the toggle row, and ability labels at 14-16px keeping a 31px button', + async run(cap) { + // The app picks its phone layout from the user agent, not the window width. + const phone = (width, darkText) => cap.open(BUILDER, { device: 'iPhone 13', viewport: { width, height: 844 }, dpr: 3, darkText }); + const SIZES = { 14: '', 15: '.roll-button { font-size: 15px !important; padding-top: 5.5px !important; padding-bottom: 5.5px !important; }', + 16: '.roll-button { font-size: 16px !important; padding-top: 4.5px !important; padding-bottom: 4.5px !important; }' }; + for (const width of [320, 390]) { + const v = await phone(width, false); + const r = await v.page.locator('div.pointer', { hasText: 'Light Theme' }).first().boundingBox(); + const right = await v.page.locator('div.pointer', { hasText: 'Dark Button Text' }).first().evaluate(e => e.getBoundingClientRect().right); + console.log(`toggles at ${width}px: right edge ${Math.round(right)}, gap ${Math.round(width - right)}px`); + await v.shoot(`toggles-${width}`, { x: 0, y: r.y - 90, width, height: 150 }); + await v.close(); + } + for (const width of [320, 390]) for (const darkText of [false, true]) { + const v = await phone(width, darkText); + await v.page.getByText(/^details$/i).first().click(); await v.page.waitForTimeout(400); + for (const [size, css] of Object.entries(SIZES)) { + await v.restyle(css); + const btn = v.page.locator('.roll-button').first(); + await btn.scrollIntoViewIfNeeded(); + console.log(width, darkText ? 'dark' : 'white', size, await btn.evaluate(e => { const r = e.getBoundingClientRect(); return `${getComputedStyle(e).fontSize} ${Math.round(r.width)}x${r.height}`; })); + const a = await btn.boundingBox(); + await v.shoot(`${size}-${darkText ? 'rim' : 'white'}-${width}`, { x: 0, y: Math.max(0, a.y - 110), width, height: 160 }); + } + await v.close(); + } + } + }, + + 'layout-width': { + out: 'style-guide/round14', what: 'a desktop window at 1280, the same window narrowed to 390, and a real phone', + async run(cap) { + const v = await cap.open(BUILDER, { viewport: { width: 1280, height: 800 } }); + await v.shoot('desktop-1280'); + await v.page.setViewportSize({ width: 390, height: 844 }); await v.page.waitForTimeout(500); + await v.page.evaluate(() => scrollTo(0, 0)); + await v.shoot('desktop-narrowed-options'); + await v.page.locator('.builder-tab', { hasText: 'Details' }).click(); await v.page.waitForTimeout(400); + const a = await v.page.locator('.roll-button').first().boundingBox(); + await v.page.evaluate(y => scrollTo(0, y - 300), a.y + await v.page.evaluate(() => scrollY)); + await v.shoot('desktop-narrowed-details'); + await v.close(); + const p = await cap.open(BUILDER, { device: 'iPhone 13' }); + await p.shoot('phone-options'); + await p.close(); + } + }, + + 'phone-header': { + out: 'style-guide/round15', what: 'the header on 390 and 320px phones, and on desktop to show it unchanged', + async run(cap) { + for (const [name, opts, height] of [['phone-390', { device: 'iPhone 13' }, 300], ['phone-320', { device: 'iPhone SE' }, 300], + ['desktop-1280', { viewport: { width: 1280, height: 800 } }, 520]]) { + const v = await cap.open(BUILDER, opts); + console.log(name, 'sideways overflow', await v.page.evaluate(() => document.documentElement.scrollWidth - innerWidth)); + await v.shoot(name, { x: 0, y: 0, width: v.page.viewportSize().width, height }); + await v.close(); + } + } + }, + + 'left-edges': { + out: 'style-guide/round16', what: "the phone header's left edges, as built and all on 10px; the bar with search and login", + async run(cap) { + const v = await cap.open(BUILDER, { device: 'iPhone 13', dpr: 3 }); + const bar = { x: 0, y: 0, width: 390, height: 52 }; + await v.shoot('bar', bar); + for (const state of ['now', 'aligned']) { + if (state === 'aligned') { + await v.page.evaluate(() => document.querySelector('h1').closest('.flex-wrap').children[1].classList.add('edge-align-buttons')); + await v.addStyle(`.app-header-bar .w-100-p { padding-left: 10px !important; padding-right: 10px !important; } + .app-header-menu { padding-left: 8px !important; padding-right: 8px !important; } + .flex-wrap.edge-align-buttons.edge-align-buttons { margin-left: 5px !important; }`); + await v.page.waitForTimeout(300); + await v.shoot('bar-aligned', bar); + } + // the ink, not the box: a text's left edge is where its first glyph starts + const edges = await v.page.evaluate(() => { + const ink = e => { const r = document.createRange(); r.selectNodeContents(e); return r.getBoundingClientRect().left; }; + return [['logo', document.querySelector('.app-header-bar img').getBoundingClientRect().left, '#ff5c5c'], + ['tabs', document.querySelector('.header-tab').getBoundingClientRect().left, '#3ddc84'], + ['title', ink(document.querySelector('h1')), '#4da3ff'], + ['buttons', document.querySelector('h1').closest('.flex-wrap').children[1].firstElementChild.getBoundingClientRect().left, '#c084fc'], + ['panel', document.querySelector('.builder-tabs').parentElement.getBoundingClientRect().left, '#ff8fd1']]; + }); + await drawLines(v, edges.map(([, x, c]) => [x, c])); + console.log(state, edges.map(([n, x]) => `${n} ${Math.round(x)}`).join(', ')); + await v.shoot(`edges-${state}`, { x: 0, y: 0, width: 390, height: 600 }); + await v.shoot(`strip-${state}`, { x: 0, y: 0, width: 70, height: 600 }); + } + await v.close(); + } + }, + + gutters: { + out: 'style-guide/round17', what: 'the builder on a 390px phone as built, and with every row on a 10px and a 16px gutter', + async run(cap) { + // Each row's own spacing, set so its visible edge lands on the gutter G. + const css = G => ` + .app-header-bar .w-100-p { padding-left: ${G}px !important; padding-right: ${G}px !important; } + .app-header-menu { padding-left: ${G - 2}px !important; padding-right: ${G - 2}px !important; } + h1[data-g] { margin-left: ${G}px !important; } + [data-g=buttons].flex-wrap { margin-left: ${G - 5}px !important; margin-right: ${G - 5}px !important; } + [data-g=toggles].flex-wrap { padding-right: ${G}px !important; } + [data-g=builder-in].flex { padding-left: ${G}px !important; } + [data-g=builder-out].w-100-p { padding-right: ${G}px !important; }`; + for (const [name, G] of [['built', null], ['g10', 10], ['g16', 16]]) { + const v = await cap.open(BUILDER, { device: 'iPhone 13', dpr: 3 }); + if (G) { + await v.page.evaluate(() => { + const h1 = document.querySelector('h1'); h1.dataset.g = 'title'; + h1.closest('.flex-wrap').children[1].dataset.g = 'buttons'; + [...document.querySelectorAll('div.pointer')].find(e => /Dark Button Text/.test(e.textContent) && e.children.length < 4).parentElement.dataset.g = 'toggles'; + const inner = document.querySelector('.builder-tabs').parentElement.parentElement; + inner.dataset.g = 'builder-in'; inner.parentElement.dataset.g = 'builder-out'; + }); + await v.addStyle(css(G)); await v.page.waitForTimeout(400); + await drawLines(v, [[G, '#4da3ff'], [390 - G, '#4da3ff']]); + } + console.log(name, await v.page.evaluate(() => { + const W = innerWidth, r = e => e.getBoundingClientRect(); + const ink = e => { const x = document.createRange(); x.selectNodeContents(e); return x.getBoundingClientRect().left; }; + const bar = document.querySelector('.app-header-bar .w-100-p'), tabs = [...document.querySelectorAll('.header-tab')]; + const panel = document.querySelector('.builder-tabs').parentElement; + const toggles = [...document.querySelectorAll('div.pointer')].find(e => /Dark Button Text/.test(e.textContent) && e.children.length < 4); + const firstBtn = document.querySelector('h1').closest('.flex-wrap').children[1].firstElementChild; + const m = { logo: r(bar.querySelector('img')).left, tabs: r(tabs[0]).left, title: ink(document.querySelector('h1')), + buttons: r(firstBtn).left, panel: r(panel).left, 'login R': W - r(bar.lastElementChild).right, + 'tabs R': W - r(tabs[tabs.length - 1]).right, 'toggles R': W - r(toggles).right, 'panel R': W - r(panel).right }; + return Object.entries(m).map(([k, x]) => `${k} ${Math.round(x)}`).join(', '); + })); + await v.shoot(name, { x: 0, y: 0, width: 390, height: 640 }); + await v.close(); + } + } + }, + + 'roll-16': { + out: 'style-guide/round19', what: "16px roll buttons: the builder's ability row on phones and desktop, white and dark text, and the skills", + async run(cap) { + const report = async (v, label) => console.log(label, await v.page.evaluate(() => { + const bs = [...document.querySelectorAll('.roll-button')].filter(b => b.offsetParent); + const hs = [...new Set(bs.map(b => Math.round(b.getBoundingClientRect().height)))]; + return `${bs.length} buttons, ${getComputedStyle(bs[0]).fontSize}, heights ${hs.join('/')}, labels overflowing ${bs.filter(b => b.scrollWidth > b.clientWidth + 1).length}`; + })); + const shootFrom = async (v, name, selector, above, height) => { + const el = v.page.locator(selector).first(); + await v.page.mouse.move(1, 1); + await el.scrollIntoViewIfNeeded(); + await v.page.evaluate(() => scrollBy(0, -120)); await v.page.waitForTimeout(200); + const r = await el.boundingBox(); + await v.shoot(name, { x: 0, y: Math.max(0, r.y - above), width: v.page.viewportSize().width, height }); + }; + const details = async v => { await v.page.getByText(/^details$/i).first().click(); await v.page.waitForTimeout(400); }; + for (const [w, device] of [['390', 'iPhone 13'], ['320', 'iPhone SE']]) for (const darkText of [false, true]) { + const v = await cap.open(BUILDER, { device, darkText }); + await details(v); + const tag = `phone-${w}-${darkText ? 'dark' : 'white'}`; + await report(v, tag); await shootFrom(v, tag, '.roll-button', 120, 560); + await v.close(); + } + const d = await cap.open(BUILDER, { darkText: false }); + await report(d, 'desktop'); await shootFrom(d, 'desktop', '.roll-button', 90, 420); + await d.close(); + const s = await cap.open(BUILDER, { device: 'iPhone 13', darkText: false }); + await details(s); await shootFrom(s, 'phone-390-skills', 'text=Acrobatics', 60, 520); + await s.close(); + } + }, + + 'header-menus': { + out: 'style-guide/round21', what: 'the Characters flyout on desktop and phone, and the logged-in user menu, with their computed boxes', + async run(cap) { + const d = await cap.open(BUILDER); + console.log('desktop', await d.describe('.header-tab')); + await d.page.locator('.header-tab').first().hover(); await d.page.waitForTimeout(400); + console.log('desktop', await d.describe('.header-tab .header-flyout')); + console.log('desktop', await d.describe('.header-tab .header-flyout > div:nth-child(2)')); + await d.shoot('desktop-flyout', { x: 560, y: 170, width: 720, height: 330 }); + await d.close(); + const p = await cap.open(BUILDER, { device: 'iPhone 13' }); + await p.page.locator('.header-tab').first().focus(); await p.page.waitForTimeout(400); + console.log('phone', await p.describe('.header-tab .header-flyout')); + await p.shoot('phone-flyout', { x: 0, y: 0, width: 390, height: 300 }); + await p.close(); + const u = await cap.open(BUILDER, { login: true }); + await u.page.locator('#user-header').hover(); await u.page.waitForTimeout(400); + console.log('user', await u.describe('#user-menu')); + await u.shoot('user-menu', { x: 980, y: 0, width: 300, height: 180 }); + await u.page.mouse.move(400, 600); await u.page.waitForTimeout(400); + console.log('user closed', await u.describe('#user-menu')); + await u.close(); + } + }, + + 'phone-menus': { + out: 'style-guide/round21/menus', what: "every header tab's menu on a phone, checked fully on screen and not clipped (fails the run if one is)", + async run(cap) { + let bad = 0; + for (const [w, device] of [[390, 'iPhone 13'], [320, 'iPhone SE']]) { + const v = await cap.open(BUILDER, { device }); + const tabs = v.page.locator('.header-tab'); + for (let i = 0, n = await tabs.count(); i < n; i++) { + const tab = tabs.nth(i); + if (!(await tab.locator('.header-flyout').count())) continue; + await tab.focus(); await v.page.waitForTimeout(350); + // the point at the menu's bottom-centre must be the menu itself, not what's behind it + const r = await tab.locator('.header-flyout').evaluate(f => { + const b = f.getBoundingClientRect(), hit = document.elementFromPoint(b.left + b.width / 2, b.bottom - 4); + return { l: Math.round(b.left), r: Math.round(innerWidth - b.right), bottom: Math.round(b.bottom), + visible: !!hit && f.contains(hit), over: document.documentElement.scrollWidth - innerWidth }; + }); + const ok = r.l >= 0 && r.r >= 0 && r.visible && r.over <= 0; + if (!ok) bad++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${w}px tab ${i + 1}: left ${r.l}, right ${r.r}, bottom ${r.bottom}, fully shown ${r.visible}`); + await v.shoot(`${w}-tab${i + 1}`, { x: 0, y: 0, width: w, height: 420 }); + await v.page.evaluate(() => document.activeElement.blur()); await v.page.waitForTimeout(150); + } + await v.close(); + } + if (bad) throw new Error(`${bad} menu(s) off screen or clipped`); + } + }, + + bold: { + out: 'style-guide/bold', what: "faux bold (the site's 400-only Open Sans) against the real 600 and 700 faces", + async run(cap) { + for (const theme of both) for (const state of ['before', 'after']) { + const v = await cap.open(BUILDER, { theme }); + if (state === 'after') await v.addFontWeights([600, 700]); + const loaded = await v.page.evaluate(() => [...new Set([...document.fonts].filter(f => /Open Sans/.test(f.family) && f.status === 'loaded').map(f => f.weight))]); + console.log(` ${theme} ${state}: loaded Open Sans weights ${JSON.stringify(loaded)}`); + const tabs = v.page.locator('.builder-tabs').first(); + await tabs.scrollIntoViewIfNeeded(); + const box = await tabs.boundingBox(); + await v.shoot(`${theme}-${state}-builder`, { x: 0, y: Math.max(0, box.y - 220), width: 1280, height: box.height + 260 }); + await v.shoot(`${theme}-${state}-button`, '.form-button'); + await v.close(); + } + } + }, + + weights: { + out: 'style-guide/weights', what: 'header tab and page title at each real Open Sans weight, and the pairs in context', + async run(cap) { + const header = v => v.page.locator('.header-tab').first().evaluate(el => { const r = el.parentElement.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }); + const title = v => v.page.locator('h1.f-s-36').first().boundingBox(); + const VARIANTS = { tabs: ['.header-tab', [400, 500, 600, 700], header], title: ['h1.f-s-36', [400, 600, 700], title] }; + for (const theme of both) { + const v = await cap.open(BUILDER, { theme }); + await v.addFontWeights([400, 500, 600, 700]); + await v.page.mouse.move(5, 890); + for (const [name, [sel, weights, boxOf]] of Object.entries(VARIANTS)) for (const w of weights) { + await v.restyle(`${sel}, ${sel} * { font-weight: ${w} !important; }`); + const b = await boxOf(v); + await v.shoot(`${theme}-${name}-${w}`, { x: Math.max(0, b.x - 10), y: Math.max(0, b.y - 6), width: Math.min(1280, b.width + 20), height: b.height + 12 }); + } + for (const [tw, hw] of [[400, 400], [500, 600], [700, 600]]) { + await v.restyle(`.header-tab, .header-tab * { font-weight: ${tw} !important; } h1.f-s-36 { font-weight: ${hw} !important; }`); + const top = Math.max(0, (await header(v)).y - 10), h1 = await title(v); + await v.shoot(`${theme}-pair-${tw}-${hw}`, { x: 0, y: top, width: 1280, height: h1.y + h1.height + 30 - top }); + } + await v.close(); + } + } + }, + + 'survey-weights': { + out: 'style-guide', what: 'every visible text at weight 600 or more on four pages, and where its weight comes from (prints only)', + async run(cap) { + const v = await cap.open(null, { dpr: 1 }); + for (const [url, name] of PAGES) { + await v.go(url); + const rows = await v.page.evaluate(() => { + const out = new Map(); + for (const el of document.querySelectorAll('body *')) { + const own = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent.trim()).join(' ').trim(); + if (!own || !el.offsetParent) continue; + const s = getComputedStyle(el), w = +s.fontWeight; if (w < 600) continue; + const r = el.getBoundingClientRect(); if (r.top > 900 || r.width === 0) continue; + let src = 'inherited'; + for (let e = el; e && e !== document.body; e = e.parentElement) { + const tag = e.tagName.toLowerCase(), cls = [...e.classList].find(c => /^f-w-/.test(c)); + if (e.style && e.style.fontWeight) { src = `inline on <${tag}>`; break; } + if (cls) { src = `.${cls} on <${tag}>`; break; } + if (/^h[1-6]$/.test(tag)) { src = `<${tag}> default`; break; } + if (['button', 'b', 'strong'].includes(tag)) { src = `<${tag}>`; break; } + } + const key = `${parseFloat(s.fontSize)}px ${w} ${s.textTransform === 'uppercase' ? 'CAPS' : 'mixed'} | ${src}`; + if (!out.has(key)) out.set(key, []); + if (out.get(key).length < 3) out.get(key).push(own.slice(0, 40)); + } + return [...out.entries()].sort((a, b) => parseFloat(b[0]) - parseFloat(a[0])); + }); + console.log(`== ${name}`); + for (const [k, ex] of rows) console.log(` ${k} e.g. ${JSON.stringify(ex)}`); + } + await v.close(); + } + }, + + 'survey-amber': { + out: 'style-guide', what: 'every text on an amber surface across the main pages, both themes, grouped by class (prints only)', + async run(cap) { + const urls = ['/pages/dnd/5e/character-builder', '/dnd/5e/my-content', '/pages/dnd/5e/spells', '/pages/dnd/5e/monsters', + '/pages/dnd/5e/magic-items', '/pages/dnd/5e/characters', '/pages/dnd/5e/encounter-builder', '/pages/dnd/5e/combat-tracker', + '/pages/login-page', '/pages/register-page', '/']; + for (const theme of both) { + const v = await cap.open(null, { theme, dpr: 1 }); + const found = new Map(); + for (const url of urls) { + await v.go(url).catch(() => {}); + await v.page.waitForTimeout(500); + const rows = await v.page.evaluate(() => { + const amber = s => (s.match(/rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)/g) || []).some(c => { + const [r, g, b, a] = c.match(/[\d.]+/g).map(Number); + return (a === undefined || a > 0.6) && r > 190 && g > 120 && g < 200 && b < 110; + }); + const out = []; + for (const el of document.querySelectorAll('body *')) { + if (!el.offsetParent) continue; + const s = getComputedStyle(el); + if (!(amber(s.backgroundColor) || amber(s.backgroundImage))) continue; + const text = (el.innerText || '').trim().replace(/\s+/g, ' '); + if (!text) continue; + out.push({ key: `${el.tagName.toLowerCase()}.${[...el.classList].join('.') || '(no class)'}${el.getAttribute('style') ? ' +inline style' : ''}`, + text: text.slice(0, 24), color: s.color }); + } + return out; + }); + for (const r of rows) { + if (!found.has(r.key)) found.set(r.key, { n: 0, pages: new Set(), ex: r.text, color: r.color }); + const f = found.get(r.key); f.n++; f.pages.add(url); + } + } + console.log(`== ${theme}`); + for (const [k, f] of [...found.entries()].sort((a, b) => b[1].n - a[1].n)) + console.log(` ${String(f.n).padStart(3)} ${k.slice(0, 70)} text ${f.color} e.g. "${f.ex}" on ${[...f.pages].join(' ')}`); + await v.close(); + } + } + }, + + // --- artist pages --- + + 'artist-pages': { + out: 'artist-profiles/garden', what: 'the artist profile and index, both themes, desktop and phone (TAG=before|after names the set)', + async run(cap) { + const tag = process.env.TAG || 'shot'; + for (const theme of both) for (const [size, opts] of [['desktop', {}], ['phone', { device: 'iPhone 13' }]]) { + const v = await cap.open(null, { theme, dpr: 1, ...opts }); + for (const [name, url] of [['profile', '/artists/fusspot'], ['index', '/artists']]) { + await v.go(url); + await v.page.waitForSelector('.ap-card'); + await v.shoot(`${tag}-${name}-${theme}-${size}`, await v.clipPage('.ap-root')); + } + await v.close(); + } + } + }, + + 'artist-cards': { + out: 'artist-profiles/cards', what: "card layouts: one column, a split card, and no pictures; with the server's own examples", + async run(cap) { + const CSS = CARD_CSS + ` + .mk-pill { align-self: center; font: 600 10px/1 'Open Sans', sans-serif; letter-spacing: .12em; text-transform: uppercase; + color: var(--lk-dim); border: 1px dashed var(--lk-rule); padding: 5px 9px; border-radius: 99px; } + .mk-link .lk-ico { width: 15px; height: 15px; } + .mk-ex { display: flex; justify-content: center; gap: 10px; } + .mk-ex img { width: 150px; height: 188px; border-radius: 8px; display: block; border: 1px solid rgba(240,161,0,0.16); } + .ap-root.light-theme .mk-ex img { border-color: rgba(0,0,0,0.12); } + .mk-excap { text-align: center; font: italic 12px/1.4 'Vollkorn', Georgia, serif; color: var(--lk-dim); margin-top: -8px; } + .mk-pieces { text-align: center; font-size: 12px; color: var(--lk-dim); margin: 0; } + .mk-pieces a { color: inherit; text-decoration: underline dotted var(--lk-under); text-underline-offset: 3px; } + .mk-foot { margin: 0; padding-top: 14px; border-top: 1px solid rgba(255,255,255,0.06); font-size: 12px; color: var(--lk-dim); text-align: center; } + .ap-root.light-theme .mk-foot { border-top-color: rgba(0,0,0,0.08); } + .mk-split { display: grid; grid-template-columns: 150px 1fr; gap: 22px; align-items: start; } + .mk-split .mk-head { text-align: left; } .mk-split .mk-pill { align-self: flex-start; } + .mk-split .lk-row .lk-rule.l { display: none; } .mk-split .ap-name { font-size: 34px; } + .mk-side { display: flex; flex-direction: column; gap: 16px; min-width: 0; } + @media (max-width: 560px) { + .mk-card { padding: 22px 16px 18px; } .mk-split { grid-template-columns: 1fr; } + .mk-split .mk-head { text-align: center; } .mk-split .mk-pill { align-self: center; } + .mk-split .lk-row .lk-rule.l { display: block; } }`; + const ico = (name, color) => ``; + const HEAD = `
    Portrait artist
    +

    Fusspot

    +

    [The artist's own short bio, up to 280 characters.]

    [Commission status]
    `; + const LINKS = ``; + const FOOT = '

    Every portrait made with these pieces credits Fusspot.

    '; + const ex = n => `Example portrait`; + const LAYOUTS = { + column: `${HEAD}${LINKS}
    ${ex(1)}${ex(2)}
    Made with Fusspot's pieces
    ${FOOT}`, + split: `
    ${ex(1)}
    ${HEAD}${LINKS}
    ${FOOT}`, + minimal: `${HEAD}${LINKS}

    28 pieces in the portrait maker: heads, hair, eyes and 7 more layers. + Try them in a portrait ›

    ${FOOT}` }; + for (const theme of both) for (const [tag, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) { + const v = await cap.open('/artists/fusspot', { theme, viewport, dpr: 1 }); + if (theme === 'dark' && tag === 'desktop') for (const n of [1, 2]) { + fs.mkdirSync(cap.out, { recursive: true }); + const res = await v.page.request.get(`${BASE}/artists/fusspot/examples/${n}`); + fs.writeFileSync(path.join(cap.out, `example-${n - 1}.jpg`), await res.body()); + } + await v.addStyle(CSS); + for (const [name, html] of Object.entries(LAYOUTS)) { + await v.replace(CARD, 'mk-card', html); + await v.page.waitForFunction(() => [...document.images].every(i => i.complete)); + await v.shoot(`${name}-${theme}-${tag}`, await v.clipPage('.ap-root')); + } + await v.close(); + } + } + }, + + 'artist-icons': { + out: 'artist-profiles/icons', what: "candidate site icons in the drawer credit and the card's link rows, and two whole icon families (ICONS=dir)", + async run(cap) { + const SITE = ['now-site', 'lu-globe', 'lu-house', 'ph-house-line', 'tb-world-www', 'lu-link', 'gi-lorc-quill-ink', 'gi-lorc-quill', + 'ph-pen-nib', 'ph-feather', 'gi-delapouite-palette', 'lu-palette', 'gi-delapouite-paint-brush', 'ph-paint-brush', 'gi-lorc-castle']; + const FAMILIES = { + gameicons: [['gi-lorc-quill-ink', '#f0a100', 'Site', 'fusspot.rip'], ['now-twitch', '#9146ff', 'Twitch', 'twitch.tv/fusspot'], + ['gi-delapouite-paint-brush', '#e0a24d', 'Portfolio', 'example.test/work'], ['gi-lorc-scroll-unfurled', '#c9a36a', 'Commissions', 'example.test/commissions'], + ['gi-lorc-open-book', '#7a94b8', 'Blog', 'example.test/blog'], ['lu-link', '#8b95a5', 'Anything else', 'example.test']], + lucide: [['lu-house', '#f0a100', 'Site', 'fusspot.rip'], ['now-twitch', '#9146ff', 'Twitch', 'twitch.tv/fusspot'], + ['lu-palette', '#e0a24d', 'Portfolio', 'example.test/work'], ['lu-scroll-text', '#c9a36a', 'Commissions', 'example.test/commissions'], + ['lu-book-open', '#7a94b8', 'Blog', 'example.test/blog'], ['lu-link', '#8b95a5', 'Anything else', 'example.test']] }; + const row = (f, color, label, host, feature) => + `${icon(iconUri(f), color, 16)}${label}${host}`; + for (const theme of both) { + const v = await cap.open(null, { theme }); + await v.openCredit(); + for (const f of SITE) { await setCreditMark(v, icon(iconUri(f), '#f0a100', 12)); await v.shoot(`credit-${theme}-${f}`, '.pl-attribution'); } + await v.go('/artists/fusspot'); + await v.addStyle(LINK_CSS + '.mk-links { width: 420px; padding: 14px; }'); + for (const f of SITE) { await v.replace(CARD, 'mk-links', row(f, '#f0a100', 'Site', 'fusspot.rip', true)); await v.shoot(`card-${theme}-${f}`, '.mk-links'); } + for (const [fam, rows] of Object.entries(FAMILIES)) { + await v.replace(CARD, 'mk-links', rows.map(([f, c, l, h], i) => row(f, c, l, h, i === 0)).join('')); + await v.shoot(`family-${theme}-${fam}`, '.mk-links'); + } + await v.close(); + } + } + }, + + 'artist-site-mark': { + out: 'artist-profiles/marks', what: "filled site icons in five colours, and a badge, beside the credit's Twitch mark (ICONS=dir)", + async run(cap) { + const ICON_SET = ['now-site', 'ph-browser-fill', 'ph-app-window-fill', 'ph-house-fill', 'ph-compass-fill', 'ph-sparkle-fill', 'gi-lorc-quill-ink']; + const BADGE_SET = ['ph-browser-fill', 'ph-house-fill', 'gi-lorc-quill-ink']; + const COLORS = { amber: '#f0a100', coral: '#ff6b57', teal: '#1fb5a3', rose: '#e6488f', green: '#43b85c' }; + const badge = (u, color) => `` + + ``; + for (const theme of both) { + const v = await cap.open(null, { theme }); + await v.openCredit(); + for (const f of ICON_SET) for (const [cn, c] of Object.entries(COLORS)) { + await setCreditMark(v, icon(iconUri(f), c, 12)); await v.shoot(`${theme}-${f}-${cn}`, '.pl-attribution .lk-row'); + } + for (const f of BADGE_SET) for (const [cn, c] of Object.entries(COLORS)) { + await setCreditMark(v, badge(iconUri(f), c)); await v.shoot(`${theme}-badge-${f}-${cn}`, '.pl-attribution .lk-row'); + } + await v.close(); + } + } + }, + + 'artist-picks': { + out: 'artist-profiles/picks', what: 'the four pickable site icons in coral and teal, in the credit and on the card (ICONS=dir)', + async run(cap) { + const COLORS = { coral: '#ff6b57', teal: '#1fb5a3' }; + for (const theme of both) { + const v = await cap.open(null, { theme }); + await v.openCredit(); + for (const [f] of SITE_ICONS) for (const [cn, c] of Object.entries(COLORS)) { + await setCreditMark(v, icon(iconUri(f), c, 12)); await v.shoot(`credit-${theme}-${f}-${cn}`, '.pl-attribution'); + } + await v.go('/artists/fusspot'); + await v.addStyle(CARD_CSS); + for (const [f] of SITE_ICONS) for (const [cn, c] of Object.entries(COLORS)) { + await v.replace(CARD, 'mk-card', `
    Portrait artist
    +

    Fusspot

    +

    [The artist's own short bio, up to 280 characters.]

    + `); + await v.shoot(`card-${theme}-${f}-${cn}`, '.mk-card'); + } + await v.close(); + } + } + }, + + 'artist-swatches': { + out: 'artist-profiles/swatches', what: 'themed colour pairs: the picker with split swatches, and each pair in the real credit (ICONS=dir; PAIRS_JSON, SELECTED_JSON)', + async run(cap) { + // [dark-theme colour, light-theme colour]; each >= 4.3:1 on its own ground + const PAIRS = process.env.PAIRS_JSON ? JSON.parse(process.env.PAIRS_JSON) : { coral: ['#ff6b57', '#d9432f'], teal: ['#1fb5a3', '#0f8577'], + yellow: ['#ffd21a', '#9a7400'], rose: ['#f06aa6', '#c2386f'], green: ['#43b85c', '#2f8a44'] }; + const SELECTED = process.env.SELECTED_JSON ? JSON.parse(process.env.SELECTED_JSON) : [['ph-browser-fill', 'teal'], ['ph-sparkle-fill', 'yellow']]; + for (const theme of both) { + const ti = theme === 'dark' ? 0 : 1; + const v = await cap.open('/artists/fusspot', { theme }); + await v.addStyle(PANEL_CSS); + for (const [f, cn] of SELECTED) { + await v.replace(CARD, 'pk', pickerPanel({ chosenIcon: f, chosenColor: cn, shown: PAIRS[cn][ti], colours: PAIRS, half: 1, + note: 'Each colour has a version for the dark theme and one for the light theme. Visitors see whichever matches their theme.' })); + await v.shoot(`panel-${theme}-${f}-${cn}`, '.pk'); + } + await v.openCredit(); + for (const [f] of SITE_ICONS) for (const [n, pair] of Object.entries(PAIRS)) { + await setCreditMark(v, icon(iconUri(f), pair[ti], 12)); await v.shoot(`credit-${theme}-${f}-${n}`, '.pl-attribution .lk-row'); + } + await v.close(); + } + } + }, + + 'artist-picker': { + out: 'artist-profiles/picker', what: 'the two-step colour picker: five colours, then a deeper shade or a paired colour for the light theme (ICONS=dir)', + async run(cap) { + // name: [dark, light as a deeper shade, light as a paired colour] + const COLORS = { yellow: ['#ffd21a', '#9a7400', '#33658A'], coral: ['#ff6b57', '#d9432f', '#0f7c80'], + teal: ['#1fb5a3', '#0f8577', '#b4532a'], rose: ['#f06aa6', '#c2386f', '#1f7a4a'], green: ['#43b85c', '#2f8a44', '#8e3a78'] }; + for (const theme of both) { + const v = await cap.open('/artists/fusspot', { theme }); + await v.addStyle(PANEL_CSS); + for (const [f, cn, mode] of [['ph-browser-fill', 'teal', 'shade'], ['ph-browser-fill', 'teal', 'paired'], + ['ph-sparkle-fill', 'yellow', 'shade'], ['ph-sparkle-fill', 'yellow', 'paired']]) { + const [d, s, p] = COLORS[cn], half = mode === 'shade' ? 1 : 2; + const modes = `
    A deeper shade + A paired colour
    `; + await v.replace(CARD, 'pk', pickerPanel({ chosenIcon: f, chosenColor: cn, shown: theme === 'dark' ? d : COLORS[cn][half], colours: COLORS, half, modes, + note: 'The left half of each swatch is the dark theme; the right half is the light theme. Visitors see whichever matches theirs.' })); + await v.shoot(`picker-${theme}-${f}-${cn}-${mode}`, '.pk'); + } + await v.close(); + } + } + }, +}; diff --git a/docs/design/style-guide.md b/docs/design/style-guide.md index 1d02c23b2..182e1ad53 100644 --- a/docs/design/style-guide.md +++ b/docs/design/style-guide.md @@ -137,7 +137,7 @@ from the pair captured together: `style-guide/weights.html`. Why: at display sizes 700 thickens mixed-case letters and reads plump, while small capitals carry 700 well and read more easily for it. Found by the owner on the builder's "Race" heading, its info sentence and My Content's "Import Option Source"; a survey of every bold element on four pages -(`survey-weights.js` on `claude/artist-profile-pages`) showed the same split. +(the `survey-weights` round in `docs/design/capture/`) showed the same split. **Button contrast: opt-in dark text.** White on the amber button gradient is 2.1:1, under the 4.5:1 minimum for text; dark text (`#15202e`) is 7.8:1. Long-time dark-theme users know the @@ -180,7 +180,7 @@ font, and nothing set one, so `.form-button` and the other button classes render **Screenshot caveat.** In the Claude Code sandbox, the headless browser can't verify the network proxy's certificate for Google Fonts, so pages silently fall back to another sans. Captures made before 2026-09-30 (the artist-profile mockups included) show that fallback, not Open Sans. -`style-guide/capture-bold.js` shows the fix: fetch font requests on the Node side with +`docs/design/capture/lib.js` holds the fix: it fetches font requests on the Node side with `NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt`, which keeps certificate checks on. Sizes come from the `.f-s-*` utilities (`core.clj:66`): 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, diff --git a/docs/design/style-guide/capture-bold.js b/docs/design/style-guide/capture-bold.js deleted file mode 100644 index c97f32699..000000000 --- a/docs/design/style-guide/capture-bold.js +++ /dev/null @@ -1,71 +0,0 @@ -// Faux bold vs real bold, captured from the running site. -// -// The site loads Open Sans at weight 400 only (index.clj), so 600/700 text is -// synthesised by the browser. "after" adds the real 600 and 700 faces from -// Google Fonts at capture time -- the one-line fix -- and nothing else. -// -// lein fig:build && lein e2e-server -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt NODE_PATH=$(npm root -g) \ -// node docs/design/style-guide/capture-bold.js -// -// In the Claude Code sandbox the browser cannot verify the proxy's certificate -// for Google Fonts, so the page silently falls back to another sans. Font -// requests are therefore fetched on the Node side, which trusts the bundle in -// NODE_EXTRA_CA_CERTS, and handed to the page. Verification stays on. - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const OUT = process.argv[2] || 'bold-shots'; -const findChrome = () => { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -}; -const FIX = 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@600;700&display=block'; - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark', 'light']) { - for (const state of ['before', 'after']) { - const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async route => { - await route.fulfill({ response: await route.fetch() }); - }); - const page = await ctx.newPage(); - await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); - const c = page.locator('#cookie-btn'); - if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - if (state === 'after') { - await page.evaluate(href => new Promise((ok, no) => { - const l = document.createElement('link'); - l.rel = 'stylesheet'; l.href = href; l.onload = ok; l.onerror = no; - document.head.appendChild(l); - }), FIX); - await page.evaluate(() => Promise.all(['600', '700'].map(w => document.fonts.load(`${w} 12px "Open Sans"`)))); - await page.evaluate(() => document.fonts.ready); - await page.waitForTimeout(400); - } - await page.evaluate(() => document.fonts.ready); - const weights = await page.evaluate(() => - ([...document.fonts].filter(f => /Open Sans/.test(f.family) && f.status === 'loaded').map(f => f.weight))); - console.log(` ${theme} ${state}: loaded Open Sans weights ${JSON.stringify([...new Set(weights)])}`); - // the builder's tab row and the buttons above it: 600-weight uppercase UI - const tabs = page.locator('.builder-tabs').first(); - await tabs.scrollIntoViewIfNeeded(); - const box = await tabs.boundingBox(); - await page.screenshot({ path: path.join(OUT, `${theme}-${state}-builder.png`), - clip: { x: 0, y: Math.max(0, box.y - 220), width: 1280, height: box.height + 260 } }); - // one button up close - const btn = page.locator('.form-button').first(); - if (await btn.count()) await btn.screenshot({ path: path.join(OUT, `${theme}-${state}-button.png`) }); - await ctx.close(); - } - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-gutters.js b/docs/design/style-guide/capture-gutters.js deleted file mode 100644 index c6564aadc..000000000 --- a/docs/design/style-guide/capture-gutters.js +++ /dev/null @@ -1,67 +0,0 @@ -// Phone gutters (round17.html): the builder on a 390px phone as built, and two previews (CSS -// injected here only) with every row on one gutter: 10px and 16px. Lines mark the gutter on both -// sides. Needs the app on :8890 (prod build) from feature/style-guide. Run from this folder: -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-gutters.js -const { chromium, devices } = require('playwright'); -// Each row's own spacing, set so its visible edge lands on the gutter G. -const css = G => ` - .app-header-bar .w-100-p { padding-left: ${G}px !important; padding-right: ${G}px !important; } - .app-header-menu { padding-left: ${G - 2}px !important; padding-right: ${G - 2}px !important; } - h1[data-g] { margin-left: ${G}px !important; } - [data-g=buttons].flex-wrap { margin-left: ${G - 5}px !important; margin-right: ${G - 5}px !important; } - [data-g=toggles].flex-wrap { padding-right: ${G}px !important; } - [data-g=builder-in].flex { padding-left: ${G}px !important; } - [data-g=builder-out].w-100-p { padding-right: ${G}px !important; }`; -(async () => { - const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const [name, G] of [['built', null], ['g10', 10], ['g16', 16]]) { - const ctx = await b.newContext({ ...devices['iPhone 13'], deviceScaleFactor: 3 }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); - await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); - await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); - if (G) await p.evaluate(rules => { - const h1 = document.querySelector('h1'); h1.dataset.g = 'title'; - h1.closest('.flex-wrap').children[1].dataset.g = 'buttons'; - [...document.querySelectorAll('div.pointer')].find(e => /Dark Button Text/.test(e.textContent) && e.children.length < 4).parentElement.dataset.g = 'toggles'; - const inner = document.querySelector('.builder-tabs').parentElement.parentElement; - inner.dataset.g = 'builder-in'; inner.parentElement.dataset.g = 'builder-out'; - const s = document.createElement('style'); s.textContent = rules; document.head.appendChild(s); - }, css(G)); - await p.waitForTimeout(400); - const edges = await p.evaluate(G => { - const W = innerWidth, r = e => e.getBoundingClientRect(); - const ink = e => { const x = document.createRange(); x.selectNodeContents(e); return x.getBoundingClientRect().left; }; - const bar = document.querySelector('.app-header-bar .w-100-p'), tabs = [...document.querySelectorAll('.header-tab')]; - const panel = document.querySelector('.builder-tabs').parentElement; - const toggles = [...document.querySelectorAll('div.pointer')].find(e => /Dark Button Text/.test(e.textContent) && e.children.length < 4); - const firstBtn = document.querySelector('h1').closest('.flex-wrap').children[1].firstElementChild; - const m = { - logo: r(bar.querySelector('img')).left, tabs: r(tabs[0]).left, title: ink(document.querySelector('h1')), - buttons: r(firstBtn).left, panel: r(panel).left, - 'login R': W - r(bar.lastElementChild).right, 'tabs R': W - r(tabs[tabs.length - 1]).right, - 'toggles R': W - r(toggles).right, 'panel R': W - r(panel).right }; - if (G) { - const layer = document.createElement('div'); - layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99999'; - for (const x of [G, W - G]) { - const line = document.createElement('div'); - line.style.cssText = `position:absolute;top:0;bottom:0;left:${x - 0.5}px;width:1px;background:#4da3ff`; - layer.append(line); - } - document.body.appendChild(layer); - } - return Object.entries(m).map(([k, v]) => `${k} ${Math.round(v)}`).join(', '); - }, G); - console.log(name, edges); - await p.screenshot({ path: `round17/${name}.png`, clip: { x: 0, y: 0, width: 390, height: 640 } }); - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-header-menus.js b/docs/design/style-guide/capture-header-menus.js deleted file mode 100644 index f1cad7753..000000000 --- a/docs/design/style-guide/capture-header-menus.js +++ /dev/null @@ -1,62 +0,0 @@ -// Header menus (round21.html): the active tab, the Characters flyout open on desktop and on a -// phone with its current item highlighted, and the logged-in user menu. Prints each one's -// computed position and colours. Needs the app on :8890 (prod build, seeded user kaylee). -// Run from this folder: OUT=round21/before NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-header-menus.js -const { chromium, devices } = require('playwright'); -const BASE = 'http://localhost:8890', OUT = process.env.OUT || 'round21'; -async function open(b, context, login) { - const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); - await ctx.addInitScript(() => { try { if (!localStorage.getItem('user')) localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - if (login) { - await p.goto(`${BASE}/pages/login-page`, { waitUntil: 'domcontentloaded' }); - await p.waitForSelector('input'); - await p.locator('input').nth(0).fill('kaylee'); await p.locator('input').nth(1).fill('serenity99'); - await p.locator('button.form-button').first().click(); - await p.waitForFunction(() => /:token/.test(localStorage.getItem('user') || ''), null, { timeout: 20000 }); - } - await p.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); - await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); - await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - return { ctx, p }; -} -const desc = (p, sel) => p.evaluate(sel => { - const e = document.querySelector(sel); if (!e) return 'missing'; - const s = getComputedStyle(e), r = e.getBoundingClientRect(); - return `${sel}: ${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)} bg ${s.backgroundColor} pos ${s.position} z ${s.zIndex} display ${s.display}`; -}, sel); -(async () => { - const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - { - const { ctx, p } = await open(b, { viewport: { width: 1280, height: 900 } }); - const tab = p.locator('.header-tab').first(); - console.log('desktop', await desc(p, '.header-tab')); - await tab.hover(); await p.waitForTimeout(400); - console.log('desktop', await desc(p, '.header-tab .header-flyout')); - console.log('desktop', await desc(p, '.header-tab .header-flyout > div:nth-child(2)')); - await p.screenshot({ path: `${OUT}/desktop-flyout.png`, clip: { x: 560, y: 170, width: 720, height: 330 } }); - await ctx.close(); - } - { - const { ctx, p } = await open(b, devices['iPhone 13']); - await p.locator('.header-tab').first().focus(); await p.waitForTimeout(400); - console.log('phone', await desc(p, '.header-tab .header-flyout')); - await p.screenshot({ path: `${OUT}/phone-flyout.png`, clip: { x: 0, y: 0, width: 390, height: 300 } }); - await ctx.close(); - } - { - const { ctx, p } = await open(b, { viewport: { width: 1280, height: 900 } }, true); - await p.locator('#user-header').hover(); await p.waitForTimeout(400); - console.log('user', await desc(p, '#user-menu')); - await p.screenshot({ path: `${OUT}/user-menu.png`, clip: { x: 980, y: 0, width: 300, height: 180 } }); - await p.mouse.move(400, 600); await p.waitForTimeout(400); - console.log('user closed', await desc(p, '#user-menu')); - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-layout-width.js b/docs/design/style-guide/capture-layout-width.js deleted file mode 100644 index 17dba6487..000000000 --- a/docs/design/style-guide/capture-layout-width.js +++ /dev/null @@ -1,41 +0,0 @@ -// Layout follows the window width (round14.html): one desktop browser at 1280, then the -// same window narrowed to 390, then a real phone. Needs the app on :8890 (prod build) from -// feature/style-guide. Run from this folder: -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-layout-width.js -const { chromium, devices } = require('playwright'); -async function open(b, context) { - const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); - await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); - await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); - return { ctx, p }; -} -const tab = async (p, name) => { await p.locator('.builder-tab', { hasText: name }).click(); await p.waitForTimeout(400); }; -(async () => { - const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - { - const { ctx, p } = await open(b, { viewport: { width: 1280, height: 800 } }); - await p.screenshot({ path: 'round14/desktop-1280.png' }); - await p.setViewportSize({ width: 390, height: 844 }); await p.waitForTimeout(500); - await p.evaluate(() => scrollTo(0, 0)); - await p.screenshot({ path: 'round14/desktop-narrowed-options.png' }); - await tab(p, 'Details'); - const a = await p.locator('.roll-button').first().boundingBox(); - await p.evaluate(y => scrollTo(0, y - 300), a.y + await p.evaluate(() => scrollY)); - await p.screenshot({ path: 'round14/desktop-narrowed-details.png' }); - await ctx.close(); - } - { - const { ctx, p } = await open(b, { ...devices['iPhone 13'] }); - await p.screenshot({ path: 'round14/phone-options.png' }); - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-left-edges.js b/docs/design/style-guide/capture-left-edges.js deleted file mode 100644 index 3dc816ef6..000000000 --- a/docs/design/style-guide/capture-left-edges.js +++ /dev/null @@ -1,61 +0,0 @@ -// The phone header's left edges (round16.html): a line at the left edge of each element down the -// page, as built and with a preview (CSS injected here only) that puts them all on 10px; and the -// bar with the search and login boxes. Needs the app on :8890 (prod build) from -// feature/style-guide. Run from this folder: -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-left-edges.js -const { chromium, devices } = require('playwright'); -(async () => { - const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - const ctx = await b.newContext({ ...devices['iPhone 13'], deviceScaleFactor: 3 }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); - await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); - await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); - await p.screenshot({ path: 'round16/bar.png', clip: { x: 0, y: 0, width: 390, height: 52 } }); - const ALIGN = ` - .app-header-bar .w-100-p { padding-left: 10px !important; padding-right: 10px !important; } - .app-header-menu { padding-left: 8px !important; padding-right: 8px !important; } - .flex-wrap.edge-align-buttons.edge-align-buttons { margin-left: 5px !important; }`; - for (const state of ['now', 'aligned']) { - if (state === 'aligned') { - await p.evaluate(css => { - document.querySelector('h1').closest('.flex-wrap').children[1].classList.add('edge-align-buttons'); - const s = document.createElement('style'); s.textContent = css; document.head.appendChild(s); - }, ALIGN); - await p.waitForTimeout(300); - await p.screenshot({ path: 'round16/bar-aligned.png', clip: { x: 0, y: 0, width: 390, height: 52 } }); - } - const edges = await p.evaluate(() => { - document.querySelectorAll('.edge-layer').forEach(e => e.remove()); - // the ink, not the box: a text's left edge is where its first glyph starts - const ink = e => { const r = document.createRange(); r.selectNodeContents(e); return r.getBoundingClientRect().left; }; - const list = [ - ['logo', document.querySelector('.app-header-bar img').getBoundingClientRect().left, '#ff5c5c'], - ['tabs', document.querySelector('.header-tab').getBoundingClientRect().left, '#3ddc84'], - ['title', ink(document.querySelector('h1')), '#4da3ff'], - ['buttons', document.querySelector('h1').closest('.flex-wrap').children[1].firstElementChild.getBoundingClientRect().left, '#c084fc'], - ['panel', document.querySelector('.builder-tabs').parentElement.getBoundingClientRect().left, '#ff8fd1'], - ]; - const layer = document.createElement('div'); - layer.className = 'edge-layer'; - layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:99999'; - list.forEach(([, x, color]) => { - const line = document.createElement('div'); - line.style.cssText = `position:absolute;top:0;bottom:0;left:${x - 0.5}px;width:1px;background:${color}`; - layer.append(line); - }); - document.body.appendChild(layer); - return list.map(([n, x]) => `${n} ${Math.round(x)}`); - }); - console.log(state, edges.join(', ')); - await p.screenshot({ path: `round16/edges-${state}.png`, clip: { x: 0, y: 0, width: 390, height: 600 } }); - await p.screenshot({ path: `round16/strip-${state}.png`, clip: { x: 0, y: 0, width: 70, height: 600 } }); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-outline-colour.js b/docs/design/style-guide/capture-outline-colour.js deleted file mode 100644 index e072cf6cb..000000000 --- a/docs/design/style-guide/capture-outline-colour.js +++ /dev/null @@ -1,38 +0,0 @@ -// Coloured outline options for dark text on the ability buttons (round10.html). Needs the app on :8890 (prod build). -// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline.js -const { chromium } = require('playwright'); -const SEL = '.app.dark-button-text .roll-button'; -const S = (w, c) => `${SEL} { -webkit-text-stroke: ${w}px ${c}; paint-order: stroke fill; }`; -const OPT = { - 'w-white': null, - 'i-white-2': S(2, 'rgba(255,255,255,.85)'), - 'j-yellow-1': S(1, '#ffd21a'), - 'k-yellow-2': S(2, '#ffd21a'), - 'l-paleyellow-2': S(2, '#ffe680'), - 'm-orange-2': S(2, '#ff8a00'), - 'n-amber-2': S(2, '#f5b942'), -}; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const dark of [false, true]) { - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - for (const [k, css] of Object.entries(OPT)) { - if ((k === 'w-white') !== !dark) continue; - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c || ''; document.head.appendChild(e); }, css); - await p.waitForTimeout(120); - const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); - await p.screenshot({ path: `round10/roll-${k}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); - } - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-outline-gradient.js b/docs/design/style-guide/capture-outline-gradient.js deleted file mode 100644 index 73104eae8..000000000 --- a/docs/design/style-guide/capture-outline-gradient.js +++ /dev/null @@ -1,46 +0,0 @@ -// Gradient outline options for dark text on the ability buttons (round11.html). Needs the app on -// :8890 (prod build). DPR=1 or 2 sets the pixel density. Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline-gradient.js -// A stroke can't take a gradient, so each option draws a copy of the label behind the real one -// (::before) with a transparent stroke and a gradient clipped to the text; Chrome paints the clip -// into the stroke area too. The real label keeps its dark fill and the built light glow. -const { chromium } = require('playwright'); -const SEL = '.app.dark-button-text .roll-button'; -const G = grad => ` - ${SEL} { position: relative; isolation: isolate; -webkit-text-stroke: 0 !important; } - ${SEL}::before { content: attr(data-label); position: absolute; inset: 0; z-index: -1; - display: flex; align-items: center; justify-content: center; - -webkit-text-stroke: 2px transparent; color: transparent; text-shadow: none; - background: ${grad}; -webkit-background-clip: text; background-clip: text; }`; -const OPT = { - 'w-white': null, - 'k-built': '', - 'o-reverse': G('linear-gradient(to bottom, #dbab50 40%, #f1a20f 62%)'), - 'p-bottom-solid': G('#dbab50'), - 'q-yellow-reverse': G('linear-gradient(to bottom, #ffd21a 40%, #dbab50 62%)'), - 'r-bevel': G('linear-gradient(to bottom, #ffd21a 40%, #b37a00 62%)'), -}; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const dark of [false, true]) { - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: +(process.env.DPR || 2) }); - await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.querySelectorAll('.roll-button').forEach(e => e.dataset.label = e.textContent)); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - for (const [k, css] of Object.entries(OPT)) { - if ((k === 'w-white') !== !dark) continue; - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c || ''; document.head.appendChild(e); }, css); - await p.waitForTimeout(120); - const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); - await p.screenshot({ path: `round11/roll-${k}${process.env.DPR ? "@" + process.env.DPR : ""}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); - } - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-outline.js b/docs/design/style-guide/capture-outline.js deleted file mode 100644 index d67745747..000000000 --- a/docs/design/style-guide/capture-outline.js +++ /dev/null @@ -1,39 +0,0 @@ -// Outline options for dark text on the ability buttons (round9.html). Needs the app on :8890 (prod build). -// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-outline.js -const { chromium } = require('playwright'); -const SEL = '.app.dark-button-text .roll-button'; -const OPT = { - 'w-white': null, - 'd-built': '', - 'e-shadow-ring': `${SEL} { text-shadow: 1px 0 0 rgba(255,255,255,.55), -1px 0 0 rgba(255,255,255,.55), 0 1px 0 rgba(255,255,255,.55), 0 -1px 0 rgba(255,255,255,.55) !important; }`, - 'f-stroke-hair': `${SEL} { -webkit-text-stroke: 1px rgba(255,255,255,.75); paint-order: stroke fill; }`, - 'g-stroke-hair-glow': `${SEL} { -webkit-text-stroke: 1px rgba(255,255,255,.75); paint-order: stroke fill; }`, - 'h-stroke-full': `${SEL} { -webkit-text-stroke: 2px rgba(255,255,255,.85); paint-order: stroke fill; text-shadow: none !important; }`, - 'i-stroke-full-glow': `${SEL} { -webkit-text-stroke: 2px rgba(255,255,255,.85); paint-order: stroke fill; }`, -}; -// f: stroke only, no glow -OPT['f-stroke-hair'] += ` ${SEL} { text-shadow: none !important; }`; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const dark of [false, true]) { - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - for (const [k, css] of Object.entries(OPT)) { - if ((k === 'w-white') !== !dark) continue; - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c || ''; document.head.appendChild(e); }, css); - await p.waitForTimeout(120); - const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); - await p.screenshot({ path: `round9/roll-${k}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); - } - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-phone-header.js b/docs/design/style-guide/capture-phone-header.js deleted file mode 100644 index 5670a2e06..000000000 --- a/docs/design/style-guide/capture-phone-header.js +++ /dev/null @@ -1,31 +0,0 @@ -// The phone header (round15.html): 390px and 320px phones, and desktop at 1280 to show it is -// unchanged. Needs the app on :8890 (prod build) from feature/style-guide. Run from this folder: -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-phone-header.js (OUT=dir to save elsewhere) -const { chromium, devices } = require('playwright'); -const SHOTS = [ - ['phone-390', { ...devices['iPhone 13'] }, 300], - ['phone-320', { ...devices['iPhone SE'] }, 300], - ['desktop-1280', { viewport: { width: 1280, height: 800 } }, 520], -]; -(async () => { - const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const [name, context, height] of SHOTS) { - const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); - await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); - await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); - const w = p.viewportSize().width; - const over = await p.evaluate(() => document.documentElement.scrollWidth - innerWidth); - console.log(name, 'sideways overflow', over); - await p.screenshot({ path: `${process.env.OUT || 'round15'}/${name}.png`, clip: { x: 0, y: 0, width: w, height } }); - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-phone-menus.js b/docs/design/style-guide/capture-phone-menus.js deleted file mode 100644 index 78473e1dd..000000000 --- a/docs/design/style-guide/capture-phone-menus.js +++ /dev/null @@ -1,42 +0,0 @@ -// Every header tab's menu on a phone (round21.html): opens it, checks it is fully on screen and -// not clipped by the header, and saves a screenshot. Needs the app on :8890 (prod build). -// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-phone-menus.js -const { chromium, devices } = require('playwright'); -(async () => { - const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - let bad = 0; - for (const [w, dev] of [[390, 'iPhone 13'], [320, 'iPhone SE']]) { - const ctx = await b.newContext({ ...devices[dev], deviceScaleFactor: 2 }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); - await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); - await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); - const n = await p.locator('.header-tab').count(); - for (let i = 0; i < n; i++) { - const tab = p.locator('.header-tab').nth(i); - if (!(await tab.locator('.header-flyout').count())) continue; - await tab.focus(); await p.waitForTimeout(350); - const r = await tab.locator('.header-flyout').evaluate(f => { - const b = f.getBoundingClientRect(); - // the point at the menu's bottom-centre must be the menu itself, not what's behind it - const hit = document.elementFromPoint(b.left + b.width / 2, b.bottom - 4); - return { l: Math.round(b.left), r: Math.round(innerWidth - b.right), bottom: Math.round(b.bottom), - visible: !!hit && f.contains(hit), over: document.documentElement.scrollWidth - innerWidth }; - }); - const ok = r.l >= 0 && r.r >= 0 && r.visible && r.over <= 0; - if (!ok) bad++; - console.log(`${ok ? 'PASS' : 'FAIL'} ${w}px tab ${i + 1}: left ${r.l}, right ${r.r}, bottom ${r.bottom}, fully shown ${r.visible}`); - await p.screenshot({ path: `round21/menus/${w}-tab${i + 1}.png`, clip: { x: 0, y: 0, width: w, height: 420 } }); - await p.evaluate(() => document.activeElement.blur()); await p.waitForTimeout(150); - } - await ctx.close(); - } - await b.close(); - process.exit(bad ? 1 : 0); -})().catch(e => { console.error(e); process.exit(2); }); diff --git a/docs/design/style-guide/capture-roll-16.js b/docs/design/style-guide/capture-roll-16.js deleted file mode 100644 index 43d8fed4a..000000000 --- a/docs/design/style-guide/capture-roll-16.js +++ /dev/null @@ -1,63 +0,0 @@ -// Roll buttons at 16px (round19.html): the builder's ability row and skills on 390px and 320px -// phones and on desktop, white and dark text, and the sheet's skills and saves. Reports each button's size. -// Needs the app on :8890 (prod build) from feature/style-guide. Run from this folder: -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-roll-16.js -const { chromium, devices } = require('playwright'); -const BASE = 'http://localhost:8890'; -async function open(b, context, darkText, path) { - const ctx = await b.newContext({ ...context, deviceScaleFactor: 2 }); - await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, darkText); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto(BASE + path, { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); - await c.waitFor({ timeout: 5000 }).then(() => c.click()).catch(() => {}); - await c.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - return { ctx, p }; -} -async function report(p, label) { - const info = await p.evaluate(() => { - const bs = [...document.querySelectorAll('.roll-button')].filter(b => b.offsetParent); - const over = bs.filter(b => b.scrollWidth > b.clientWidth + 1).length; - const hs = [...new Set(bs.map(b => Math.round(b.getBoundingClientRect().height)))]; - return `${bs.length} buttons, ${getComputedStyle(bs[0]).fontSize}, heights ${hs.join('/')}, labels overflowing ${over}`; - }); - console.log(label, info); -} -async function shoot(p, name, selector, above, h) { - const el = p.locator(selector).first(); - await p.mouse.move(1, 1); - await el.scrollIntoViewIfNeeded(); - await p.evaluate(() => scrollBy(0, -120)); await p.waitForTimeout(200); - const r = await el.boundingBox(); - await p.screenshot({ path: `${process.env.OUT || 'round19'}/${name}.png`, clip: { x: 0, y: Math.max(0, r.y - above), width: p.viewportSize().width, height: h } }); -} -(async () => { - const fs = require('fs'); const d = fs.readdirSync('/opt/pw-browsers').filter(x => x.startsWith('chromium-') && !x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const [dev, ctxOpts] of [['390', devices['iPhone 13']], ['320', devices['iPhone SE']]]) { - for (const dark of [false, true]) { - const { ctx, p } = await open(b, ctxOpts, dark, '/pages/dnd/5e/character-builder'); - await p.getByText(/^details$/i).first().click(); await p.waitForTimeout(400); - await report(p, `phone ${dev} ${dark ? 'dark' : 'white'}`); - await shoot(p, `phone-${dev}-${dark ? 'dark' : 'white'}`, '.roll-button', 120, 560); - await ctx.close(); - } - } - { - const { ctx, p } = await open(b, { viewport: { width: 1280, height: 900 } }, false, '/pages/dnd/5e/character-builder'); - await report(p, 'desktop'); - await shoot(p, 'desktop', '.roll-button', 90, 420); - await ctx.close(); - } - { - // The sheet's skills and saving throws: wider buttons, same label size. - const { ctx, p } = await open(b, devices['iPhone 13'], false, '/pages/dnd/5e/character-builder'); - await p.getByText(/^details$/i).first().click(); await p.waitForTimeout(400); - await shoot(p, 'phone-390-skills', 'text=Acrobatics', 60, 520); - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-round3.js b/docs/design/style-guide/capture-round3.js deleted file mode 100644 index 05f80b66a..000000000 --- a/docs/design/style-guide/capture-round3.js +++ /dev/null @@ -1,56 +0,0 @@ -const { chromium } = require('playwright'); -const PAGES = [['/pages/dnd/5e/character-builder','builder'], ['/dnd/5e/my-content','mycontent'], - ['/pages/dnd/5e/spells','spells'], ['/pages/dnd/5e/monsters','monsters']]; -const BTN = { - arial: 'button { font-family: Arial, sans-serif !important; } .form-button { font-weight: 700 !important; }', - os600: '.form-button { font-weight: 600 !important; }', - os700: '', - dark: '.app:not(.light-theme) .form-button { color: #15202e !important; }' -}; -const WRAP = { - asis: '', - ls02: '.header-tab .title { letter-spacing: -0.02em; }', - ls04: '.header-tab .title { letter-spacing: -0.04em; }', - s13: '.header-tab { font-size: 13px !important; }', - w600: '.header-tab { font-weight: 600 !important; }' -}; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const theme of ['dark','light']) { - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - const prep = async url => { - await p.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(400); - }; - for (const [url, name] of PAGES) { - await prep(url); - await p.screenshot({ path: `sg3/${theme}-v2-${name}.png`, clip: { x: 0, y: 150, width: 1280, height: 750 } }); - } - // buttons, 2x close-up of the builder's button row - await prep('/pages/dnd/5e/character-builder'); - const set = css => p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); - for (const [k, css] of Object.entries(BTN)) { - await set(css); await p.waitForTimeout(150); - const first = await p.locator('.form-button').first().boundingBox(); - const last = await p.locator('.form-button').nth(4).boundingBox(); - await p.screenshot({ path: `sg3/${theme}-btn-${k}.png`, scale: 'device', - clip: { x: first.x - 6, y: first.y - 6, width: last.x + last.width - first.x + 12, height: first.height + 12 } }); - } - await set(''); - // header wrap options - for (const [k, css] of Object.entries(WRAP)) { - await set(css); await p.waitForTimeout(150); - const r = await p.locator('.header-tab').first().evaluate(el => { const q = el.parentElement.getBoundingClientRect(); return { x: q.x, y: q.y, width: q.width, height: q.height }; }); - await p.screenshot({ path: `sg3/${theme}-wrap-${k}.png`, clip: { x: r.x - 4, y: r.y - 4, width: Math.min(1280 - r.x + 4, r.width + 8), height: r.height + 8 } }); - } - await ctx.close(); console.log(theme, 'done'); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-round4.js b/docs/design/style-guide/capture-round4.js deleted file mode 100644 index c961d4a75..000000000 --- a/docs/design/style-guide/capture-round4.js +++ /dev/null @@ -1,63 +0,0 @@ -const { chromium } = require('playwright'); -const WRAP = { - 'as is (700)': '', - 'letter spacing -2%': '.header-tab .title { letter-spacing: -0.02em; }', - 'letter spacing -4%': '.header-tab .title { letter-spacing: -0.04em; }', - '13px': '.header-tab { font-size: 13px !important; }', - '600 weight': '.header-tab { font-weight: 600 !important; }', - 'before (400, integration)': '.header-tab, .header-tab * { font-weight: 400 !important; }' -}; -const PAGES = [['/pages/dnd/5e/character-builder','builder'], ['/dnd/5e/my-content','mycontent'], - ['/pages/dnd/5e/spells','spells'], ['/pages/dnd/5e/monsters','monsters']]; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - await p.evaluate(() => document.fonts.ready); - const oneLine = () => p.evaluate(() => { - const t = [...document.querySelectorAll('.header-tab .title')].find(e => /my content/i.test(e.textContent)); - if (!t || !t.offsetParent) return null; - const lh = parseFloat(getComputedStyle(t).lineHeight) || parseFloat(getComputedStyle(t).fontSize) * 1.4; - return t.getBoundingClientRect().height < lh * 1.5; - }); - // A: narrowest width that keeps MY CONTENT on one line, per option - for (const [name, css] of Object.entries(WRAP)) { - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); - let fits = null; - for (let w = 1600; w >= 700; w -= 10) { - await p.setViewportSize({ width: w, height: 900 }); await p.waitForTimeout(40); - const r = await oneLine(); - if (r === null) { fits = fits ?? `labels hidden below ${w}`; break; } - if (!r) break; - fits = w; - } - console.log(`${name.padEnd(28)} one line down to ${fits}px`); - } - await p.setViewportSize({ width: 1280, height: 900 }); - // B: dark text on every amber button, dark theme, four pages - const DARK = `(() => { for (const el of document.querySelectorAll('*')) { - const bg = getComputedStyle(el).backgroundImage; - if (/rgb\\(241, 162, 15\\)/.test(bg)) { el.style.setProperty('color', '#15202e', 'important'); - el.querySelectorAll('*').forEach(c => c.style.setProperty('color', '#15202e', 'important')); } } })()`; - const ctx2 = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }); - await ctx2.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx2.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const q = await ctx2.newPage(); - for (const [url, name] of PAGES) for (const state of ['white', 'dark']) { - await q.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }); - const c = q.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await q.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await q.evaluate(() => document.fonts.ready); - if (state === 'dark') await q.evaluate(DARK); - await q.waitForTimeout(300); - const n = await q.evaluate(() => [...document.querySelectorAll('*')].filter(el => /rgb\(241, 162, 15\)/.test(getComputedStyle(el).backgroundImage)).length); - if (state === 'dark') console.log(`${name}: ${n} amber elements`); - await q.screenshot({ path: `sg4/dark-${state}-${name}.png`, clip: { x: 0, y: 150, width: 1280, height: 750 } }); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-round5.js b/docs/design/style-guide/capture-round5.js deleted file mode 100644 index 9d5f62b3a..000000000 --- a/docs/design/style-guide/capture-round5.js +++ /dev/null @@ -1,29 +0,0 @@ -const { chromium } = require('playwright'); -const V = { - '1-today': '', - '2-shadow': '.form-button { text-shadow: 0 1px 1px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.25); }', - '3-deeper': '.form-button { background-image: linear-gradient(to bottom, #c98300, #b8792a) !important; }', - '4-deeper-shadow': '.form-button { background-image: linear-gradient(to bottom, #c98300, #b8792a) !important; text-shadow: 0 1px 1px rgba(0,0,0,0.3); }', - '5-dark': '.form-button { color: #15202e !important; }' -}; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); - for (const [k, css] of Object.entries(V)) { - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); - await p.waitForTimeout(150); - const f = await p.locator('.form-button').first().boundingBox(); - const l = await p.locator('.form-button').nth(4).boundingBox(); - await p.screenshot({ path: `sg6/${k}.png`, clip: { x: f.x - 8, y: f.y - 8, width: l.x + l.width - f.x + 16, height: f.height + 16 } }); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-round6.js b/docs/design/style-guide/capture-round6.js deleted file mode 100644 index a0604f906..000000000 --- a/docs/design/style-guide/capture-round6.js +++ /dev/null @@ -1,29 +0,0 @@ -const { chromium } = require('playwright'); -const V = { - '6-white-shadow': '.form-button { text-shadow: 0 1px 1px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.25); }', - '7-dark': '.form-button { color: #15202e !important; }', - '8-dark-emboss': '.form-button { color: #15202e !important; text-shadow: 0 1px 0 rgba(255,255,255,0.35); }', - '9-dark-glow': '.form-button { color: #15202e !important; text-shadow: 0 0 2px rgba(255,255,255,0.45), 0 1px 0 rgba(255,255,255,0.3); }', - '10-dark-emboss-strong': '.form-button { color: #15202e !important; text-shadow: 0 1px 0 rgba(255,255,255,0.55), 0 0 1px rgba(255,255,255,0.3); }' -}; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(() => { try { localStorage.setItem('user', '{:theme "dark-theme"}'); } catch (_) {} }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); - for (const [k, css] of Object.entries(V)) { - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); - await p.waitForTimeout(150); - const f = await p.locator('.form-button').first().boundingBox(); - const l = await p.locator('.form-button').nth(4).boundingBox(); - await p.screenshot({ path: `sg7/${k}.png`, clip: { x: f.x - 8, y: f.y - 8, width: l.x + l.width - f.x + 16, height: f.height + 16 } }); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-round7.js b/docs/design/style-guide/capture-round7.js deleted file mode 100644 index d0a3babb6..000000000 --- a/docs/design/style-guide/capture-round7.js +++ /dev/null @@ -1,36 +0,0 @@ -const { chromium } = require('playwright'); -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const theme of ['dark-theme', 'light-theme']) for (const dark of [false, true]) { - if (theme === 'light-theme' && dark) continue; - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(([t, dk]) => { try { localStorage.setItem('user', `{:theme "${t}" :dark-button-text? ${dk}}`); } catch (_) {} }, [theme, dark]); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - const sh = await p.evaluate(() => ['.form-button', '.roll-button'].map(s => { - const e = document.querySelector(s); const cs = getComputedStyle(e); return `${s}: ${cs.color} / ${cs.textShadow.slice(0, 40)}`; })); - const tag = `${theme.replace('-theme','')}${dark ? '-darktext' : ''}`; - console.log(tag, sh.join(' | ')); - const f = await p.locator('.form-button').first().boundingBox(); const l = await p.locator('.form-button').nth(4).boundingBox(); - await p.screenshot({ path: `sg8/${tag}-buttons.png`, clip: { x: f.x - 8, y: f.y - 8, width: l.x + l.width - f.x + 16, height: f.height + 16 } }); - const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); - await p.screenshot({ path: `sg8/${tag}-roll.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); - if (!dark) { - // header tab: as built, and with the dark lift (not built) - for (const [k, css] of [['tab-now', ''], ['tab-lift', `.header-tab { text-shadow: 0 1px 1px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.25); }`]]) { - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); - await p.waitForTimeout(100); - const t = await p.locator('.header-tab').first().evaluate(el => { const q = el.parentElement.getBoundingClientRect(); return { x: q.x, y: q.y, width: q.width, height: q.height }; }); - await p.screenshot({ path: `sg8/${tag}-${k}.png`, clip: { x: t.x - 4, y: t.y - 4, width: Math.min(1280 - t.x + 4, 470), height: t.height + 8 } }); - } - } - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-size-phone.js b/docs/design/style-guide/capture-size-phone.js deleted file mode 100644 index 91d882840..000000000 --- a/docs/design/style-guide/capture-size-phone.js +++ /dev/null @@ -1,51 +0,0 @@ -// The builder on a phone (round13.html), at 320px and 390px: the toggle row, and the ability -// buttons at 14px (as built), 15px and 16px, with the vertical padding trimmed so the button -// keeps its 31px height. Uses an iPhone user agent: the app picks its phone layout from the -// user agent, not the window width. Needs the app on :8890 (prod build). -// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node capture-size-phone.js -const { chromium, devices } = require('playwright'); -const SIZES = { 14: '', 15: '.roll-button { font-size: 15px !important; padding-top: 5.5px !important; padding-bottom: 5.5px !important; }', - 16: '.roll-button { font-size: 16px !important; padding-top: 4.5px !important; padding-bottom: 4.5px !important; }' }; -const OUT = process.env.OUT || 'round13'; -async function open(b, width, darkText) { - const ctx = await b.newContext({ ...devices['iPhone 13'], viewport: { width, height: 844 }, deviceScaleFactor: 3 }); - await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, darkText); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - return { ctx, p }; -} -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const width of [320, 390]) { - const { ctx, p } = await open(b, width, false); - const row = p.locator('div.pointer', { hasText: 'Light Theme' }).first(); - const r = await row.boundingBox(); - const right = await p.locator('div.pointer', { hasText: 'Dark Button Text' }).first().evaluate(e => e.getBoundingClientRect().right); - console.log(`toggles at ${width}px: right edge ${Math.round(right)}, gap ${Math.round(width - right)}px`); - await p.screenshot({ path: `${OUT}/toggles-${width}.png`, clip: { x: 0, y: r.y - 90, width, height: 150 } }); - await ctx.close(); - } - for (const width of [320, 390]) for (const darkText of [false, true]) { - const { ctx, p } = await open(b, width, darkText); - await p.getByText(/^details$/i).first().click(); - await p.waitForTimeout(400); - for (const [size, css] of Object.entries(SIZES)) { - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); - await p.waitForTimeout(120); - const btn = p.locator('.roll-button').first(); - await btn.scrollIntoViewIfNeeded(); - const info = await btn.evaluate(e => { const r = e.getBoundingClientRect(); return `${getComputedStyle(e).fontSize} ${Math.round(r.width)}x${r.height}`; }); - console.log(width, darkText ? 'dark' : 'white', size, info); - const a = await p.locator('.roll-button').first().boundingBox(); - await p.screenshot({ path: `${OUT}/${size}-${darkText ? 'rim' : 'white'}-${width}.png`, clip: { x: 0, y: Math.max(0, a.y - 110), width, height: 160 } }); - } - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-size.js b/docs/design/style-guide/capture-size.js deleted file mode 100644 index bae55c6cc..000000000 --- a/docs/design/style-guide/capture-size.js +++ /dev/null @@ -1,50 +0,0 @@ -// Ability-button label size options (round12.html): 14px (as built), 15px and 16px, each with -// white text, dark text with the glow only, the built yellow rim, and the lit-from-above rim. -// Needs the app on :8890 (prod build). DPR=1 or 2 sets the pixel density. -// Run from this folder: NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt DPR=1 node capture-size.js -const { chromium } = require('playwright'); -const SEL = '.app.dark-button-text .roll-button'; -const LIT = ` - ${SEL} { position: relative; isolation: isolate; -webkit-text-stroke: 0 !important; } - ${SEL}::before { content: attr(data-label); position: absolute; inset: 0; z-index: -1; - display: flex; align-items: center; justify-content: center; - -webkit-text-stroke: 2px transparent; color: transparent; text-shadow: none; - background: linear-gradient(to bottom, #ffd21a 40%, #dbab50 62%); - -webkit-background-clip: text; background-clip: text; }`; -const STYLES = { - white: null, - glow: `${SEL} { -webkit-text-stroke: 0 !important; }`, - rim: '', - lit: LIT, -}; -const DPR = +(process.env.DPR || 2); -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const dark of [false, true]) { - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: DPR }); - await ctx.addInitScript(dk => { try { localStorage.setItem('user', `{:theme "dark-theme" :dark-button-text? ${dk}}`); } catch (_) {} }, dark); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - const c = p.locator('#cookie-btn'); if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await p.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await p.evaluate(() => document.querySelectorAll('.roll-button').forEach(e => e.dataset.label = e.textContent)); - await p.evaluate(() => document.fonts.ready); await p.waitForTimeout(300); - for (const size of [14, 15, 16]) { - for (const [k, css] of Object.entries(STYLES)) { - if ((k === 'white') !== !dark) continue; - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, - `.roll-button { font-size: ${size}px !important; } ${css || ''}`); - await p.waitForTimeout(120); - const got = await p.locator('.roll-button').first().evaluate(e => `${getComputedStyle(e).fontSize} h=${e.getBoundingClientRect().height}`); - if (DPR === 1) console.log(size, k, got); - const r = await p.locator('.roll-button').first().boundingBox(); const r2 = await p.locator('.roll-button').nth(5).boundingBox(); - await p.screenshot({ path: `round12/${size}-${k}@${DPR}.png`, clip: { x: r.x - 8, y: r.y - 8, width: r2.x + r2.width - r.x + 16, height: r.height + 16 } }); - } - } - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/capture-weights.js b/docs/design/style-guide/capture-weights.js deleted file mode 100644 index 76ae42410..000000000 --- a/docs/design/style-guide/capture-weights.js +++ /dev/null @@ -1,72 +0,0 @@ -// Header tab and page-title weights, captured from the running site with real -// Open Sans 400-700 loaded. Each variant only overrides font-weight on the -// element in question; nothing else changes. No source changes. -// -// NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt NODE_PATH=$(npm root -g) \ -// node docs/design/style-guide/capture-weights.js -// (see capture-bold.js for why fonts are fetched on the Node side) - -const { chromium } = require('playwright'); -const fs = require('fs'); -const path = require('path'); - -const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; -const OUT = process.argv[2] || 'weight-shots'; -const findChrome = () => { - const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers'; - const dir = fs.readdirSync(base).filter(d => d.startsWith('chromium-') && !d.includes('headless')).sort().pop(); - return path.join(base, dir, 'chrome-linux', 'chrome'); -}; -const FONTS = 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=block'; -const VARIANTS = { - tabs: { sel: '.header-tab', weights: [400, 500, 600, 700], clip: 'header' }, - title: { sel: 'h1.f-s-36', weights: [400, 600, 700], clip: 'title' } -}; - -(async () => { - fs.mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch({ executablePath: findChrome() }); - for (const theme of ['dark', 'light']) { - const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme + '-theme'); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => { try { const resp = await r.fetch(); if (resp.status() !== 200) console.log("font", resp.status(), r.request().url()); await r.fulfill({ response: resp }); } catch (e) { console.log("fontfetch", String(e).slice(0,160)); await r.abort(); } }); - const page = await ctx.newPage(); - await page.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'networkidle' }); - const c = page.locator('#cookie-btn'); - if (await c.count()) await c.click({ timeout: 3000 }).catch(() => {}); - await page.addStyleTag({ content: '.whats-new-backdrop { display: none !important; }' }); - await page.evaluate(href => new Promise((ok, no) => { - const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.onload = ok; l.onerror = no; - document.head.appendChild(l); - }), FONTS); - await page.evaluate(() => Promise.all(['400', '500', '600', '700'].map(w => document.fonts.load(`${w} 14px "Open Sans"`)))); - await page.mouse.move(5, 890); - for (const [name, { sel, weights, clip }] of Object.entries(VARIANTS)) { - for (const w of weights) { - await page.evaluate(([s, css]) => { document.querySelectorAll('style[data-w]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.w = '1'; e.textContent = css; document.head.appendChild(e); }, - [null, `${sel}, ${sel} * { font-weight: ${w} !important; }`]); - await page.waitForTimeout(150); - const box = clip === 'header' - ? await page.locator('.header-tab').first().evaluate(el => { const r = el.parentElement.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }) - : await page.locator('h1.f-s-36').first().boundingBox(); - await page.screenshot({ path: path.join(OUT, `${theme}-${name}-${w}.png`), - clip: { x: Math.max(0, box.x - 10), y: Math.max(0, box.y - 6), width: Math.min(1280, box.width + 20), height: box.height + 12 } }); - } - } - // the pair together, in context: [tabs, title] - for (const [tw, hw] of [[400, 400], [500, 600], [700, 600]]) { - await page.evaluate(css => { document.querySelectorAll('style[data-w]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.w = '1'; e.textContent = css; document.head.appendChild(e); }, - `.header-tab, .header-tab * { font-weight: ${tw} !important; } h1.f-s-36 { font-weight: ${hw} !important; }`); - await page.waitForTimeout(150); - const top = await page.locator('.header-tab').first().evaluate(el => el.parentElement.getBoundingClientRect().y); - const h1 = await page.locator('h1.f-s-36').first().boundingBox(); - await page.screenshot({ path: path.join(OUT, `${theme}-pair-${tw}-${hw}.png`), - clip: { x: 0, y: Math.max(0, top - 10), width: 1280, height: (h1.y + h1.height + 30) - Math.max(0, top - 10) } }); - } - await ctx.close(); - console.log(' ' + theme + ' done'); - } - await browser.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/measure-tab-room.js b/docs/design/style-guide/measure-tab-room.js deleted file mode 100644 index 1796d3cc7..000000000 --- a/docs/design/style-guide/measure-tab-room.js +++ /dev/null @@ -1,36 +0,0 @@ -const { chromium } = require('playwright'); -const WRAP = { - 'before (400, integration)': '.header-tab, .header-tab * { font-weight: 400 !important; }', - 'as is (700)': '', - 'letter spacing -2%': '.header-tab .title { letter-spacing: -0.02em; }', - 'letter spacing -4%': '.header-tab .title { letter-spacing: -0.04em; }', - '13px': '.header-tab { font-size: 13px !important; }', - '600 weight': '.header-tab, .header-tab * { font-weight: 600 !important; }' -}; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - await p.goto('http://localhost:8890/pages/dnd/5e/character-builder', { waitUntil: 'networkidle' }); - await p.evaluate(() => document.fonts.ready); - for (const [name, css] of Object.entries(WRAP)) { - await p.evaluate(c => { document.querySelectorAll('style[data-x]').forEach(e => e.remove()); - const e = document.createElement('style'); e.dataset.x = 1; e.textContent = c; document.head.appendChild(e); }, css); - await p.waitForTimeout(80); - const r = await p.evaluate(() => { - const t = [...document.querySelectorAll('.header-tab .title')].find(e => /my content/i.test(e.textContent)); - const box = t.parentElement; // the padded area the title sits in - const cs = getComputedStyle(box); - const avail = box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); - // natural one-line width of the label - const probe = t.cloneNode(true); probe.style.cssText += ';position:absolute;visibility:hidden;white-space:nowrap;width:auto'; - t.parentElement.appendChild(probe); const need = probe.getBoundingClientRect().width; probe.remove(); - const tab = t.closest('.header-tab').getBoundingClientRect().width; - return { tab: Math.round(tab), avail: Math.round(avail * 10) / 10, need: Math.round(need * 10) / 10 }; - }); - console.log(`${name.padEnd(27)} tab ${r.tab}px, room ${r.avail}px, label needs ${r.need}px -> ${r.avail - r.need >= 0 ? 'fits, ' + (r.avail - r.need).toFixed(1) + 'px spare' : 'wraps, ' + (r.need - r.avail).toFixed(1) + 'px short'}`); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/survey-amber.js b/docs/design/style-guide/survey-amber.js deleted file mode 100644 index 1114dd962..000000000 --- a/docs/design/style-guide/survey-amber.js +++ /dev/null @@ -1,48 +0,0 @@ -// Every element with text sitting on an amber/orange surface (solid or gradient), -// grouped by class, across the main pages, in both themes. -const { chromium } = require('playwright'); -const PAGES = ['/pages/dnd/5e/character-builder', '/dnd/5e/my-content', '/pages/dnd/5e/spells', - '/pages/dnd/5e/monsters', '/pages/dnd/5e/magic-items', '/pages/dnd/5e/characters', - '/pages/dnd/5e/encounter-builder', '/pages/dnd/5e/combat-tracker', '/pages/login-page', '/pages/register-page', '/']; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - for (const theme of ['dark-theme', 'light-theme']) { - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } }); - await ctx.addInitScript(t => { try { localStorage.setItem('user', `{:theme "${t}"}`); } catch (_) {} }, theme); - const p = await ctx.newPage(); - const found = new Map(); - for (const url of PAGES) { - await p.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }).catch(() => {}); - await p.waitForTimeout(800); - const rows = await p.evaluate(() => { - const amber = s => { - const m = s.match(/rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)/g) || []; - return m.some(c => { const [r,g,b,a] = c.match(/[\d.]+/g).map(Number); - return (a === undefined || a > 0.6) && r > 190 && g > 120 && g < 200 && b < 110; }); - }; - const out = []; - for (const el of document.querySelectorAll('body *')) { - if (!el.offsetParent) continue; - const s = getComputedStyle(el); - if (!(amber(s.backgroundColor) || amber(s.backgroundImage))) continue; - const text = (el.innerText || '').trim().replace(/\s+/g, ' '); - if (!text) continue; - const tc = getComputedStyle(el).color; - out.push({ key: `${el.tagName.toLowerCase()}.${[...el.classList].join('.') || '(no class)'}${el.getAttribute('style') ? ' +inline style' : ''}`, - text: text.slice(0, 24), color: tc }); - } - return out; - }); - for (const r of rows) { - const k = r.key; if (!found.has(k)) found.set(k, { n: 0, pages: new Set(), ex: r.text, color: r.color }); - const f = found.get(k); f.n++; f.pages.add(url); - } - } - console.log(`== ${theme}`); - for (const [k, f] of [...found.entries()].sort((a, b) => b[1].n - a[1].n)) - console.log(` ${String(f.n).padStart(3)} ${k.slice(0, 70)} text ${f.color} e.g. "${f.ex}" on ${[...f.pages].join(' ')}`); - await ctx.close(); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); diff --git a/docs/design/style-guide/survey-weights.js b/docs/design/style-guide/survey-weights.js deleted file mode 100644 index af3d1f365..000000000 --- a/docs/design/style-guide/survey-weights.js +++ /dev/null @@ -1,41 +0,0 @@ -// For each page, every visible text element rendered at weight >= 600: its text, size, -// weight, and where the weight comes from (class, inline style, or a stylesheet rule). -const { chromium } = require('playwright'); -const PAGES = [['/pages/dnd/5e/character-builder','builder'], ['/dnd/5e/my-content','mycontent'], - ['/pages/dnd/5e/spells','spells'], ['/pages/dnd/5e/monsters','monsters']]; -(async () => { - const fs=require('fs'); const d=fs.readdirSync('/opt/pw-browsers').filter(x=>x.startsWith('chromium-')&&!x.includes('headless')).pop(); - const b = await chromium.launch({ executablePath: `/opt/pw-browsers/${d}/chrome-linux/chrome` }); - const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } }); - await ctx.route(/fonts\.(googleapis|gstatic)\.com/, async r => r.fulfill({ response: await r.fetch() })); - const p = await ctx.newPage(); - for (const [url, name] of PAGES) { - await p.goto('http://localhost:8890' + url, { waitUntil: 'networkidle' }); - await p.evaluate(() => document.fonts.ready); - const rows = await p.evaluate(() => { - const out = new Map(); - for (const el of document.querySelectorAll('body *')) { - const own = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent.trim()).join(' ').trim(); - if (!own || !el.offsetParent) continue; - const s = getComputedStyle(el); const w = +s.fontWeight; if (w < 600) continue; - const r = el.getBoundingClientRect(); if (r.top > 900 || r.width === 0) continue; - // where does the weight come from? - let src = 'inherited'; - for (let e = el; e && e !== document.body; e = e.parentElement) { - if (e.style && e.style.fontWeight) { src = `inline on <${e.tagName.toLowerCase()}>`; break; } - const cls = [...e.classList].find(c => /^f-w-/.test(c)); - if (cls) { src = `.${cls} on <${e.tagName.toLowerCase()}>`; break; } - if (/^H[1-6]$/.test(e.tagName)) { src = `<${e.tagName.toLowerCase()}> default`; break; } - if (e.tagName === 'BUTTON' || e.tagName === 'B' || e.tagName === 'STRONG') { src = `<${e.tagName.toLowerCase()}>`; break; } - } - const key = `${parseFloat(s.fontSize)}px ${w} ${s.textTransform === 'uppercase' ? 'CAPS' : 'mixed'} | ${src}`; - if (!out.has(key)) out.set(key, []); - const list = out.get(key); if (list.length < 3) list.push(own.slice(0, 40)); - } - return [...out.entries()].sort((a, b) => parseFloat(b[0]) - parseFloat(a[0])); - }); - console.log(`== ${name}`); - for (const [k, ex] of rows) console.log(` ${k} e.g. ${JSON.stringify(ex)}`); - } - await b.close(); -})().catch(e => { console.error(e); process.exit(1); }); From b7fcba1adaf2481fd7e3684c9bb4d924f00524a6 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 12:45:36 +0000 Subject: [PATCH 54/91] Fit all seven header tabs on a 320px phone Each phone tab kept the 10px side padding of its icon box and so could not shrink below 44px: the row came to 352px on a 320px screen, and the last tab (My Content) and its menu ran 22px off the right edge. On phones the tabs now drop that side padding and may shrink, so they share the row evenly; 390px and up look as before. The header-menus probe now checks every tab and every tapped-open menu is on screen on an iPhone SE and an iPhone 13. Fail state, before the fix: "iPhone SE (320px): every header tab is on screen - tab 7 298-342" and "menu 7 is on screen - 219-342", 48/50. After: 50/50. --- src/clj/orcpub/styles/core.clj | 7 +++++-- test/browser/header_menus_e2e.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 0725ec80d..772788f3c 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1819,11 +1819,14 @@ {:text-shadow text-lift-dark} [:.title {:letter-spacing "-0.04em"}]] ;; The tabs are spaced by the row's gap, not their own margins; on a phone the - ;; row's padding is the 10px gutter. + ;; row's padding is the 10px gutter, and the tabs share the row with no side + ;; padding of their own, so all seven fit a 320px screen. [:.app-header-menu {:gap "4px" :padding "0 2px"} - [:&.phone-tabs {:padding "0 10px"}]] + [:&.phone-tabs {:padding "0 10px"} + [:.header-tab {:min-width 0} + [:>.p-10 {:padding-left 0 :padding-right 0}]]]] ;; The header bar's search and login boxes, and the desktop slot left of the ;; tabs that holds the supporter link. diff --git a/test/browser/header_menus_e2e.js b/test/browser/header_menus_e2e.js index f51ceb5fe..9158a1dd2 100644 --- a/test/browser/header_menus_e2e.js +++ b/test/browser/header_menus_e2e.js @@ -174,6 +174,34 @@ const check = (name, ok, detail = '') => { } await short.close(); } + + // Phones: every tab, and each menu opened by tapping it, sits inside the screen. At 320px + // the seven tabs once could not shrink below their icon's padding and the last ran off it. + const { devices } = require('playwright'); + for (const device of ['iPhone SE', 'iPhone 13']) { + const phone = await browser.newContext({ ...devices[device] }); + await suppressCookieBanner(phone); + const p = await phone.newPage(); + await p.goto(`${BASE}/pages/dnd/5e/character-builder`, { waitUntil: 'domcontentloaded' }); + await p.waitForSelector('#app-main', { timeout: 30000 }); + await p.waitForTimeout(1500); + const width = p.viewportSize().width; + const off = await p.evaluate(() => [...document.querySelectorAll('.header-tab')] + .map((t, i) => [i + 1, t.getBoundingClientRect()]) + .filter(([, r]) => r.left < 0 || r.right > innerWidth).map(([i, r]) => `tab ${i} ${Math.round(r.left)}-${Math.round(r.right)}`)); + check(`${device} (${width}px): every header tab is on screen`, off.length === 0, off.join(', ')); + const tabs = p.locator('.header-tab:has(.header-flyout)'); + for (let i = 0, n = await tabs.count(); i < n; i++) { + await tabs.nth(i).focus(); + await p.waitForTimeout(200); + const r = await tabs.nth(i).locator('.header-flyout').evaluate(f => { + const b = f.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.right)]; + }); + check(`${device} (${width}px): menu ${i + 1} is on screen`, r[0] >= 0 && r[1] <= width, `${r[0]}-${r[1]}`); + await p.evaluate(() => document.activeElement.blur()); + } + await phone.close(); + } await browser.close(); const failed = results.filter(r => !r.ok); From 05c78177acb645c3947a703d4e34bc30ee5034b9 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 13:38:42 +0000 Subject: [PATCH 55/91] Give the tick boxes a real look: rounded, filled with the accent when ticked comps/checkbox was a Font Awesome tick on a white square built from utility classes (white, black, b-1, orange-shadow), the tick drawn in white to hide it. It is now one .checkbox in Garden: a rounded box on a dim ground that fills amber with a dark tick when checked in the dark theme, slate blue with a white tick in the light theme, and dims when disabled. The sign-up page's own variant (checkbox-border) now uses comps/labeled-checkbox; the unused .checkbox rules left in core.clj are replaced, and the dark ink on amber is a named colour (button-ink) shared with the dark-button-text rule. New capture round: checkboxes (settings row, spell-builder rows, both themes). cljs 660 tests, comment rules and the dark-button-text e2e pass. --- docs/branch-changelog.md | 2 ++ docs/design/capture/rounds.js | 18 ++++++++++ src/clj/orcpub/styles/core.clj | 60 ++++++++++++++++++------------- src/cljc/orcpub/components.cljc | 11 +++--- src/cljs/orcpub/dnd/e5/views.cljs | 6 ++-- 5 files changed, 63 insertions(+), 34 deletions(-) diff --git a/docs/branch-changelog.md b/docs/branch-changelog.md index 448fc7ead..7ff46a32a 100644 --- a/docs/branch-changelog.md +++ b/docs/branch-changelog.md @@ -54,6 +54,8 @@ Scope notes for review: - **Accounts:** site emails greet you by your preferred name, or by your username when you have not set one, instead of "Hi there". - **Characters:** the builder's tab bar wraps on narrow screens. +- **Site:** tick boxes are rounded and fill with the theme's accent when ticked (amber in the dark + theme, blue in the light), and the sign-up page's updates box matches the rest. ## Fixed diff --git a/docs/design/capture/rounds.js b/docs/design/capture/rounds.js index 18427b88c..502a62de9 100644 --- a/docs/design/capture/rounds.js +++ b/docs/design/capture/rounds.js @@ -649,6 +649,24 @@ module.exports = { } }, + checkboxes: { + out: 'style-guide/checkboxes', what: "the builder's setting boxes and the spell builder's components, checked and not, both themes", + async run(cap) { + for (const [theme, darkText] of [['dark', false], ['dark', true], ['light', false]]) { + const tag = `${theme}${darkText ? '-darktext' : ''}`; + const v = await cap.open(BUILDER, { theme, darkText, dpr: 3 }); + await v.shoot(`${tag}-settings`, await v.clipAround(v.page.locator('.setting-toggle').first().locator('..'), 8)); + await v.go('/pages/dnd/5e/spell-builder'); + // tick the first of each row, so both states show side by side + for (const t of ['verbal', 'bard']) await v.page.getByText(t, { exact: true }).first().click(); + await v.page.mouse.move(1, 1); await v.page.waitForTimeout(150); + for (const [name, t] of [['components', 'verbal'], ['classes', 'bard']]) + await v.shoot(`${tag}-${name}`, await v.clipAround(v.page.getByText(t, { exact: true }).first().locator('xpath=ancestor::div[contains(@class,"flex-wrap")][1]'), 8)); + await v.close(); + } + } + }, + // --- artist pages --- 'artist-pages': { diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 772788f3c..c4cc7853c 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -28,6 +28,7 @@ (def broken-red "#e5637a") ; broken severity: invalid / unexportable data (def light-text "#363636") ; body text in the light theme (def slate-blue "#33658A") ; the light theme's accent, where the dark theme uses orange +(def button-ink "#15202e") ; dark text on amber: buttons with dark text on, a checked box (def orange-hairline "rgba(240,161,0,0.16)") ; orange border on dark portrait panels ;; Portrait surfaces: the artist pages here, the compositor drawer in portrait.cljs. @@ -1794,7 +1795,7 @@ ;; is 7.8:1. Dark theme only; the light theme's slate buttons pass in white. [".app.dark-button-text:not(.light-theme)" [:.form-button :.mc-btn :.roll-button - {:color "#15202e" + {:color button-ink :text-shadow text-lift-light}] ;; the ability buttons are short numbers: a stronger glow and a yellow rim to ;; hold them up. paint-order draws the rim behind the letters, not over them. @@ -2398,20 +2399,41 @@ :padding "11px 0" :align-items :center}] + ;; comps/checkbox: a rounded box that fills with the accent and shows a dark tick + ;; when checked (amber in the dark theme, slate blue in the light one). [:.checkbox - {:width "16px" + {:display :inline-flex + :align-items :center + :justify-content :center + :box-sizing :border-box + :width "16px" :height "16px" - :box-shadow "0 1px 0 0 #f0a100" - :background-color :white - :cursor :pointer} - - [:.fa-check - {:font-size "14px" - :margin "1px"}]] - - [:.checkbox.checked.disabled - {:background-color "rgba(255, 255, 255, 0.37)" - :cursor :not-allowed}] + :flex-shrink 0 + :border "1.5px solid rgba(255,255,255,0.55)" + :border-radius "4px" + :background-color "rgba(0,0,0,0.25)" + :color :transparent + :font-size "10px" + :line-height 1 + :vertical-align :middle + :cursor :pointer + :transition "background-color 0.12s, border-color 0.12s"} + [:&:hover {:border-color orange}] + [:&.checked + {:background-color orange + :border-color orange + :color button-ink}] + [:&.disabled + {:opacity 0.5 + :cursor :not-allowed}]] + [".app.light-theme .checkbox" + {:border-color "rgba(0,0,0,0.35)" + :background-color :white} + [:&:hover {:border-color slate-blue}] + [:&.checked + {:background-color slate-blue + :border-color slate-blue + :color :white}]] [:.checkbox-text {:margin-left "5px"}] @@ -2441,10 +2463,7 @@ [:.checkbox {:width "14px" :height "14px" - :min-width "14px" - :flex-shrink 0} - [:.fa-check - {:font-size "12px"}]] + :font-size "9px"}] [:.flex.pointer {:align-items :center :gap "8px"}]] @@ -3454,13 +3473,6 @@ {:padding "20px 5px" :background-color "rgba(0,0,0,0.15)"}] - [:.checkbox-border - {:margin-top "-3px" - :border-color "#f0a100" - :border-style :solid - :border-width "1px" - :border-bottom-width "3px"}] - [:.close-btn-posn {:right "8px" :top "8px"}] diff --git a/src/cljc/orcpub/components.cljc b/src/cljc/orcpub/components.cljc index eecbb3277..30a037d5c 100644 --- a/src/cljc/orcpub/components.cljc +++ b/src/cljc/orcpub/components.cljc @@ -2,12 +2,11 @@ (:require [clojure.string :as s] #?(:cljs [reagent.core :refer [atom]]))) -(defn checkbox [selected? disable?] - [:i.fa.fa-check.f-s-14.bg-white.b-color-gray.orange-shadow.pointer.b-1 - {:class (str (if selected? "black slight-text-shadow" "white transparent") - " " - (when disable? - "opacity-5"))}]) +(defn checkbox + "A tick box, checked when `selected?` and dimmed when `disable?` (.checkbox in core.clj)." + [selected? disable?] + [:i.fa.fa-check.checkbox + {:class (str (when selected? "checked") (when disable? " disabled"))}]) (defn labeled-checkbox [label selected? disabled? on-click] [:div.flex.pointer diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 609c3834d..bc32ccdbd 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -1238,10 +1238,8 @@ :on-password (fn [e] (dispatch [:registration-password (event-value e)])) :on-confirm (fn [e] (dispatch [:registration-verify-password (event-value e)]))}] [:div.m-t-20.t-a-l.m-l-15 - [:i.fa.fa-check.f-s-14.pointer.checkbox-border - {:class (if send-updates? "orange" "white") - :on-click #(dispatch [:registration-send-updates? (not send-updates?)])}] - [:span.m-l-5 (str "Yes! Send me updates about " branding/app-name)]] + [comps/labeled-checkbox (str "Yes! Send me updates about " branding/app-name) send-updates? false + #(dispatch [:registration-send-updates? (not send-updates?)])]] ] :tail [:div From fc3d660135e0898e9020b57ca59ac2fa6bc32957 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Tue, 13 Oct 2026 13:50:04 +0000 Subject: [PATCH 56/91] Centre the tick in the tick box: draw it instead of using a font glyph The tick was Font Awesome's check character. Flex centring centres a glyph's text box, not its ink, and the check's ink sits off that box's middle, so the tick read low and to one side. It is now drawn: an L of two borders turned 45deg, in currentColor, nudged up 15% of its height so its ink is centred (measured from an 8x render: 0.492 of the box on both axes, within one device pixel at 2x). --- src/clj/orcpub/styles/core.clj | 22 ++++++++++++++-------- src/cljc/orcpub/components.cljc | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index c4cc7853c..4f9b1a38c 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -2400,7 +2400,9 @@ :align-items :center}] ;; comps/checkbox: a rounded box that fills with the accent and shows a dark tick - ;; when checked (amber in the dark theme, slate blue in the light one). + ;; when checked (amber in the dark theme, slate blue in the light one). The tick is + ;; drawn (an L of two borders, turned 45deg), not a font glyph, so it sits dead + ;; centre: a glyph centres its text box, not its ink. [:.checkbox {:display :inline-flex :align-items :center @@ -2412,17 +2414,22 @@ :border "1.5px solid rgba(255,255,255,0.55)" :border-radius "4px" :background-color "rgba(0,0,0,0.25)" - :color :transparent - :font-size "10px" - :line-height 1 + :color button-ink :vertical-align :middle :cursor :pointer :transition "background-color 0.12s, border-color 0.12s"} [:&:hover {:border-color orange}] [:&.checked {:background-color orange - :border-color orange - :color button-ink}] + :border-color orange} + [:&:after + {:content "\"\"" + :width "28%" + :height "55%" + :border-style :solid + :border-color :currentColor + :border-width "0 2px 2px 0" + :transform "translateY(-15%) rotate(45deg)"}]] [:&.disabled {:opacity 0.5 :cursor :not-allowed}]] @@ -2462,8 +2469,7 @@ {:background-color "rgba(255,255,255,0.08)"}] [:.checkbox {:width "14px" - :height "14px" - :font-size "9px"}] + :height "14px"}] [:.flex.pointer {:align-items :center :gap "8px"}]] diff --git a/src/cljc/orcpub/components.cljc b/src/cljc/orcpub/components.cljc index 30a037d5c..4964943cf 100644 --- a/src/cljc/orcpub/components.cljc +++ b/src/cljc/orcpub/components.cljc @@ -5,7 +5,7 @@ (defn checkbox "A tick box, checked when `selected?` and dimmed when `disable?` (.checkbox in core.clj)." [selected? disable?] - [:i.fa.fa-check.checkbox + [:span.checkbox {:class (str (when selected? "checked") (when disable? " disabled"))}]) (defn labeled-checkbox [label selected? disabled? on-click] From 8f087ce6f0a7c45ee88375bb6c003fb6448e15f1 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 14 Oct 2026 15:16:57 +0000 Subject: [PATCH 57/91] Changelog: phone header fix under Fixed, drop the operator-only line; capture tool notes Summer Patch gains a Fixed line for the 320px header (the bug is on integration) and loses the artist-page switch, which only an operator sees. The capture library's header names where shots really go, and the usage line no longer hard-codes this sandbox's CA bundle path. --- CHANGELOG.md | 2 +- docs/design/capture/capture.js | 3 ++- docs/design/capture/lib.js | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f6f0ea45..3a097f9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,6 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte three small watermarked example portraits and the links they listed, and a page listing every artist. - **Characters:** the portrait credit links to the artist's page. -- **Site:** an artist's page can be turned off in the deployment's portrait-artist settings. ### Changed @@ -92,6 +91,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte ### Fixed +- **Site:** all seven header tabs and their menus fit on a 320px phone. - **Homebrew:** a custom element with a blank or symbol-only name no longer breaks the character. (#675) - **Homebrew:** older spell names (Leomund's, Tasha's, Bigby's) match in imported packs. (#676) - **Homebrew:** a homebrew class's source label no longer corrupts saved spell selections. (#670) diff --git a/docs/design/capture/capture.js b/docs/design/capture/capture.js index 694928eee..1f6c0cac8 100644 --- a/docs/design/capture/capture.js +++ b/docs/design/capture/capture.js @@ -1,6 +1,7 @@ // Run design captures against the app on ORCPUB_BASE (default http://localhost:8890): -// NODE_PATH=$(npm root -g) NODE_EXTRA_CA_CERTS=/root/.ccr/ca-bundle.crt node docs/design/capture/capture.js ... +// NODE_PATH=$(npm root -g) node docs/design/capture/capture.js ... // With no round, lists them. OUT=dir writes there instead of beside the round's review page. +// Behind a TLS-inspecting proxy, point NODE_EXTRA_CA_CERTS at its CA bundle so fonts load. const path = require('path'); const { Capture } = require('./lib'); const rounds = require('./rounds'); diff --git a/docs/design/capture/lib.js b/docs/design/capture/lib.js index 843a48f95..7ff26be6f 100644 --- a/docs/design/capture/lib.js +++ b/docs/design/capture/lib.js @@ -1,6 +1,7 @@ // What every design capture shares: a browser, and a view of one app page that is themed, has // its fonts and no overlays, and can be restyled, measured and shot. Rounds (rounds.js) use only -// this. Shots go under OUT (default docs/design/capture/out/), which git ignores. +// this. Shots go beside the round's review page (its `out`, under docs/design/, where git ignores +// images), or under OUT when set. require('../../../test/browser/lib/suppress-overlays-preload.js'); const { chromium, devices } = require('playwright'); const { findChrome } = require('../../../test/browser/lib/find-chrome.js'); From 254969b9e62a5449a6fd1ef696ad2fb7b8431b17 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 14 Oct 2026 15:20:44 +0000 Subject: [PATCH 58/91] Art mark: put the JPEG comment after the APPn segments, so JFIF stays valid The comment went straight after start-of-image, ahead of JFIF's APP0, and the JDK's JPEG reader refuses that the moment metadata is read. It now goes after any leading APPn segments. Fail state, before the fix: ERROR in (a-marked-example-still-decodes-with-its-metadata) javax.imageio.IIOException: JFIF APP0 must be first marker after SOI FAIL in (a-jpeg-keeps-its-app-segments-first) expected: (= (concat [255 216] app0 app1 [255 254]) (take 14 marked)) After: art-mark tests 13 tests, 0 failures; cljs suite green. --- src/cljc/orcpub/art_mark.cljc | 21 +++++++++++++++------ test/clj/orcpub/art_mark_drawing_test.clj | 11 +++++++++++ test/cljc/orcpub/art_mark_test.cljc | 7 +++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/cljc/orcpub/art_mark.cljc b/src/cljc/orcpub/art_mark.cljc index 7a039beae..a153575e2 100644 --- a/src/cljc/orcpub/art_mark.cljc +++ b/src/cljc/orcpub/art_mark.cljc @@ -148,19 +148,28 @@ (copy! b ihdr-end out (+ ihdr-end (alength chunk-bytes)) (- (alength b) ihdr-end)) out)) +(defn- after-app-segments + "The offset in JPEG bytes `b` just past start-of-image and any APPn segments that follow it." + [b] + (loop [i 2] + (if (and (<= (+ i 4) (alength b)) (= 0xFF (u8 b i)) (<= 0xE0 (u8 b (inc i)) 0xEF)) + (recur (+ i 2 (* 256 (u8 b (+ i 2))) (u8 b (+ i 3)))) + i))) + (defn- jpeg-with-mark - "JPEG bytes `b` with the mark `text` as a comment segment straight after the start-of-image - marker; the compressed image data is untouched." + "JPEG bytes `b` with the mark `text` as a comment segment after the APPn segments (JFIF's APP0 + must stay first); the compressed image data is untouched." [b text] (let [payload (text-bytes (str mark-keyword "\u0000" text)) len (+ 2 (alength payload)) _ (when (> len 0xFFFF) (throw (ex-info "art mark too long for a JPEG comment" {:length len}))) + at (min (after-app-segments b) (alength b)) out (new-bytes (+ (alength b) 2 len))] - (copy! b 0 out 0 2) + (copy! b 0 out 0 at) (doseq [[i v] (map-indexed vector [0xFF 0xFE (bit-and (bit-shift-right len 8) 0xff) (bit-and len 0xff)])] - (put! out (+ 2 i) v)) - (copy! payload 0 out 6 (alength payload)) - (copy! b 2 out (+ 6 (alength payload)) (- (alength b) 2)) + (put! out (+ at i) v)) + (copy! payload 0 out (+ at 4) (alength payload)) + (copy! b at out (+ at 4 (alength payload)) (- (alength b) at)) out)) (defn write-mark diff --git a/test/clj/orcpub/art_mark_drawing_test.clj b/test/clj/orcpub/art_mark_drawing_test.clj index 96d710782..05433819f 100644 --- a/test/clj/orcpub/art_mark_drawing_test.clj +++ b/test/clj/orcpub/art_mark_drawing_test.clj @@ -83,6 +83,17 @@ "licensed; composed from ") "the small watermarked JPEG carries the mark like every other drawing"))) +;; the JDK's JPEG reader insists on JFIF APP0 straight after start-of-image when it reads metadata +(deftest a-marked-example-still-decodes-with-its-metadata + (with-marked-art + #(let [jpeg (pr/render-example-jpeg (portrait) "w") + reader (.next (ImageIO/getImageReadersByFormatName "jpeg"))] + (is (some? (mark/read-mark jpeg))) + (with-open [in (ImageIO/createImageInputStream (ByteArrayInputStream. jpeg))] + (.setInput reader in) + (is (some? (.getImageMetadata reader 0)) "metadata reads without an APP0 complaint") + (is (pos? (.getWidth (.read reader 0)))))))) + (deftest strand-files-from-marked-art-are-marked (let [dir (.toFile (Files/createTempDirectory "strands" (make-array java.nio.file.attribute.FileAttribute 0))) f (io/file dir "bangs" "l9_bangs_01.png")] diff --git a/test/cljc/orcpub/art_mark_test.cljc b/test/cljc/orcpub/art_mark_test.cljc index 5f244a2c5..24998cf81 100644 --- a/test/cljc/orcpub/art_mark_test.cljc +++ b/test/cljc/orcpub/art_mark_test.cljc @@ -40,6 +40,13 @@ (is (nil? (mark/read-mark (bytes-of tiny-jpeg)))) (is (= (ints-of marked) (ints-of (mark/write-mark marked "other"))) "marking twice adds nothing"))) +(deftest a-jpeg-keeps-its-app-segments-first + ;; JFIF needs its APP0 straight after start-of-image, so the comment goes after any APPn + (let [app0 [255 224 0 4 74 70] app1 [255 225 0 2] + marked (ints-of (mark/write-mark (bytes-of (concat [255 216] app0 app1 [255 217])) "x"))] + (is (= (concat [255 216] app0 app1 [255 254]) (take 14 marked)) "APP0 and APP1 untouched, then the comment") + (is (= "x" (mark/read-mark (bytes-of marked)))))) + (deftest an-unmarkable-format-is-refused-not-skipped (is (thrown? #?(:clj Exception :cljs js/Error) (mark/write-mark (bytes-of [71 73 70 56 57 97]) "x")) "a GIF cannot hold the mark, so writing one fails loudly")) From 27df1516e47dac9fa0f30d42b904e788a6f4aa11 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Wed, 14 Oct 2026 15:23:09 +0000 Subject: [PATCH 59/91] Escape the page title: an artist's name never becomes page source Hiccup escapes attribute values, so the share tags were safe, but the text went in raw, and an artist page's title carries the artist's name (any page's title can carry a name a person typed). Fail state, before the fix: FAIL in (an-artist-name-is-never-page-source) expected: (not (s/includes? body "<b>MARK</b>")) After: artist-page-test 9 tests, 0 failures. --- src/clj/orcpub/index.clj | 4 +++- test/clj/orcpub/artist_page_test.clj | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index cc8f5bf72..7e5deccf8 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -1,5 +1,6 @@ (ns orcpub.index (:require [hiccup.page :refer [html5 include-css]] + [hiccup.util :refer [escape-html]] [cheshire.core :as cheshire] [orcpub.oauth :as oauth] [orcpub.fork.branding :as branding] @@ -245,7 +246,8 @@ html { min-height: 100%; }"] [:style spinner/css] - [:title title] + ;; text, unlike the meta attributes, is not escaped by hiccup; a title can carry a name + [:title (escape-html title)] (integrations/head-tags nonce) (script-tag {:nonce nonce} (str "window.__BRANDING__=" (script-json (branding/client-config)) ";" diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj index 62eb16ac5..e2d7a2e5e 100644 --- a/test/clj/orcpub/artist_page_test.clj +++ b/test/clj/orcpub/artist_page_test.clj @@ -87,3 +87,13 @@ (is (not (s/includes? (:body (routes/artist-page (req "fusspot"))) "Fusspot")) "and the 404's share tags do not name them") (finally (pa/set-artist-overrides! {})))) + +(deftest an-artist-name-is-never-page-source + (testing "a name with markup in it appears escaped in the title and share tags, never as tags" + (try + (pa/set-artist-overrides! {:house-pack {:artist/name "<b>MARK</b>"}}) + (let [{:keys [status body]} (routes/artist-page (req "fusspot"))] + (is (= 200 status)) + (is (not (s/includes? body "<b>MARK</b>")) "the raw markup is nowhere in the page") + (is (s/includes? body "<b>MARK</b>") "the name is there, escaped")) + (finally (pa/set-artist-overrides! {}))))) From 943725160811ed845ffa28836dbaf2dfca4a2511 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 15:28:01 +0000 Subject: [PATCH 60/91] Artist-page examples: draw through the portrait pool, single-flight, bounded, with a failure wait The examples were kept in a defonce atom that only grew (its key holds the watermark text, which every link edit changes), concurrent misses each drew their own copy, a failure was redrawn on every request, nothing bounded how many drew at once, and a fresh container watermarked with the registry's links instead of the artist's own edits. Now: drawn through portrait-store/draw-pool (503 + Retry-After when busy), single-flight and kept in a bounded cache of 64, a failure remembered for 60 s, and artist-credit/refresh! before the watermark is worked out. Fail state, before the fix (artist-page-test): FAIL a-failed-render-waits-before-it-is-tried-again: (nil? ...) and (= 1 @calls) FAIL examples-render-through-the-draw-pool: (= 503 (:status resp)), Retry-After FAIL examples-use-the-current-credits: (= :the-db @refreshed) FAIL concurrent-requests-share-one-render: (= 1 @calls) FAIL the-kept-examples-are-bounded: (<= (cache-size 'example-jpegs) 64) 7 failures. After: 13 tests, 0 failures. --- docs/PORTRAIT-SERVING.md | 9 ++++ src/clj/orcpub/portrait_store.clj | 8 ++-- src/clj/orcpub/routes.clj | 59 +++++++++++++++++------ test/clj/orcpub/artist_page_test.clj | 70 ++++++++++++++++++++++++++-- 4 files changed, 124 insertions(+), 22 deletions(-) diff --git a/docs/PORTRAIT-SERVING.md b/docs/PORTRAIT-SERVING.md index 1f7b9b54f..53b4b2c68 100644 --- a/docs/PORTRAIT-SERVING.md +++ b/docs/PORTRAIT-SERVING.md @@ -72,6 +72,15 @@ image someone uploads, and the shared picture carries the artist's name. When a portrait changes, its fingerprint and so its address change: chat apps that cached the old picture under the old address fetch the new one, and nothing has to be purged. +## Artist-page examples + +`/artists/<slug>/examples/<n>` draws its small watermarked JPEG through the same portrait pool +(`portrait-store/draw-pool`), single-flight and kept in a bounded cache keyed by artist, example and +watermark text (the text comes from the artist's links, so an edit makes new keys and old ones age +out). A failed draw is remembered for 60 s, so a broken example is not redrawn on every request. A +busy pool answers 503 + Retry-After. The credits are refreshed first, so a fresh container +watermarks with the artist's current links. + ## Strand fields The build step writes `<piece>.strands.png` beside each hair piece. Without it (running from diff --git a/src/clj/orcpub/portrait_store.clj b/src/clj/orcpub/portrait_store.clj index de149761f..e3ddd96e5 100644 --- a/src/clj/orcpub/portrait_store.clj +++ b/src/clj/orcpub/portrait_store.clj @@ -118,9 +118,9 @@ ;; Drawing ;; --------------------------------------------------------------------------- -(def ^:private pool - "Portraits drawn at once, sized by ORCPUB_PORTRAIT_CONCURRENCY. Each draw is about half a second - of one core; seeded at that." +(def draw-pool + "Portraits drawn at once, sized by ORCPUB_PORTRAIT_CONCURRENCY: the stored drawings here and the + artist pages' examples. Each draw is about half a second of one core; seeded at that." (work-pool/pool {:size #(config/get-portrait-concurrency) :timeout-ms #(config/get-portrait-queue-timeout-ms) :seed-ms 500})) @@ -139,7 +139,7 @@ drawn [id fp] #(or (read-stored id fp) (let [{:keys [result busy]} - (work-pool/run pool (fn [] (portrait-render/render-png (char5e/parse-portrait stored))))] + (work-pool/run draw-pool (fn [] (portrait-render/render-png (char5e/parse-portrait stored))))] (cond busy (throw (ex-info "portrait draw busy" {::busy busy})) result (do (try (write-stored! id fp result) (catch Exception e diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 358180318..8ca71f362 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -2526,39 +2526,70 @@ (artist-profile/example-path artist 1))}) (assoc (index-page-response request {:title "No such artist"}) :status 404))) -(defonce ^:private example-jpegs (atom {})) +(def ^:private example-jpegs + "Rendered examples by [artist n watermark]. Single-flight, so a burst of requests for a new page + shares one render; bounded, since every edit to an artist's links makes new watermark keys." + (bounded-cache/cache {:max-entries 64})) + +(def ^:private example-failures + "Examples that failed to render, remembered for a minute so a broken one is not redrawn on every + request." + (bounded-cache/cache {:max-entries 64 :max-age-ms 60000})) (defn- example-jpeg - "JPEG bytes of example `n` (1-based) for `artist-id`, watermarked with `text`; nil when - there is none. A success is rendered once per process and then served from memory; a - failure is not kept, so the next request tries again." + "JPEG bytes of example `n` (1-based) for `artist-id`, watermarked with `text`: nil when there is + none or it failed within the last minute, {:busy retry-seconds} when no draw slot frees. Drawn + once through the portrait draw pool and kept." [artist-id n text] (let [k [artist-id n text]] - (or (get @example-jpegs k) - (when-let [jpeg (some-> (artist-profile/example-portraits artist-id) - (get (dec n)) - (portrait-render/render-example-jpeg text))] - (swap! example-jpegs assoc k jpeg) - jpeg)))) + (when-not (bounded-cache/lookup example-failures k) + (try + (bounded-cache/fetch! + example-jpegs k + #(let [{:keys [result busy]} + (work-pool/run portrait-store/draw-pool + (fn [] (some-> (artist-profile/example-portraits artist-id) + (get (dec n)) + (portrait-render/render-example-jpeg text))))] + (cond busy (throw (ex-info "example draw busy" {::busy busy})) + result result + :else (throw (ex-info "no example" {::failed true}))))) + (catch clojure.lang.ExceptionInfo e + (let [{::keys [busy]} (ex-data e)] + (if busy + {:busy busy} + (do (bounded-cache/put! example-failures k true) nil)))) + (catch Exception e + (println "artist example" k "failed -" (.getMessage e)) + (bounded-cache/put! example-failures k true) + nil))))) (defn artist-example-jpeg "Ring handler: example image :n (1 to example-count) of the artist at :slug, small, - flattened and watermarked. 404 for an unknown slug, a page turned off, or any other :n." - [{{:keys [slug n]} :path-params}] + flattened and watermarked. 404 for an unknown slug, a page turned off, or any other :n; + 503 with a Retry-After when every draw slot is taken." + [{:keys [db] {:keys [slug n]} :path-params}] + ;; before the watermark is worked out from the artist's links + (when db (artist-credit/refresh! db)) (let [artist (artist-profile/artist-by-slug slug) n (some->> n (re-matches #"[1-9]") parse-long) jpeg (when (and artist n (<= n artist-profile/example-count)) (example-jpeg (:artist/id artist) n (artist-profile/watermark-text artist (or (portrait-render/site-mark) branding/app-name))))] - (if jpeg + (cond + (:busy jpeg) + {:status 503 :headers {"Retry-After" (str (:busy jpeg)) "Content-Type" "text/plain"} :body "busy"} + + jpeg {:status 200 :headers {"Content-Type" "image/jpeg" "Cache-Control" "public, max-age=86400" ;; contributed artwork -- see character-portrait-png "X-Robots-Tag" "noai, noimageai"} :body (ByteArrayInputStream. jpeg)} - {:status 404 :body "no such example"}))) + + :else {:status 404 :body "no such example"}))) (def header-style {:style "color:#2c3445"}) diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj index e2d7a2e5e..343c52079 100644 --- a/test/clj/orcpub/artist_page_test.clj +++ b/test/clj/orcpub/artist_page_test.clj @@ -6,7 +6,10 @@ [orcpub.routes :as routes] [orcpub.dnd.e5.artist-profile :as ap] [orcpub.dnd.e5.portrait-assets :as pa] - [orcpub.portrait-render :as render]) + [orcpub.portrait-render :as render] + [orcpub.artist-credit :as artist-credit] + [orcpub.bounded-cache :as bounded-cache] + [orcpub.work-pool :as work-pool]) (:import (javax.imageio ImageIO))) (defn- req [slug] @@ -57,19 +60,78 @@ (is (= (seq (body-bytes (example "fusspot" "2"))) (seq (body-bytes (example "fusspot" "2"))))))) -(deftest a-failed-render-is-tried-again - (testing "a render that fails is not kept: the next request renders again, and a success is kept" +(defn- cache-of + "The routes cache named `sym` (examples kept, or failures remembered)." + [sym] + (some-> (ns-resolve 'orcpub.routes sym) deref)) + +(defn- clear-cache! + "Empty the routes cache named `sym`; anything that is not a bounded cache is left as it is." + [sym] + (let [c (cache-of sym)] (when (map? c) (bounded-cache/clear! c)))) + +(defn- cache-size + "How many entries the routes cache named `sym` holds." + [sym] + (let [c (cache-of sym)] (if (map? c) (bounded-cache/size c) (count @c)))) + +(defn- fresh-examples! + "Forget every kept example and failure, so a test starts from nothing." + [] + (clear-cache! 'example-jpegs) + (clear-cache! 'example-failures)) + +(deftest a-failed-render-waits-before-it-is-tried-again + (testing "a failure is remembered for a short while, so a broken example is not re-rendered on + every request; once that passes it is tried again, and a success is kept" + (fresh-examples!) (let [calls (atom 0)] (with-redefs [ap/example-portraits (fn [_] [{:layers {}}]) render/render-example-jpeg (fn [_ _] (swap! calls inc) (when (> @calls 1) (byte-array [1 2 3])))] (is (nil? (#'routes/example-jpeg :cache-test-artist 1 "t")) "the first render fails") + (is (nil? (#'routes/example-jpeg :cache-test-artist 1 "t")) "and is not retried at once") + (is (= 1 @calls)) + (clear-cache! 'example-failures) (is (= [1 2 3] (seq (#'routes/example-jpeg :cache-test-artist 1 "t"))) - "the next request renders again instead of serving the failure") + "after the wait it renders again") (#'routes/example-jpeg :cache-test-artist 1 "t") (is (= 2 @calls) "and the success is kept"))))) +(deftest concurrent-requests-share-one-render + (fresh-examples!) + (let [calls (atom 0)] + (with-redefs [ap/example-portraits (fn [_] [{:layers {}}]) + render/render-example-jpeg (fn [_ _] (swap! calls inc) (Thread/sleep 300) (byte-array [7]))] + (let [fs (doall (repeatedly 4 #(future (#'routes/example-jpeg :flight-test 1 "t"))))] + (is (every? #(= [7] (seq @%)) fs)) + (is (= 1 @calls) "four requests at once, one render"))))) + +(deftest the-kept-examples-are-bounded + (testing "the key includes the watermark text, so every link edit makes new keys; old ones go" + (fresh-examples!) + (with-redefs [ap/example-portraits (fn [_] [{:layers {}}]) + render/render-example-jpeg (fn [_ _] (byte-array [1]))] + (doseq [i (range 200)] (#'routes/example-jpeg :bound-test 1 (str "text " i))) + (is (<= (cache-size 'example-jpegs) 64))))) + +(deftest examples-render-through-the-draw-pool + (testing "when no draw slot frees, the example answers 503 with a Retry-After instead of queueing" + (fresh-examples!) + (with-redefs [work-pool/run (fn [_ _] {:busy 4 :waiting 9})] + (let [resp (example "fusspot" "1")] + (is (= 503 (:status resp))) + (is (= "4" (get-in resp [:headers "Retry-After"]))))))) + +(deftest examples-use-the-current-credits + (testing "a fresh container reads the artists' own credit edits before drawing the watermark" + (fresh-examples!) + (let [refreshed (atom nil)] + (with-redefs [artist-credit/refresh! (fn [db] (reset! refreshed db))] + (routes/artist-example-jpeg {:db :the-db :path-params {:slug "fusspot" :n "1"}}) + (is (= :the-db @refreshed)))))) + (deftest there-are-only-three-examples (doseq [n ["0" "4" "x" "1.jpg" nil]] (is (= 404 (:status (example "fusspot" n))) (pr-str n)))) From b9ab23221ec97e1856a524dcc1b3bde1b198eef5 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 15:36:41 +0000 Subject: [PATCH 61/91] Artist links: https only from any source, marked as user-supplied, shown by their real domain Links from the deployment's PORTRAIT_ARTISTS settings skipped the rule artist edits are saved under: web-url? let plain http and odd host characters through. It now is that rule (orcpub.artist-links/url-problem) plus no whitespace, read on the encoded host, and the views check it again where a link is drawn, through one helper, external-link. Every external link opens with rel="noopener noreferrer nofollow ugc". An international host is shown and linked in punycode, so lookalike letters cannot pass for another site, and the artist page's buttons name the domain each link opens. Fail state, before the fix: artist-profile-test (external-link stubbed as the views wrote anchors): FAIL only-https-links-reach-the-page: http://, a backslash host, listed http link FAIL an-international-domain-is-shown-encoded: url and link-host not in punycode FAIL every-external-link-is-marked-as-someone-elses: rel, javascript:, http:// 8 failures cljs every-link-on-the-page-is-https-marked-and-names-its-domain: FAIL rel is not "noopener noreferrer nofollow ugc"; FAIL no .ap-link-host After: artist-profile-test 26 tests, 0 failures; cljs 665 tests, 0 failures. --- src/clj/orcpub/styles/core.clj | 2 ++ src/cljc/orcpub/dnd/e5/artist_profile.cljc | 32 +++++++++++++---- src/cljs/orcpub/dnd/e5/portrait.cljs | 36 +++++++++---------- src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 20 ++++++----- .../orcpub/dnd/e5/artist_profile_test.cljc | 18 ++++++++++ .../dnd/e5/artist_page_credits_test.cljs | 14 ++++++++ 6 files changed, 89 insertions(+), 33 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 4f9b1a38c..8d1bbbe05 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1713,6 +1713,8 @@ [".ap-link:hover, .ap-link:focus-visible" {:border-color orange :background "rgba(240,161,0,0.06)" :outline :none}] [".ap-link .lk-ico" {:width "14px" :height "14px"}] + ;; the domain a link really opens, so a label cannot pass for another site + [:.ap-link-host {:font-weight 400 :letter-spacing 0 :color "var(--lk-dim)"}] [(str light " .ap-link") {:border-color "rgba(0,0,0,0.14)" :background :white :color light-text}] [(str light " .ap-link:hover, " light " .ap-link:focus-visible") diff --git a/src/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc index a9e8e3d3c..dc9e26ae7 100644 --- a/src/cljc/orcpub/dnd/e5/artist_profile.cljc +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -6,6 +6,7 @@ `example-portraits`, `listed-links`, `profile-link`. Plan and decisions: docs/design/artist-profiles/PLAN.md." (:require [clojure.string :as s] + [orcpub.artist-links :as artist-links] [orcpub.route-map :as route-map] [orcpub.dnd.e5.portrait-assets :as pa] [orcpub.dnd.e5.portrait-random :as random])) @@ -74,10 +75,21 @@ [artist n] (route-map/path-for route-map/artist-example-route :slug (slug artist) :n (str n))) +(defn ascii-url + "`url` with an international host in its encoded (punycode) form, so lookalike letters show as + what they are: https://ex\u0430mple.test/ is https://xn--exmple-4nf.test/. Anything else as given." + [url] + (let [u (some-> url str s/trim)] + (if-let [[_ head host tail] (some->> u (re-find #"^(https?://)([^/?#:]+)(.*)$"))] + (if (re-find #"[^\x00-\x7F]" host) + (str head #?(:clj (java.net.IDN/toASCII host) :cljs (.-hostname (js/URL. (str "https://" host)))) tail) + u) + u))) + (defn link-host - "The host of `url` without scheme, www. or trailing path: \"fusspot.rip\". nil if none." + "The host of `url` without scheme, www. or trailing path, encoded: \"fusspot.rip\". nil if none." [url] - (some->> url (re-find #"(?i)^https?://(?:www\.)?([^/?#]+)") second not-empty)) + (some->> (ascii-url url) (re-find #"(?i)^https?://(?:www\.)?([^/?#]+)") second not-empty)) (defn watermark-text "What is tiled across `artist`'s example images: the host of their own link (else their @@ -121,11 +133,18 @@ ;; ---------- links ---------- (defn web-url? - "Whether `u` is an http(s) address with no userinfo. Everything else -- a - mailto:, a javascript:, a bare address -- is not something this page links - to. `https://example.test/@name` passes: the @ is in the path." + "Whether `u` is a link this site will draw: the rule artist edits are saved under + (orcpub.artist-links/url-problem), https only, hostname characters only, no userinfo, its host + read encoded, no whitespace. A mailto:, a javascript:, plain http and a bare address are not." [u] - (boolean (and (string? u) (re-find #"(?i)^https?://[^\s@/?#]+(?:[/?#]\S*)?$" u)))) + (and (string? u) (not (re-find #"\s" (s/trim u))) (nil? (artist-links/url-problem (ascii-url u))))) + +(defn external-link + "Anchor attributes for a link to someone else's site, or nil when `url` is not one this site + draws: a new tab, no opener or referrer, and marked as user-supplied for search engines." + [url] + (when (web-url? url) + {:href (ascii-url url) :target "_blank" :rel "noopener noreferrer nofollow ugc"})) (defn listed-links "The links the artist listed, and only those: :artist/links, or their single @@ -139,6 +158,7 @@ [{:link/label "Site" :link/icon "site" :link/url l}]))] (->> links (filter (comp web-url? :link/url)) + (map #(update % :link/url ascii-url)) (reduce (fn [[seen out] l] (if (seen (:link/url l)) [seen out] diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index aa84579f1..2aa80109f 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -2077,13 +2077,10 @@ a.lk-name:active { filter: var(--lk-halo-hot); } 34) (defn- artist-ref - "An artist's name, as a link where they have one." + "An artist's name, as a link where they have one this site draws." [{:keys [:artist/name :artist/link]}] - (if link - [:a.pl-thumb-credit-link - {:href link :target "_blank" :rel "noopener" - :on-click #(.stopPropagation %)} - name] + (if-let [attrs (artist-profile/external-link link)] + [:a.pl-thumb-credit-link (assoc attrs :on-click #(.stopPropagation %)) name] [:span name])) (defn- linked-credit @@ -2135,18 +2132,21 @@ a.lk-name:active { filter: var(--lk-halo-hot); } The label is the accessible name: an icon alone is unreadable to a screen reader, and a link with no icon falls back to the label as text." [artist-name {:link/keys [label url icon color]}] - [:a.lk-mark {:href url :target "_blank" :rel "noopener" - :title (str artist-name " on " label) - :aria-label (str artist-name " on " label) - :style (when color {:color color})} - (if icon - [:span.lk-ico {:style {:WebkitMaskImage (str "url(/image/social/" icon ".svg)") - :maskImage (str "url(/image/social/" icon ".svg)")}}] - [:span.lk-mark-text (s/lower-case label)])]) - -(defn- credit-name [{:keys [:artist/name :artist/link]}] - (if link - [:a.lk-name {:href link :target "_blank" :rel "noopener"} name] + (when-let [attrs (artist-profile/external-link url)] + [:a.lk-mark (merge attrs + {:title (str artist-name " on " label " (" (artist-profile/link-host url) ")") + :aria-label (str artist-name " on " label) + :style (when color {:color color})}) + (if icon + [:span.lk-ico {:style {:WebkitMaskImage (str "url(/image/social/" icon ".svg)") + :maskImage (str "url(/image/social/" icon ".svg)")}}] + [:span.lk-mark-text (s/lower-case label)])])) + +(defn- credit-name + "The artist's name in the credit, a link to where they chose when it is one this site draws." + [{:keys [:artist/name :artist/link]}] + (if-let [attrs (artist-profile/external-link link)] + [:a.lk-name attrs name] [:span.lk-name name])) (defn- rule [side] [:span.lk-rule {:class side :aria-hidden true}]) diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs index 0f3504829..a002a50aa 100644 --- a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -22,16 +22,18 @@ body)])) (defn- link-button - "One of the artist's listed links as a button: its mark, if it has one, and its label. - Opens in a new tab." + "One of the artist's listed links as a button: its mark, if it has one, its label and the domain + it really goes to. Opens in a new tab; nothing for a link this site does not draw." [{:link/keys [label url icon color]}] - [:a.ap-link {:href url :target "_blank" :rel "noopener"} - (when icon - [:span.lk-ico {:aria-hidden true - :style {:color color - :WebkitMaskImage (str "url(/image/social/" icon ".svg)") - :maskImage (str "url(/image/social/" icon ".svg)")}}]) - label]) + (when-let [attrs (ap/external-link url)] + [:a.ap-link attrs + (when icon + [:span.lk-ico {:aria-hidden true + :style {:color color + :WebkitMaskImage (str "url(/image/social/" icon ".svg)") + :maskImage (str "url(/image/social/" icon ".svg)")}}]) + label + [:span.ap-link-host (ap/link-host url)]])) (defn- names "The names of `artists` as prose: A / A and B / A, B and C." diff --git a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc index 5bbd882ae..7cf6dea65 100644 --- a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc +++ b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc @@ -125,6 +125,24 @@ (is (not (ap/web-url? "https://example.test/ spaced"))) (is (not (ap/web-url? nil)))) +(deftest only-https-links-reach-the-page + (testing "the rule artist edits follow holds for links from the deployment's settings too" + (is (not (ap/web-url? "http://example.test/")) "plain http can be rewritten on the way") + (is (not (ap/web-url? "https://evil.test\\x.twitch.tv/")) "hostname characters only") + (is (= [] (ap/listed-links {:artist/link "http://example.test/"}))))) + +(deftest an-international-domain-is-shown-encoded + (testing "lookalike letters cannot pass for another site: the host is shown in punycode" + (let [[l] (ap/listed-links {:artist/link "https://ex\u0430mple.test/shop"})] + (is (= "https://xn--exmple-4nf.test/shop" (:link/url l))) + (is (= "xn--exmple-4nf.test" (ap/link-host (:link/url l))))))) + +(deftest every-external-link-is-marked-as-someone-elses + (is (= {:href "https://example.test/" :target "_blank" :rel "noopener noreferrer nofollow ugc"} + (ap/external-link "https://example.test/"))) + (is (nil? (ap/external-link "javascript:alert(1)")) "not a web address: no link at all") + (is (nil? (ap/external-link "http://example.test/")) "checked again where it is drawn")) + (deftest the-links-are-the-ones-listed-and-nothing-else (let [fusspot (ap/artist-by-slug "fusspot")] (is (= ["https://fusspot.rip/" "https://www.twitch.tv/fusspot"] diff --git a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs index 38b2aca5c..037b57580 100644 --- a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs +++ b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs @@ -55,3 +55,17 @@ (r/flush) (is (nil? (.querySelector el "h1.ap-name")) "the profile is gone") (is (some? (.querySelector el ".ap-missing")) "and the page says nobody is here")))) + +(deftest every-link-on-the-page-is-https-marked-and-names-its-domain + (with-page [artist-page/artist-page {:slug "fusspot"}] + (fn [el] + (dispatch-sync [:portrait/artists-loaded + {"house-pack" {:artist/links [{:link/label "Shop" :link/url "https://exаmple.test/shop"} + {:link/label "Old" :link/url "http://example.test/"}]}}]) + (r/flush) + (let [links (array-seq (.querySelectorAll el "a.ap-link"))] + (is (= ["https://xn--exmple-4nf.test/shop"] (map #(.getAttribute % "href") links)) + "the plain-http link is not drawn, the other goes to its encoded host") + (is (every? #(= "noopener noreferrer nofollow ugc" (.getAttribute % "rel")) links)) + (is (= "xn--exmple-4nf.test" (some-> (.querySelector el "a.ap-link .ap-link-host") .-textContent)) + "the button shows the real domain"))))) From fdf8b480619797486935322fa672e9fccc4365be Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 15:41:08 +0000 Subject: [PATCH 62/91] Artist credits: one saved change a minute, since each one emails the admin A burst of edits sent the admin a burst of emails. save! now refuses a change within a minute of the account's last one ({:wait seconds}); the route answers 429 with a Retry-After and a message the account page shows. A save that changes nothing, or has errors, is answered as before. Fail state, before the fix (artist-credit-test): FAIL saving-changes-is-limited-to-one-a-minute: (= 429 (:status second-save)), Retry-After, the message, (= 1 (count @told)) 4 failures. After: 46 tests across the artist suites, 0 failures. --- src/clj/orcpub/artist_credit.clj | 23 +++++++++++++++++++++-- src/clj/orcpub/routes.clj | 7 +++++-- src/cljs/orcpub/dnd/e5/events.cljs | 2 +- test/clj/orcpub/artist_credit_test.clj | 22 +++++++++++++++++++++- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/clj/orcpub/artist_credit.clj b/src/clj/orcpub/artist_credit.clj index 8e2d0a837..8839fc867 100644 --- a/src/clj/orcpub/artist_credit.clj +++ b/src/clj/orcpub/artist_credit.clj @@ -24,6 +24,7 @@ [datomic.api :as d] [orcpub.fork.branding :as branding] [orcpub.artist-links :as links] + [orcpub.bounded-cache :as bounded-cache] [orcpub.dnd.e5.portrait-assets :as pa])) ;; --------------------------------------------------------------------------- @@ -136,9 +137,25 @@ :own own :locked (set (keys config))})))) +(def save-gap-ms + "How long after a saved change an artist waits before saving another: each one emails the admin." + 60000) + +(def ^:private recent-saves + "When each account last saved a change, kept for `save-gap-ms`." + (bounded-cache/cache {:max-entries 1024 :max-age-ms save-gap-ms})) + +(defn- wait-secs + "Whole seconds until `user-id` may save another change, or nil when they may now." + [user-id] + (when-let [[at] (bounded-cache/lookup recent-saves user-id)] + (let [left (- (+ at save-gap-ms) (System/currentTimeMillis))] + (when (pos? left) (long (Math/ceil (/ left 1000.0))))))) + (defn save! - "Save `edit` as the credit for the artist `user-id` speaks for. Returns - {:errors ..}, {:unchanged true}, or {:saved credit :before old :artist-id id}." + "Save `edit` as the credit for the artist `user-id` speaks for. Returns {:errors ..}, + {:unchanged true}, {:wait seconds} when they saved a change under a minute ago, or + {:saved credit :before old :artist-id id}." [conn user-id edit] (let [db (d/db conn) {:orcpub.user/keys [artist artist-credit]} @@ -150,9 +167,11 @@ (cond errors {:errors errors} (= (not-empty credit) before) {:unchanged true} + (wait-secs user-id) {:wait (wait-secs user-id)} :else (do @(d/transact conn [(if (seq credit) {:db/id user-id :orcpub.user/artist-credit (pr-str credit)} [:db/retract user-id :orcpub.user/artist-credit artist-credit])]) + (bounded-cache/put! recent-saves user-id (System/currentTimeMillis)) (refresh! (d/db conn)) {:saved credit :before before :artist-id artist})))))) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 8ca71f362..22830eec5 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -856,16 +856,19 @@ (defn update-artist-credit "PUT /user/artist-credit -- an artist edits how they're credited. The - artist and the admin both hear about every change." + artist and the admin both hear about every change, so changes are limited to one a minute + (429 with a Retry-After)." [{:keys [transit-params db conn identity]}] (let [user (find-user-by-username db (:user identity)) id (:db/id user)] (if-not id {:status 400 :body {:error "User not found"}} - (let [{:keys [errors unchanged saved before artist-id]} + (let [{:keys [errors unchanged wait saved before artist-id]} (artist-credit/save! conn id (select-keys transit-params [:name :link :links]))] (cond errors {:status 400 :body {:errors errors}} + wait {:status 429 :headers {"Retry-After" (str wait)} + :body {:error (str "You saved a change a moment ago. Try again in " wait " seconds.")}} unchanged {:status 200 :body (artist-credit-body (d/db conn) id)} :else (do (future (artist-email/credit-changed! {:artist-id artist-id :user user diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 3319f3da5..1f49b5708 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -2178,7 +2178,7 @@ {:status :error :errors (get-in response [:body :errors]) :error (when-not (get-in response [:body :errors]) - "Couldn't save that. Please try again.")}))) + (or (get-in response [:body :error]) "Couldn't save that. Please try again."))}))) (reg-event-db :clear-account-save-status diff --git a/test/clj/orcpub/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index 9b8732975..cf73cfef5 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -2,6 +2,7 @@ (:require [clojure.test :refer [deftest is testing use-fixtures]] [datomic.api :as d] [orcpub.artist-credit :as ac] + [orcpub.bounded-cache :as bounded-cache] [orcpub.fork.branding :as branding] [orcpub.dnd.e5.portrait-assets :as pa] [orcpub.db.schema :as schema] @@ -20,6 +21,9 @@ (use-fixtures :each (fn [t] (let [before (pa/current-overrides)] + ;; every test's in-memory database hands out the same ids, so one test's save must not + ;; count against the next + (bounded-cache/clear! @#'ac/recent-saves) (try (t) (finally (pa/set-artist-overrides! before)))))) (defn- add-artist! [conn username artist] @@ -95,7 +99,8 @@ (is (= "https://fuss.example/" (:artist/link (pa/artist-info :house-pack)))) (is (= #{:artist/name} (:locked (ac/credit-for-account (d/db conn) id))) "the settings page can say which fields the site has fixed"))) - (testing "clearing the edit goes back to the code's default" + (testing "clearing the edit (a minute later) goes back to the code's default" + (bounded-cache/clear! @#'ac/recent-saves) (ac/save! conn id {}) (is (= "Fusspot" (:artist/name (pa/artist-info :house-pack)))))))) @@ -151,6 +156,21 @@ (is (= 1 (count @told)) "one notice per real change, none for a failed or no-op save") (is (= {:artist/name "Fuss"} (select-keys (:after (first @told)) [:artist/name])))))))) +(deftest saving-changes-is-limited-to-one-a-minute + (testing "each saved change emails the admin, so a burst of edits cannot flood their inbox" + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [told (atom [])] + (with-redefs [orcpub.artist-email/credit-changed! #(swap! told conj %) + clojure.core/future-call (fn [f] (f) (delay nil))] + (let [first-save (orcpub.routes/update-artist-credit (req conn "fusspot" {:name "Fuss One"})) + second-save (orcpub.routes/update-artist-credit (req conn "fusspot" {:name "Fuss Two"}))] + (is (= 200 (:status first-save))) + (is (= 429 (:status second-save)) "a second change straight after is refused") + (is (some? (get-in second-save [:headers "Retry-After"]))) + (is (string? (get-in second-save [:body :error])) "with a message the account page shows") + (is (= 1 (count @told)) "and only the first emailed anyone"))))))) + (deftest a-non-artist-cannot-save-a-credit (with-conn conn (add-artist! conn "someone" nil) From bd9f43fbd24c9ac46f690825892f7c3687b9ed62 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 15:47:39 +0000 Subject: [PATCH 63/91] Styles: the credit's artist-page link and the page's link marks come from Garden .lk-about's rules move out of portrait.cljs's drawer-styles string into the artist-pages Garden block. The artist page's link mark loses its inline style map: .ap-link-ico in Garden reads the mark and colour from two CSS variables (--ap-ico, --ap-ico-color), the only per-link values left on the element, since a deployment's settings may give any icon and colour. Fail state, before the fix (cljs): FAIL link-marks-are-styled-by-the-stylesheet: no .ap-link-ico class; inline properties other than variables (color, mask-image) FAIL the-credit-link-to-the-artist-page-is-styled-in-garden: .lk-about in drawer-styles After: cljs 667 tests, 0 failures; the page and credit render as before, checked in both themes. --- src/clj/orcpub/styles/core.clj | 12 ++++++++++++ src/cljs/orcpub/dnd/e5/portrait.cljs | 12 ------------ src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 8 ++++---- .../orcpub/dnd/e5/artist_page_credits_test.cljs | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 8d1bbbe05..959355873 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -1713,8 +1713,20 @@ [".ap-link:hover, .ap-link:focus-visible" {:border-color orange :background "rgba(240,161,0,0.06)" :outline :none}] [".ap-link .lk-ico" {:width "14px" :height "14px"}] + [:.ap-link-ico {:color "var(--ap-ico-color, currentColor)" + :-webkit-mask-image "var(--ap-ico)" :mask-image "var(--ap-ico)"}] ;; the domain a link really opens, so a label cannot pass for another site [:.ap-link-host {:font-weight 400 :letter-spacing 0 :color "var(--lk-dim)"}] + ;; the credit's link to these pages, at its foot: the ART BY label's size, tracking and + ;; dim colour, so it reads as the frame around the names rather than another name + [:.lk-about + {:align-self :center :margin-top "1px" + :font (str "600 8.5px/1 " open-sans) :letter-spacing "0.16em" + :text-transform :uppercase :color "var(--lk-dim)" :text-decoration :none + :padding "3px 2px" :transition "color 160ms ease"}] + [:.lk-about-arrow {:padding-left "4px" :letter-spacing 0}] + [".lk-about:hover, .lk-about:focus-visible" {:color "var(--lk-glow-text)" :outline :none}] + [:.lk-about:focus-visible {:text-decoration :underline :text-underline-offset "3px"}] [(str light " .ap-link") {:border-color "rgba(0,0,0,0.14)" :background :white :color light-text}] [(str light " .ap-link:hover, " light " .ap-link:focus-visible") diff --git a/src/cljs/orcpub/dnd/e5/portrait.cljs b/src/cljs/orcpub/dnd/e5/portrait.cljs index 2aa80109f..60ed0a3c7 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -1477,18 +1477,6 @@ a.lk-name:active { filter: var(--lk-halo-hot); } -webkit-mask-position: center; mask-position: center; } .lk-mark-text { font: 400 11px/1 'Open Sans', system-ui, sans-serif; } -/* The profile link: the foot of the credit, mirroring the ART BY label at its - head -- same size, same tracking, same dim colour -- so it reads as part of - the frame around the names rather than as another name. */ -.lk-about { - align-self: center; margin-top: 1px; - font: 600 8.5px/1 'Open Sans', system-ui, sans-serif; letter-spacing: 0.16em; - text-transform: uppercase; color: var(--lk-dim); text-decoration: none; - padding: 3px 2px; transition: color 160ms ease; -} -.lk-about-arrow { padding-left: 4px; letter-spacing: 0; } -.lk-about:hover, .lk-about:focus-visible { color: var(--lk-glow-text); outline: none; } -.lk-about:focus-visible { text-decoration: underline; text-underline-offset: 3px; } .pl-drawer-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; } diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs index a002a50aa..3a28b4bde 100644 --- a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -28,10 +28,10 @@ (when-let [attrs (ap/external-link url)] [:a.ap-link attrs (when icon - [:span.lk-ico {:aria-hidden true - :style {:color color - :WebkitMaskImage (str "url(/image/social/" icon ".svg)") - :maskImage (str "url(/image/social/" icon ".svg)")}}]) + ;; this link's own mark and colour, as the variables .ap-link-ico reads (core.clj) + [:span.lk-ico.ap-link-ico {:aria-hidden true + :style (cond-> {"--ap-ico" (str "url(/image/social/" icon ".svg)")} + color (assoc "--ap-ico-color" color))}]) label [:span.ap-link-host (ap/link-host url)]])) diff --git a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs index 037b57580..d08d870df 100644 --- a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs +++ b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs @@ -8,6 +8,7 @@ [orcpub.dnd.e5.events] [orcpub.dnd.e5.subs] [orcpub.dnd.e5.views.artist-page :as artist-page] + [orcpub.dnd.e5.portrait :as portrait] [orcpub.dnd.e5.portrait-assets :as pa])) (defn- with-page @@ -69,3 +70,17 @@ (is (every? #(= "noopener noreferrer nofollow ugc" (.getAttribute % "rel")) links)) (is (= "xn--exmple-4nf.test" (some-> (.querySelector el "a.ap-link .ap-link-host") .-textContent)) "the button shows the real domain"))))) + +(deftest link-marks-are-styled-by-the-stylesheet + (testing "a mark's own icon and colour arrive as CSS variables; the rule that uses them is Garden's" + (with-page [artist-page/artist-page {:slug "fusspot"}] + (fn [el] + (let [ico (.querySelector el "a.ap-link .lk-ico") + props (map #(.item (.-style ico) %) (range (.-length (.-style ico))))] + (is (.contains (.-classList ico) "ap-link-ico")) + (is (seq props)) + (is (every? #(.startsWith % "--") props) (str "only variables inline: " (vec props)))))))) + +(deftest the-credit-link-to-the-artist-page-is-styled-in-garden + (is (not (re-find #"\.lk-about" portrait/drawer-styles)) + "its rules live in styles/core.clj, not the drawer's style string")) From 0ddfa1014d84a01f4d9f19b7b85ff83a8b7aba42 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 15:57:51 +0000 Subject: [PATCH 64/91] Tick boxes: keyboard and screen-reader controls, a visible focus ring, a 3:1 edge comps/checkbox is now role="checkbox" with aria-checked, reached with Tab (skipped and aria-disabled when disabled), and Space or Enter click it, so the click handler its caller put on it or an ancestor runs. It takes an optional label for its accessible name; every caller passes one. comps/labeled-checkbox is one control, the row, named by its label, with the box inside drawn by the new comps/checkbox-mark (aria-hidden); the settings switches, already role="switch", use the mark too, so there is one tab stop per control. A 2px focus ring in the accent (amber, slate in the light theme). The light theme's unticked edge is muted-on-light, 4.6:1 on white; the dark theme's box is opaque, so it holds on a white panel. Fail state, before the fix: orcpub.styles.checkbox-test: FAIL the-light-themes-edge-stands-out-on-white: 2.44 >= 3.0 FAIL the-dark-themes-box-holds-on-a-white-panel: fill alpha 0.25 cljs orcpub.components-checkbox-test: 10 failures, 3 errors (no role, aria-checked, tabindex, aria-label or aria-disabled; Space and Enter did nothing; the labelled row was not a control) After: both green; cljs 670 tests, 0 failures; the dark-button-text e2e passes; in the app Space toggles a box and Tab moves on, focus ring checked in both themes. --- src/clj/orcpub/styles/core.clj | 12 +++- src/cljc/orcpub/components.cljc | 48 +++++++++++-- src/cljs/orcpub/character_builder.cljs | 8 +-- src/cljs/orcpub/dnd/e5/views.cljs | 26 ++++--- test/clj/orcpub/styles/checkbox_test.clj | 67 +++++++++++++++++++ .../cljs/orcpub/components_checkbox_test.cljs | 58 ++++++++++++++++ test/cljs/orcpub/test_runner.cljs | 2 + 7 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 test/clj/orcpub/styles/checkbox_test.clj create mode 100644 test/cljs/orcpub/components_checkbox_test.cljs diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 959355873..0024509f8 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -2427,7 +2427,8 @@ :flex-shrink 0 :border "1.5px solid rgba(255,255,255,0.55)" :border-radius "4px" - :background-color "rgba(0,0,0,0.25)" + ;; opaque, so a box on a white panel is still a dark box there, not a see-through tint + :background-color "#1b2130" :color button-ink :vertical-align :middle :cursor :pointer @@ -2447,8 +2448,15 @@ [:&.disabled {:opacity 0.5 :cursor :not-allowed}]] + ;; the keyboard's place: on the box, or on the box of a labelled row that is the control + [".checkbox:focus-visible, .checkbox-row:focus-visible .checkbox" + {:outline (str "2px solid " orange) + :outline-offset "2px"}] + [".app.light-theme .checkbox:focus-visible, .app.light-theme .checkbox-row:focus-visible .checkbox" + {:outline-color slate-blue}] + [:.checkbox-row:focus-visible {:outline :none}] [".app.light-theme .checkbox" - {:border-color "rgba(0,0,0,0.35)" + {:border-color muted-on-light :background-color :white} [:&:hover {:border-color slate-blue}] [:&.checked diff --git a/src/cljc/orcpub/components.cljc b/src/cljc/orcpub/components.cljc index 4964943cf..d8bad0ff8 100644 --- a/src/cljc/orcpub/components.cljc +++ b/src/cljc/orcpub/components.cljc @@ -2,16 +2,50 @@ (:require [clojure.string :as s] #?(:cljs [reagent.core :refer [atom]]))) -(defn checkbox - "A tick box, checked when `selected?` and dimmed when `disable?` (.checkbox in core.clj)." +(defn- press-to-click + "A keydown handler that turns Space or Enter into a click on the element, so whatever handles + its clicks (on it or an ancestor) handles the key too." + [e] + #?(:cljs (when (#{" " "Enter"} (.-key e)) + (.preventDefault e) + (.click (.-currentTarget e))) + :clj e)) + +(defn checkbox-mark + "The box drawn alone, hidden from screen readers: for a row that is itself the control (a + labelled checkbox, a settings switch). .checkbox in core.clj." [selected? disable?] [:span.checkbox - {:class (str (when selected? "checked") (when disable? " disabled"))}]) + {:class (str (when selected? "checked") (when disable? " disabled")) + :aria-hidden true}]) + +(defn checkbox + "A tick box, checked when `selected?` and dimmed when `disable?`: a checkbox to a screen reader, + named `label` when given, reached with Tab, and Space or Enter click it, so the caller's click + handler (on it or an ancestor) runs." + ([selected? disable?] (checkbox selected? disable? nil)) + ([selected? disable? label] + [:span.checkbox + (cond-> {:class (str (when selected? "checked") (when disable? " disabled")) + :role "checkbox" + :aria-checked (str (boolean selected?)) + :tab-index (if disable? -1 0)} + label (assoc :aria-label label) + disable? (assoc :aria-disabled "true") + (not disable?) (assoc :on-key-down press-to-click))])) -(defn labeled-checkbox [label selected? disabled? on-click] - [:div.flex.pointer - {:on-click on-click} - [checkbox selected? disabled?] +(defn labeled-checkbox + "`label` beside a tick box, the two one checkbox control: clicked, or Space or Enter on it, calls + `on-click`. Checked when `selected?`, dimmed and skipped when `disabled?`." + [label selected? disabled? on-click] + [:div.flex.pointer.checkbox-row + (cond-> {:on-click on-click + :role "checkbox" + :aria-checked (str (boolean selected?)) + :tab-index (if disabled? -1 0)} + disabled? (assoc :aria-disabled "true") + (not disabled?) (assoc :on-key-down press-to-click)) + [checkbox-mark selected? disabled?] [:span.m-l-5 label]]) (defn selection-item [key name selected?] diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index a616fd57b..80d4822eb 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -346,7 +346,7 @@ [:div.f-w-b.flex.align-items-c [:div.pointer.m-l-5.m-r-5 {:on-click check-fn} - (comps/checkbox equipped? false)] + (comps/checkbox equipped? false (str item-name " equipped"))] (if user-created? [comps/input-field :input @@ -731,7 +731,7 @@ [:div.flex-grow-1 [:div.flex.align-items-c (when multiselect? - [:span.m-r-5 (comps/checkbox selected? disable-checkbox?)]) + [:span.m-r-5 (comps/checkbox selected? disable-checkbox? name)]) (when icon [:div.m-r-5 (views5e/svg-icon icon 24)]) [:span.f-w-b.f-s-1.flex-grow-1 name] (when edit-event @@ -2513,7 +2513,7 @@ [:div.pointer.setting-toggle (merge {:on-click toggle-theme} (views5e/switch-attrs (= "light-theme" theme) toggle-theme)) - [:span.m-r-5 (comps/checkbox (= "light-theme" theme) false)] + [:span.m-r-5 (comps/checkbox-mark (= "light-theme" theme) false)] [:span.main-text-color "Light Theme"]])) (defn button-text-toggle @@ -2526,7 +2526,7 @@ (let [toggle #(dispatch [:toggle-dark-button-text])] [:div.pointer.setting-toggle.m-l-20 (merge {:on-click toggle} (views5e/switch-attrs on? toggle)) - [:span.m-r-5 (comps/checkbox on? false)] + [:span.m-r-5 (comps/checkbox-mark on? false)] [:span.main-text-color "Dark Button Text"]])))) (defn set-loading [] diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index bc32ccdbd..a597efa6c 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -2826,7 +2826,8 @@ ^{:key i} [:span.m-l-5 {:on-click #(dispatch [::char/toggle-spell-slot-used id level i])} - (comps/checkbox @(subscribe [::char/spell-slot-used? id level i]) false)]))]]) + (comps/checkbox @(subscribe [::char/spell-slot-used? id level i]) false + (str (common/ordinal level) " level slot " (inc i) " used"))]))]]) spell-slots))])])))) (defn dropdown [{:keys [items value on-change]}] @@ -2936,7 +2937,8 @@ selected? (and (not selected?) (or always-prepared? - (not (pos? remaining-preps))))))]) + (not (pos? remaining-preps)))) + (str (:name spell) " prepared")))]) (:name spell)] [:td.p-l-10.p-b-5.p-t-5 class] [:td.p-l-10.p-b-5.p-t-5 (when ability (s/upper-case (common/safe-name ability)))] @@ -3215,7 +3217,7 @@ ^{:key i} [:span {:on-click (toggle-feature-used-handler id units k)} - [:span.m-r-5 (comps/checkbox @(subscribe [::char/feature-used? id units k]) false)]])))] + [:span.m-r-5 (comps/checkbox @(subscribe [::char/feature-used? id units k]) false (str nm " use " (inc i)))]])))] ;; larger number of uses (let [initial-value @(subscribe [::char/feature-used-count id units nm amount])] @@ -4776,7 +4778,7 @@ (defn labeled-checkbox [label selected?] [:div.flex.align-items-c.pointer.m-b-10 - (comps/checkbox selected? false) + (comps/checkbox selected? false label) [:span.m-l-5.f-s-14 label]]) (defn with-help @@ -5401,7 +5403,7 @@ [:div [:div.flex.align-items-c.m-b-10 {:on-click (make-event-handler ::mi/toggle-attunement)} - (comps/checkbox attunement false) + (comps/checkbox attunement false "Attunement") [:span.f-s-24.f-w-b.m-l-5 "Attunement"]] (when attunement [:div @@ -9128,7 +9130,7 @@ [:div.m-r-10.pointer.m-b-10 {:on-click #(do (dispatch [::spells/toggle-spell-list key]) (dispatch [:clear-builder-field-error :spell-lists]))} - [comps/checkbox (get-in spell [:spell-lists key])] + [comps/checkbox (get-in spell [:spell-lists key]) false name] [:span.m-l-5 name]]) @(subscribe [::spells/spellcasting-classes]))]]])) @@ -10627,7 +10629,8 @@ {:on-click (fn [e] (dispatch [::char/toggle-selected id]) (.stopPropagation e))} - (comps/checkbox (get selected-ids id) false)] + (comps/checkbox (get selected-ids id) false + (str "Select " (or (::char/character-name summary) "this character")))] [:div.f-s-24.f-w-600 [:div.list-character-summary [character-summary-2 summary true owner false]]]] @@ -11231,7 +11234,8 @@ ^{:key size} [:div.p-5.pointer {:on-click (make-event-handler ::char/toggle-monster-filter-hidden :size size)} - (comps/checkbox (not @(subscribe [::char/monster-filter-hidden? :size size])) false) + (comps/checkbox (not @(subscribe [::char/monster-filter-hidden? :size size])) false + (s/capitalize (common/kw-to-name size))) (str " " (s/capitalize (common/kw-to-name size)))]) @(subscribe [::char/monster-sizes])))]] [:div.main-text-color.p-20 @@ -11243,7 +11247,8 @@ ^{:key type} [:div.p-5.pointer {:on-click (make-event-handler ::char/toggle-monster-filter-hidden :type type)} - (comps/checkbox (not @(subscribe [::char/monster-filter-hidden? :type type])) false) + (comps/checkbox (not @(subscribe [::char/monster-filter-hidden? :type type])) false + (s/capitalize (common/kw-to-name type))) (str " " (s/capitalize (common/kw-to-name type)))]) @(subscribe [::char/monster-types])))]] (let [subtypes @(subscribe [::char/monster-subtypes])] @@ -11256,7 +11261,8 @@ ^{:key subtype} [:div.p-5.pointer {:on-click (make-event-handler ::char/toggle-monster-filter-hidden :subtype subtype)} - (comps/checkbox (not @(subscribe [::char/monster-filter-hidden? :subtype subtype])) false) + (comps/checkbox (not @(subscribe [::char/monster-filter-hidden? :subtype subtype])) false + (s/capitalize (common/kw-to-name subtype))) (str " " (s/capitalize (common/kw-to-name subtype)))]) subtypes))]])]) diff --git a/test/clj/orcpub/styles/checkbox_test.clj b/test/clj/orcpub/styles/checkbox_test.clj new file mode 100644 index 000000000..43759bce4 --- /dev/null +++ b/test/clj/orcpub/styles/checkbox_test.clj @@ -0,0 +1,67 @@ +(ns orcpub.styles.checkbox-test + "The tick box's edge, read from the compiled stylesheet: unticked, it must stand out from what is + behind it by at least 3:1 (WCAG 1.4.11), in both themes and on a white panel in the dark one." + (:require [clojure.test :refer [deftest is testing]] + [clojure.string :as s] + [garden.core :as garden] + [orcpub.styles.core :as styles])) + +(def ^:private css (delay (garden/css {:pretty-print? false} styles/app))) + +(defn- rule + "The declarations of the first rule whose selector is exactly `selector`." + [selector] + (some (fn [[_ sel body]] (when (= sel selector) body)) + (re-seq #"(?:^|\})([^{}]+)\{([^{}]*)" @css))) + +(defn- decl + "The value of `prop` in `body`, or nil." + [body prop] + (some->> body (re-find (re-pattern (str "(?:^|;)" prop ":([^;]+)"))) second s/trim)) + +(defn- rgba + "[r g b a] of a CSS colour: #rgb, #rrggbb, rgb(), rgba() or white." + [c] + (let [c (s/trim c)] + (cond + (= c "white") [255 255 255 1.0] + (re-matches #"#[0-9a-fA-F]{6}" c) (conj (mapv #(Integer/parseInt (subs c % (+ % 2)) 16) [1 3 5]) 1.0) + (re-matches #"#[0-9a-fA-F]{3}" c) (conj (mapv #(* 17 (Integer/parseInt (subs c % (inc %)) 16)) [1 2 3]) 1.0) + :else (let [[r g b a] (map #(Double/parseDouble %) (re-seq #"[\d.]+" c))] [r g b (or a 1.0)])))) + +(defn- over + "Colour `top` [r g b a] painted over opaque `under` [r g b]." + [[r g b a] under] + (mapv #(+ (* a %1) (* (- 1 a) %2)) [r g b] under)) + +(defn- contrast + "WCAG contrast ratio of two opaque [r g b] colours." + [x y] + (let [lum (fn [rgb] (let [[r g b] (map #(let [v (/ % 255.0)] + (if (<= v 0.03928) (/ v 12.92) (Math/pow (/ (+ v 0.055) 1.055) 2.4))) + rgb)] + (+ (* 0.2126 r) (* 0.7152 g) (* 0.0722 b)))) + [hi lo] (sort > [(lum x) (lum y)])] + (/ (+ hi 0.05) (+ lo 0.05)))) + +(def ^:private white [255 255 255]) +(def ^:private dark-grounds + "The dark theme's grounds under a tick box: the page, a menu panel, a portrait card." + {"page" [8 10 13] "panel" [44 52 69] "card" [19 25 36]}) + +(deftest the-light-themes-edge-stands-out-on-white + (let [edge (rgba (decl (rule ".app.light-theme .checkbox") "border-color"))] + (is (>= (contrast (over edge white) white) 3.0) + (str "edge " edge " on white: " (contrast (over edge white) white))))) + +(deftest the-dark-themes-edge-stands-out-on-its-grounds + (let [body (rule ".checkbox") + edge (rgba (last (re-find #"solid (.+)$" (decl body "border"))))] + (doseq [[k ground] dark-grounds] + (is (>= (contrast (over edge ground) ground) 3.0) (str "edge on the " k))))) + +(deftest the-dark-themes-box-holds-on-a-white-panel + (testing "an opaque box, so on a white panel its fill (not a see-through tint) meets the white" + (let [fill (rgba (decl (rule ".checkbox") "background-color"))] + (is (= 1.0 (last fill)) (str "fill " fill)) + (is (>= (contrast (vec (take 3 fill)) white) 3.0))))) diff --git a/test/cljs/orcpub/components_checkbox_test.cljs b/test/cljs/orcpub/components_checkbox_test.cljs new file mode 100644 index 000000000..a1e9f72d1 --- /dev/null +++ b/test/cljs/orcpub/components_checkbox_test.cljs @@ -0,0 +1,58 @@ +(ns orcpub.components-checkbox-test + "The tick box is a keyboard control: a screen reader hears a checkbox and its state, Tab reaches + it, and Space or Enter does what a click does." + (:require [cljs.test :refer-macros [deftest is testing]] + [reagent.core :as r] + [reagent.dom :as rdom] + [orcpub.components :as comps])) + +(defn- with-rendered + "Renders `view` into a detached element, calls `f` with it, and cleans up." + [view f] + (let [el (.createElement js/document "div")] + (try + (.appendChild (.-body js/document) el) + (rdom/render view el) + (r/flush) + (f el) + (finally + (rdom/unmount-component-at-node el) + (.remove el))))) + +(defn- press! + "Dispatch a keydown of `key` on `el`." + [el key] + (.dispatchEvent el (js/KeyboardEvent. "keydown" #js {:key key :bubbles true :cancelable true}))) + +(deftest a-box-is-a-checkbox-you-can-reach-and-press + (let [clicks (atom 0)] + (with-rendered [:div {:on-click #(swap! clicks inc)} [comps/checkbox true false "Ritual"]] + (fn [el] + (let [box (.querySelector el ".checkbox")] + (is (= "checkbox" (.getAttribute box "role"))) + (is (= "true" (.getAttribute box "aria-checked"))) + (is (= "0" (.getAttribute box "tabindex")) "Tab reaches it") + (is (= "Ritual" (.getAttribute box "aria-label"))) + (press! box " ") + (press! box "Enter") + (is (= 2 @clicks) "Space and Enter click it, so the caller's handler runs")))))) + +(deftest a-disabled-box-is-skipped-and-says-so + (with-rendered [comps/checkbox false true] + (fn [el] + (let [box (.querySelector el ".checkbox")] + (is (= "false" (.getAttribute box "aria-checked"))) + (is (= "true" (.getAttribute box "aria-disabled"))) + (is (= "-1" (.getAttribute box "tabindex"))))))) + +(deftest a-labelled-box-is-one-control-named-by-its-label + (let [clicks (atom 0)] + (with-rendered [comps/labeled-checkbox "Verbal" false false #(swap! clicks inc)] + (fn [el] + (let [row (.querySelector el "[role=checkbox]")] + (is (some? row)) + (is (= "Verbal" (.-textContent row)) "named by its own label") + (is (= "0" (.getAttribute row "tabindex"))) + (is (= 1 (.-length (.querySelectorAll el "[tabindex='0']"))) "one stop, not two") + (press! row "Enter") + (is (= 1 @clicks))))))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index eb1f9fc9a..d7756a90e 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -14,6 +14,7 @@ ;; the artist pages' examples are drawn on the server and captioned here [orcpub.dnd.e5.artist-profile-test] [orcpub.dnd.e5.artist-page-credits-test] + [orcpub.components-checkbox-test] [orcpub.dnd.e5.portrait-colorize-test] [orcpub.dnd.e5.portrait-effects-test] [orcpub.dnd.e5.portrait-face-test] @@ -66,6 +67,7 @@ 'orcpub.dnd.e5.portrait-layout-test 'orcpub.dnd.e5.artist-profile-test 'orcpub.dnd.e5.artist-page-credits-test + 'orcpub.components-checkbox-test 'orcpub.dnd.e5.portrait-colorize-test 'orcpub.dnd.e5.portrait-effects-test 'orcpub.dnd.e5.portrait-face-test 'orcpub.dnd.e5.portrait-light-test 'orcpub.artist-links-test From 40127a6464c5af6c6367e7158a03687c47dfeee2 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 16:00:52 +0000 Subject: [PATCH 65/91] Header menus probe: a phone menu that did not open fails, not passes A menu that never opened measures 0-0, which passed as "on screen". The check now needs the menu to have a width. The baseline counts the probe's 50 checks (the phone checks added 20), and the comment says what the check is rather than how the bug was. Fail state, with tap-to-open removed from the stylesheet (restored with git): before the fix: PASS iPhone SE (320px): menu 1 is on screen - 0-0 after the fix: FAIL iPhone SE (320px): menu 1 opens on screen - did not open 36/50 checks passed Restored: 50/50 checks passed; the runner records 50 checks. --- scripts/test/probe-baseline.json | 2 +- test/browser/header_menus_e2e.js | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index 4d9e710eb..772bcce60 100644 --- a/scripts/test/probe-baseline.json +++ b/scripts/test/probe-baseline.json @@ -29,7 +29,7 @@ "seconds": 34 }, "header_menus_e2e.js": { - "checks": 30, + "checks": 50, "seconds": 50 }, "homebrew_safety_net_e2e.js": { diff --git a/test/browser/header_menus_e2e.js b/test/browser/header_menus_e2e.js index 9158a1dd2..b72751748 100644 --- a/test/browser/header_menus_e2e.js +++ b/test/browser/header_menus_e2e.js @@ -175,8 +175,8 @@ const check = (name, ok, detail = '') => { await short.close(); } - // Phones: every tab, and each menu opened by tapping it, sits inside the screen. At 320px - // the seven tabs once could not shrink below their icon's padding and the last ran off it. + // Phones: every tab, and each menu opened by tapping it, sits inside the screen. A menu that + // did not open measures 0-0, so it has to have a width to count. const { devices } = require('playwright'); for (const device of ['iPhone SE', 'iPhone 13']) { const phone = await browser.newContext({ ...devices[device] }); @@ -195,9 +195,10 @@ const check = (name, ok, detail = '') => { await tabs.nth(i).focus(); await p.waitForTimeout(200); const r = await tabs.nth(i).locator('.header-flyout').evaluate(f => { - const b = f.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.right)]; + const b = f.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.right), b.width]; }); - check(`${device} (${width}px): menu ${i + 1} is on screen`, r[0] >= 0 && r[1] <= width, `${r[0]}-${r[1]}`); + check(`${device} (${width}px): menu ${i + 1} opens on screen`, r[2] > 0 && r[0] >= 0 && r[1] <= width, + r[2] > 0 ? `${r[0]}-${r[1]}` : 'did not open'); await p.evaluate(() => document.activeElement.blur()); } await phone.close(); From 319f4500f40c1118974da62038496b5277e2ecad Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 16:12:21 +0000 Subject: [PATCH 66/91] Verification pass: key the credit save gap by connection; a renamed pool in one test The full suite found two things the targeted runs did not. The save gap was keyed by account id alone, and every in-memory test database hands out the same ids, so a save in one test file counted against the next (portrait-artists-endpoint-test: "the credit is saved" failed); it is now keyed by [connection account], one and the same in production. portrait-og-test reached the draw pool by its old private name. One unused refer in the new checkbox test. lein test: 1026 tests, 0 failures. cljs: 670 tests, 0 failures. clj-kondo on the changed files: 0 errors. --- src/clj/orcpub/artist_credit.clj | 12 ++++++------ test/clj/orcpub/artist_credit_test.clj | 3 --- test/clj/orcpub/portrait_og_test.clj | 2 +- test/cljs/orcpub/components_checkbox_test.cljs | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/clj/orcpub/artist_credit.clj b/src/clj/orcpub/artist_credit.clj index 8839fc867..92f74779c 100644 --- a/src/clj/orcpub/artist_credit.clj +++ b/src/clj/orcpub/artist_credit.clj @@ -142,13 +142,13 @@ 60000) (def ^:private recent-saves - "When each account last saved a change, kept for `save-gap-ms`." + "When each account last saved a change, by [connection account], kept for `save-gap-ms`." (bounded-cache/cache {:max-entries 1024 :max-age-ms save-gap-ms})) (defn- wait-secs - "Whole seconds until `user-id` may save another change, or nil when they may now." - [user-id] - (when-let [[at] (bounded-cache/lookup recent-saves user-id)] + "Whole seconds until account `user-id` on `conn` may save another change, or nil when they may now." + [conn user-id] + (when-let [[at] (bounded-cache/lookup recent-saves [conn user-id])] (let [left (- (+ at save-gap-ms) (System/currentTimeMillis))] (when (pos? left) (long (Math/ceil (/ left 1000.0))))))) @@ -167,11 +167,11 @@ (cond errors {:errors errors} (= (not-empty credit) before) {:unchanged true} - (wait-secs user-id) {:wait (wait-secs user-id)} + (wait-secs conn user-id) {:wait (wait-secs conn user-id)} :else (do @(d/transact conn [(if (seq credit) {:db/id user-id :orcpub.user/artist-credit (pr-str credit)} [:db/retract user-id :orcpub.user/artist-credit artist-credit])]) - (bounded-cache/put! recent-saves user-id (System/currentTimeMillis)) + (bounded-cache/put! recent-saves [conn user-id] (System/currentTimeMillis)) (refresh! (d/db conn)) {:saved credit :before before :artist-id artist})))))) diff --git a/test/clj/orcpub/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index cf73cfef5..90f356850 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -21,9 +21,6 @@ (use-fixtures :each (fn [t] (let [before (pa/current-overrides)] - ;; every test's in-memory database hands out the same ids, so one test's save must not - ;; count against the next - (bounded-cache/clear! @#'ac/recent-saves) (try (t) (finally (pa/set-artist-overrides! before)))))) (defn- add-artist! [conn username artist] diff --git a/test/clj/orcpub/portrait_og_test.clj b/test/clj/orcpub/portrait_og_test.clj index 596db2dcf..d0dba9508 100644 --- a/test/clj/orcpub/portrait_og_test.clj +++ b/test/clj/orcpub/portrait_og_test.clj @@ -309,7 +309,7 @@ (is (= [1 2 3] (vec (second (render tag (a-portrait)))))) (is (= 1 @renders))) (testing "with every render slot taken, a new portrait is refused, not queued forever" - (let [slots (work-pool/permits @#'store/pool) + (let [slots (work-pool/permits @#'store/draw-pool) n (.drainPermits slots)] (try (is (pos? n)) diff --git a/test/cljs/orcpub/components_checkbox_test.cljs b/test/cljs/orcpub/components_checkbox_test.cljs index a1e9f72d1..2704ffc14 100644 --- a/test/cljs/orcpub/components_checkbox_test.cljs +++ b/test/cljs/orcpub/components_checkbox_test.cljs @@ -1,7 +1,7 @@ (ns orcpub.components-checkbox-test "The tick box is a keyboard control: a screen reader hears a checkbox and its state, Tab reaches it, and Space or Enter does what a click does." - (:require [cljs.test :refer-macros [deftest is testing]] + (:require [cljs.test :refer-macros [deftest is]] [reagent.core :as r] [reagent.dom :as rdom] [orcpub.components :as comps])) From 1128d37719c43d261c3cee96835177f4da6a298a Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 17:12:16 +0000 Subject: [PATCH 67/91] Artist links: rel="noopener" only; Referrer-Policy strict-origin-when-cross-origin site-wide Owner's decision: artists are vetted accounts, and should see this site in their own analytics and get the search credit, so their links lose noreferrer, nofollow and ugc and keep noopener. Every response that sets no policy of its own now carries Referrer-Policy: strict-origin-when-cross-origin, so another site only ever sees the domain; the password-reset page keeps its no-referrer. Fail state, before the fix: artist-profile-test FAIL every-external-link-is-marked-as-someone-elses: expected :rel "noopener", was "noopener noreferrer nofollow ugc" pedestal-test FAIL every-page-sends-only-its-origin-to-other-sites: no Referrer-Policy on a response that set none After: both green; cljs 669 tests, 0 failures. --- src/clj/orcpub/pedestal.clj | 14 +++++++++++++- src/cljc/orcpub/dnd/e5/artist_profile.cljc | 7 ++++--- test/clj/orcpub/pedestal_test.clj | 15 +++++++++++++++ test/cljc/orcpub/dnd/e5/artist_profile_test.cljc | 2 +- .../orcpub/dnd/e5/artist_page_credits_test.cljs | 2 +- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/clj/orcpub/pedestal.clj b/src/clj/orcpub/pedestal.clj index 67b121aa9..0c3bb75af 100644 --- a/src/clj/orcpub/pedestal.clj +++ b/src/clj/orcpub/pedestal.clj @@ -78,6 +78,17 @@ ;; Create the nonce interceptor with current dev-mode? setting (def nonce-interceptor (make-nonce-interceptor (config/dev-mode?))) +(def referrer-policy-interceptor + "Sets Referrer-Policy: strict-origin-when-cross-origin on every response that has not set its own, + so a site this one links to sees only its domain. A handler's own policy wins (the + password-reset page sends no-referrer)." + (interceptor/interceptor + {:name :referrer-policy + :leave (fn [ctx] + (if (get-in ctx [:response :headers "Referrer-Policy"]) + ctx + (assoc-in ctx [:response :headers "Referrer-Policy"] "strict-origin-when-cross-origin")))})) + (def etag-interceptor (interceptor/interceptor {:name :etag-interceptor @@ -131,7 +142,8 @@ this (cond-> service-map ;; nonce-interceptor first: runs last in :leave phase (sets CSP header after response built) - true (update ::http/interceptors conj nonce-interceptor (db-interceptor conn) etag-interceptor) + true (update ::http/interceptors conj nonce-interceptor referrer-policy-interceptor + (db-interceptor conn) etag-interceptor) true http/create-server ;; Arm the banner here, fire it when Jetty says it is up. It cannot go after ;; http/start -- that BLOCKS in prod, where join? defaults true, so the first diff --git a/src/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc index dc9e26ae7..c5f3692d5 100644 --- a/src/cljc/orcpub/dnd/e5/artist_profile.cljc +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -140,11 +140,12 @@ (and (string? u) (not (re-find #"\s" (s/trim u))) (nil? (artist-links/url-problem (ascii-url u))))) (defn external-link - "Anchor attributes for a link to someone else's site, or nil when `url` is not one this site - draws: a new tab, no opener or referrer, and marked as user-supplied for search engines." + "Anchor attributes for a link to an artist's own site, or nil when `url` is not one this site + draws: a new tab without access to this one (rel noopener). The referrer is sent, as the + site-wide Referrer-Policy allows (the domain only), so the artist sees where visitors came from." [url] (when (web-url? url) - {:href (ascii-url url) :target "_blank" :rel "noopener noreferrer nofollow ugc"})) + {:href (ascii-url url) :target "_blank" :rel "noopener"})) (defn listed-links "The links the artist listed, and only those: :artist/links, or their single diff --git a/test/clj/orcpub/pedestal_test.clj b/test/clj/orcpub/pedestal_test.clj index 61616eb68..050758b80 100644 --- a/test/clj/orcpub/pedestal_test.clj +++ b/test/clj/orcpub/pedestal_test.clj @@ -98,3 +98,18 @@ out (leave ctx)] (is (= 304 (get-in out [:response :status]))) (is (nil? (get-in out [:response :body])))))) + +(defn- referrer-leave + "The :leave of orcpub.pedestal's referrer-policy interceptor, or nil when there is none." + [] + (some-> (ns-resolve 'orcpub.pedestal 'referrer-policy-interceptor) deref :leave)) + +(deftest every-page-sends-only-its-origin-to-other-sites + (let [leave (or (referrer-leave) identity)] + (testing "a page that says nothing gets strict-origin-when-cross-origin: other sites see the domain" + (is (= "strict-origin-when-cross-origin" + (get-in (leave {:response {:status 200 :headers {}}}) [:response :headers "Referrer-Policy"])))) + (testing "a page that set its own keeps it (the password-reset page sends nothing at all)" + (is (= "no-referrer" + (get-in (leave {:response {:status 200 :headers {"Referrer-Policy" "no-referrer"}}}) + [:response :headers "Referrer-Policy"])))))) diff --git a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc index 7cf6dea65..eb2700c73 100644 --- a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc +++ b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc @@ -138,7 +138,7 @@ (is (= "xn--exmple-4nf.test" (ap/link-host (:link/url l))))))) (deftest every-external-link-is-marked-as-someone-elses - (is (= {:href "https://example.test/" :target "_blank" :rel "noopener noreferrer nofollow ugc"} + (is (= {:href "https://example.test/" :target "_blank" :rel "noopener"} (ap/external-link "https://example.test/"))) (is (nil? (ap/external-link "javascript:alert(1)")) "not a web address: no link at all") (is (nil? (ap/external-link "http://example.test/")) "checked again where it is drawn")) diff --git a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs index 33e675d7f..dffe21a2e 100644 --- a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs +++ b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs @@ -66,7 +66,7 @@ (let [links (array-seq (.querySelectorAll el "a.ap-link"))] (is (= ["https://xn--exmple-4nf.test/shop"] (map #(.getAttribute % "href") links)) "the plain-http link is not drawn, the other goes to its encoded host") - (is (every? #(= "noopener noreferrer nofollow ugc" (.getAttribute % "rel")) links)) + (is (every? #(= "noopener" (.getAttribute % "rel")) links)) (is (= "xn--exmple-4nf.test" (some-> (.querySelector el "a.ap-link .ap-link-host") .-textContent)) "the button shows the real domain"))))) From f6148d0bf405006d6f3bb4cc55adce5fa2eb0db1 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 17:16:48 +0000 Subject: [PATCH 68/91] Artist credit emails: the first change at once, the rest of a ten-minute window in one batch Replaces the one-change-a-minute limit. The first change after a quiet spell is emailed at once (artist and admin) and opens a ten-minute window; changes inside it are kept pending and sent together, from the credit before the first of them to the credit now, when the window ends. The window starts at that first change and never extends. The state is two attributes on the account (artist-credit-window, artist-credit-pending), so a batch survives a restart; a once-a-minute job (orcpub.artist-credit-notices, its own component, not the hourly heartbeat) sends due batches, claiming each by compare-and-set so several containers send it once. Saves are capped at 60 an hour per account (429 with a Retry-After). Fail state, before the fix (artist-credit-test): FAIL a-burst-is-one-email-now-and-one-batch (3): the second change was refused FAIL editing-more-cannot-hold-the-batch-back (2) FAIL a-batch-is-sent-once-whoever-flushes FAIL saves-are-capped-against-abuse: the 61st change was saved a-single-change-is-emailed-at-once, with the immediate send broken (saved! always pending): 2 FAILs, nothing sent. Restored with git. After: 16 tests, 0 failures; the artist suites 53 tests, 0 failures. --- ARTISTS.md | 5 +- src/clj/orcpub/artist_credit.clj | 36 ++++---- src/clj/orcpub/artist_credit_notices.clj | 97 ++++++++++++++++++++++ src/clj/orcpub/db/schema.clj | 10 +++ src/clj/orcpub/routes.clj | 13 +-- src/clj/orcpub/system.clj | 7 ++ test/clj/orcpub/artist_credit_test.clj | 101 +++++++++++++++++++---- 7 files changed, 231 insertions(+), 38 deletions(-) create mode 100644 src/clj/orcpub/artist_credit_notices.clj diff --git a/ARTISTS.md b/ARTISTS.md index 1a9b9ca97..eb677444b 100644 --- a/ARTISTS.md +++ b/ARTISTS.md @@ -105,7 +105,10 @@ What the site shows is layered, and later layers win field by field: So you can always correct a credit from config without touching the account. Fields the config sets show as fixed on the artist's settings -page. Every edit is emailed to the artist and to the admin. +page. Edits are emailed to the artist and to the admin: the first change +after a quiet spell at once, and any further changes in the ten minutes +after it together in one email when those ten minutes end (more editing +cannot postpone it). An account can save up to 60 changes an hour. ### Settings these need diff --git a/src/clj/orcpub/artist_credit.clj b/src/clj/orcpub/artist_credit.clj index 92f74779c..855a9772c 100644 --- a/src/clj/orcpub/artist_credit.clj +++ b/src/clj/orcpub/artist_credit.clj @@ -25,6 +25,7 @@ [orcpub.fork.branding :as branding] [orcpub.artist-links :as links] [orcpub.bounded-cache :as bounded-cache] + [orcpub.heartbeat :as heartbeat] [orcpub.dnd.e5.portrait-assets :as pa])) ;; --------------------------------------------------------------------------- @@ -137,24 +138,29 @@ :own own :locked (set (keys config))})))) -(def save-gap-ms - "How long after a saved change an artist waits before saving another: each one emails the admin." - 60000) +(def save-cap + "Changes an account may save in an hour: generous for an artist, a wall against a script." + 60) (def ^:private recent-saves - "When each account last saved a change, by [connection account], kept for `save-gap-ms`." - (bounded-cache/cache {:max-entries 1024 :max-age-ms save-gap-ms})) + "When each account saved its changes in the last hour (ms), by [connection account]." + (bounded-cache/cache {:max-entries 1024 :max-age-ms (* 60 60 1000)})) + +(defn- saves-this-hour + "The times (ms) account `user-id` on `conn` saved a change in the hour before `now` (ms)." + [conn user-id now] + (filterv #(> % (- now 3600000)) (some-> (bounded-cache/lookup recent-saves [conn user-id]) first))) (defn- wait-secs - "Whole seconds until account `user-id` on `conn` may save another change, or nil when they may now." - [conn user-id] - (when-let [[at] (bounded-cache/lookup recent-saves [conn user-id])] - (let [left (- (+ at save-gap-ms) (System/currentTimeMillis))] - (when (pos? left) (long (Math/ceil (/ left 1000.0))))))) + "Whole seconds until a save at `now` (ms) is allowed after the hour's saves `times`, or nil when + fewer than `save-cap` were made." + [times now] + (when (>= (count times) save-cap) + (long (Math/ceil (/ (- (+ (apply min times) 3600000) now) 1000.0))))) (defn save! "Save `edit` as the credit for the artist `user-id` speaks for. Returns {:errors ..}, - {:unchanged true}, {:wait seconds} when they saved a change under a minute ago, or + {:unchanged true}, {:wait seconds} when the account has saved `save-cap` changes this hour, or {:saved credit :before old :artist-id id}." [conn user-id edit] (let [db (d/db conn) @@ -163,15 +169,17 @@ (if-not artist {:errors {:account "This account isn't an artist account"}} (let [{:keys [errors credit]} (check edit) - before (some-> artist-credit read-stored)] + before (some-> artist-credit read-stored) + now (.getTime ^java.util.Date (heartbeat/now)) + times (saves-this-hour conn user-id now)] (cond errors {:errors errors} (= (not-empty credit) before) {:unchanged true} - (wait-secs conn user-id) {:wait (wait-secs conn user-id)} + (wait-secs times now) {:wait (wait-secs times now)} :else (do @(d/transact conn [(if (seq credit) {:db/id user-id :orcpub.user/artist-credit (pr-str credit)} [:db/retract user-id :orcpub.user/artist-credit artist-credit])]) - (bounded-cache/put! recent-saves [conn user-id] (System/currentTimeMillis)) + (bounded-cache/put! recent-saves [conn user-id] (conj times now)) (refresh! (d/db conn)) {:saved credit :before before :artist-id artist})))))) diff --git a/src/clj/orcpub/artist_credit_notices.clj b/src/clj/orcpub/artist_credit_notices.clj new file mode 100644 index 000000000..dafc09fc5 --- /dev/null +++ b/src/clj/orcpub/artist_credit_notices.clj @@ -0,0 +1,97 @@ +(ns orcpub.artist-credit-notices + "Emails about an artist's credit changes, to the artist and the admin, batched without letting + anyone delay them: the first change after a quiet spell is emailed at once and opens a ten-minute + window; changes inside it are collected and sent together, once, when it ends. The window starts + at that first change and never extends. State is in the database (:orcpub.user/artist-credit-window + and -pending), so a batch survives a restart and is sent once however many containers run; + `flush-due!` runs every minute." + (:require [clojure.edn :as edn] + [clojure.string :as s] + [com.stuartsierra.component :as component] + [datomic.api :as d] + [orcpub.artist-credit :as artist-credit] + [orcpub.artist-email :as artist-email] + [orcpub.heartbeat :as heartbeat]) + (:import [java.util.concurrent Executors ScheduledExecutorService ThreadFactory TimeUnit])) + +(def window-ms + "How long after an emailed change further changes are collected into one email." + (* 10 60 1000)) + +(defn- window-open? + "Whether a window that started at `start` (a Date or nil) is still open at `now` (a Date)." + [^java.util.Date start ^java.util.Date now] + (boolean (and start (< (.getTime now) (+ (.getTime start) window-ms))))) + +(defn saved! + "Call after artist `user` (an account entity map) saved a change on `conn`, from `before` to + `after` (credits) for `artist-id`. Outside a window: emails at once and opens one. Inside: marks + the change pending (keeping the earliest `before`) for `flush-due!`. Returns :sent or :pending." + [conn {:keys [user artist-id before after]}] + (let [id (:db/id user) + now (heartbeat/now) + {:orcpub.user/keys [artist-credit-window artist-credit-pending]} + (d/pull (d/db conn) [:orcpub.user/artist-credit-window :orcpub.user/artist-credit-pending] id)] + (if (window-open? artist-credit-window now) + (do (when (s/blank? artist-credit-pending) + @(d/transact conn [{:db/id id :orcpub.user/artist-credit-pending (pr-str (or before {}))}])) + :pending) + (do @(d/transact conn [{:db/id id :orcpub.user/artist-credit-window now + :orcpub.user/artist-credit-pending ""}]) + (future (artist-email/credit-changed! {:artist-id artist-id :user user + :before before :after after})) + :sent)))) + +(defn- claim! + "Take the pending batch `pending` of account `id`, clearing it by compare-and-set; true only for + the one caller whose clear commits, so each batch is sent once." + [conn id pending] + (try @(d/transact conn [[:db/cas id :orcpub.user/artist-credit-pending pending ""]]) true + (catch Exception _ false))) + +(defn flush-due! + "Email every batch on `conn` whose window has ended, once each: the credit from before its first + change to the credit now (nothing when the changes undid themselves). Returns how many went." + [conn] + (let [now (heartbeat/now) + db (d/db conn) + due (d/q '[:find ?u ?pending ?start + :where [?u :orcpub.user/artist-credit-pending ?pending] + [(not= ?pending "")] + [?u :orcpub.user/artist-credit-window ?start]] + db)] + (reduce (fn [n [id pending start]] + (if (and (not (window-open? start now)) (claim! conn id pending)) + (let [user (d/pull db '[*] id) + before (not-empty (edn/read-string pending)) + after (some-> (:orcpub.user/artist-credit user) artist-credit/read-stored)] + (if (= before after) + n + (do (artist-email/credit-changed! {:artist-id (:orcpub.user/artist user) :user user + :before before :after after}) + (inc n)))) + n)) + 0 due))) + +(defrecord CreditNotices [conn ^ScheduledExecutorService executor] + component/Lifecycle + (start [this] + (if executor + this + (let [ex (Executors/newSingleThreadScheduledExecutor + (reify ThreadFactory + (newThread [_ r] (doto (Thread. ^Runnable r "credit-notices") (.setDaemon true)))))] + (.scheduleAtFixedRate ex ^Runnable (fn [] (try (flush-due! (:conn conn)) + (catch Throwable e + (println "WARNING: credit change emails not sent this minute:" + (.getMessage e))))) + 1 1 TimeUnit/MINUTES) + (assoc this :executor ex)))) + (stop [this] + (when executor (.shutdownNow executor)) + (assoc this :executor nil))) + +(defn new-credit-notices + "The component that sends due credit-change batches every minute; needs :conn." + [] + (map->CreditNotices {})) diff --git a/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index b9782c155..2248ccd7a 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -197,6 +197,16 @@ {:db/ident :orcpub.user/artist-credit :db/valueType :db.type/string :db/cardinality :db.cardinality/one} + ;; When this artist was last emailed at once about a credit change: the start of a ten-minute + ;; window in which further changes are collected rather than emailed (orcpub.artist-credit-notices). + {:db/ident :orcpub.user/artist-credit-window + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one} + ;; The credit, as EDN, from before the first change not yet emailed; empty when none is waiting. + ;; Cleared by compare-and-set, so one container sends each batch. + {:db/ident :orcpub.user/artist-credit-pending + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} ;; When the artist welcome email for this account was handed to the mail ;; server. Unset means it never went out, so the next start sends it again. {:db/ident :orcpub.user/artist-welcome-sent diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 22830eec5..fc91da4b9 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -52,6 +52,7 @@ [orcpub.portrait-pack.strands :as strands] [orcpub.dnd.e5.portrait-effects :as portrait-fx] [orcpub.artist-credit :as artist-credit] + [orcpub.artist-credit-notices :as credit-notices] [orcpub.artist-email :as artist-email] [orcpub.crypto :as crypto] [orcpub.spellbook :as spellbook] @@ -855,9 +856,9 @@ {:status 400 :body {:error "User not found"}}))) (defn update-artist-credit - "PUT /user/artist-credit -- an artist edits how they're credited. The - artist and the admin both hear about every change, so changes are limited to one a minute - (429 with a Retry-After)." + "PUT /user/artist-credit -- an artist edits how they're credited. The artist and the admin hear + about every change, batched by orcpub.artist-credit-notices; 429 with a Retry-After once the + account has saved artist-credit/save-cap changes in the hour." [{:keys [transit-params db conn identity]}] (let [user (find-user-by-username db (:user identity)) id (:db/id user)] @@ -868,11 +869,11 @@ (cond errors {:status 400 :body {:errors errors}} wait {:status 429 :headers {"Retry-After" (str wait)} - :body {:error (str "You saved a change a moment ago. Try again in " wait " seconds.")}} + :body {:error (str "That's a lot of changes for one hour. Try again in " + (max 1 (quot (+ wait 59) 60)) " minutes.")}} unchanged {:status 200 :body (artist-credit-body (d/db conn) id)} :else - (do (future (artist-email/credit-changed! {:artist-id artist-id :user user - :before before :after saved})) + (do (credit-notices/saved! conn {:artist-id artist-id :user user :before before :after saved}) {:status 200 :body (artist-credit-body (d/db conn) id)})))))) (defn hash-reset-key diff --git a/src/clj/orcpub/system.clj b/src/clj/orcpub/system.clj index 9da308dfc..9d804df2c 100644 --- a/src/clj/orcpub/system.clj +++ b/src/clj/orcpub/system.clj @@ -10,6 +10,7 @@ [orcpub.artist-accounts :as artist-accounts] [orcpub.portrait-pack.strands :as strands] [orcpub.heartbeat :as heartbeat] + [orcpub.artist-credit-notices :as credit-notices] [orcpub.routes.share :as share] [orcpub.pwned :as pwned] [orcpub.security :as security] @@ -159,6 +160,12 @@ "rate limit summary" security/summary-job}) [:conn]) + ;; emails the batched artist credit changes whose ten minutes are up, every minute + :credit-notices + (component/using + (credit-notices/new-credit-notices) + [:conn]) + ;; links or creates artist accounts named in PORTRAIT_ARTISTS; runs in the ;; background after the database is up and never blocks the site starting :artist-accounts diff --git a/test/clj/orcpub/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index 90f356850..06a389ccc 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -2,12 +2,12 @@ (:require [clojure.test :refer [deftest is testing use-fixtures]] [datomic.api :as d] [orcpub.artist-credit :as ac] - [orcpub.bounded-cache :as bounded-cache] [orcpub.fork.branding :as branding] [orcpub.dnd.e5.portrait-assets :as pa] [orcpub.db.schema :as schema] [orcpub.crypto] [orcpub.artist-email] + [orcpub.heartbeat] [orcpub.routes]) (:import [java.util UUID])) @@ -96,8 +96,7 @@ (is (= "https://fuss.example/" (:artist/link (pa/artist-info :house-pack)))) (is (= #{:artist/name} (:locked (ac/credit-for-account (d/db conn) id))) "the settings page can say which fields the site has fixed"))) - (testing "clearing the edit (a minute later) goes back to the code's default" - (bounded-cache/clear! @#'ac/recent-saves) + (testing "clearing the edit goes back to the code's default" (ac/save! conn id {}) (is (= "Fusspot" (:artist/name (pa/artist-info :house-pack)))))))) @@ -153,20 +152,88 @@ (is (= 1 (count @told)) "one notice per real change, none for a failed or no-op save") (is (= {:artist/name "Fuss"} (select-keys (:after (first @told)) [:artist/name])))))))) -(deftest saving-changes-is-limited-to-one-a-minute - (testing "each saved change emails the admin, so a burst of edits cannot flood their inbox" - (with-conn conn - (add-artist! conn "fusspot" :house-pack) - (let [told (atom [])] - (with-redefs [orcpub.artist-email/credit-changed! #(swap! told conj %) - clojure.core/future-call (fn [f] (f) (delay nil))] - (let [first-save (orcpub.routes/update-artist-credit (req conn "fusspot" {:name "Fuss One"})) - second-save (orcpub.routes/update-artist-credit (req conn "fusspot" {:name "Fuss Two"}))] - (is (= 200 (:status first-save))) - (is (= 429 (:status second-save)) "a second change straight after is refused") - (is (some? (get-in second-save [:headers "Retry-After"]))) - (is (string? (get-in second-save [:body :error])) "with a message the account page shows") - (is (= 1 (count @told)) "and only the first emailed anyone"))))))) +(def ^:private t0 (java.util.Date. 1760000000000)) +(defn- at "`m` minutes after t0." [m] (java.util.Date. (+ (.getTime ^java.util.Date t0) (* m 60000)))) + +(defn- flush! + "Send the credit-change batches that are due on `conn` at `when`; nothing when there is no sender." + [conn when] + (when-let [f (try (requiring-resolve 'orcpub.artist-credit-notices/flush-due!) (catch Exception _ nil))] + (with-redefs [orcpub.heartbeat/now (constantly when)] (f conn)))) + +(defmacro ^:private with-emails + "Run `body` with credit emails collected in `sent` (as [minute before-name after-name]) instead of sent." + [sent clock & body] + `(with-redefs [orcpub.artist-email/credit-changed! + (fn [e#] (swap! ~sent conj [@~clock (:artist/name (:before e#)) (:artist/name (:after e#))])) + orcpub.heartbeat/now (fn [] (at @~clock)) + clojure.core/future-call (fn [f#] (f#) (delay nil))] + ~@body)) + +(defn- save-name! + "Save the credit name `n` as fusspot through the route; returns the response." + [conn n] + (orcpub.routes/update-artist-credit (req conn "fusspot" {:name n}))) + +(deftest a-single-change-is-emailed-at-once + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [sent (atom []) clock (atom 0)] + (with-emails sent clock + (is (= 200 (:status (save-name! conn "Fuss One")))) + (is (= [[0 nil "Fuss One"]] @sent) "straight away, to the artist and the admin") + (reset! clock 30) (flush! conn (at 30)) + (is (= 1 (count @sent)) "and nothing follows it"))))) + +(deftest a-burst-is-one-email-now-and-one-batch + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [sent (atom []) clock (atom 0)] + (with-emails sent clock + (save-name! conn "One") + (reset! clock 1) (is (= 200 (:status (save-name! conn "Two"))) "a change in the window is saved") + (reset! clock 2) (save-name! conn "Three") + (is (= [[0 nil "One"]] @sent) "only the first went at once") + (flush! conn (at 9)) + (is (= 1 (count @sent)) "the batch waits for the window to end") + (flush! conn (at 10)) + (is (= [[0 nil "One"] [0 "One" "Three"]] (mapv #(assoc % 0 0) @sent)) + "one email for the rest, from before the first of them to the last") + (flush! conn (at 11)) + (is (= 2 (count @sent)) "sent once"))))) + +(deftest editing-more-cannot-hold-the-batch-back + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [sent (atom []) clock (atom 0)] + (with-emails sent clock + (save-name! conn "A") + (doseq [[m n] [[4 "B"] [8 "C"]]] (reset! clock m) (save-name! conn n)) + (flush! conn (at 10)) + (is (= 2 (count @sent)) "the window ends ten minutes after the first change, however many follow") + (reset! clock 12) (save-name! conn "D") + (is (= 3 (count @sent)) "the next change after it is a first change again: emailed at once"))))) + +(deftest a-batch-is-sent-once-whoever-flushes + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [sent (atom []) clock (atom 0)] + (with-emails sent clock + (save-name! conn "A") (reset! clock 1) (save-name! conn "B") + (let [fs (doall (repeatedly 3 #(future (flush! conn (at 10)))))] (run! deref fs)) + (is (= 2 (count @sent)) "three containers flushing at once send it once"))))) + +(deftest saves-are-capped-against-abuse + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [sent (atom []) clock (atom 0)] + (with-emails sent clock + (dotimes [i 60] (save-name! conn (str "N" i))) + (let [r (save-name! conn "one too many")] + (is (= 429 (:status r))) + (is (some? (get-in r [:headers "Retry-After"])))) + (reset! clock 61) + (is (= 200 (:status (save-name! conn "an hour on")))))))) (deftest a-non-artist-cannot-save-a-credit (with-conn conn From 331f3a0c55dd9fcd21a316d86503d79ec77b0586 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 17:41:12 +0000 Subject: [PATCH 69/91] Artists control their own page: on or off, and its examples, links and count On My Account, an artist switches their page on or off and, while it is on, its example portraits, its links and its piece count (the name always shows). The switches are stored with their credit edits (only when off), reach the site on the next request, and go through the same batched change emails, which now list each switch. A page switched off answers /artists/<slug> with a 302 (never 301, so switching it back on works at once) to /artists, which no longer lists them, with no share tags naming them; its examples 404 and the credit loses its 'About the artist' line, keeping the name and the artist's own link. The deployment can still force a page off, never on. An unknown slug is still a 404. profile-enabled?'s docstring now says this. Fail state, before the fix: artist-page-test: a-page-turned-off-redirects-to-the-list (302, Location), examples-switched-off-are-not-served (404, no example in the share card), the-piece-count-switched-off-is-not-shared artist-credit-test: the-artist-turns-their-page-and-its-parts-off (6), the-deployment-can-force-a-page-off-but-not-on, the-page-switches-arrive-through-the-route (the route dropped the switches; found driving My Account in the app) artist-profile-test: links-switched-off-are-not-listed artist-email-test: the-change-email-shows-the-page-switches (3) cljs: the page, the list and My Account (7) After: lein test 1042 tests, 0 failures; cljs 673 tests, 0 failures. In the app, as the artist: page off -> 302 to /artists and the example 404; on again -> 200. --- CHANGELOG.md | 3 ++ src/clj/orcpub/artist_credit.clj | 34 ++++++++++---- src/clj/orcpub/artist_email.clj | 12 +++-- src/clj/orcpub/routes.clj | 32 +++++++------ src/cljc/orcpub/components.cljc | 2 +- src/cljc/orcpub/dnd/e5/artist_profile.cljc | 39 +++++++++++----- src/cljc/orcpub/dnd/e5/portrait_assets.cljc | 11 +++-- src/cljs/orcpub/dnd/e5/views.cljs | 42 +++++++++++++---- src/cljs/orcpub/dnd/e5/views/artist_page.cljs | 25 ++++++----- test/clj/orcpub/artist_credit_test.clj | 45 +++++++++++++++++-- test/clj/orcpub/artist_email_test.clj | 6 +++ test/clj/orcpub/artist_page_test.clj | 28 +++++++++--- .../orcpub/dnd/e5/artist_profile_test.cljc | 3 ++ .../dnd/e5/artist_page_credits_test.cljs | 38 ++++++++++++++++ 14 files changed, 253 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a097f9d3..1d8e4f873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,8 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte three small watermarked example portraits and the links they listed, and a page listing every artist. - **Characters:** the portrait credit links to the artist's page. +- **Accounts:** artists choose on My Account whether they have a page, and whether it shows + their example portraits, their links and how many pieces they drew. ### Changed @@ -137,6 +139,7 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte ### Security +- **Site:** other sites a page links to are told only this site's domain, never the page's address. - Sign-in, password reset, share links, image fetching and PDF export are hardened against misuse, and secrets are no longer printed at startup. ## [breaking/2026-stack-modernization] diff --git a/src/clj/orcpub/artist_credit.clj b/src/clj/orcpub/artist_credit.clj index 855a9772c..a0ae7701d 100644 --- a/src/clj/orcpub/artist_credit.clj +++ b/src/clj/orcpub/artist_credit.clj @@ -42,12 +42,26 @@ (def ^:private link-entry links/link-entry) (def ^:private clean-name links/clean-name) +(def page-switches + "The parts of their page an artist can switch off, as {edit key credit key}. Stored only when + off, so a credit without one shows that part." + {:profile? :artist/profile? + :show-examples? :artist/show-examples? + :show-links? :artist/show-links? + :show-count? :artist/show-count?}) + +(defn- switched-off + "{credit key false} for every page switch `m` (keyed by `key-of` each switch) turns off." + [m key-of] + (into {} (for [[edit-k credit-k] page-switches :when (false? (get m (key-of [edit-k credit-k])))] + [credit-k false]))) + (defn check "Validate an edit submitted from the settings page: - {:name \"..\" :link \"..\" :links [{:url \"..\"}]} - Each link's icon is worked out from its address. - Returns {:credit <clean stored form>} or {:errors {field message}}. - A blank name or link means \"use the default\"." + {:name \"..\" :link \"..\" :links [{:url \"..\"}] :profile? :show-examples? ..} + Each link's icon is worked out from its address. Returns {:credit <clean stored form>} or + {:errors {field message}}. A blank name or link means \"use the default\"; a page switch is + kept only when false." [{:keys [name link links] :as edit}] (let [errors (links/edit-errors edit) nm (clean-name name) @@ -55,7 +69,7 @@ urls (->> links (map #(some-> (:url %) str s/trim)) (remove s/blank?))] (if (seq errors) {:errors errors} - {:credit (cond-> {} + {:credit (cond-> (switched-off edit first) nm (assoc :artist/name nm) link (assoc :artist/link link) (seq urls) (assoc :artist/links (mapv link-entry urls)))}))) @@ -74,7 +88,7 @@ (filter #(and (string? %) (nil? (url-problem %)))) (take max-links))] (not-empty - (cond-> {} + (cond-> (switched-off m second) (and nm (<= (count nm) max-name-length)) (assoc :artist/name nm) (and (string? link) (nil? (url-problem link))) (assoc :artist/link link) ;; icons re-derived from the links, whatever was stored @@ -100,9 +114,13 @@ [a credit])))) (defn effective - "Artist edits under deployment config, field by field." + "Artist edits under deployment config, field by field; except that either one turning an + artist's page off (:artist/profile? false) keeps it off." [edits config] - (merge-with merge edits config)) + (reduce-kv (fn [m artist-id e] + (cond-> m (false? (:artist/profile? e)) (assoc-in [artist-id :artist/profile?] false))) + (merge-with merge edits config) + edits)) (defonce ^:private applied-t (atom nil)) diff --git a/src/clj/orcpub/artist_email.clj b/src/clj/orcpub/artist_email.clj index 127f93433..fb34febcc 100644 --- a/src/clj/orcpub/artist_email.clj +++ b/src/clj/orcpub/artist_email.clj @@ -297,14 +297,20 @@ ;; --------------------------------------------------------------------------- (defn credit-rows - "A credit as [label value] rows, for showing before and after." + "A credit as [label value] rows, for showing before and after: the name, links and icons, then + each artist-page switch as On or Off." [credit] - (let [{:artist/keys [name link links]} credit] + (let [{:artist/keys [name link links]} credit + on-off #(if (false? (get credit %)) "Off" "On")] (concat [["Name" (or name "(default)")] ["Link" (or link "(default)")]] (if (seq links) (for [{:link/keys [label url]} links] [label url]) - [["Icons" "(default)"]])))) + [["Icons" "(default)"]]) + [["Artist page" (on-off :artist/profile?)] + ["Example portraits" (on-off :artist/show-examples?)] + ["Links on the page" (on-off :artist/show-links?)] + ["Piece count" (on-off :artist/show-count?)]]))) (defn- rows-table [rows] [:table {:role "presentation" :cellpadding "0" :cellspacing "0" :border "0" diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index fc91da4b9..b04318067 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -865,7 +865,8 @@ (if-not id {:status 400 :body {:error "User not found"}} (let [{:keys [errors unchanged wait saved before artist-id]} - (artist-credit/save! conn id (select-keys transit-params [:name :link :links]))] + (artist-credit/save! conn id (select-keys transit-params + (into [:name :link :links] (keys artist-credit/page-switches))))] (cond errors {:status 400 :body {:errors errors}} wait {:status 429 :headers {"Retry-After" (str wait)} @@ -2516,19 +2517,23 @@ :description "The artists who drew the portrait pieces here."})) (defn artist-page - "An artist's profile. The body is the SPA's, like the character page; the - share tags are set here because a crawler never runs the JavaScript. An - unknown slug is a real 404, so a mistyped link is not indexed as a page -- - the SPA still loads and says there is nobody here." - [{:keys [headers] {:keys [slug]} :path-params :as request}] + "An artist's profile. The body is the SPA's; the share tags are set here because a crawler never + runs the JavaScript. A page its artist switched off redirects (302) to /artists; an unknown + slug is a real 404, so a mistyped link is not indexed (the SPA says there is nobody here)." + [{:keys [headers db] {:keys [slug]} :path-params :as request}] + ;; before the artist's own switches are read + (when db (artist-credit/refresh! db)) (if-let [artist (artist-profile/artist-by-slug slug)] (index-page-response request - {:title (artist-profile/page-title artist) - :description (artist-profile/share-description artist) - :image-url (str "https://" (headers "host") - (artist-profile/example-path artist 1))}) - (assoc (index-page-response request {:title "No such artist"}) :status 404))) + (cond-> {:title (artist-profile/page-title artist) + :description (artist-profile/share-description artist)} + (artist-profile/shows? artist :examples) + (assoc :image-url (str "https://" (headers "host") (artist-profile/example-path artist 1))))) + (if (artist-profile/registered-by-slug slug) + ;; switched off: 302, never 301, so switching it back on works at once + {:status 302 :headers {"Location" (artist-profile/index-path)} :body ""} + (assoc (index-page-response request {:title "No such artist"}) :status 404)))) (def ^:private example-jpegs "Rendered examples by [artist n watermark]. Single-flight, so a burst of requests for a new page @@ -2570,14 +2575,15 @@ (defn artist-example-jpeg "Ring handler: example image :n (1 to example-count) of the artist at :slug, small, - flattened and watermarked. 404 for an unknown slug, a page turned off, or any other :n; + flattened and watermarked. 404 for an unknown slug, a page or examples switched off, or any + other :n; 503 with a Retry-After when every draw slot is taken." [{:keys [db] {:keys [slug n]} :path-params}] ;; before the watermark is worked out from the artist's links (when db (artist-credit/refresh! db)) (let [artist (artist-profile/artist-by-slug slug) n (some->> n (re-matches #"[1-9]") parse-long) - jpeg (when (and artist n (<= n artist-profile/example-count)) + jpeg (when (and artist n (<= n artist-profile/example-count) (artist-profile/shows? artist :examples)) (example-jpeg (:artist/id artist) n (artist-profile/watermark-text artist (or (portrait-render/site-mark) branding/app-name))))] diff --git a/src/cljc/orcpub/components.cljc b/src/cljc/orcpub/components.cljc index d8bad0ff8..5f72bdf65 100644 --- a/src/cljc/orcpub/components.cljc +++ b/src/cljc/orcpub/components.cljc @@ -38,7 +38,7 @@ "`label` beside a tick box, the two one checkbox control: clicked, or Space or Enter on it, calls `on-click`. Checked when `selected?`, dimmed and skipped when `disabled?`." [label selected? disabled? on-click] - [:div.flex.pointer.checkbox-row + [:div.flex.align-items-c.pointer.checkbox-row (cond-> {:on-click on-click :role "checkbox" :aria-checked (str (boolean selected?)) diff --git a/src/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc index c5f3692d5..36f21c55d 100644 --- a/src/cljc/orcpub/dnd/e5/artist_profile.cljc +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -27,14 +27,19 @@ (some #(when (= artist-id (:artist/id %)) %) pa/registry)) (defn profile-enabled? - "Whether the artist wants a profile page. On unless :artist/profile? is - explicitly false -- the switch an artist's account setting will flip. Off - means no page at all, not a page saying it is off: the address 404s like a - slug nobody has, they drop out of /artists, and the credit loses its - 'About the artist' line. The credit's name still goes to their own link." + "Whether `artist` has a profile page: on unless :artist/profile? is false, which the artist sets + on My Account (or the deployment forces). Off means /artists/<slug> redirects to /artists, they + drop out of the list, their example images 404 and the credit loses its 'About the artist' + line; the credit itself, and its link to the artist's own site, stay." [artist] (not (false? (:artist/profile? artist)))) +(defn shows? + "Whether `artist`'s page shows `part` (:examples, :links or :count): on unless they switched it + off (:artist/show-examples? and the like false). The name always shows." + [artist part] + (not (false? (get artist (keyword "artist" (str "show-" (name part) "?")))))) + (defn profile "The artist, overrides applied, if they have a profile: they are registered, have said how they want to be named, and have not turned the page off. An @@ -55,6 +60,14 @@ (profile (:artist/id %))) pa/registry)))) +(defn registered-by-slug + "The registry entry for a URL segment whether or not its page is on, or nil: what tells a page + switched off (a redirect) from an address nobody has (a 404)." + [segment] + (when (string? segment) + (let [wanted (s/lower-case (s/trim segment))] + (some #(when (= wanted (some-> (slug %) s/lower-case)) %) pa/registry)))) + (defn all-profiles "Every artist with a profile, in registry order." [] @@ -154,9 +167,10 @@ reach the page even if one is put in the registry. Duplicates are dropped, first one kept." [artist] - (let [links (or (seq (:artist/links artist)) + (let [links (when (shows? artist :links) + (or (seq (:artist/links artist)) (when-let [l (:artist/link artist)] - [{:link/label "Site" :link/icon "site" :link/url l}]))] + [{:link/label "Site" :link/icon "site" :link/url l}])))] (->> links (filter (comp web-url? :link/url)) (map #(update % :link/url ascii-url)) @@ -224,9 +238,12 @@ (str (:artist/name artist) " — portrait artist")) (defn share-description - "The share-card description of `artist`'s profile, naming them and their piece count." + "The share-card description of `artist`'s profile: their name, and the piece count, examples and + links where their page shows them." [artist] (let [n (piece-count artist)] - (str "Portrait art by " (:artist/name artist) ": " - n " hand-drawn " (if (= 1 n) "piece" "pieces") - ", a few portraits made from them, and where to find more."))) + (str "Portrait art by " (:artist/name artist) + (when (shows? artist :count) (str ": " n " hand-drawn " (if (= 1 n) "piece" "pieces"))) + (when (shows? artist :examples) ", a few portraits made from them") + (when (shows? artist :links) ", and where to find more") + "."))) diff --git a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc index aa6090ef2..6c2d6c161 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -301,9 +301,9 @@ (defonce ^:private artist-overrides (atom {})) (defn set-artist-overrides! - "Replace the runtime artist overrides. `m` is {artist-id {field value}}; only - :artist/name, :artist/link, :artist/links, :artist/license and :artist/profile? are - kept. :artist/profile? false turns the artist's profile page off (artist-profile/profile). + "Replace the runtime artist overrides. `m` is {artist-id {field value}}; only the name, link, + links, licence and the page switches (:artist/profile?, :artist/show-examples?, -links? and + -count?, false to turn off; artist-profile/shows?) are kept. See PORTRAIT-COMPOSITOR.md, \"Deployment overrides\"." [m] (reset! artist-overrides @@ -313,7 +313,10 @@ :artist/link :artist/links :artist/license - :artist/profile?])] + :artist/profile? + :artist/show-examples? + :artist/show-links? + :artist/show-count?])] :when (seq keep-fields)] [id keep-fields])))) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index f90c4183b..dfb978ca6 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -9977,12 +9977,21 @@ (when problem {:border-color "#e0602c"})) (defn- credit-edit - "The form's state as the edit the server takes. Blank rows are dropped here - so the server's per-row errors line up with what is on screen." - [nm link links] - {:name (s/trim nm) - :link (s/trim link) - :links (vec (for [{:keys [url]} links :when (not (s/blank? url))] {:url url}))}) + "The form's state as the edit the server takes: name, link, the non-blank link rows (so the + server's per-row errors line up with what is on screen) and the page `switches` + ({:profile? true ..})." + [nm link links switches] + (merge {:name (s/trim nm) + :link (s/trim link) + :links (vec (for [{:keys [url]} links :when (not (s/blank? url))] {:url url}))} + switches)) + +(def ^:private page-switches + "The artist-page switches on My Account, as [edit key, credit key, label]; the first is the page." + [[:profile? :artist/profile? "Show my artist page"] + [:show-examples? :artist/show-examples? "Example portraits"] + [:show-links? :artist/show-links? "Links"] + [:show-count? :artist/show-count? "How many pieces you drew"]]) (defn- credit-preview-info "What the credit will look like once saved: the code's default, under the @@ -10022,12 +10031,13 @@ next-id (atom 0) new-row (fn [url] {:id (swap! next-id inc) :url url}) links (r/atom (vec (for [l (:artist/links own)] (new-row (:link/url l))))) + switches (r/atom (into {} (for [[k ck] page-switches] [k (not (false? (get own ck)))]))) ;; fields the person has left, keyed :name / :link / row id left (r/atom #{})] (let [save-status @(subscribe [:account-save-status :artist-credit-save]) server-errors (:errors save-status) clear! #(dispatch [:clear-account-save-status :artist-credit-save]) - edit (credit-edit @nm @link @links) + edit (credit-edit @nm @link @links @switches) blocking (artist-links/edit-errors edit) locked? (fn [k] (contains? (set locked) k)) left? #(contains? @left %) @@ -10091,6 +10101,21 @@ {:on-click #(swap! links conj (new-row ""))} "+ Add a link"]) [field-note (or (:links server-errors) (:links blocking))]])] + [:div.p-5 + [:div.f-s-14.f-w-b "Your artist page"] + [:div.f-s-12.m-b-5.opacity-7 + "A public page with your name. When it is off, the credit under a portrait still names you" + " and links to your site, but not to the page."] + (let [[[pk _ plabel] & parts] page-switches] + [:div + [:div.m-b-5 + [comps/labeled-checkbox [:span.f-s-14 plabel] (pk @switches) false #(do (swap! switches update pk not) (clear!))]] + (when (pk @switches) + [:div.m-l-20 + (for [[k _ label] parts] + ^{:key k} + [:div.m-b-5 + [comps/labeled-checkbox [:span.f-s-14 label] (k @switches) false #(do (swap! switches update k not) (clear!))]])])])] [:div.p-5.m-t-5 [:button.form-button {:disabled (boolean (seq blocking)) @@ -10101,7 +10126,8 @@ "Save credit"] [save-note save-status]] [:div.f-s-12.p-5.opacity-7 - "Every change is emailed to you and to the site's admins, so a change you didn't make gets noticed."]]))) + "Changes are emailed to you and to the site's admins, so a change you didn't make gets noticed:" + " the first at once, and any made in the ten minutes after it together in one email."]]))) (defn artist-credit-setting "For artist accounts: how their art is credited across the site." diff --git a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs index 518671a70..ed149df11 100644 --- a/src/cljs/orcpub/dnd/e5/views/artist_page.cljs +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -58,7 +58,8 @@ [:figcaption "with pieces by " (names others)])])) (defn- profile-card - "The profile of `artist`: name, piece count, listed links, the examples and the footer." + "The profile of `artist`: the name, then the piece count, listed links and examples where the + artist keeps them on (ap/shows?), and the footer." [{:keys [:artist/id :artist/name] :as artist}] (let [links (ap/listed-links artist) n (ap/piece-count artist)] @@ -69,16 +70,18 @@ [:span.lk-rule.l {:aria-hidden true}] [:h1.ap-name name] [:span.lk-rule.r {:aria-hidden true}]] - [:p.ap-lede n " hand-drawn " (if (= 1 n) "piece" "pieces") - " in the portrait maker."] + (when (ap/shows? artist :count) + [:p.ap-lede n " hand-drawn " (if (= 1 n) "piece" "pieces") + " in the portrait maker."]) (when (seq links) [:nav.ap-links {:aria-label (str "Find " name)} (for [l links] ^{:key (:link/url l)} [link-button l])])] - [:section.ap-section - [:h2.lk-cap "Portraits from these pieces"] - [:div.ap-examples - (map-indexed (fn [i p] ^{:key i} [example artist i p]) - (ap/example-portraits id))]] + (when (ap/shows? artist :examples) + [:section.ap-section + [:h2.lk-cap "Portraits from these pieces"] + [:div.ap-examples + (map-indexed (fn [i p] ^{:key i} [example artist i p]) + (ap/example-portraits id))]]) [:p.ap-foot "Every portrait made with these pieces credits " name ", on the character page, the share card and the PDF. " @@ -111,6 +114,8 @@ :let [n (ap/piece-count a)]] ^{:key id} [:a.ap-index-card {:href (ap/profile-path a)} - [:div.ap-frame [example-img a 1 (str "A portrait from " name "'s pieces")]] + (when (ap/shows? a :examples) + [:div.ap-frame [example-img a 1 (str "A portrait from " name "'s pieces")]]) [:span.lk-name name] - [:span.ap-index-count n (if (= 1 n) " piece" " pieces")]])]]])) + (when (ap/shows? a :count) + [:span.ap-index-count n (if (= 1 n) " piece" " pieces")])])]]])) diff --git a/test/clj/orcpub/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index 06a389ccc..8390fc685 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -156,10 +156,10 @@ (defn- at "`m` minutes after t0." [m] (java.util.Date. (+ (.getTime ^java.util.Date t0) (* m 60000)))) (defn- flush! - "Send the credit-change batches that are due on `conn` at `when`; nothing when there is no sender." - [conn when] + "Send the credit-change batches that are due on `conn` at time `t`; nothing when there is no sender." + [conn t] (when-let [f (try (requiring-resolve 'orcpub.artist-credit-notices/flush-due!) (catch Exception _ nil))] - (with-redefs [orcpub.heartbeat/now (constantly when)] (f conn)))) + (with-redefs [orcpub.heartbeat/now (constantly t)] (f conn)))) (defmacro ^:private with-emails "Run `body` with credit emails collected in `sent` (as [minute before-name after-name]) instead of sent." @@ -271,3 +271,42 @@ (orcpub.routes/update-user-preferences (req conn "someone" {:preferred-name "Kaylee"})) (orcpub.routes/do-send-password-reset id "someone@example.test" conn {:headers {"host" "x.test"} :scheme :https}) (is (= "Kaylee" (:first-and-last-name @sent))))))) + +(deftest the-artist-turns-their-page-and-its-parts-off + (with-conn conn + (let [id (add-artist! conn "fusspot" :house-pack)] + (testing "the page" + (is (:saved (ac/save! conn id {:profile? false}))) + (is (false? (:artist/profile? (pa/artist-info :house-pack)))) + (is (nil? (orcpub.dnd.e5.artist-profile/artist-by-slug "fusspot")) "no page") + (is (false? (get-in (ac/credit-for-account (d/db conn) id) [:own :artist/profile?])) + "the settings page reads the switch back")) + (testing "and, with the page on, its parts one by one" + (is (:saved (ac/save! conn id {:show-examples? false :show-links? false :show-count? false}))) + (is (= {:artist/show-examples? false :artist/show-links? false :artist/show-count? false} + (select-keys (pa/artist-info :house-pack) + [:artist/show-examples? :artist/show-links? :artist/show-count?]))) + (is (some? (orcpub.dnd.e5.artist-profile/artist-by-slug "fusspot")) "the page is back"))))) + +(deftest the-deployment-can-force-a-page-off-but-not-on + (with-conn conn + (let [id (add-artist! conn "fusspot" :house-pack)] + (ac/save! conn id {:profile? false}) + (with-redefs [branding/portrait-artists {:house-pack {:artist/profile? true}}] + @(d/transact conn [{:db/id id :orcpub.user/username "fusspot"}]) + (ac/refresh! (d/db conn)) + (is (false? (:artist/profile? (pa/artist-info :house-pack))) "the artist's off stands")) + (ac/save! conn id {:profile? true}) + (with-redefs [branding/portrait-artists {:house-pack {:artist/profile? false}}] + @(d/transact conn [{:db/id id :orcpub.user/username "fusspot"}]) + (ac/refresh! (d/db conn)) + (is (false? (:artist/profile? (pa/artist-info :house-pack))) "the deployment's off stands"))))) + +(deftest the-page-switches-arrive-through-the-route + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (with-redefs [orcpub.artist-email/credit-changed! (fn [_]) + clojure.core/future-call (fn [f] (f) (delay nil))] + (is (= 200 (:status (orcpub.routes/update-artist-credit + (req conn "fusspot" {:name "" :link "" :links [] :profile? false :show-count? true}))))) + (is (false? (:artist/profile? (pa/artist-info :house-pack))) "what My Account sends is what is saved")))) diff --git a/test/clj/orcpub/artist_email_test.clj b/test/clj/orcpub/artist_email_test.clj index 3acb0c4fd..d55bfa8b6 100644 --- a/test/clj/orcpub/artist_email_test.clj +++ b/test/clj/orcpub/artist_email_test.clj @@ -107,3 +107,9 @@ (is (s/includes? text "If this wasn't you")) (is (s/includes? admin "Before:\n Name: (default)")) (is (s/includes? admin "PORTRAIT_ARTISTS"))))) + +(deftest the-change-email-shows-the-page-switches + (let [rows (into {} (orcpub.artist-email/credit-rows {:artist/profile? false :artist/show-links? false}))] + (is (= "Off" (rows "Artist page"))) + (is (= "Off" (rows "Links on the page"))) + (is (= "On" (rows "Example portraits")) "a switch never turned off reads On"))) diff --git a/test/clj/orcpub/artist_page_test.clj b/test/clj/orcpub/artist_page_test.clj index 343c52079..a72f9278b 100644 --- a/test/clj/orcpub/artist_page_test.clj +++ b/test/clj/orcpub/artist_page_test.clj @@ -140,14 +140,30 @@ (is (= 200 (:status (routes/artists-page {:headers {"host" "example.test"} :uri "/artists"}))))) -(deftest a-page-the-artist-turned-off-is-a-404 +(deftest a-page-turned-off-redirects-to-the-list (try (pa/set-artist-overrides! {:house-pack {:artist/profile? false}}) - (is (= 404 (:status (routes/artist-page (req "fusspot"))))) - (is (= 404 (:status (example "fusspot" "1"))) - "the example images go with it") - (is (not (s/includes? (:body (routes/artist-page (req "fusspot"))) "Fusspot")) - "and the 404's share tags do not name them") + (let [resp (routes/artist-page (req "fusspot"))] + (is (= 302 (:status resp)) "temporary, so turning it back on works at once") + (is (= "/artists" (get-in resp [:headers "Location"]))) + (is (not (s/includes? (str (:body resp)) "Fusspot")) "and nothing in it names them")) + (is (= 404 (:status (example "fusspot" "1"))) "the example images go with it") + (is (= 404 (:status (routes/artist-page (req "nobody")))) "a slug nobody has is still a 404") + (finally (pa/set-artist-overrides! {})))) + +(deftest examples-switched-off-are-not-served + (try + (pa/set-artist-overrides! {:house-pack {:artist/show-examples? false}}) + (is (= 404 (:status (example "fusspot" "1")))) + (let [body (:body (routes/artist-page (req "fusspot")))] + (is (s/includes? body "Fusspot") "the page itself is still there") + (is (not (s/includes? body "/examples/")) "and its share card shows no example")) + (finally (pa/set-artist-overrides! {})))) + +(deftest the-piece-count-switched-off-is-not-shared + (try + (pa/set-artist-overrides! {:house-pack {:artist/show-count? false}}) + (is (not (re-find #"\d+ hand-drawn" (:body (routes/artist-page (req "fusspot")))))) (finally (pa/set-artist-overrides! {})))) (deftest an-artist-name-is-never-page-source diff --git a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc index eb2700c73..3a1b34a94 100644 --- a/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc +++ b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc @@ -164,6 +164,9 @@ {:link/label "B" :link/url "https://example.test/"}]}))))) (is (= [] (ap/listed-links {})) "no links, no buttons")) +(deftest links-switched-off-are-not-listed + (is (= [] (ap/listed-links (assoc (ap/artist-by-slug "fusspot") :artist/show-links? false))))) + ;; ---------- examples ---------- (deftest the-seeded-randomness-is-the-same-on-both-platforms diff --git a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs index dffe21a2e..d5ef33801 100644 --- a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs +++ b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs @@ -8,6 +8,7 @@ [orcpub.dnd.e5.events] [orcpub.dnd.e5.subs] [orcpub.dnd.e5.views.artist-page :as artist-page] + [orcpub.dnd.e5.views :as views] [orcpub.dnd.e5.portrait-assets :as pa])) (defn- with-page @@ -78,3 +79,40 @@ props (map #(.item (.-style ico) %) (range (.-length (.-style ico))))] (is (seq props)) (is (every? #(.startsWith % "--") props) (str "only variables inline: " (vec props)))))))) + +(deftest the-page-shows-only-the-parts-the-artist-keeps-on + (with-page [artist-page/artist-page {:slug "fusspot"}] + (fn [el] + (dispatch-sync [:portrait/artists-loaded {"house-pack" {:artist/show-examples? false + :artist/show-links? false + :artist/show-count? false}}]) + (r/flush) + (is (= "Fusspot" (some-> (.querySelector el "h1.ap-name") .-textContent)) "the name always shows") + (is (nil? (.querySelector el ".ap-examples")) "no examples") + (is (nil? (.querySelector el "a.ap-link")) "no links") + (is (not (re-find #"hand-drawn" (.-textContent el))) "no piece count")))) + +(deftest the-list-shows-only-the-parts-the-artist-keeps-on + (with-page [artist-page/artists-page nil] + (fn [el] + (dispatch-sync [:portrait/artists-loaded {"house-pack" {:artist/show-examples? false + :artist/show-count? false}}]) + (r/flush) + (is (re-find #"Fusspot" (.-textContent el))) + (is (nil? (.querySelector el ".ap-index-card img")) "no example on the card") + (is (nil? (.querySelector el ".ap-index-count")) "no count on the card")))) + +(deftest the-artist-switches-their-page-on-my-account + (let [sent (atom nil)] + (with-redefs [re-frame.core/dispatch (fn [ev] (when (= :save-artist-credit (first ev)) (reset! sent (second ev))))] + (with-page [views/artist-credit-form {:own {:artist/show-links? false} :default {} :current {} :locked #{}}] + (fn [el] + (let [box (fn [label] (some #(when (= label (.-textContent %)) %) + (array-seq (.querySelectorAll el "[role=checkbox]"))))] + (is (= "true" (some-> (box "Show my artist page") (.getAttribute "aria-checked")))) + (is (= "false" (some-> (box "Links") (.getAttribute "aria-checked"))) "read back from the saved credit") + (some-> (box "Example portraits") .click) + (r/flush) + (some-> (some #(when (= "Save credit" (.-textContent %)) %) (array-seq (.querySelectorAll el "button"))) .click) + (is (= {:profile? true :show-examples? false :show-links? false :show-count? true} + (select-keys @sent [:profile? :show-examples? :show-links? :show-count?]))))))))) From 859d5fd29a1a25850a1eb4371d9521ca106edb28 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 14 Oct 2026 22:22:56 -0400 Subject: [PATCH 70/91] Reviewed images: drop the entries for design screenshots no longer in the repository The design screenshots under docs/design/artist-profiles/ and docs/design/style-guide/ were removed from history when the branch was rebuilt; their capture scripts remain and write to ignored folders. The 44 site images recorded before the portrait art stay. --- test/reviewed-images.edn | 1811 +------------------------------------- 1 file changed, 1 insertion(+), 1810 deletions(-) diff --git a/test/reviewed-images.edn b/test/reviewed-images.edn index 36322c41e..1033a9f40 100644 --- a/test/reviewed-images.edn +++ b/test/reviewed-images.edn @@ -130,1813 +130,4 @@ :what "UI icon or spinner, added 2020-11-25, before the portrait art"} "resources/public/image/world-anvil.jpeg" {:sha256 "2ded7ce0c3fcd5cefb6758e1904dae4654ce23e9cc9e20811416652a92e67c18" - :what "site background / partner image, added 2020-11-25, before the portrait art"} - "docs/design/artist-profiles/cards/column-dark-desktop.png" - {:sha256 "03fb84c8a3e26f625f593bdd783981f312052362a2693736a138ea5b84027253" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/column-dark-phone.png" - {:sha256 "e775aaba29a921e1e55ea2128c9b49f2f84b7523087252bec77e19ac15436d22" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/column-light-desktop.png" - {:sha256 "dff328ff1fd9f36c2cdea8877d82efeda541ef6ea21118cce8e1ac931df7d665" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/column-light-phone.png" - {:sha256 "0bddc007f750369049a875d7a0aaeab6a5ae4e11a60e57e1baf26d20f8dfd4c4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/example-0.jpg" - {:sha256 "78ce9e6b8dbd4e0e2365f5633c488f1210e81a343500c9a8da07886614f6a95b" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/example-1.jpg" - {:sha256 "6fcd50d02c02f673d4ac54af02abcf3b7308d3465e1af233a97c9734b44000ee" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/minimal-dark-desktop.png" - {:sha256 "f85d56fded51e42c73cf0ed0ab020cbbbd962b36076fb193224a220cc6133b37" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/minimal-dark-phone.png" - {:sha256 "725d0a1d40238c4ecd3e8ab80fdfd8d918263eb324e973abf65a060b92b7ce36" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/minimal-light-desktop.png" - {:sha256 "93aadd65d7ec8f12ae7d8f1debae3d6801b5cc98efd57f959688d146876c77c8" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/minimal-light-phone.png" - {:sha256 "0b7f17547ac33fda8f2645e854b4a72d84976869b591604ceecf609a24130d47" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/split-dark-desktop.png" - {:sha256 "09553589c78a0097b8de60bca008b098d906214e278ab14ba5dda40617cc55a3" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/split-dark-phone.png" - {:sha256 "c723456db7889673912b1aea0fe742a840bd6caa81edec3c3e439d3d8fa2d7a9" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/split-light-desktop.png" - {:sha256 "df54ab19dfcb184b1fb5a7ecb56f983bf4fd3f8dcce051e1e88fa94f8ee27ca8" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/cards/split-light-phone.png" - {:sha256 "f204669f29524425c1f29f40d7c529bc46578c13694fb05c86e6a0f1599ded8f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/example-1.jpg" - {:sha256 "b004b1e290c7774ffec181641e9946c26dae75cd0d18d8de8485f271929abf13" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/example-2.jpg" - {:sha256 "5cb5ca987b1467f45caddb5fe13476222d01aff0a6509b25c2f95360e02638e5" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/example-3.jpg" - {:sha256 "2c54ae40f135e11cec53e1d391ab2eab8cf8d1ca91ed57f338abd28434958691" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/page-index-dark-desktop.png" - {:sha256 "863f2a1e965d0d89e922e4ec129591b5a6c020b3ce322e3bac1a2bd4155587d9" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/page-profile-dark-desktop.png" - {:sha256 "bbcb011f7a0b3464be35c85ff47b7efdbc6ce54e5e6f241ab9cbbf8fc01934ba" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/page-profile-dark-phone.png" - {:sha256 "4efd9fdbf0191ff5c3f5e997883166975efe48071d898a97a5ebedf427312a69" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/page-profile-light-desktop.png" - {:sha256 "88dec8f608955878c36a5a52119a69b5fe12b725d0e19e51b0ce2dfa7f23e908" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/examples/page-profile-light-phone.png" - {:sha256 "f211b116d60486ace50333702fd932ed90af56e8514923e4deba72acaa8a2cea" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-index-dark-desktop.png" - {:sha256 "ab1e2d38ddc50819739ee63e7abe89cfadfac1528dcafa73b8c3a828b2cf833f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-index-dark-phone.png" - {:sha256 "49c8946ca5d982ea369037f07e448ea468d75cd5599bc0b6b1474f37116331d6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-index-light-desktop.png" - {:sha256 "f08190c28709a8955a629df60e59e1ac8d828b347cdb04e1cd3491f3058fa96d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-index-light-phone.png" - {:sha256 "7115af2a7f6680c6369308c8d7a95d046df86e64b23eb2620e4e671454180fd6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-profile-dark-desktop.png" - {:sha256 "fdb926f45716acac225a1e0de42061418b696a2e1b2a6285fc2fd68a929af2fe" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-profile-dark-phone.png" - {:sha256 "35e27a87d81abf1f3708b07eddc022400514da59a86b6f700f5bc45949564ea1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-profile-light-desktop.png" - {:sha256 "1ad087d4eaabf2b8a3ce027a0c24d51db47ab402a70c60f7f8db6b32b9825a64" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/after-profile-light-phone.png" - {:sha256 "0a6fc68592a227e7b6f067eea2b54e1e9398c4d3e9730c8dba525ff35d5ccf08" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-index-dark-desktop.png" - {:sha256 "ab1e2d38ddc50819739ee63e7abe89cfadfac1528dcafa73b8c3a828b2cf833f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-index-dark-phone.png" - {:sha256 "49c8946ca5d982ea369037f07e448ea468d75cd5599bc0b6b1474f37116331d6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-index-light-desktop.png" - {:sha256 "f08190c28709a8955a629df60e59e1ac8d828b347cdb04e1cd3491f3058fa96d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-index-light-phone.png" - {:sha256 "7115af2a7f6680c6369308c8d7a95d046df86e64b23eb2620e4e671454180fd6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-profile-dark-desktop.png" - {:sha256 "fdb926f45716acac225a1e0de42061418b696a2e1b2a6285fc2fd68a929af2fe" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-profile-dark-phone.png" - {:sha256 "35e27a87d81abf1f3708b07eddc022400514da59a86b6f700f5bc45949564ea1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-profile-light-desktop.png" - {:sha256 "1ad087d4eaabf2b8a3ce027a0c24d51db47ab402a70c60f7f8db6b32b9825a64" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/before-profile-light-phone.png" - {:sha256 "0a6fc68592a227e7b6f067eea2b54e1e9398c4d3e9730c8dba525ff35d5ccf08" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-index-dark-desktop.png" - {:sha256 "863f2a1e965d0d89e922e4ec129591b5a6c020b3ce322e3bac1a2bd4155587d9" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-index-dark-phone.png" - {:sha256 "e2865af58ac9f24fc3c0163572fe96a37c6c120562311db6a13e74d3bebdcaa0" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-index-light-desktop.png" - {:sha256 "325fd502f025cc6fdd4255c9f120c90176bfb7d3a6eb912e2f4b03668dbe815e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-index-light-phone.png" - {:sha256 "bdb842ff5ee5fb3f7c7f9fc075cab8f911b43c84b1a813088d48d92d7bdcd8f8" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-profile-dark-desktop.png" - {:sha256 "bbcb011f7a0b3464be35c85ff47b7efdbc6ce54e5e6f241ab9cbbf8fc01934ba" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-profile-dark-phone.png" - {:sha256 "4efd9fdbf0191ff5c3f5e997883166975efe48071d898a97a5ebedf427312a69" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-profile-light-desktop.png" - {:sha256 "88dec8f608955878c36a5a52119a69b5fe12b725d0e19e51b0ce2dfa7f23e908" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/examples-profile-light-phone.png" - {:sha256 "f211b116d60486ace50333702fd932ed90af56e8514923e4deba72acaa8a2cea" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-index-dark-desktop.png" - {:sha256 "ab1e2d38ddc50819739ee63e7abe89cfadfac1528dcafa73b8c3a828b2cf833f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-index-dark-phone.png" - {:sha256 "49c8946ca5d982ea369037f07e448ea468d75cd5599bc0b6b1474f37116331d6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-index-light-desktop.png" - {:sha256 "f08190c28709a8955a629df60e59e1ac8d828b347cdb04e1cd3491f3058fa96d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-index-light-phone.png" - {:sha256 "7115af2a7f6680c6369308c8d7a95d046df86e64b23eb2620e4e671454180fd6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-profile-dark-desktop.png" - {:sha256 "e2e159ea4322b33f6063e94ce374d726f77924664f2ca62dbb11e2c5faf8c1c9" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-profile-dark-phone.png" - {:sha256 "4660a4d8ad936ed26d7d8fbce2fa5359ad88f197e30d59fb52d4e8cb076c6f13" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-profile-light-desktop.png" - {:sha256 "6d5545e6559e8e04476a12a07cd3ac292f6e5878b3621184a4f82b864354763f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/garden/no-pieces-profile-light-phone.png" - {:sha256 "b708c4fccf15d8eee03e13582abaa18ab6ac9b8f1797be95d2c0f79e9c77d374" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-gi-delapouite-paint-brush.png" - {:sha256 "d5e8ef6cc7c518206a135d8d92b6abd9e0d499557ef7386a996e509f824344f9" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-gi-delapouite-palette.png" - {:sha256 "e137209ffb5e1b091643882eaa78c72a8160f95466cb514fe71644119b61fa9c" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-gi-lorc-castle.png" - {:sha256 "5c54a3d96c7573070085fee63269e94bd013ae8d08bb4e18822f4ae6981b9310" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-gi-lorc-quill-ink.png" - {:sha256 "9090727f6dd2432c05b79cbad17a3a7d218f3b35263bfa6a5a61a1cfd248332e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-gi-lorc-quill.png" - {:sha256 "5156b8ef423974e76a9d65465b6d5e83a392aa45d53fd43cd3c4dd4dfab2cc45" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-lu-globe.png" - {:sha256 "79e34e39df0ad99b4d1155828f502c53922a8d3f7a50d9ed9f61b9915dc9ef09" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-lu-house.png" - {:sha256 "966b52c0e0b606466f7852e997498e7199de1785c50d6a9fd7e3f301d9369000" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-lu-link.png" - {:sha256 "cc770f9f21760939cc7034ea6c891120eed358c6279c65053a73f16b6725eb1a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-lu-palette.png" - {:sha256 "aed7b4680b6c05c9d73504574a8db89565965a4f4f96cbbe71baa49454be6d0c" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-now-site.png" - {:sha256 "e9b002b7c771dcb14900e726e75775d1474f96e9436b27a2f5f840f747031b1f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-ph-feather.png" - {:sha256 "51ad0cdeee52363417e7cf2f9874b10f1ddf2778c611740475993118efe6928e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-ph-house-line.png" - {:sha256 "22fcc8ac9470df3027b48f773785b66bafbe9766a505a94bbe7a43add08a13bf" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-ph-paint-brush.png" - {:sha256 "3c33d0d9f790dd089d88df21ea00e09e20cdb69459415cad6ee09ce644f8c8c3" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-ph-pen-nib.png" - {:sha256 "9b467202227b86e9cc557bf2a526569d1c58797316661fcb9e3790512a12d5b1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-dark-tb-world-www.png" - {:sha256 "f01286a7f4ecb8fcef2ce1c2a71d50c925af5e0a12b91f8770394a086c1b905d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-gi-delapouite-paint-brush.png" - {:sha256 "fd6bd80f445422564dcd95ef82ba12e7d7d9c3215a6d32cc2ead3774c6cd34ca" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-gi-delapouite-palette.png" - {:sha256 "6aa3d91f2e6e2c8fa78aad8c18c9f5234f5f0cd4f8ffdbbdda9cb7bba0368e35" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-gi-lorc-castle.png" - {:sha256 "0683bb4f65982c0e2977b106a1597b718a41582aa5133a4b2dd5d69739555d2a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-gi-lorc-quill-ink.png" - {:sha256 "13b2e6fe4ad271b21ba707e009b30a855e3f813ee6d761bee774bbe74ac0cdd5" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-gi-lorc-quill.png" - {:sha256 "2b8d7693fbc746a1f2adda374946aa46b674c9c055e6f3dfb2e5837e660104d8" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-lu-globe.png" - {:sha256 "84097e5fb62d6774b3b48d9f9803580bd4589e07dda43d14f34b597d19724f82" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-lu-house.png" - {:sha256 "53b084cab338795e707815846eeebce21e17c898e7b443418c53dff6a19bb9ba" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-lu-link.png" - {:sha256 "24992a49c5414088843218c10c02e003a907d4b9fe22403283233ac09cdda8c1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-lu-palette.png" - {:sha256 "c0a0115d182b14cf5afc3e2a4b2f06fb4baa5e8fdd53d5ce810529ffcc467f24" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-now-site.png" - {:sha256 "31affe110a720955ef77a655b77279146e6d1a7b56ddf586c0617cb93ea27152" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-ph-feather.png" - {:sha256 "5d3aadec0d5cc82372b3aca2b939601cf846ad3bd20a4e1f913e0cd281b8d6d9" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-ph-house-line.png" - {:sha256 "4485ff7c3a4d10bc139107c97cc693095d6f50816fc208636d0a9113ca8cc9b0" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-ph-paint-brush.png" - {:sha256 "a277c73f2dfecf6b0aaecaf5de34770c66394375fa9ae877ee8bd7baa2985574" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-ph-pen-nib.png" - {:sha256 "fc29a9a8af41cd01c05ddf4c3950cce3188ad4c575369507b308914636e3593c" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/card-light-tb-world-www.png" - {:sha256 "5594c633f5f7a261fc8f4da0a977adb70b148c52a946bf9036f81bdef39c3a70" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-gi-delapouite-paint-brush.png" - {:sha256 "663b75fe7bedce1c9893427791e09e4a9cdff8c997f0d5817ca28ff6afb72217" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-gi-delapouite-palette.png" - {:sha256 "372968074671a74b194937b5ad51b1a684eb13c146c21c4b3fc6c65917444944" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-gi-lorc-castle.png" - {:sha256 "fadbf828274021b7cb2343315adb1dafb7952b354f67e771d1e2315bce21abbe" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-gi-lorc-quill-ink.png" - {:sha256 "d61c6f7ffd0ac7ac4977314224df39f55107e87cbc477edd6871bb97eeb1e17c" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-gi-lorc-quill.png" - {:sha256 "73578808267eaa905870680a279b624b6f020f55eee7edaf1b99416142101a4c" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-lu-globe.png" - {:sha256 "8f0a741125b498f8f5da155acc4f3f80a1df0419f7b9add46ef03d4f2608e48c" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-lu-house.png" - {:sha256 "1b622ca3f23e41eaebd564b475c93c031add2c356099c69d610ca777eb983ca7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-lu-link.png" - {:sha256 "f3f95c8685ca52774667ae69c637e2e9bb8b65e48bcf71eebcc08d98172e1bf8" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-lu-palette.png" - {:sha256 "7a7c718cbc90fd0317387c7218ab330298ee8c6d942878503a03bed55cb22408" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-now-site.png" - {:sha256 "e9ef7e6801a66b6a537912cb070b62930e8d912917ed77bce4bcc48c723a3da7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-ph-feather.png" - {:sha256 "0f0aec7d3b5aeadb9cb3e86313461a0970c641983191075f6762cb115ff7401a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-ph-house-line.png" - {:sha256 "8d64738a1187cc490e6d327a42d4a0aea85ab237d91b8ab2fbfd0c43591db8fe" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-ph-paint-brush.png" - {:sha256 "19f4979fe2efc75462876cc35eda72d923307a0458deed0eb7159f447ccbee52" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-ph-pen-nib.png" - {:sha256 "18ecbbd7af344d45fc416395ad3a9b267a759e264308877bf12f7dda8167769d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-dark-tb-world-www.png" - {:sha256 "2d45821878687ec88044aa2f28bb9c9cd847d823f50572c70059424466f8224f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-gi-delapouite-paint-brush.png" - {:sha256 "ea6ac56ba01d488b8b5b8f520c8482777af39fb31b1014caa5155c819b63bf13" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-gi-delapouite-palette.png" - {:sha256 "2be272632a069139010c82aa2c5be803d0a8e133780d60df094564b5cdb15c24" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-gi-lorc-castle.png" - {:sha256 "d07f6598743cf100e0f96e06ed92f6c535fd2b9470f14c191d8a52fd49ac2652" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-gi-lorc-quill-ink.png" - {:sha256 "a98b62ba32e393df613b7b7c2e2429094038dc8b7336892cb57b112d25d89c92" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-gi-lorc-quill.png" - {:sha256 "12924d805634f44b29564ca40f98326a4a44e393675c508eb17ab0823c4a0ea4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-lu-globe.png" - {:sha256 "1303bff6e3294a6b9d077e3ebdf46111d00ff590281f1b323d8982f4740dc4bd" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-lu-house.png" - {:sha256 "cc766cd781083a5b464cf9b72bea41b722f7a4b5926a720914f508b4dc7fd25b" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-lu-link.png" - {:sha256 "90049fbe435f40301eebdbefffe6ebae2a0e3fbec62de73213a55573282f66a3" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-lu-palette.png" - {:sha256 "1db96101fb5226520a2064131f1731d672b98a5f63176df3a79b8e45991fc5ca" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-now-site.png" - {:sha256 "9aaf4ef821c89cdb825f9d933fe9fe889bbe3fff4be3901897c3829561c7ec3d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-ph-feather.png" - {:sha256 "239dfa427fa7d38af4e91b77042e92b0198fc8986b7ea6478e9e6a1d1d1808be" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-ph-house-line.png" - {:sha256 "453cd398103bce92dd7472cb06b4b889c5f995a2e22c40fc64eb3f6df7ec4e26" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-ph-paint-brush.png" - {:sha256 "e447f0a4c6bafcbcca334766d513bebe729a67f3a557b4f71610352da5dccfd3" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-ph-pen-nib.png" - {:sha256 "5c077183b89360491680ac7502a91a3083da905b1f19434a9904200fb0ccc8ab" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/credit-light-tb-world-www.png" - {:sha256 "ba74c1ee19f89fef0b4e2988dd494949358f20ea9ca61fb8711a9c48eafdde6a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/family-dark-gameicons.png" - {:sha256 "5e68da0f456125c020f2cb1c647ec275b2232af70233f9b2cf533135978d8084" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/family-dark-lucide.png" - {:sha256 "8cded82f9686b6cf811d4e3c053bcdf9b9cf868ed6c6e0375dd70136e7d39748" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/family-light-gameicons.png" - {:sha256 "eaf71a60bf5e8f9264c79b6bc5d805a39b55718f554c0517fbcdf20d2d1ad548" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/icons/family-light-lucide.png" - {:sha256 "e2c237ea3c12896b044ee4a14494c1b0ba7a3115de1a87841810b2568060e212" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-amber.png" - {:sha256 "014d31053aa1be3684b66db030585db5e4cbaa6b2b9221c727af893c013282a7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-coral.png" - {:sha256 "dd2cdd2e4dee92770bcd030d5151d61c97af9be9fd1d9873abcc9a0396d35af6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-green.png" - {:sha256 "1270b547835a18f439c07905aab54c753e73a4f73887098b82a2249ff583e449" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-rose.png" - {:sha256 "ac2a1f3b7a95c566cae1bf48827ce43f0af6cbf3208fe43bdca3f187f385627f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-gi-lorc-quill-ink-teal.png" - {:sha256 "44925ba53cc76ab409da3cbaa72b9dd14970abe8f4e35198e62cd83f32d8cebe" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-amber.png" - {:sha256 "4456b960ce76a8030ef2a785794cf34c08ce3b3e37ac542ed53389651171f305" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-coral.png" - {:sha256 "754ab8258d5cb33b5d879a4404a8e1035993221e41d059b1640cdff7c7aa2b3e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-green.png" - {:sha256 "bc97bf56e34119e94c897aecd552bd4f2a38f363ad3d9b053cf4c0c2ec529a92" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-rose.png" - {:sha256 "a7dfa13d088f7fbe091aca908693f0e1a680891a5e2e617289d710daf417588e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-browser-fill-teal.png" - {:sha256 "65f58257797c4ae915f592cf8bfe743041ec59f73634bed2f90a9f68cca35bac" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-amber.png" - {:sha256 "c50b072bb967cc3fc494924c043060a35b56109620f96c2a95c9b53e4dc8ad77" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-coral.png" - {:sha256 "4ab1fdbf9d45aaaff1a4402b4cfafc371306ced511b754e1dddbc4a2b846ee9d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-green.png" - {:sha256 "474ace6fb7ba87f2fae841751106bf6d8375df24dec5ee881069a7dae2c23206" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-rose.png" - {:sha256 "30f204b6b4a6808c4fae70f25c03df70450d3994b066fbcf5bc2499db977af0e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-badge-ph-house-fill-teal.png" - {:sha256 "624799e305445633e607d456a91bf1835c23aea3e2c551a4b23ab30c2a0ae5bd" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-amber.png" - {:sha256 "91118d52c00de8fd2015ed94782a6bb3bc50a1e69e4a5e35164c34c0bd57c17e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-coral.png" - {:sha256 "d7e5c2fb904766089d652c6fd6e939bcbb1c841d54a88263f0235ddd21bb7392" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-green.png" - {:sha256 "18a71ef7806a2ec7a123a9c6291603a97ed055034c53ddb155940a416a435c24" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-rose.png" - {:sha256 "0abc937f26aa6f763a345490d98ac6b5a10557956c1f5eb7b46d453249303744" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-gi-lorc-quill-ink-teal.png" - {:sha256 "019a991d36a902e054618b495dd934cf2936eb9e8365482970eabff6d26015b3" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-now-site-amber.png" - {:sha256 "2f61cb70749e80a09dc19b64b84719a35c3564ab6113acf0add6e07b24c2d69a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-now-site-coral.png" - {:sha256 "e0632e7ba5a43d31152ec5957991c4ad58266ee9ee2f9e5971ae56526d2eb416" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-now-site-green.png" - {:sha256 "98560b9132d06a2dcdb19dc48368fd998ae7d06f07c1498e4f96363a18f2ccb1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-now-site-rose.png" - {:sha256 "edb19cc09d37cf95b6a19c312ec5b4d35cb21455300c33f2b2ea5d1eed64c9be" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-now-site-teal.png" - {:sha256 "4bc5721a728243b1c2f2b0c590f5311932eddfd467e5c21c4f5cceb33f813fb4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-app-window-fill-amber.png" - {:sha256 "85a9a74cd0c80da79560f1c1593b8f4e65ed8f17f681003be4eb17565e3fccb0" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-app-window-fill-coral.png" - {:sha256 "1630db2e57a6abaa0aaa4b186638dc6ae677886e3033bda08eed544925018020" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-app-window-fill-green.png" - {:sha256 "05fc2a07c2226cce26998d9baee50d06a062e29a0f016bda9a2efc3eafaa5738" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-app-window-fill-rose.png" - {:sha256 "28f74d35929abc0221f487724ed2af4cea9c76ca2164a7b29506a925ccaf7b98" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-app-window-fill-teal.png" - {:sha256 "3966e1b0e1b77e102622069180b8e29446d705176dbd2f9306edc952aded77ff" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-browser-fill-amber.png" - {:sha256 "f5f9c134d2d4e0ec4cc5d95d98b8f1eae7e4939d9bd1d08de5eee2ae1ff15e0d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-browser-fill-coral.png" - {:sha256 "bc66992845dfd5b36d2ebc1752a9dcb936e5ddbf1d4fd0fa0b224845957313ad" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-browser-fill-green.png" - {:sha256 "7ec4c727f4274160c7325687e828f3ce26e81ff07608dc7afda3c0acb27946d2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-browser-fill-rose.png" - {:sha256 "cae740f2476290ac83275c4c9df1849e293ccf74cb9c4b61ee7b4ffd48f877a2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-browser-fill-teal.png" - {:sha256 "31bf21368c1acae8b780859841dfe2e8db341b6daaa07b52f99716a1eb47a82f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-compass-fill-amber.png" - {:sha256 "0226a3552df77f7ed71b4164d8652ddb15bc8f695c3869efb5315d470e10dc35" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-compass-fill-coral.png" - {:sha256 "7ac2638d5304386fccc70bd5a61e3084e1ed859015ca88e5cdc4a8f48970b1bc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-compass-fill-green.png" - {:sha256 "106a39549967284fd86cf0dfcba07a0dce5794e122aa698b762d81e07797298f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-compass-fill-rose.png" - {:sha256 "a970475005a190f2c014854d0534009eb647db42d57d8b806df0797e25dfc88a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-compass-fill-teal.png" - {:sha256 "93c0d40f2f537eed523e2f529f51a83635419969894d35cbd8d82e64b393dd61" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-house-fill-amber.png" - {:sha256 "c33368c7445b6aca5113f5f05cc035706d658f33bc4b1aad97de00ac86939686" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-house-fill-coral.png" - {:sha256 "7032cd677f6df7946b93c270b40cfb4d866577122835f306d8a74e200f95fb4f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-house-fill-green.png" - {:sha256 "f860ed70ff4346f75192d2da30b9f3871bbb57635cf6571ec6d3f13d12153f79" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-house-fill-rose.png" - {:sha256 "6baf007bbf1aa51415624a262737bc9b5d649930a67097362adb3b8529772894" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-house-fill-teal.png" - {:sha256 "7e7520f39785138e3469635972a85188a233b407ed6e92945d8818264297d5cc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-amber.png" - {:sha256 "8e0164d27e66d3c0aedea101fa44e51433369006f4a0c0ca71f7501d05609e45" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-coral.png" - {:sha256 "fd89218e12b3a0e12d3e4bf4d232fad67d253db5b21d9f0188e1f20b1f5fa204" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-green.png" - {:sha256 "6fc7f9c0e267d893f7be17058d2f9a3784f8ed4f78eebd3305ff5469c7e9d01f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-rose.png" - {:sha256 "db7c7b9a3e384148617b01794f97100649813818f21a319082d23c2b6eccc5d6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/dark-ph-sparkle-fill-teal.png" - {:sha256 "88ca6936ab1d3218a8e74b8eb29556dad3c3f8e0ada35df23d18ecaebacd2440" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-amber.png" - {:sha256 "9ed64d112cd087d80779afac2d7e60052158ac8ad2347d3830b1bc351287c8cc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-coral.png" - {:sha256 "ca31b3f0ff10ac7e456020d6f560daa72db9fbd49e2744cbd5b836f37489503b" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-green.png" - {:sha256 "a7e98729542d622b2694d7ee080e8a6b53d96516dc8117d255918d14b64f1024" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-rose.png" - {:sha256 "15b945d34473ea3d57f0ad3731403b5da5f9d9a65be591ee6139342c5498bbdb" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-gi-lorc-quill-ink-teal.png" - {:sha256 "2de8d775447b30386e5f92dc572aebfc80ad6a06319144e6077b7694f97edb01" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-amber.png" - {:sha256 "3bdaafd320d6e7f960ef4e6c74e926d0a698965dd4ec676ba4de7ee0a74c1db0" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-coral.png" - {:sha256 "577e1620c018745237b9d0b09a14e42c7708b0285bee643824c64ce1760d4626" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-green.png" - {:sha256 "996a2ab3248588e2e8a77847d3c2249ed325fa0d302bd908659971a965769d94" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-rose.png" - {:sha256 "e73869e4739d0867c506e30c80746908096c67e1bdadadfb79c25923848f7945" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-browser-fill-teal.png" - {:sha256 "19a130ee11427e1850dc8b9f67d8f4a334bd88950ed4726ae5ac3672dd05b0d0" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-house-fill-amber.png" - {:sha256 "84e2afe13c1bb7c7c9c18cb830ca24638020280964095f3014a23d2fea1ff31f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-house-fill-coral.png" - {:sha256 "488a36279a1bca6bc53b88d31d3cd3225774100ae857d03bfa67205a28a34974" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-house-fill-green.png" - {:sha256 "430649ddb46e17b4f8b7c7fdeae51395fd3b17df5d5f9b25ea05012bacb6a5ef" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-house-fill-rose.png" - {:sha256 "e33ea621ce090be1d585f27c22b4253b2551b542e489396cec0eefea40321b72" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-badge-ph-house-fill-teal.png" - {:sha256 "11bac0b8b100786aa43c62b5374f0d7391383d2042c4c6797c35dca1d241ead5" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-amber.png" - {:sha256 "c5c843cc15bbff3f20fe516b6f69b2d07ecc92ab3def83a5511416549d23085d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-coral.png" - {:sha256 "d6f3e05b4b8f06f8a111771f1b47ef15567aeb7bae4c6a8bb5833d09ba78cd69" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-green.png" - {:sha256 "8fec823b8cdcb1ce615737d2c2971335b9e37b9f1885b7dfd9f85af737cf31b5" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-rose.png" - {:sha256 "18036d38996025715b02d4e9fc2b7cb83fc0f3fba45851097bacecc295a90829" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-gi-lorc-quill-ink-teal.png" - {:sha256 "ca617380087521034e66b913bd8bbee0aff7e2ee0e49741688b0bc611e760938" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-now-site-amber.png" - {:sha256 "35df027699b0a5a01e4e19b4d246a91b9a6accf88c3e35a319e4da0946ee8aec" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-now-site-coral.png" - {:sha256 "7a7a7cec6a3c1828aa64e6d666df86f6fa263ba10732aafe24cfdf16273e3b25" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-now-site-green.png" - {:sha256 "047ec1796f178183c49cb768f769b11d9ccf2b7dba480efae07fb44a1fef78b4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-now-site-rose.png" - {:sha256 "0962f591aa7d0e7709cc2c896dc627e5a9a6882baeadb736f58b0cd3257aaf8b" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-now-site-teal.png" - {:sha256 "5c54de2063ff8faf7541beefab8ee9a2de1d06d7ef20e348e0df51ed92f7b5ae" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-app-window-fill-amber.png" - {:sha256 "86326769f455d88e87c93271969a1bf4be73ac3c9d0ce8c5cca2cb19c5482666" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-app-window-fill-coral.png" - {:sha256 "58f60de0d3de2ed7ef1a38424adffb42e930d0f4299846b688fd3fba9662efa1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-app-window-fill-green.png" - {:sha256 "632375be7330c8577da3df7f678e05bd0132553017ee138921a117d54a8a43e2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-app-window-fill-rose.png" - {:sha256 "e67d031befca1ed5acf5774852e368c36efacde3b736fd8a053593ae40ae77d7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-app-window-fill-teal.png" - {:sha256 "f4067c5136547ef427786cfa5eece5f47295033a6e85628b47d61abebe634c95" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-browser-fill-amber.png" - {:sha256 "6337e1c2107e9493eb7178f68b0bbd65d1f9c8527b35392a59a1214b5b174ecd" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-browser-fill-coral.png" - {:sha256 "fa909b396ab5c617169661d807edfb7b38b39bc997aaebe28e41a78dbc05f791" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-browser-fill-green.png" - {:sha256 "69aca7781910877b3f68ab27153ed5beda13576cb2b4b6d1eb15a0a5a74b945b" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-browser-fill-rose.png" - {:sha256 "d1474f467a70f9916ece5d90cdc96adb35e45387046dcbf61dced12b71229106" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-browser-fill-teal.png" - {:sha256 "4f4af564f22834511b205911cc67805a9cabd257ac3f432b902e707ec896fad2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-compass-fill-amber.png" - {:sha256 "948271eab09a379f7f58c905db205e103c1d5f854aa9e35819f37c5c6bf4040a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-compass-fill-coral.png" - {:sha256 "d5124525f926396975bf8eea7d458f96cf3de8ae54eae8d1f53c26bbca057d00" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-compass-fill-green.png" - {:sha256 "4d528f59dcda2e91428716214c6c063012c9887d591371efdc329094897dfd67" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-compass-fill-rose.png" - {:sha256 "3c053a61a0d321709f7ee3f178fdfa39aca68ea45eb8f1e82fc8870589aefb10" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-compass-fill-teal.png" - {:sha256 "5888a4b6f9e96aa46107809a7f3667fde7b13353f010c8469df49e27d02779ea" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-house-fill-amber.png" - {:sha256 "f5005b3bd4a4247933afc3bb6672b246d9824a774820f4a36606ec8aa79741a5" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-house-fill-coral.png" - {:sha256 "7027d0f99e62b14aed14f5687715a81184f5af31977712d024619cc5c376dbc1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-house-fill-green.png" - {:sha256 "79718f194e2785b6d64e3820c1a768056075024027767b43d81b760b106ad5c0" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-house-fill-rose.png" - {:sha256 "885cab6522d35ab1f457bba2095dae756cc3ec7445c0f281c801dfee9a19e6dc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-house-fill-teal.png" - {:sha256 "c5caa9bb705d6eeda53b4cba976ac2a7f9b176e21f40e930a2e787799c5df054" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-sparkle-fill-amber.png" - {:sha256 "8c18f3f1b34965da0ee386bea01f5471d5ce7519e11e7507644a52e505f4e722" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-sparkle-fill-coral.png" - {:sha256 "d3bd43c6f3ce5470a2200e575a4ab2c9626e0cf62ce20875a86e6c4452cd0514" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-sparkle-fill-green.png" - {:sha256 "4d6dc65795ab317a173d7d77ac20ac598c96d961db2829a30f98a88f903dddb2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-sparkle-fill-rose.png" - {:sha256 "e8b5e982997862409313ff0847bcd3ba39b9bc3d5d8a8a6ef9b2f7323a48f12e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/marks/light-ph-sparkle-fill-teal.png" - {:sha256 "abece46efc4952b233def90401e41d4fa4acbd3e1416ee3e885770c4440e39de" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-dark-ph-browser-fill-teal-paired.png" - {:sha256 "992fe5dafe1b4aca51cb38b9bc5222aee91b986a91623d53eaf1167838000a61" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-dark-ph-browser-fill-teal-shade.png" - {:sha256 "b3e9be779c6a197f75abd951c2b8523d1a2c8ce8e1370e962d7f4e0d23b3a394" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-dark-ph-sparkle-fill-yellow-paired.png" - {:sha256 "9df1bf9f2ca1f056ee3564f56d8375e3180c434754b648a93c2dbe32602666ba" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-dark-ph-sparkle-fill-yellow-shade.png" - {:sha256 "f0b3649c25ab3ac3702e94f4ac3e9b796e9b8f83c06aec96464f58f46255242e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-light-ph-browser-fill-teal-paired.png" - {:sha256 "5df6072ce2ef9fb8d15f15722c433d6599467666b6529b7494d527b5809ecd74" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-light-ph-browser-fill-teal-shade.png" - {:sha256 "810c4ad186c6bdcbf9cef77dd1be505f4dd637575cec9d9611012f61f76bdbfc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-light-ph-sparkle-fill-yellow-paired.png" - {:sha256 "a25cbcd1c30c2b2d967209f7124c95a2fb4d02386027f516222554293fe42d24" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picker/picker-light-ph-sparkle-fill-yellow-shade.png" - {:sha256 "f532007ac5b6d82dc9e4eeca305284e6c3405d86308f0e72d6dadf51f8ce56a6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-browser-fill-coral.png" - {:sha256 "14aff43bd8061ca81349e17cc6ded020ba6c3eb964871a0a1b22d0a75956f5aa" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-browser-fill-teal.png" - {:sha256 "9d0dc6f208b3473669251697dc111868ad610e14f0f34c5948b88540627dc0db" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-compass-fill-coral.png" - {:sha256 "84961050b58226fbfffee1ca1026411c14a44aa320e32f0b47b06bcd21fe7a27" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-compass-fill-teal.png" - {:sha256 "f78200d6b745febe7d13f6f0ed4e9655aebb9a62de191d61e0afc594fb0cf398" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-house-fill-coral.png" - {:sha256 "6a18dcf3891754e6445ece408747c0fad9303471602b166463a2206be1fb0955" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-house-fill-teal.png" - {:sha256 "e31993d23c8df66189062189d9d2f2ad0c41c21518f71b9c0416e75baf73290d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-sparkle-fill-coral.png" - {:sha256 "165aefb3e488f6d7dd7db64b8b8b5e1ed406c4f00c67de0c2670d2a3ede9cad5" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-dark-ph-sparkle-fill-teal.png" - {:sha256 "37b0e4333d490fe746afb73cdad839e56726d556902e7e4ffc09d07767949e10" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-browser-fill-coral.png" - {:sha256 "93675572cc90bae292d6f934eeefdcc87a0cc9d6531aa1f03eefcf44c3e33cac" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-browser-fill-teal.png" - {:sha256 "c880acf668a9d82726ab103b18dee95edb5c88df463fcf495c85a15817d22ac0" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-compass-fill-coral.png" - {:sha256 "417346465e13045fa3c56c1914a8ae2d62a3762f01607fbbaa86ac50a0df1262" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-compass-fill-teal.png" - {:sha256 "89934efc6d799648758822900afa735fd598bc3fc94b03a601965430871cb372" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-house-fill-coral.png" - {:sha256 "82b88c0d81eb27be7d7f9af1b354f4ee635814ffa167ae2cf8abb83f918c640a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-house-fill-teal.png" - {:sha256 "37c12e7b5e89b5e8cf9debddf101d569ba32f1df574c807d59960371021526ca" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-sparkle-fill-coral.png" - {:sha256 "d08524222cea17718148ec45aa142baadcbc753a4174996c6239634903600ffa" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/card-light-ph-sparkle-fill-teal.png" - {:sha256 "1cf992d9669feb101aa338136a7d5cc2d2108f604d1e36e7d305019477c53e7f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-browser-fill-coral.png" - {:sha256 "4ad71dd07519aa4c63263a1786aa35b4aa69b3abdb71939804aa85508911c11d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-browser-fill-teal.png" - {:sha256 "5c2a807b181f85c27c303a69d228a2454feb4dd7fb2eb6c0ef96f291588d66e8" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-compass-fill-coral.png" - {:sha256 "ad9cc773f14e51739c01dbb713d4e9e5305c882d046353fb69d07eb26a631ad7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-compass-fill-teal.png" - {:sha256 "5ef0f323f6f52edbbf4f5ffd1b3f4e3bfa2bb4cac3bd266eb1808e896d278976" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-house-fill-coral.png" - {:sha256 "0f8ffb59a577533ff384e6362b95bf604106a26aa5dab6421597c0b40758372e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-house-fill-teal.png" - {:sha256 "53d2197d2f1c2c8b6b8930d9d38ac1df52f89813e68ccfe50277dc84344684eb" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-sparkle-fill-coral.png" - {:sha256 "8678dcf96a9a1fcc66d90f7057dfbd8f42542ffb1044f410f6e054fc92f57bf6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-dark-ph-sparkle-fill-teal.png" - {:sha256 "2ff75b7ebd314fa1f240a58f59a31339537c9ab6f22dd7158bac091fc8c345f7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-browser-fill-coral.png" - {:sha256 "5ec10484eaff48da44f2e048eb80da6966da0b5777855d8932c8a8cdf6d33ee4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-browser-fill-teal.png" - {:sha256 "9c56328796a1f3179b0f2e9cd0ae7e6194a48c83275938b558fce07ad6732f60" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-compass-fill-coral.png" - {:sha256 "f1b4cb5d8183da6c971000c221d0db7858b9a6b5596e7f5d95d9ce94939f178e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-compass-fill-teal.png" - {:sha256 "ecf959dc3f085bacd33a7c0f54cffd7214fd8366b8750c8aa140ac5c15018e07" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-house-fill-coral.png" - {:sha256 "c5c38e3172e68da746e785595a3d722a9cd0fe62710447bd7566654622973e16" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-house-fill-teal.png" - {:sha256 "23a62f3dfec430beceb369c5224187a9ac71b1768b22536c6da495090c9642e3" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-sparkle-fill-coral.png" - {:sha256 "b24cb6ef63e4e3ec7fb75383c1cffaa7e10cfa1d37b0de279dbcedfb08d7cd20" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/picks/credit-light-ph-sparkle-fill-teal.png" - {:sha256 "e570cf7b53e601c83395f29e171e04f36c81bf218e9df2b4a017949a28c2f3ac" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/drawer-credit-dark-hover.png" - {:sha256 "4d5259ca37ceefdba9de0d040a9d52ee6617ad44d7b1f57e7b5dac0ada13b095" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/drawer-credit-dark.png" - {:sha256 "f917b143968ebd7edc0e4596da596a064927f31121351a2c416b990e15ab7219" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/drawer-credit-light-hover.png" - {:sha256 "62ca89ac8139cb79f59f43e3eae589d4cb82e3cb1af304e5174a24764601c425" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/drawer-credit-light.png" - {:sha256 "d41a6b56e8ce9fa0a58a8de0358b4fae5d3d3a1f823dd00cd2c6ae7a9352e82e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/index-dark.png" - {:sha256 "71cb7d51e46aec4bf21c9195440b8194cd68db5bd0f8e5a1d197e7fc3985e4fa" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/profile-dark.png" - {:sha256 "a8125b2e3a6ca40e6dd94ce707b926a514e5213e1762d38ec895c4988dba4e68" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/profile-light.png" - {:sha256 "69e579024d00023cafbf1be9c69e93183411280f4beb6c103a4e26889e4406fa" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/shots/profile-phone.png" - {:sha256 "063dd00472a7ab788bfb4d18de885c1757e40b00a3c3d0767576c25f3aaf0f9a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-bloom.png" - {:sha256 "99070dbba3fde99e92ec71042f9778eb025d9507c32279e6701d00339c9955fc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-ember.png" - {:sha256 "bc66992845dfd5b36d2ebc1752a9dcb936e5ddbf1d4fd0fa0b224845957313ad" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-gold.png" - {:sha256 "a2551b0b457bea39734f6a375c08c8ff3e7dc412578c08335a5ff87a6e47465a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-moss.png" - {:sha256 "7ec4c727f4274160c7325687e828f3ce26e81ff07608dc7afda3c0acb27946d2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-browser-fill-tide.png" - {:sha256 "31bf21368c1acae8b780859841dfe2e8db341b6daaa07b52f99716a1eb47a82f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-bloom.png" - {:sha256 "ae27daa1e085c41d09c4a107f4dc5065ffde63375477580fd59ac022a5db6815" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-ember.png" - {:sha256 "7ac2638d5304386fccc70bd5a61e3084e1ed859015ca88e5cdc4a8f48970b1bc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-gold.png" - {:sha256 "aed271fde5a18718491b01cfb71c1b916160be69fbcd21807be9840201523f14" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-moss.png" - {:sha256 "106a39549967284fd86cf0dfcba07a0dce5794e122aa698b762d81e07797298f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-compass-fill-tide.png" - {:sha256 "93c0d40f2f537eed523e2f529f51a83635419969894d35cbd8d82e64b393dd61" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-bloom.png" - {:sha256 "310a20cec2e5cf973b8753f40e8717fc538bd89024301f84d95f3f812c2c3061" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-ember.png" - {:sha256 "7032cd677f6df7946b93c270b40cfb4d866577122835f306d8a74e200f95fb4f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-gold.png" - {:sha256 "40f660abf0029ffa4800851d513948b55a9b750a4b4e1ce3d5a2a57345715791" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-moss.png" - {:sha256 "f860ed70ff4346f75192d2da30b9f3871bbb57635cf6571ec6d3f13d12153f79" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-house-fill-tide.png" - {:sha256 "7e7520f39785138e3469635972a85188a233b407ed6e92945d8818264297d5cc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-bloom.png" - {:sha256 "79541762ed7fee04cb53d345d3e477c3e75894f7ba53620cc31e9965f39209c2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-ember.png" - {:sha256 "fd89218e12b3a0e12d3e4bf4d232fad67d253db5b21d9f0188e1f20b1f5fa204" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-gold.png" - {:sha256 "35f01e2d89bd0d66e62da9b91161cd8e347bc0a93bd3cbcaa00443486ff115c7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-moss.png" - {:sha256 "6fc7f9c0e267d893f7be17058d2f9a3784f8ed4f78eebd3305ff5469c7e9d01f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-dark-ph-sparkle-fill-tide.png" - {:sha256 "88ca6936ab1d3218a8e74b8eb29556dad3c3f8e0ada35df23d18ecaebacd2440" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-bloom.png" - {:sha256 "249a46e88da3891fb449294865910f145eeeb97625d7d9d29525c0f6e11ade87" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-ember.png" - {:sha256 "f179b8c4a56f3405da94a1ca43a09a90796b7dbc0532e3d17b5376a69e43e317" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-gold.png" - {:sha256 "ad0f6bef130058ca14124787dd2d176f099de58ecf2e21bcf8c22d5396b074e8" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-moss.png" - {:sha256 "3e288e5c95c781a29c46b9ef3027cb8835b3266d54bd8e1794d77f7c2cfe96af" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-browser-fill-tide.png" - {:sha256 "c7245e5af658ca36ba039e634115d8475277a98aee48f56f20ce3db1dba07afa" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-bloom.png" - {:sha256 "6e408fbb81070a509474c63a2db9141fdd26d8e11989c70c83cbf475a6c38083" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-ember.png" - {:sha256 "119bd5ab4b471e068b8d0da33907226b11d45296de6cbfc88115d89433b559a4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-gold.png" - {:sha256 "b07941b9c1cc6d3080d8f5664a36295243cd0e45173369e99db7e3286976c421" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-moss.png" - {:sha256 "91d00b4d2cecf7060019e6895ac6e84a2ba91a714e0348210427eb9d38bd21c4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-compass-fill-tide.png" - {:sha256 "66dcdc21d527379e01aaffdce4c14c5d7493f142bc83805c16713cf00edbf4ee" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-bloom.png" - {:sha256 "06278b2caed1e69f4b5132fa6a4ff298fe93821463fb371df728e08b6cfc822d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-ember.png" - {:sha256 "bd4119ca024935eb7a62015d54f67ac45a9cff28c1c9b37f686800c2f25e4ad9" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-gold.png" - {:sha256 "43c22cc0bb7b58c112e13f0f5815a4038dfcf60fd83795c73e853b616fea95b6" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-moss.png" - {:sha256 "889a6f590ee4fce244cae4cec0a3d31422b1b737885e10a50be0d6646ae81e50" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-house-fill-tide.png" - {:sha256 "8b58d4e74ae72ec154c96f67bc6f9a74a44bb8f6394f2b9cde253841bfb71424" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-bloom.png" - {:sha256 "faf506ed1e95fc5c6adcc5646b9cbca783a09769c3805fdc35c286071550ef12" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-ember.png" - {:sha256 "f8aa1eb0b56f1e269a4fb5e1a9732af8378d478873d84940108f492154a6b33c" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-gold.png" - {:sha256 "e6478003249f0c163622cbae523d931319208f6317d7d060b4eaa5e18405458f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-moss.png" - {:sha256 "07572c87c2e49efbe1b0a5b8210c5fd64685a05b7e182548298f3d3758c58bd4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/credit-light-ph-sparkle-fill-tide.png" - {:sha256 "83b48d32a44604686e6dbb0a4e0fd1d36b22f1af6b577e6d3fbfd469967e2820" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/panel-dark-ph-browser-fill-tide.png" - {:sha256 "501595599e37ca4063006adc469379f0b5c3e1c87684a91202e396881c7a589b" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/panel-dark-ph-sparkle-fill-gold.png" - {:sha256 "4231cda84004b721fba789cefab983961edd713e53990aa47cf2be70ee45f7ae" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/panel-light-ph-browser-fill-tide.png" - {:sha256 "846f4892f73ac2c51c0fbfbec2bf37ebd57eaaaf1c08d05a8e1c965c4d975a93" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches-rehue/panel-light-ph-sparkle-fill-gold.png" - {:sha256 "bb6a9314f5fc982d0d1a5a1dded3c596d5edad274d500d8720187238ef2a0cdb" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-coral.png" - {:sha256 "bc66992845dfd5b36d2ebc1752a9dcb936e5ddbf1d4fd0fa0b224845957313ad" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-green.png" - {:sha256 "7ec4c727f4274160c7325687e828f3ce26e81ff07608dc7afda3c0acb27946d2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-rose.png" - {:sha256 "99070dbba3fde99e92ec71042f9778eb025d9507c32279e6701d00339c9955fc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-teal.png" - {:sha256 "31bf21368c1acae8b780859841dfe2e8db341b6daaa07b52f99716a1eb47a82f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-browser-fill-yellow.png" - {:sha256 "a2551b0b457bea39734f6a375c08c8ff3e7dc412578c08335a5ff87a6e47465a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-coral.png" - {:sha256 "7ac2638d5304386fccc70bd5a61e3084e1ed859015ca88e5cdc4a8f48970b1bc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-green.png" - {:sha256 "106a39549967284fd86cf0dfcba07a0dce5794e122aa698b762d81e07797298f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-rose.png" - {:sha256 "ae27daa1e085c41d09c4a107f4dc5065ffde63375477580fd59ac022a5db6815" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-teal.png" - {:sha256 "93c0d40f2f537eed523e2f529f51a83635419969894d35cbd8d82e64b393dd61" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-compass-fill-yellow.png" - {:sha256 "aed271fde5a18718491b01cfb71c1b916160be69fbcd21807be9840201523f14" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-coral.png" - {:sha256 "7032cd677f6df7946b93c270b40cfb4d866577122835f306d8a74e200f95fb4f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-green.png" - {:sha256 "f860ed70ff4346f75192d2da30b9f3871bbb57635cf6571ec6d3f13d12153f79" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-rose.png" - {:sha256 "310a20cec2e5cf973b8753f40e8717fc538bd89024301f84d95f3f812c2c3061" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-teal.png" - {:sha256 "7e7520f39785138e3469635972a85188a233b407ed6e92945d8818264297d5cc" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-house-fill-yellow.png" - {:sha256 "40f660abf0029ffa4800851d513948b55a9b750a4b4e1ce3d5a2a57345715791" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-coral.png" - {:sha256 "fd89218e12b3a0e12d3e4bf4d232fad67d253db5b21d9f0188e1f20b1f5fa204" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-green.png" - {:sha256 "6fc7f9c0e267d893f7be17058d2f9a3784f8ed4f78eebd3305ff5469c7e9d01f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-rose.png" - {:sha256 "79541762ed7fee04cb53d345d3e477c3e75894f7ba53620cc31e9965f39209c2" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-teal.png" - {:sha256 "88ca6936ab1d3218a8e74b8eb29556dad3c3f8e0ada35df23d18ecaebacd2440" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-dark-ph-sparkle-fill-yellow.png" - {:sha256 "35f01e2d89bd0d66e62da9b91161cd8e347bc0a93bd3cbcaa00443486ff115c7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-coral.png" - {:sha256 "42d84f3ce361fb81fd64ebf7a2ef1a9124df559c43c51e9d2b6cbfcc24f031be" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-green.png" - {:sha256 "a25b0ef213f138a25f4a9abc326e15826659e66a9888b12bef17bfcac6671032" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-rose.png" - {:sha256 "14d51e136c32b71cc174b3dd219f93cc9ae5b3cfa2bcce78c487612d2b57c6b7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-teal.png" - {:sha256 "711971daaf3ae1268e14bdda0bb08866317428fb52efb2c206caaca0c20984f7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-browser-fill-yellow.png" - {:sha256 "c6a2944edb2003d9f512490e76a6febe15735b67d3dca066e4be3533e569b94d" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-coral.png" - {:sha256 "c8c3ee597101a950086dedb3602d8b3e72e94edc35b2fbbe394ae205075cde46" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-green.png" - {:sha256 "c9a22ed41c50eb3dda48a2b4ea1f5ede03c788efe1b84266e27a9a69127b2b0f" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-rose.png" - {:sha256 "feaff0fb4b222f0a31f8bb9e0ebf1710c6a9fa6f33740b11ce6cecaa03a32e57" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-teal.png" - {:sha256 "495de7e23203f4202120509f022d46248455e25757d6b89344e4ab8fb5360fda" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-compass-fill-yellow.png" - {:sha256 "f2b7c8433cd3bc3a9f3c0faa8010a7c560763780e7c1b0deab9b9673b6713ba1" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-coral.png" - {:sha256 "7dcfb7ef4fb771f8a742e08809c477b1d17dffad18ab324f87c34f2a38fe8599" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-green.png" - {:sha256 "808d9852cd2e3d0c23d2a9507f6b3a55f71bf90dfb7b835e4de67a354ff704a4" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-rose.png" - {:sha256 "7222beb5d0fc417e8b2b97263d7c387746346361da1db182ca5d56d650a68ba7" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-teal.png" - {:sha256 "b4d3d6bc92952184096be95162bc737a0b419dd1134b259250360915df8c3fec" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-house-fill-yellow.png" - {:sha256 "56ba1a186e9ecab99c7ae3b3addc3b1f4c9860a7b8c26ab6b5f3730528792996" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-coral.png" - {:sha256 "4d890b346dd28f445a4718937a125d785666817663ae46c038bafae6f12ffb72" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-green.png" - {:sha256 "aa4b24ac5d6a7ec7510f5752190d45a9886b5ed41d92949f1fae17e5c1e24957" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-rose.png" - {:sha256 "ae801f4e3182ee33092ca9ce50d2ff28437ca64e1c69e5d8eade99eebfa11bdb" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-teal.png" - {:sha256 "d9b20a6d75ebc79026d2cdd1ecd12ae852ee1bedd1db0372f31f731963fe3333" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/credit-light-ph-sparkle-fill-yellow.png" - {:sha256 "99743f1e74c378e397d2704be9240a8f213ff7e8382475520dc8245183b5d0bb" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/panel-dark-ph-browser-fill-teal.png" - {:sha256 "7fbc8bb106de526be7996a632420388d92936bbc1cbbee682657723edbcea70e" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/panel-dark-ph-sparkle-fill-yellow.png" - {:sha256 "a9641791195539ffd6f6822f6847dafaf03ba470b94872eed7193e9239d5b44a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/panel-light-ph-browser-fill-teal.png" - {:sha256 "a375b9eac0c6e8ea5f7e3833fef5afd5e431ec0a0bececbfd7e2f851e965b795" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/artist-profiles/swatches/panel-light-ph-sparkle-fill-yellow.png" - {:sha256 "e6cb1987098e5badfb2331a76b88d27ba24337d9e767d7619b3b201067b89f8a" - :what "owner-reviewed 2026-10-12: silhouette composite / CSS render / icon library"} - "docs/design/style-guide/bold/dark-after-builder.png" - {:sha256 "de0f77e4eb4e172e176e48e7e8998ab958305e5bce8bae893308f6401a1d188a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/bold/dark-after-button.png" - {:sha256 "46ac76c6431a9fad830ff75fc19705edf136870d0113157a58bb47bae96e2f61" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/bold/dark-before-builder.png" - {:sha256 "37979b43879694ed303491ca3318dfd00f0f2ae3d2b356efa892446ed5c03dc4" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/bold/dark-before-button.png" - {:sha256 "46ac76c6431a9fad830ff75fc19705edf136870d0113157a58bb47bae96e2f61" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/bold/light-after-builder.png" - {:sha256 "98f1908796a930761d6b6f31156c4845d3fac734ec56515a54159c527c79147a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/bold/light-after-button.png" - {:sha256 "6fac77aefb9cdc62e3e89cd61d17a1543b37dca533a090f9666c2f73c0f8d4e7" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/bold/light-before-builder.png" - {:sha256 "be558b5001a2e4c8d719c0fd2af8f5990ed7575eddeaa6c81f15db557e1e56d4" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/bold/light-before-button.png" - {:sha256 "6fac77aefb9cdc62e3e89cd61d17a1543b37dca533a090f9666c2f73c0f8d4e7" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round10/roll-i-white-2.png" - {:sha256 "5be9f1137dfb4b60f8d6cc9ef690ec61ecf09015e32dc8bc239fca1f7af7776a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round10/roll-j-yellow-1.png" - {:sha256 "08b94d3806d3756c4154d03a0c69d781534effcc4c42653adbbb4ecb6c24dc7f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round10/roll-k-yellow-2.png" - {:sha256 "dce25b6e06b7cf0810386f62b02dfdeaaf617b27471fb9bf3819159392156223" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round10/roll-l-paleyellow-2.png" - {:sha256 "c7d36ba5ea4e2cea87acb0472fb051ddb86bf12b1d0c6966d896d07b2199d8ca" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round10/roll-m-orange-2.png" - {:sha256 "7a651a83a79a44df7b80063d44cf54bff48d0d6fcdd3f40ac3707fcc0ef9747b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round10/roll-n-amber-2.png" - {:sha256 "82c0ac0ce2e75054bdaed66444209ca8fcf659434b523deffce63b01f0649994" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round10/roll-w-white.png" - {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-k-built@1.png" - {:sha256 "c7646581d90a9791c1795045412cf469d66412ab368541e8c932b836984d1e05" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-k-built@2.png" - {:sha256 "dce25b6e06b7cf0810386f62b02dfdeaaf617b27471fb9bf3819159392156223" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-o-reverse@1.png" - {:sha256 "0796b7ee362346d79dae26de957b812876e721ee286d81d8da61de5668f2a863" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-o-reverse@2.png" - {:sha256 "4a9b8afda0aecd5a2d98df222eb2e9660a72a7565a2b1b5ec6f08e4ddb725a67" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-p-bottom-solid@1.png" - {:sha256 "3aef6b118fa67ced340c4c0c7126f096292197fa3a60e3714509c38bdaed7b58" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-p-bottom-solid@2.png" - {:sha256 "71f9b5c4a335ad24a037a6d1e599624558154787d42930907877369c07b30b1b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-q-yellow-reverse@1.png" - {:sha256 "4599315add23bb6eeba571ad402efeb1b2bdf6cb7252b2bc11acf5fad250cbcf" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-q-yellow-reverse@2.png" - {:sha256 "c505cd51edc907958c53faf6f0b53f4e017be60c47356df4e4f0271e812f769f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-r-bevel@1.png" - {:sha256 "5306058589376d2bcfd8a84c7e6292daf08ba3ffab1ec033f28f1be9c84c93d2" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-r-bevel@2.png" - {:sha256 "1c10c06b6b6b822f53192c3c355c7c41c6a767105b6c3ac4d3f9fa819dd3eee0" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-w-white@1.png" - {:sha256 "1db680a30bc118e0f9fe5bcc9f50f76db838d114ed6f21831e3f1b6e095ec017" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round11/roll-w-white@2.png" - {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-glow@1.png" - {:sha256 "230103e0e39ed5c552cb9d1cc9847ca113f898ca1f20c972b5538f1379ce39c6" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-glow@2.png" - {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-lit@1.png" - {:sha256 "4599315add23bb6eeba571ad402efeb1b2bdf6cb7252b2bc11acf5fad250cbcf" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-lit@2.png" - {:sha256 "c505cd51edc907958c53faf6f0b53f4e017be60c47356df4e4f0271e812f769f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-rim@1.png" - {:sha256 "c7646581d90a9791c1795045412cf469d66412ab368541e8c932b836984d1e05" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-rim@2.png" - {:sha256 "dce25b6e06b7cf0810386f62b02dfdeaaf617b27471fb9bf3819159392156223" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-white@1.png" - {:sha256 "1db680a30bc118e0f9fe5bcc9f50f76db838d114ed6f21831e3f1b6e095ec017" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/14-white@2.png" - {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-glow@1.png" - {:sha256 "2c1e5c330f1cb15efb51fee6f6b113f6464859d97624b178004e35500cfa2085" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-glow@2.png" - {:sha256 "4d6bcefba5a2d7efd4410fa0e1797ce67614ee370f4f1168b28b3a39d81f5872" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-lit@1.png" - {:sha256 "837f261e9d32f4cd01187bc054476bee9c5517202f1671deb8da1fc7060c37fb" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-lit@2.png" - {:sha256 "2c2dc54a03d89d83e694c900a86d589d6cec4d7257c816ca1686a506b97c9d8a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-rim@1.png" - {:sha256 "60440bbbdf8719c2ddd421be3adff02945a0b7111ec80170c98aac56595b1341" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-rim@2.png" - {:sha256 "ba862973ca399dc32ecfeaaf7c40b5d332c4b13d4c92d94247a80391e9e405a7" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-white@1.png" - {:sha256 "af36e8b0fe95e815ab06f67e77f592a3f9d90afaa0a1abce80ad2361bf1e3530" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/15-white@2.png" - {:sha256 "45ed0ebcc0cd2c6e638e697c0c1c17918d238f2766e8140922d58d0079022914" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-glow@1.png" - {:sha256 "5b7ddf0bedd4d98c2da5e357a0be1a8b92196774b9577843f045b1957229b673" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-glow@2.png" - {:sha256 "249b81e7507d119fa70edff8a01f3e0713bc8be7a2c7b633f755ce69c5389415" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-lit@1.png" - {:sha256 "f7dbb0f3254e9f6c0c333ed1e7929d0e703a8914e15dd26a929c4b7a9ab7d538" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-lit@2.png" - {:sha256 "d6744d48fee7027044455543d163fa780a677afbbef8e69b2b02d65122730008" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-rim@1.png" - {:sha256 "73c49c0cdb0af314d95e25252baa7df7f024eae964fafc0f704bb8eaad5568e7" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-rim@2.png" - {:sha256 "fd32fb16e87b7c11c6cb4f122fd835be7e4b494543875a51852111b279004c29" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-white@1.png" - {:sha256 "4b22b7da0e15253981da33d26d3370e76bd6d7b2a672055a1a6ea44328de9797" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round12/16-white@2.png" - {:sha256 "eda8ef7a556f46ad428125cfc01c4af4bc71441313f5f67a521aad783b00392d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/14-rim-320.png" - {:sha256 "e2ef434f98c6c8703bc47e10ed12bdbeeb050811895fb10972ba53874053e778" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/14-rim-390.png" - {:sha256 "41a2500d0a43fd657e374a3d38277c99905bb5baed4434324eb1f5547a60e5d4" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/14-white-320.png" - {:sha256 "8af766b17d67c96547d5dbba3cf63159f16dff15563015a8edb86015c888104e" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/14-white-390.png" - {:sha256 "21cc50a7071feaaf1d856182c6e68c02bceef8a194055f8cec4dec8477b41bdc" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/15-rim-320.png" - {:sha256 "d2e26262f9a8d298e859fd003361ed3156ce7e98cbee7133fbd2ca4bd6977086" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/15-rim-390.png" - {:sha256 "d1143c0725ca522619c570572f7f009045ae3a1e809fddd3af144e5a832fff4d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/15-white-320.png" - {:sha256 "48990873edba43380653dbb6e876a1756abbb48f65d09b19a997a8c5728465f1" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/15-white-390.png" - {:sha256 "c279d1ebb4d7efcee46c2300371aa11c5bf0586be288d99100ef8c70ce2e00ab" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/16-rim-320.png" - {:sha256 "4831c088a4d840856b0b0f58581be1952db05b3a233eaee9368f55163796014f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/16-rim-390.png" - {:sha256 "49d7cd0785c0503c581f73442809f289bdddec3264a7316c3a9c4d8a767fb369" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/16-white-320.png" - {:sha256 "ea907c50e38993ca7fb147ffa63fe99b5d1d57168dcc51edbd8ecaafe0b222dd" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/16-white-390.png" - {:sha256 "30587842bfe68cf4b980c1c32c0936232dc24610460bea24268c04d9b1d59c53" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/toggles-320.png" - {:sha256 "39fd0fd4fff0e1c120752b6f8ea8d3ed40ba232c5343ddb3ae3ed29070cb4fe0" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round13/toggles-390.png" - {:sha256 "282a703aec2edb17f94209d59dcdaf02e4391437643043563826681d81c8b14d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round14/desktop-1280.png" - {:sha256 "19da08bd4c7842c9ff24e587146902a730e542d63cf378072846337331b4ba1d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round14/desktop-narrowed-details.png" - {:sha256 "36528d83f2df44a9211a220c1ea19a0f266fe2e34eb5f0d844f3282114137148" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round14/desktop-narrowed-options.png" - {:sha256 "061d1982ca5eeab4a184f3bc615dcc6c669758510398106e408e497a22835b69" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round14/phone-options.png" - {:sha256 "c8121c2c6637e7a2511ee0a56d826f0a63054a32dda16c2738b3f217ab6c9a6a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round15/desktop-1280.png" - {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round15/phone-320.png" - {:sha256 "13487adedda621ec46e4c7281dc2de5a9728e92d94c6b7e8dbec5ec00ba7b011" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round15/phone-390.png" - {:sha256 "fab77f673f30f0a3af32acc8c2c54298df9a4f905b17997f4e0bf47f91b601bb" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round16/bar.png" - {:sha256 "2fe942021102a81ba044bab84fe8aec26036f3575d480751fd67b216af9749e7" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round16/edges-aligned.png" - {:sha256 "6cb921265360187666171aded48196f3441427f5d80a471555ffce9390b5b279" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round16/edges-now.png" - {:sha256 "664cd6083a3d58a5dc8b5355b44866219c4fa981a818361515fc0fc4a932f404" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round16/strip-aligned.png" - {:sha256 "8c3588cd5a80cb98ced416f57eb53e85072ddf88536990a968c988afdc44f42d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round16/strip-now.png" - {:sha256 "033da9068729108f0455235c197bfb0432316883d99b45bc1dfbf78b642b3ee9" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round17/built.png" - {:sha256 "15c1cf422d7debd6991935c3edf51f8f697823d7d8dd5dff5babc1e678d29362" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round17/g10.png" - {:sha256 "f38a8a5f17717a1ee65fc107921fb9aa5eb7b48356f12c4e90e8803bd5968044" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round17/g16.png" - {:sha256 "47ca02f9f649dbeb2e300f733cb96d8d8c67950e11ae465f53349cff49f8209f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round18/desktop-1280.png" - {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round18/phone-320.png" - {:sha256 "3c8d12b8785da47a2222907eb8ae8bcc89fa39b7dc9ca070adfd0f0f6ca22f43" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round18/phone-390.png" - {:sha256 "cbe0eb33597126a65866e3f04f95cbcdbc6bf94403abf567086b3b82f3764535" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round19/desktop.png" - {:sha256 "0993ced3491c25e664e7a671d88e5355c07e34522f4becf85318a786590a193a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round19/phone-320-dark.png" - {:sha256 "559e6fca94692099d415b660a42d353e67141e21bc637dc64997a67a0b46e399" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round19/phone-320-white.png" - {:sha256 "0c9531796f2950736147dd318f9d3c222e39f5721e8e73daf567782d373f69b9" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round19/phone-390-dark.png" - {:sha256 "296c733e31487dbd0b81c7c9304fccc0923532865a799ff4ffa41b7ad87a8f9e" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round19/phone-390-skills.png" - {:sha256 "22eb8ac9629b482d85c32c693e2f27b822eab11113e721e1e601a02edd14fc71" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round19/phone-390-white.png" - {:sha256 "bc0502ea83fd0c85b2c1609a5a3e2926aca38a8598c301a2530a1437b488bfc2" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/desktop-1280.png" - {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/desktop.png" - {:sha256 "0993ced3491c25e664e7a671d88e5355c07e34522f4becf85318a786590a193a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/phone-320-dark.png" - {:sha256 "559e6fca94692099d415b660a42d353e67141e21bc637dc64997a67a0b46e399" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/phone-320-white.png" - {:sha256 "0c9531796f2950736147dd318f9d3c222e39f5721e8e73daf567782d373f69b9" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/phone-320.png" - {:sha256 "3c8d12b8785da47a2222907eb8ae8bcc89fa39b7dc9ca070adfd0f0f6ca22f43" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/phone-390-dark.png" - {:sha256 "296c733e31487dbd0b81c7c9304fccc0923532865a799ff4ffa41b7ad87a8f9e" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/phone-390-skills.png" - {:sha256 "22eb8ac9629b482d85c32c693e2f27b822eab11113e721e1e601a02edd14fc71" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/phone-390-white.png" - {:sha256 "bc0502ea83fd0c85b2c1609a5a3e2926aca38a8598c301a2530a1437b488bfc2" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/after/phone-390.png" - {:sha256 "cbe0eb33597126a65866e3f04f95cbcdbc6bf94403abf567086b3b82f3764535" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/desktop-1280.png" - {:sha256 "4048bede2c5f42f14c35796cc7e002806d1506025158dca141b11c4b17dae148" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/desktop.png" - {:sha256 "0993ced3491c25e664e7a671d88e5355c07e34522f4becf85318a786590a193a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/phone-320-dark.png" - {:sha256 "559e6fca94692099d415b660a42d353e67141e21bc637dc64997a67a0b46e399" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/phone-320-white.png" - {:sha256 "0c9531796f2950736147dd318f9d3c222e39f5721e8e73daf567782d373f69b9" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/phone-320.png" - {:sha256 "3c8d12b8785da47a2222907eb8ae8bcc89fa39b7dc9ca070adfd0f0f6ca22f43" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/phone-390-dark.png" - {:sha256 "296c733e31487dbd0b81c7c9304fccc0923532865a799ff4ffa41b7ad87a8f9e" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/phone-390-skills.png" - {:sha256 "22eb8ac9629b482d85c32c693e2f27b822eab11113e721e1e601a02edd14fc71" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/phone-390-white.png" - {:sha256 "bc0502ea83fd0c85b2c1609a5a3e2926aca38a8598c301a2530a1437b488bfc2" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round20/before/phone-390.png" - {:sha256 "cbe0eb33597126a65866e3f04f95cbcdbc6bf94403abf567086b3b82f3764535" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/after/desktop-flyout.png" - {:sha256 "efb802d9a70eebafa5476fdb8ae31779a931c42f2bd20feca60b269bf5b6312b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/after/phone-flyout.png" - {:sha256 "340e72d1210e435f128ca4c399cdb56f730857d1e60fa0ce7b2ed69ff041cd23" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/after/user-menu.png" - {:sha256 "2e6de9a6b31de8024e06dc01ff1c7a7d73f650bc739d58a271e370dac3a84633" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/before/desktop-flyout.png" - {:sha256 "efb802d9a70eebafa5476fdb8ae31779a931c42f2bd20feca60b269bf5b6312b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/before/phone-flyout.png" - {:sha256 "6186335d44cf48be49ccadfe6f8998564d45bd9b550a09c1a723d59e93a354d2" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/before/user-menu.png" - {:sha256 "2e6de9a6b31de8024e06dc01ff1c7a7d73f650bc739d58a271e370dac3a84633" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/320-tab1.png" - {:sha256 "669559a578e7b2d0e39d105a6df12af39d37b1f69371c72d56a2dd8147191707" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/320-tab2.png" - {:sha256 "8c06398d342bd794cff03d10536fd25c981b8f5f2aaa01a9d1f1e51e21b9cc87" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/320-tab3.png" - {:sha256 "7bd282ac9c99ccf3500974efc8ad6bbdfd5d53f52f8328986b670e8332db51d0" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/320-tab4.png" - {:sha256 "fe263deb46fcfe48bce346f5a4475a4c90a6d0bf764d1cffa203557878ab5c71" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/320-tab5.png" - {:sha256 "643bfdaa87fad27fc6523bee0c24ff2363f79a0394d89237bfcce19613e75272" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/320-tab6.png" - {:sha256 "6104fa7f2e75374f9af442f32c731dbed33435faa8aaa123f699e7c0dc9ff78e" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/390-tab1.png" - {:sha256 "b3acd6b112c25e0f5cbf5627e2311786c755bda88778077821d09cb32519e56e" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/390-tab2.png" - {:sha256 "9e32f8056eb14464664bf8c8c34cfb18ef5748e858ee437985f02d14d65ffd7f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/390-tab3.png" - {:sha256 "09792904ed48c873ca00a34de261af3ceb12519b13d0a73579312734e63309d1" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/390-tab4.png" - {:sha256 "0c7d6953f1e3d71dc5c97a2df7c16bcd49a3beeb02f8e65ea90e66edb2abafa6" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/390-tab5.png" - {:sha256 "a01b6959a6310cc25c4376a42d19d13509b059c465330e338638af0013ef6f7b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus-before/390-tab6.png" - {:sha256 "ab588299f28e59955e6af1c0d79a53a45b674f361c0abd776adff2e22075db20" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/320-tab1.png" - {:sha256 "5d2f4e6729738d634bfc85208fc8805587526986f1133fadff6f5b7184255d8f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/320-tab2.png" - {:sha256 "5f17b4cae5cb64bb6d7421dd6e7df4357cb5ea9670cf54ae1e0b43beee0eafe4" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/320-tab3.png" - {:sha256 "a5c9c2119460f7c7bf2a8d866229fb8d89c31fbad04f98068ed637fb70e7cff1" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/320-tab4.png" - {:sha256 "315ad3aa7e2a23b3a9c98c61c9b40a81b27d2788fad039b7e401588e59d71dc6" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/320-tab5.png" - {:sha256 "d1bdc069b899c613a538c413311017c40d21d06b530a10f3b7dbbd09f0c0e7ac" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/320-tab6.png" - {:sha256 "b141e1e02f3f421ca42aabaf5b72baac1595e9e78a8236bc1c9c2866070e802c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/390-tab1.png" - {:sha256 "58b46737ca7ae69c9000ec11fc15d1fc3199b93c988f22aed9cdbfefbaca3971" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/390-tab2.png" - {:sha256 "6a4921443e2820aaf90deef4283b084d3e24023a5873b40b6b4c34d63b1ac61d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/390-tab3.png" - {:sha256 "18177fb40283cedae55968b5301867bc5db7b9dc16d8c3c18a44a3a81be82674" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/390-tab4.png" - {:sha256 "6794b80d7168bae1cfb67ac9966dab96fe2e680d30664401d36bb3da28acbcee" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/390-tab5.png" - {:sha256 "c62f9096ab965f15d1bef0e51cb6d7b5a377e4883ce88ce4fb5d144c6ea424aa" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round21/menus/390-tab6.png" - {:sha256 "85249509fa0cd45448da657a3e95a302b9363daaa95cb22c2b091d4471b1cd54" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-btn-arial.png" - {:sha256 "54962fcdc2e9bfe4dbc40f5c54de1647571fa01fbb14bccf44f54b8ced2eab30" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-btn-dark.png" - {:sha256 "70450b173c5fb011be9d06dba9cce628ce6a10ebd044ddcbf466df4041e3ce20" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-btn-os600.png" - {:sha256 "e5b15847bcfdc1533abf65d1b68d3012f99c4947eaae0c056f7a5fde3f8d3b54" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-btn-os700.png" - {:sha256 "5d00fae8a84ab96d94be8195009e8488b6c7a6a070131013061fb49ddde460fd" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-v2-builder.png" - {:sha256 "eb59d124cf7e926b7c9659f19bfd679f57f1d9dc595baae77731a1da5f65cdee" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-v2-monsters.png" - {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-v2-mycontent.png" - {:sha256 "8d866f643d18bd247360164c05909e9468191c39aebf4a05c673a29edb7e7698" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-v2-spells.png" - {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-wrap-asis.png" - {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-wrap-ls02.png" - {:sha256 "4e97d2a100331d46770be7a14f7097e0b72ca0c834908ec45e1f05e44a30de4b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-wrap-ls04.png" - {:sha256 "68c3d46839a9187f748c69a12ad0afe8d3ccf200f7f8ed16e7f3b2506ec8d563" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-wrap-s13.png" - {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/dark-wrap-w600.png" - {:sha256 "f416cf3a968defebc4149f0f2c6a1f0c369ab44c7e32a8ac9e2cd36a6d6ae9cf" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-btn-arial.png" - {:sha256 "957f7701a0d2a717095a4531ebd3343afd70932df2477e939afe097711beebfa" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-btn-dark.png" - {:sha256 "26b411c5bc5c046a2cbe6080f7c08066250b3f93f6ef8fd180ca35f1c4fa42bd" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-btn-os600.png" - {:sha256 "3d652d57e9401f6d20ffbb9318f905cb142959f997819ec6381cc40eb3a1e08c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-btn-os700.png" - {:sha256 "26b411c5bc5c046a2cbe6080f7c08066250b3f93f6ef8fd180ca35f1c4fa42bd" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-v2-builder.png" - {:sha256 "d8e7577d09922ea6c73d15ad165b1fde7a9c3e9ccfdfc5448cd6c69ad7336766" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-v2-monsters.png" - {:sha256 "f15c88956182732c2390052771aaf8899c374a639221626872b1870ff9b368bf" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-v2-mycontent.png" - {:sha256 "2f9cd672064c9f7fdd41e3f857acf3ed5b3b065af0eccde1c17a53732e9cec9c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-v2-spells.png" - {:sha256 "131c5e3949c25e8ba54a197596a5ce059db22f1bcdda4ed70b10c9eaea9603ce" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-wrap-asis.png" - {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-wrap-ls02.png" - {:sha256 "4e97d2a100331d46770be7a14f7097e0b72ca0c834908ec45e1f05e44a30de4b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-wrap-ls04.png" - {:sha256 "68c3d46839a9187f748c69a12ad0afe8d3ccf200f7f8ed16e7f3b2506ec8d563" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-wrap-s13.png" - {:sha256 "259fafd23c3875f2c9d4734e78c55d2b2eeed03f4d7729442f1c0191f7a26c02" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round3/light-wrap-w600.png" - {:sha256 "f416cf3a968defebc4149f0f2c6a1f0c369ab44c7e32a8ac9e2cd36a6d6ae9cf" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-dark-builder.png" - {:sha256 "659a00f01ac53b9e881076bf6287cc9de698023e2f39fab899ff9c7fcbc01d28" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-dark-monsters.png" - {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-dark-mycontent.png" - {:sha256 "53a656687d15dc6bbd3cb8aa81a8ce064119a895fbf295b4c3a972c62ea6721d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-dark-spells.png" - {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-white-builder.png" - {:sha256 "eb59d124cf7e926b7c9659f19bfd679f57f1d9dc595baae77731a1da5f65cdee" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-white-monsters.png" - {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-white-mycontent.png" - {:sha256 "8d866f643d18bd247360164c05909e9468191c39aebf4a05c673a29edb7e7698" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round4/dark-white-spells.png" - {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round5/1-today.png" - {:sha256 "63234e4b5c2b96de968176b9265cc49b97c097987b8b5569a187b259b9b055ab" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round5/2-shadow.png" - {:sha256 "042fbc6474b340638e356b9fc73838a1fb5f3612645f68a8e5c3cb9ab8688eba" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round5/3-deeper.png" - {:sha256 "4baa474dca0f27e198ea830990f8aaccd6ecd1cdd72ce5e6e27db7cf09c5786c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round5/4-deeper-shadow.png" - {:sha256 "2348b2c22b8df8522ddbd04a67d4799af3e5c5c7c1504fcc87f3fa99d22d8c0d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round5/5-dark.png" - {:sha256 "dae94bebf2c6418ab31ed3d6fabae3724362294e28809568d38d4e393fb813f4" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round6/10-dark-emboss-strong.png" - {:sha256 "5afc7f629340ae8ae6829e29ab9be23e19520a84a27fb8deb36ed8a8ebb78c47" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round6/6-white-shadow.png" - {:sha256 "042fbc6474b340638e356b9fc73838a1fb5f3612645f68a8e5c3cb9ab8688eba" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round6/7-dark.png" - {:sha256 "dae94bebf2c6418ab31ed3d6fabae3724362294e28809568d38d4e393fb813f4" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round6/8-dark-emboss.png" - {:sha256 "6638bf5596541dde6cf8db45317a5c1ecd7080d4c9ca1d0238e0ca7a055bd4b5" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round6/9-dark-glow.png" - {:sha256 "d4244f1cbfb6c600f9808b7bc56a6b7312e1b1791cd13ef1c6f043404eb4ef61" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/dark-buttons.png" - {:sha256 "042fbc6474b340638e356b9fc73838a1fb5f3612645f68a8e5c3cb9ab8688eba" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/dark-darktext-buttons.png" - {:sha256 "d4244f1cbfb6c600f9808b7bc56a6b7312e1b1791cd13ef1c6f043404eb4ef61" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/dark-darktext-roll.png" - {:sha256 "2c3960fd090716bed7149f399139113219e2ab83be00e627e2c18d6223348e2c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/dark-roll.png" - {:sha256 "90abaa97721559e12814ed281f69f6ca3667319bc73e512435abc557ec5a3eb1" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/dark-tab-lift.png" - {:sha256 "9f74a4f0b62aa40f479dc1d4ce6740be5c8325c174cc560e8a20aa70ba59c9a8" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/dark-tab-now.png" - {:sha256 "f93950b65a25131b28308a09abfaaf9b836f58b17b0d77df0561e1876d80bd4c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/light-buttons.png" - {:sha256 "178e4682ad30fd6568d02fdf5e12b12f6216f04fc1b1cb9e01adeab2cf1984cd" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/light-roll.png" - {:sha256 "fb37515425dc17adc5f8393d174a5b40c61b15bbf7fc4bc2aa8e6e9e579ae3c8" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/light-tab-lift.png" - {:sha256 "9f74a4f0b62aa40f479dc1d4ce6740be5c8325c174cc560e8a20aa70ba59c9a8" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round7/light-tab-now.png" - {:sha256 "f93950b65a25131b28308a09abfaaf9b836f58b17b0d77df0561e1876d80bd4c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round8/header-built.png" - {:sha256 "78a5efb8a8a40d13d186c116271a013e6348560ea379c6ce13d2cd181df4d624" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round8/roll-a-before.png" - {:sha256 "2c3960fd090716bed7149f399139113219e2ab83be00e627e2c18d6223348e2c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round8/roll-b-700.png" - {:sha256 "1f0c29ee8b18a4820966696ca902861e2e40bdfdee5a940fcb8b46bfed5ce1fd" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round8/roll-built.png" - {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round8/roll-c-glow.png" - {:sha256 "3e669a5614954291f8868fb472d27f2ccde9686d770d23c1c7edc516fa8a2714" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round8/roll-d-700-glow.png" - {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round8/roll-w-white.png" - {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round9/roll-d-built.png" - {:sha256 "0d8c5cf427dbbe7ea7f973791f82c971245937df8f50c9b61950dd7144f5d56f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round9/roll-e-shadow-ring.png" - {:sha256 "e87995488c563408d38eb64c55e4ed18329b7b885e0da6293425cad2b721fd30" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round9/roll-f-stroke-hair.png" - {:sha256 "ab8e0926f1dc74a6cb1d868775b0e92d0d2956d6af5427e3e5289911cafb4109" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round9/roll-g-stroke-hair-glow.png" - {:sha256 "78323baa58deb5087768c97846369880056ccab880b875842b48a58593da9325" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round9/roll-h-stroke-full.png" - {:sha256 "7851f962a1f6eb40ad5ca2eab39121849c3131b9739103c98f154d4f06ecca17" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round9/roll-i-stroke-full-glow.png" - {:sha256 "5be9f1137dfb4b60f8d6cc9ef690ec61ecf09015e32dc8bc239fca1f7af7776a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/round9/roll-w-white.png" - {:sha256 "c4f80a42c40826ee4411794f84abd99e8587838d9ab42e27587591174f2a7473" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-after-builder.png" - {:sha256 "e78d209816bdf54893f3d5a9297cc53f4015159fe9e313c26f6b96d6ca1f1ec2" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-after-monsters.png" - {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-after-mycontent.png" - {:sha256 "56ef087f7e42392787925a0122070c5ff26c756c2f71323f2f367c58fa64da4a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-after-spells.png" - {:sha256 "d4e1c851290e8a53afdeef8b8f15b0259c713d2c3cf003c2fc42abed8682f970" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-before-builder.png" - {:sha256 "0adc3532bab7f6f0fa12d3f93b76a356e95001ad22f60e899f9156f713cceed3" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-before-monsters.png" - {:sha256 "05a53fdf04486f08c97bbb160bc5edb4f5edaf5fe89a2ac6719eb4bfee555865" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-before-mycontent.png" - {:sha256 "f400e196ee1f1a3793fadf64bcdef33c0c499f10039e870b64f1fe0aeaf83bd9" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-before-spells.png" - {:sha256 "002febee16487914747ec2e2fb3c1404a0fa2fad7e9e0547514b9076b52d4bf0" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-rule-builder.png" - {:sha256 "c398fd986babcd570b44bb4efc6dca22f7b568c1b7e3e8a2582fcad1fd4def05" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-rule-monsters.png" - {:sha256 "b64891b6d8d54690716a44c09ed6b98743461a8152100ababcaa430d31d046ef" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-rule-mycontent.png" - {:sha256 "9635e95f2ac5679d9978d261230ee439e5edec473cdb8d6a5b31530959bc6058" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/dark-rule-spells.png" - {:sha256 "0b51574a2f5623b279ae77a2c4ef9b7704073d8b629b255024d0937b97c907ff" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-after-builder.png" - {:sha256 "3413eca2cdcd4375b4176e84c02cabe95859c1761b9048db5e9d39a2206fe7d0" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-after-monsters.png" - {:sha256 "f15c88956182732c2390052771aaf8899c374a639221626872b1870ff9b368bf" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-after-mycontent.png" - {:sha256 "b4b697ab4871657ac3e17dae778ad34b82a12332ed8040fd05dc54f26e7bd55d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-after-spells.png" - {:sha256 "696f637efd328155a60ec63b0223674db31ff62d29ca01cc4f24687b0b63c26a" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-before-builder.png" - {:sha256 "c6f627b103fa72eefb5f03743144de2f284e643d42cdf0b9de931456df3e9566" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-before-monsters.png" - {:sha256 "301b7c289b369956efe4f98febff8919741d0bedbde9e5083aab1c1286dc2138" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-before-mycontent.png" - {:sha256 "0c2ba28175684a3647ba2333fb72f9d8bb0a7605a475db870f317d068a5a075e" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-before-spells.png" - {:sha256 "774bd95b085eedbd667f29572ce6df4ad931a0357512d0489c7129d43de7f9d5" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-rule-builder.png" - {:sha256 "00b373460eea7e59b0efa0861d2e94c271d1b521847031a7bf3bb5fbb8c0622f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-rule-monsters.png" - {:sha256 "f15c88956182732c2390052771aaf8899c374a639221626872b1870ff9b368bf" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-rule-mycontent.png" - {:sha256 "220916999702580235c9926401e515d23dd774bfbb70c43a5a05b854733a1db7" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/sitewide/light-rule-spells.png" - {:sha256 "131c5e3949c25e8ba54a197596a5ce059db22f1bcdda4ed70b10c9eaea9603ce" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-pair-400-400.png" - {:sha256 "1c95f2e6b12b811259a75147111f52414c96833a31e127362ee05333dfccc160" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-pair-500-600.png" - {:sha256 "c06ea2abd9c998c788b5927dd21711069c7c80a60b9a75c5cc903bd1c791dadd" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-pair-700-600.png" - {:sha256 "0c0114a53dc18e4b9745c5b727090e165a3808fcb565f6310085e427421a3a9b" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-tabs-400.png" - {:sha256 "1c7daaacd0aa822098fa071c9ad3e629a834700af15e97dae10a2339fddc7a5d" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-tabs-500.png" - {:sha256 "c4904977c323ff7264251a9c3282a7c7bc37f3917f803d4a4a34d7e3d8417972" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-tabs-600.png" - {:sha256 "e8607924fc459588ceb492d1e80c2eb61e5421742eed7e1feca690541d7bbd25" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-tabs-700.png" - {:sha256 "b0e1ca23463c0819324389e654691824461bf9156cc40064d3e8245a99492305" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-title-400.png" - {:sha256 "04ab9c464774f0f16cda29c07931b4fb9305f536f0e7ecb1edb30c65e67eb001" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-title-600.png" - {:sha256 "ea9a2ed8f90757f97011769af504f048d5efc5946cae1e8c0acd05db565d2510" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/dark-title-700.png" - {:sha256 "f457ec2fb555360de9412493c6a7349a0e6c068684f44a2d57ee293264c4d697" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-pair-400-400.png" - {:sha256 "d17439d7e320810a3aa70bf30e2f7d1fa01e951503611c9c5e82ece79b874c3c" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-pair-500-600.png" - {:sha256 "1573364f3b7d2dd2d7dc0836ff096ae86786129bafd5e3c548d35b9475b8eea2" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-pair-700-600.png" - {:sha256 "cc0550e44e24072aa3426f4d888ce5d838799f58269d298bcbda2ac87e711aa3" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-tabs-400.png" - {:sha256 "1232d90c86e7d30fd9ee9814b5cf5c80e64c4ec8644547cc99ad83ab7fb57072" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-tabs-500.png" - {:sha256 "ba9704a99616ca296e271eda5fcb3364fab1061b1bfcdc8da19a549aba86f612" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-tabs-600.png" - {:sha256 "c29869b307cd80a8adb94480535ec4222b7dcf3597bdfc81353f7692c42a44f3" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-tabs-700.png" - {:sha256 "149da8c7441a6c23d8167f5bbc6062a585205c9a8d43f19e41a4ac1f761a294f" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-title-400.png" - {:sha256 "e04feb68eba1dde905fc147f275709d99bb22c4102355c7a8c12ae78bb152fd3" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-title-600.png" - {:sha256 "9e4f40d663fd95ff1042c437147b737bcede32e29c7baab938c5563872487b08" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"} - "docs/design/style-guide/weights/light-title-700.png" - {:sha256 "56cdb204d3f1a44d43f399f7825d3f0f4113b961abd7736925aa916a68398cd1" - :what "agent-reviewed: site UI screenshot; dropped at the rebuild"}} + :what "site background / partner image, added 2020-11-25, before the portrait art"}} From 04becd21abae24f674002836596f1b86f174c396 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 27 Oct 2026 13:08:52 +0000 Subject: [PATCH 71/91] Credit notices: queue each email in the transaction that records it; keep an overdue batch Two CodeRabbit findings on #65: - A change saved after a window ended but before the minute job ran opened the next window and wiped the waiting batch, so its changes were never emailed. Now that save takes the overdue batch (compare-and-set, so a flush racing it cannot also send it) and emails from the credit before the batch's first change. - Emails went out from an unobserved future after the state was committed, and a batch was cleared before its email was sent, so a failure lost the notice. Now credit-changed-tx returns outbox rows that go in the same transaction as the window or the claim: the email exists exactly when the state change does, and the outbox retries it until sent. --- src/clj/orcpub/artist_credit_notices.clj | 72 ++++++++++++++---------- src/clj/orcpub/artist_email.clj | 22 +++++--- test/clj/orcpub/artist_credit_test.clj | 8 +-- 3 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/clj/orcpub/artist_credit_notices.clj b/src/clj/orcpub/artist_credit_notices.clj index dafc09fc5..520ad1ea6 100644 --- a/src/clj/orcpub/artist_credit_notices.clj +++ b/src/clj/orcpub/artist_credit_notices.clj @@ -11,6 +11,7 @@ [datomic.api :as d] [orcpub.artist-credit :as artist-credit] [orcpub.artist-email :as artist-email] + [orcpub.outbox :as outbox] [orcpub.heartbeat :as heartbeat]) (:import [java.util.concurrent Executors ScheduledExecutorService ThreadFactory TimeUnit])) @@ -25,33 +26,51 @@ (defn saved! "Call after artist `user` (an account entity map) saved a change on `conn`, from `before` to - `after` (credits) for `artist-id`. Outside a window: emails at once and opens one. Inside: marks - the change pending (keeping the earliest `before`) for `flush-due!`. Returns :sent or :pending." + `after` (credits) for `artist-id`. Inside a window: marks the change pending (keeping the + earliest `before`) for `flush-due!`. Outside one: queues the email in the outbox and opens a + window in one transaction, from an overdue batch's `before` when one is still waiting. Returns + :sent or :pending." [conn {:keys [user artist-id before after]}] (let [id (:db/id user) now (heartbeat/now) {:orcpub.user/keys [artist-credit-window artist-credit-pending]} - (d/pull (d/db conn) [:orcpub.user/artist-credit-window :orcpub.user/artist-credit-pending] id)] + (d/pull (d/db conn) [:orcpub.user/artist-credit-window :orcpub.user/artist-credit-pending] id) + overdue (when-not (s/blank? artist-credit-pending) artist-credit-pending)] (if (window-open? artist-credit-window now) - (do (when (s/blank? artist-credit-pending) + (do (when-not overdue @(d/transact conn [{:db/id id :orcpub.user/artist-credit-pending (pr-str (or before {}))}])) :pending) - (do @(d/transact conn [{:db/id id :orcpub.user/artist-credit-window now - :orcpub.user/artist-credit-pending ""}]) - (future (artist-email/credit-changed! {:artist-id artist-id :user user - :before before :after after})) - :sent)))) + (let [from (if overdue (not-empty (edn/read-string overdue)) before)] + ;; the cas fails if a flush took the overdue batch first; that flush then emails it + @(d/transact conn (into [(if overdue + [:db/cas id :orcpub.user/artist-credit-pending overdue ""] + {:db/id id :orcpub.user/artist-credit-pending ""}) + {:db/id id :orcpub.user/artist-credit-window now}] + (artist-email/credit-changed-tx {:artist-id artist-id :user user + :before from :after after}))) + (@outbox/nudge) + :sent)))) -(defn- claim! - "Take the pending batch `pending` of account `id`, clearing it by compare-and-set; true only for - the one caller whose clear commits, so each batch is sent once." - [conn id pending] - (try @(d/transact conn [[:db/cas id :orcpub.user/artist-credit-pending pending ""]]) true - (catch Exception _ false))) +(defn- send-batch! + "Claim the pending batch `pending` of account `id` (read from `db`) and queue its email in one + transaction: the claim clears the pending field by compare-and-set, so of several callers one + commits, and the email exists exactly when the claim does. No email when the changes undid + themselves. Returns true when this caller's claim committed with an email." + [conn db id pending] + (let [user (d/pull db '[*] id) + before (not-empty (edn/read-string pending)) + after (some-> (:orcpub.user/artist-credit user) artist-credit/read-stored) + emails (when (not= before after) + (artist-email/credit-changed-tx {:artist-id (:orcpub.user/artist user) :user user + :before before :after after}))] + (try @(d/transact conn (into [[:db/cas id :orcpub.user/artist-credit-pending pending ""]] emails)) + (boolean (seq emails)) + (catch Exception _ false)))) (defn flush-due! - "Email every batch on `conn` whose window has ended, once each: the credit from before its first - change to the credit now (nothing when the changes undid themselves). Returns how many went." + "Queue the email of every batch on `conn` whose window has ended, once each: the credit from + before its first change to the credit now (none when the changes undid themselves). Returns how + many were queued." [conn] (let [now (heartbeat/now) db (d/db conn) @@ -59,19 +78,12 @@ :where [?u :orcpub.user/artist-credit-pending ?pending] [(not= ?pending "")] [?u :orcpub.user/artist-credit-window ?start]] - db)] - (reduce (fn [n [id pending start]] - (if (and (not (window-open? start now)) (claim! conn id pending)) - (let [user (d/pull db '[*] id) - before (not-empty (edn/read-string pending)) - after (some-> (:orcpub.user/artist-credit user) artist-credit/read-stored)] - (if (= before after) - n - (do (artist-email/credit-changed! {:artist-id (:orcpub.user/artist user) :user user - :before before :after after}) - (inc n)))) - n)) - 0 due))) + db) + n (count (filter (fn [[id pending start]] + (and (not (window-open? start now)) (send-batch! conn db id pending))) + (vec due)))] + (when (pos? n) (@outbox/nudge)) + n)) (defrecord CreditNotices [conn ^ScheduledExecutorService executor] component/Lifecycle diff --git a/src/clj/orcpub/artist_email.clj b/src/clj/orcpub/artist_email.clj index 4730c1aba..f202ab903 100644 --- a/src/clj/orcpub/artist_email.clj +++ b/src/clj/orcpub/artist_email.clj @@ -415,14 +415,18 @@ (println "artist-email: APP_URL is not set; upgrade email not sent"))) :unlinked (fn [event] (deliver! :unlinked (unlinked-message event) event))}) -(defn credit-changed! - "Tell the artist and the admin that a credit changed. Unlike the startup - emails this needs no APP_URL -- the message reads fine without the link." +(defn credit-changed-tx + "Outbox transaction data telling the artist and the admin that a credit changed (`event`: + :artist-id, :user, :before, :after), for the transaction that records the change, so the emails + are queued exactly when it commits and retried until sent. [] when email is off. No APP_URL + needed: the message reads fine without the link." [event] (if-not (email-on?) - (println "artist-email: email is off; credit change for" - (get-in event [:user :orcpub.user/username]) "not announced") - (do (send! (credit-changed-message (base-url) event)) - (if (admin-recipient) - (send! (credit-admin-message event)) - (println "artist-email: no admin address (EMAIL_ADMIN_TO, EMAIL_ERRORS_TO, APP_SUPPORT_EMAIL); admin copy not sent"))))) + (do (println "artist-email: email is off; credit change for" + (get-in event [:user :orcpub.user/username]) "not announced") + []) + (let [artist (outbox/email-tx :artist (credit-changed-message (base-url) event))] + (if (admin-recipient) + [artist (outbox/email-tx :artist (credit-admin-message event))] + (do (println "artist-email: no admin address (EMAIL_ADMIN_TO, EMAIL_ERRORS_TO, APP_SUPPORT_EMAIL); admin copy not sent") + [artist]))))) diff --git a/test/clj/orcpub/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index 991ca584d..65d1fa4d1 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -134,7 +134,7 @@ (with-conn conn (add-artist! conn "fusspot" :house-pack) (let [told (atom [])] - (with-redefs [orcpub.artist-email/credit-changed! #(swap! told conj %) + (with-redefs [orcpub.artist-email/credit-changed-tx #(do (swap! told conj %) []) clojure.core/future-call (fn [f] (f) (delay nil))] (let [bad (orcpub.routes/update-artist-credit (req conn "fusspot" {:link "http://x.test"})) @@ -164,8 +164,8 @@ (defmacro ^:private with-emails "Run `body` with credit emails collected in `sent` (as [minute before-name after-name]) instead of sent." [sent clock & body] - `(with-redefs [orcpub.artist-email/credit-changed! - (fn [e#] (swap! ~sent conj [@~clock (:artist/name (:before e#)) (:artist/name (:after e#))])) + `(with-redefs [orcpub.artist-email/credit-changed-tx + (fn [e#] (swap! ~sent conj [@~clock (:artist/name (:before e#)) (:artist/name (:after e#))]) []) orcpub.heartbeat/now (fn [] (at @~clock)) clojure.core/future-call (fn [f#] (f#) (delay nil))] ~@body)) @@ -306,7 +306,7 @@ (deftest the-page-switches-arrive-through-the-route (with-conn conn (add-artist! conn "fusspot" :house-pack) - (with-redefs [orcpub.artist-email/credit-changed! (fn [_]) + (with-redefs [orcpub.artist-email/credit-changed-tx (fn [_] []) clojure.core/future-call (fn [f] (f) (delay nil))] (is (= 200 (:status (orcpub.routes/update-artist-credit (req conn "fusspot" {:name "" :link "" :links [] :profile? false :show-count? true}))))) From 5bb6d63bf9eb4911b25576d2a5ad36a24843fb19 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 27 Oct 2026 13:10:46 +0000 Subject: [PATCH 72/91] Tests: an overdue batch survives the next save; credit emails are queued with their change Fail state, each claim broken by hand in artist_credit_notices.clj (04becd21) and restored with git: saved! ignoring the overdue batch: FAIL a-change-after-an-unflushed-window-keeps-the-batch expected: (= [[0 nil "A"] [11 "A" "C"]] @sent) emails queued outside the transaction (a future), the claim without them: FAIL credit-emails-are-queued-with-the-change-that-causes-them (3): (= 2 (queued-credit-emails conn)) x2, (= 4 (queued-credit-emails conn)) After: artist-credit-test 21 tests, 0 failures. --- test/clj/orcpub/artist_credit_test.clj | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/clj/orcpub/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index 65d1fa4d1..b92f64442 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -311,3 +311,35 @@ (is (= 200 (:status (orcpub.routes/update-artist-credit (req conn "fusspot" {:name "" :link "" :links [] :profile? false :show-count? true}))))) (is (false? (:artist/profile? (pa/artist-info :house-pack))) "what My Account sends is what is saved")))) + +(deftest a-change-after-an-unflushed-window-keeps-the-batch + (testing "a save after the window ends, before the minute job runs, still emails the waiting changes" + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [sent (atom []) clock (atom 0)] + (with-emails sent clock + (save-name! conn "A") + (reset! clock 1) (save-name! conn "B") + (reset! clock 11) (save-name! conn "C") + (is (= [[0 nil "A"] [11 "A" "C"]] @sent) "one email, from before B to C") + (flush! conn (at 12)) + (is (= 2 (count @sent)) "and the batch is not sent a second time")))))) + +(defn- queued-credit-emails + "How many artist credit emails are waiting in the outbox of `conn`." + [conn] + (or (d/q '[:find (count ?e) . :where [?e :orcpub.outbox/kind :artist]] (d/db conn)) 0)) + +(deftest credit-emails-are-queued-with-the-change-that-causes-them + (testing "queued in the outbox in the same transaction, so the outbox retries a failed send" + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (with-redefs [orcpub.artist-email/email-on? (constantly true) + orcpub.artist-email/admin-recipient (constantly "admin@example.test") + orcpub.heartbeat/now (fn [] (at 0))] + (save-name! conn "A") + (is (= 2 (queued-credit-emails conn)) "the artist's and the admin's, queued as the window opened") + (with-redefs [orcpub.heartbeat/now (fn [] (at 1))] (save-name! conn "B")) + (is (= 2 (queued-credit-emails conn)) "a change in the window waits") + (flush! conn (at 10)) + (is (= 4 (queued-credit-emails conn)) "the batch's two, queued as it was claimed"))))) From cc24d2a6d8f3f71f3c90b1e967f91b95ac658eba Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 27 Oct 2026 13:12:53 +0000 Subject: [PATCH 73/91] My Account: page switches start from the setting in force; ones the site fixes are locked CodeRabbit on #65: with the deployment forcing a page off and the artist never having saved off, "Show my artist page" read On and could be toggled to no effect. credit-for-account now reports the effective credit (either side's off keeps the page off) and locks a switch only when the config turns it off, since the config cannot force one on; the form starts each locked switch from what is in force, disables it and says the site's admins set it. --- src/clj/orcpub/artist_credit.clj | 10 ++++++---- src/cljs/orcpub/dnd/e5/views.cljs | 14 ++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/clj/orcpub/artist_credit.clj b/src/clj/orcpub/artist_credit.clj index a0ae7701d..b122203e4 100644 --- a/src/clj/orcpub/artist_credit.clj +++ b/src/clj/orcpub/artist_credit.clj @@ -141,8 +141,9 @@ ;; --------------------------------------------------------------------------- (defn credit-for-account - "What the settings page shows an artist: {:current <what the site shows> - :own <their saved edits> :locked #{fields the config decides}}." + "What the settings page shows an artist: {:current <what the site shows> :own <their saved edits> + :locked #{fields the config decides}}. A page switch is locked only when the config turns it + off: the config cannot force a page on (`effective`)." [db user-id] (let [{:orcpub.user/keys [artist artist-credit]} (d/pull db [:orcpub.user/artist :orcpub.user/artist-credit] user-id)] @@ -152,9 +153,10 @@ base (some #(when (= artist (:artist/id %)) %) pa/registry)] {:artist-id artist :default (select-keys base [:artist/name :artist/link :artist/links]) - :current (merge base own config) + :current (get (effective {artist (merge base own)} {artist config}) artist) :own own - :locked (set (keys config))})))) + :locked (set (remove #(and (contains? (set (vals page-switches)) %) (not (false? (get config %)))) + (keys config)))})))) (def save-cap "Changes an account may save in an hour: generous for an artist, a wall against a script." diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 2bb5e9869..0b9194514 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -10337,7 +10337,9 @@ next-id (atom 0) new-row (fn [url] {:id (swap! next-id inc) :url url}) links (r/atom (vec (for [l (:artist/links own)] (new-row (:link/url l))))) - switches (r/atom (into {} (for [[k ck] page-switches] [k (not (false? (get own ck)))]))) + ;; a switch the site's config fixes starts from what is in force, not the artist's own + switches (r/atom (into {} (for [[k ck] page-switches] + [k (not (false? (get (if (contains? (set locked) ck) (:current credit) own) ck)))]))) ;; fields the person has left, keyed :name / :link / row id left (r/atom #{})] (let [save-status @(subscribe [:account-save-status :artist-credit-save]) @@ -10415,13 +10417,17 @@ (let [[[pk _ plabel] & parts] page-switches] [:div [:div.m-b-5 - [comps/labeled-checkbox [:span.f-s-14 plabel] (pk @switches) false #(do (swap! switches update pk not) (clear!))]] + [comps/labeled-checkbox [:span.f-s-14 plabel] (pk @switches) (locked? (second (first page-switches))) + #(when-not (locked? (second (first page-switches))) (swap! switches update pk not) (clear!))] + (when (locked? (second (first page-switches))) fixed-note)] (when (pk @switches) [:div.m-l-20 - (for [[k _ label] parts] + (for [[k ck label] parts] ^{:key k} [:div.m-b-5 - [comps/labeled-checkbox [:span.f-s-14 label] (k @switches) false #(do (swap! switches update k not) (clear!))]])])])] + [comps/labeled-checkbox [:span.f-s-14 label] (k @switches) (locked? ck) + #(when-not (locked? ck) (swap! switches update k not) (clear!))] + (when (locked? ck) fixed-note)])])])] [:div.p-5.m-t-5 [:button.form-button {:disabled (boolean (seq blocking)) From 52b52c9b04d7c10fa74465100238664b5e1c22fc Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 27 Oct 2026 13:16:21 +0000 Subject: [PATCH 74/91] Tests: My Account's page switches show the setting in force, locked where the site fixes it Fail state, artist_credit.clj and views.cljs restored to cc24d2a6~1, then restored with git: FAIL the-settings-page-shows-the-switches-in-force (2): with the config on and the artist off, current read on and the switch was locked cljs a-switch-the-site-fixes-shows-its-setting-and-is-locked: 3 failures (read On, not disabled, no admins' note) After: artist-credit-test 22 tests, 0 failures; cljs 871 tests, 0 failures. --- test/clj/orcpub/artist_credit_test.clj | 13 +++++++++++++ .../orcpub/dnd/e5/artist_page_credits_test.cljs | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/test/clj/orcpub/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index b92f64442..43eda6490 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -343,3 +343,16 @@ (is (= 2 (queued-credit-emails conn)) "a change in the window waits") (flush! conn (at 10)) (is (= 4 (queued-credit-emails conn)) "the batch's two, queued as it was claimed"))))) + +(deftest the-settings-page-shows-the-switches-in-force + (with-conn conn + (let [id (add-artist! conn "fusspot" :house-pack)] + (with-redefs [branding/portrait-artists {:house-pack {:artist/profile? false}}] + (let [{:keys [current locked]} (ac/credit-for-account (d/db conn) id)] + (is (false? (:artist/profile? current)) "the site's off is what the page reads") + (is (contains? locked :artist/profile?) "and the artist cannot switch it"))) + (ac/save! conn id {:profile? false}) + (with-redefs [branding/portrait-artists {:house-pack {:artist/profile? true}}] + (let [{:keys [current locked]} (ac/credit-for-account (d/db conn) id)] + (is (false? (:artist/profile? current)) "the config cannot force a page on") + (is (not (contains? locked :artist/profile?)) "so it leaves the switch with the artist")))))) diff --git a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs index d5ef33801..0ff20efa9 100644 --- a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs +++ b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs @@ -116,3 +116,13 @@ (some-> (some #(when (= "Save credit" (.-textContent %)) %) (array-seq (.querySelectorAll el "button"))) .click) (is (= {:profile? true :show-examples? false :show-links? false :show-count? true} (select-keys @sent [:profile? :show-examples? :show-links? :show-count?]))))))))) + +(deftest a-switch-the-site-fixes-shows-its-setting-and-is-locked + (with-page [views/artist-credit-form {:own {} :default {} :current {:artist/profile? false} + :locked #{:artist/profile?}}] + (fn [el] + (let [box (some #(when (= "Show my artist page" (.-textContent %)) %) + (array-seq (.querySelectorAll el "[role=checkbox]")))] + (is (= "false" (some-> box (.getAttribute "aria-checked"))) "reads Off, as the site has it") + (is (= "true" (some-> box (.getAttribute "aria-disabled"))) "and cannot be switched") + (is (re-find #"Set by the site's admins" (.-textContent el))))))) From 406d4e1948bcbb100d81addf1d0f4e5e37d0f0f1 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 27 Oct 2026 13:16:32 +0000 Subject: [PATCH 75/91] Docs: the off page redirects, the show switches are overridable, one name for #33658A, phone viewports Four CodeRabbit findings on #65: PLAN.md said a page switched off returns 404 (it redirects, 302); PORTRAIT-COMPOSITOR.md left the three show switches out of the overridable fields; the style guide proposed a second name for #33658A, which core.clj calls slate-blue; and the design comparison pages had no viewport declaration, so a phone scaled their 320px and 390px captures down. --- docs/PORTRAIT-COMPOSITOR.md | 5 +++-- docs/design/artist-profiles/PLAN.md | 4 ++-- docs/design/artist-profiles/cards.html | 1 + docs/design/artist-profiles/examples/index.html | 1 + docs/design/artist-profiles/garden/index.html | 1 + docs/design/artist-profiles/icons.html | 1 + docs/design/artist-profiles/marks.html | 1 + docs/design/artist-profiles/mockup.html | 1 + docs/design/artist-profiles/picker.html | 1 + docs/design/artist-profiles/picks.html | 1 + docs/design/artist-profiles/swatches.html | 1 + docs/design/style-guide.md | 4 ++-- docs/design/style-guide/bold.html | 1 + docs/design/style-guide/round10.html | 1 + docs/design/style-guide/round11.html | 1 + docs/design/style-guide/round12.html | 1 + docs/design/style-guide/round13.html | 1 + docs/design/style-guide/round14.html | 1 + docs/design/style-guide/round15.html | 1 + docs/design/style-guide/round16.html | 1 + docs/design/style-guide/round17.html | 1 + docs/design/style-guide/round18.html | 1 + docs/design/style-guide/round19.html | 1 + docs/design/style-guide/round20.html | 1 + docs/design/style-guide/round21.html | 1 + docs/design/style-guide/round3.html | 1 + docs/design/style-guide/round4.html | 1 + docs/design/style-guide/round5.html | 1 + docs/design/style-guide/round6.html | 1 + docs/design/style-guide/round7.html | 1 + docs/design/style-guide/round8.html | 1 + docs/design/style-guide/round9.html | 1 + docs/design/style-guide/sitewide.html | 1 + docs/design/style-guide/weights.html | 1 + 34 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/PORTRAIT-COMPOSITOR.md b/docs/PORTRAIT-COMPOSITOR.md index e5db06ba6..dbfe3aafc 100644 --- a/docs/PORTRAIT-COMPOSITOR.md +++ b/docs/PORTRAIT-COMPOSITOR.md @@ -100,8 +100,9 @@ The registry in `portrait_assets.cljc` holds the structure, which artist drew wh and that has to be public because the app cannot resolve an asset without it. How a credit is presented is a deployment concern: a fork may ship the same art under a different arrangement with the artist, or add artists its own users contributed. So `:artist/name`, -`:artist/link`, `:artist/links`, `:artist/license` and `:artist/profile?` are overridable, -and nothing else is. A fork can credit its own contributors without editing shared code, and +`:artist/link`, `:artist/links`, `:artist/license` and the page switches (`:artist/profile?`, +`:artist/show-examples?`, `:artist/show-links?`, `:artist/show-count?`) are overridable, and +nothing else is. A fork can credit its own contributors without editing shared code, and cannot silently re-attribute someone's art to a different pack. - **`PORTRAIT_ARTISTS` that does not parse is ignored with a warning.** A broken credit diff --git a/docs/design/artist-profiles/PLAN.md b/docs/design/artist-profiles/PLAN.md index 157c9f304..5836653bb 100644 --- a/docs/design/artist-profiles/PLAN.md +++ b/docs/design/artist-profiles/PLAN.md @@ -128,8 +128,8 @@ accounts are being built elsewhere, so this branch adds only the switch the page `set-artist-overrides!` next to the other credit fields, so the account setting only has to write it. When it is off: -- `/artists/<slug>` and its example images return 404, the same as for a name nobody - has, and the 404's share tags don't name the artist; +- `/artists/<slug>` redirects (302, never 301, so turning it back on works at once) to + `/artists`, and its example images return 404; neither response names the artist; - the artist drops out of `/artists`; - the credit loses its "About the artist" line; - the credit's name still links to the artist's own `:artist/link`. diff --git a/docs/design/artist-profiles/cards.html b/docs/design/artist-profiles/cards.html index 651f4173d..f7a97c1ca 100644 --- a/docs/design/artist-profiles/cards.html +++ b/docs/design/artist-profiles/cards.html @@ -1,3 +1,4 @@ +<meta name="viewport" content="width=device-width, initial-scale=1"> <title>Artist Card Options