Are you using AI to make websites? If so, how?

AI is everywhere now. While a lot of it feels like shoehorning it into a workflow just for the sake of it, in some instances, it’s genuinely handy.

For website building, the obvious use is getting it to turn your design ideas into code. I tried this as an experiment, and it was a complete waste of time. But maybe some of you have had more success? If so, I’d love to hear about it.

For me, though, AI has been a gamechanger, not for the design or building, but for the content.

I’ve been using it on and off for about a year to tune content, but a few months ago I decided to use it to create all the content for a rebuild of my own business websites. The results were staggering.

First, I gave it a massive amount of info about the business, the tone I wanted, and how I operate. I even fed it all my old blog posts so it could learn my style. Then, I built the structure of the new site and more or less handed things over. It saw the page titles, suggested what content should go where, and then created it for me, in my style. It took a few attempts to get the “feel” right, but once we hit on the formula, it fired through each page super-fast, suggesting the various page sections and creating the content.

It also created all the metadata, alt tags, and that sort of thing; essentially the boring stuff you always leave to the end and end up rushing.

What it produced was “me”, but better (impossible I hear you say!). It kept my “feel” and tone, but rounded it out for a wider audience and worked in strong key phrases. It also did a great job structuring each page so the content flowed well, and even suggested on-site links and a couple of additional pages for SEO value.

One of the best things was having “something” to bounce ideas off and ask potentially stupid questions to, without worrying about sounding dumb! It really was like having another person on the team.

The end result was fantastic. It was entirely my design, but AI took the lead on the content, resulting in an infinitely better finished product. It’s actually reignited my interest in building websites because it’s taken one of the biggest challenges, creating the content, and turned it into an interesting and quick process.

I’m now in the middle of building two client sites the same way. The process has been the same, and the results are already far better than I could have managed on my own.

I’m really interested in hearing how others are using AI when it comes to creating websites. What are your experiences?

(This post was created by me, but improved by AI!)

4 Likes

So far, i’ve only used AI to write some custom code as part of two projects. The design on those projects is still ‘all me’.

But I have been toying around with AI for other purposes, and this has gone REALLY well. This includes tweaking simulations, help in writing some Windows specific C++ code for a client and just some back-and-forth for advice.

I’m currently on holiday (just logging in to do some taxes and checking a forum or two). I’m planning on doing some trials with AI-led website building later this year to see what the fuzz is about and if it’s something that can be of use to me.

1 Like

Same for me, I’ve used it to brainstorm content and it has (generally) done a really good job. For the last site I did, the client was to develop the content but was struggling so I offered to get a start on things and she basically took it as is. I knew the tone she wanted and within a couple of refining prompts, ChatGPT basically nailed it as well as help me generate a clever blog title, page descriptions etc

@TemplateRepo Did you use a certain AI for that coding task? Chat GPT (free or paid) or Claude or Perplexity…?

I use Gemini Pro for all AI stuff now. Even cancelled my Shutterstock account and use Gemini for images too.

(Shutterstock was pretty pants for AI images, but it did come with a good indemnity package.)

2 Likes

I am not hitting my daily prompt limit for ChatGPT often :wink: I think it’s quite good.

1 Like

Thanks @TemplateRepo and @Jannis! Currently using Midjourney (paid) for images and Chat GPT for all the rest…

2 Likes

Have played with a few tools with varying degrees of success. If you can provide strong guidance then the output can be excellent - even to create fully functional web apps.

Check out the likes of Replit, Lovable and Bolt etc. There are kids using these kinds of tools to build software businesses without knowing how to write a single line of code.

I built out a full site with my (7 year old) son who had an idea for an story-building app where you earn characters and locations by answering questions about stories which can then be used by the user to dynamically build out a whole new story. It is fully functional and the first iteration was up and running in less than half an hour. Blew me away.

3 Likes

Fecking 7 year olds. Will put us all out of jobs one day.

:wink:

2 Likes

I didn’t test, but looks interesting: GitHub Spark - Dream it. See it. Ship it. · GitHub

1 Like

Also started to use AI for content. I often find clients want to create content for their websites, but find it hard to get started. In the past, I would write something so they don’t have a blank canvas, now I’m starting to get AI to write it. It’s never the finished product but it does get the ball rolling and creates ideas. Not really tried it the way you have but will give it a go. My preferred platform so far is Claude.ai but it’s early days!

That’s how I started. Kinda.

I’d write something. Give it to AI to refine. Edit it myself and then let AI do a final pass. It was good for individual pieces, but not so much for, say, an entire website.

Gemini allows lots of “chats” to be open (not sure if others do). So now I open a chat about a client and spend time teaching Gemini about the client. I then explain the task, give it some guidance and let it go.

I find the results are as good as the “teaching” you first did at the start of the chat. The better you educate Gemini about the client, the better the output.

1 Like

I have to admit to using the B. stuff, but it is good for content creation - I tend to give it pre-written text and then prompt “Improve this text , optimising for AI search engines, emphasising a, b and C” and it works. It also creates keywords, descriptions and page schemas for me…

