1/**
2 * Single source of truth for Alchemy's brand mark — a Sri-Yantra-style
3 * downward water triangle inscribed in a circle, with the centroid dot.
4 *
5 * Both the runtime Astro component and the build-time asset generators
6 * (favicon, OG images) consume this module so the geometry is defined once.
7 */
8
9const YANTRA = {
10 viewBox: [0, 0, 24, 24],
11 center: 12,
12 circleRadius: 9.5,
13 binduRadius: 1.1,
14 strokeWidth: 1.1,
15} as const;
16
17/**
18 * Path for the equilateral triangle (apex down), centered on the circle:
19 *
20 * apex : (cx, cy + r)
21 * top-left : (cx - r·cos30°, cy - r·sin30°)
22 * top-right: (cx + r·cos30°, cy - r·sin30°)
23 *
24 * `r` is the circle's radius pulled in by a quarter stroke width. A round join
25 * extends half a stroke width past the vertex, so this lands each tip's outer
26 * edge midway between the ring's centerline and its outer edge: far enough in
27 * that the tips don't bulge the ring's silhouette, but still deep enough into
28 * the band that the corners read as merged rather than butted up against it.
29 * (A half-width inset stops the tips at the centerline, which opens a visible
30 * notch at each corner.) The triangle's centroid stays at the circle center
31 * regardless, so the bindu is shared.
32 *
33 * Derived rather than transcribed, so the coordinates cannot drift from the
34 * formulas above. Rounded to 4 decimals — well under a pixel at any size we
35 * rasterize.
36 */
37function yantraTrianglePath(): string {
38 const r = YANTRA.circleRadius - YANTRA.strokeWidth / 4;
39 const round = (n: number) => Number(n.toFixed(4));
40 const dx = round(r * Math.cos(Math.PI / 6));
41 const topY = round(YANTRA.center - r * Math.sin(Math.PI / 6));
42 const apexY = round(YANTRA.center + r);
43 return `M${YANTRA.center} ${apexY} L${YANTRA.center - dx} ${topY} L${YANTRA.center + dx} ${topY} Z`;
44}
45
46/**
47 * Brand palette, one entry per theme — mirrors tokens.css. Every rendering of
48 * the mark is painted from one of these two entries, so the favicons, app
49 * icons, OG cards and README hero can't drift apart.
50 */
51export const YANTRA_THEMES = {
52 light: {
53 /** `--alc-accent-deep` */
54 stroke: "#3f5a2a",
55 /** `--alc-terracotta-deep`, mirrored by `--alc-yantra-dot` */
56 dot: "#9a4f27",
57 /** `--alc-bg`. Only used where an asset must be opaque. */
58 bg: "#f5efe3",
59 },
60 dark: {
61 /** `--alc-accent-deep` (dark block) */
62 stroke: "#a3c473",
63 /** `--alc-terracotta` (dark block), mirrored by `--alc-yantra-dot` */
64 dot: "#d8835a",
65 /** `--alc-bg` (dark block) */
66 bg: "#14110d",
67 },
68} as const;
69
70export type YantraTheme = keyof typeof YANTRA_THEMES;
71
72export interface YantraOptions {
73 /** Pixel size of the rendered SVG (square). Default 24. */
74 size?: number;
75 /** Brand palette. Default light; `auto` follows theme tokens in inline SVG. */
76 theme?: YantraTheme | "auto";
77 /** Output format. `svg` for inline SVG, `url` for data URL. Default `svg`. */
78 output?: "svg" | "url";
79}
80
81export function yantraSvg({
82 size = 24,
83 theme = "light",
84 output = "svg",
85}: YantraOptions = {}): string {
86 const colors = YANTRA_THEMES[theme === "auto" ? "light" : theme];
87
88 const stroke =
89 theme === "auto"
90 ? `var(--alc-accent-deep, ${colors.stroke})`
91 : colors.stroke;
92
93 const dot =
94 theme === "auto" ? `var(--alc-yantra-dot, ${colors.dot})` : colors.dot;
95
96 const svg = [
97 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${YANTRA.viewBox.join(" ")}" fill="none" stroke="${stroke}" stroke-width="${YANTRA.strokeWidth}" stroke-linecap="round" stroke-linejoin="round">`,
98 `<circle cx="${YANTRA.center}" cy="${YANTRA.center}" r="${YANTRA.circleRadius}"/>`,
99 `<path d="${yantraTrianglePath()}"/>`,
100 `<circle cx="${YANTRA.center}" cy="${YANTRA.center}" r="${YANTRA.binduRadius}" fill="${dot}" stroke="none"/>`,
101 `</svg>`,
102 ].join("");
103
104 return output === "url"
105 ? `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`
106 : svg;
107}