diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 566cfd020..2af8161a5 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -3,5 +3,8 @@ # agent (all push as codeGlaze). Ask with `@coderabbitai full review` when a PR is ready. # CodeRabbit reads this file from the PR's own branch, so a branch made before a change keeps the old one. reviews: + # Design mockups and capture scripts are not reviewed: a PR over 100 files is skipped whole. + path_filters: + - "!docs/design/**" auto_review: enabled: false diff --git a/.gitignore b/.gitignore index a535f975a..b806ffa57 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,11 @@ __pycache__/ # strand fields are derived from the real portrait art and travel with it resources/public/image/portraits/**/*.strands.png + +# Design captures (screenshots, renders) are never committed: write them anywhere under +# docs/design/ and they stay local. The capture scripts and written plans are tracked as usual. +docs/design/**/*.png +docs/design/**/*.jpg +docs/design/**/*.jpeg +docs/design/**/*.webp +docs/design/**/*.gif diff --git a/ARTISTS.md b/ARTISTS.md index 972c02be9..bbd674126 100644 --- a/ARTISTS.md +++ b/ARTISTS.md @@ -55,7 +55,8 @@ A deployment can restate a credit without editing shared code, via PORTRAIT_ARTISTS='{"house-pack": {"name": "…", "link": "https://…"}}' ``` -Only `name`, `link`, `links` and `license` are honoured. Which artist drew which asset +`name`, `link`, `links` and `license` are honoured, and `profile`, `show_examples`, `show_links` and +`show_count` only when set to `false` (the list below). Which artist drew which asset is structural, lives in the registry, and is not overridable — a configuration cannot quietly re-attribute someone's work to a different pack. @@ -101,11 +102,16 @@ What the site shows is layered, and later layers win field by field: 1. the registry in `portrait_assets.cljc` (the public default) 2. the artist's own edits, from My Account → Artist credit -3. `name`, `link` and `links` in `PORTRAIT_ARTISTS` +3. `name`, `link` and `links` in `PORTRAIT_ARTISTS`; and `profile`, `show_examples`, + `show_links` and `show_count` set to `false` turn the artist's page, or that part of it, off + whatever the artist chose (`true` does nothing: the site cannot force a page on) 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/CHANGELOG.md b/CHANGELOG.md index ed4a44526..dbbef902d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,59 +41,65 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Site:** a What's New panel shows each release's highlights once. (#711) - **Site:** a "Dark Button Text" option for the yellow buttons. (#711) - **Server:** a startup report of the server's settings, naming what is missing and what signup will do, never printing secrets, and ending with the settings new in this release that are not set yet. (#712) -- **Accounts:** sign-up, password reset and the other account emails are kept and retried until the mail server takes them, so signing up works while the mail server is down, and no email is lost to a busy one. -- **Server:** after the app runs out of memory or crashes, it restarts clean and the admin is emailed what happened; the evidence is kept in `/var/log/orcpub` on the host, on Docker Compose and Swarm alike. Docker logs are capped so they cannot fill the disk. -- **Server:** sign-ins, failed sign-ins, sign-ups, password resets, email changes, deleted accounts and refused logins are written to the server log, without passwords or email addresses. The log no longer lists every web request; the web proxy keeps that record. -- **Server:** admin alerts: one email per problem with a count of repeats, an hourly ceiling, and nothing lost or slowed down when the mail server or the database is down. -- **Rules:** a Rules tab with the full SRD 5.1 rules reference, by section, and the 15 conditions. +- **Accounts:** sign-up, password reset and the other account emails are kept and retried until the mail server takes them, so signing up works while the mail server is down, and no email is lost to a busy one. (#716) +- **Server:** after the app runs out of memory or crashes, it restarts clean and the admin is emailed what happened; the evidence is kept in `/var/log/orcpub` on the host, on Docker Compose and Swarm alike. Docker logs are capped so they cannot fill the disk. (#716) +- **Server:** sign-ins, failed sign-ins, sign-ups, password resets, email changes, deleted accounts and refused logins are written to the server log, without passwords or email addresses. The log no longer lists every web request; the web proxy keeps that record. (#716) +- **Server:** admin alerts: one email per problem with a count of repeats, an hourly ceiling, and nothing lost or slowed down when the mail server or the database is down. (#716) +- **Rules:** a Rules tab with the full SRD 5.1 rules reference, by section, and the 15 conditions. (#715) - **Rules:** links in rules text to the spells, conditions and rules they name, with a preview - on hover, or on a first tap on a phone. -- **Orcacle:** finds rules and conditions by name. + on hover, or on a first tap on a phone. (#715) +- **Orcacle:** finds rules and conditions by name. (#715) - **Monsters:** conditions in a monster's condition immunities, traits and actions link to their - page, with a preview. -- **Equipment:** weapons and armor show their SRD price and weight. + page, with a preview. (#715) +- **Equipment:** weapons and armor show their SRD price and weight. (#715) - **Rules:** clarification notes on rules, in our own words, linked to where Wizards published - the ruling. -- **Spells:** conditions named in spell and magic item text link to their page, with a preview. + the ruling. (#715) +- **Spells:** conditions named in spell and magic item text link to their page, with a preview. (#715) - **Characters:** a portrait builder: pick or randomize layered art for hair, head, ears, eyes, nose, mouth and shirt, from the summary thumbnail, beside the Image URL field, or in a Portrait - tab in the builder. + tab in the builder. (#714) - **Characters:** portrait colours per character for hair, skin, eyes, eye whites, lips and shirt, - with a lighter, darker or different colour for any single piece. + with a lighter, darker or different colour for any single piece. (#714) - **Characters:** hair ombré with streaks that follow the drawn strands, a shine you can place, - dyed bangs, and soft shadows where hair falls over the face. + dyed bangs, and soft shadows where hair falls over the face. (#714) - **Characters:** eyes with slit or goat pupils, a different colour for each eye, brightness, and - glowing eyes. -- **Characters:** blush, freckles and shading under the hair. -- **Characters:** mood light: torch, moonlight or arcane, as a lit edge, a cast or a rim. + glowing eyes. (#714) +- **Characters:** blush, freckles and shading under the hair. (#714) +- **Characters:** mood light: torch, moonlight or arcane, as a lit edge, a cast or a rim. (#714) - **Characters:** the artist is credited under the portrait, on the sheet, in shared link previews - and in the picture itself, with the links the artist chose. -- **Printing:** the portrait prints on the exported character sheet, at full resolution. + and in the picture itself, with the links the artist chose. (#714) +- **Printing:** the portrait prints on the exported character sheet, at full resolution. (#714) - **Sharing:** a shared character link previews with the character's own portrait, and the - preview updates when the portrait changes. -- **Accounts:** a preferred name, used to greet you in site emails. + preview updates when the portrait changes. (#714) +- **Accounts:** a preferred name, used to greet you in site emails. (#714) - **Accounts:** artists get an account with a welcome email and can change how they are credited - on My Account. -- **Site:** pages and portrait images ask AI crawlers not to train on them. + on My Account. (#714) +- **Site:** pages and portrait images ask AI crawlers not to train on them. (#714) - **Characters:** unsaved changes left in a closed tab are offered once when you open that character again, with Undo; older ones wait in an "Unsaved drafts" list in the builder and on the character list for 30 days (a character never saved to an account is kept). A character open in another - tab waits for "Edit here". -- **Characters:** lowering a level or changing a class, subclass or race keeps the choices that depend on it while you edit; change your mind and they come back by themselves, with Undo. -- **Characters:** the builder shows choices waiting on a higher level beside the level picker, and says when each comes back. -- **Characters:** with more than one class, a "1st" badge marks the starting class; tap another class's badge to make it the starting class, and choices that only apply to the starting class follow. -- **Characters:** a Choices list in the builder shows every choice a character stores, including ones whose homebrew isn't loaded in this browser, and lets those be cleared. -- **Homebrew:** races, subraces, backgrounds and feats can give ability score increases the player places, such as +2 to one ability and +1 to another, or +1 to any martial ability; subclasses can too, in an optional section. -- **Homebrew:** homebrew can give saving throw proficiencies, fixed or chosen, on their own or with an ability score increase. -- **Homebrew:** a file that uses these is marked so an older version of the site refuses it instead of loading it wrong. + tab waits for "Edit here". (#704) +- **Characters:** lowering a level or changing a class, subclass or race keeps the choices that depend on it while you edit; change your mind and they come back by themselves, with Undo. (#718) +- **Characters:** the builder shows choices waiting on a higher level beside the level picker, and says when each comes back. (#718) +- **Characters:** with more than one class, a "1st" badge marks the starting class; tap another class's badge to make it the starting class, and choices that only apply to the starting class follow. (#718) +- **Characters:** a Choices list in the builder shows every choice a character stores, including ones whose homebrew isn't loaded in this browser, and lets those be cleared. (#718) +- **Homebrew:** races, subraces, backgrounds and feats can give ability score increases the player places, such as +2 to one ability and +1 to another, or +1 to any martial ability; subclasses can too, in an optional section. (#717) +- **Homebrew:** homebrew can give saving throw proficiencies, fixed or chosen, on their own or with an ability score increase. (#717) +- **Homebrew:** a file that uses these is marked so an older version of the site refuses it instead of loading it wrong. (#717) - **Characters:** the character builder's Back and Next repeat at the bottom of each page, and open the next page at its top. - **Homebrew:** the add buttons on homebrew builders repeat below a long list of traits or options. -- **Printing:** A4 as well as US Letter, for the character sheet, spell cards, magic item cards and the spellbook. An A4 spellbook page carries about 7% more than a Letter one. -- **Printing:** when part of an export cannot be produced, the PDF opens with a page naming what was left out and why. A clean export is unchanged and gains no page. +- **Printing:** A4 as well as US Letter, for the character sheet, spell cards, magic item cards and the spellbook. An A4 spellbook page carries about 7% more than a Letter one. (#719) +- **Printing:** when part of an export cannot be produced, the PDF opens with a page naming what was left out and why. A clean export is unchanged and gains no page. (#719) +- **Site:** a public page for each portrait artist, with their name, how many pieces they drew, + 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 -- **Server:** a deployed server will not start without a mail server unless `EMAIL_REQUIRED=false` says that is intended, and the message names the setting to add. Development mode is exempt; `./run` and `./run --upgrade` set the opt-out for installs that run without email, so none stops on upgrade. -- **Server:** admin alerts and artist notices fall back to the public support address (`APP_SUPPORT_EMAIL`) when `EMAIL_ERRORS_TO` and `EMAIL_ADMIN_TO` are unset, and the startup report names the address in use. +- **Server:** a deployed server will not start without a mail server unless `EMAIL_REQUIRED=false` says that is intended, and the message names the setting to add. Development mode is exempt; `./run` and `./run --upgrade` set the opt-out for installs that run without email, so none stops on upgrade. (#716) +- **Server:** admin alerts and artist notices fall back to the public support address (`APP_SUPPORT_EMAIL`) when `EMAIL_ERRORS_TO` and `EMAIL_ADMIN_TO` are unset, and the startup report names the address in use. (#716) - **Characters:** switching between Race and Class no longer freezes the builder, and an edit rebuilds the character once. (#621) - **Characters:** the Missing Content warning also covers spells and languages, and ignores the built-in "Custom" option. (#524) @@ -109,8 +115,10 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Characters:** the builder's tab bar wraps on narrow screens. - **Characters:** opening a character whose choices no longer apply sets them aside and lists them in a notice; the saved copy changes only when you save. - **Printing:** a character portrait whose host is briefly unreachable is fetched a second time before the export gives up on it. -- **Printing:** every sans-serif line in an export -- sheet fields, spell-column headings, the site line, the notice page and card captions -- is set in one embedded font with Helvetica's exact widths, so it looks the same in every PDF viewer and prints names such as Łukasz. -- **Server:** the first start after upgrading builds new database indexes and gives older accounts an email lookup key, both in the background; until that finishes, an older account with a mixed-case address signs in by username rather than email. +- **Printing:** every sans-serif line in an export -- sheet fields, spell-column headings, the site line, the notice page and card captions -- is set in one embedded font with Helvetica's exact widths, so it looks the same in every PDF viewer and prints names such as Łukasz. (#719) +- **Server:** the first start after upgrading builds new database indexes and gives older accounts an email lookup key, both in the background; until that finishes, an older account with a mixed-case address signs in by username rather than email. (#721) +- **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 @@ -135,8 +143,8 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Homebrew:** a custom magic item saved with no type no longer breaks. (#694) - **Characters:** the Features tab loads when a trait has no name. (#680) - **Characters:** saving a character that was deleted in another tab no longer sends you to the login page with nothing said; your changes stay, and Save stores them as a new character. -- **Characters:** the Robe of the Archmagi sets your AC to 15 + Dex instead of adding 5, so it no longer stacks with Draconic Resilience or Unarmored Defense. -- **Characters:** a tortle's Ring of Protection, Bracers of Defense and similar bonuses now count toward its AC of 17; only armor is ignored. +- **Characters:** the Robe of the Archmagi sets your AC to 15 + Dex instead of adding 5, so it no longer stacks with Draconic Resilience or Unarmored Defense. (#720) +- **Characters:** a tortle's Ring of Protection, Bracers of Defense and similar bonuses now count toward its AC of 17; only armor is ignored. (#720) - **Characters:** anyone who can open a character sees its owner's custom items. (#252) - **Accounts:** a failed verification email no longer leaves an account stuck. (#700) - **Accounts:** the app signs you out when the server stops accepting your login. (#708) @@ -152,22 +160,22 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Site:** dismissing the cookie notice now hides it on every page, not only the page where it was dismissed. - **Server:** the same content gets the same keys on Turkish and other locales. (#699) - **Monsters:** stat blocks show reactions, such as the Marilith's Parry, and the monster builder - can add one. + can add one. (#715) - **Monsters:** the Adult and Ancient Gold Dragons, Adult Green Dragon, Ancient Brass Dragon, - Succubus/Incubus and Spy show all their skills; Stealth and Persuasion were missing. -- **Monsters:** the Flying Snake shows its Flyby trait. + Succubus/Incubus and Spy show all their skills; Stealth and Persuasion were missing. (#715) +- **Monsters:** the Flying Snake shows its Flyby trait. (#715) - **Equipment:** tools, musical instruments, gaming sets and equipment packs show their SRD prices, - and tools and instruments their weights. -- **Magic items:** the Alchemy Jug shows that its liquid can be created once a day. + and tools and instruments their weights. (#715) +- **Magic items:** the Alchemy Jug shows that its liquid can be created once a day. (#715) - **Characters:** unsaved builder edits are no longer lost when a character's sheet or list saves, - whether for another character or the same one, or when it levels up. + whether for another character or the same one, or when it levels up. (#704) - **Characters:** a sheet edit is no longer undone by a builder Save or by a save that was already - under way. + under way. (#704) - **Characters:** closing the tab still warns while a save has not gone through. - **Characters:** builder tabs open on different characters keep their own unsaved changes; a reload no longer brings back the other tab's character. (#704) - **Characters:** on a shared browser, logging in no longer opens the previous account's unsaved - character; each account's unsaved work stays its own, and work done logged out carries over. + character; each account's unsaved work stays its own, and work done logged out carries over. (#704) - **Characters:** a change made on the character sheet while offline is saved when the connection returns, instead of waiting for another change. - **Sharing:** link previews of shared characters failed on Discord, Mastodon, Slack, Telegram and @@ -185,15 +193,18 @@ Homebrew can be moved, switched off and repaired instead of lost, and a characte - **Printing:** a long class name in a spellbook chapter heading, or a long custom spell name, is now shortened to fit instead of running off the page. - **Printing:** an export with more spells or magic items than the cap allows now says so on the notice page; the extras were dropped in silence. - **Printing:** on a server whose JVM runs a non-English locale, spell-card school names and the sheet's ability labels drew the wrong letter: a Turkish server printed "İllusion" and "İNT". -- **Accounts:** follow buttons remember who you follow after the page reloads. +- **Accounts:** follow buttons remember who you follow after the page reloads. (#721) - **Server:** sign-in, password reset and character, party, folder and item lists no longer slow down and use more memory as the site grows. +- **Site:** all seven header tabs and their menus fit on a 320px phone. +- **Spells:** a spell can no longer be prepared once every preparation is used. ### Security - **Accounts:** on a server whose JVM runs a non-English locale, an email address containing a capital I folded to a different account key, so sign-in, password reset, the unsubscribe link and email change could all miss the account. - Sign-in, password reset, share links, image fetching and PDF export are hardened against misuse, and secrets are no longer printed at startup. -- **Site:** pages no longer load fonts from Google, so visiting the site no longer sends your address to Google. +- **Site:** pages no longer load fonts from Google, so visiting the site no longer sends your address to Google. (#721) +- **Site:** other sites a page links to are told only this site's domain, never the page's address. ## [breaking/2026-stack-modernization] 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/docs/PORTRAIT-COMPOSITOR.md b/docs/PORTRAIT-COMPOSITOR.md new file mode 100644 index 000000000..dbfe3aafc --- /dev/null +++ b/docs/PORTRAIT-COMPOSITOR.md @@ -0,0 +1,226 @@ +# 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 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 +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 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 + 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-SERVING.md b/docs/PORTRAIT-SERVING.md index 9069a9b8b..11be27323 100644 --- a/docs/PORTRAIT-SERVING.md +++ b/docs/PORTRAIT-SERVING.md @@ -73,6 +73,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//examples/` 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 `.strands.png` beside each hair piece. Without it (running from diff --git a/docs/PORTRAIT-TINTING.md b/docs/PORTRAIT-TINTING.md index 6ba898046..dbec2c2a4 100644 --- a/docs/PORTRAIT-TINTING.md +++ b/docs/PORTRAIT-TINTING.md @@ -109,6 +109,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 new file mode 100644 index 000000000..d90cdcee8 --- /dev/null +++ b/docs/design/artist-profiles/PLAN.md @@ -0,0 +1,221 @@ +# 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, 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, +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//examples/` | example `n` (1 to 3) as a small watermarked JPEG, rendered on first request and kept in a bounded cache (a failed render is retried after 60 s); 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 +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, 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. + 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 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 + +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, +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. + +--- + +## 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/` 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`. + +## 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/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`) | + +## What was verified + +- `lein test`: the new `artist-profile-test` (27 tests) and `artist-page-test` (15 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 46 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.** 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. +4. **When a page is turned off**, the address redirects to `/artists`, as if nobody were there. 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. + +## 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. + +## 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 on first request (`routes/example-jpeg`) and keeps it in a +bounded cache, single-flight so a burst shares one render; a failed render is retried after 60 s. Each is +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 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 +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/docs/design/artist-profiles/cards.html b/docs/design/artist-profiles/cards.html new file mode 100644 index 000000000..f7a97c1ca --- /dev/null +++ b/docs/design/artist-profiles/cards.html @@ -0,0 +1,122 @@ + +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.
  • +
