feat(napplets): app-store-style card + icon manifest tag; demote capabilities

Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.

Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
  icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 17:13:41 +00:00
parent 4cd71b140c
commit a2eae42c07
16 changed files with 278 additions and 84 deletions
@@ -166,12 +166,12 @@ object NappletCommands {
StaticSitePublish.run(
dataDir,
rest,
"napplet publish <dir> --server <blossom-url> [--requires identity,relay,…] [--d ID] [--relay R] [--title T]",
"napplet publish <dir> --server <blossom-url> [--requires identity,relay,…] [--d ID] [--relay R] [--title T] [--icon URL]",
) { m ->
if (m.identifier != null) {
NamedNappletEvent.build(m.identifier, m.paths, m.servers, m.requires, m.title, m.description, m.source)
NamedNappletEvent.build(m.identifier, m.paths, m.servers, m.requires, m.title, m.description, m.source, m.icon)
} else {
RootNappletEvent.build(m.paths, m.servers, m.requires, m.title, m.description, m.source)
RootNappletEvent.build(m.paths, m.servers, m.requires, m.title, m.description, m.source, m.icon)
}
}
@@ -176,14 +176,14 @@ object NsiteCommands {
StaticSitePublish.run(
dataDir,
rest,
"nsite publish <dir> --server <blossom-url> [--d ID] [--relay R] [--title T] [--description D] [--source URL]",
"nsite publish <dir> --server <blossom-url> [--d ID] [--relay R] [--title T] [--description D] [--source URL] [--icon URL]",
) { m ->
if (m.identifier != null) {
NamedSiteEvent.build(m.identifier, m.paths, m.servers, m.title, m.description, m.source) {
NamedSiteEvent.build(m.identifier, m.paths, m.servers, m.title, m.description, m.source, m.icon) {
siteAggregateHash(m.paths)
}
} else {
RootSiteEvent.build(m.paths, m.servers, m.title, m.description, m.source) {
RootSiteEvent.build(m.paths, m.servers, m.title, m.description, m.source, m.icon) {
siteAggregateHash(m.paths)
}
}
@@ -49,6 +49,7 @@ object StaticSitePublish {
val title: String?,
val description: String?,
val source: String?,
val icon: String?,
)
suspend fun run(
@@ -70,6 +71,7 @@ object StaticSitePublish {
val title = args.flag("title")
val description = args.flag("description")
val sourceUrl = args.flag("source")
val icon = args.flag("icon")
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
Context.open(dataDir).use { ctx ->
@@ -82,7 +84,7 @@ object StaticSitePublish {
return Output.error("upload_failed", e.message ?: "upload failed")
}
val manifest = Manifest(identifier, result.pathTags, servers, requires, title, description, sourceUrl)
val manifest = Manifest(identifier, result.pathTags, servers, requires, title, description, sourceUrl, icon)
val signed = ctx.signer.sign(buildEvent(manifest))
val relays =
@@ -100,6 +102,7 @@ object StaticSitePublish {
"kind" to signed.kind,
"d" to identifier,
"title" to title,
"icon" to icon,
"servers" to servers,
"requires" to requires,
"aggregate_sha256" to SiteAggregateHash.compute(result.pathTags),