on master help sign in
Add the app icon and favicon, served from the ship and inlined in the docket, and rewrite the README
commit24f74adf287f1d49d1abf454b296b5d68b4b0031 authoriko <mail@iko.soy> dateTue 20 Oct 2026, 22:07 (3 days ago) parentcd590f3
8 files changed+254−27
README.md+164−21 desk/app/shelf.hoon+27 desk/desk.docket-0+2−1 desk/lib/shelf.hoon+17−4 desk/mar/svg.hoon+12 desk/tests/lib/shelf.hoon+30−1 desk/web/favicon.svg+1 desk/web/shelf-icon.svg+1
README.md+164−21
@@ -1,3 +1,5 @@
1+<img src="desk/web/shelf-icon.svg" width="96" height="96" alt="Shelf logo">
2+
13# %shelf
24
35A public web page that lists the apps your Urbit ship publishes, so anyone can
@@ -7,29 +9,29 @@ The page shows your ship's `%treaty` alliance: your own published desks plus
79any desks you republish from other ships. Each app has an Install button that
810opens it in the visitor's own Landscape, and the Dojo `|install` command as a
911fallback. The page follows the visitor's light or dark preference. It has no
10−JavaScript except for the Copy buttons.
12+JavaScript except for the Copy buttons. The same URL also returns the list as
13+JSON.
1114
1215%shelf answers on exactly one path, `/shelf`. Run it behind a reverse proxy
1316that serves it at the root of a dedicated domain (see
14−[Serving it on its own domain](#serving-it-on-its-own-domain)). All of the
15−page's links point at `/`.
17+[Serving it on its own domain](#serving-it-on-its-own-domain)). The page's
18+links and its install form all point at `/`.
1619
1720## Requirements
1821
1922- Kernel `[%zuse 408]`.
20−- `%landscape`, which provides `%treaty`. Fresh ships have it.
23+- `%landscape`, which provides `%treaty` and `%docket`. Fresh ships have it.
24+- Nix, for the one-command build. Without it, copy `desk/` from a checkout.
2125
2226## Install
2327
24−Build the desk with Nix. `result` is then the desk folder:
25−
2628```sh
2729nix build github:iko-soy/shelf
2830```
2931
30−From a checkout, `nix build` does the same, or you can use `desk/` directly.
31−
32−Then, with your ship running:
32+`result` is now the desk folder (see [Building with Nix](#building-with-nix)).
33+Without Nix, use `desk/` from a checkout in its place. Then, with your ship
34+running:
3335
3436```
3537~sampel-palnet:dojo> |new-desk %shelf
@@ -51,6 +53,44 @@ tile that opens it. It is meant to be opened through your own domain, though:
5153on the ship's address the page renders, but its links lead to `/`, which
5254belongs to Landscape.
5355
56+## Building with Nix
57+
58+`flake.nix` has one output: `packages.<system>.default`, the desk. Building it
59+copies `desk/` into the Nix store; nothing is compiled, because Clay compiles
60+the Hoon on your ship when you commit the desk.
61+
62+| Command | Builds |
63+|---|---|
64+| `nix build github:iko-soy/shelf` | the latest commit on the default branch |
65+| `nix build github:iko-soy/shelf/<commit>` | a specific commit |
66+| `nix build` | your checkout, from the repo root |
67+
68+Each one leaves a `result` symlink to the desk in the store:
69+
70+```
71+result/
72+├── desk.bill desk.docket-0 sys.kelvin
73+├── app/ gen/ lib/ mar/ sur/ tests/
74+└── web/
75+```
76+
77+- Files in the store are read-only, so `chmod -R u+w` the mount after copying
78+ them in: the ship has to be able to write to its own mount.
79+- `nix flake check --all-systems` checks the flake on every system it
80+ supports: `x86_64-linux`, `aarch64-linux` and `aarch64-darwin`. Plain
81+ `nix flake check` checks only your own. Current nixpkgs no longer supports
82+ Intel Macs.
83+- nixpkgs is only used for `runCommandLocal`. `nix flake update` moves its
84+ pin in `flake.lock`.
85+- From another flake, for example a NixOS config that sets up a ship, the desk
86+ is:
87+
88+ ```nix
89+ inputs.shelf.url = "github:iko-soy/shelf";
90+ # …
91+ shelf.packages.${system}.default
92+ ```
93+
5494## Update
5595
5696Build and copy the desk into the mount again, as above, and run
@@ -76,7 +116,9 @@ before you can publish it.
76116 `:treaty|unally ~other-ship`.
77117
78118%shelf has a docket of its own, so `:treaty|publish %shelf` lists it on its
79−own page, where others can install it.
119+own page, where others can install it. The docket carries the app icon
120+inline, as a URL-encoded `data:` URL, so the icon shows in Landscape and on
121+other ships' pages with nothing to host.
80122
81123A republished app appears once your ship has received its docket from the
82124publisher, which happens a few seconds after you ally them. Apps are sorted by
@@ -103,7 +145,7 @@ apps.example.com {
103145- `redir` sends every path except `/` back to `/`, so nothing else on your
104146 ship can be reached through this domain.
105147- `rewrite` replaces only the path, so the query string the page relies on
106− (`/?install=…`) reaches the ship intact.
148+ (`/?install=…`, `/?asset=…`) reaches the ship intact.
107149- `Forwarded` tells Eyre the visitor came over HTTPS. Eyre trusts it only
108150 from `127.0.0.1`, hence the IP instead of `localhost`. Without it, a ship
109151 that also serves its own TLS redirects every proxied request to its HTTPS
@@ -113,6 +155,10 @@ apps.example.com {
113155 another proxy, pass `Host` through (nginx: `proxy_set_header Host $host;`)
114156 and send `Forwarded` (or `X-Forwarded-Proto`) the same way.
115157- Your ship's own domain is unaffected: `/` still goes to Landscape.
158+- %shelf serves everything the page needs from the desk, at the same URL: the
159+ CSS and JavaScript inline, and the favicon at `/?asset=favicon.svg`. It
160+ also serves the app icon at `/?asset=shelf-icon.svg`, though the docket
161+ carries that inline. Nothing else needs to be reachable.
116162
117163This config was tested with Caddy 2.11 in front of a fake ship.
118164
@@ -155,12 +201,15 @@ curl -H 'Accept: application/json' https://apps.example.com/
155201- `image` is `null` when the docket has none, and `website` is `""`.
156202- `image` and `website` are the docket's values as they are. Dockets can come
157203 from other ships, and unlike the page, the JSON doesn't filter these, so
158− check for `http(s)` before using them.
204+ check them before using them. `image` may also be an inline
205+ `data:image/…` URL.
206+- Text that isn't valid UTF-8 comes out with U+FFFD in its place, so the
207+ listing is always valid JSON.
159208- Entries are sorted like the page. Key order within an object is not fixed.
160209- JSON wins when the header ranks `application/json` above `text/html`. When
161210 the two rank equally, the more specific match wins, so
162211 `application/json, */*` gets JSON. A full tie, a bare `*/*` or a missing
163− header gets HTML.
212+ header gets HTML. Repeated `Accept` lines count as one list.
164213- The response has `Access-Control-Allow-Origin: *`, varies on `Accept` and
165214 `Origin`, and is `private, max-age=60`. It is private because Eyre attaches
166215 its session cookie to every response, and on your own logged-in requests
@@ -172,10 +221,10 @@ curl -H 'Accept: application/json' https://apps.example.com/
172221 URL (for example `sampel-palnet.tlon.network`) and shows the Dojo command.
1732222. The form is POSTed. %shelf checks the URL and keeps only its scheme, host
174223 and port. It remembers that origin in a cookie for a year, then redirects
175− to
176− `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
224+ to `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
177225 Landscape's own "Get App" dialog. The visitor's ship fetches the app's
178− details directly from the publisher.
226+ details directly from the publisher, and a visitor who isn't logged in
227+ lands there after their ship's login page.
1792283. On later visits, Install links straight there. The button reads
180229 `Install on ~sampel-palnet` when the URL's first label is a planet name
181230 (or a moon or comet), or any ship name on `arvo.network`, `tlon.network`
@@ -191,8 +240,9 @@ Guards:
191240 docket image URL pointing at your domain, can plant a URL in a visitor's
192241 cookie or bounce them elsewhere through your domain.
193242- Only apps actually on the shelf can be installed through the form.
194−- On the page, image and website URLs from dockets are used only if they are
195− `http(s)`. Dockets can come from other ships.
243+- On the page, docket images are used only if they are `http(s)` or
244+ `data:image/…` URLs, and website links only if they are `http(s)`.
245+ Dockets can come from other ships.
196246
197247## Traffic
198248
@@ -235,22 +285,115 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
235285 Installing %shelf again binds `/shelf` again. %shelf re-binds every time
236286 it loads, so it also restores the binding if someone removed it by hand.
237287
288+## Changes from the design handoffs
289+
290+%shelf follows two design handoffs: the page itself (layout, copy, behaviour,
291+JSON, a skeleton agent) and a later icon update. Rendered with the mock's sample
292+data, the page matches the design mock pixel for pixel in light and dark mode,
293+apart from the icons and names. Everything below differs from the handoffs,
294+and why.
295+
296+**Serving**
297+- **One path, `/shelf`, instead of `/`.** On most ships Landscape's `%docket`
298+ owns `/`, and Eyre doesn't refuse a second claim: it silently hands `/`
299+ over, which would take away the owner's login page. A dedicated domain
300+ mapped onto `/shelf` gives visitors a clean root URL instead.
301+- **One URL for everything.** Because only `/` reaches the ship through that
302+ domain, the CSS and JavaScript are inlined (not `/shelf.css`, `/copy.js`),
303+ the icons are at `/?asset=…` (not `/favicon.svg`, `/shelf-icon.svg`), the
304+ install form posts to `/` (not `/install`), and Change ship is `/?forget`
305+ (not `/forget`). Other paths are the proxy's job; the ship answers them
306+ with 404.
307+- **JSON by `Accept` header at the same URL, not `/apps.json`, and
308+ `private`.** The single URL rules out `/apps.json`, and `public` caching
309+ could hand an owner's login cookie to a shared cache.
310+- **HTML varies on `Cookie` as well as `Accept`.** Otherwise a browser shows
311+ its cached copy for up to a minute after Change ship or a save.
312+- **Bodies go out in 16 KiB pieces**, because a single large response gets
313+ quadratically slower on its way out of the ship.
314+
315+**Installing**
316+- **The install form is a POST, accepted only from the page itself, and only
317+ for apps on the shelf.** A GET could be triggered by a link or by another
318+ publisher's docket image, planting a phishing URL in visitors' cookies.
319+- **Deep link:** `<ship>/apps/landscape/search/direct/apps/~pub/desk`,
320+ Landscape's own "show install" route, tested end to end. The handoff's
321+ `…/leap/search/…` also works, but only through a redirect.
322+- **Saved ship URLs keep only scheme, host and port**, so a pasted
323+ `…/apps/groups` doesn't break the deep link. `localhost` and `127.*`
324+ default to `http://`, and ports must be 1–65535.
325+- **The cookie holds the URL as is (not URL-encoded), `HttpOnly`, and gets
326+ `Secure` when the visitor came over HTTPS**, as `Forwarded` or
327+ `X-Forwarded-Proto` says behind a proxy. The normalized URL only contains
328+ cookie-safe characters.
329+- **"Install on ~ship" needs a planet or longer name, or a known ship host.**
330+ The handoff's rule (any first DNS label that parses as an `@p`) turned
331+ `dev.example.com` into "Install on ~dev".
332+- **Returning visitors see "Installs open on ~ship. Change ship"** in the
333+ masthead, a muted link that clears the cookie. The handoff left this open.
334+
335+**Reading the alliance**
336+- **Own desks' treaties are built from Clay**, from each desk's
337+ `desk.docket-0`, as `%treaty` does. `%treaty`'s `/treaty/<ship>/<desk>`
338+ scry only covers other ships' desks and crashes for your own.
339+- **Only scries that can't fail.** The handoff wrapped scries in `mule`, but
340+ inside an agent `mule` can't catch a failed scry: the request just fails.
341+- **Visitors arrive under an Eyre guest identity**, so the owner check
342+ applies only to the settings pokes. Checking every request would 500 for
343+ all visitors.
344+- **Docket text from other ships is handled defensively:** images only
345+ `http(s)` or `data:image/…`, links only `http(s)`, invalid UTF-8 replaced
346+ in the JSON, and a title that starts with a control character still
347+ renders.
348+
349+**Page**
350+- **Header and rows share one grid (CSS subgrid)**, so columns stay aligned
351+ when Install labels get wider for a saved ship.
352+- **Narrow screens:** with a saved ship the stacked layout starts at 1040px
353+ instead of 860px, and the install button's text wraps; the URL field gets
354+ its own line; long words wrap instead of overflowing.
355+- **Keyboard focus inside a row tints it**, as the handoff's interaction table
356+ says (its CSS only did this on hover).
357+- **"1 app"**, not "1 apps".
358+- **The sigil is drawn in Hoon** by a vendored renderer, in `currentColor` on
359+ `var(--ink)` so it inverts with the theme, at sigil-js's 56px geometry.
360+ Moons and comets get the plain square, as in Landscape.
361+- **New: owner-set raw HTML in `<head>` and `<body>`**, for analytics.
362+
363+**Icons and docket** (the icon update)
364+- **The docket's `image` is the icon inline, as a URL-encoded `data:` URL**,
365+ instead of an absolute URL on the ship, which would need filling in per
366+ ship and would break wherever that ship can't be reached.
367+- **The tile opens `/shelf`** (`site+/shelf`, not `site+/`), and the docket's
368+ website is this repo.
369+- **The docket's description drops "with one-click install"**: first-time
370+ visitors enter their ship's URL before the Install button takes them there.
371+- **`mar/svg` is the upstream mark from `%base`**, not the handoff's new one.
372+- **Icons are served `private`** for the same login-cookie reason as the
373+ JSON.
374+
375+**Other**
376+- **Kernel `[%zuse 408]`**, the current release, replacing the handoff's
377+ placeholder 409.
378+
238379## Development
239380
240381The desk is self-contained. `desk/` holds everything Clay needs:
241382
242383| Path | What |
243384|---|---|
244−| `desk.docket-0` | What Landscape shows: title, description, color, version, website, license |
385+| `desk.docket-0` | What Landscape shows: title, description, color, icon, version, website, license. Its `image` is `web/shelf-icon.svg` URL-encoded; a test checks they match |
245386| `app/shelf.hoon` | The agent: Eyre binding, routing, scries, responses |
246387| `lib/shelf.hoon` | Pure helpers and the page (Sail) |
247388| `sur/shelf.hoon`, `mar/shelf/action.hoon`, `gen/shelf/` | The `head`/`body` settings |
248389| `web/shelf.css`, `web/copy.js` | Inlined into the page at build time with `/*` |
390+| `web/shelf-icon.svg`, `web/favicon.svg` | The app icon (also inlined in the docket) and the favicon, served at `?asset=` |
249391| `tests/lib/shelf.hoon` | Unit tests, including the rendered page |
250392| `lib/sigil.hoon`, `lib/sigil/symbols.hoon` | Vendored [sigil renderer](https://github.com/Fang-/suite) (MIT, ~paldev; shapes from `@urbit/sigil-js`, MIT, Tlon) |
251−| other `lib/`, `mar/`, `sur/` files | Copied unmodified from `%base` (urbit/urbit `5a187fe`) and `%landscape` (`sur/treaty`, `sur/docket`, `lib/docket`, `mar/docket-0`; tloncorp/landscape `46c6ed8`) |
393+| other `lib/`, `mar/`, `sur/` files | Copied unmodified from `%base` (urbit/urbit `5a187fe`, including `mar/svg`) and `%landscape` (`sur/treaty`, `sur/docket`, `lib/docket`, `mar/docket-0`; tloncorp/landscape `46c6ed8`) |
252394
253−`flake.nix` makes `nix build` produce the desk as `result`.
395+Outside the desk, `flake.nix` and `flake.lock` define the Nix build (see
396+[Building with Nix](#building-with-nix)).
254397
255398Run the tests on a ship with the desk installed:
256399
desk/app/shelf.hoon+27
@@ -7,6 +7,9 @@
77:: Meant to sit behind a reverse proxy that maps a dedicated domain's
88:: / onto /shelf, so the page's links all point at /.
99::
10+:: It also serves its two icons from the desk, at ?asset=favicon.svg
11+:: and ?asset=shelf-icon.svg.
12+::
1013:: The owner can append raw HTML to <head> and <body>, for analytics:
1114:: :shelf|head '<script defer src="…"></script>'
1215:: :shelf|body '…'
@@ -15,6 +18,8 @@
1518/+ default-agent, dbug, server, *shelf
1619/* shelf-css %css /web/shelf/css
1720/* copy-js %js /web/copy/js
21+/* favicon %svg /web/favicon/svg
22+/* shelf-icon %svg /web/shelf-icon/svg
1823|%
1924+$ card card:agent:gall
2025+$ state-0
@@ -112,6 +117,7 @@
112117 ?. ?=(?(%'GET' %'HEAD') method.request.req)
113118 [[405 ~[['allow' 'GET, HEAD, POST']]] ~]
114119 =/ q=(map @t @t) (malt args.rl)
120+ ?^ name=(~(get by q) 'asset') (asset u.name)
115121 ?: (~(has by q) 'forget') (forget hs secure)
116122 ?: (wants-json (header-all 'accept' hs)) listing-json
117123 (page q hs)
@@ -127,6 +133,27 @@
127133 ^- simple-payload:http
128134 [[code ~[['location' loc] ['cache-control' 'no-store']]] ~]
129135::
136+:: +asset: one of the icons in the desk's /web
137+::
138+:: private, like the JSON: Eyre adds its session cookie to every
139+:: response, and for a logged-in owner that is their login token.
140+::
141+++ asset
142+ |= name=@t
143+ ^- simple-payload:http
144+ =/ file=(unit @)
145+ ?+ name ~
146+ %'favicon.svg' `favicon
147+ %'shelf-icon.svg' `shelf-icon
148+ ==
149+ ?~ file (plain 404 'Not found')
150+ :_ `(as-octs:mimes:html u.file)
151+ :- 200
152+ :~ ['content-type' 'image/svg+xml']
153+ ['cache-control' 'private, max-age=86400']
154+ ['x-content-type-options' 'nosniff']
155+ ==
156+::
130157:: +forget: clear the visitor's saved ship
131158::
132159:: Only when the browser is navigating here, not when a docket's image
desk/desk.docket-0+2−1
@@ -1,6 +1,7 @@
11:~ title+'Shelf'
2− info+'A public page listing the apps this ship publishes, so anyone can install them on their own ship.'
2+ info+'A public page of the apps this ship publishes.'
33 color+0xe8.801f
4+ image+'data:image/svg+xml,%3Csvg%20height%3D%22120%22%20viewBox%3D%220%200%20120%20120%22%20width%3D%22120%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22m0%200h120v120h-120z%22%20fill%3D%22%23e8801f%22%2F%3E%3Cg%20fill%3D%22%23151514%22%3E%3Cpath%20d%3D%22m24%2052h20v20h-20z%22%2F%3E%3Cpath%20d%3D%22m50%2052h20v20h-20z%22%2F%3E%3Cpath%20d%3D%22m76%2040h20v20h-20z%22%2F%3E%3Cpath%20d%3D%22m24%2072h72v8h-72z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E'
45 site+/shelf
56 version+[0 1 0]
67 website+'https://github.com/iko-soy/shelf'
desk/lib/shelf.hoon+17−4
@@ -230,6 +230,18 @@
230230 ?. |(=("https://" t) =("http://" (scag 7 t))) ~
231231 `url
232232::
233+:: +image-url: a URL fit for an <img src>: http(s), or an inline image
234+::
235+:: A docket may carry its icon as a data:image/ URL, so it shows
236+:: without a server. An <img> can't run script, whatever it loads.
237+::
238+++ image-url
239+ |= url=@t
240+ ^- (unit @t)
241+ ?^ (web-url url) `url
242+ ?. =("data:image/" (cass (trip (end [3 11] url)))) ~
243+ `url
244+::
233245:: +header: a request header, matching its name case-insensitively
234246::
235247++ header
@@ -539,10 +551,11 @@
539551 :~ ;meta(charset "utf-8");
540552 ;meta(name "viewport", content "width=device-width, initial-scale=1");
541553 ;meta(name "color-scheme", content "light dark");
542− :: no favicon, so browsers don't ask the proxy for /favicon.ico,
543− :: which it would answer with a second render of this page
554+ :: query-relative, so it resolves on the proxy's / and the
555+ :: ship's /shelf alike; an explicit icon also stops browsers
556+ :: asking the proxy for /favicon.ico, a second page render
544557 ::
545− ;link(rel "icon", href "data:,");
558+ ;link(rel "icon", type "image/svg+xml", href "?asset=favicon.svg");
546559 ;title: {me} · Apps
547560 ;style: {(trip css)}
548561 ==
@@ -613,7 +626,7 @@
613626 =/ pub=tape (scow %p ship.t)
614627 =/ cmd=tape (install-cmd ship.t desk.t)
615628 =/ is-open=? =(open `[ship.t desk.t])
616− =/ img=(unit @t) (biff image.d web-url)
629+ =/ img=(unit @t) (biff image.d image-url)
617630 =/ site=(unit @t) ?:(=('' website.d) ~ (web-url website.d))
618631 ;article.app(id (anchor ship.t desk.t))
619632 ;div.app-row
desk/mar/svg.hoonnew file+12
@@ -0,0 +1,12 @@
1+|_ dat=@
2+++ grow
3+ |%
4+ ++ mime [/image/'svg+xml' (as-octs:mimes:html dat)]
5+ --
6+++ grab
7+ |%
8+ ++ mime |=([p=mite q=octs] q.q)
9+ ++ noun @
10+ --
11+++ grad %mime
12+--
desk/tests/lib/shelf.hoon+30−1
@@ -1,5 +1,7 @@
11/- docket, treaty
22/+ *test, *shelf
3+/* icon %svg /web/shelf-icon/svg
4+/* dok %docket-0 /desk/docket-0
35|%
46:: fixtures for the page tests
57::
@@ -17,6 +19,13 @@
1719 ~ [0 4 2] 'https://github.com/bus/marginalia' 'MIT'
1820 ==
1921::
22+++ shelfapp
23+ ^- treaty:treaty
24+ :* ~zod %shelf da+~2026.1.1 0v4
25+ %1 'Shelf' 'Its own icon.' 0xe8.801f [%site /shelf]
26+ `'data:image/svg+xml,%3Csvg%3E' [0 1 0] 'https://github.com/iko-soy/shelf' 'MIT'
27+ ==
28+::
2029++ oddity
2130 ^- treaty:treaty
2231 :* ~zod %oddity da+~2026.1.1 0v3
@@ -52,7 +61,8 @@
5261 (expect !>((has doc "<title>~zod · Apps</title>")))
5362 (expect !>((has doc "<style>CSS</style>")))
5463 (expect !>((has doc "<main class=\"page\">")))
55− (expect !>((has doc "<link rel=\"icon\" href=\"data:,\" />")))
64+ %- expect !>
65+ (has doc "<link rel=\"icon\" type=\"image/svg+xml\" href=\"?asset=favicon.svg\" />")
5666 (expect !>((has doc "<script>JS</script>")))
5767 ==
5868::
@@ -80,6 +90,10 @@
8090 (expect !>((has doc "New to Urbit?\0a<a href=\"https://urbit.org\"")))
8191 ==
8292::
93+++ test-ui-inline-icon
94+ =/ doc (render ~zod ~[shelfapp] ~ | ~)
95+ (expect !>((has doc "<img src=\"data:image/svg+xml,%3Csvg%3E\" alt=\"\" loading=\"lazy\" />")))
96+::
8397++ test-ui-one-app
8498 =/ doc (render ~zod ~[harbor] ~ | ~)
8599 (expect !>((has doc "<span class=\"count\">1 app</span>")))
@@ -193,6 +207,13 @@
193207 (expect-eq !>('') !>((clean-utf8 '')))
194208 ==
195209::
210+:: the docket's inline icon is web/shelf-icon.svg, URL-encoded
211+::
212+++ test-docket-icon
213+ %+ expect-eq
214+ !>(`(unit @t)``(crip (weld "data:image/svg+xml," (en-urlt:html (trip icon)))))
215+ !>(image.docket.dok)
216+::
196217++ test-treaty-json
197218 =/ one (en:json:html (treaty-json harbor))
198219 =/ two (en:json:html (treaty-json marginalia))
@@ -282,6 +303,14 @@
282303 (expect-eq !>(`(unit @t)`~) !>((web-url '//evil.com/x')))
283304 (expect-eq !>(`(unit @t)`~) !>((web-url 'data:image/png;base64,AAAA')))
284305 (expect-eq !>(`(unit @t)`~) !>((web-url '')))
306+ (expect-eq !>(`(unit @t)``'data:image/png;base64,AAAA') !>((image-url 'data:image/png;base64,AAAA')))
307+ (expect-eq !>(`(unit @t)``'https://a.b/i.png') !>((image-url 'https://a.b/i.png')))
308+ %+ expect-eq !>(`(unit @t)``'data:image/svg+xml,%3Csvg%3E')
309+ !>((image-url 'data:image/svg+xml,%3Csvg%3E'))
310+ ::
311+ (expect-eq !>(`(unit @t)``'DATA:Image/png;base64,AA') !>((image-url 'DATA:Image/png;base64,AA')))
312+ (expect-eq !>(`(unit @t)`~) !>((image-url 'data:text/html,%3Cscript%3E')))
313+ (expect-eq !>(`(unit @t)`~) !>((image-url 'javascript:alert(1)')))
285314 ==
286315::
287316++ test-header
desk/web/favicon.svgnew file+1
@@ -0,0 +1 @@
1+<svg height="32" viewBox="0 0 120 120" width="32" xmlns="http://www.w3.org/2000/svg"><path d="m0 0h120v120h-120z" fill="#e8801f"/><g fill="#151514"><path d="m13.2 49.6h26v26h-26z"/><path d="m47 49.6h26v26h-26z"/><path d="m80.8 34h26v26h-26z"/><path d="m13.2 75.6h93.6v10.4h-93.6z"/></g></svg>
desk/web/shelf-icon.svgnew file+1
@@ -0,0 +1 @@
1+<svg height="120" viewBox="0 0 120 120" width="120" xmlns="http://www.w3.org/2000/svg"><path d="m0 0h120v120h-120z" fill="#e8801f"/><g fill="#151514"><path d="m24 52h20v20h-20z"/><path d="m50 52h20v20h-20z"/><path d="m76 40h20v20h-20z"/><path d="m24 72h72v8h-72z"/></g></svg>