Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
103 commits
Select commit Hold shift + click to select a range
c9286f3
Plan the artist profile pages
codeGlaze Sep 29, 2026
450312c
Add public artist profile pages at /artists/<slug>
codeGlaze Sep 29, 2026
b17e66f
Let an artist turn their profile page off
codeGlaze Sep 29, 2026
c17705d
Verify the artist pages in the running app, and record what was built
codeGlaze Sep 29, 2026
9e87f14
Add a mockup of the artist page options
codeGlaze Sep 29, 2026
e7443c0
Capture three artist card layouts from the running site
codeGlaze Sep 29, 2026
432785f
Capture link-icon candidates in the running credit and card
codeGlaze Sep 29, 2026
3b6be90
Capture site-mark icon and colour options in the running credit
codeGlaze Sep 29, 2026
8a06f6a
Capture the four pickable site icons in coral and teal
codeGlaze Sep 29, 2026
0cdb95f
Capture themed colour pairs and a split-swatch picker for the site mark
codeGlaze Sep 30, 2026
4e74f7c
Capture a paired-colour set for the site mark
codeGlaze Sep 30, 2026
397173c
Capture a two-step site-mark picker offering both colour sets
codeGlaze Sep 30, 2026
c51737f
Add a style guide for the site, from the Garden stylesheet
codeGlaze Sep 30, 2026
9ade9b2
Show Open Sans before and after loading 600/700, and correct the finding
codeGlaze Sep 30, 2026
6f68cb8
Capture header tab and page title weights in real Open Sans
codeGlaze Sep 30, 2026
bdfc7b7
Record header weights: tabs 500, page title 600
codeGlaze Sep 30, 2026
f3dc53d
Correct the header weights: title 600, tabs not decided
codeGlaze Sep 30, 2026
ac730c4
Decide header weights: tabs 700, page title 600
codeGlaze Sep 30, 2026
5779b03
Capture four pages before and after feature/style-guide's font changes
codeGlaze Sep 30, 2026
40db743
Capture a proposed weight rule across four pages, and the survey behi…
codeGlaze Sep 30, 2026
8f63ea0
Capture feature/style-guide with the weight rule built
codeGlaze Sep 30, 2026
68a3e07
Try dark text on the yellow buttons, and measure room for My Content
codeGlaze Sep 30, 2026
a54969a
Capture five treatments for the yellow button text
codeGlaze Sep 30, 2026
9936634
Capture dark button text with a light edge, the inverse of the white …
codeGlaze Sep 30, 2026
db3c240
Capture the built text lift, and the survey of text on amber
codeGlaze Sep 30, 2026
a9ecc48
Capture the header tab lift and the ability-button options
codeGlaze Sep 30, 2026
451e501
Capture outline options for dark text on the ability buttons
codeGlaze Sep 30, 2026
88eff83
Capture coloured outline options for dark text on the ability buttons
codeGlaze Sep 30, 2026
87d1a22
Capture gradient outline options for dark text on the ability buttons
codeGlaze Sep 30, 2026
c3db1dd
Show the gradient outline options at actual size and smaller
codeGlaze Sep 30, 2026
ed32cb3
Capture ability button label sizes at 14, 15 and 16px
codeGlaze Sep 30, 2026
03e47c5
Capture ability button label sizes on a phone
codeGlaze Sep 30, 2026
0c339a6
Recapture the phone views as a real phone, with the layout fixes
codeGlaze Sep 30, 2026
8139501
Capture the builder laid out by window width
codeGlaze Oct 1, 2026
ce5e3a6
Capture the tidied phone header, and refresh the phone layout shots
codeGlaze Oct 1, 2026
00199ad
Capture the phone header's left edges, built and aligned, and the cen…
codeGlaze Oct 1, 2026
96da146
Capture the phone builder on 10px and 16px gutters beside the built one
codeGlaze Oct 1, 2026
223f728
Capture the phone page on its 10px gutter
codeGlaze Oct 1, 2026
90b4f20
Capture the roll buttons at 16px
codeGlaze Oct 1, 2026
c52292c
Capture the style tidy before and after
codeGlaze Oct 2, 2026
3d8e30d
Capture the header menus moved into Garden, and the phone menu fix
codeGlaze Oct 2, 2026
52898f5
Merge integration into claude/artist-profile-pages
codeGlaze Oct 2, 2026
c1c70f6
Bring the portrait and artist comments to the comment rule
codeGlaze Oct 3, 2026
daebf75
Move the artist pages' stylesheet into Garden
codeGlaze Oct 3, 2026
a703f1c
Before/after page for the artist pages' Garden move
codeGlaze Oct 3, 2026
858c419
Stop listing every art piece on the artist page
codeGlaze Oct 4, 2026
501d18c
Render the artist pages' examples once, small and watermarked
codeGlaze Oct 4, 2026
c986211
Page showing the watermarked artist examples at actual size
codeGlaze Oct 4, 2026
1834a3d
Docstrings to the current rule: every function has one, GOTCHA only w…
codeGlaze Oct 11, 2026
f3f2ca5
Prove the artist tests fail, and check the watermark is drawn
codeGlaze Oct 11, 2026
75fce85
Merge the portrait branch into claude/artist-profile-pages
codeGlaze Oct 12, 2026
f13e547
Merge the portrait branch's review fixes into claude/artist-profile-p…
codeGlaze Oct 12, 2026
851252c
Merge the portrait branch at e2f91af5; artist pages redraw when credi…
codeGlaze Oct 12, 2026
05e2dfb
Review items: images recorded, failed examples retried, What's New dr…
codeGlaze Oct 13, 2026
ad05041
cljs test runner: wait for the summary, not for the page's load event
codeGlaze Oct 13, 2026
c111c26
Merge the portrait branch at 6d71d2b1; ignore design captures; correc…
codeGlaze Oct 13, 2026
b2c7196
Mark every image drawn from licensed art, JPEG included, through one API
codeGlaze Oct 13, 2026
9564d9b
Consolidate the 29 design capture scripts into one library and one li…
codeGlaze Oct 13, 2026
b7fcba1
Fit all seven header tabs on a 320px phone
codeGlaze Oct 13, 2026
05c7817
Give the tick boxes a real look: rounded, filled with the accent when…
codeGlaze Oct 13, 2026
fc3d660
Centre the tick in the tick box: draw it instead of using a font glyph
codeGlaze Oct 13, 2026
0490d50
Merge remote-tracking branch 'origin/claude/character-portrait-genera…
codeGlaze Oct 13, 2026
6ae8a0c
Merge the portrait branch at dc102fe1: fold artist-page lines into Su…
codeGlaze Oct 13, 2026
8f087ce
Changelog: phone header fix under Fixed, drop the operator-only line;…
codeGlaze Oct 14, 2026
254969b
Art mark: put the JPEG comment after the APPn segments, so JFIF stays…
codeGlaze Oct 14, 2026
27df151
Escape the page title: an artist's name never becomes page source
codeGlaze Oct 14, 2026
9437251
Artist-page examples: draw through the portrait pool, single-flight, …
codeGlaze Oct 14, 2026
b9ab232
Artist links: https only from any source, marked as user-supplied, sh…
codeGlaze Oct 14, 2026
fdf8b48
Artist credits: one saved change a minute, since each one emails the …
codeGlaze Oct 14, 2026
bd9f43f
Styles: the credit's artist-page link and the page's link marks come …
codeGlaze Oct 14, 2026
0ddfa10
Tick boxes: keyboard and screen-reader controls, a visible focus ring…
codeGlaze Oct 14, 2026
40127a6
Header menus probe: a phone menu that did not open fails, not passes
codeGlaze Oct 14, 2026
319f450
Verification pass: key the credit save gap by connection; a renamed p…
codeGlaze Oct 14, 2026
d628815
Merge the portrait branch at 4b5073e5: its Garden move and the escapi…
codeGlaze Oct 14, 2026
1128d37
Artist links: rel="noopener" only; Referrer-Policy strict-origin-when…
codeGlaze Oct 14, 2026
f6148d0
Artist credit emails: the first change at once, the rest of a ten-min…
codeGlaze Oct 14, 2026
331f3a0
Artists control their own page: on or off, and its examples, links an…
codeGlaze Oct 14, 2026
859d5fd
Reviewed images: drop the entries for design screenshots no longer in…
codeGlaze Oct 15, 2026
da2b7e0
Merge feature/portrait-builder: portrait_tab_e2e declares it needs th…
codeGlaze Oct 15, 2026
7abc06c
Merge feature/portrait-builder: artist credits load when a view shows…
codeGlaze Oct 15, 2026
155fd25
Merge integration into feature/artist-pages
codeGlaze Oct 27, 2026
04becd2
Credit notices: queue each email in the transaction that records it; …
codeGlaze Oct 27, 2026
5bb6d63
Tests: an overdue batch survives the next save; credit emails are que…
codeGlaze Oct 27, 2026
cc24d2a
My Account: page switches start from the setting in force; ones the s…
codeGlaze Oct 27, 2026
52b52c9
Tests: My Account's page switches show the setting in force, locked w…
codeGlaze Oct 27, 2026
406d4e1
Docs: the off page redirects, the show switches are overridable, one …
codeGlaze Oct 27, 2026
4784ea7
Review fixes (server): switches reach My Account and the settings; re…
codeGlaze Oct 27, 2026
43399c8
Tests for the server review fixes: switches, settings, referrer chain…
codeGlaze Oct 27, 2026
bd0e94a
Review fixes (client): a full preparation list refuses another spell;…
codeGlaze Oct 27, 2026
48a14d8
Keep this branch's heal re-dispatch: :character-updated keeps the por…
codeGlaze Oct 27, 2026
e6ccb1e
Test that a spell cannot be prepared once every preparation is used
codeGlaze Oct 27, 2026
de27f6d
Header probe: a phone must show the desktop's menus, and say how they…
codeGlaze Oct 27, 2026
9f5dfe0
Artist page docs: a page switched off redirects; examples are cached,…
codeGlaze Oct 27, 2026
6acf7c4
Prepared-spell test requires re-frame.core for the dispatch it redefines
codeGlaze Oct 27, 2026
134d04c
Merge integration into feature/artist-pages
codeGlaze Oct 28, 2026
4a15c78
Artist pages review fixes: credit notices write the pending batch wit…
codeGlaze Oct 28, 2026
5cafb47
Artist pages review fixes: What's New names the link that reaches an …
codeGlaze Oct 28, 2026
4ed89a7
Test the artist pages review fixes
codeGlaze Oct 28, 2026
5041c6c
Changelog: cite the new umbrella issues #714-#721 on the Summer Patch…
codeGlaze Oct 28, 2026
e466cae
CodeRabbit: skip docs/design so a large PR is not skipped for file count
codeGlaze Oct 28, 2026
d2cef86
Artist pages: review fixes (marks default, notice failure, locked swi…
codeGlaze Oct 28, 2026
a812f90
Artist pages: tests for the review fixes
codeGlaze Oct 29, 2026
af89cf0
Fold the branch changelog into Summer Patch
codeGlaze Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
docs/design/**/*.jpg
docs/design/**/*.jpeg
docs/design/**/*.webp
docs/design/**/*.gif
12 changes: 9 additions & 3 deletions ARTISTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
109 changes: 60 additions & 49 deletions CHANGELOG.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions dev/orcpub/portrait_gallery.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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 <nm>.png to ORCPUB_PACK_OUT, carrying the pack's licensed-art mark when it has
Expand All @@ -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))))

Expand Down
226 changes: 226 additions & 0 deletions docs/PORTRAIT-COMPOSITOR.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

## 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 `<meta name="robots" content="noai, noimageai">`, 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.
9 changes: 9 additions & 0 deletions docs/PORTRAIT-SERVING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/examples/<n>` draws its small watermarked JPEG through the same portrait pool
(`portrait-store/draw-pool`), single-flight and kept in a bounded cache keyed by artist, example and
watermark text (the text comes from the artist's links, so an edit makes new keys and old ones age
out). A failed draw is remembered for 60 s, so a broken example is not redrawn on every request. A
busy pool answers 503 + Retry-After. The credits are refreshed first, so a fresh container
watermarks with the artist's current links.

## Strand fields

The build step writes `<piece>.strands.png` beside each hair piece. Without it (running from
Expand Down
Loading
Loading