- commit
- 4681ae6188416706206bdca752bbae56e016305d
- parent
- 13c1ad60e0b34b2f41850aa23b942c2a20eab456
- author
- Dami <dami.etoile@gmail.com>
| A | cerium/ | 43 | ++++++++ |
| A | cerium/ | 38 | +++++++ |
| A | demo/ | 63 | ++++++++++++ |
| A | demo/ | 67 | +++++++++++++ |
| A | europium/ | 58 | +++++++++++ |
| A | europium/ | 52 | ++++++++++ |
| A | praseodymium/ | 62 | ++++++++++++ |
| A | praseodymium/ | 64 | ++++++++++++ |
| A | test/ | 101 | ++++++++++++++++++++ |
| A | test/ | 154 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,43 @@
1+# 🎨 cerium.js - one stylesheet2+3+cerium.js is the single stylesheet for nobel's visual presets. It adds one `<style>` to the page with quiet defaults for cards, chips, avatars and notes, all inside `@layer nobel` and `:where()`, so any rule of yours wins without `!important`. Presets never style themselves, so turning cerium off leaves plain, semantic HTML.4+5+```html6+<script src="cerium.js"></script>7+<style>:root { --n-accent:#c2410c; --n-radius:4px; }</style>8+```9+10+That themes every preset on the page through custom properties, in light and dark.11+12+## Custom properties13+14+Set them on `:root` or any ancestor. Dark values apply under `prefers-color-scheme: dark`.15+16+| property | light | dark | used for |17+|---|---|---|---|18+| `--n-fg` | `#1c1c1f` | `#ececf1` | text |19+| `--n-bg` | `#fff` | `#17171a` | card background |20+| `--n-muted` | `#6d6d75` | `#9a9aa3` | NIP-05, times, copy buttons |21+| `--n-accent` | `#5b4fc4` | `#a99cff` | links, verified mark, copied state |22+| `--n-line` | `#e2e2e7` | `#2e2e34` | hairline borders, avatar placeholder |23+| `--n-radius` | `.75rem` | | card corners |24+| `--n-avatar` | `3rem` | | avatar size (chips use `1.4em`) |25+| `--n-font` | unset | | font family (the page's font when unset) |26+27+## Parts28+29+Every lanthanide marks what it generates with a part attribute in its own prefix, `[n??-part=name]`, and cerium is the one place that styles them. New lanthanides add their rules here, as `:where()` selectors inside the layer.30+31+| library | host | parts |32+|---|---|---|33+| praseodymium | `[npr-card]`, `[npr-chip]`, `img[npr-avatar]` | `avatar`, `name`, `nip05`, `about`, `copy`, plus `[npr-copied]` and radon's `[nrn-verified]` |34+| europium | `[neu-note]` | `author`, `avatar`, `name`, `time`, `content`, `link` |35+36+## Opting out37+38+| option | description |39+|---|---|40+| `nce-bare` on `<html>` | skip the stylesheet |41+| `window.nceCfg = {bare:true}` | same, from script |42+| `cerium.css` | the CSS text, to copy into your own stylesheet |43+| `cerium.style` | the injected `<style nce-style>`, or `null` |@@ -0,0 +1,38 @@
1+(()=>{2+ let g = window, d = document3+ if (g.cerium) return4+ // Zero-specificity rules in a layer: any page rule wins. Lanthanides style their [n??-part] hooks here.5+ let css = `@layer nobel {6+:where(:root) { --n-fg:#1c1c1f; --n-bg:#fff; --n-muted:#6d6d75; --n-accent:#5b4fc4; --n-line:#e2e2e7; --n-radius:.75rem; --n-avatar:3rem; }7+@media (prefers-color-scheme:dark) { :where(:root) { --n-fg:#ececf1; --n-bg:#17171a; --n-muted:#9a9aa3; --n-accent:#a99cff; --n-line:#2e2e34; } }8+:where([npr-card], [neu-note]) { font-family:var(--n-font); color:var(--n-fg); overflow-wrap:anywhere; }9+:where([npr-card]) { display:grid; grid-template-columns:var(--n-avatar) 1fr; gap:.15rem .75rem; align-items:center; max-width:32rem; padding:1rem; background:var(--n-bg); border:1px solid var(--n-line); border-radius:var(--n-radius); }10+:where([npr-card]:has(> [npr-part=nip05]:not(:empty)) > [npr-part=avatar]) { grid-row:span 2; }11+:where([npr-card] > [npr-part=about], [npr-card] > [npr-part=copy]) { grid-column:1 / -1; }12+:where(img[npr-avatar], [npr-part=avatar], [neu-part=avatar]) { width:var(--n-avatar); height:var(--n-avatar); border-radius:50%; object-fit:cover; background:var(--n-line); }13+:where([npr-chip]) { display:inline-flex; align-items:center; gap:.35em; vertical-align:middle; }14+:where([npr-chip] > [npr-part=avatar]) { width:1.4em; height:1.4em; }15+:where([npr-part=name], [neu-part=name]) { font-weight:600; }16+:where([npr-part=nip05], [npr-part=copy], [neu-part=time]) { color:var(--n-muted); font-size:.85em; }17+:where([npr-part=nip05][nrn-verified])::after { content:" ✓"; color:var(--n-accent); }18+:where([npr-part=nip05], [npr-part=about]):empty { display:none; }19+:where([npr-part=about]) { margin:.5rem 0 0; white-space:pre-line; }20+:where([npr-part=copy]) { justify-self:start; margin-top:.5rem; padding:.2em .7em; font:inherit; font-size:.8em; color:var(--n-muted); background:none; border:1px solid var(--n-line); border-radius:999px; }21+:where([npr-copy]) { cursor:copy; }22+:where([npr-copied]) { color:var(--n-accent); border-color:var(--n-accent); }23+:where([neu-note]) { display:block; padding:.75rem 0; border-bottom:1px solid var(--n-line); }24+:where([neu-part=author]) { display:flex; align-items:center; gap:.5rem; }25+:where([neu-part=author] [neu-part=avatar]) { width:calc(var(--n-avatar) * .75); height:calc(var(--n-avatar) * .75); }26+:where([neu-part=content]) { margin:.4rem 0 0; white-space:pre-wrap; }27+:where([neu-part=content] img) { display:block; max-width:100%; border-radius:var(--n-radius); }28+:where([neu-part=link], [neu-part=content] a) { color:var(--n-accent); }29+}`30+ let style = null31+ if (!d.documentElement.hasAttribute("nce-bare") && !g.nceCfg?.bare){32+ style = d.createElement("style")33+ style.setAttribute("nce-style", "")34+ style.textContent = css35+ ;(d.head ?? d.documentElement).prepend(style)36+ }37+ g.cerium = {css, style}38+})()@@ -0,0 +1,63 @@
1+2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1">6+<title>europium demo</title>7+<script src="relay.js"></script>8+<script src="../neon/neon.js"></script>9+<script src="../krypton/krypton.js"></script>10+<script src="../argon/argon.js"></script>11+<script src="../hydrogen/hydrogen.js"></script>12+<script src="../xenon/xenon.js"></script>13+<script src="../radon/radon.js"></script>14+<script src="../europium/europium.js"></script>15+<style>16+ /* Demo page chrome only: note styling belongs to cerium.css. */17+ :root { --ink:#1d1b16; --paper:#f4efe4; --line:#d8cfbd; --gas:#b91c1c; --muted:#7a7263; }18+ body { margin:0; background:var(--paper); color:var(--ink); font:15px/1.5 ui-serif, Georgia, serif; }19+ main { max-width:640px; margin:0 auto; padding:40px 20px 80px; }20+ h1 { font:600 13px/1 ui-monospace, Menlo, monospace; letter-spacing:.12em; text-transform:uppercase; margin:0 0 4px; }21+ h1 b { color:var(--gas); }22+ h2 { font:12px ui-monospace, Menlo, monospace; color:var(--muted); text-transform:uppercase; letter-spacing:.08em; margin:28px 0 8px; }23+ .sub { color:var(--muted); margin:0 0 12px; font-style:italic; }24+ article { border:1px solid var(--line); border-radius:8px; padding:12px 14px; background:#fffdf8; }25+ article img[neu-part=avatar] { width:24px; height:24px; border-radius:50%; vertical-align:middle; }26+ article [neu-part=content] { margin:6px 0; white-space:pre-wrap; overflow-wrap:anywhere; }27+ article a { color:var(--gas); }28+ [neu-loading], [neu-missing] { color:var(--muted); font-style:italic; }29+</style>30+</head>31+<body>32+<main>33+ <h1><b>europium</b> demo</h1>34+ <p class="sub">Note embeds: each article below is only <code><article neu-note="..."></code>. The page publishes a sample profile and notes first.</p>35+ <h2>nevent (default markup)</h2><div id="a"></div>36+ <h2>note1</h2><div id="b"></div>37+ <h2>hex id</h2><div id="c"></div>38+ <h2>a hand-written template, on a reply</h2><div id="d"></div>39+ <h2>missing</h2><div id="e"></div>40+</main>41+<script>42+ (async()=>{43+ let sk = krypton.key(), pk = krypton.pub(sk), now = Date.now() / 1e3 | 044+ let sign = (kind, content, tags = [])=>krypton.finish({kind, created_at:now, tags, content}, sk)45+ let profile = await sign(0, JSON.stringify({name:"Ada"}))46+ let note = await sign(1, "hello <b>europium</b> https://example.com/x")47+ let reply = await sign(1, "a reply", [["e", note.id, relay, "root"], ["p", pk]])48+ for (let e of [profile, note, reply]) await hydrogen.pub(e, [relay])49+ let nevent = neon.encode("nevent", {id:note.id, relays:[relay], author:pk, kind:1})50+ window.demo = {note, reply, nevent, pk}51+ a.innerHTML = `<article neu-note="${nevent}">loading...</article>`52+ b.innerHTML = `<article neu-note="${neon.encode("note", note.id)}"></article>`53+ c.innerHTML = `<article neu-note="${note.id}"></article>`54+ d.innerHTML = `<article neu-note="${reply.id}"><template>55+ <p neu-part="content">{content}</p>56+ <a href="https://njump.me/{reply}" neu-part="link">in reply to</a>57+ </template></article>`58+ e.innerHTML = `<article neu-note="${"0".repeat(64)}" neu-timeout="1500">loading...</article>`59+ })()60+ document.addEventListener("neu:missing", (evt)=>evt.target.textContent = "note not found")61+</script>62+</body>63+</html>@@ -0,0 +1,67 @@
1+2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1">6+<title>nobel lanthanides</title>7+<script src="relay.js"></script>8+<script src="../neon/neon.js"></script>9+<script src="../krypton/krypton.js"></script>10+<script src="../argon/argon.js"></script>11+<script src="../hydrogen/hydrogen.js"></script>12+<script src="../xenon/xenon.js"></script>13+<script src="../radon/radon.js"></script>14+<script src="../praseodymium/praseodymium.js"></script>15+<script src="../cerium/cerium.js"></script>16+<style>17+ :root { color-scheme:light dark; }18+ body { margin:0; font:15px/1.5 system-ui, sans-serif; background:var(--n-bg); color:var(--n-fg); }19+ main { max-width:640px; margin:0 auto; padding:40px 20px 80px; }20+ h2 { font-size:13px; letter-spacing:.1em; text-transform:uppercase; color:var(--n-muted); margin:32px 0 12px; }21+ .row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }22+ pre { font-size:12px; white-space:pre-wrap; overflow-wrap:anywhere; background:color-mix(in srgb, var(--n-line) 40%, transparent); padding:12px; border-radius:8px; }23+ /* A page rule beats cerium's layered default. */24+ #themed { --n-accent:rgb(200, 30, 60); }25+</style>26+</head>27+<body>28+<script>relay && document.body.setAttribute("nhy-relay", relay)</script>29+<main>30+ <h1>lanthanides</h1>31+ <form id="profile" class="row" nxe-publish nxe-kind="0">32+ <input name="name" placeholder="name" value="Ada">33+ <input name="about" placeholder="about" value="Counting engines.">34+ <input name="picture" placeholder="picture URL">35+ <button>Publish kind 0</button>36+ </form>37+38+ <section id="praseodymium">39+ <h2>praseodymium: cards, chips, avatars, copy</h2>40+ <article id="card" npr-card=""></article>41+ <h2>hand-written card (left alone)</h2>42+ <article id="hand" npr-card=""><b nrn-field="name">my own markup</b></article>43+ <h2>chip and avatar</h2>44+ <p>Mentioned: <span id="chip" npr-chip=""></span></p>45+ <div class="row"><img id="avatar" npr-avatar="" alt=""> <button id="copy" npr-copy="hello nobel">copy "hello nobel"</button></div>46+ <h2>markup("card", pk)</h2>47+ <pre id="markup"></pre>48+ </section>49+50+ <section id="cerium">51+ <h2>cerium: page CSS overrides the theme</h2>52+ <p id="themed"><button npr-copy="themed" npr-copied>copied state in a custom accent</button></p>53+ </section>54+</main>55+<script>56+ // One local key (krypton's); its kind 0 comes from the form. Every preset points at it.57+ let pk = krypton.pub(krypton.key())58+ for (let a of ["npr-card", "npr-chip", "npr-avatar"]) document.querySelectorAll(`[${a}=""]`).forEach((el)=>el.setAttribute(a, neon.encode("npub", pk)))59+ hand.setAttribute("nrn-profile", pk)60+ markup.textContent = praseodymium.markup("card", pk)61+ // Profiles are kind 0 events whose content is JSON, so build it from the form's filled fields.62+ profile.addEventListener("nhy:config", (evt)=>{63+ evt.detail.cfg.event.content = JSON.stringify(Object.fromEntries([...new FormData(profile)].filter(([, v])=>v)))64+ })65+</script>66+</body>67+</html>@@ -0,0 +1,58 @@
1+# 🔴 europium.js - note embeds2+3+europium.js is a miniature of fiatjaf's nostr-note web component: one attribute embeds a note, with its author, time, linked content and a link out. It does no fetching or rendering itself. It writes the xenon, radon and neon markup you could have written by hand, and those libraries do the work (argon caches it, too).4+5+```html6+<article neu-note="nevent1...">loading...</article>7+```8+9+The article subscribes to the note's id (on its relay hints, when the nevent has some), shows `loading...` until it arrives, then becomes the markup below.10+11+## Generated markup12+13+europium sets `nxe-filter='{"ids":["<id>"]}'` and `nxe-swap="innerHTML"` (unless already present), and `nhy-relay` to the nevent's relay hints plus `nhyCfg.relays` when neither the article nor an ancestor has one. If the article has no child elements, it appends this template (text is kept as a placeholder), then dispatches `nxe:process`:14+15+```html16+<template><header neu-part="author"><img nrn-profile="{pubkey}" nrn-field="picture" neu-part="avatar" alt=""> <b nrn-profile="{pubkey}" nrn-field="name" neu-part="name">{npub}</b> <time nne-ago="{created_at}" neu-part="time">{ago}</time></header><div neu-part="content">{content}</div><a href="https://njump.me/{nevent}" neu-part="link">open</a></template>17+```18+19+If the article already has child elements, such as your own `<template>`, europium leaves the markup alone and only wires the subscription:20+21+```html22+<article neu-note="note1..."><template>23+ <p neu-part="content">{content}</p>24+ <a href="https://njump.me/{reply}" neu-part="link">in reply to</a>25+</template></article>26+```27+28+## Attributes29+30+| attribute | description |31+|---|---|32+| `neu-note` | `nevent`, `note` (both need neon.js) or hex event id |33+| `neu-timeout` | ms before the note counts as missing (default `neuCfg.timeout` or 8000) |34+| `neu-loading` | Set by europium until the first render |35+| `neu-missing` | Set by europium when nothing arrived in time. Cleared if the note shows up later |36+| `neu-part` | On generated pieces: `author`, `avatar`, `name`, `time`, `content`, `link`, for styling |37+| `neu-ignore` | europium skips this element and its descendants |38+39+`window.neuCfg = {link, timeout}`: `link` is the prefix for the note link (default `nneCfg.link`, else `https://njump.me/`).40+41+Templates inside `[neu-note]` also get `{reply}`: the `nevent` of the note's NIP-10 parent (`reply` marker, else `root`, else the last unmarked `e` tag), or empty.42+43+## Events44+45+| event | cancel to | detail |46+|---|---|---|47+| `neu:missing` | skip setting `neu-missing` | `id` |48+49+Rendering reports through xenon's `nxe:swapped` on the article.50+51+## API52+53+| function | returns |54+|---|---|55+| `europium.markup(link?)` | the default template string |56+| `europium.process(node)` | initializes `[neu-note]` elements in and under `node` (new ones are picked up automatically) |57+58+Requires xenon.js and hydrogen.js. neon.js decodes ids and fills `{content}`, `{npub}`, `{nevent}`, `{ago}`; radon.js fills names and pictures; argon.js caches.@@ -0,0 +1,52 @@
1+(()=>{2+ let g = window, d = document3+ if (g.europium) return4+ let send = (el, type, detail)=>el.dispatchEvent(new CustomEvent("neu:" + type, {detail, cancelable:1, bubbles:1, composed:1}))5+ let markup = (link = g.neuCfg?.link ?? g.nneCfg?.link ?? "https://njump.me/")=>`<header neu-part="author"><img nrn-profile="{pubkey}" nrn-field="picture" neu-part="avatar" alt=""> <b nrn-profile="{pubkey}" nrn-field="name" neu-part="name">{npub}</b> <time nne-ago="{created_at}" neu-part="time">{ago}</time></header><div neu-part="content">{content}</div><a href="${link}{nevent}" neu-part="link">open</a>`6+ let decode = (s = "")=>{7+ if (/^[0-9a-f]{64}$/.test(s)) return {id:s}8+ try {9+ let {type, data} = neon.decode(s)10+ return type == "note" ? {id:data} : type == "nevent" && data11+ } catch {}12+ }13+ let init = (el)=>{14+ let n = decode(el.getAttribute("neu-note").trim()), set = (k, v)=>el.hasAttribute(k) || el.setAttribute(k, v), t15+ if (el.__neu || !n || el.closest("[neu-ignore]")) return16+ el.__neu = 117+ set("nxe-filter", JSON.stringify({ids:[n.id]}))18+ set("nxe-swap", "innerHTML")19+ if (n.relays?.length && !el.closest("[nhy-relay]")) el.setAttribute("nhy-relay", [...new Set([...n.relays, ...g.nhyCfg?.relays ?? []])].join(" "))20+ if (!el.children.length) el.append(Object.assign(d.createElement("template"), {innerHTML:markup()}))21+ el.setAttribute("neu-loading", "")22+ let miss = ()=>{23+ el.removeAttribute("neu-loading")24+ if (send(el, "missing", {id:n.id})) el.setAttribute("neu-missing", "")25+ }26+ t = setTimeout(miss, +el.getAttribute("neu-timeout") || g.neuCfg?.timeout || 8e3)27+ el.addEventListener("nxe:swapped", (evt)=>{28+ if (evt.target != el) return29+ clearTimeout(t)30+ el.removeAttribute("neu-loading")31+ el.removeAttribute("neu-missing")32+ })33+ el.dispatchEvent(new CustomEvent("nxe:process", {bubbles:1}))34+ }35+ let process = (n)=>{36+ if (n.matches?.("[neu-note]")) init(n)37+ n.querySelectorAll?.("[neu-note]").forEach(init)38+ }39+ // NIP-10: the parent is the "reply" marker, else "root", else the last unmarked e tag.40+ d.addEventListener("nhy:event", (evt)=>{41+ if (!evt.target.matches?.("[neu-note]")) return42+ let e = evt.detail.event.tags.filter((t)=>t[0] == "e" && /^[0-9a-f]{64}$/.test(t[1]))43+ let p = e.find((t)=>t[3] == "reply") ?? e.find((t)=>t[3] == "root") ?? e.findLast((t)=>!t[3])44+ evt.detail.fields.reply = p && g.neon ? neon.encode("nevent", {id:p[1], relays:p[2] ? [p[2]] : []}) : ""45+ })46+ g.europium = {markup, process}47+ let boot = ()=>{48+ new MutationObserver((recs)=>recs.forEach((r)=>r.addedNodes.forEach(process))).observe(d.documentElement, {childList:1, subtree:1})49+ process(d.body)50+ }51+ d.readyState == "loading" ? d.addEventListener("DOMContentLoaded", boot) : boot()52+})()@@ -0,0 +1,62 @@
1+# 🪞 praseodymium.js - profiles you can see2+3+praseodymium.js is a set of profile presets: a card, a chip and an avatar. Each preset only writes the markup you could have written yourself from radon and neon attributes, then lets those libraries fill it in. If the element already has children, nothing is generated and you keep your own markup.4+5+```html6+<article npr-card="npub1..."></article>7+<p>Thanks to <span npr-chip="npub1..."></span></p>8+<img npr-avatar="npub1..." alt="">9+<button npr-copy="npub1...">copy</button>10+```11+12+The card and chip get the markup below, radon fills the name, picture, NIP-05 and about, and the button copies its text to the clipboard.13+14+## Generated markup15+16+`PK` is the hex pubkey and `NPUB` its npub (hex without neon.js). `SHORT` is the npub cut to `npub1abcd…wxyz`, shown until radon has a name. Every piece carries `npr-part`, the styling hook cerium.js uses.17+18+`npr-card`:19+20+```html21+<img npr-avatar="PK" npr-part="avatar" alt=""><b nrn-profile="PK" nrn-field="name" npr-part="name">SHORT</b><span nrn-profile="PK" nrn-field="nip05" npr-part="nip05"></span><p nrn-profile="PK" nrn-field="about" npr-part="about"></p><button npr-copy="NPUB" npr-part="copy">SHORT</button>22+```23+24+`npr-chip`:25+26+```html27+<img npr-avatar="PK" npr-part="avatar" alt=""><b nrn-profile="PK" nrn-field="name" npr-part="name">SHORT</b>28+```29+30+`npr-avatar` generates nothing. It adds `nrn-profile="PK"`, `nrn-field="picture"` (unless already set) and, if there is no `src` yet, an identicon `src`: a 5x5 mirrored SVG data URI colored from the pubkey. A picture that fails to load falls back to the identicon.31+32+## Attributes33+34+| attribute | description |35+|---|---|36+| `npr-card` | Pubkey (hex, or `npub`/`nprofile` with neon.js). Empty element: fills it with the card markup. Changing the pubkey regenerates it |37+| `npr-chip` | Pubkey. Empty element: fills it with the chip markup (inline avatar and name) |38+| `npr-avatar` | Pubkey, on an `<img>`. Adds radon's attributes and the identicon fallback |39+| `npr-copy` | Text to copy on click. Works on any element |40+| `npr-copied` | Set by praseodymium for 1.5s after a copy |41+| `npr-part` | Names each generated piece: `avatar`, `name`, `nip05`, `about`, `copy` |42+| `npr-ignore` | praseodymium skips this element and its descendants |43+44+Elements with children (a `<template>` counts) are left as written. `window.nprCfg = {copied}` sets how long `npr-copied` stays, in ms.45+46+## Events47+48+| event | cancel to | detail |49+|---|---|---|50+| `npr:render` | keep the element as it is | `kind`, `pubkey`, `html` (change it to insert something else) |51+| `npr:copy` | skip copying | `text` (can be changed) |52+| `npr:copied` | | `text` |53+54+## API55+56+| function | returns |57+|---|---|58+| `praseodymium.markup(kind, pubkey)` | the HTML string for `"card"` or `"chip"`, to copy into a page |59+| `praseodymium.process(el)` | initializes presets on and under `el` (new elements are picked up on their own) |60+| `praseodymium.face(hex)` | the identicon data URI |61+62+Requires radon.js to fill profiles. Style it with cerium.js, or with your own rules on `[npr-part]`.@@ -0,0 +1,64 @@
1+(()=>{2+ let g = window, d = document3+ if (g.praseodymium) return4+ let send = (el, type, detail)=>el.dispatchEvent(new CustomEvent("npr:" + type, {detail, cancelable:1, bubbles:1, composed:1}))5+ let hexOf = (s)=>{6+ if (/^[0-9a-f]{64}$/.test(s)) return s7+ try { let x = neon.decode(s).data; return x.pubkey ?? x } catch {}8+ }9+ let npub = (pk)=>g.neon ? neon.encode("npub", pk) : pk10+ let short = (pk)=>(pk = npub(pk)).slice(0, 10) + "…" + pk.slice(-4)11+ // A 5x5 mirrored identicon, colored by the pubkey.12+ let face = (pk)=>{13+ let h = parseInt(pk.slice(0, 4), 16) % 360, p = ""14+ for (let i = 0; i < 15; i++) if (parseInt(pk[i + 4], 16) & 1) p += `M${i % 3} ${i / 3 | 0}h1v1h-1zM${4 - i % 3} ${i / 3 | 0}h1v1h-1z`15+ return "data:image/svg+xml," + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5"><rect width="5" height="5" fill="hsl(${h},40%,88%)"/><path fill="hsl(${h},45%,45%)" d="${p}"/></svg>`)16+ }17+ let f = (pk, field, tag, text = "")=>`<${tag} nrn-profile="${pk}" nrn-field="${field}" npr-part="${field}">${text}</${tag}>`18+ let kinds = {19+ card:(pk)=>`<img npr-avatar="${pk}" npr-part="avatar" alt="">${f(pk, "name", "b", short(pk))}${f(pk, "nip05", "span")}${f(pk, "about", "p")}<button npr-copy="${npub(pk)}" npr-part="copy">${short(pk)}</button>`,20+ chip:(pk)=>`<img npr-avatar="${pk}" npr-part="avatar" alt="">${f(pk, "name", "b", short(pk))}`21+ }22+ let markup = (kind, pk)=>(pk = hexOf(pk)) && kinds[kind]?.(pk)23+ let init = (el, kind)=>{24+ let pk = hexOf(el.getAttribute("npr-" + kind))25+ if (!pk || el.__npr == pk || el.closest("[npr-ignore]")) return26+ if (kind == "avatar"){27+ el.setAttribute("nrn-field", el.getAttribute("nrn-field") || "picture")28+ el.setAttribute("nrn-profile", pk)29+ if (!el.getAttribute("src") || el.__npr) el.src = face(pk)30+ } else {31+ let detail = {kind, pubkey:pk, html:kinds[kind](pk)}32+ if (el.firstElementChild && !el.__npr || !send(el, "render", detail)) return33+ el.innerHTML = detail.html34+ }35+ el.__npr = pk36+ }37+ let process = (n)=>{38+ for (let k of ["card", "chip", "avatar"]){39+ if (n.matches?.(`[npr-${k}]`)) init(n, k)40+ n.querySelectorAll?.(`[npr-${k}]`).forEach((e)=>init(e, k))41+ }42+ }43+ d.addEventListener("click", (evt)=>{44+ let el = evt.target.closest?.("[npr-copy]"), detail = {text:el?.getAttribute("npr-copy")}45+ if (el && send(el, "copy", detail)) navigator.clipboard.writeText(detail.text).then(()=>{46+ el.setAttribute("npr-copied", "")47+ clearTimeout(el.__nprT)48+ el.__nprT = setTimeout(()=>el.removeAttribute("npr-copied"), g.nprCfg?.copied ?? 1500)49+ send(el, "copied", detail)50+ }, ()=>{})51+ })52+ // A picture that fails to load falls back to the identicon.53+ d.addEventListener("error", (evt)=>{54+ let el = evt.target, pk = el.matches?.("img[npr-avatar]") && hexOf(el.getAttribute("npr-avatar")), fb = pk && face(pk)55+ if (fb && el.src != fb) el.src = fb56+ }, 1)57+ g.praseodymium = {markup, process, face}58+ let mo = new MutationObserver((recs)=>recs.forEach((r)=>r.type == "attributes" ? process(r.target) : r.addedNodes.forEach(process)))59+ let start = ()=>{60+ mo.observe(d.documentElement, {childList:1, subtree:1, attributes:1, attributeFilter:["npr-card", "npr-chip", "npr-avatar"]})61+ process(d.body)62+ }63+ d.readyState == "loading" ? d.addEventListener("DOMContentLoaded", start) : start()64+})()@@ -0,0 +1,101 @@
1+// Drives demo/europium.html in headless Chromium against the dev server's in-memory relay.2+import { assert, assertEquals } from "jsr:@std/assert@1";3+import { chromium } from "npm:playwright@1.58";4+import { serve } from "../server.ts";5+6+// Use CHROMIUM if set, else the newest headless shell already in the Playwright cache.7+async function chromiumPath() {8+ if (Deno.env.get("CHROMIUM")) return Deno.env.get("CHROMIUM");9+ const cache = `${Deno.env.get("HOME")}/Library/Caches/ms-playwright`;10+ try {11+ const dirs = [...Deno.readDirSync(cache)].map((d) => d.name).filter((n) => n.startsWith("chromium_headless_shell-")).sort();12+ const dir = `${cache}/${dirs.at(-1)}`;13+ const arch = [...Deno.readDirSync(dir)][0].name;14+ return `${dir}/${arch}/chrome-headless-shell`;15+ } catch { return undefined; }16+}17+18+Deno.test({ name: "europium: note embeds", sanitizeResources: false, sanitizeOps: false }, async (t) => {19+ const server = serve(0);20+ const url = `http://localhost:${server.addr.port}/demo/europium.html`;21+ const browser = await chromium.launch({ executablePath: await chromiumPath() });22+ const page = await browser.newPage();23+ const errors = [];24+ page.on("pageerror", (e) => errors.push(e.message));25+ const rendered = (sel) => page.waitForFunction((s) => document.querySelector(`${s} article [neu-part=content]`), sel);26+ try {27+ await page.goto(url);28+ await page.waitForFunction(() => window.demo);29+ const demo = await page.evaluate(() => window.demo);30+31+ await t.step("nevent renders the default markup with escaped, linked content", async () => {32+ const a = page.locator("#a article");33+ assertEquals(await a.getAttribute("nxe-filter"), JSON.stringify({ ids: [demo.note.id] }));34+ assertEquals(await a.getAttribute("nhy-relay"), `ws://localhost:${server.addr.port}/relay`, "relay hints become nhy-relay");35+ await rendered("#a");36+ assertEquals(await a.locator("[neu-part=content] b").count(), 0, "content must be escaped");37+ assert((await a.locator("[neu-part=content]").textContent()).includes("<b>europium</b>"));38+ assertEquals(await a.locator("[neu-part=content] a").getAttribute("href"), "https://example.com/x");39+ assertEquals(await a.locator("[neu-part=time]").textContent(), "now");40+ const link = await a.locator("[neu-part=link]").getAttribute("href");41+ assert(link.startsWith("https://njump.me/nevent1"));42+ assertEquals(await page.evaluate((n) => neon.decode(n.slice(17)).data.id, link), demo.note.id);43+ assertEquals(await a.locator("template").count(), 0, "the swap replaces the template");44+ assertEquals(await a.getAttribute("neu-loading"), null, "loading clears after the swap");45+ for (const p of ["author", "avatar", "name", "time", "content", "link"]) assertEquals(await a.locator(`[neu-part=${p}]`).count(), 1, p);46+ });47+48+ await t.step("radon fills the author's name", async () => {49+ await page.waitForFunction(() => document.querySelector("#a [neu-part=name]").textContent === "Ada");50+ });51+52+ await t.step("note1 and hex ids work too", async () => {53+ await rendered("#b");54+ await rendered("#c");55+ for (const s of ["#b", "#c"]) assert((await page.locator(`${s} [neu-part=content]`).textContent()).includes("europium"));56+ });57+58+ await t.step("a hand-written template is used instead of the default, with {reply}", async () => {59+ await rendered("#d");60+ const d = page.locator("#d article");61+ assertEquals(await d.locator("header").count(), 0);62+ assertEquals(await d.locator("p[neu-part=content]").textContent(), "a reply");63+ const href = await d.locator("[neu-part=link]").getAttribute("href");64+ assertEquals(await page.evaluate((h) => neon.decode(h.slice(17)).data.id, href), demo.note.id);65+ });66+67+ await t.step("a missing note sets neu-missing after neu-timeout", async () => {68+ const e = page.locator("#e article");69+ assertEquals(await e.getAttribute("neu-loading"), "");70+ await page.waitForFunction(() => document.querySelector("#e article").hasAttribute("neu-missing"), null, { timeout: 4000 });71+ assertEquals(await e.getAttribute("neu-loading"), null);72+ assertEquals(await e.textContent(), "note not found");73+ });74+75+ await t.step("europium.markup() and process() are exposed", async () => {76+ assert(await page.evaluate(() => typeof europium.process === "function" && europium.markup().includes('neu-part="content">{content}')));77+ assert(await page.evaluate(() => europium.markup("https://x/").includes('href="https://x/{nevent}"')));78+ });79+80+ await t.step("reload renders from argon's cache with no relay", async () => {81+ await page.addInitScript(() => {82+ window.__from = [];83+ document.addEventListener("nhy:event", (e) => e.target.id === "cached" && window.__from.push(e.detail.from));84+ });85+ await page.reload();86+ await page.waitForFunction(() => window.demo);87+ await page.evaluate((id) => {88+ document.body.insertAdjacentHTML("beforeend", `<article id="cached" nhy-relay="ws://127.0.0.1:9/" neu-note="${neon.encode("note", id)}"></article>`);89+ }, demo.note.id);90+ await page.waitForFunction(() => document.querySelector("#cached [neu-part=content]"));91+ assert((await page.locator("#cached [neu-part=content]").textContent()).includes("europium"));92+ assertEquals(await page.evaluate(() => window.__from), ["argon"]);93+ await page.waitForFunction(() => document.querySelector("#cached [neu-part=name]").textContent === "Ada");94+ });95+96+ assertEquals(errors, []);97+ } finally {98+ await browser.close();99+ await server.shutdown();100+ }101+});@@ -0,0 +1,154 @@
1+// Drives demo/lanthanides.html (praseodymium + cerium) in headless Chromium against the dev server's relay.2+import { assert, assertEquals } from "jsr:@std/assert@1";3+import { chromium } from "npm:playwright@1.58";4+import { serve } from "../server.ts";5+6+// Use CHROMIUM if set, else the newest headless shell already in the Playwright cache.7+async function chromiumPath() {8+ if (Deno.env.get("CHROMIUM")) return Deno.env.get("CHROMIUM");9+ const cache = `${Deno.env.get("HOME")}/Library/Caches/ms-playwright`;10+ try {11+ const dirs = [...Deno.readDirSync(cache)].map((d) => d.name).filter((n) => n.startsWith("chromium_headless_shell-")).sort();12+ const dir = `${cache}/${dirs.at(-1)}`;13+ const arch = [...Deno.readDirSync(dir)][0].name;14+ return `${dir}/${arch}/chrome-headless-shell`;15+ } catch { return undefined; }16+}17+18+const PIC = "data:image/svg+xml," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1" fill="red"/></svg>');19+20+Deno.test({ name: "lanthanides: praseodymium presets, cerium styles", sanitizeResources: false, sanitizeOps: false }, async (t) => {21+ const server = serve(0);22+ const origin = `http://localhost:${server.addr.port}`;23+ const url = `${origin}/demo/lanthanides.html`;24+ const browser = await chromium.launch({ executablePath: await chromiumPath() });25+ const context = await browser.newContext({ permissions: ["clipboard-read", "clipboard-write"] });26+ const page = await context.newPage();27+ const errors = [];28+ page.on("pageerror", (e) => errors.push(e.message));29+ // Record what praseodymium inserts, before radon or avatars touch it.30+ await page.addInitScript(() => {31+ window.__rendered = {};32+ document.addEventListener("npr:render", (e) => window.__rendered[e.target.id] = e.detail.html);33+ });34+ try {35+ await page.goto(url);36+ const pk = await page.evaluate(() => pk);37+ const npub = await page.evaluate(() => neon.encode("npub", pk));38+ const short = npub.slice(0, 10) + "…" + npub.slice(-4);39+40+ await t.step("an empty npr-card gets exactly the documented markup", async () => {41+ const expected = `<img npr-avatar="${pk}" npr-part="avatar" alt="">` +42+ `<b nrn-profile="${pk}" nrn-field="name" npr-part="name">${short}</b>` +43+ `<span nrn-profile="${pk}" nrn-field="nip05" npr-part="nip05"></span>` +44+ `<p nrn-profile="${pk}" nrn-field="about" npr-part="about"></p>` +45+ `<button npr-copy="${npub}" npr-part="copy">${short}</button>`;46+ assertEquals(await page.evaluate(() => praseodymium.markup("card", pk)), expected);47+ assertEquals(await page.evaluate(() => praseodymium.markup("card", neon.encode("npub", pk))), expected, "npub in, same markup");48+ assertEquals(await page.evaluate(() => window.__rendered.card), expected, "markup() equals what was inserted");49+ assertEquals(await page.evaluate(() => praseodymium.markup("chip", pk)),50+ `<img npr-avatar="${pk}" npr-part="avatar" alt=""><b nrn-profile="${pk}" nrn-field="name" npr-part="name">${short}</b>`);51+ assertEquals(await page.evaluate(() => window.__rendered.chip), await page.evaluate(() => praseodymium.markup("chip", pk)));52+ });53+54+ await t.step("hand-writing markup() gives the same result as the preset", async () => {55+ const same = await page.evaluate(async () => {56+ let a = document.createElement("article");57+ a.innerHTML = praseodymium.markup("card", pk);58+ document.querySelector("#praseodymium").append(a);59+ await new Promise((r) => setTimeout(r, 50));60+ let out = a.innerHTML === card.innerHTML;61+ a.remove();62+ return out;63+ });64+ assert(same);65+ });66+67+ await t.step("a card with hand-written children is left alone", async () => {68+ assertEquals(await page.evaluate(() => window.__rendered.hand), undefined);69+ assertEquals(await page.locator("#hand > *").count(), 1);70+ assertEquals(await page.textContent("#hand b"), "my own markup");71+ });72+73+ await t.step("npr-avatar shows the identicon before a picture", async () => {74+ const src = await page.getAttribute("#avatar", "src");75+ assert(src.startsWith("data:image/svg+xml,"), src);76+ assertEquals(src, await page.evaluate(() => praseodymium.face(pk)));77+ assertEquals(await page.getAttribute("#avatar", "nrn-profile"), pk);78+ assertEquals(await page.getAttribute("#avatar", "nrn-field"), "picture");79+ assert(await page.evaluate(() => avatar.complete && avatar.naturalWidth > 0), "fallback renders");80+ assertEquals(await page.getAttribute("#card [npr-part=avatar]", "src"), src);81+ });82+83+ await t.step("a published kind 0 fills cards, chips and avatars", async () => {84+ await page.fill("#profile [name=picture]", PIC);85+ await page.evaluate(() => new Promise((r) => {86+ document.addEventListener("nxe:published", r, { once: true });87+ profile.requestSubmit();88+ }));89+ await page.waitForFunction(() => document.querySelector("#card [npr-part=name]").textContent === "Ada");90+ assertEquals(await page.textContent("#card [npr-part=about]"), "Counting engines.");91+ assertEquals(await page.getAttribute("#avatar", "src"), PIC);92+ assertEquals(await page.textContent("#chip [npr-part=name]"), "Ada");93+ assertEquals(await page.textContent("#hand b"), "Ada", "radon still fills hand-written fields");94+ });95+96+ await t.step("after reload radon loads the profile from the relay", async () => {97+ await page.reload();98+ await page.waitForFunction(() => document.querySelector("#card [npr-part=name]").textContent === "Ada");99+ await page.waitForFunction((pic) => document.querySelector("#card [npr-part=avatar]").getAttribute("src") === pic, PIC);100+ assertEquals(await page.textContent("#card [npr-part=about]"), "Counting engines.");101+ });102+103+ await t.step("a broken picture falls back to the identicon", async () => {104+ const src = await page.evaluate(async () => {105+ avatar.src = "/nope.png";106+ await new Promise((r) => setTimeout(r, 300));107+ return avatar.getAttribute("src");108+ });109+ assertEquals(src, await page.evaluate(() => praseodymium.face(pk)));110+ });111+112+ await t.step("npr-copy copies and toggles npr-copied", async () => {113+ const copied = page.evaluate(() => new Promise((r) => document.addEventListener("npr:copied", (e) => r(e.detail.text), { once: true })));114+ await page.click("#copy");115+ assertEquals(await copied, "hello nobel");116+ assertEquals(await page.evaluate(() => navigator.clipboard.readText()), "hello nobel");117+ assert(await page.evaluate(() => copy.hasAttribute("npr-copied")));118+ await page.waitForFunction(() => !copy.hasAttribute("npr-copied"), null, { timeout: 3000 });119+ await page.click("#card [npr-part=copy]");120+ await page.waitForFunction(() => document.querySelector("#card [npr-part=copy]").hasAttribute("npr-copied"));121+ assertEquals(await page.evaluate(() => navigator.clipboard.readText()), npub);122+ });123+124+ await t.step("cerium injects one layered, zero-specificity style", async () => {125+ const styles = await page.evaluate(() => [...document.querySelectorAll("style[nce-style]")].map((s) => s.textContent));126+ assertEquals(styles.length, 1);127+ assert(styles[0].startsWith("@layer nobel {"));128+ assertEquals(await page.evaluate(() => cerium.css === cerium.style.textContent), true);129+ assertEquals(await page.evaluate(() => getComputedStyle(card).borderTopLeftRadius), "12px");130+ assertEquals(await page.evaluate(() => getComputedStyle(avatar).borderTopLeftRadius), "50%");131+ });132+133+ await t.step("page CSS overrides a themed property", async () => {134+ assertEquals(await page.evaluate(() => getComputedStyle(document.querySelector("#themed button")).color), "rgb(200, 30, 60)");135+ await page.addStyleTag({ content: "#card { border-radius: 2px }" });136+ assertEquals(await page.evaluate(() => getComputedStyle(card).borderTopLeftRadius), "2px");137+ });138+139+ await t.step("nce-bare skips the style but keeps cerium.css", async () => {140+ const bare = await context.newPage();141+ bare.on("pageerror", (e) => errors.push(e.message));142+ await bare.goto(`${origin}/demo/`);143+ await bare.setContent(`<html nce-bare><head><script src="${origin}/cerium/cerium.js"></script></head><body></body></html>`);144+ assertEquals(await bare.evaluate(() => document.querySelectorAll("style[nce-style]").length), 0);145+ assert(await bare.evaluate(() => cerium.css.includes("[npr-part=avatar]") && cerium.style === null));146+ await bare.close();147+ });148+149+ assertEquals(errors, []);150+ } finally {151+ await browser.close();152+ await server.shutdown();153+ }154+});