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:
parent
3cb76b33c8
commit
6f23c47e7a
1 changed files with 145 additions and 0 deletions
145
src/components/CouncilMark.astro
Normal file
145
src/components/CouncilMark.astro
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue