- `<.tag_vote user={…} user_tag={…} viewer={…} />` — the profile **Tags** chip: a brand-tint tag chip whose name links to the tag page, with the visible-endorsement count as a **calm brand-tint pill** right after the name (`bg-brand-100`/`text-brand-700`, dark `bg-brand-800`/`text-brand-100`; recoloured off the coral accent on purpose, a vouch count is social proof, not an alert). For the **read-only** viewer (owner / logged-out) the pill is a plain `<span>`, hidden at 0. For the **actionable** viewer (logged-in non-owner) the **same pill is the endorse toggle**: it is a `<button>` (carrying `data-tag-vote-count`) inside the CSRF `<.vote_form>`, looking identical to the read-only pill until you endorse, then **filling in** (`data-[endorsed=true]:bg-brand-600`/`text-white`); a **zero-count tag shows a `+`** so there is something to click. No more caret + floating corner badge (that split is gone, along with `<.tag_count_badge>` and `caret_up`). Hovering or focusing the chip raises a small **voter roster popover** (`group-hover`/`group-focus-within`, pure CSS, no JS, no library): each endorser is a **link to their profile** (the card takes pointer events and an invisible `after:` strip bridges the `mb-2` gap so the cursor can reach it), names **truncate**, and an "and N more" link to this member's per-tag endorser list (`/:slug/tags/:tag/endorsers`, a public paginated people list, a `UserTagController` action served in HTML and the agent formats, listing everyone who endorses this member for the tag) closes out the capped list (~6). The actionable viewer's **own row is pre-rendered** in the roster (hidden, marked `data-self-endorser`, and excluded from the server list so it never doubles); the `TagVote` JS reveals it the moment they endorse, so they appear in the roster **without a reload**. For them the popover is rendered even on a zero-roster tag, but hover is disabled (`active?` toggles the `group-hover`/`group-focus-within` classes, which the JS turns on once there is a visible row) so an empty card never pops. The toggle is a CSRF `<.form>` (the no-JS fallback) that the `TagVote` enhancement in `app.js` drives over fetch (POST endorse / DELETE undo), flipping `data-endorsed`, swapping the `+`↔count text and revealing/hiding the self row. The count is the **visible** endorsement tally (`compact_count`, the issue #783 gate); the roster comes from the profile's `UserTagEndorsement.visible_with_endorser/0` preload (endorsers loaded with the endorsement, sorted newest-first by their UUID v7 id, so it is in-memory, no per-tag query). The controller (`UserTagEndorsementController`) answers the fetch request with `{count, endorsed}` JSON and a plain submit with the classic flash + redirect. **`live?`** (default false): on the profile (a LiveView, `VutuvWeb.UserProfileLive`) the chip is passed `live?`, so the pill is a `phx-click="endorse"`/`"unendorse"` button (`phx-value-id` the user_tag id) and the LiveView re-renders the pill + roster server-side on every toggle — no fetch, no `TagVote` JS, no reload (the count, the `+`↔count text and the self-row in the roster all come from the re-render). The CSRF `<.vote_form>` + `TagVote` enhancement stays the path for any non-live caller. **Honor tags** (`user_tag.tag.honor?` — the reserved, admin-granted badge like `vutuv_developer`, assigned only from `/admin/tags/:slug`): the chip renders the name plus a small brand-tint **`honor_tag_badge`** (icon-only heroicons check-badge, `title`/`aria-label` gettext("Honor tag") — no flow text, so `tag_wording_test` stays about tags) and **drops the whole endorsement apparatus** (no toggle button, no count pill, no voter popover — they are authoritative, not peer-vouched, so non-endorsable end to end). The agent-doc siblings mark them too (`SectionDocs.tag_entry/1` carries `honor`, md/text show "(honor tag)").
0 commit comments