on master help sign in
9 files changed+293−41
since splitsnapshots
unifiedsplit
README.md+191−28 desk/app/shelf.hoon+27 desk/desk.docket-0+2−1 desk/lib/shelf.hoon+18−5 desk/mar/svg.hoon+12 desk/tests/lib/shelf.hoon+31−2 desk/web/favicon.svg+1 desk/web/shelf-icon.svg+1 flake.nix+10−5
README.md+191−28
@@ -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,42 @@ 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, if you install from source. Without it,
25+ copy `desk/` from a checkout.
2126
2227## Install
2328
24−Build the desk with Nix. `result` is then the desk folder:
29+Install %shelf from ~donmec-sallud, which publishes it:
30+
31+```
32+~sampel-palnet:dojo> |install ~donmec-sallud %shelf
33+```
34+
35+%shelf then gets its updates from ~donmec-sallud.
36+[shelf.iko.soy](https://shelf.iko.soy) is %shelf running on that ship. It
37+lists all the apps ~donmec-sallud offers, %shelf among them.
38+
39+To install from source instead, build the desk:
2540
2641```sh
2742nix build github:iko-soy/shelf
2843```
2944
30−From a checkout, `nix build` does the same, or you can use `desk/` directly.
31−
32−Then, with your ship running:
45+`result` is now the desk folder (see [Building with Nix](#building-with-nix)).
46+Without Nix, use `desk/` from a checkout in its place. Then, with your ship
47+running:
3348
3449```
3550~sampel-palnet:dojo> |new-desk %shelf
@@ -46,18 +61,59 @@ chmod -R u+w /path/to/pier/shelf # files from the Nix store are read-only
4661~sampel-palnet:dojo> |install our %shelf
4762```
4863
49−The page is now at `https://<your-ship>/shelf`, and Landscape shows a Shelf
50−tile that opens it. It is meant to be opened through your own domain, though:
51−on the ship's address the page renders, but its links lead to `/`, which
52−belongs to Landscape.
64+Either way, once %shelf runs, the page is at `https://<your-ship>/shelf`, and
65+Landscape shows a Shelf tile that opens it. It is meant to be opened through
66+your own domain, though: on the ship's address the page renders, but its links
67+lead to `/`, which belongs to Landscape.
68+
69+## Building with Nix
70+
71+`flake.nix` has one output: the desk, `packages.<system>.desk`, which is also
72+the default package. Building it copies `desk/` into the Nix store; nothing is
73+compiled, because Clay compiles the Hoon on your ship when you commit the desk.
74+
75+| Command | Builds |
76+|---|---|
77+| `nix build github:iko-soy/shelf` | the latest commit on the default branch |
78+| `nix build github:iko-soy/shelf/<commit>` | a specific commit |
79+| `nix build` | your checkout, from the repo root |
80+
81+Each one leaves a `result` symlink to the desk in the store:
82+
83+```
84+result/
85+├── desk.bill desk.docket-0 sys.kelvin
86+├── app/ gen/ lib/ mar/ sur/ tests/
87+└── web/
88+```
89+
90+- Files in the store are read-only, so `chmod -R u+w` the mount after copying
91+ them in: the ship has to be able to write to its own mount.
92+- `nix flake check --all-systems` checks the flake on every system it
93+ supports: `x86_64-linux`, `aarch64-linux` and `aarch64-darwin`. Plain
94+ `nix flake check` checks only your own. Current nixpkgs no longer supports
95+ Intel Macs.
96+- nixpkgs is only used for `runCommandLocal`. `nix flake update` moves its
97+ pin in `flake.lock`.
98+- From another flake, for example a NixOS config that sets up a ship, the desk
99+ is:
100+
101+ ```nix
102+ inputs.shelf.url = "github:iko-soy/shelf";
103+ # …
104+ shelf.packages.${system}.desk
105+ ```
53106
54107## Update
55108
56−Build and copy the desk into the mount again, as above, and run
57−`|commit %shelf`. Then check that `.^(cass:clay %cw /=shelf=)` moved: if the
58−new version doesn't build, Clay keeps the old one and logs nothing.
109+A %shelf installed from ~donmec-sallud gets its updates from that ship.
59110
60−%shelf is installed from your own ship, so it gets no over-the-air updates.
111+To update a source install, build and copy the desk into the mount again, as
112+above, and run `|commit %shelf`. Then check that `.^(cass:clay %cw /=shelf=)`
113+moved: if the new version doesn't build, Clay keeps the old one and logs
114+nothing.
115+
116+A source install comes from your own ship, so it gets no over-the-air updates.
61117After a kernel upgrade, Clay holds it until `desk/sys.kelvin` lists the new
62118`%zuse` version; update that file (or pull a release that has) and commit.
63119`+vats %shelf` shows whether it is running or held.
@@ -76,7 +132,9 @@ before you can publish it.
76132 `:treaty|unally ~other-ship`.
77133
78134%shelf has a docket of its own, so `:treaty|publish %shelf` lists it on its
79−own page, where others can install it.
135+own page, where others can install it. The docket carries the app icon
136+inline, as a URL-encoded `data:` URL, so the icon shows in Landscape and on
137+other ships' pages with nothing to host.
80138
81139A republished app appears once your ship has received its docket from the
82140publisher, which happens a few seconds after you ally them. Apps are sorted by
@@ -103,7 +161,7 @@ apps.example.com {
103161- `redir` sends every path except `/` back to `/`, so nothing else on your
104162 ship can be reached through this domain.
105163- `rewrite` replaces only the path, so the query string the page relies on
106− (`/?install=…`) reaches the ship intact.
164+ (`/?install=…`, `/?asset=…`) reaches the ship intact.
107165- `Forwarded` tells Eyre the visitor came over HTTPS. Eyre trusts it only
108166 from `127.0.0.1`, hence the IP instead of `localhost`. Without it, a ship
109167 that also serves its own TLS redirects every proxied request to its HTTPS
@@ -113,6 +171,10 @@ apps.example.com {
113171 another proxy, pass `Host` through (nginx: `proxy_set_header Host $host;`)
114172 and send `Forwarded` (or `X-Forwarded-Proto`) the same way.
115173- Your ship's own domain is unaffected: `/` still goes to Landscape.
174+- %shelf serves everything the page needs from the desk, at the same URL: the
175+ CSS and JavaScript inline, and the favicon at `/?asset=favicon.svg`. It
176+ also serves the app icon at `/?asset=shelf-icon.svg`, though the docket
177+ carries that inline. Nothing else needs to be reachable.
116178
117179This config was tested with Caddy 2.11 in front of a fake ship.
118180
@@ -155,12 +217,15 @@ curl -H 'Accept: application/json' https://apps.example.com/
155217- `image` is `null` when the docket has none, and `website` is `""`.
156218- `image` and `website` are the docket's values as they are. Dockets can come
157219 from other ships, and unlike the page, the JSON doesn't filter these, so
158− check for `http(s)` before using them.
220+ check them before using them. `image` may also be an inline
221+ `data:image/…` URL.
222+- Text that isn't valid UTF-8 comes out with U+FFFD in its place, so the
223+ listing is always valid JSON.
159224- Entries are sorted like the page. Key order within an object is not fixed.
160225- JSON wins when the header ranks `application/json` above `text/html`. When
161226 the two rank equally, the more specific match wins, so
162227 `application/json, */*` gets JSON. A full tie, a bare `*/*` or a missing
163− header gets HTML.
228+ header gets HTML. Repeated `Accept` lines count as one list.
164229- The response has `Access-Control-Allow-Origin: *`, varies on `Accept` and
165230 `Origin`, and is `private, max-age=60`. It is private because Eyre attaches
166231 its session cookie to every response, and on your own logged-in requests
@@ -172,10 +237,10 @@ curl -H 'Accept: application/json' https://apps.example.com/
172237 URL (for example `sampel-palnet.tlon.network`) and shows the Dojo command.
1732382. The form is POSTed. %shelf checks the URL and keeps only its scheme, host
174239 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
240+ to `<ship>/apps/landscape/search/direct/apps/~publisher/desk`. That is
177241 Landscape's own "Get App" dialog. The visitor's ship fetches the app's
178− details directly from the publisher.
242+ details directly from the publisher, and a visitor who isn't logged in
243+ lands there after their ship's login page.
1792443. On later visits, Install links straight there. The button reads
180245 `Install on ~sampel-palnet` when the URL's first label is a planet name
181246 (or a moon or comet), or any ship name on `arvo.network`, `tlon.network`
@@ -191,8 +256,9 @@ Guards:
191256 docket image URL pointing at your domain, can plant a URL in a visitor's
192257 cookie or bounce them elsewhere through your domain.
193258- 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.
259+- On the page, docket images are used only if they are `http(s)` or
260+ `data:image/…` URLs, and website links only if they are `http(s)`.
261+ Dockets can come from other ships.
196262
197263## Traffic
198264
@@ -223,6 +289,9 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
223289 Landscape. This removes the tile and suspends the agent; a plain
224290 `|uninstall %shelf` would leave a suspended tile behind. Your `head`/`body`
225291 HTML is kept, and `|install our %shelf` brings the page straight back.
292+ Uninstalling a %shelf installed from ~donmec-sallud also stops its updates.
293+ To turn them back on, run `|install ~donmec-sallud %shelf` after
294+ `|install our %shelf`.
2262953. To delete that state too: `|nuke %shelf, =desk &` (answer `y`).
2272964. Optionally, free the path. Only the binding's owner can remove it, so take
228297 it over from the dojo, then remove it:
@@ -235,22 +304,116 @@ after %shelf stops. Until you remove it, `/shelf` answers 503 at once.
235304 Installing %shelf again binds `/shelf` again. %shelf re-binds every time
236305 it loads, so it also restores the binding if someone removed it by hand.
237306
307+## Changes from the design handoffs
308+
309+%shelf follows two design handoffs: the page itself (layout, copy, behaviour,
310+JSON, a skeleton agent) and a later icon update. Rendered with the mock's sample
311+data, the page matches the design mock pixel for pixel in light and dark mode,
312+apart from the icons and names. Everything below differs from the handoffs,
313+and why.
314+
315+**Serving**
316+- **One path, `/shelf`, instead of `/`.** On most ships Landscape's `%docket`
317+ owns `/`, and Eyre doesn't refuse a second claim: it silently hands `/`
318+ over, which would take away the owner's login page. A dedicated domain
319+ mapped onto `/shelf` gives visitors a clean root URL instead.
320+- **One URL for everything.** Because only `/` reaches the ship through that
321+ domain, the CSS and JavaScript are inlined (not `/shelf.css`, `/copy.js`),
322+ the icons are at `/?asset=…` (not `/favicon.svg`, `/shelf-icon.svg`), the
323+ install form posts to `/` (not `/install`), and Change ship is `/?forget`
324+ (not `/forget`). Other paths are the proxy's job; the ship answers them
325+ with 404.
326+- **JSON by `Accept` header at the same URL, not `/apps.json`, and
327+ `private`.** The single URL rules out `/apps.json`, and `public` caching
328+ could hand an owner's login cookie to a shared cache.
329+- **HTML varies on `Cookie` as well as `Accept`.** Otherwise a browser shows
330+ its cached copy for up to a minute after Change ship or a save.
331+- **Bodies go out in 16 KiB pieces**, because a single large response gets
332+ quadratically slower on its way out of the ship.
333+
334+**Installing**
335+- **The install form is a POST, accepted only from the page itself, and only
336+ for apps on the shelf.** A GET could be triggered by a link or by another
337+ publisher's docket image, planting a phishing URL in visitors' cookies.
338+- **Deep link:** `<ship>/apps/landscape/search/direct/apps/~pub/desk`,
339+ Landscape's own "show install" route, tested end to end. The handoff's
340+ `…/leap/search/…` also works, but only through a redirect.
341+- **Saved ship URLs keep only scheme, host and port**, so a pasted
342+ `…/apps/groups` doesn't break the deep link. `localhost` and `127.*`
343+ default to `http://`, and ports must be 1–65535.
344+- **The cookie holds the URL as is (not URL-encoded), `HttpOnly`, and gets
345+ `Secure` when the visitor came over HTTPS**, as `Forwarded` or
346+ `X-Forwarded-Proto` says behind a proxy. The normalized URL only contains
347+ cookie-safe characters.
348+- **"Install on ~ship" needs a planet or longer name, or a known ship host.**
349+ The handoff's rule (any first DNS label that parses as an `@p`) turned
350+ `dev.example.com` into "Install on ~dev".
351+- **Returning visitors see "Installs open on ~ship. Change ship"** in the
352+ masthead, a muted link that clears the cookie. The handoff left this open.
353+
354+**Reading the alliance**
355+- **Own desks' treaties are built from Clay**, from each desk's
356+ `desk.docket-0`, as `%treaty` does. `%treaty`'s `/treaty/<ship>/<desk>`
357+ scry only covers other ships' desks and crashes for your own.
358+- **Only scries that can't fail.** The handoff wrapped scries in `mule`, but
359+ inside an agent `mule` can't catch a failed scry: the request just fails.
360+- **Visitors arrive under an Eyre guest identity**, so the owner check
361+ applies only to the settings pokes. Checking every request would 500 for
362+ all visitors.
363+- **Docket text from other ships is handled defensively:** images only
364+ `http(s)` or `data:image/…`, links only `http(s)`, invalid UTF-8 replaced
365+ in the JSON, and a title that starts with a control character still
366+ renders.
367+
368+**Page**
369+- **Header and rows share one grid (CSS subgrid)**, so columns stay aligned
370+ when Install labels get wider for a saved ship.
371+- **Narrow screens:** with a saved ship the stacked layout starts at 1040px
372+ instead of 860px, and the install button's text wraps; the URL field gets
373+ its own line; long words wrap instead of overflowing.
374+- **Keyboard focus inside a row tints it**, as the handoff's interaction table
375+ says (its CSS only did this on hover).
376+- **"1 app"**, not "1 apps".
377+- **Republished apps say "from ~publisher"**, not "by ~publisher".
378+- **The sigil is drawn in Hoon** by a vendored renderer, in `currentColor` on
379+ `var(--ink)` so it inverts with the theme, at sigil-js's 56px geometry.
380+ Moons and comets get the plain square, as in Landscape.
381+- **New: owner-set raw HTML in `<head>` and `<body>`**, for analytics.
382+
383+**Icons and docket** (the icon update)
384+- **The docket's `image` is the icon inline, as a URL-encoded `data:` URL**,
385+ instead of an absolute URL on the ship, which would need filling in per
386+ ship and would break wherever that ship can't be reached.
387+- **The tile opens `/shelf`** (`site+/shelf`, not `site+/`), and the docket's
388+ website is this repo.
389+- **The docket's description drops "with one-click install"**: first-time
390+ visitors enter their ship's URL before the Install button takes them there.
391+- **`mar/svg` is the upstream mark from `%base`**, not the handoff's new one.
392+- **Icons are served `private`** for the same login-cookie reason as the
393+ JSON.
394+
395+**Other**
396+- **Kernel `[%zuse 408]`**, the current release, replacing the handoff's
397+ placeholder 409.
398+
238399## Development
239400
240401The desk is self-contained. `desk/` holds everything Clay needs:
241402
242403| Path | What |
243404|---|---|
244−| `desk.docket-0` | What Landscape shows: title, description, color, version, website, license |
405+| `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 |
245406| `app/shelf.hoon` | The agent: Eyre binding, routing, scries, responses |
246407| `lib/shelf.hoon` | Pure helpers and the page (Sail) |
247408| `sur/shelf.hoon`, `mar/shelf/action.hoon`, `gen/shelf/` | The `head`/`body` settings |
248409| `web/shelf.css`, `web/copy.js` | Inlined into the page at build time with `/*` |
410+| `web/shelf-icon.svg`, `web/favicon.svg` | The app icon (also inlined in the docket) and the favicon, served at `?asset=` |
249411| `tests/lib/shelf.hoon` | Unit tests, including the rendered page |
250412| `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`) |
413+| 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`) |
252414
253−`flake.nix` makes `nix build` produce the desk as `result`.
415+Outside the desk, `flake.nix` and `flake.lock` define the Nix build (see
416+[Building with Nix](#building-with-nix)).
254417
255418Run the tests on a ship with the desk installed:
256419
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+18−5
@@ -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
@@ -625,7 +638,7 @@
625638 ;div.app-name
626639 ;strong: {(trip title.d)}
627640 ;* ?: =(ship.t our) ~
628− :~ ;span.app-by: by {pub}
641+ :~ ;span.app-by: from {pub}
629642 ==
630643 ==
631644 ;div.app-desc
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+31−2
@@ -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::
@@ -60,7 +70,7 @@
6070 =/ doc (render ~zod ~[harbor marginalia oddity] ~ | ~)
6171 ;: weld
6272 (expect !>((has doc "<span class=\"count\">3 apps</span>")))
63− (expect !>(=(1 (count doc "by ~bus"))))
73+ (expect !>(=(1 (count doc "from ~bus"))))
6474 (expect !>((has doc "<section class=\"apps\" aria-label=\"Published apps\">")))
6575 (expect !>((has doc "&lt;Odd &amp; &quot;Quoted&quot;&gt; App")))
6676 (expect !>((has doc "a &lt; b &amp; c &gt; &#39;d&#39;")))
@@ -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>
flake.nix+10−5
@@ -10,10 +10,15 @@
1010 in
1111 {
1212 # The desk: copy it into a mounted %shelf desk and |commit it.
13− packages = forAllSystems (pkgs: {
14− default = pkgs.runCommandLocal "shelf-desk" { } ''
15− cp -r ${./desk} $out
16− '';
17− });
13+ packages = forAllSystems (pkgs:
14+ let
15+ desk = pkgs.runCommandLocal "shelf-desk" { } ''
16+ cp -r ${./desk} $out
17+ '';
18+ in
19+ {
20+ inherit desk;
21+ default = desk;
22+ });
1823 };
1924}