+
+
+ + diff --git a/docs/design/artist-profiles/examples/index.html b/docs/design/artist-profiles/examples/index.html new file mode 100644 index 000000000..ae800ecb8 --- /dev/null +++ b/docs/design/artist-profiles/examples/index.html @@ -0,0 +1,76 @@ + +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
+
+
+
diff --git a/docs/design/artist-profiles/garden/index.html b/docs/design/artist-profiles/garden/index.html new file mode 100644 index 000000000..44a8d3e59 --- /dev/null +++ b/docs/design/artist-profiles/garden/index.html @@ -0,0 +1,76 @@ + +Artist Pages in Garden + + + +
+
+

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 +
+ +
+
+ + diff --git a/docs/design/artist-profiles/icons.html b/docs/design/artist-profiles/icons.html new file mode 100644 index 000000000..45caa5dc8 --- /dev/null +++ b/docs/design/artist-profiles/icons.html @@ -0,0 +1,161 @@ + +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.
  • +
+
+
+ + diff --git a/docs/design/artist-profiles/marks.html b/docs/design/artist-profiles/marks.html new file mode 100644 index 000000000..f95363103 --- /dev/null +++ b/docs/design/artist-profiles/marks.html @@ -0,0 +1,113 @@ + +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.
  • +
+
+
+ diff --git a/docs/design/artist-profiles/mockup.html b/docs/design/artist-profiles/mockup.html new file mode 100644 index 000000000..683186897 --- /dev/null +++ b/docs/design/artist-profiles/mockup.html @@ -0,0 +1,448 @@ + +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 · the artists list Built

+
Example ArtistPage and links as in section 1.
+

Off sends the visitor to the artists list without naming anyone, 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.

+
+
+
+
+ + diff --git a/docs/design/artist-profiles/picker.html b/docs/design/artist-profiles/picker.html new file mode 100644 index 000000000..c767e0cda --- /dev/null +++ b/docs/design/artist-profiles/picker.html @@ -0,0 +1,96 @@ + +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.
  • +
+
+
+ diff --git a/docs/design/artist-profiles/picks.html b/docs/design/artist-profiles/picks.html new file mode 100644 index 000000000..51fca3596 --- /dev/null +++ b/docs/design/artist-profiles/picks.html @@ -0,0 +1,107 @@ + +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.
  • +
+
+
+ diff --git a/docs/design/artist-profiles/swatches.html b/docs/design/artist-profiles/swatches.html new file mode 100644 index 000000000..17144705f --- /dev/null +++ b/docs/design/artist-profiles/swatches.html @@ -0,0 +1,136 @@ + +Site Mark Colour Pairs + + + + +
+
+
+
OrcPub · portrait artists
+

Site mark colour pairs

+

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.

+
+
+ +
+

In the credit

+
+
+ +
+

The pairs

+

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

+
ColourDark themeLight theme
+
+ +
+

Trade-off

+
    +
  • 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.
  • +
+
+
+ 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/docs/design/capture/capture.js b/docs/design/capture/capture.js new file mode 100644 index 000000000..1f6c0cac8 --- /dev/null +++ b/docs/design/capture/capture.js @@ -0,0 +1,23 @@ +// Run design captures against the app on ORCPUB_BASE (default http://localhost:8890): +// 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'); + +(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..1b2d9314f --- /dev/null +++ b/docs/design/capture/lib.js @@ -0,0 +1,159 @@ +// 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 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'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.ORCPUB_BASE || 'http://localhost:8890'; +// login() reads its address from E2E_BASE when its module loads, so it signs in on this server. +process.env.E2E_BASE = BASE; +const { login } = require('../../../scripts/e2e/lib.js'); + +// 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..70652adb7 --- /dev/null +++ b/docs/design/capture/rounds.js @@ -0,0 +1,867 @@ +// 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 = '', dpr = 2) { + for (const darkText of [false, true]) { + const v = await cap.open(BUILDER, { darkText, dpr }); + 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}` : '', +(process.env.DPR || 2)) + }, + + '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(); + } + } + }, + + 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': { + 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 2c74b467f..2751f4e4f 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-21`) +### Named colours (`core.clj:8-30`) These are the palette. Use them by name. @@ -56,6 +56,15 @@ These are the palette. Use them by name. | `green` | `#70a800` | Conflict resolution: keep existing | | `cyan` | `#47eaf8` | Import log; conflict rename option | | `purple` | `#8b7ec8` | Conflict skip option | +| `light-text` | `#363636` | Body text, light theme | +| `slate-blue` | `#33658A` | The light theme's accent, where the dark theme uses orange | +| `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-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 +carries the same values as literals in its inline stylesheet (`portrait.cljs`), until it moves. **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 @@ -84,8 +93,8 @@ These recur as literals. **Proposed:** give each a name. | `#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`. +`#33658A` does for the light theme what `orange` does for the dark one: `slate-blue` in +`core.clj`, the name the palette above uses. ### Near-duplicates to fold together @@ -128,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 @@ -171,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/bold.html b/docs/design/style-guide/bold.html new file mode 100644 index 000000000..1e385bae9 --- /dev/null +++ b/docs/design/style-guide/bold.html @@ -0,0 +1,99 @@ + +Open Sans Weights + + + + +
+
+
+
OrcPub · style guide
+

Open Sans weights, before and after

+

The character builder in the running site. Before is the site as it ships: Open Sans loaded at weight 400 only. After adds the 600 and 700 weights from Google Fonts and changes nothing else. Toggle between them and watch the header tabs, the page title and the class name.

+
+
+
+ + +
+
+ + +
+
+
+
+
+
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 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-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/round10.html b/docs/design/style-guide/round10.html new file mode 100644 index 000000000..7f76774fe --- /dev/null +++ b/docs/design/style-guide/round10.html @@ -0,0 +1,71 @@ + +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.

+
diff --git a/docs/design/style-guide/round11.html b/docs/design/style-guide/round11.html new file mode 100644 index 000000000..7eac41bc7 --- /dev/null +++ b/docs/design/style-guide/round11.html @@ -0,0 +1,77 @@ + +Gradient Outlines + + + + + +
+
+
+
OrcPub · feature/style-guide · options, not built
+

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.

+
+
+

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.

+
diff --git a/docs/design/style-guide/round12.html b/docs/design/style-guide/round12.html new file mode 100644 index 000000000..3c88b1414 --- /dev/null +++ b/docs/design/style-guide/round12.html @@ -0,0 +1,79 @@ + +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.

+
diff --git a/docs/design/style-guide/round13.html b/docs/design/style-guide/round13.html new file mode 100644 index 000000000..cdc371ee1 --- /dev/null +++ b/docs/design/style-guide/round13.html @@ -0,0 +1,88 @@ + +Ability Buttons on a Phone + + + + + + + +
+
+
OrcPub · feature/style-guide · 6ce56c00
+

Ability buttons on a phone

+

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, 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.

