diff --git a/assets/controllers/article_highlight_controller.js b/assets/controllers/article_highlight_controller.js
index d2eeee6..b5c4090 100644
--- a/assets/controllers/article_highlight_controller.js
+++ b/assets/controllers/article_highlight_controller.js
@@ -208,7 +208,10 @@ export default class extends Controller {
} else {
this._clearHoverLeaveTimer();
}
- this.popoverInnerTarget.innerHTML = meta.headHtml || '';
+ const head = meta.headHtml || '';
+ const body = (meta.bodyHtml || '').trim();
+ this.popoverInnerTarget.innerHTML =
+ head + (body !== '' ? `
${body}
` : '');
this._placePopover(mark);
this.popoverTarget.hidden = false;
}
diff --git a/assets/styles/article.css b/assets/styles/article.css
index 07debbe..c8af856 100644
--- a/assets/styles/article.css
+++ b/assets/styles/article.css
@@ -429,9 +429,12 @@
}
.article-body-highlight--target {
- box-shadow: inset 0 -2px 0 0 var(--color-secondary);
- background: color-mix(in srgb, var(--color-secondary) 10%, transparent);
- transition: background 0.35s ease, box-shadow 0.35s ease;
+ box-shadow: none;
+ background: transparent;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in srgb, var(--color-text) 35%, transparent);
+ text-underline-offset: 0.12em;
+ transition: text-decoration-color 0.25s ease;
}
.article-body-highlight:focus-visible {
@@ -493,7 +496,7 @@
font-size: 0.86rem;
}
-/* Full `context` quote + optional on the `content` substring (highlighter, not a box) */
+/* Full `context` quote + optional on the `content` substring (body copy, not a box) */
.user-highlight__body {
margin: 0.35rem 0 0;
font-size: 0.95rem;
@@ -502,10 +505,23 @@
font-family: var(--main-body-font), serif;
}
-/* In-flow highlighter marker (NIP-84: `content` inside `context` quote) */
-.article-main mark.user-highlight__marker,
-.user-highlight__body mark.user-highlight__marker,
-mark.user-highlight__marker {
+/* In-flow article body: interactive but no highlighter fill (cards below own the color) */
+.article-main mark.user-highlight__marker {
+ margin: 0;
+ padding: 0;
+ border-radius: 0;
+ font: inherit;
+ line-height: inherit;
+ color: inherit;
+ background: transparent;
+ box-shadow: none;
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+}
+
+/* Popover + home aside: full context where present, `content` visibly marked */
+.article-body-highlight__body mark.user-highlight__marker,
+.home-aside-highlights__quote--html mark.user-highlight__marker {
margin: 0;
padding: 0.08em 0.1em 0.12em;
border-radius: 0.12em;
diff --git a/config/unfold.yaml b/config/unfold.yaml
index 89b1ccd..f6b3130 100644
--- a/config/unfold.yaml
+++ b/config/unfold.yaml
@@ -21,6 +21,8 @@ parameters:
'wss://nostr.einundzwei.space'
]
# Kind-0 / profile fetches (author metadata, prewarm). Tried first, then default + article_relays (deduped).
+ # Also used as a second pass for kind 30040 (magazine category indices) and category long-form ingest
+ # when article_relays return nothing — not used for the generic /articles DB listing or getArticles().
profile_relays: [
'wss://relay.damus.io',
'wss://nos.lol',
diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php
index e442055..07799b0 100644
--- a/src/Controller/ArticleController.php
+++ b/src/Controller/ArticleController.php
@@ -449,6 +449,7 @@ class ArticleController extends AbstractController
'authorPubkey' => $h->getAuthorPubkey(),
'dateLabel' => $this->formatHighlightListDate($h->getEventCreatedAt()),
]),
+ 'bodyHtml' => $h->getBodyHtml(),
];
}
if ($out === []) {
diff --git a/src/Entity/ArticleHighlight.php b/src/Entity/ArticleHighlight.php
index 088b5e4..63a3798 100644
--- a/src/Entity/ArticleHighlight.php
+++ b/src/Entity/ArticleHighlight.php
@@ -146,9 +146,9 @@ class ArticleHighlight
}
/**
- * HTML for the home aside and article hover cards: when a `context` tag exists, the full quote is
- * shown with `content` marked inside it; otherwise the event `content` only in a . The
- * rendered article body still only wraps the `content` passage (see ArticleBodyHighlightInjector).
+ * Card body HTML: the optional `context` tag is the full passage; the event `content` is
+ * highlighted (marked) where it appears inside that text. If there is no `context` tag, only
+ * `content` is wrapped in a mark.
*/
public function getBodyHtml(): string
{
diff --git a/src/Service/HighlightSyncService.php b/src/Service/HighlightSyncService.php
index aa8060a..55a701c 100644
--- a/src/Service/HighlightSyncService.php
+++ b/src/Service/HighlightSyncService.php
@@ -65,6 +65,7 @@ final class HighlightSyncService
if (!\is_array($tags)) {
$tags = [];
}
+ $tags = HighlightEventTags::normalizeTagsForStorage($tags);
$content = (string) ($ev->content ?? '');
$ca = (int) ($ev->created_at ?? 0);
if ($ca < 0) {
diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php
index 36476c6..1a1dc56 100644
--- a/src/Service/NostrClient.php
+++ b/src/Service/NostrClient.php
@@ -43,6 +43,12 @@ class NostrClient
*/
private const MAX_DISCUSSION_RELAY_URLS = 10;
+ /**
+ * Kind-9802 highlight ingest ({@see fetchHighlightEventsForArticle} / prewarm): main + article + profile
+ * + author NIP-65, deduped. Higher than {@see MAX_DISCUSSION_RELAY_URLS} so profile relays are not dropped.
+ */
+ private const MAX_HIGHLIGHT_RELAY_URLS = 32;
+
/**
* {@see Request::send()} hits relays sequentially; profile pages (metadata, long-form list, 10133) used
* the full default+article+profile list (~8–9 wss) → 2 slow relays can exceed PHP’s 30s default max_execution_time.
@@ -174,6 +180,44 @@ class NostrClient
return $relaySet;
}
+ /**
+ * Configured profile relays (kind-0 / NIP-05 hints) that are not already in the article relay list.
+ * Used as a second pass for magazine 30040 and category long-form ingest when article relays return nothing.
+ * Intentionally excludes merging article URLs again — {@see createRelaySet()} prepends article relays.
+ *
+ * @return list
+ */
+ private function profileRelayUrlsExcludedFromArticleRelays(): array
+ {
+ $article = array_fill_keys($this->configuredArticleRelayUrlList(), true);
+ $out = [];
+ foreach ($this->profileRelayUrlList() as $u) {
+ if (!isset($article[$u])) {
+ $out[] = $u;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Relay set built only from the given URLs (no implicit article-relay merge).
+ */
+ private function createRelaySetFromUrlsOnly(array $relayUrls): RelaySet
+ {
+ $relaySet = new RelaySet();
+ $seen = [];
+ foreach ($relayUrls as $relayUrl) {
+ if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
+ continue;
+ }
+ $seen[$relayUrl] = true;
+ $relaySet->addRelay(new Relay($relayUrl));
+ }
+
+ return $relaySet;
+ }
+
/**
* Single-relay set for I/O that intentionally hits one wss (e.g. longform ingest). Magazine
* 30040 resolution uses the full article relay set so all relays can contribute the latest
@@ -1401,7 +1445,10 @@ class NostrClient
}
/**
- * Fetches kind 9802 (highlights) that reference the long-form address. Used for DB ingest only.
+ * Fetches kind 9802 (highlights) that reference the long-form address. Used for DB ingest only
+ * ({@see HighlightSyncService} / prewarm). Relays: {@see configuredArticleRelayUrlList} (main +
+ * article_relays), then config {@see profileRelayUrlList}, then author NIP-65, deduped (cap
+ * {@see MAX_HIGHLIGHT_RELAY_URLS}).
*
* @return list