I use AI everyday – mostly ChatGPT (code) and Midjourney (images), but also tried Brizy Cloud AI (bought a lifetime access through App Sumo) to create a website - The AI feature was totally useless … Brizy Cloud works very well, but the AI feature – for now – forget it.

A heads up here - ChatGPT (paid) has been useful to create optimised, keyword rich text for many of my sites and it has worked very effectively judging by traffic increases. My start point was always existing text with prompted keywords and other requests to be added into the text and descriptions.

However…

Chat GPT can also create JSON page schemas for optimised searchability and therein lies the issue. The generated schemas constantly bring up google search console errors, with incorrect syntax, missing colons and the like. The more I tried to get AI to fix them, the more errors were flagged. Then I started to read the generated code and realised that ChatGPT was simply inventing certain information, despite the web page being used as source info. For example a 1 day course, running from 10am - 4pm and costing a few GBP£ suddenly became a 4 day event, 8 hours a day for GBP£500…no user intervention required!

Hopefully this warning may help others planning to use these tools?

ChatGPT 5 is very underrated because people got used to treating ChatGPT 4 like a chat buddy, but these tools aren’t actually built for that. When GPT-5 arrived, many were unsettled because they felt they had “lost” their old AI buddy. It’s a real pity it turned out that way. ChatGPT operates at a PhD level of intelligence. You can get incredible outputs on almost any subject. I can program with it, and most of the time the solution is done in the first iteration.

To help people get the most out of ChatGPT 5, I built a prompt helper called Prompt Smith, and over the last few days I’ve been finalizing version 2. It produces such powerful prompts that I was initially reluctant to release it. Instead of getting scared, I added safety measures so it won’t help people create prompts about false subjects, fake news, and the like. I haven’t officially released it yet, but to clear some of the fog around AI and what it can do, I’ll share the link here. Just bear in mind it’s still in beta, there are a few small things I need to fix. For now, it’s free to use. Try it and be amazed.

3 Likes

I just generated this simple game from a prompt that Prompt Smith created based on my very rough request: “I want a prompt that can get ChatGPT to generate a small game for me.” The game took about five minutes to generate, required no corrections, and the code ran without errors.

<!doctype html>
<!--
How to Play
Goal: Collect as many teal shards as you can before time runs out; avoid red seekers.
Controls: Arrow keys move. On touch/mouse, press and hold to guide the player.
Pause/Resume: Press P or Space. Restart: Press R or click Restart.
Toggle sound (muted by default) with the Sound button (or M).
-->
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
  <title>Shard Sprint</title>
  <style>
    :root{
      --bg:#121212; --ink:#ffffff; --accent1:#4ecdc4; --accent2:#ff6b6b; --muted:#2a2a2a;
    }
    *{box-sizing:border-box}
    html,body{height:100%;margin:0;background:var(--bg);color:var(--ink);font:16px/1.4 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif}
    header,footer{max-width:880px;margin:0 auto;padding:12px 16px}
    header{display:flex;gap:16px;align-items:center;justify-content:space-between}
    #hud{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
    .pill{padding:8px 12px;border-radius:999px;background:var(--muted);min-width:120px;text-align:center}
    .controls{display:flex;gap:8px;flex-wrap:wrap}
    button{
      padding:8px 12px;border-radius:12px;border:2px solid var(--ink);background:transparent;color:var(--ink);
      cursor:pointer;min-width:104px;font-weight:600;letter-spacing:.2px
    }
    button:focus{outline:3px solid var(--accent1);outline-offset:2px}
    button[aria-pressed="true"]{border-color:var(--accent1)}
    main{max-width:880px;margin:0 auto;padding:0 16px 16px}
    #wrap{position:relative; width:100%; aspect-ratio:16/9; background:#0b0b0b; border-radius:16px; overflow:hidden; box-shadow:0 8px 24px rgba(0,0,0,.5)}
    canvas{width:100%;height:100%;touch-action:none;display:block}
    #overlay{
      position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
      pointer-events:none
    }
    .banner{
      background:rgba(0,0,0,.72);border:2px solid var(--ink);color:var(--ink);
      padding:16px 24px;border-radius:16px;text-align:center;max-width:80%;
    }
    .banner h2{margin:0 0 8px;font-size:24px}
    .banner p{margin:4px 0}
    .accent {color:var(--accent1)}
    .warn {color:var(--accent2)}
    footer{opacity:.75;font-size:12px;text-align:center}
    @media (max-width:480px){
      .pill{min-width:auto}
      button{min-width:auto}
    }
  </style>