+
diff --git a/docs/design/style-guide/round14.html b/docs/design/style-guide/round14.html new file mode 100644 index 000000000..414c9949b --- /dev/null +++ b/docs/design/style-guide/round14.html @@ -0,0 +1,88 @@ + +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
+
diff --git a/docs/design/style-guide/round15.html b/docs/design/style-guide/round15.html new file mode 100644 index 000000000..13504d2f8 --- /dev/null +++ b/docs/design/style-guide/round15.html @@ -0,0 +1,86 @@ + +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
+
diff --git a/docs/design/style-guide/round16.html b/docs/design/style-guide/round16.html new file mode 100644 index 000000000..d58b31ab0 --- /dev/null +++ b/docs/design/style-guide/round16.html @@ -0,0 +1,118 @@ + +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.

+
diff --git a/docs/design/style-guide/round17.html b/docs/design/style-guide/round17.html new file mode 100644 index 000000000..3ff620501 --- /dev/null +++ b/docs/design/style-guide/round17.html @@ -0,0 +1,95 @@ + +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.

+
diff --git a/docs/design/style-guide/round18.html b/docs/design/style-guide/round18.html new file mode 100644 index 000000000..c94283a7d --- /dev/null +++ b/docs/design/style-guide/round18.html @@ -0,0 +1,86 @@ + +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
+
diff --git a/docs/design/style-guide/round19.html b/docs/design/style-guide/round19.html new file mode 100644 index 000000000..258c06aad --- /dev/null +++ b/docs/design/style-guide/round19.html @@ -0,0 +1,92 @@ + +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
+
diff --git a/docs/design/style-guide/round20.html b/docs/design/style-guide/round20.html new file mode 100644 index 000000000..275e45f05 --- /dev/null +++ b/docs/design/style-guide/round20.html @@ -0,0 +1,158 @@ + +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.

+
diff --git a/docs/design/style-guide/round21.html b/docs/design/style-guide/round21.html new file mode 100644 index 000000000..e53988282 --- /dev/null +++ b/docs/design/style-guide/round21.html @@ -0,0 +1,158 @@ + +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.

+
diff --git a/docs/design/style-guide/round3.html b/docs/design/style-guide/round3.html new file mode 100644 index 000000000..6e2efc032 --- /dev/null +++ b/docs/design/style-guide/round3.html @@ -0,0 +1,126 @@ + +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
+
+
+
+ diff --git a/docs/design/style-guide/round4.html b/docs/design/style-guide/round4.html new file mode 100644 index 000000000..aec54541f --- /dev/null +++ b/docs/design/style-guide/round4.html @@ -0,0 +1,114 @@ + +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)
+
+
+ diff --git a/docs/design/style-guide/round5.html b/docs/design/style-guide/round5.html new file mode 100644 index 000000000..194236e00 --- /dev/null +++ b/docs/design/style-guide/round5.html @@ -0,0 +1,68 @@ + +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)
+
+
diff --git a/docs/design/style-guide/round6.html b/docs/design/style-guide/round6.html new file mode 100644 index 000000000..cfa51dec6 --- /dev/null +++ b/docs/design/style-guide/round6.html @@ -0,0 +1,68 @@ + +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
+
+
diff --git a/docs/design/style-guide/round7.html b/docs/design/style-guide/round7.html new file mode 100644 index 000000000..8801c8e36 --- /dev/null +++ b/docs/design/style-guide/round7.html @@ -0,0 +1,70 @@ + +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/round8.html b/docs/design/style-guide/round8.html new file mode 100644 index 000000000..d492f1060 --- /dev/null +++ b/docs/design/style-guide/round8.html @@ -0,0 +1,74 @@ + +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
+
+
diff --git a/docs/design/style-guide/round9.html b/docs/design/style-guide/round9.html new file mode 100644 index 000000000..33343c047 --- /dev/null +++ b/docs/design/style-guide/round9.html @@ -0,0 +1,70 @@ + +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
+
+
diff --git a/docs/design/style-guide/sitewide.html b/docs/design/style-guide/sitewide.html new file mode 100644 index 000000000..9cfbd5e03 --- /dev/null +++ b/docs/design/style-guide/sitewide.html @@ -0,0 +1,116 @@ + +Bold Across the Site + + + + +
+
+
+
OrcPub · feature/style-guide
+

Bold across the site

+

The feature/style-guide build, running. Before is what integration ships (Open Sans 400 only, buttons in Arial). After is the branch: 400/600/700 loaded, tabs 700, page titles 600, buttons in Open Sans. Loading the bold weights doesn’t only affect the header: everything the code marks bold gets heavier.

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

+
    +
  • 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.
  • +
+
+
+ diff --git a/docs/design/style-guide/weights.html b/docs/design/style-guide/weights.html new file mode 100644 index 000000000..423ad5b7d --- /dev/null +++ b/docs/design/style-guide/weights.html @@ -0,0 +1,114 @@ + +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.

+
+
+ + +
+
+
+

Together, in context

+

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

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

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.
  • +
+
+
+ diff --git a/scripts/e2e/layout-width.js b/scripts/e2e/layout-width.js index d612f8b77..1b138d34d 100644 --- a/scripts/e2e/layout-width.js +++ b/scripts/e2e/layout-width.js @@ -60,7 +60,7 @@ async function resize(page, width, height) { (async () => { const browser = await chromium.launch({ executablePath: findChrome() }); console.log(`\nlayout width e2e -- ${BASE}\n`); - const PHONE = 'options,description,details', WIDE = 'options,description'; + const PHONE = 'options,description,portrait,details', WIDE = 'options,description,portrait'; // --- a desktop browser, resized ------------------------------------------ { diff --git a/scripts/test/probe-baseline.json b/scripts/test/probe-baseline.json index c7e90c3b8..78716db93 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": 52, "seconds": 50 }, "homebrew_safety_net_e2e.js": { @@ -84,6 +84,10 @@ "checks": 11, "seconds": 45 }, + "artist_profile_e2e.js": { + "checks": 46, + "seconds": 60 + }, "character_heal_e2e.js": { "checks": 13, "seconds": 25 diff --git a/scripts/test/run-browser-probes.js b/scripts/test/run-browser-probes.js index 99c521f6e..90cbc3559 100755 --- a/scripts/test/run-browser-probes.js +++ b/scripts/test/run-browser-probes.js @@ -69,6 +69,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/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, diff --git a/src/clj/orcpub/artist_credit.clj b/src/clj/orcpub/artist_credit.clj index 8e2d0a837..b122203e4 100644 --- a/src/clj/orcpub/artist_credit.clj +++ b/src/clj/orcpub/artist_credit.clj @@ -24,6 +24,8 @@ [datomic.api :as d] [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])) ;; --------------------------------------------------------------------------- @@ -40,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 } 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 } 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) @@ -53,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)))}))) @@ -72,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 @@ -98,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)) @@ -121,8 +141,9 @@ ;; --------------------------------------------------------------------------- (defn credit-for-account - "What the settings page shows an artist: {:current - :own :locked #{fields the config decides}}." + "What the settings page shows an artist: {:current :own + :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)] @@ -132,13 +153,35 @@ 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." + 60) + +(def ^:private recent-saves + "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 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}, 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 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) {:orcpub.user/keys [artist artist-credit]} @@ -146,13 +189,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 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] (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..a20164a0c --- /dev/null +++ b/src/clj/orcpub/artist_credit_notices.clj @@ -0,0 +1,115 @@ +(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.datomic :as orcpub-datomic] + [orcpub.outbox :as outbox] + [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`. 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) + overdue (when-not (s/blank? artist-credit-pending) artist-credit-pending)] + (if (window-open? artist-credit-window now) + (do (when-not overdue + ;; claimed from the empty value read above; if a concurrent save claimed it first, + ;; its `before` stands + (try @(d/transact conn [[:db/cas id :orcpub.user/artist-credit-pending + artist-credit-pending (pr-str (or before {}))]]) + (catch Exception e + (when-not (orcpub-datomic/cas-failed? e) (throw e))))) + :pending) + (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- 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! + "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) + 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) + 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 + (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/artist_email.clj b/src/clj/orcpub/artist_email.clj index 2c0e4913c..f202ab903 100644 --- a/src/clj/orcpub/artist_email.clj +++ b/src/clj/orcpub/artist_email.clj @@ -298,14 +298,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" @@ -409,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/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index c0c803d4c..56348d936 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -207,6 +207,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/fork/branding.clj b/src/clj/orcpub/fork/branding.clj index d5115b829..27cf28b7c 100644 --- a/src/clj/orcpub/fork/branding.clj +++ b/src/clj/orcpub/fork/branding.clj @@ -189,9 +189,11 @@ ;; ─── Portrait Artist Credits ─────────────────────────────────────── (defn artist-override - "One artist's credit `fields` from PORTRAIT_ARTISTS (:name :link :links :license) as overrides. + "One artist's credit `fields` from PORTRAIT_ARTISTS (:name :link :links :license, and the page + switches :profile :show_examples :show_links :show_count, kept only when false) as overrides. Links pass artist-links/url-problem, the check an artist's own edits pass; a link's icon and - colour come from its address (artist-links/link-entry), never from the setting. Returns the map." + colour come from its address (artist-links/link-entry), never from the setting. A switch can + only turn a page or its part off. Returns the map." [fields] (let [ok-url (fn [u] (if (links/url-problem u) (do (warn! :portrait-artists u (links/url-problem u)) nil) @@ -205,25 +207,18 @@ (string? (:name fields)) (assoc :artist/name (:name fields)) link (assoc :artist/link link) (seq extra) (assoc :artist/links extra) - (string? (:license fields)) (assoc :artist/license (:license fields))))) + (string? (:license fields)) (assoc :artist/license (:license fields)) + true (merge (into {} (for [[k ck] {:profile :artist/profile? :show_examples :artist/show-examples? + :show_links :artist/show-links? :show_count :artist/show-count?} + :when (false? (get fields k))] + [ck false])))))) (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\". + A value that does not parse is ignored with a warning." (or (when-let [raw (not-empty (env/value :portrait-artists))] (try (into {} diff --git a/src/clj/orcpub/pdf.clj b/src/clj/orcpub/pdf.clj index a7a75eebc..e8255eecb 100644 --- a/src/clj/orcpub/pdf.clj +++ b/src/clj/orcpub/pdf.clj @@ -1489,18 +1489,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 @@ -1526,18 +1517,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. + 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) @@ -1557,21 +1539,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/pedestal.clj b/src/clj/orcpub/pedestal.clj index f3e0dcc82..00b676206 100644 --- a/src/clj/orcpub/pedestal.clj +++ b/src/clj/orcpub/pedestal.clj @@ -80,6 +80,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")))})) + (defn- read-content-stamp "The first 12 hex digits of the SHA-1 of the public resource at `path`, or nil when it is missing." [path] 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 b4c888fad..75043cfec 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 draw/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!`. + Every failure returns nil (no portrait) rather than throwing. + Why: PORTRAIT-COMPOSITOR.md, \"Rendering on the server\"." (:require [orcpub.env :as env] [clojure.java.io :as io] [clojure.string :as s] @@ -31,12 +18,12 @@ [orcpub.portrait-pack.strands :as strands] [orcpub.dnd.e5.portrait-face :as face] [orcpub.dnd.e5.portrait-light :as light]) - (:import [java.awt AlphaComposite BasicStroke Color Graphics2D RenderingHints] - [java.awt.geom AffineTransform Area Ellipse2D$Double Path2D$Double] + (:import [java.awt AlphaComposite BasicStroke Color Graphics2D RadialGradientPaint RenderingHints] + [java.awt.geom AffineTransform Area Ellipse2D$Double 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 @@ -56,14 +43,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. + 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 ".."))) @@ -168,16 +150,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) @@ -507,13 +482,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. + Faces ship with the app, so rendering does not depend on the host's fonts." (memoize (fn [path] (try @@ -532,17 +502,10 @@ (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 the credit for artist `names` centred along the bottom of a `w`x`h` frame, outlined + then filled: the longest wording from pa/credit-variants that fits beside the site mark + (layout/fit-credit). Why it is in the pixels: PORTRAIT-COMPOSITOR.md, \"The baked credit + and the site mark\"." [^Graphics2D g names w h] (let [{:keys [halo baseline] [or* og ob oa] :outline @@ -615,10 +578,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 @@ -678,16 +643,26 @@ (try (light! img portrait drawable placed-of w h) (catch Exception e (println "portrait-render: light skipped -" (.getMessage e)))) - (try - (when-let [names (not-empty (pa/credit-names portrait))] - (draw-credit! g names 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 (not (false? marks?)) + (try + (when-let [names (not-empty (pa/credit-names portrait))] + (draw-credit! g names 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))))) +(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." @@ -697,12 +672,113 @@ (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)))) + +;; ---------- 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 + "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." + [^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.)] + (try + (with-open [ios (ImageIO/createImageOutputStream out)] + (.setOutput writer ios) + (.write writer nil (IIOImage. img nil nil) param)) + ;; the writer holds native resources, released even when the write throws + (finally (.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))) + (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/portrait_store.clj b/src/clj/orcpub/portrait_store.clj index 99cb126d4..27b6df045 100644 --- a/src/clj/orcpub/portrait_store.clj +++ b/src/clj/orcpub/portrait_store.clj @@ -134,9 +134,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})) @@ -155,7 +155,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 bd2cf0eee..e643be560 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -30,6 +30,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] @@ -54,6 +55,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] @@ -360,7 +362,8 @@ (when-let [{:keys [artist-id default current own locked]} (artist-credit/credit-for-account db user-id)] {:artist-id (name artist-id) :default default - :current (select-keys current [:artist/id :artist/name :artist/link :artist/links]) + :current (select-keys current (into [:artist/id :artist/name :artist/link :artist/links] + (vals artist-credit/page-switches))) :own own :locked locked ;; so the page can show which icon a link will get as it's typed @@ -800,21 +803,29 @@ {: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." + "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. A notice that cannot be recorded + is logged and the saved change still answers 200." [{: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]} - (artist-credit/save! conn id (select-keys transit-params [:name :link :links]))] + (let [{:keys [errors unchanged wait saved before artist-id]} + (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)} + :body {:error (let [m (max 1 (quot (+ wait 59) 60))] + (str "That's a lot of changes for one hour. Try again in " + m (if (= 1 m) " minute." " 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 (try (credit-notices/saved! conn {:artist-id artist-id :user user :before before :after saved}) + (catch Exception e + (println "ERROR: artist credit saved, but its notice could not be recorded:" (.getMessage e)))) {:status 200 :body (artist-credit-body (d/db conn) id)})))))) (defn hash-reset-key @@ -1114,7 +1125,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) @@ -1704,7 +1715,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))})) ;; Last, so every notice is in: it prepends a page naming what was left out, and ;; adds nothing at all when nothing was. (pdf/add-notice-page! doc sans) @@ -2211,12 +2222,9 @@ (seq items) (assoc ::char5e/custom-items items))})))) (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 @@ -2446,28 +2454,16 @@ [route-map/dnd-e5-orcacle-page-route]]) (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) @@ -2476,15 +2472,10 @@ (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. A `v` query + param matching the tag's version makes the response long-cacheable." [{:keys [db headers query-params] {:keys [id]} :path-params}] (artist-credit/refresh! db) (let [stored (some-> (d/pull db '[{::se/values [::char5e/portrait]}] id) @@ -2558,11 +2549,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) ;; Versioned, so a link preview made after the portrait changes fetches the new ;; picture instead of the copy every chat app kept under the old address. @@ -2583,6 +2572,98 @@ :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; 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 + (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 + 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 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]] + (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 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) (artist-profile/shows? artist :examples)) + (example-jpeg (:artist/id artist) n + (artist-profile/watermark-text + artist (or (portrait-render/site-mark) branding/app-name))))] + (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)} + + :else {:status 404 :body "no such example"}))) + (def header-style {:style "color:#2c3445"}) @@ -2764,6 +2845,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-example-route :slug ":slug" :n ":n") + {:get `artist-example-jpeg}] [(route-map/path-for route-map/dnd-e5-portrait-artists-route) {:get `portrait-artists}] [(route-map/path-for route-map/dnd-e5-char-parties-route) ^:interceptors [check-auth] diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index c79891206..505b0f817 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -29,6 +29,16 @@ (def error-tint "#fdf5f6") (def warning-yellow "#ffd21a") ; attention severity: unresolved conflicts, missing fields (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. +(def portrait-panel "#131924") ; card and tile ground, dark theme +(def portrait-panel-text "#ebeef4") ; text on portrait-panel +(def portrait-frame-glow "#202939") ; portrait frame gradient, centre +(def portrait-frame-edge "#0f141c") ; portrait frame gradient, edge ;; Text on the amber buttons is small and low-contrast either way, so it gets a ;; faint edge in the opposite tone: dark under white text, light under dark. @@ -1774,6 +1784,95 @@ [:.details-column {:max-width "500px"}])]) +(def artist-pages + "The public artist pages (views/artist_page.cljs), inside .pl-root so the drawer's + --lk-* variables and credit classes apply. .ap-* is only what the drawer lacks." + (let [vollkorn "'Vollkorn', Georgia, serif" + open-sans "'Open Sans', system-ui, sans-serif" + light ".ap-root.light-theme"] + [[:.ap-root {:font-family open-sans :color portrait-panel-text :padding "24px 12px 40px"}] + [light {:color light-text}] + ;; .pl-root.light-theme sets --lk-dim on this same element and is emitted later, so match it + ;; with a third class; #5f6672 is 5.8:1 on white, #858c96 only 3.4:1 + [(str ".pl-root" light) {:--lk-dim "#5f6672"}] + [:.ap-card + {:max-width "640px" :margin "0 auto" :padding "28px 28px 24px" + :background portrait-panel :border (str "1px solid " orange-hairline) + :border-radius "12px" :box-shadow "0 20px 50px -30px rgba(0,0,0,0.7)" + :display :flex :flex-direction :column :gap "30px"}] + [(str light " .ap-card") + {:background :white :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 (str "italic 400 40px/1.1 " vollkorn) + :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 portrait-panel-text :text-decoration :none + :font (str "600 12px/1 " open-sans) :letter-spacing "0.04em" + :transition "border-color 160ms ease, background-color 160ms ease"}] + [".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 .lk-ico" {:color "var(--lk-mark-color, currentColor)"}] + ;; 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)"}] + [:.ap-link:visited {:color portrait-panel-text}] + [(str light " .ap-link") + {:border-color "rgba(0,0,0,0.14)" :background :white :color light-text}] + [(str light " .ap-link:visited") {:color light-text}] + [(str light " .ap-link:hover, " light " .ap-link:focus-visible") + {:border-color slate-blue :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: 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 (str "radial-gradient(circle at 50% 35%, " portrait-frame-glow ", " + 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 + {: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}] + [(str light " .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:visited, .ap-missing a:visited" {:color :inherit}] + [".ap-foot a:hover, .ap-missing a:hover" {:color "var(--lk-under-lit)"}] + [:.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-under-lit)" :filter "var(--lk-halo)"}] + [".ap-index-card:focus-visible" {:outline :none}] + [:.ap-index-count {:font-size "11px" :color "var(--lk-dim)"}] + (at-media {:max-width "560px"} + [:.ap-card {:padding "22px 16px 18px" :gap "26px"}] + [:.ap-name {:font-size "32px"}] + [:.ap-examples {:gap "8px"}])])) + (def open-sans-subsets "Open Sans character sets as [subset unicode-range], one variable-weight woff2 file each under resources/public/fonts/open-sans/ (OFL, see OFL.txt there). A browser downloads only the files @@ -1836,7 +1935,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. @@ -1861,11 +1960,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. @@ -2434,20 +2536,56 @@ :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). 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 - {: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" + ;; 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 + :transition "background-color 0.12s, border-color 0.12s"} + [:&:hover {:border-color orange}] + [:&.checked + {:background-color orange + :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}]] + ;; 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 muted-on-light + :background-color :white} + [:&:hover {:border-color slate-blue}] + [:&.checked + {:background-color slate-blue + :border-color slate-blue + :color :white}]] [:.checkbox-text {:margin-left "5px"}] @@ -2476,11 +2614,7 @@ {:background-color "rgba(255,255,255,0.08)"}] [:.checkbox {:width "14px" - :height "14px" - :min-width "14px" - :flex-shrink 0} - [:.fa-check - {:font-size "12px"}]] + :height "14px"}] [:.flex.pointer {:align-items :center :gap "8px"}]] @@ -3490,13 +3624,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"}] @@ -3709,6 +3836,7 @@ [:.z-200 {:z-index 200}] ];concat-bracket + artist-pages margin-lefts margin-tops min-widths diff --git a/src/clj/orcpub/styles/portrait.clj b/src/clj/orcpub/styles/portrait.clj index 0ad84f164..c7c97123e 100644 --- a/src/clj/orcpub/styles/portrait.clj +++ b/src/clj/orcpub/styles/portrait.clj @@ -675,6 +675,21 @@ :-webkit-mask-position "center" :mask-position "center"}] [".lk-mark-text" {:font "400 11px/1 'Open Sans', system-ui, sans-serif"}] + ;; the link to the artist pages, at the credit's 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 "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:visited" {:color "var(--lk-dim)"}] + [".lk-about-arrow" {:padding-left "4px" :letter-spacing "0"}] + [".lk-about:hover, .lk-about:focus-visible" {:color "var(--lk-under-lit)" :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" diff --git a/src/clj/orcpub/system.clj b/src/clj/orcpub/system.clj index c0199ecbf..ab9121d09 100644 --- a/src/clj/orcpub/system.clj +++ b/src/clj/orcpub/system.clj @@ -11,6 +11,7 @@ [orcpub.user-email :as user-email] [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] @@ -82,6 +83,12 @@ [service-map] (update service-map ::http/interceptors #(into [routes/portrait-art-guard] %))) +(defn with-referrer-policy + "The service map with pedestal/referrer-policy-interceptor first in its chain, so its :leave runs + last and reaches every response: static files, the art guard's and the default 404 included." + [service-map] + (update service-map ::http/interceptors #(into [pedestal/referrer-policy-interceptor] %))) + (def prod-service-map {::http/routes routes/routes ::http/type :jetty @@ -151,6 +158,7 @@ true with-static-caching true with-portrait-guard true with-client-address + true with-referrer-policy (= :dev env) http/dev-interceptors) ;; :token-withdrawals is listed only so Component starts it first; Pedestal @@ -162,7 +170,7 @@ ;; as the server runs, so a component that starts after :pedestal never starts. ;; system_start_order_test.clj fails when one is missing. [:service-map :conn :token-withdrawals :alerts :outbox - :heartbeat :crash-report :artist-accounts :strand-check :email-keys]) + :heartbeat :crash-report :artist-accounts :strand-check :email-keys :credit-notices]) ;; Tells the admin, once, when the previous run crashed (heap dump or JVM crash log). :crash-report @@ -204,6 +212,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/src/cljc/orcpub/art_mark.cljc b/src/cljc/orcpub/art_mark.cljc index 0ea3aa314..829391992 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,55 @@ (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, anywhere among the marker segments, those between the scans of a progressive JPEG + included. Image data is skipped by its markers, never searched, so its bytes cannot match." + [b] + (when (jpeg? b) + (let [prefix (str mark-keyword "\u0000") + n (alength b) + ;; past a scan's entropy-coded data: the next 0xFF that is not a stuffed 0x00, a fill 0xFF + ;; or a restart marker (RST0-7, 0xD0-0xD7) + after-scan (fn [i] (loop [j i] + (cond (>= (inc j) n) n + (and (= 0xFF (u8 b j)) + (not (#{0x00 0xFF} (u8 b (inc j)))) + (not (<= 0xD0 (u8 b (inc j)) 0xD7))) j + :else (recur (inc j)))))] + (loop [i 2] + (if (and (< (inc i) n) (= 0xFF (u8 b i)) (= 0xFF (u8 b (inc i)))) + (recur (inc i)) ; a fill byte before a marker + (when (and (<= (+ i 4) n) (= 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 + (= 0xD9 kind) nil ; end of image + (and (= 0xFE kind) (<= end n) + (= prefix (ascii b start (min end (+ start (count prefix)))))) + (utf8 b (+ start (count prefix)) end) + (= 0xDA kind) (recur (after-scan end)) ; start of scan: its header, then the data + :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 +137,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 +159,52 @@ (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- 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 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 at) + (doseq [[i v] (map-indexed vector [0xFF 0xFE (bit-and (bit-shift-right len 8) 0xff) (bit-and len 0xff)])] + (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 + "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/cljc/orcpub/components.cljc b/src/cljc/orcpub/components.cljc index eecbb3277..5f72bdf65 100644 --- a/src/cljc/orcpub/components.cljc +++ b/src/cljc/orcpub/components.cljc @@ -2,17 +2,50 @@ (: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- 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 labeled-checkbox [label selected? disabled? on-click] - [:div.flex.pointer - {:on-click on-click} - [checkbox selected? disabled?] +(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")) + :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` 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.align-items-c.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/cljc/orcpub/dnd/e5/artist_profile.cljc b/src/cljc/orcpub/dnd/e5/artist_profile.cljc new file mode 100644 index 000000000..1aa9edb09 --- /dev/null +++ b/src/cljc/orcpub/dnd/e5/artist_profile.cljc @@ -0,0 +1,253 @@ +(ns orcpub.dnd.e5.artist-profile + "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.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])) + +;; ---------- 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 + "The registry entry for `artist-id`, without deployment overrides, or nil." + [artist-id] + (some #(when (= artist-id (:artist/id %)) %) pa/registry)) + +(defn profile-enabled? + "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/ 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 + 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 (and (not (s/blank? (:artist/name a))) (profile-enabled? 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 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." + [] + (into [] (keep (comp profile :artist/id)) pa/registry)) + +(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 + "The address of the list of artists, /artists." + [] + (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 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; + nil when the host cannot be encoded." + [url] + (let [u (some-> url str s/trim)] + (if-let [[_ head host tail] (some->> u (re-find #"^(https?://)([^/?#:]+)(.*)$"))] + (if (re-find #"[^\x00-\x7F]" host) + (try + (str head #?(:clj (java.net.IDN/toASCII host) :cljs (.-hostname (js/URL. (str "https://" host)))) tail) + (catch #?(:clj Exception :cljs :default) _ nil)) + u) + u))) + +(defn link-host + "The host of `url` without scheme, www. or trailing path, encoded: \"fusspot.rip\". nil if none." + [url] + (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 + 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. + 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 + "How many pieces `artist` drew, the generated scalp not counted." + [artist] + (reduce + (map (comp count :pieces) (pieces-by-layer artist)))) + +;; ---------- links ---------- + +(defn web-url? + "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] + (and (string? u) (not (re-find #"\s" (s/trim u))) + (when-let [a (ascii-url u)] (nil? (artist-links/url-problem a))))) + +(defn external-link + "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"})) + +(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 (when (shows? artist :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)) + (map #(update % :link/url ascii-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 + "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 + "`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)] + (let [base (str "artist-" (slug artist)) + own (:artist/layers artist) + offset (fn [k] (random/seed->int (str base "/" (name k))))] + (vec + (for [i (range n)] + (let [rnd (random/mulberry32 (random/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 + "The share-card title of `artist`'s profile: \" — portrait artist\"." + [artist] + (str (:artist/name artist) " — portrait artist")) + +(defn share-description + "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) + (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/character.cljc b/src/cljc/orcpub/dnd/e5/character.cljc index bfc6581f6..53cd16713 100644 --- a/src/cljc/orcpub/dnd/e5/character.cljc +++ b/src/cljc/orcpub/dnd/e5/character.cljc @@ -737,15 +737,9 @@ 8000) (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 c88e4e775..2ac64deee 100644 --- a/src/cljc/orcpub/dnd/e5/portrait_assets.cljc +++ b/src/cljc/orcpub/dnd/e5/portrait_assets.cljc @@ -1,25 +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`. - - What each piece CARRIES beyond its name -- iris regions, lids, whites, - whether it casts a shadow, which mouth is lips -- is not written here. It - lives in resources/portrait-pack (loom.edn from the Loom, pieces.edn by - hand) and is merged in at build time; see orcpub.dnd.e5.portrait-calibration." + "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`; per-piece data (iris regions, lids, whites, shadows) is merged in from + resources/portrait-pack at build time (portrait-calibration). Attribution goes through + `registry`: `credit-order`, `credit-line`. The repo's art files are silhouettes. + Why the code is shaped this way: PORTRAIT-COMPOSITOR.md, PORTRAIT-TINTING.md." (:require [clojure.string :as s] [orcpub.dnd.e5.portrait-random :as rnd] #?(:clj [orcpub.dnd.e5.portrait-calibration :refer [calibration]])) @@ -37,12 +22,8 @@ [:hair-bits :hair-back :head :shirt :hair-front :ears :eyes :nose :mouth :bangs]) (def hidden-layers - "Layers that render but are not offered in the picker. None today. - - There was one, :scalp -- a generated blob under the hair for the hair-front - and bangs pairs that left an island of skin on the crown. The art now meets - (hair front 02 was patched to reach bangs 03), so it was retired; a saved - portrait that still names it is read without it (see `current-layers`)." + "Layers that render but are not offered in the picker. None today; a saved portrait that + still names the retired :scalp is read without it (`current-layers`)." #{}) (def pickable-layers @@ -85,14 +66,9 @@ "/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. The files in the repo are silhouettes at + the real art's paths; the art pack is unpacked over them at deploy." { :hair-bits [{:asset/id :l0-hair-bits-pony-long @@ -193,16 +169,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"}] @@ -259,27 +228,17 @@ 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 - ;; 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/"} @@ -342,8 +301,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 and :artist/license are honoured." + "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 (into {} @@ -351,7 +312,11 @@ :let [keep-fields (select-keys fields [:artist/name :artist/link :artist/links - :artist/license])] + :artist/license + :artist/profile? + :artist/show-examples? + :artist/show-links? + :artist/show-count?])] :when (seq keep-fields)] [id keep-fields])))) @@ -385,13 +350,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)))) @@ -401,12 +362,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. + 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 {} @@ -420,16 +378,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. - - 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. 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 @@ -442,20 +393,10 @@ :ears 1}) (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) [] @@ -494,15 +435,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)] @@ -567,14 +502,10 @@ (into [] (keep :artist/name) (artists-for-layers (:layers portrait)))) (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. Surfaces with a width to fit - use `credit-names` + `fit-credit` instead." + "\"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; surfaces with a width to fit use + `credit-names` and layout/fit-credit." [portrait] (format-credit (credit-names portrait))) @@ -610,23 +541,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 @@ -780,17 +704,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 @@ -800,14 +717,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 b9ec7d64f..86aa1b729 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; each renderer takes its numbers from here. + 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)))) @@ -108,3 +78,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 60a8b24cb..cb9e6beed 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -100,6 +100,14 @@ +;; 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) +;; 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) (def my-account-page-route :my-account) @@ -167,6 +175,10 @@ "following/users" {["/" :user] follow-user-route} + "artists" {"" artists-page-route + ["/" :slug] artist-page-route + ["/" :slug "/examples/" :n] artist-example-route} + "dnd/" {"5e/" {"characters" {"" dnd-e5-char-list-route ["/" :id] dnd-e5-char-route diff --git a/src/cljc/orcpub/whats_new.cljc b/src/cljc/orcpub/whats_new.cljc index b3a2815af..1069ab1c8 100644 --- a/src/cljc/orcpub/whats_new.cljc +++ b/src/cljc/orcpub/whats_new.cljc @@ -54,6 +54,14 @@ :icon "fa-search" :headline "Find equipment by typing" :detail "The Equipment tab's long dropdowns filter as you type. Nothing is hidden behind a cap — scroll the whole list, or walk it with the arrow keys and press Enter to add."} + {: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 portrait picker's About the artist link, and the artists list, take you there."} {:group "Sharing" diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index bbf84b4b2..24a6ac7c2 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -453,7 +453,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 @@ -838,7 +838,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? (or disable-checkbox? (not selectable?)) 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 @@ -2771,7 +2771,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 @@ -2784,7 +2784,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/autosave_fx.cljs b/src/cljs/orcpub/dnd/e5/autosave_fx.cljs index 0ec704165..d150d34cf 100644 --- a/src/cljs/orcpub/dnd/e5/autosave_fx.cljs +++ b/src/cljs/orcpub/dnd/e5/autosave_fx.cljs @@ -83,7 +83,8 @@ (defn cache-template "Handler for `::cache-template`: stores `template`, what it offers (`offered-keys`, `offered-by-type`) and its `choice-tags`. On the first list, if the loaded character has a key - the list now lets it heal, re-dispatches `:set-character`." + the list now lets it heal, re-dispatches `:character-updated` (the same character, so its + portrait draft stays) and announces the heal." [{:keys [db]} [_ template]] (let [offered (content-recon/offered-keys template) by-type (library/offered-by-type template) @@ -100,7 +101,7 @@ ::content-recon/offered-by-type by-type ::content-recon/choice-tags (library/choice-tags template))} ;; No route follows this heal, so it announces itself. - heal? (assoc :dispatch-n [[:set-character character] [:orcpub.dnd.e5/announce-heal]])))) + heal? (assoc :dispatch-n [[:character-updated character] [:orcpub.dnd.e5/announce-heal]])))) ;; Registered in events.cljs, inside the planned-picks hold. diff --git a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs index 52b5f7e6a..603a2dfcd 100644 --- a/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs +++ b/src/cljs/orcpub/dnd/e5/content_reconciliation.cljs @@ -360,8 +360,8 @@ ;; Long form and tracing: character-heals.md. ;; ;; FIELD NOTE (heal-sites): every heal goes through reconcile-former-keys, from three places: -;; events/set-character the builder's character, on every load -;; autosave-fx/cache-template re-dispatches :set-character once, when the offered-key list +;; events/reconciled-character the builder's character, on :set-character and :character-updated +;; autosave-fx/cache-template re-dispatches :character-updated once, when the offered-key list ;; first exists (before it, the indexes are empty) ;; subs ::char5e/character saved characters as pages read them; never stored ;; A pick healed in the builder but not on a page (or the reverse) means a path skips one of these. diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index da4a32c6e..ccb959ec1 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -247,13 +247,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)))) @@ -712,26 +709,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))) @@ -2489,7 +2470,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 @@ -2570,9 +2551,8 @@ (defn- reconciled-character "Run the content reconcilers over `character` and report what they changed. - - Shared by the two events below, which differ only in whether the portrait - draft survives -- everything about resolving renamed content is identical." + Shared by :set-character and :character-updated, which differ only in whether + the portrait draft survives." [db character] ;; db :plugins are already hydrated here -- ::e5/plugins is a sync cofx at ;; :initialize-db, so the reconcilers can trust loaded-class-keys and the @@ -2611,29 +2591,15 @@ :character-binding-report (when (or (seq (:unbound-classes binding-report)) (seq (:subclass-mismatches binding-report))) binding-report) - ;; Cleared when there is nothing to report, which is what makes it - ;; self-resetting: after a save, the character is re-installed, the - ;; reconcilers find nothing left to fix, and the prompt goes away on its - ;; own rather than needing to be dismissed. + ;; nil after a save: the saved character is re-installed and nothing is left to heal. :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. + 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) same-saved-character? (and (some? id) (= id (:db/id (:character db))))] @@ -2884,13 +2850,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 2bb016089..48090be62 100644 --- a/src/cljs/orcpub/dnd/e5/portrait.cljs +++ b/src/cljs/orcpub/dnd/e5/portrait.cljs @@ -1,25 +1,16 @@ (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]] [orcpub.art-mark :as art-mark] [re-frame.db] [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-colorize :as colorize] [orcpub.dnd.e5.portrait-effects :as fx] [orcpub.dnd.e5.portrait-face :as face] @@ -681,11 +672,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 @@ -703,14 +691,10 @@ (set! (.-src img) src))))))) (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 the credit for artist `names` on canvas context `ctx` along the bottom of a `w`x`h` + frame, stroked then filled: the longest wording from pa/credit-variants that fits + (layout/fit-credit). The face must already be loaded. Why it is in the pixels: + PORTRAIT-COMPOSITOR.md, \"The baked credit and the site mark\"." [ctx names w h] (let [{:keys [halo center-x baseline outline fill]} (layout/credit-layout w h) font-at #(str "italic " % "px '" layout/credit-font-family "', Georgia, serif") @@ -766,7 +750,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* @@ -825,15 +809,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. (cond (= :as-drawn (pa/render-mode layer-key asset)) nil @@ -924,8 +901,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 {})) @@ -1332,28 +1308,16 @@ 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 - "The credit under the character page's thumbnail, each name a link where - the artist has one. - - The strip is 100px, so it can't hold many names. When the whole line fits - it's shown whole; when it doesn't, the artist who drew most of the picture - is named and everyone else collapses into a '+N' that opens the full list, - every name still a link. Before, the strip clipped after two lines and the - smallest contributors just vanished. - - This is the one surface downstream of the builder that can send someone to - the artist -- a PNG, a PDF and a social unfurl can only carry the name as - text -- so nobody's link is allowed to drop off it." + "The credit under the character page's thumbnail, each name a link where the artist has + one. When the whole line overflows the 100px strip, the lead artist is named and the rest + collapse into a '+N' that opens the full list, every name still a link." [portrait] (r/with-let [open? (r/atom false)] (let [_ @(subscribe [:portrait/artist-overrides]) ; re-render when the credits arrive @@ -1391,27 +1355,28 @@ ;; ---------------- 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. 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 {"--lk-mark-color" color})} - (if icon - [:span.lk-ico {:style {"--lk-icon" (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 {"--lk-mark-color" color})}) + (if icon + [:span.lk-ico {:style {"--lk-icon" (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}]) @@ -1429,13 +1394,9 @@ [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)] @@ -1452,7 +1413,13 @@ [: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 credit-preview "One artist's credit exactly as the builder draws it, for the account page: @@ -1499,10 +1466,7 @@ [:div.pl-empty-hint {:style {"--pl-logo" (str "url(" branding/logo-path ")")}} "Pick a layer below, or hit " [:em "Randomize"] "."])] [iris-alternative-switch portrait] - ;; 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 @@ -1521,17 +1485,10 @@ [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])] @@ -1563,16 +1520,9 @@ "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 14a6a1b63..3c02b4fed 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -1263,10 +1263,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 @@ -2843,7 +2841,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 typed? class-name]}] @@ -2948,6 +2947,21 @@ (def expanded-spell-background-style {:background-color "rgba(0,0,0,0.1)"}) +(defn prepared-spell-box + "The tick box that prepares spell `key` (named `spell-name`) for `class` on character `id`: ticked + when `always-prepared?` or `prepared?`; with no preparations left (`remaining-preps`) an unticked + box is disabled and a click on it does nothing, as does any click on an always-prepared spell." + [id class key spell-name always-prepared? prepared? remaining-preps] + (let [selected? (or always-prepared? prepared?) + disabled? (and (not selected?) (not (pos? remaining-preps)))] + [:span.m-r-5 + {:class (when (or always-prepared? disabled?) "cursor-disabled") + :on-click (fn [e] + (when-not (or always-prepared? disabled?) + (dispatch [::char/toggle-spell-prepared id class key])) + (.stopPropagation e))} + (comps/checkbox selected? (or disabled? always-prepared?) (str spell-name " prepared"))])) + (defn spell-row [id lvl spell-modifiers prepares-spells prepared-spells-by-class {:keys [key ability qualifier class always-prepared?]} expanded? on-click prepare-spell-count prepared-spell-count] (let [spell-map @(subscribe [::spells/spells-map]) spell (spell-map key) @@ -2960,20 +2974,8 @@ [:td.p-l-10.p-b-5.p-t-5.f-w-b (when (and (pos? lvl) (get prepares-spells class)) - [:span.m-r-5 - {:class (when always-prepared? - "cursor-disabled") - :on-click (fn [e] - (when (not always-prepared?) - (dispatch [::char/toggle-spell-prepared id class key])) - (.stopPropagation e))} - (let [selected? (or always-prepared? - (get-in prepared-spells-by-class [class key]))] - (comps/checkbox - selected? - (and (not selected?) - (or always-prepared? - (not (pos? remaining-preps))))))]) + [prepared-spell-box id class key (:name spell) always-prepared? + (get-in prepared-spells-by-class [class key]) remaining-preps]) (: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)))] @@ -3252,7 +3254,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])] @@ -4824,7 +4826,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 @@ -5449,7 +5451,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 @@ -9422,7 +9424,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]))]]])) @@ -10279,12 +10281,29 @@ (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- submitted-switches + "The page switches the form sends: `shown` ({:profile? true ..}, as on screen) except each switch + the site fixes (its credit key is in `locked`), which goes as the artist's own saved value in + `own`, so the site's setting is never stored as their choice. Returns {edit key boolean}." + [shown locked own] + (into {} (for [[k ck] page-switches] + [k (if (contains? (set locked) ck) (not (false? (get own ck))) (get shown k))]))) (defn room-for-a-link? "Whether the credit form can show another link row, given its `rows` as on screen. Blank rows @@ -10330,12 +10349,15 @@ 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))))) + ;; 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]) 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 (submitted-switches @switches locked own)) blocking (artist-links/edit-errors edit) locked? (fn [k] (contains? (set locked) k)) left? #(contains? @left %) @@ -10399,6 +10421,25 @@ {: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) (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 ck label] parts] + ^{:key k} + [:div.m-b-5 + [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)) @@ -10409,7 +10450,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." @@ -10927,7 +10969,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]]]] @@ -11569,7 +11612,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 @@ -11581,7 +11625,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])] @@ -11594,7 +11639,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/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..ed149df11 --- /dev/null +++ b/src/cljs/orcpub/dnd/e5/views/artist_page.cljs @@ -0,0 +1,121 @@ +(ns orcpub.dnd.e5.views.artist-page + "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; what the drawer lacks is .ap-*, in + styles/core.clj `artist-pages`. + 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.views :as views])) + +(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}] body)])) + +(defn- link-button + "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]}] + (when-let [attrs (ap/external-link url)] + [:a.ap-link attrs + (when icon + ;; this link's own mark and colour, as the variables the credit's .lk-ico reads + [:span.lk-ico {:aria-hidden true + :style (cond-> {"--lk-icon" (str "url(/image/social/" icon ".svg)")} + color (assoc "--lk-mark-color" color))}]) + 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." + [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-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 + "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 + "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)] + [: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}]] + (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])])] + (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. " + [:a {:href (ap/index-path)} "All portrait artists"]]])) + +(defn artist-page + "/artists/. Redraws when the artist credits arrive, which can rename the artist, + change their links or turn the page off." + [{:keys [slug]}] + (let [_ @(subscribe [:portrait/artist-overrides])] + (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. Redraws when the artist credits + arrive." + [_] + (let [_ @(subscribe [:portrait/artist-overrides])] + [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)} + (when (ap/shows? a :examples) + [:div.ap-frame [example-img a 1 (str "A portrait from " name "'s pieces")]]) + [:span.lk-name name] + (when (ap/shows? a :count) + [: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..df78db83c --- /dev/null +++ b/test/browser/artist_profile_e2e.js @@ -0,0 +1,248 @@ +// 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 { findChrome } = require('./lib/find-chrome'); + +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; } +} + +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) { + 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); + // 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 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 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); + + 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 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(); + } + + // --- 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); + + 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 ----------------------------------------------- + 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(); + // 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'); + 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/browser/header_menus_e2e.js b/test/browser/header_menus_e2e.js index f51ceb5fe..a9ecb97be 100644 --- a/test/browser/header_menus_e2e.js +++ b/test/browser/header_menus_e2e.js @@ -51,6 +51,7 @@ const PARTIAL_PACK = '{:orcpub.dnd.e5/spells {:valid-spell {:option-pack "Header + ':invalid-spell "not an entry" :another-valid {:option-pack "Header Menus Probe" :name "Another Valid"}}}'; const results = []; +let desktopTabCount = null; // the first pass's menu count, which the phones must match const check = (name, ok, detail = '') => { results.push({ name, ok }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); @@ -73,6 +74,7 @@ const check = (name, ok, detail = '') => { const tabs = page.locator('.header-tab:has(.header-flyout)'); const tabCount = await tabs.count(); check(`${label}: the header has flyout menus to test`, tabCount > 0, `${tabCount} tabs`); + if (desktopTabCount === null) desktopTabCount = tabCount; for (let i = 0; i < tabCount; i++) { const tab = tabs.nth(i); @@ -174,6 +176,39 @@ const check = (name, ok, detail = '') => { } await short.close(); } + + // Phones: every tab, and each menu, sits inside the screen. Each menu is opened by focusing its + // tab, the state a tap leaves it in. A menu that did not open measures 0-0, so it has to have a + // width to count, and a phone must show as many menus as the desktop did. + 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)'); + const n = await tabs.count(); + check(`${device} (${width}px): the header has the desktop's ${desktopTabCount} menus`, + n > 0 && n === desktopTabCount, `${n} menus`); + for (let i = 0; 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), b.width]; + }); + 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(); + } await browser.close(); const failed = results.filter(r => !r.ok); diff --git a/test/clj/orcpub/art_mark_drawing_test.clj b/test/clj/orcpub/art_mark_drawing_test.clj index bf6824f09..05433819f 100644 --- a/test/clj/orcpub/art_mark_drawing_test.clj +++ b/test/clj/orcpub/art_mark_drawing_test.clj @@ -55,33 +55,51 @@ (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"))) + +;; 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")] (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 +122,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/artist_credit_test.clj b/test/clj/orcpub/artist_credit_test.clj index 1a6357bd4..a2025aff1 100644 --- a/test/clj/orcpub/artist_credit_test.clj +++ b/test/clj/orcpub/artist_credit_test.clj @@ -1,5 +1,6 @@ (ns orcpub.artist-credit-test - (:require [clojure.test :refer [deftest is testing use-fixtures]] + (:require [clojure.edn :as edn] + [clojure.test :refer [deftest is testing use-fixtures]] [datomic.api :as d] [orcpub.artist-credit :as ac] [orcpub.fork.branding :as branding] @@ -7,6 +8,7 @@ [orcpub.db.schema :as schema] [orcpub.crypto] [orcpub.artist-email] + [orcpub.heartbeat] [orcpub.routes]) (:import [java.util UUID])) @@ -133,7 +135,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"})) @@ -151,6 +153,89 @@ (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])))))))) +(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 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 t)] (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-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)) + +(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 (add-artist! conn "someone" nil) @@ -188,3 +273,147 @@ (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-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}))))) + (is (false? (:artist/profile? (pa/artist-info :house-pack))) "what My Account sends is what is saved")))) + +(deftest a-notice-that-cannot-be-recorded-does-not-fail-the-saved-credit + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (with-redefs [orcpub.artist-credit-notices/saved! (fn [& _] (throw (ex-info "outbox down" {}))) + orcpub.artist-email/credit-changed-tx (fn [_] []) + clojure.core/future-call (fn [f] (f) (delay nil))] + (let [res (orcpub.routes/update-artist-credit (req conn "fusspot" {:name "Fuss"}))] + (is (= 200 (:status res)) "the artist is not told a saved change failed") + (is (= "Fuss" (get-in res [:body :current :artist/name])) "the response reads the saved credit") + (is (= "Fuss" (:artist/name (pa/artist-info :house-pack))) "the credit stands"))))) + +(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"))))) + +(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")))))) + +(deftest the-account-route-carries-the-switches-in-force + (with-conn conn + (let [id (add-artist! conn "fusspot" :house-pack)] + (with-redefs [branding/portrait-artists {:house-pack {:artist/show-links? false}}] + (is (false? (get-in (orcpub.routes/artist-credit-body (d/db conn) id) [:current :artist/show-links?])) + "what the form starts a locked switch from"))))) + +(deftest the-deployment-settings-can-switch-a-page-off + (is (= {:artist/profile? false :artist/show-links? false} + (select-keys (branding/artist-override {:profile false :show_links false :show_count true}) + [:artist/profile? :artist/show-links? :artist/show-count?])) + "false turns a switch off; true is not a setting, since the site cannot force a page on")) + +(deftest a-concurrent-save-in-the-window-keeps-the-first-before + (testing "two in-window saves that both read the pending batch as empty: the first's `before` stands" + (with-conn conn + (let [id (add-artist! conn "fusspot" :house-pack) + sent (atom []) clock (atom 0) + real-pull d/pull + saved (fn [before after] + (@(requiring-resolve 'orcpub.artist-credit-notices/saved!) + conn {:user {:db/id id} :artist-id :house-pack + :before {:artist/name before} :after {:artist/name after}}))] + (with-emails sent clock + (save-name! conn "A") + (reset! clock 1) + (is (= :pending (saved "A" "B"))) + (with-redefs [d/pull (fn [db pat e] + (if (= 2 (count pat)) + {:orcpub.user/artist-credit-window (at 0) + :orcpub.user/artist-credit-pending ""} + (real-pull db pat e)))] + (is (= :pending (saved "B" "C")))) + (is (= {:artist/name "A"} + (edn/read-string (:orcpub.user/artist-credit-pending + (d/pull (d/db conn) [:orcpub.user/artist-credit-pending] id)))) + "the later save did not overwrite the earliest before")))))) + +(deftest the-throttle-message-says-minute-or-minutes + (with-conn conn + (add-artist! conn "fusspot" :house-pack) + (let [sent (atom []) clock (atom 0) + error #(get-in (save-name! conn "over") [:body :error])] + (with-emails sent clock + (dotimes [i 60] (save-name! conn (str "N" i))) + (is (re-find #"in 60 minutes\.$" (error))) + (reset! clock 59) + (is (re-find #"in 1 minute\.$" (error))))))) diff --git a/test/clj/orcpub/artist_email_test.clj b/test/clj/orcpub/artist_email_test.clj index b8abb1d24..8190eb757 100644 --- a/test/clj/orcpub/artist_email_test.clj +++ b/test/clj/orcpub/artist_email_test.clj @@ -106,3 +106,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 new file mode 100644 index 000000000..a72f9278b --- /dev/null +++ b/test/clj/orcpub/artist_page_test.clj @@ -0,0 +1,177 @@ +(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 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.artist-profile :as ap] + [orcpub.dnd.e5.portrait-assets :as pa] + [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] + {: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/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 (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")) + (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" + (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"))))))) + +(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"))) + "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)))) + +(deftest the-list-page-is-served + (is (= 200 (:status (routes/artists-page {:headers {"host" "example.test"} + :uri "/artists"}))))) + +(deftest a-page-turned-off-redirects-to-the-list + (try + (pa/set-artist-overrides! {:house-pack {:artist/profile? false}}) + (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 + (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 "MARK"}}) + (let [{:keys [status body]} (routes/artist-page (req "fusspot"))] + (is (= 200 status)) + (is (not (s/includes? body "MARK")) "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! {}))))) diff --git a/test/clj/orcpub/pedestal_test.clj b/test/clj/orcpub/pedestal_test.clj index 61616eb68..a29e087c4 100644 --- a/test/clj/orcpub/pedestal_test.clj +++ b/test/clj/orcpub/pedestal_test.clj @@ -11,7 +11,10 @@ log/error's value instead discards the response: Pedestal then has nothing to write and Jetty emits a bare 200. That is what made defect 1 silent -- the exception was logged, but the response was already gone." - (:require [clojure.test :refer [deftest testing is use-fixtures]] + (:require [io.pedestal.http] + [io.pedestal.test] + [orcpub.system] + [clojure.test :refer [deftest testing is use-fixtures]] [orcpub.pedestal :as pedestal]) (:import [java.time ZonedDateTime] [java.time.format DateTimeFormatter] @@ -98,3 +101,36 @@ 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"])))))) + +(defn- service-response + "The response of a minimal service built the way system.clj builds the real one (default + interceptors, then the referrer policy first), for a GET of `path`." + [path] + (let [with-policy (or (some-> (ns-resolve 'orcpub.system 'with-referrer-policy) deref) identity) + service (-> {:io.pedestal.http/routes #{["/here" :get (fn [_] {:status 200 :body "ok"}) :route-name ::here]} + :io.pedestal.http/resource-path "/public" + :io.pedestal.http/type :jetty} + io.pedestal.http/default-interceptors + with-policy + io.pedestal.http/create-servlet + :io.pedestal.http/service-fn)] + (io.pedestal.test/response-for service :get path))) + +(deftest static-files-and-404s-carry-the-referrer-policy-too + (doseq [path ["/favicon.ico" "/no-such-page"]] + (is (= "strict-origin-when-cross-origin" (get-in (service-response path) [:headers "Referrer-Policy"])) path))) diff --git a/test/clj/orcpub/portrait_art_guard_test.clj b/test/clj/orcpub/portrait_art_guard_test.clj index e2cc63554..d567d27b8 100644 --- a/test/clj/orcpub/portrait_art_guard_test.clj +++ b/test/clj/orcpub/portrait_art_guard_test.clj @@ -97,8 +97,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 @@ -111,9 +111,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 @@ -158,14 +158,18 @@ (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)))))))) (deftest the-guard-fails-when-it-cannot-list-files ;; a guard that sees no files passes; git failing must fail it instead 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/clj/orcpub/portrait_render_test.clj b/test/clj/orcpub/portrait_render_test.clj index 8e380134b..c0ca1f0ea 100644 --- a/test/clj/orcpub/portrait_render_test.clj +++ b/test/clj/orcpub/portrait_render_test.clj @@ -161,6 +161,21 @@ (is (not-any? (fn [[_ y]] (> y (* h 0.94))) changed) "and never reaches the caption band at the bottom")))) +(defn- argb-pixels + "Every ARGB pixel of `img`, as a vector." + [img] + (vec (.getRGB ^java.awt.image.BufferedImage img 0 0 (.getWidth img) (.getHeight img) nil 0 (.getWidth img)))) + +(deftest marks-stay-on-unless-switched-off + (testing "an options map that leaves :marks? out keeps both marks; only false removes them" + (with-redefs [branding/app-url "orcpub.com"] + (let [p (portrait-with [:head :shirt]) + default (argb-pixels (pr/render p 200 250)) + omitted (argb-pixels (pr/render p 200 250 {})) + off (argb-pixels (pr/render p 200 250 {:marks? false}))] + (is (= default omitted) "{} draws what the three-argument form draws") + (is (not= default off) "the marks really are drawn by default"))))) + (deftest no-site-mark-when-branding-is-unset (let [p (portrait-with [:head])] (is (= (vec (pr/render-png p)) diff --git a/test/clj/orcpub/styles/artist_pages_test.clj b/test/clj/orcpub/styles/artist_pages_test.clj new file mode 100644 index 000000000..ab506ff44 --- /dev/null +++ b/test/clj/orcpub/styles/artist_pages_test.clj @@ -0,0 +1,34 @@ +(ns orcpub.styles.artist-pages-test + "The artist pages' link and text colours hold up where the browser's own styles would override + them: a visited link, and the muted text on the light theme." + (:require [clojure.test :refer [deftest is testing]] + [clojure.string :as str] + [garden.core :as garden] + [orcpub.styles.core :as styles])) + +(def ^:private css (garden/css {:pretty-print? false} styles/app)) + +(defn- channel + "The linear-light value of one 0-255 sRGB channel `c`." + [c] + (let [s (/ c 255.0)] + (if (<= s 0.03928) (/ s 12.92) (Math/pow (/ (+ s 0.055) 1.055) 2.4)))) + +(defn- contrast-with-white + "WCAG contrast ratio of the #rrggbb colour `hex` against white." + [hex] + (let [[r g b] (map #(Integer/parseInt % 16) (re-seq #"[0-9a-f]{2}" (subs hex 1))) + lum (+ (* 0.2126 (channel r)) (* 0.7152 (channel g)) (* 0.0722 (channel b)))] + (/ 1.05 (+ lum 0.05)))) + +(deftest visited-links-keep-the-page-colours + (testing "a visited rule is (0,1,1) and beats a single-class colour, so each link class needs its own" + (is (str/includes? css ".lk-about:visited{color:var(--lk-dim)}")) + (is (re-find #"\.ap-foot a:visited,\.ap-missing a:visited\{color:inherit\}" css)) + (is (str/includes? css ".ap-link:visited{")))) + +(deftest muted-text-on-the-light-theme-passes-contrast + (let [[_ hex] (re-find #"\.pl-root\.ap-root\.light-theme\{--lk-dim:(#[0-9a-f]{6})\}" css)] + (is (some? hex) "the light theme on an artist page redefines --lk-dim") + (when hex + (is (>= (contrast-with-white hex) 4.5) (str hex " on white"))))) 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/clj/orcpub/styles/variables_test.clj b/test/clj/orcpub/styles/variables_test.clj new file mode 100644 index 000000000..398782f67 --- /dev/null +++ b/test/clj/orcpub/styles/variables_test.clj @@ -0,0 +1,15 @@ +(ns orcpub.styles.variables-test + "Every credit variable (--lk-*) the stylesheet reads is one it declares: an undeclared one makes + its rule silently do nothing." + (:require [clojure.test :refer [deftest is]] + [garden.core :as garden] + [orcpub.styles.core :as styles])) + +(deftest every-credit-variable-read-is-declared + (let [css (garden/css {:pretty-print? false} styles/app) + read (set (map second (re-seq #"var\((--lk-[a-z0-9-]+)" css))) + declared (set (map second (re-seq #"(--lk-[a-z0-9-]+):" css))) + ;; set on each link element by the views, not in the stylesheet (portrait.cljs, artist_page.cljs) + per-element #{"--lk-icon" "--lk-mark-color"}] + (is (empty? (remove (some-fn declared per-element) read)) + (str "read but never declared: " (sort (remove (some-fn declared per-element) read)))))) diff --git a/test/clj/orcpub/system_start_order_test.clj b/test/clj/orcpub/system_start_order_test.clj index 83c9d3490..cae1e6119 100644 --- a/test/clj/orcpub/system_start_order_test.clj +++ b/test/clj/orcpub/system_start_order_test.clj @@ -6,14 +6,11 @@ [com.stuartsierra.dependency :as dep] [orcpub.system :as system])) -(defn- start-order - "The keys of system map `s` in the order component/start-system starts them." - [s] - (sort (dep/topo-comparator (component/dependency-graph s (keys s))) (keys s))) - (deftest every-component-starts-before-the-web-server - (let [order (vec (start-order (system/system :prod))) - after (subvec order (inc (.indexOf order :pedestal)))] - (is (empty? after) - (str "these start after :pedestal, whose start blocks in production, so they never start: " - (pr-str after) " (add them to :pedestal's component/using in system.clj)")))) + (let [s (system/system :prod) + graph (component/dependency-graph s (keys s)) + before-pedestal (dep/transitive-dependencies graph :pedestal) + missing (sort (remove (conj before-pedestal :pedestal) (keys s)))] + (is (empty? missing) + (str "these are not started before :pedestal, whose start blocks in production, so they may " + "never start: " (pr-str missing) " (add them to :pedestal's component/using in system.clj)")))) diff --git a/test/cljc/orcpub/art_mark_test.cljc b/test/cljc/orcpub/art_mark_test.cljc index 194901dd6..0fb5d8c92 100644 --- a/test/cljc/orcpub/art_mark_test.cljc +++ b/test/cljc/orcpub/art_mark_test.cljc @@ -22,13 +22,63 @@ [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 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")) + +(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"])))) + +(deftest a-mark-between-the-scans-of-a-progressive-jpeg-is-found + (let [com (fn [text] (let [t (map #(#?(:clj int :cljs .charCodeAt) %) (str "orcpub-art" (char 0) text)) + n (+ 2 (count t))] + (concat [255 254 (quot n 256) (rem n 256)] t))) + sos [255 218 0 8 1 1 0 0 63 0] + ;; entropy-coded data with a stuffed FF00, a restart marker and a fill byte, none of them markers + data [18 255 0 52 255 208 86 255 255] + jpeg (concat [255 216] sos data (com "x") [255 217])] + (is (= "x" (mark/read-mark (bytes-of jpeg))) "the comment after the first scan is read") + (is (nil? (mark/read-mark (bytes-of (concat [255 216] sos (map #(#?(:clj int :cljs .charCodeAt) %) "orcpub-art") [255 217])))) + "the keyword inside image data is not a mark"))) + +(deftest fill-bytes-before-a-marker-are-skipped + (let [com (fn [text] (let [t (map #(#?(:clj int :cljs .charCodeAt) %) (str "orcpub-art" (char 0) text)) + n (+ 2 (count t))] + (concat [255 254 (quot n 256) (rem n 256)] t)))] + (is (= "x" (mark/read-mark (bytes-of (concat [255 216] [255 255 255] (com "x") [255 217])))) + "padding FF bytes before a marker are not a marker") + (is (= "x" (mark/read-mark (bytes-of (concat [255 216 255 224 0 4 0 0] [255 255] (com "x") [255 217])))) + "and between segments"))) 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..f4d3c92e7 --- /dev/null +++ b/test/cljc/orcpub/dnd/e5/artist_profile_test.cljc @@ -0,0 +1,288 @@ +(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] + [orcpub.dnd.e5.portrait-random :as random])) + +(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 (= {: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] + (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 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"} + (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"] + (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")) + +(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 + (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 (random/seed->int "abc"))) + (is (= 993024918 (random/seed->int "artist-fusspot/0"))) + (let [r (random/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") + (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 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)))) + +;; ---------- 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) "@"))))) + +;; ---------- 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"))) + +(deftest a-host-that-cannot-be-encoded-is-not-a-link + (testing "a link whose international host fails to encode is dropped, not thrown" + (let [bad (str "https://" (apply str (repeat 70 "\u00e9")) ".test/")] + #?(:clj (do (is (nil? (ap/ascii-url bad))) + (is (not (ap/web-url? bad))) + (is (= [] (ap/listed-links {:artist/link bad})))) + :cljs (is (some? (ap/listed-links {:artist/link bad})) + "the browser's URL parser accepts a long label, so only the call is checked"))))) diff --git a/test/cljs/orcpub/character_builder_test.cljs b/test/cljs/orcpub/character_builder_test.cljs index 7a1325827..e5c82ae10 100644 --- a/test/cljs/orcpub/character_builder_test.cljs +++ b/test/cljs/orcpub/character_builder_test.cljs @@ -87,3 +87,21 @@ ["Bag (large)" "(l"] ["AXE" "ax"]]] (let [[before hit after] (parts (cb/highlight-match nm q))] (is (= nm (str before hit after)) (str "rejoining " nm " around " q)))))) + +(defn- checkbox-of + "The :span.checkbox attribute map in the hiccup `option-selector-base` draws for `opts`." + [opts] + (let [render (cb/option-selector-base)] + (->> (tree-seq vector? seq (render opts)) + (filter #(and (vector? %) (= :span.checkbox (first %)))) + first + second))) + +(deftest a-multiselect-option-that-cannot-be-picked-has-a-disabled-checkbox + (testing "an option the character cannot take reads as disabled to a screen reader and a keyboard" + (let [attrs (checkbox-of {:name "X" :multiselect? true :selected? false :selectable? false})] + (is (= "true" (:aria-disabled attrs))) + (is (= -1 (:tab-index attrs))))) + (testing "a pickable option and a chosen one stay enabled" + (is (nil? (:aria-disabled (checkbox-of {:name "X" :multiselect? true :selected? false :selectable? true})))) + (is (nil? (:aria-disabled (checkbox-of {:name "X" :multiselect? true :selected? true :selectable? true})))))) diff --git a/test/cljs/orcpub/components_checkbox_test.cljs b/test/cljs/orcpub/components_checkbox_test.cljs new file mode 100644 index 000000000..3bdcece68 --- /dev/null +++ b/test/cljs/orcpub/components_checkbox_test.cljs @@ -0,0 +1,74 @@ +(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]] + [re-frame.core :as rf] + [reagent.core :as r] + [reagent.dom :as rdom] + [orcpub.components :as comps] + [orcpub.dnd.e5.views :as views])) + +(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))))))) + +(deftest a-spell-cannot-be-prepared-past-the-limit + (let [sent (atom [])] + (with-redefs [rf/dispatch #(swap! sent conj (first %))] + (with-rendered [views/prepared-spell-box 1 :wizard :shield "Shield" false false 0] + (fn [el] + (let [box (.querySelector el ".checkbox")] + (is (= "true" (.getAttribute box "aria-disabled")) "no preparations left: the box is off") + (.click box) + (is (empty? @sent) "and a click prepares nothing")))) + (with-rendered [views/prepared-spell-box 1 :wizard :shield "Shield" false false 1] + (fn [el] + (.click (.querySelector el ".checkbox")) + (is (= 1 (count @sent)) "with one left, a click prepares it")))))) diff --git a/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs new file mode 100644 index 000000000..83f95b798 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/artist_page_credits_test.cljs @@ -0,0 +1,138 @@ +(ns orcpub.dnd.e5.artist-page-credits-test + "Artist credits are fetched after the page loads, so an artist page already on screen has to + redraw when they arrive: a new name, new links, or the page turned off." + (:require [cljs.test :refer-macros [deftest is testing]] + [re-frame.core :refer [dispatch-sync]] + [reagent.core :as r] + [reagent.dom :as rdom] + [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 + "Renders `page` into a detached element, calls `f` with it, and cleans up, restoring the + artist overrides." + [page f] + (let [before (pa/current-overrides) + el (.createElement js/document "div")] + (try + (dispatch-sync [:portrait/artists-loaded {}]) + (.appendChild (.-body js/document) el) + (rdom/render page el) + (r/flush) + (f el) + (finally + (rdom/unmount-component-at-node el) + (.remove el) + (pa/set-artist-overrides! before))))) + +(deftest the-profile-redraws-when-the-credits-arrive + (with-page [artist-page/artist-page {:slug "fusspot"}] + (fn [el] + (is (= "Fusspot" (some-> (.querySelector el "h1.ap-name") .-textContent)) + "the registry's public default until they arrive") + (dispatch-sync [:portrait/artists-loaded {"house-pack" {:artist/name "Fuss MARK"}}]) + (r/flush) + (testing "the arrival redraws the page already on screen" + (is (= "Fuss MARK" (some-> (.querySelector el "h1.ap-name") .-textContent)) + "the new name, shown as text") + (is (nil? (.querySelector el "h1.ap-name b")) "never as markup"))))) + +(deftest the-list-redraws-when-the-credits-arrive + (with-page [artist-page/artists-page nil] + (fn [el] + (is (re-find #"Fusspot" (.-textContent el))) + (dispatch-sync [:portrait/artists-loaded {"house-pack" {:artist/name "Someone Else"}}]) + (r/flush) + (is (re-find #"Someone Else" (.-textContent el)) "the list shows the arrived name")))) + +(deftest a-page-turned-off-in-the-credits-goes-away-on-arrival + (with-page [artist-page/artist-page {:slug "fusspot"}] + (fn [el] + (is (some? (.querySelector el "h1.ap-name"))) + (dispatch-sync [:portrait/artists-loaded {"house-pack" {:artist/profile? false}}]) + (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" (.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 (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?]))))))))) + +(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))))))) + +(deftest a-fixed-switch-is-not-saved-as-the-artists-own-choice + (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 {} :default {} :current {:artist/profile? false} + :locked #{:artist/profile?}}] + (fn [el] + (some-> (some #(when (= "Save credit" (.-textContent %)) %) (array-seq (.querySelectorAll el "button"))) .click) + (is (some? @sent) "Save went out") + (is (true? (:profile? @sent)) "the site's Off is not sent as the artist's own setting")))))) diff --git a/test/cljs/orcpub/dnd/e5/events_test.cljs b/test/cljs/orcpub/dnd/e5/events_test.cljs index b38a55de8..077d1a857 100644 --- a/test/cljs/orcpub/dnd/e5/events_test.cljs +++ b/test/cljs/orcpub/dnd/e5/events_test.cljs @@ -1165,8 +1165,9 @@ fx (autosave-fx/cache-template {:db {:plugins renamed-plugins :character character}} [::autosave-fx/cache-template small-template])] (is (= #{:race :half-elf-ua} (get-in fx [:db ::content-recon/offered-keys]))) - (is (= [[:set-character character] [::e5/announce-heal]] (:dispatch-n fx)) - "the waiting heal runs once the list exists, and announces itself: no route follows it") + (is (= [[:character-updated character] [::e5/announce-heal]] (:dispatch-n fx)) + "the waiting heal runs once the list exists, as an update so the portrait draft stays, + and announces itself: no route follows it") (is (nil? (:dispatch-n (autosave-fx/cache-template {:db (:db fx)} [::autosave-fx/cache-template small-template]))) "and only on the first list"))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index 9ef553f0c..c033ff372 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -11,6 +11,10 @@ ;; 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.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] @@ -64,6 +68,9 @@ '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.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 diff --git a/test/comment-baseline.edn b/test/comment-baseline.edn index 5a506ab5e..0151e8294 100644 --- a/test/comment-baseline.edn +++ b/test/comment-baseline.edn @@ -33,54 +33,14 @@ :long-docstring "ns orcpub.dnd.e5.portrait-calibration" "db2461976d52"] - ["src/clj/orcpub/fork/branding.clj" - :long-docstring - "def portrait-artists" - "93e01e1a65d3"] ["src/clj/orcpub/index.clj" :long-comment "Opt out of generative-AI training and image reuse. This is the" "9be8149c8797"] - ["src/clj/orcpub/pdf.clj" - :long-docstring - "def max-artwork-embedded" - "2381c953c327"] - ["src/clj/orcpub/pdf.clj" - :long-docstring - "defn decode-artwork-bytes" - "65ab0ed9b694"] - ["src/clj/orcpub/pdf.clj" - :long-docstring - "defn- fit-artwork" - "a54fb1cb70e0"] ["src/clj/orcpub/portrait_pack/import.clj" :long-docstring "ns orcpub.portrait-pack.import" "fe978b474568"] - ["src/clj/orcpub/portrait_render.clj" - :long-docstring - "def load-face" - "c1031884bdf4"] - ["src/clj/orcpub/portrait_render.clj" - :long-docstring - "defn asset-source" - "179f0d2f5c12"] - ["src/clj/orcpub/portrait_render.clj" - :long-docstring - "defn- draw-credit!" - "5f3bd1ef4cf0"] - ["src/clj/orcpub/portrait_render.clj" - :long-docstring - "defn- multiply!" - "072519bb41c4"] - ["src/clj/orcpub/portrait_render.clj" - :long-docstring - "ns orcpub.portrait-render" - "51262181839c"] - ["src/clj/orcpub/routes.clj" - :history-phrase - "defn character-summary-for-id | it used to" - "5d1cb548cc72"] ["src/clj/orcpub/routes.clj" :long-comment "A composed (paper-doll) portrait has no URL that could produce" @@ -93,94 +53,22 @@ :long-comment "The wildcard cannot take the path above. `*` asks whether a" "8a2d62b299e0"] - ["src/clj/orcpub/routes.clj" - :long-comment - "drawable?, not (seq :layers). A selection naming only assets this" - "1d2c7b22f6b1"] - ["src/clj/orcpub/routes.clj" - :long-docstring - "defn character-portrait-png" - "a144eea509f7"] ["src/clj/orcpub/routes.clj" :long-docstring "defn stamp-document-info!" "fc84684cb0b7"] - ["src/clj/orcpub/routes.clj" - :long-docstring - "defn- any-representation-requested?" - "d5ba2f9ef19c"] - ["src/clj/orcpub/routes.clj" - :long-docstring - "defn- covered-by-if-none-match?" - "b90716ef319c"] ["src/cljc/orcpub/artist_links.cljc" :long-docstring "ns orcpub.artist-links" "19ded82cc1a0"] - ["src/cljc/orcpub/dnd/e5/character.cljc" - :long-docstring - "defn parse-portrait" - "8d21ef50cd6e"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-comment - "---------- character colors ----------" - "2d19f53c345e"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-comment - "Resolve through the registry by asset id first, and" - "6e4e103d9f80"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-comment - "The full set, for the builder, which has room. Chosen with her rather" - "66506163e2e2"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "def asset-inventory" - "23302fbc7ae6"] ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" :long-docstring "def calibration-fields" "25bc6e3fd7b3"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "def color-slots" - "cb2abaed9fe1"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "def gap-inventory" - "de52dc91db85"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "def layer-credit-weight" - "0fef5fb01084"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "defn credit-line" - "1a0a604801c1"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "defn credit-mark-layout" - "aafcad88f149"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "defn credit-order" - "f93344ce426a"] ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" :long-docstring "defn credit-variants" "c8989dac8740"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "defn render-mode" - "7117bdd2c708"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "defn tint-gamma" - "d925edf6e192"] - ["src/cljc/orcpub/dnd/e5/portrait_assets.cljc" - :long-docstring - "ns orcpub.dnd.e5.portrait-assets" - "21bb1c7ba47f"] ["src/cljc/orcpub/dnd/e5/portrait_colorize.cljc" :long-comment "Feathering the region's edge" @@ -229,89 +117,24 @@ :long-docstring "ns orcpub.dnd.e5.portrait-face" "97e179724c1b"] - ["src/cljc/orcpub/dnd/e5/portrait_layout.cljc" - :long-docstring - "defn credit-layout" - "d9091b403a80"] - ["src/cljc/orcpub/dnd/e5/portrait_layout.cljc" - :long-docstring - "defn site-mark-layout" - "709bcde3fdc8"] ["src/cljc/orcpub/dnd/e5/portrait_light.cljc" :long-docstring "ns orcpub.dnd.e5.portrait-light" "87319a407767"] - ["src/cljs/orcpub/dnd/e5/events.cljs" - :long-comment - "---- portrait compositor drawer ----" - "839937248c56"] - ["src/cljs/orcpub/dnd/e5/events.cljs" - :long-docstring - "defn drop-portrait-draft" - "65a857e69c12"] - ["src/cljs/orcpub/dnd/e5/events.cljs" - :long-docstring - "defn saved-character-is-the-one-being-edited?" - "eafd79915e13"] - ["src/cljs/orcpub/dnd/e5/events.cljs" - :long-docstring - "defn set-character" - "36d729ea143c"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :history-phrase - "Directly under the art it describes. It used to sit in the footer | it used to" - "22c67b2d6b67"] ["src/cljs/orcpub/dnd/e5/portrait.cljs" :long-comment "---------------- developer: the Eyes 01 far-iris alternative ---------" "9aa5258ac663"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-comment - "---------------- rasterization (for PDF export) ----------------" - "eb266d462cc6"] ["src/cljs/orcpub/dnd/e5/portrait.cljs" :long-comment "---------------- solid insides (developer tool) ----------------" - "6f6c60e817d8"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-comment - "---------------- the artist credit ----------------" - "d99ef2480175"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-comment - "Tint by MULTIPLY, not by masking. source-in reads the" - "852cfc2fefde"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-docstring - "defn drawer" - "2e6cefd20673"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-docstring - "defn tab-panel" - "c91ae9c538ed"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-docstring - "defn- credit-lockup" - "ec686e9f13e6"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-docstring - "defn- draw-credit!" - "10ab3e611ebb"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-docstring - "defn- linked-credit" - "6f41e5783914"] - ["src/cljs/orcpub/dnd/e5/portrait.cljs" - :long-docstring - "ns orcpub.dnd.e5.portrait" - "73118ff3edc9"]], + "6f6c60e817d8"]], :field-notes - {["src/clj/orcpub/pdf/draw.clj" "glyph-throw"] - "fca62d7237f8", + {["src/clj/orcpub/pdf/draw.clj" "glyph-throw"] "fca62d7237f8", ["src/cljc/orcpub/dnd/e5/options.cljc" "background-name-key"] "e8e6f28a2ab7", ["src/cljs/orcpub/dnd/e5/content_reconciliation.cljs" "heal-sites"] - "0c6c2fd217f8", + "e8848afc64b2", ["src/cljs/orcpub/dnd/e5/content_reconciliation.cljs" "heal-typed"] "175cd837f475", ["src/cljs/orcpub/dnd/e5/content_reconciliation.cljs" diff --git a/test/reviewed-images.edn b/test/reviewed-images.edn index a7d623517..1033a9f40 100644 --- a/test/reviewed-images.edn +++ b/test/reviewed-images.edn @@ -130,5 +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"} -} + :what "site background / partner image, added 2020-11-25, before the portrait art"}} diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index 8ecff249c..d0644ffe6 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -9,6 +9,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] [orcpub.user-agent :as user-agent] [cljs-http.client :as http] @@ -118,7 +119,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