"0 services · 3 links" on a page with three things on it was the tell: the link/service split is how you reach something, not what it is. Everything on a profile is a site. One is reached at its own address, the other through its owner's daemon, and that is the whole of the difference. So the page stops sorting itself. One list, in publication order, no headings by kind and none by visibility. The count reads "3 sites". Which mechanism a row uses is said once, by an arrow or its absence — not a second time by drawing one as a link and the other as a button, which was re-creating the same taxonomy in control shapes. The service stays a <button>, because clicking it acts on this page rather than navigating and that is the correct element; it is just no longer dressed as a different class of thing. Visibility moves onto the row and is written out only where it is not public. What has not changed, and should not: a link is still always public. dev.site can hide a URL from a profile but cannot stop anyone holding it from opening it, so "private" there could only ever mean concealment, while for a service it means the control plane refuses a capability and the daemon refuses the connection. One word, two strengths. The model now says everything is a site; it still does not say every site is protected the same way. "Shared with me" keeps its own section. Those are other people's sites appearing on your page, which is a different thing rather than a subdivision of yours.
- commit
- 1a02e9fa0aa7f5ad9fa84984c3bfeaa56de0f761
- parent
- 713eb4a0b58494649a7b8c49ee9c232b741c253c
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 54 | ++++++++++------ |
| M | web/ | 100 | +++++++++++++----------------- |
| M | web/ | 23 | +++++++- |
@@ -40,26 +40,30 @@ changes here and changes to the whitelist belong in the same commit.
4040 <article id="profile">4141 <hgroup>4242 <h1>@alice</h1>43- <p>2 services · 1 link</p>43+ <p>3 sites</p>4444 </hgroup>4545 46- <!-- one section per visibility, omitted when empty -->47- <section class="group" data-visibility="public">48- <h2>Public</h2>49- <ul class="entries">50- <!-- a link goes somewhere else, so it is an anchor -->51- <li class="entry" data-kind="link">52- <a href="https://klot.ski" target="_blank" rel="noopener noreferrer">Klot Ski</a>53- <small class="state">klot.ski ↗</small>54- </li>55- <!-- a service opens here, so it is a button. It carries no state:56- nothing knows whether it is reachable until you ask it. -->57- <li class="entry" data-kind="service">58- <button class="outline">Hermes</button>59- </li>60- </ul>46+ <!-- one list, in the order things were published -->47+ <ul class="entries">48+ <!-- reached at its own address, so it is an anchor and takes you away -->49+ <li class="entry" data-kind="link" data-visibility="public">50+ <a href="https://klot.ski" target="_blank" rel="noopener noreferrer">klot.ski</a>51+ <small class="state">↗</small>52+ </li>53+ <!-- reached through its owner's daemon, so it is a button and opens54+ here. No reachability state: nothing knows whether it is running55+ until you ask it. -->56+ <li class="entry" data-kind="service" data-visibility="private">57+ <button class="outline">Hermes</button>58+ <small class="state">private</small>59+ </li>60+ </ul>61+62+ <!-- other people's sites, on your own profile only -->63+ <section class="group" data-visibility="shared-with-me">64+ <h2>Shared with me</h2>65+ <ul class="entries">…</ul>6166 </section>62- <!-- data-visibility is one of: public · private · shared · shared-with-me -->6367 </article>6468 </main>6569 @@ -67,9 +71,19 @@ changes here and changes to the whitelist belong in the same commit.
6771 </body>6872 ```6973 70-Entries carry `data-kind` so a theme can tell a link from a service, and nothing71-else. There is deliberately no reachability state to style: the page does not72-know whether a daemon is running, and says so by saying nothing.74+Everything on a profile is a site. `data-kind` says how you get to one — `link`75+is reached at its own address, `service` through its owner's daemon — and that is76+the only difference there is. The page does not sort itself into kinds, because77+which of the two a thing is says nothing about what it is for.78+79+`data-visibility` is on the row: `public`, `private` or `shared`. It is written80+out in `.state` only where it is not `public`, and a link is always `public` —81+dev.site can hide a URL but cannot stop anyone holding it from opening it, so82+concealment is the only thing "private" could mean there and the word is not83+offered.84+85+There is deliberately no reachability state to style: the page does not know86+whether a daemon is running, and says so by saying nothing.7387 7488 `--pico-ins-color` and `--pico-del-color` are still in the whitelist, and are7589 what the viewer's success and failure messages are drawn with.@@ -172,26 +172,38 @@ function renderSession() {
172172 173173 /// One row of a profile.174174 ///175-/// Links are anchors and services are buttons, because a link goes somewhere and176-/// a service opens here.175+/// Everything on a profile is a site. The only difference between them is how you176+/// get there: a link is reached at its own address, so it is an anchor and takes177+/// you away; a service is reached through its owner's daemon, so it is a button178+/// and opens here. That is the whole of the distinction, and the row says it with179+/// an arrow rather than by sorting the page into kinds.177180 ///178181 /// A service carries no reachability state. Nothing on this page knows whether179182 /// the far end is running — the control plane is not told, and the only thing180183 /// that could answer is a connection attempt. So the row makes no claim, and181184 /// clicking it finds out.182-function entry({ kind, name, state, href, onClick }) {185+function siteRow(item, { onClick, from } = {}) {183186 const li = document.createElement('li');184187 li.className = 'entry';185- li.dataset.kind = kind;188+ li.dataset.kind = item.kind;189+ li.dataset.visibility = item.visibility;186190 187- if (href) {191+ const name = esc(item.name)192+ + (from ? ` <small>from @${esc(from)}</small>` : '');193+194+ if (item.kind === 'link') {188195 li.innerHTML =189- `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${name}</a>` +190- `<small class="state">${esc(state)}</small>`;191- } else {192- li.innerHTML = `<button class="outline">${name}</button>`;193- if (onClick) li.querySelector('button').addEventListener('click', onClick);196+ `<a href="${esc(item.url)}" target="_blank" rel="noopener noreferrer">${name}</a>` +197+ `<small class="state">${linkHost(item)}</small>`;198+ return li;194199 }200+201+ // Visibility is worth saying only where it is not the default. On someone202+ // else's profile it also explains why you can see a thing at all.203+ const note = item.visibility === 'public' ? '' : esc(item.visibility);204+ li.innerHTML = `<button class="outline">${name}</button>`205+ + (note ? `<small class="state">${note}</small>` : '');206+ if (onClick) li.querySelector('button').addEventListener('click', onClick);195207 return li;196208 }197209 @@ -206,6 +218,9 @@ function linkHost(item) {
206218 return same ? '↗' : `${host} ↗`;207219 }208220 221+/// A titled list. Used only for "shared with me", which really is a different222+/// thing — other people's sites, on your page — rather than a subdivision of223+/// yours.209224 function group(title, visibility, rows) {210225 const section = document.createElement('section');211226 section.className = 'group';@@ -226,63 +241,34 @@ function renderProfile(profile) {
226241 const article = document.createElement('article');227242 article.id = 'profile';228243 229- const counts = [230- profile.entries.filter((e) => e.kind === 'service').length,231- profile.entries.filter((e) => e.kind === 'link').length,232- ];233- const summary = [234- counts[0] === 1 ? '1 service' : `${counts[0]} services`,235- counts[1] === 1 ? '1 link' : `${counts[1]} links`,236- ].join(' · ');237-244+ const total = profile.entries.length;238245 const heading = document.createElement('hgroup');239- heading.innerHTML = `<h1>@${esc(profile.handle)}</h1><p>${summary}</p>`;246+ heading.innerHTML = `<h1>@${esc(profile.handle)}</h1>`247+ + `<p>${total === 1 ? '1 site' : `${total} sites`}</p>`;240248 article.append(heading);241249 242- const buckets = { public: [], private: [], shared: [] };243- for (const item of profile.entries) {244- if (item.kind === 'link') {245- buckets.public.push(entry({246- kind: 'link',247- name: esc(item.name),248- // The host is there to say where a link goes when its name does not.249- // When someone names a link after its domain — which is the honest thing250- // to do for a site that has no other name — repeating it says nothing,251- // so only the arrow remains.252- state: linkHost(item),253- href: item.url,254- }));255- } else {256- buckets[item.visibility].push(entry({257- kind: 'service',258- name: esc(item.name),259- onClick: () => openService(item),260- }));261- }262- }263-264- const titles = { public: 'Public', private: 'Private', shared: 'Shared' };265- for (const visibility of ['public', 'private', 'shared']) {266- if (buckets[visibility].length) {267- article.append(group(titles[visibility], visibility, buckets[visibility]));250+ // One list, in the order things were published. No sections by kind or by251+ // visibility: a profile is a list of sites, and sorting it into "links" and252+ // "services" would be publishing an implementation detail as a heading.253+ if (total) {254+ const list = document.createElement('ul');255+ list.className = 'entries';256+ for (const item of profile.entries) {257+ list.append(siteRow(item, { onClick: () => openService(item) }));268258 }259+ article.append(list);260+ } else {261+ const empty = document.createElement('p');262+ empty.innerHTML = '<small>Nothing published yet.</small>';263+ article.append(empty);269264 }270265 271266 if (profile.shared_with_me.length) {272- const rows = profile.shared_with_me.map((item) => entry({273- kind: 'service',274- name: `${esc(item.name)} <small>from @${esc(item.owner_handle)}</small>`,275- onClick: () => openService(item),276- }));267+ const rows = profile.shared_with_me.map((item) =>268+ siteRow(item, { onClick: () => openService(item), from: item.owner_handle }));277269 article.append(group('Shared with me', 'shared-with-me', rows));278270 }279271 280- if (!article.querySelector('.entry')) {281- const empty = document.createElement('p');282- empty.innerHTML = '<small>Nothing published yet.</small>';283- article.append(empty);284- }285-286272 main.append(article);287273 }288274 @@ -69,7 +69,7 @@ body > main {
6969 color: var(--pico-muted-color);7070 font-size: 1rem;7171 }72-.group + .group {72+.entries + .group {7373 margin-top: var(--pico-block-spacing-vertical);7474 }7575 @@ -96,6 +96,27 @@ body > main {
9696 .entries > li > :is(a, button, [role='button']) {9797 margin-bottom: 0;9898 }99+100+/* A service is a button because clicking it acts on this page rather than101+ * navigating — that is the correct element and it stays. It should not *look*102+ * like a different class of thing, though: the row already says how you get103+ * there with an arrow or its absence, and drawing one as a box and the other as104+ * text would re-sort the page into kinds the model no longer has. So the button105+ * is dressed as what it is: an entry in a list of sites. */106+.entries > li > button {107+ width: auto;108+ padding: 0;109+ border: 0;110+ background: none;111+ font: inherit;112+ color: var(--pico-primary);113+ text-decoration: underline;114+ text-underline-offset: var(--pico-text-underline-offset);115+ cursor: pointer;116+}117+.entries > li > button:is(:hover, :focus-visible) {118+ color: var(--pico-primary-hover);119+}99120 /* Links carry their host here. Services carry nothing: whether one is reachable100121 * is not known until you ask it. */101122 .entries .state {