</head>
<body>
  <header>
    <div id="hud">
      <div id="score" class="pill" aria-live="polite">Score: 0</div>
      <div id="time" class="pill" aria-live="polite">Time: 60.0</div>
      <div id="status" class="pill">Running</div>
    </div>
    <div class="controls">
      <button id="pauseBtn" aria-pressed="false" title="Pause/Resume (P or Space)">Pause</button>
      <button id="restartBtn" title="Restart (R)">Restart</button>
      <button id="soundBtn" aria-pressed="false" title="Toggle Sound (M)">Sound: Off</button>
    </div>
  </header>
  <main>
    <div id="wrap" role="application" aria-label="Shard Sprint game area">
      <canvas id="c" tabindex="0" aria-label="Game Canvas"></canvas>
      <div id="overlay" aria-hidden="true">
        <div class="banner" id="banner" hidden>
          <h2 id="bannerTitle">Game Over</h2>
          <p id="bannerMsg">Press <span class="accent">R</span> to restart.</p>
        </div>
      </div>
    </div>
  </main>
  <footer>Colorblind-safe palette · High contrast UI · Keyboard-first controls</footer>

  <script>
  (()=>{"use strict";
    // --- Short helpers (no per-frame allocations) ---
    const $=sel=>document.querySelector(sel);
    const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
    const rand=(a,b)=>Math.random()*(b-a)+a;
    const dist=(ax,ay,bx,by)=>Math.hypot(ax-bx,ay-by);
    const sign=(x)=>x?x<0?-1:1:0;

    // --- Canvas & DPI scaling ---
    const canvas=$("#c"), ctx=canvas.getContext("2d");
    const wrap=$("#wrap");
    let W=800,H=450,dpr=1;
    function resize(){
      const r=wrap.getBoundingClientRect();
      dpr=window.devicePixelRatio||1;
      canvas.width=Math.floor(r.width*dpr);
      canvas.height=Math.floor(r.height*dpr);
      W=r.width; H=r.height;
      ctx.setTransform(dpr,0,0,dpr,0,0);
    }
    window.addEventListener("resize", resize,{passive:true}); resize();

    // --- UI elements ---
    const scoreEl=$("#score"), timeEl=$("#time"), statusEl=$("#status");
    const pauseBtn=$("#pauseBtn"), restartBtn=$("#restartBtn"), soundBtn=$("#soundBtn");
    const banner=$("#banner"), bannerTitle=$("#bannerTitle"), bannerMsg=$("#bannerMsg");

    // --- Audio (muted by default) ---
    let ac=null, muted=true;
    function ensureAC(){ if(!ac){ try{ ac=new (window.AudioContext||window.webkitAudioContext)(); }catch{} } }
    function beep(freq=440, dur=0.07, type="sine", vol=0.07){
      if(muted||!ac) return;
      const t=ac.currentTime;
      const o=ac.createOscillator(), g=ac.createGain();
      o.type=type; o.frequency.value=freq;
      g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(vol, t+0.005);
      g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
      o.connect(g).connect(ac.destination); o.start(t); o.stop(t+dur+0.02);
    }

    // --- Game state ---
    const state={
      running:true, over:false, win:false,
      score:0, time:60, target:25, difficulty:1,
      player:{x:0,y:0,r:10,speed:190,vx:0,vy:0},
      shards:[], enemies:[], input:{left:0,right:0,up:0,down:0},
      pointer:{active:false,x:0,y:0}
    };

    // --- Game setup/reset ---
    function reset(){
      state.running=true; state.over=false; state.win=false;
      banner.hidden=true; statusEl.textContent="Running";
      state.score=0; state.time=60; state.difficulty=1;
      state.player.r=Math.max(8,Math.min(W,H)*0.02);
      state.player.x=W*0.5; state.player.y=H*0.5; state.player.vx=0; state.player.vy=0;
      state.shards.length=0; state.enemies.length=0;
      for(let i=0;i<5;i++) spawnShard();
      for(let i=0;i<2;i++) spawnEnemy();
      updateHUD();
      canvas.focus();
    }

    // --- Entities creation ---
    function spawnShard(){
      // Keep away from player spawn
      let x,y,tries=0;
      do{ x=rand(16,W-16); y=rand(16,H-16); tries++; }while(dist(x,y,state.player.x,state.player.y)<64 && tries<25);
      state.shards.push({x,y,r:8});
    }
    function spawnEnemy(){
      // Spawn at edges aiming inward
      const side=Math.floor(rand(0,4));
      const pad=16;
      const pos=[
        {x:rand(pad,W-pad), y:pad}, {x:rand(pad,W-pad), y:H-pad},
        {x:pad, y:rand(pad,H-pad)}, {x:W-pad, y:rand(pad,H-pad)}
      ][side];
      state.enemies.push({x:pos.x,y:pos.y,r:10,base:70+rand(0,30)});
    }

    // --- Input: keyboard, mouse, touch ---
    function key(e,down){
      const k=e.key;
      if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].includes(k)) e.preventDefault();
      if(k==="ArrowLeft") state.input.left=down?1:0;
      if(k==="ArrowRight") state.input.right=down?1:0;
      if(k==="ArrowUp") state.input.up=down?1:0;
      if(k==="ArrowDown") state.input.down=down?1:0;
      if(down && (k==="p"||k==="P"||k===" ")) togglePause();
      if(down && (k==="r"||k==="R")) reset();
      if(down && (k==="m"||k==="M")) toggleSound();
    }
    document.addEventListener("keydown", e=>key(e,true));
    document.addEventListener("keyup", e=>key(e,false));

    // Pointer to guide the player smoothly
    function setPointer(e,active){
      const rect=canvas.getBoundingClientRect();
      const x=(e.clientX!==undefined?e.clientX:e.touches[0].clientX)-rect.left;
      const y=(e.clientY!==undefined?e.clientY:e.touches[0].clientY)-rect.top;
      state.pointer.active=active; state.pointer.x=x; state.pointer.y=y;
    }
    canvas.addEventListener("pointerdown", e=>{ ensureAC(); setPointer(e,true); canvas.setPointerCapture(e.pointerId); });
    canvas.addEventListener("pointermove", e=>{ if(state.pointer.active) setPointer(e,true); });
    canvas.addEventListener("pointerup",   e=>{ state.pointer.active=false; });
    canvas.addEventListener("touchstart", e=>{ ensureAC(); setPointer(e,true); },{passive:false});
    canvas.addEventListener("touchmove",  e=>{ if(state.pointer.active) setPointer(e,true); },{passive:false});
    canvas.addEventListener("touchend",   e=>{ state.pointer.active=false; },{passive:true});

    // --- Buttons ---
    pauseBtn.addEventListener("click", togglePause);
    restartBtn.addEventListener("click", reset);
    soundBtn.addEventListener("click", toggleSound);
    function togglePause(){
      if(state.over) return;
      state.running=!state.running;
      pauseBtn.setAttribute("aria-pressed", String(!state.running));
      pauseBtn.textContent=state.running?"Pause":"Resume";
      statusEl.textContent=state.running?"Running":"Paused";
      if(state.running) last=performance.now();
    }
    function toggleSound(){
      ensureAC();
      muted=!muted; if(ac && ac.state==="suspended" && !muted) ac.resume();
      soundBtn.setAttribute("aria-pressed", String(!muted));
      soundBtn.textContent=muted?"Sound: Off":"Sound: On";
      if(!muted) beep(600,0.05,"square",0.03);
    }

    // --- HUD update ---
    function updateHUD(){
      scoreEl.textContent="Score: "+state.score;
      timeEl.textContent="Time: "+state.time.toFixed(1);
    }

    // --- Difficulty ramp ---
    function rampDifficulty(){
      // Increase with time and score; spawn enemies periodically
      const s=state.score, t=state.time;
      const targetEnemies=2+Math.floor((25 - t)/10)+Math.floor(s/6);
      while(state.enemies.length<targetEnemies) spawnEnemy();
      state.difficulty=1 + (25 - t)*0.03 + s*0.05; // grows as time dwindles / score rises
    }

    // --- Update loop ---
    let last=performance.now();
    function update(dt){
      // Timer
      state.time=Math.max(0, state.time - dt);
      if(state.time<=0 && !state.over){ end(false, "Time's up!"); }

      // Movement: keyboard
      const p=state.player, sp=state.player.speed;
      let ax=state.input.right-state.input.left;
      let ay=state.input.down-state.input.up;

      // Movement: pointer guidance
      if(state.pointer.active){
        const dx=state.pointer.x - p.x, dy=state.pointer.y - p.y;
        const d=Math.hypot(dx,dy);
        if(d>1){ ax = dx/d; ay = dy/d; }
      }
      const mag=Math.hypot(ax,ay)||1;
      p.vx = (ax/mag)*sp;
      p.vy = (ay/mag)*sp;
      p.x = clamp(p.x + p.vx*dt, p.r, W-p.r);
      p.y = clamp(p.y + p.vy*dt, p.r, H-p.r);

      // Shard collection
      for(let i=state.shards.length-1;i>=0;i--){
        const s=state.shards[i];
        if(dist(p.x,p.y,s.x,s.y) < p.r + s.r){
          state.shards.splice(i,1);
          state.score++;
          beep(740,0.06,"triangle",0.05);
          spawnShard();
          if(state.score%5===0) spawnEnemy();
          if(state.score>=state.target && !state.over){ end(true, "You reached "+state.target+"!"); }
        }
      }

      // Enemies chase
      for(let i=0;i<state.enemies.length;i++){
        const e=state.enemies[i];
        const dx=p.x-e.x, dy=p.y-e.y, d=Math.hypot(dx,dy)||1;
        const speed=(e.base + 30*state.difficulty);
        e.x += (dx/d)*speed*dt;
        e.y += (dy/d)*speed*dt;
        // Keep within bounds
        e.x=clamp(e.x, e.r, W-e.r); e.y=clamp(e.y, e.r, H-e.r);
        // Collision with player -> lose
        if(dist(p.x,p.y,e.x,e.y) < p.r + e.r){ end(false,"Caught by a seeker!"); break; }
      }

      rampDifficulty();
      updateHUD();
    }

    // --- Draw loop ---
    function draw(){
      // Background grid for subtle motion cue
      ctx.clearRect(0,0,W,H);
      ctx.fillStyle="#0b0b0b"; ctx.fillRect(0,0,W,H);
      ctx.lineWidth=1; ctx.strokeStyle="rgba(255,255,255,0.06)";
      const step=16;
      for(let x=0;x<=W;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
      for(let y=0;y<=H;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }

      // Shards (teal squares)
      ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent1');
      for(let i=0;i<state.shards.length;i++){
        const s=state.shards[i]; ctx.fillRect(s.x-6,s.y-6,12,12);
      }

      // Player (ring)
      ctx.beginPath(); ctx.arc(state.player.x,state.player.y,state.player.r,0,Math.PI*2);
      ctx.strokeStyle="#ffffff"; ctx.lineWidth=3; ctx.stroke();

      // Enemies (red triangles)
      ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue('--accent2');
      for(let i=0;i<state.enemies.length;i++){
        const e=state.enemies[i];
        const dx=state.player.x-e.x, dy=state.player.y-e.y, a=Math.atan2(dy,dx);
        const r=12;
        ctx.beginPath();
        ctx.moveTo(e.x + Math.cos(a)*r, e.y + Math.sin(a)*r);
        ctx.lineTo(e.x + Math.cos(a+2.5)*r, e.y + Math.sin(a+2.5)*r);
        ctx.lineTo(e.x + Math.cos(a-2.5)*r, e.y + Math.sin(a-2.5)*r);
        ctx.closePath(); ctx.fill();
      }

      // Edge warnings if near timeout
      if(state.time<10 && !state.over){
        ctx.fillStyle="rgba(255,107,107,0.08)";
        ctx.fillRect(0,0,W,H);
      }
    }

    // --- End game ---
    function end(win,msg){
      state.over=true; state.running=false; state.win=win;
      pauseBtn.textContent="Resume"; pauseBtn.setAttribute("aria-pressed","true");
      statusEl.textContent="Ended";
      banner.hidden=false;
      bannerTitle.textContent=win?"You Win!":"Game Over";
      bannerMsg.innerHTML=(msg||"")+'<br>Press <span class="accent">R</span> to restart.';
      beep(win?880:220,0.2, win?"sawtooth":"square",0.05);
    }

    // --- Main loop ---
    function loop(t){
      if(!last) last=t;
      const dt = state.running ? Math.min(0.05,(t-last)/1000) : 0;
      if(state.running) update(dt);
      draw();
      last=t; requestAnimationFrame(loop);
    }

    // --- Start ---
    reset(); requestAnimationFrame(loop);
  })();
  </script>
</body>
</html>

Interesting work.

To be clear, I have been using a variety of GPTs, not the basic 4.0. Google’s code validator initially cleared the Schemas, but errors kept being flagged a few days later and these went on, despite the corrections made immediately. Perhaps I need to revisit again with GPT 5

It will work with Claude 4 as well

1 Like
<!doctype html>
<html lang="en" data-theme="light">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>World Map Search — Leaflet + OpenStreetMap (No API Key)</title>

  <!-- Apply saved/OS theme ASAP to avoid flashes -->
  <script>
    (function () {
      try {
        const saved = localStorage.getItem("theme");
        const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
        const theme = saved || (prefersDark ? "dark" : "light");
        document.documentElement.setAttribute("data-theme", theme);
      } catch (_) {}
    })();
  </script>

  <!-- Leaflet CSS (from CDNJS) -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css" />

  <style>
    /* ---------- Design tokens ---------- */
    :root {
      --bg: #ffffff;
      --surface: #f6f7f9;
      --text: #0f172a;
      --muted: #475569;
      --border: #e5e7eb;
      --accent: #2563eb;
      --accent-weak: #dbeafe;
      --focus: #7aa7ff;
      --shadow: 0 1px 2px rgba(0,0,0,.06), 0 2px 8px rgba(0,0,0,.08);
    }
    [data-theme="dark"] {
      --bg: #0b0f17;
      --surface: #121826;
      --text: #e5e7eb;
      --muted: #9aa9c0;
      --border: #1f2937;
      --accent: #60a5fa;
      --accent-weak: #1f2c43;
      --focus: #3b82f6;
      --shadow: 0 1px 2px rgba(0,0,0,.4), 0 2px 8px rgba(0,0,0,.5);
    }

    /* ---------- Base layout ---------- */
    html, body {
      height: 100%;
      margin: 0;
      padding: 0;
      background: var(--bg);
      color: var(--text);
      font: 16px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji";
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
    }

    body {
      display: flex;
      flex-direction: column;
      min-height: 100vh;
    }

    header.site-header {
      position: sticky;
      top: 0;
      z-index: 1000;
      background: var(--bg);
      border-bottom: 1px solid var(--border);
      box-shadow: var(--shadow);
    }

    .container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 12px 16px;
      display: grid;
      grid-template-columns: 1fr;
      gap: 12px;
    }

    .brand {
      font-weight: 700;
      letter-spacing: .2px;
      font-size: 18px;
    }

    /* Toolbar: title, search, actions */
    .toolbar {
      display: grid;
      grid-template-columns: minmax(140px, 220px) 1fr auto;
      gap: 12px;
      align-items: center;
    }
    @media (max-width: 720px) {
      .toolbar { grid-template-columns: 1fr; }
      .brand { order: 1; }
      .actions { order: 3; }
      .search-wrap { order: 2; }
    }

    .search-wrap {
      position: relative;
    }

    form#searchForm {
      display: grid;
      grid-template-columns: 1fr auto auto;
      gap: 8px;
      align-items: center;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 8px;
    }

    label[for="searchInput"] {
      position: absolute;
      width: 1px; height: 1px; padding: 0; margin: -1px;
      overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
    }

    #searchInput {
      appearance: none;
      border: 0;
      background: transparent;
      color: var(--text);
      padding: 8px 6px;
      min-width: 120px;
      outline: none;
      font-size: 16px;
    }
    #searchInput::placeholder { color: var(--muted); opacity: .9; }

    .btn {
      appearance: none;
      border: 1px solid var(--border);
      background: var(--bg);
      color: var(--text);
      padding: 8px 12px;
      border-radius: 10px;
      font-size: 14px;
      cursor: pointer;
      box-shadow: none;
      transition: transform .04s ease;
      min-height: 40px;
    }
    .btn:hover { background: var(--surface); }
    .btn:active { transform: translateY(1px); }
    .btn[disabled] {
      opacity: .5;
      cursor: not-allowed;
    }

    .btn-accent {
      border-color: transparent;
      background: var(--accent);
      color: #fff;
    }
    .btn-accent:hover { filter: brightness(0.95); }

    .actions {
      display: inline-flex;
      gap: 8px;
      align-items: center;
    }

    /* Loading indicator */
    .spinner {
      width: 18px;
      height: 18px;
      border: 2px solid var(--accent-weak);
      border-top-color: var(--accent);
      border-radius: 50%;
      animation: spin 1s linear infinite;
      margin-inline: 6px;
      visibility: hidden;
    }
    .spinner.is-visible { visibility: visible; }
    @keyframes spin { to { transform: rotate(360deg); } }

    /* Suggestions dropdown */
    .suggestions {
      position: absolute;
      left: 0; right: 0;
      top: calc(100% + 4px);
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: 12px;
      box-shadow: var(--shadow);
      padding: 4px;
      margin: 0;
      list-style: none;
      max-height: 272px;
      overflow: auto;
      display: none;
    }
    .suggestions.is-open { display: block; }

    .suggestions li {
      padding: 10px 12px;
      border-radius: 8px;
      cursor: pointer;
      font-size: 14px;
      line-height: 1.2;
      outline: none;
    }
    .suggestions li:hover,
    .suggestions li.is-active,
    .suggestions li[aria-selected="true"] {
      background: var(--accent-weak);
    }
    .sublabel {
      display: block;
      font-size: 12px;
      color: var(--muted);
      margin-top: 2px;
    }

    /* Inline status */
    .inline-status {
      margin-top: 6px;
      font-size: 13px;
      color: var(--muted);
      min-height: 18px;
    }

    /* Accessibility helpers */
    .sr-only {
      position: absolute !important;
      width: 1px !important; height: 1px !important;
      padding: 0 !important; margin: -1px !important;
      overflow: hidden !important; clip: rect(0,0,0,0) !important;
      white-space: nowrap !important; border: 0 !important;
    }
    :focus-visible {
      outline: 3px solid var(--focus);
      outline-offset: 2px;
      border-radius: 6px;
    }

    /* Map region */
    main {
      flex: 1 1 auto;
      min-height: 0;
      display: flex;
    }
    #map {
      flex: 1;
      width: 100%;
      height: 100%;
    }

    /* Leaflet theme tweaks for dark mode */
    [data-theme="dark"] .leaflet-control-attribution,
    [data-theme="dark"] .leaflet-control-scale {
      background: rgba(12,16,24,.8);
      color: var(--text);
      border: 1px solid var(--border);
    }
    [data-theme="dark"] .leaflet-popup-content-wrapper,
    [data-theme="dark"] .leaflet-popup-tip {
      background: #0f172a;
      color: var(--text);
    }
  </style>
