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