feat(component): CouncilMark — generative member sigil

SVG component, sizes sm (48) / md (100) / lg (200) on a 120 viewBox.

12 quarter positions on a circle, i=0 = current quarter at 12 o'clock,
going clockwise into the past. A position is lit iff the member has a
roadmap_attribution to an item with shipped_at in that quarter. Pulse
votes do NOT light a quarter — the sigil represents consequential
contribution, not participation.

Visual layers:
- faint dotted outer ring rotating 90s linear
- small grey dots on empty quarters
- terracotta-filled polygon (15% fill) connecting lit dots, draws in
  over 1.6s on mount via stroke-dasharray
- terracotta dots on lit quarters with staggered 2.4s breathing
- serif italic initials in the centre, walnut secondary

Uses --pigment-terracotta instead of the spec's coral — no new tokens.
Honours prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 14:48:47 +02:00
parent 3cb76b33c8
commit 6f23c47e7a

View file

@ -0,0 +1,145 @@
---
import type { UserPublic } from '../lib/db';
import { getLitQuarters } from '../lib/db';
interface Props {
member: UserPublic;
size?: 'sm' | 'md' | 'lg';
}
const { member, size = 'md' } = Astro.props;
const SIZES = { sm: 48, md: 100, lg: 200 } as const;
const px = SIZES[size];
// 12 positions on a circle, i=0 = current quarter at 12 o'clock, going clockwise
// into the past. A position is "lit" iff the member has a roadmap_attribution
// to an item with shipped_at falling in that quarter. Pulse votes do NOT light.
const lit = getLitQuarters(member.id);
const litArr = [...lit].sort((a, b) => a - b);
const CENTER = 60;
const RADIUS = 42;
function pointAt(i: number) {
const angle = -Math.PI / 2 + (i * Math.PI * 2) / 12;
return { x: CENTER + RADIUS * Math.cos(angle), y: CENTER + RADIUS * Math.sin(angle) };
}
const litPoints = litArr.map(pointAt);
const polygonPoints = litPoints.length >= 3
? litPoints.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
: null;
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return (parts[0][0] ?? '').toUpperCase();
return ((parts[0][0] ?? '') + (parts[parts.length - 1][0] ?? '')).toUpperCase();
}
---
<svg
class:list={['council-mark', `cm-${size}`]}
width={px}
height={px}
viewBox="0 0 120 120"
role="img"
aria-label={`${member.name} — council mark`}
>
<g class="cm-ring">
<circle cx="60" cy="60" r="54" fill="none" stroke="currentColor"
stroke-width="0.5" stroke-dasharray="1 4" stroke-linecap="round"
opacity="0.35" />
</g>
{Array.from({ length: 12 }).map((_, i) => {
if (lit.has(i)) return null;
const p = pointAt(i);
return <circle cx={p.x} cy={p.y} r="1.5" fill="var(--on-surface-muted)" opacity="0.45" />;
})}
{polygonPoints && (
<polygon
points={polygonPoints}
fill="var(--pigment-terracotta)"
fill-opacity="0.15"
stroke="var(--pigment-terracotta)"
stroke-width="0.8"
stroke-opacity="0.6"
class="cm-polygon"
/>
)}
{litArr.map((i, idx) => {
const p = pointAt(i);
return (
<circle
cx={p.x} cy={p.y} r="3.5"
fill="var(--pigment-terracotta)"
class="cm-dot"
style={`animation-delay: ${(idx * 0.4).toFixed(2)}s`}
/>
);
})}
<text
x="60" y="60"
text-anchor="middle"
dominant-baseline="central"
class="cm-initials"
>{initials(member.name)}</text>
</svg>
<style>
.council-mark {
display: block;
color: var(--on-surface-muted);
}
.cm-ring {
transform-origin: 60px 60px;
transform-box: view-box;
animation: cm-ring-spin 90s linear infinite;
}
@keyframes cm-ring-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.cm-polygon {
stroke-dasharray: 2000;
stroke-dashoffset: 2000;
animation: cm-polygon-draw 1.6s var(--ease-standard) forwards;
}
@keyframes cm-polygon-draw {
to { stroke-dashoffset: 0; }
}
.cm-dot {
animation: cm-dot-breathe 2.4s ease-in-out infinite;
}
@keyframes cm-dot-breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.cm-initials {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
fill: var(--secondary);
}
.cm-sm .cm-initials { font-size: 12px; }
.cm-md .cm-initials { font-size: 16px; }
.cm-lg .cm-initials { font-size: 24px; }
/* sm scale: drop the polygon — too dense at 48px and dots speak for themselves */
.cm-sm .cm-polygon { display: none; }
@media (prefers-reduced-motion: reduce) {
.cm-ring, .cm-polygon, .cm-dot { animation: none; }
.cm-polygon { stroke-dashoffset: 0; }
}
</style>