/* 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, ); }