Files
com.gregkilmer.www/packages/ui/src/lib/ornaments.ts
T
Claude 807528a613 Phase 4: component library
The approved element rules and ornament kit as Astro components in
packages/ui: buttons, pills, chips, filters, kickers, notices, fields and
cards; star paths, the header divider, tick scales and rings, seals and
edge seals, gilt corners, dust and placeholder art; the site header,
footer and page shell. Brand fonts are self-hosted. A gallery page shows
everything, and astro check now runs with the root typecheck.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 20:44:16 +00:00

163 lines
5.8 KiB
TypeScript

/**
* Geometry for the ornament kit, as pure functions so every page draws the
* same shapes and tests can pin them down. Randomness is seeded, so a star
* path looks the same on every build.
*/
export const colors = {
sky: "#0E1522",
midnight: "#131C2B",
lines: "#26344A",
linesStrong: "#3A4C66",
starlight: "#F4F2ED",
text: "#ECE9E2",
soft: "#C8D3D8",
muted: "#9FB3BD",
teal: "#14A39A",
tealBright: "#2BC2B7",
gold: "#C9A24A",
goldBright: "#D8B35A",
violetOnDark: "#9A70F5",
/** Light mode only. */
violet: "#6A2FE0",
goldDeep: "#8A6516",
tealDeep: "#0A726B",
} as const;
/** A small, fast seeded generator (mulberry32). */
export function seeded(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** A string to a seed, so components can be seeded by a name. */
export function seedOf(text: string): number {
let h = 2166136261;
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619);
return h >>> 0;
}
const r1 = (n: number) => Math.round(n * 10) / 10;
/**
* The thin four-point star used everywhere: points of a polygon centered on
* (cx, cy) with outer radius r. The waist stays at 16% so it never reads as a
* cross.
*/
export function starPoints(cx: number, cy: number, r: number): string {
const w = r * 0.16;
const pts: [number, number][] = [
[cx, cy - r],
[cx + w, cy - w],
[cx + r, cy],
[cx + w, cy + w],
[cx, cy + r],
[cx - w, cy + w],
[cx - r, cy],
[cx - w, cy - w],
];
return pts.map(([x, y]) => `${r1(x)},${r1(y)}`).join(" ");
}
export type StarPathMark = { kind: "dot"; x: number; y: number; r: number } | { kind: "star"; x: number; y: number; r: number; gold: boolean };
export interface StarPath {
/** Dotted segments between marks, stopping short of each. */
links: { x1: number; y1: number; x2: number; y2: number }[];
marks: StarPathMark[];
}
export const strengths = { calm: 0.1, standard: 0.22, wild: 0.42 } as const;
/**
* Greg's star path: a straight line replaced by evenly spread stars, each
* nudged off the line, joined by dotted segments. About a third of the points
* are small violet stars, the rest Starlight dots. Ends stay on the line.
*/
export function starPath(o: {
x1: number;
x2: number;
y: number;
steps: number;
seed: number;
strength?: keyof typeof strengths;
/** Put a large gold star at the middle point. */
centerStar?: boolean;
/** Leave the end points off, when a gold line or star already marks them. */
openEnds?: boolean;
gap?: number;
dot?: number;
star?: number;
}): StarPath {
const rand = seeded(o.seed);
const step = (o.x2 - o.x1) / o.steps;
const nudge = strengths[o.strength ?? "standard"] * Math.abs(step);
const gap = o.gap ?? 4;
const points = Array.from({ length: o.steps + 1 }, (_, i) => {
const ends = i === 0 || i === o.steps;
return { x: o.x1 + step * i, y: ends ? o.y : o.y + (rand() * 2 - 1) * nudge };
});
const mid = Math.round(o.steps / 2);
const marks: StarPathMark[] = points.map((p, i) => {
if (o.centerStar && i === mid) return { kind: "star", x: r1(p.x), y: r1(p.y), r: (o.star ?? 4) * 3, gold: true };
// Every third point, offset by the seed, is a small star.
const isStar = (i + (o.seed % 3)) % 3 === 1;
return isStar ? { kind: "star", x: r1(p.x), y: r1(p.y), r: o.star ?? 4, gold: false } : { kind: "dot", x: r1(p.x), y: r1(p.y), r: o.dot ?? 1.5 };
});
const links = points.slice(1).map((b, i) => {
const a = points[i]!;
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const ga = gap + (marks[i]!.kind === "star" ? marks[i]!.r * 0.5 : 0);
const gb = gap + (marks[i + 1]!.kind === "star" ? marks[i + 1]!.r * 0.5 : 0);
return { x1: r1(a.x + ux * ga), y1: r1(a.y + uy * ga), x2: r1(b.x - ux * gb), y2: r1(b.y - uy * gb) };
});
const drop = (i: number) => o.openEnds && (i === 0 || i === points.length - 1);
return { links, marks: marks.filter((_, i) => !drop(i)) };
}
/** Tick marks for a scale: a long tick every `major`, short ones between. */
export function ticks(width: number, count: number, major = 5): { x: number; long: boolean }[] {
const step = width / count;
return Array.from({ length: count + 1 }, (_, i) => ({ x: r1(0.5 + step * i), long: i % major === 0 }));
}
/** Ticks around a circle, for tick rings on step numbers and medallions. */
export function ringTicks(size: number, count = 48, major = 4): { x1: number; y1: number; x2: number; y2: number; long: boolean }[] {
const c = size / 2;
const outer = c - 1;
return Array.from({ length: count }, (_, i) => {
const a = (i / count) * Math.PI * 2;
const long = i % major === 0;
const inner = outer - (long ? size * 0.066 : size * 0.033);
const f = (n: number) => Math.round(n * 100) / 100;
return { x1: f(c + Math.cos(a) * outer), y1: f(c + Math.sin(a) * outer), x2: f(c + Math.cos(a) * inner), y2: f(c + Math.sin(a) * inner), long };
});
}
/**
* Constellation dust: sparse specks for a page background as CSS
* radial-gradients, about one in five gold. Static by design.
*/
export function dust(count: number, seed: number): string {
const rand = seeded(seed);
const specks: string[] = [];
for (let i = 0; i < count; i++) {
const size = [1, 1, 1.2, 1.6][Math.floor(rand() * 4)];
const alpha = [0.25, 0.35, 0.6][Math.floor(rand() * 3)];
const gold = rand() < 0.2;
const color = gold ? `rgba(216,179,90,${alpha})` : `rgba(236,233,226,${alpha})`;
specks.push(`radial-gradient(${size}px ${size}px at ${r1(rand() * 100)}% ${r1(rand() * 100)}%, ${color}, transparent)`);
}
return specks.join(", ");
}