Get-Involved icon row — three-tap commitment signal on every project, working-group, and proposal card
Every project card, working-group card, and member proposal card now carries a three-icon 'Get involved' row — Lead-Potential / Active-Participant / Keep-Informed — that writes the user's commitment level to a new Turso table on tap. One row per (user, entity, level), enforced by a composite unique index, so a re-tap on the same icon clears the row and a tap on a different icon updates the level in place. Logged-out users see the icons at full fidelity, hover-tooltips describing what each level means, and a click-triggered 'Only the penitent man shall pass' gating message that links them to /login with the return URL preserved. Wired into both galleries (portfolio, mission-brief, lite variants) and into the recent-proposals rail on /projects/propose and /working-groups/propose. Built for tomorrow's launch session — 60 attendees, low-friction signal-collection.
Changelog — 2026-04-29 (06)
Get-involved icon row, end-to-end
Tomorrow morning at 11:00 EST, sixty Kauffman Fellows show up to the Agentic VC Dojo launch session. The whole point of the session is to convert curiosity into commitment — to get people to say, on the record, which working groups and projects they want to be part of. The prior surface for that was a CTA button on the gallery card that linked into a project detail page. One click, one navigation, one form. Way too much friction for sixty people scrolling through twelve cards while a host is talking.
This entry replaces the navigation with a three-icon row that lives directly on every card. Three taps to register a commitment, three taps to revise it, zero page navigations. The signal lands in a new Turso table within ~50ms, persists across sessions, and shows up next time the user lands on the page so they can see what they’ve already committed to.
Why Care?
The dojo’s value proposition is member-driven projects + working groups. That only works if the host (and the maintainers, and the working-group leads) can see who’s signed up at what level of commitment. “Lead-Potential” tells a working-group lead they have a deputy. “Active-Participant” tells them attendance is reliable. “Keep-Informed” tells them someone wants to stay in the loop without making any promises. Without those distinctions, recruiting a working-group is a cold-DM exercise; with them, it’s a list.
Six hours before the launch, the cards on /projects/ and /working-groups/ looked great but had no in-place affordance for that signal. A user could read a project, decide they’d lead it, and then… leave the page to navigate somewhere else. The drop-off rate on a hosted live event with sixty people on a Zoom call is brutal — every required navigation costs you ~30% of clicks.
This row eliminates the navigation. The signal happens on the card itself.
What Got Built
ParticipationInterest table on Astro DB
Added a sixth table to db/config.ts:
const ParticipationInterest = defineTable({
columns: {
id: column.text({ primaryKey: true }),
user_id: column.text(),
entity_kind: column.text(),
entity_slug: column.text(),
level: column.text(),
created_at: column.date(),
updated_at: column.date(),
},
indexes: {
pi_user_entity_unique: { on: ['user_id', 'entity_kind', 'entity_slug'], unique: true },
pi_entity_lookup: { on: ['entity_kind', 'entity_slug'] },
},
});
user_id is the canonical roster email (lowercased), matching the convention established for Vote.user_id and Proposal.user_id — same person, same key, joins everywhere. The composite unique index (user_id, entity_kind, entity_slug) is the integrity contract: one row per user per entity, no duplicates. The secondary entity_lookup index is for the future “who’s interested in this project?” query that working-group leads will eventually want.
entity_kind is a four-value enum:
project— slug from the projects content collectionworking-group— slug from the working-groups content collectionproject-proposal— UUID fromProposal.id, for member-submitted backlog rowsworking-group-proposal— UUID fromProposal.id, same
The *-proposal kinds are deliberately separate from the published-entity kinds. When a maintainer eventually promotes a proposal into a published markdown file, the human-readable title might match but the underlying identifier doesn’t — and we don’t want stale signal from the backlog phase to mistakenly bind onto the published entity.
level is a three-value enum:
lead-potential— “Eager and able”active-participant— “Count me reliably in”keep-informed— “Want to stay informed”
/api/participation-interest — three verbs, one endpoint
src/pages/api/participation-interest.ts exports GET, POST, and DELETE against the same URL. Session + roster gated, mirroring /api/proposals. Returns loggedIn: false (200, not 401) on the GET path so the client can render gracefully without treating absence-of-session as an error.
- GET (no params) → returns the full list of the current user’s interests so the page can hydrate every icon row from a single fetch.
- GET ?kind=&slug= → single row for one entity (unused right now; preserved for future single-card hydration).
- POST
{ entity_kind, entity_slug, level }→ upsert. If a row exists with the same(user, kind, slug), update itslevel; otherwise insert. The composite unique index would fail an insert on duplicate, so the explicit select-then-update path is intentional. - DELETE
{ entity_kind, entity_slug }→ clear the row. Used when a user re-taps the same icon they had already selected.
The POST/DELETE both return { ok: true, level } on success and a sane error object on failure (401 not_authenticated, 400 invalid_*). The component reads those status codes to decide between rollback + error banner vs. silent retry.
Span__GetInvolvedOptionScale--IconRow.astro — the component
src/components/forms/Span__GetInvolvedOptionScale--IconRow.astro. Naming follows the project’s Span__ComponentName--Variant convention — this is a span-style affordance, not a section. Props:
export interface Props {
entityKind: 'project' | 'working-group' | 'project-proposal' | 'working-group-proposal';
entitySlug: string;
entityTitleTxt?: string;
size?: 'sm' | 'md';
}
Three buttons, left-to-right, in increasing weight from heaviest commitment on the left to lightest on the right:
- Lead-Potential — flame icon, accent color (with subtle inner fill), filled-in selected state with amber halo. “Eager and able”.
- Active-Participant — check-in-circle, primary violet, medium fill in the unselected state, primary-violet fill when selected. “Count me reliably in”.
- Keep-Informed — eye outline, muted gray, outline only at all states except selected (then text-color filled). “Want to stay informed”.
Each button has a small per-icon hover tooltip with the level description. Tooltips are pure CSS, fade-in on hover/focus, positioned above the button with a triangular arrow. They stay visible regardless of login state — they describe what the icon means, which is useful information whether you can act on it or not.
Hydration
The component is rendered SSR (or prerendered, depending on the page). On the client, a single page-level <script> block — Astro deduplicates it across N component instances — runs hydrate() on DOMContentLoaded (and on astro:page-load for view-transition navigations). Hydration:
- Find every
[data-gioscale-root]element on the page. - Issue a single
GET /api/participation-interestto fetch the current user’s complete interest map. - For each root, look up the
(kind, slug)pair in the map and apply the initial selected state. - Set
data-logged-in="true|false"on each root from the response. - Wire click handlers per button, idempotent via a
data-gioscaleBoundflag (so hot-reload doesn’t double-bind).
One fetch hydrates twelve cards. No N+1.
Click flow (logged in)
- User taps an icon. Optimistic toggle:
setSelected(root, nextLevel)immediately. fetch('/api/participation-interest', { method: nextLevel ? 'POST' : 'DELETE', ... }).- On 2xx → keep the optimistic state. Console-log
persist ok. - On non-2xx → roll back, flash an error animation, render an inline error banner (“Save failed (500)”) for 3s. On 401 specifically, also flip
data-logged-in="false"so subsequent clicks route through the gating tooltip path.
Toggling the same icon twice clears the row (POST → DELETE on second tap). Tapping a different icon updates level in place — the API’s upsert path handles this.
Click flow (logged out) — the gating tooltip
Logged-out users see all three icons at full opacity, with their per-icon hover tooltips intact. Clicking an icon does NOT redirect immediately. Instead, the click sets data-show-login-tip="true" on the row, which reveals a row-level tooltip:
Only those who have logged in may get involved. Only the penitent man shall pass. CLICK HERE TO LOG IN →
The tooltip is a real <a href="/login?next={current-path}">, so clicking it (or right-clicking → “open in new tab”) navigates to login with the return URL preserved. The tooltip stays open for 6 seconds, then auto-dismisses. Clicking anywhere outside the row also dismisses it.
This was a deliberate choice over the obvious “redirect on click” alternative. Auto-redirect made the icons feel like a trap door — you tap to declare interest and end up on a login form, the action you tried to take never registers. The click-to-reveal flow makes the gating explicit and gives the user a moment to decide whether they actually want to log in or just keep browsing.
Wired into both galleries
Section__ProjectGallery.astro and Section__WorkingGroupGallery.astro now render the icon row in three of their four variants:
- portfolio (active entities, 3-up grid with banner image) →
size="md", justified to the right. - mission-brief (alternative active layout, number-badged cards) →
size="md", justified to the right. - lite (proposed entities, compact 3-up no-banner cards) →
size="sm", justified to the left.
The shelf variant (archived entities) deliberately does not render the row. You can’t get involved with a project that was deliberately wound down.
The row is rendered as a sibling of <a class="pcard__hit">, not nested inside it. Reasons:
- HTML5 spec compliance.
<a>may not contain “interactive content”;<button>is interactive content. Nested buttons inside anchors is technically a violation, and browsers have been known to mishandle keyboard navigation when you do it. - Click bubbling. A button inside an anchor would, by default, trigger the anchor’s navigation on click. The script could
stopPropagationto prevent that — but earlier in this build I tried a capture-phase document-levelstopPropagationlistener that ended up suppressing the button’s own click handler entirely. Sibling layout obviates the whole problem.
To make the sibling layout work, .pcard is now display: flex; flex-direction: column; gap: 0.5rem and .pcard__hit is flex: 1 1 auto (replacing the prior height: 100%). Cards in a grid still equalize heights via align-items: stretch on the grid; the icon row sits underneath each card as a uniform-height footer band.
Wired into both propose pages
/projects/propose and /working-groups/propose render a “recent proposals” rail showing the most-recently-submitted member backlog rows. Each propose-recent__item now carries the icon row underneath the body text, using the Proposal.id UUID as the entity_slug and the appropriate *-proposal kind:
<div class="propose-recent__item-actions">
<GetInvolvedScale
entityKind="project-proposal"
entitySlug={p.id}
entityTitleTxt={p.title}
size="sm"
/>
</div>
This means tomorrow’s attendees can scroll the proposed-project rail, see what other Fellows have sketched, and immediately register interest in those sketches without waiting for them to be promoted into the published collection. That’s a dramatic shortening of the feedback loop on member proposals: a sketch goes up, signal arrives within minutes, the maintainer has data on which sketches to prioritize promoting.
Architectural Decisions
- DB table over JSON column on User. The natural alternative would be storing interests as a JSON blob on the
Userrow. We didn’t. A separate table preserves the ability to query “who is interested incontent-farm?” with a simple indexed lookup — that query is going to be the entire point of this feature for working-group leads recruiting volunteers. JSON-on-User would force a full scan of users plus a JSON parse per row. The composite unique index also gives us atomic single-row updates instead of read-modify-write on a JSON document. - Single fetch for all instances on a page. Twelve cards on
/projects/could mean twelve separateGET /api/participation-interest?kind=project&slug=...requests. Instead, one fetch returns the user’s entire interest set; the client maps each card by${kind}::${slug}lookup. Trade: tiny over-fetch (a user might have 50 interests across the whole site, of which 12 are relevant to this page). Win: one HTTP request instead of N, and the cache stays warm for any subsequent navigation that loads more cards. - Click-triggered gating tooltip, not hover-triggered. Earlier draft used hover to reveal the “must log in” message. Two problems: (1) on touch devices, hover doesn’t exist; the user would tap and get an immediate redirect with no context. (2) On desktop, the hover affordance was disconnected from the act of trying to participate — the user would see the message just by drifting the cursor over the row, which felt punitive (“you’re not logged in!” before they’ve expressed any intent). Click-triggered is the right semantic: the message appears only when the user actually attempts to engage.
- Sibling, not nested. Detailed above. Worth restating: the sibling layout sidestepped a real bug (capture-phase
stopPropagationkilling button clicks before they reached the target) and is also the spec-correct way to put interactive controls near a card-level link. - Icon weight cues at rest. Lead-Potential is
color-mix(in srgb, var(--color-accent) 70%, var(--color-text-muted))even when unselected; Active-Participant isvar(--color-primary)mixed similarly; Keep-Informed is justvar(--color-text-muted). The point: the leftmost icon already looks “loudest” before anyone touches it, so the visual hierarchy of commitment is legible even pre-interaction. Selected states intensify each axis (full accent, full primary, full text-color respectively). - Idempotent hydration.
data-gioscaleBoundon each root prevents Vite HMR (which firesastro:page-loadafter some hot-reloads) from stacking duplicate click handlers. Without this guard, a single click could fire 3+ POSTs after a few HMR cycles in dev. Caught early by reading the dev console. - Console-log breadcrumbs. Every step in the click flow logs through
[gioscale]: hydrate count, initial-fetch payload, each click, persist outcome. This was added after the first round of “clicks aren’t doing anything” debugging — having the breadcrumbs in place means the next time something doesn’t persist, the user can open DevTools and see exactly which step failed (handler not bound? fetch threw? API returned non-2xx?). Cheap insurance.
A Bug We Squashed Along the Way
First implementation included a capture-phase document.addEventListener('click', ..., true) that called stopPropagation() to prevent any click on the icon row from bubbling up to the parent card link. Looked sensible. Was wrong: stopPropagation in capture phase fires before the event reaches its target, which means the button’s own click handler never ran. From the user’s perspective, the icons were dead — no UI change, no API call, nothing.
The fix: delete the capture-phase listener entirely. The icon row is a sibling of the anchor, not nested inside it, so bubbling can’t reach the anchor anyway. The bubble-phase ev.stopPropagation() in each per-button handler is belt-and-suspenders insurance.
The lesson, again: capture phase + stopPropagation is almost never what you actually want. Bubble phase, attached to the elements whose events you actually care about, is the default for a reason.
Behavior
- Visit
/projects/while signed in → twelve cards, each with a “Get involved:” pill underneath. Console:[gioscale] hydrate — found 12 instancesand[gioscale] initial interests {loggedIn: true, interests: []}. - Tap the flame icon on Content Farm → the icon fills with amber, slight lift, faint amber halo. Console:
[gioscale] click {kind: "project", slug: "content-farm", level: "lead-potential", loggedIn: "true"}followed by[gioscale] persist ok {ok:true,level:"lead-potential"}. Runsqlite3 .astro/content.db "SELECT * FROM ParticipationInterest"and the row is there. - Tap the same flame again → the fill clears, optimistic; behind the scenes a DELETE fires; row gone from the DB.
- Tap the flame, then the eye → flame clears and eye fills (
levelupdated in place via UPDATE, not a fresh insert; only one row in the table, withlevel: "keep-informed"and a fresherupdated_at). - Sign out and revisit
/projects/→ all twelve cards still render the icons at full visibility. Hover an icon → “Eager and able” / “Count me reliably in” / “Want to stay informed” tooltip appears as before. - Click an icon while signed out → the row reveals the gating tooltip (“Only those who have logged in may get involved. Only the penitent man shall pass. Click here to log in →”). Wait six seconds → tooltip dismisses. Or: click the tooltip → navigate to
/login?next=/projects/. Or: click another icon → tooltip stays focused on the row. Or: click anywhere else on the page → tooltip dismisses. - Visit
/projects/propose→ form at top, “Recent project proposals” rail beneath. Each proposal card now carries the same icon row, keyed off the proposal’s UUID. Tapping registers an interest scoped to the proposal (withentity_kind: 'project-proposal'), separate from any future interest in the eventual published markdown entity. - Same flow on
/working-groups/and/working-groups/propose.
Schema Migration Note
The new table needs to land in production Turso before tomorrow:
pnpm --filter fullstack-vc astro db push --remote
Local dev (astro dev) regenerates .astro/content.db from db/config.ts on every startup, so the schema is automatically present in dev. Production Turso requires the explicit push because it persists across sessions and we don’t blow it away on restart.
What’s Deferred
- Working-group lead view. Tomorrow’s session will produce signal in the table. There is currently no UI for a working-group lead to see their signal — i.e., “who has expressed Lead-Potential or Active-Participant in my working group?” That UI is the natural next entry. Probably renders as a section on
/working-groups/[slug]visible only to the WG leads listed in frontmatter. Not on the critical path for tomorrow. - Aggregate counts on cards. “12 interested” / “3 potential leads” badges next to each entity. Would be a single grouped query on
ParticipationInterestplus a small per-card pill. Adds social proof to the cards and makes the signal visible to the room. Deferred because we want to see what the raw signal looks like first before deciding how to surface aggregates publicly. - Email digest for working-group leads. “Three new people expressed Lead-Potential in your working group this week.” Not a tomorrow feature. Lives downstream of the lead view.
- Migration to design-system catalog. Per the workspace’s
/design-systemconvention, every new component lands in the site’s design-system catalog when introduced. The icon row qualifies. Not yet added — will fold in with the next pass on/design-system/forms/or wherever Span__ components belong in the catalog tree. - Touch-device long-press for the gating tooltip. Right now on touch the gating tooltip fires on tap and dismisses on outside-tap, same as desktop click. Works fine in practice. A long-press-to-preview affordance would be more iOS-native but adds complexity for a marginal win.
Verification
sqlite3 sites/fullstack-vc/.astro/content.db ".schema ParticipationInterest"→ table exists with the composite unique indexpi_user_entity_uniqueand the entity-lookup index.curl -sS http://localhost:4324/api/participation-interest(no cookie) →{"loggedIn":false,"interests":[]}200 OK. API is alive and behaves correctly for unauthenticated requests.- Signed in as
mpstaton@gmail.com, tap Lead-Potential on Content Farm → row appears:mpstaton@gmail.com | project | content-farm | lead-potential | <ts>. Re-tap → row gone. Tap again, then tap the eye → row updated tokeep-informedwith the sameidand a refreshedupdated_at. - Logged out, click the flame on any card → row-level tooltip reveals with the penitent-man message. Click the tooltip’s “Click here to log in →” → land on
/login?next=/projects/. Sign in, return → the icon was not retained (because the click attempt while logged out was correctly rejected, not silently queued). - Console open during all of the above → every step has a
[gioscale]breadcrumb. Errors are logged with status codes. No unhandled promise rejections. - Hard-reload after CSS-only edits → Vite HMR picks up the new styles without a JS re-init. Idempotent hydration confirmed by clicking through several view-transition navigations and verifying only one click handler per button via the DOM inspector.
Time-to-Ship
Empty Span__GetInvolved* file at start of session. Six files later (DB schema delta, API endpoint, component, two galleries, two propose pages), the feature is live in dev and ready to push to Turso for tomorrow. Roughly four hours of work including the capture-phase-stopPropagation debugging detour and the tooltip-UX iteration. The user is going to bed. The launch is at 11:00 EST.
The icon row is the kind of feature where the design and the code are unremarkable in isolation, but the leverage is huge: every card on the site now has a one-tap commitment affordance, the data lands in a queryable table, and tomorrow’s host has a real-time view of the room’s intent without anyone having to fill out a form.
Files modified (7)
sites/fullstack-vc/db/config.tssites/fullstack-vc/src/pages/api/participation-interest.tssites/fullstack-vc/src/components/forms/Span__GetInvolvedOptionScale--IconRow.astrosites/fullstack-vc/src/components/sections/Section__ProjectGallery.astrosites/fullstack-vc/src/components/sections/Section__WorkingGroupGallery.astrosites/fullstack-vc/src/pages/projects/propose.astrosites/fullstack-vc/src/pages/working-groups/propose.astro