/*
Canonical composable UI effects contract for shared sections and components.
Owns effect targets, discriminated effect kinds, assignment arrays, and
schema validation for the RFC-0134 glass-first effects model.
- Do not render effects — rendering belongs to @warpgogol/werkstatt-site/ui.
- Do not implement future effect kinds beyond glass in the initial wave.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- warpgogol-m000200: slot:* effect targets admit the generic surface pair (glass, shadow) — component-defined slots such as the footer's slot:footer-below pass strict effectAssignmentSchema validation.
- warpgogol-m000200: shadow effect gains `inset` — an inward cast for bottom-edge surfaces (e.g. slot:footer-below) that must not reserve document space below the element; box-shadow ink overflow never extends the scroll area, so outward bottom casts are unrenderable there.
*/
import { z } from "zod";
const NAMED_EFFECT_TARGETS = [
"section",
"body",
"panel",
"item",
"card",
"row",
"column",
"media",
"cta",
"heading", // RFC-0151 — typographic effects on the section heading
"subheading", // typographic effects on the section subheading/tagline
"background", // lighten overlay on the section background image
] as const;
export const namedEffectTargetSchema = z.enum(NAMED_EFFECT_TARGETS);
const slotEffectTargetSchema = z.string().regex(/^slot:[a-z0-9-]+$/);
export const effectTargetSchema = z.union([namedEffectTargetSchema, slotEffectTargetSchema]);
export type EffectTarget = z.infer;
export const glassEffectSchema = z
.object({
kind: z.literal("glass"),
enabled: z.boolean(),
blur: z.number().min(0).max(64).optional(),
saturate: z.number().min(0).max(400).optional(),
tint: z.string().min(1).optional(),
tintOpacity: z.number().min(0).max(1).optional(),
border: z.enum(["hairline", "none"]).optional(),
})
.strict();
export type GlassEffect = z.infer;
/**
* RFC-0151 — shared color alias for typographic effects. Either a biome token
* alias resolved by the renderer, or a raw passthrough value (e.g. "#0af").
*/
export const effectColorSchema = z.union([
z.enum(["text", "primary", "accent", "shadow", "surface", "inverse"]),
z.string().min(1),
]);
export type EffectColor = z.infer;
export const shadowEffectSchema = z
.object({
kind: z.literal("shadow"),
enabled: z.boolean(),
offsetX: z.number().min(-64).max(64).optional(),
offsetY: z.number().min(-64).max(64).optional(),
blur: z.number().min(0).max(64).optional(),
/** Box-shadow spread — only meaningful for the surface (section) render. */
spread: z.number().min(-64).max(64).optional(),
/** Paint the cast INSIDE the surface edge instead of outside it — used by
bottom-edge casts (e.g. slot:footer-below) that must not reserve page
space below the element. Text-shadow strategy ignores inset. */
inset: z.boolean().optional(),
color: effectColorSchema.optional(),
opacity: z.number().min(0).max(1).optional(),
})
.strict();
export type ShadowEffect = z.infer;
export const glowEffectSchema = z
.object({
kind: z.literal("glow"),
enabled: z.boolean(),
blur: z.number().min(0).max(96).optional(),
color: effectColorSchema.optional(),
opacity: z.number().min(0).max(1).optional(),
})
.strict();
export type GlowEffect = z.infer;
export const bulgeEffectSchema = z
.object({
kind: z.literal("bulge"),
enabled: z.boolean(),
depth: z.number().min(0).max(16).optional(),
highlight: z.number().min(0).max(1).optional(),
shade: z.number().min(0).max(1).optional(),
shadowColor: effectColorSchema.optional(),
highlightColor: effectColorSchema.optional(),
})
.strict();
export type BulgeEffect = z.infer;
export const tiltEffectSchema = z
.object({
kind: z.literal("tilt"),
enabled: z.boolean(),
rotate: z.number().min(-15).max(15).optional(),
skewX: z.number().min(-15).max(15).optional(),
})
.strict();
export type TiltEffect = z.infer;
export const lightenEffectSchema = z
.object({
kind: z.literal("lighten"),
enabled: z.boolean(),
/** 0..1; opacity of the lightening overlay above the background image. */
opacity: z.number().min(0).max(1).optional(),
/** Color alias or raw value for the overlay. Defaults to --ds-color-bg. */
color: effectColorSchema.optional(),
})
.strict();
export type LightenEffect = z.infer;
export const fadeEffectSchema = z
.object({
kind: z.literal("fade"),
enabled: z.boolean(),
/** Edge the fade rises from. Default: "bottom". */
edge: z.enum(["top", "bottom", "left", "right"]).optional(),
/** Color alias or raw value for the fade color. Defaults to --ds-color-bg. */
color: effectColorSchema.optional(),
/** 0..1; opacity of the fade color at the edge. Default 1. */
opacity: z.number().min(0).max(1).optional(),
/** 0..0.5; fade band width as a fraction of the element. Default 0.35. */
extent: z.number().min(0).max(0.5).optional(),
})
.strict();
export type FadeEffect = z.infer;
export const effectSchema = z.discriminatedUnion("kind", [
glassEffectSchema,
shadowEffectSchema,
glowEffectSchema,
bulgeEffectSchema,
tiltEffectSchema,
lightenEffectSchema,
fadeEffectSchema,
]);
export type Effect = z.infer;
export type EffectKind = Effect["kind"];
export type EffectRenderStrategy = "surface" | "text";
/** Kinds that render under both strategies (text-shadow vs box-shadow) declare "both". */
type EffectKindStrategy = EffectRenderStrategy | "both";
/**
* RFC-0151 — per-kind metadata. Single source of truth shared by the stack
* validator (maxPerStack) and the @warpgogol/werkstatt-site/ui renderer (strategy).
*/
export const EFFECT_KIND_META = {
glass: { strategy: "surface", maxPerStack: 1 },
shadow: { strategy: "both", maxPerStack: 1 },
glow: { strategy: "text", maxPerStack: 1 },
bulge: { strategy: "text", maxPerStack: 1 },
tilt: { strategy: "text", maxPerStack: 1 },
lighten: { strategy: "surface", maxPerStack: 1 },
fade: { strategy: "surface", maxPerStack: 1 },
} as const satisfies Record;
/**
* RFC-0151 — which effect kinds each target accepts. Targets absent from this
* map default to glass-only (the RFC-0134 surface behavior). The `heading`
* target accepts only the typographic (text-strategy) kinds.
*/
export const TARGET_ALLOWED_KINDS: Record = {
heading: ["shadow", "glow", "bulge", "tilt"],
subheading: ["shadow", "glow", "bulge", "tilt"],
background: ["lighten", "fade"],
/** Cast shadow below the section boundary (box-shadow), plus surface glass. */
section: ["glass", "shadow"],
};
const DEFAULT_ALLOWED_KINDS: readonly EffectKind[] = ["glass"];
/**
* Component-defined named slots (`slot:*`) are surface attachment points
* owned by the component contract (RFC-0134) — e.g. the footer's
* `slot:footer-below` carries a second cast layer. They accept the generic
* surface pair, same as `section`; the component declares which named slots
* it resolves.
*/
const SLOT_ALLOWED_KINDS: readonly EffectKind[] = ["glass", "shadow"];
/** RFC-0151 — kinds accepted by a target, falling back to the glass-only default. */
export function allowedKindsForTarget(target: EffectTarget): readonly EffectKind[] {
if (target.startsWith("slot:")) return SLOT_ALLOWED_KINDS;
return TARGET_ALLOWED_KINDS[target] ?? DEFAULT_ALLOWED_KINDS;
}
export const effectStackSchema = z
.array(effectSchema)
.min(1)
.superRefine((stack, ctx) => {
const counts = new Map();
for (const effect of stack) {
counts.set(effect.kind, (counts.get(effect.kind) ?? 0) + 1);
}
for (const [kind, count] of counts) {
const max = EFFECT_KIND_META[kind].maxPerStack;
if (count > max) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Only ${max} ${kind} effect(s) allowed per effect stack.`,
});
}
}
});
export const effectAssignmentSchema = z
.object({
target: effectTargetSchema,
stack: effectStackSchema,
})
.strict()
.superRefine((assignment, ctx) => {
// RFC-0151 — target × kind admissibility. A heading accepts only the
// typographic kinds; surface targets accept only glass. This is the
// contract guard the RFC describes, enforced wherever content is validated.
const allowed = allowedKindsForTarget(assignment.target);
for (const effect of assignment.stack) {
if (!allowed.includes(effect.kind)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["stack"],
message: `Target '${assignment.target}' supports kinds [${allowed.join(", ")}], not '${effect.kind}'.`,
});
}
}
});
export type EffectAssignment = z.infer;
export const effectsSchema = z.array(effectAssignmentSchema).min(1);
export type Effects = z.infer;
export function resolveEffectsForTarget(
assignments: readonly EffectAssignment[] | undefined,
target: EffectTarget,
): Effect[] {
return assignments?.find((assignment) => assignment.target === target)?.stack ?? [];
}
export function hasEnabledGlassEffect(effects: readonly Effect[] | undefined): effects is Effect[] {
return Boolean(effects?.some((effect) => effect.kind === "glass" && effect.enabled));
}
export function resolveGlassEffect(
effects: readonly Effect[] | undefined,
): GlassEffect | undefined {
return effects?.find(
(effect): effect is GlassEffect => effect.kind === "glass" && effect.enabled,
);
}