</head>
<body>
  <header class="site-header" role="banner">
    <div class="container">
      <div class="toolbar" role="group" aria-label="Map tools">
        <div class="brand" aria-label="Site title">World Map Search</div>

        <div class="search-wrap">
          <form id="searchForm" role="search" aria-label="Global place search" autocomplete="off">
            <label for="searchInput">Search for a place</label>
            <input
              id="searchInput"
              name="q"
              type="search"
              placeholder="Search places worldwide…"
              inputmode="search"
              aria-autocomplete="list"
              aria-haspopup="listbox"
              aria-controls="suggestions"
              aria-expanded="false"
              aria-activedescendant=""
            />
            <div id="loading" class="spinner" aria-hidden="true"></div>
            <button id="clearBtn" type="button" class="btn" title="Clear marker and reset view" aria-disabled="true" disabled>Clear</button>
          </form>

          <ul id="suggestions" class="suggestions" role="listbox" aria-label="Search suggestions"></ul>
          <div id="inlineStatus" class="inline-status" aria-live="polite"></div>
          <div id="srStatus" class="sr-only" aria-live="polite"></div>
        </div>

        <div class="actions">
          <button id="themeToggle" class="btn" type="button" aria-pressed="false" title="Toggle dark mode">
            <span aria-hidden="true">🌗</span> <span class="sr-only">Toggle dark mode</span>
          </button>
          <a class="btn btn-accent" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OSM ©</a>
        </div>
      </div>
    </div>
  </header>

  <main role="main">
    <div id="map" role="region" aria-label="Interactive world map"></div>
  </main>

  <!-- Leaflet JS (from CDNJS) -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>

  <script>
    // ------------------ Utilities ------------------
    const $ = (sel, el = document) => el.querySelector(sel);
    const $$ = (sel, el = document) => Array.from(el.querySelectorAll(sel));
    const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
    const debounce = (fn, delay = 350) => {
      let t;
      return (...args) => {
        clearTimeout(t);
        t = setTimeout(() => fn(...args), delay);
      };
    };

    // Accessibility-friendly announcer
    function announce(msg) {
      const sr = $("#srStatus");
      if (!sr) return;
      sr.textContent = "";
      setTimeout(() => (sr.textContent = msg), 1);
    }

    // Sanitize by assigning to textContent
    function el(tag, props = {}, text = "") {
      const node = document.createElement(tag);
      Object.entries(props).forEach(([k, v]) => {
        if (k === "class") node.className = v;
        else if (k.startsWith("data-")) node.setAttribute(k, v);
        else node[k] = v;
      });
      if (text) node.textContent = text;
      return node;
    }

    // ------------------ Theme toggle ------------------
    (function initThemeToggle() {
      const btn = $("#themeToggle");
      function syncPressed() {
        const isDark = document.documentElement.getAttribute("data-theme") === "dark";
        btn.setAttribute("aria-pressed", String(isDark));
      }
      function setTheme(theme) {
        document.documentElement.setAttribute("data-theme", theme);
        try { localStorage.setItem("theme", theme); } catch (_) {}
        syncPressed();
      }
      btn.addEventListener("click", () => {
        const current = document.documentElement.getAttribute("data-theme");
        setTheme(current === "dark" ? "light" : "dark");
      });
      syncPressed();
    })();

    // ------------------ Map init ------------------
    const DEFAULT_CENTER = [20, 0];
    const DEFAULT_ZOOM = 2;

    const map = L.map("map", {
      worldCopyJump: true,
      zoomControl: true,
      attributionControl: true,
    }).setView(DEFAULT_CENTER, DEFAULT_ZOOM);

    L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
      maxZoom: 19,
      attribution:
        '&copy; <a href="https://www.openstreetmap.org/copyright" rel="noopener" target="_blank">OpenStreetMap</a> contributors'
    }).addTo(map);

    // Fix layout sizing if container changes
    const resizeObserver = new ResizeObserver(() => map.invalidateSize());
    resizeObserver.observe(document.body);

    // ------------------ Search / Autocomplete ------------------
    const form = $("#searchForm");
    const input = $("#searchInput");
    const list = $("#suggestions");
    const inlineStatus = $("#inlineStatus");
    const spinner = $("#loading");
    const clearBtn = $("#clearBtn");

    let currentController = null;
    let suggestions = [];
    let activeIndex = -1;
    let marker = null;

    function setLoading(isLoading) {
      spinner.classList.toggle("is-visible", isLoading);
      if (isLoading) {
        inlineStatus.textContent = "Searching…";
        announce("Searching");
      }
    }

    function openList() {
      list.classList.add("is-open");
      input.setAttribute("aria-expanded", "true");
    }

    function closeList() {
      list.classList.remove("is-open");
      input.setAttribute("aria-expanded", "false");
      input.setAttribute("aria-activedescendant", "");
      activeIndex = -1;
    }

    function renderSuggestions(items) {
      list.innerHTML = "";
      suggestions = items.slice(0, 5);
      if (!suggestions.length) {
        closeList();
        inlineStatus.textContent = "No results.";
        announce("No results");
        return;
      }
      suggestions.forEach((s, i) => {
        const li = el("li", {
          id: "sug-" + i,
          role: "option",
          tabindex: "-1",
          "data-lat": s.lat,
          "data-lon": s.lon,
          "data-name": s.display_name
        });
        const primary = el("div", { class: "primary" }, s.display_name);
        li.appendChild(primary);
        if (s.class || s.type) {
          const sub = el("span", { class: "sublabel" }, (s.class || "") + (s.type ? ` · ${s.type}` : ""));
          li.appendChild(sub);
        }
        list.appendChild(li);
      });
      openList();
      inlineStatus.textContent = `${suggestions.length} result${suggestions.length > 1 ? "s" : ""}.`;
      announce(`${suggestions.length} results available`);
    }

    function buildUrl(q) {
      const params = new URLSearchParams({
        q,
        format: "jsonv2",
        limit: "5",
        "accept-language": (navigator.language || "en").toString()
      });
      return "https://nominatim.openstreetmap.org/search?" + params.toString();
    }

    async function fetchSuggestions(q) {
      const query = q.trim();
      if (!query) {
        closeList();
        list.innerHTML = "";
        inlineStatus.textContent = "Type to search worldwide places.";
        return;
      }

      if (currentController) currentController.abort();
      currentController = new AbortController();

      try {
        setLoading(true);
        const res = await fetch(buildUrl(query), {
          method: "GET",
          signal: currentController.signal,
          headers: { "Accept": "application/json" }
        });
        if (!res.ok) throw new Error("Search failed");
        const data = await res.json();
        renderSuggestions(Array.isArray(data) ? data : []);
      } catch (err) {
        if (err.name === "AbortError") return;
        inlineStatus.textContent = "Search failed. Please try again.";
        announce("Search failed");
        closeList();
      } finally {
        setLoading(false);
      }
    }

    const debouncedFetch = debounce(fetchSuggestions, 350);

    input.addEventListener("input", (e) => {
      debouncedFetch(e.target.value);
    });

    input.addEventListener("focus", () => {
      if (suggestions.length) openList();
    });

    input.addEventListener("blur", () => {
      setTimeout(() => closeList(), 120);
    });

    function setActive(index) {
      const items = $$("#suggestions li");
      items.forEach((li, i) => {
        const active = i === index;
        li.classList.toggle("is-active", active);
        li.setAttribute("aria-selected", String(active));
        if (active) {
          input.setAttribute("aria-activedescendant", li.id);
          const parent = li.parentElement;
          const liTop = li.offsetTop;
          const liBottom = liTop + li.offsetHeight;
          if (liTop < parent.scrollTop) parent.scrollTop = liTop;
          else if (liBottom > parent.scrollTop + parent.clientHeight) parent.scrollTop = liBottom - parent.clientHeight;
        }
      });
    }

    input.addEventListener("keydown", (e) => {
      const items = $$("#suggestions li");
      if (e.key === "ArrowDown") {
        if (!items.length) return;
        e.preventDefault();
        activeIndex = clamp(activeIndex + 1, 0, items.length - 1);
        setActive(activeIndex);
      } else if (e.key === "ArrowUp") {
        if (!items.length) return;
        e.preventDefault();
        activeIndex = clamp(activeIndex - 1, 0, items.length - 1);
        setActive(activeIndex);
      } else if (e.key === "Enter") {
        if (items.length) {
          e.preventDefault();
          const idx = activeIndex >= 0 ? activeIndex : 0;
          const li = items[idx];
          if (li) selectSuggestion(li.dataset);
        }
      } else if (e.key === "Escape") {
        e.preventDefault();
        closeList();
        list.innerHTML = "";
        inlineStatus.textContent = "Suggestions cleared.";
        announce("Suggestions cleared");
      }
    });

    list.addEventListener("mousedown", (e) => {
      const li = e.target.closest("li");
      if (!li) return;
      e.preventDefault();
      selectSuggestion(li.dataset);
    });

    form.addEventListener("submit", async (e) => {
      e.preventDefault();
      if (suggestions.length) {
        const li = $("#suggestions li");
        if (li) selectSuggestion(li.dataset);
        return;
      }
      const q = input.value.trim();
      if (!q) return;
      try {
        setLoading(true);
        if (currentController) currentController.abort();
        currentController = new AbortController();
        const res = await fetch(buildUrl(q), { signal: currentController.signal });
        if (!res.ok) throw new Error("Search failed");
        const data = await res.json();
        if (Array.isArray(data) && data[0]) {
          const { lat, lon, display_name } = data[0];
          selectSuggestion({ lat, lon, name: display_name });
        } else {
          inlineStatus.textContent = "No results.";
          announce("No results");
        }
      } catch (_) {
        inlineStatus.textContent = "Search failed. Please try again.";
        announce("Search failed");
      } finally {
        setLoading(false);
      }
    });

    function enableClear(enabled) {
      clearBtn.disabled = !enabled;
      clearBtn.setAttribute("aria-disabled", String(!enabled));
    }

    function selectSuggestion(ds) {
      const lat = parseFloat(ds.lat);
      const lon = parseFloat(ds.lon);
      const name = ds.name || ds.display_name || "";
      if (Number.isFinite(lat) && Number.isFinite(lon)) {
        showOnMap(lat, lon, name);
        input.value = name;
        closeList();
        list.innerHTML = "";
        inlineStatus.textContent = "Location selected.";
        announce("Location selected");
      }
    }

    function showOnMap(lat, lon, name) {
      if (marker) {
        map.removeLayer(marker);
        marker = null;
      }
      marker = L.marker([lat, lon]).addTo(map);
      const div = document.createElement("div");
      div.textContent = name;
      marker.bindPopup(div, { maxWidth: 260, closeButton: true }).openPopup();
      map.flyTo([lat, lon], 13, { duration: 0.8 });
      enableClear(true);
    }

    clearBtn.addEventListener("click", () => {
      if (marker) {
        map.removeLayer(marker);
        marker = null;
      }
      map.flyTo([20, 0], 2, { duration: 0.8 });
      enableClear(false);
      inlineStatus.textContent = "Map reset.";
      announce("Map reset");
    });

    inlineStatus.textContent = "Type to search worldwide places.";
  </script>
</body>
</html>

1 Like