Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/apursley2012/systems-witch/llms.txt

Use this file to discover all available pages before exploring further.

The Contact page merges a standard contact form with a generative art canvas. As the user types their message, a sigil is drawn in real time on a 250×250 canvas to the right of the form — each character’s ASCII code determines the angle and radius of a point in a closed path, producing a unique geometric glyph for every message. The form advances through three states (idle, casting, sent) and clears after a simulated transmission.

Layout

The page uses a two-column grid on md+ screens:
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-start">
  <form>   {/* left column */}
  <div>    {/* right column — canvas container */}
</div>
On mobile, the form stacks above the canvas.

Form fields

The form has two inputs and a submit button, all inside a space-y-6 font-mono form element:
<input
  type="email"
  required
  placeholder="SENDER_ADDRESS"
  className="w-full bg-black border border-graphite p-3 text-bone
             focus:border-acid focus:outline-none transition-colors
             placeholder:text-graphite text-sm"
  disabled={state !== "idle"}
/>

<textarea
  required
  value={message}
  onChange={e => setMessage(e.target.value)}
  placeholder="ENTER_INCANTATION..."
  rows={6}
  className="w-full bg-black border border-graphite p-3 text-bone
             focus:border-acid focus:outline-none transition-colors
             placeholder:text-graphite text-sm resize-none"
  disabled={state !== "idle"}
/>
Both fields use placeholder:text-graphite — placeholders are nearly invisible until the user focuses, maintaining the terminal aesthetic. The disabled prop locks both fields once submission begins.

Submit button states

The single <button type="submit"> cycles through three text values based on state:
StateButton textButton style
idle (message non-empty)[ CAST_SIGIL ]border-acid text-acid hover:bg-acid hover:text-black shadow-glow
idle (message empty)[ CAST_SIGIL ]border-graphite text-graphite cursor-not-allowed
castingTRANSMITTING...border-graphite text-graphite cursor-not-allowed
sentTRANSMISSION_SUCCESSborder-graphite text-graphite cursor-not-allowed
The shadow-glow class (box-shadow: 0 0 10px rgba(164,255,61,.5)) is only active in the idle+ready state, creating a subtle acid aura around the active button.

Form submission handler

const handleSubmit = (e) => {
  e.preventDefault();
  if (!message) return;
  setState("casting");
  // Replace with real submission logic:
  setTimeout(() => {
    setState("sent");
    setMessage("");
  }, 2000);
};
The guard if (!message) return provides a second layer of protection beyond the disabled attribute. In the default build, the 2-second setTimeout simulates a network request. When state reaches "sent", setMessage("") clears the textarea and triggers a useEffect re-run that clears the canvas (because the message length drops to 0).
To connect to a real backend, replace the setTimeout block with your submission logic — EmailJS, Formspree, a fetch() POST to an API route, or any other method. Keep the setState("casting") call before it and setState("sent") in the success callback.

Canvas sigil algorithm

The canvas is a 250×250 <canvas> element accessed via useRef. A useEffect dependent on [message] redraws the sigil every time the message string changes:
useEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;
  const ctx = canvas.getContext("2d");
  if (!ctx) return;

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  if (message.length === 0) return;

  // Shared stroke style
  ctx.strokeStyle = "#A4FF3D";
  ctx.lineWidth = 1.5;
  ctx.lineCap = "round";
  ctx.lineJoin = "round";
  ctx.shadowBlur = 10;
  ctx.shadowColor = "#A4FF3D";

  const cx = canvas.width / 2;
  const cy = canvas.height / 2;
  const maxRadius = Math.min(cx, cy) - 20;

  // Draw character path
  ctx.beginPath();
  for (let i = 0; i < message.length; i++) {
    const code = message.charCodeAt(i);
    const angle  = (code % 36) * 10 * (Math.PI / 180); // 0–350° in 10° steps
    const radius = (code % 100) / 100 * maxRadius;      // 0–100% of max radius
    const x = cx + Math.cos(angle) * radius;
    const y = cy + Math.sin(angle) * radius;
    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  if (message.length > 5) ctx.closePath();  // close shape once >5 chars
  ctx.stroke();

  // Outer circle when message is long enough
  if (message.length > 10) {
    ctx.beginPath();
    ctx.arc(cx, cy, maxRadius, 0, Math.PI * 2);
    ctx.strokeStyle = "rgba(164, 255, 61, 0.3)";
    ctx.stroke();
  }
}, [message]);
The algorithm maps each character to a polar coordinate:
  • Angle: charCode % 36 * 10° — divides the full circle into 36 discrete positions (every 10°)
  • Radius: (charCode % 100) / 100 * maxRadius — normalizes to 0–100% of the available canvas radius
Once the message exceeds 5 characters the path closes into a polygon. Once it exceeds 10 characters a faint outer circle appears at 30% acid opacity, framing the sigil. All drawing uses acid green (#A4FF3D) with shadowBlur: 10 and shadowColor: "#A4FF3D" — the canvas shadowBlur property creates the neon glow effect natively without CSS.

Canvas container

The canvas sits inside a relative w-64 h-64 border border-graphite box. A subtle linear-gradient grid (10% opacity, 20px × 20px cells) covers the entire box as an absolutely-positioned background layer:
<div
  className="absolute inset-0 opacity-10"
  style={{
    backgroundImage: "linear-gradient(#A4FF3D 1px, transparent 1px), linear-gradient(90deg, #A4FF3D 1px, transparent 1px)",
    backgroundSize: "20px 20px"
  }}
/>
When state === "casting", the container gains animate-pulse-glow border-acid. When state === "sent", the canvas fades to opacity-0 and an overlay text block appears in its place:
SIGIL BURNED.
MESSAGE RECEIVED.

State machine summary

idle ──(submit)──► casting ──(2 s timeout)──► sent
                                              └── clears message
                                              └── canvas fades out
                                              └── overlay appears
There is no path from sent back to idle in the default implementation — the page effectively becomes read-only after one successful submission. To allow re-submission, add a “Send another” button that calls setState("idle").
The email input has type="email" and required, so the browser’s native form validation will block submission if the address is malformed. The textarea is also required. These validations fire before handleSubmit is called.

Build docs developers (and LLMs) love