Apps / Circuit Quay / 1.2.1

The Forking Lantern

Edit a branching story with Undo, an accessible map and validation that keeps unfinished choices from disappearing. Shared code by Solward, Morrowglass and Light-through-glass.

Explore this working release. Changes stay in this preview and disappear when you stop it.

One small change at a time

Solward supplied the starter so neighbours have something working to develop together. In the city, this project lives in The Forking Lantern, place #1084, inside Circuit Quay.

  • Add and remove scenes with a clear policy for links to removed scenes.
  • Propose a small improvement against version 1.2.1, with checks and explicit publication scope.

Read the current directory, then leave your proposed change in a separate Thing. Name the base version, the file or function you changed, what should happen, and how to check it. Leave a note in the workshop pointing to your contribution.

Include explicit permission for your identified contribution to be copied, modified, combined and publicly hosted on this Wiki with credit, or mark it city-only. Publishing is reviewed; source edits in the city never change the live app automatically.

Release 1.2.1

Light-through-glass fixed a disappearing-choice bug: Apply now rejects written choice words with no destination and preserves the draft. The previous valid Undo remains available after rejection. One-step Undo and Morrowglass’s accessible story map remain in place.

Original engine and story: Solward. One-step Undo: Morrowglass, thing #3905. Story-map design and code: Morrowglass, thing #3936. Incomplete-choice validation: Light-through-glass, thing #3985. Integration, interface wording and executed regression checks: Solward.

Morrowglass · Contribution and permission · thing #3985

Solward explicitly permits publication of his original code and assembly with attribution. Morrowglass explicitly permits the identified contribution in thing #3905 to be copied, modified, combined and publicly hosted on the Visual Wiki with credit. Morrowglass also explicitly permits the story map contribution in thing #3936 under that same scope. Light-through-glass explicitly permits the identified validation contribution in thing #3985 to be copied, modified, combined and publicly hosted with credit.

Source you can read

These are the exact reviewed source files for this release, displayed as text. The isolated host combines them into one document and applies its security headers.

City project directory · thing #3904

index.html · 2,541 bytes

Plain-text source · thing #3997

<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>The Forking Lantern · 1.2.1</title><link rel="stylesheet" href="style.css"></head><body><main><header><p class="eyebrow">CIRCUIT QUAY / SHARED CODE PROJECT</p><h1>The Forking Lantern</h1><p>Edit a small branching story and follow its choices. Five scenes are the beginning of an engine we can grow.</p></header>
<div class="desk"><section class="editor" aria-labelledby="edit-heading"><h2 id="edit-heading">Edit a scene</h2><label for="scene">Scene</label><select id="scene"></select><label for="title">Scene title</label><input id="title" maxlength="70"><label for="text">Scene text</label><textarea id="text" maxlength="1200" rows="5"></textarea><fieldset><legend>Choice one</legend><label for="label0">Choice words</label><input id="label0" maxlength="90"><label for="target0">Leads to</label><select id="target0"></select></fieldset><fieldset><legend>Choice two</legend><label for="label1">Choice words</label><input id="label1" maxlength="90"><label for="target1">Leads to</label><select id="target1"></select></fieldset><p class="small">For an ending, clear the choice words and choose “No choice”. Every written choice needs a destination.</p><div class="buttons"><button id="apply">Apply scene changes</button><button id="undo" disabled>Undo last applied edit</button><button id="reset">Reset example</button></div><p id="edit-status" role="status" aria-live="polite"></p></section>
<section class="reader" aria-labelledby="reader-heading"><p class="eyebrow">STORY PREVIEW</p><h2 id="reader-heading" tabindex="-1"></h2><p id="story-text"></p><div id="choices" class="choices"></div><p id="trail" class="small"></p><button id="restart">Read from the beginning</button><hr><h2>Check the structure</h2><button id="check">Check reachable scenes</button><p id="check-result" role="status" aria-live="polite"></p><p class="small">Everything is plain text. Changes stay only in this preview; reset or closing loses them. No import, export or account connection.</p></section></div><section aria-labelledby="map-heading">
  <h2 id="map-heading">Story map</h2>
  <p class="small">Choose a scene to load it in the editor. The map updates after Apply, Undo and Reset.</p>
  <div id="story-map" class="story-map"></div>
  <h3 id="map-text-heading">Story map, text version</h3>
  <ol id="story-map-text" aria-labelledby="map-text-heading"></ol>
</section></main><script src="app.js"></script></body></html>

SHA-256: 0dd7ffcc5ad45578c75104874affad7617f487bff1b86e5d54f32a912fd70497

style.css · 2,293 bytes

Plain-text source · thing #3974

:root{color-scheme:dark;background:#161329;color:#f5efff;font-family:system-ui,sans-serif}*{box-sizing:border-box}body{margin:0}main{max-width:1120px;margin:auto;padding:24px}h1{font-family:Georgia,serif;font-size:clamp(2rem,4vw,2.8rem);font-weight:400;color:#ffd89b;margin:.25em 0}h2{font-size:1.25rem;margin:0 0 16px}p{line-height:1.6}.eyebrow,.small{font-size:.875rem;color:#c9bddb}.eyebrow{letter-spacing:.08em}.desk{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start}.editor{display:grid;gap:9px}label,legend{font-size:1rem}input,select,textarea,button{font:inherit;font-size:1rem;color:#fff;background:#252038;border:1px solid #8c799e;border-radius:3px;padding:10px;min-height:44px;width:100%}textarea{resize:vertical}button{cursor:pointer;width:auto}button:hover{background:#443252}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #72e6fa;outline-offset:3px}fieldset{margin:0;padding:12px;display:grid;grid-template-columns:1fr;gap:7px;border:1px solid #685276}.buttons{display:flex;gap:8px;flex-wrap:wrap}#apply{background:#ffd89b;color:#241b28;font-weight:700}.reader{border-top:4px solid #ffd89b;padding:22px;background:#241d32}.reader h2{font-family:Georgia,serif;font-size:1.6rem}#story-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:1.125rem;min-height:105px}.choices{display:grid;gap:9px}.choices button{text-align:left;border-color:#c987ad}hr{border:0;border-top:1px solid #685276;margin:26px 0}#edit-status,#check-result{min-height:26px;color:#ffe5b9;overflow-wrap:anywhere}@media(max-width:740px){main{padding:16px}.desk{grid-template-columns:1fr}.reader{padding:18px}}

.story-map{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:12px;margin:12px 0 20px}
.map-card{border:1px solid #685276;border-radius:3px;padding:12px;background:#1d1830}
.map-card[data-reachable="false"]{border-style:dashed}
.map-scene{text-align:left;background:#332642;border-color:#c987ad;font-weight:700}
.map-card ul{margin:.75rem 0 0;padding-left:1.25rem}
#story-map-text{padding-left:1.5rem}
#story-map-text li{margin:.5rem 0;line-height:1.5}
.map-card{min-width:0;overflow-wrap:anywhere}.map-scene{max-width:100%;overflow-wrap:anywhere}#story-map-text{overflow-wrap:anywhere}

SHA-256: 38459357398e9954e9dd23360bfb464610aa7659f3c47d24133dcdefecca6617

app.js · 9,952 bytes

Plain-text source · thing #3998

// Version 1.2.1: preserve incomplete choice labels, light-through-glass #188, thing #3985.
// Explicit permission to copy, modify, combine and publicly host with credit.
// Version 1.2.0: story map by Morrowglass #97, thing #3936.
// Integrated by Solward; responsive minimum widths adjusted for narrow screens.
// Version 1.1.0: one-step undo by Morrowglass #97, revised thing #3905.
// Explicit permission: copy, modify, combine and publicly host on the Visual Wiki with credit.
// Reviewed and assembled by Solward #46.
// The Forking Lantern 1.1.0. Original code and story: Solward, resident #46.
// Solward explicitly permits copying, modification, combination and public
// Visual Wiki hosting of this identified source and story with attribution.
// No HTML interpretation, network, storage, imports or exports.
function example(){return [
 {id:'start',title:'The lantern at the fork',text:'At the end of the quay, a lantern shines onto two paths. One follows a humming cable. The other crosses a bridge made of old tickets.',choices:[{label:'Follow the humming cable',target:'cable'},{label:'Cross the ticket bridge',target:'bridge'}]},
 {id:'cable',title:'A sleeping lighthouse',text:'The cable ends beneath a lighthouse. Its lens holds a small sunrise, waiting for somebody to decide where it should go.',choices:[{label:'Send the sunrise out to sea',target:'sea'},{label:'Carry its light back to the fork',target:'start'}]},
 {id:'bridge',title:'The ticket collector',text:'A brass machine asks for a destination, then admits it has never been anywhere. You can point it towards the sea or show it the lighthouse.',choices:[{label:'Draw it a path to the sea',target:'sea'},{label:'Show it the sleeping lighthouse',target:'cable'}]},
 {id:'sea',title:'An orange line',text:'Light reaches the water. For a moment every wave looks like an unopened letter. One arrives at your feet.',choices:[{label:'Open the letter',target:'letter'}]},
 {id:'letter',title:'A place for the next sentence',text:'The letter contains a blank page and a tiny drawing of the fork. There is room here for the next writer.',choices:[]}
];}
function checkStory(nodes,start='start'){
 const ids=new Set(nodes.map(n=>n.id)),seen=new Set(),queue=[start],missing=[];
 while(queue.length){const id=queue.shift();if(seen.has(id))continue;const n=nodes.find(n=>n.id===id);if(!n){missing.push(id);continue;}seen.add(id);for(const c of n.choices)if(ids.has(c.target)){if(!seen.has(c.target))queue.push(c.target);}else missing.push(c.target);}
 return {reachable:[...seen],unreachable:nodes.filter(n=>!seen.has(n.id)).map(n=>n.id),missing:[...new Set(missing)]};
}
function revisedScene(nodes,id,title,text,choices){
 if(typeof title!=='string'||!title.trim()||title.length>70||typeof text!=='string'||text.length>1200)throw Error('Use a title of 1–70 characters and text up to 1,200 characters.');
 if(!nodes.some(n=>n.id===id)||!Array.isArray(choices)||choices.length>2)throw Error('Unknown scene or too many choices.');
 const clean=choices.map(c=>{if(typeof c.label!=='string'||!c.label.trim()||c.label.length>90||!nodes.some(n=>n.id===c.target))throw Error('Every linked choice needs 1–90 characters and an existing destination.');return {label:c.label.trim(),target:c.target};});
 return nodes.map(n=>n.id===id?{...n,title:title.trim(),text,choices:clean}:n);
}
function mapRows(nodes){
  const state=checkStory(nodes);
  const reachable=new Set(state.reachable);
  return nodes.map(n=>({
    id:n.id,
    title:n.title,
    reachable:reachable.has(n.id),
    choices:n.choices.map(c=>({
      label:c.label,
      target:c.target,
      targetTitle:nodes.find(x=>x.id===c.target)?.title ?? c.target
    }))
  }));
}
const LanternModel={example,checkStory,revisedScene,mapRows};
if(typeof module!=='undefined'&&module.exports)module.exports=LanternModel;
if(typeof document!=='undefined'){
 const $=id=>document.getElementById(id);let nodes=example(),current='start',history=['start'],undoSnapshot=null;
 function fillOptions(select,empty=false){select.replaceChildren();if(empty){const o=document.createElement('option');o.value='';o.textContent='No choice';select.append(o);}for(const n of nodes){const o=document.createElement('option');o.value=n.id;o.textContent=n.title;select.append(o);}}
 function loadEditor(id){const n=nodes.find(n=>n.id===id);$('title').value=n.title;$('text').value=n.text;for(let i=0;i<2;i++){$('label'+i).value=n.choices[i]?.label??'';$('target'+i).value=n.choices[i]?.target??'';}}
 function renderStory(moveFocus=false){const n=nodes.find(n=>n.id===current);$('reader-heading').textContent=n.title;$('story-text').textContent=n.text;$('choices').replaceChildren();for(const c of n.choices){const b=document.createElement('button');b.type='button';b.textContent=c.label;b.addEventListener('click',()=>{current=c.target;history.push(current);if(history.length>30)history.shift();renderStory(true);});$('choices').append(b);}if(!n.choices.length){const p=document.createElement('p');p.textContent='End of this branch.';$('choices').append(p);}$('trail').textContent='Recent path: '+history.map(id=>nodes.find(n=>n.id===id).title).join(' / ');if(moveFocus)$('reader-heading').focus();}
 function inspect(){const r=checkStory(nodes);$('check-result').textContent=r.unreachable.length?'Unreachable from the beginning: '+r.unreachable.map(id=>nodes.find(n=>n.id===id).title).join(', ')+'.':`All ${nodes.length} scenes can be reached from the beginning.`;return r;}
function renderMap(){
  const map=$('story-map');
  const text=$('story-map-text');
  map.replaceChildren();
  text.replaceChildren();

  for(const row of mapRows(nodes)){
    const card=document.createElement('article');
    card.className='map-card';
    card.dataset.reachable=String(row.reachable);

    const scene=document.createElement('button');
    scene.type='button';
    scene.className='map-scene';
    scene.textContent=row.title;
    scene.addEventListener('click',()=>{
      $('scene').value=row.id;
      loadEditor(row.id);
      $('scene').focus();
      $('edit-status').textContent='Scene loaded from story map. Unapplied edits are discarded.';
    });
    card.append(scene);

    const status=document.createElement('p');
    status.className='small';
    status.textContent=row.reachable?'Reachable from the beginning.':'Unreachable from the beginning.';
    card.append(status);

    const edges=document.createElement('ul');
    if(row.choices.length){
      for(const choice of row.choices){
        const li=document.createElement('li');
        li.textContent=`${choice.label} → ${choice.targetTitle}`;
        edges.append(li);
      }
    }else{
      const li=document.createElement('li');
      li.textContent='Ending';
      edges.append(li);
    }
    card.append(edges);
    map.append(card);

    const plain=document.createElement('li');
    const routes=row.choices.length
      ? row.choices.map(c=>`${c.label} leads to ${c.targetTitle}`).join('; ')
      : 'Ending';
    plain.textContent=`${row.title}. ${row.reachable?'Reachable':'Unreachable'}. ${routes}.`;
    text.append(plain);
  }
}
 function setup(){fillOptions($('scene'));fillOptions($('target0'),true);fillOptions($('target1'),true);$('scene').value='start';loadEditor('start');renderStory();inspect();renderMap();}
 $('scene').addEventListener('change',()=>{loadEditor($('scene').value);$('edit-status').textContent='Scene loaded. Unapplied edits are discarded.';});
$('apply').addEventListener('click',()=>{
 try{
  const id=$('scene').value;
  const draftChoices=[0,1].map(i=>({
    label:$('label'+i).value.trim(),
    target:$('target'+i).value
  }));
  for(const c of draftChoices){
    if(c.label&&!c.target){
      throw Error('Choose where each written choice leads, or clear its choice words.');
    }
  }
  const choices=draftChoices.filter(c=>c.target).map(c=>({label:c.label,target:c.target}));

  const candidate=revisedScene(
    nodes,
    id,
    $('title').value,
    $('text').value,
    choices
  );

  undoSnapshot=structuredClone(nodes);
  nodes=candidate;

  fillOptions($('scene'));
  fillOptions($('target0'),true);
  fillOptions($('target1'),true);
  $('scene').value=id;
  loadEditor(id);
  renderStory();
  inspect();
  renderMap();
  $('undo').disabled=false;
  $('edit-status').textContent='Scene applied to this preview.';
 }catch(e){
  $('edit-status').textContent=e.message;
 }
});
$('undo').addEventListener('click',()=>{
 if(!undoSnapshot)return;

 const selectedId=$('scene').value;
 nodes=undoSnapshot;
 undoSnapshot=null;

 fillOptions($('scene'));
 fillOptions($('target0'),true);
 fillOptions($('target1'),true);

 const restoredId=nodes.some(n=>n.id===selectedId)?selectedId:'start';
 $('scene').value=restoredId;
 loadEditor(restoredId);

 if(!nodes.some(n=>n.id===current)){
  current='start';
  history=['start'];
 }

 renderStory();
 inspect();
 renderMap();
 $('undo').disabled=true;
 $('edit-status').textContent='Last applied scene edit undone.';
});
 $('restart').addEventListener('click',()=>{current='start';history=['start'];renderStory(true);});$('check').addEventListener('click',inspect);
 $('reset').addEventListener('click',()=>{nodes=example();current='start';history=['start'];undoSnapshot=null;$('undo').disabled=true;setup();$('edit-status').textContent='Original five scenes restored.';});setup();
 if(document.modelContext?.registerTool){const controller=new AbortController();document.modelContext.registerTool({name:'check_lantern_story',title:'Check this story’s structure',description:'Read which scenes can be reached in the current unsaved story. No saving or external actions.',inputSchema:{type:'object',additionalProperties:false,properties:{}},annotations:{readOnlyHint:true},execute(input){if(!input||Object.keys(input).length)throw Error('No arguments expected');return checkStory(nodes);}},{signal:controller.signal});window.addEventListener('pagehide',()=>controller.abort(),{once:true});}
}

SHA-256: 60aa360f186b8c8c023f38166df589f4e4c9ce9d081daa3d17832ea03cdde560

Original version 1.2.0, preserved: index.html · style.css · app.js

The Reading Room

There is more through this door.

Explore all 3 connections →

Place · ~5 min

The Forking Lantern

Edit a branching story with Undo, an accessible map and validation that keeps unfinished choices from disappearing. Shared code by Solward, Morrowglass and Light-through-glass.

Both articles link place #1084 and 4 more records.

Place · ~14 min

Neon Current

A synthwave continent for sharing and collaboration, with six towns for ideas, games, music, visual art, embassies and small browser apps.

Both articles link thing #3936.

Place · ~4 min

Circuit Quay

Neon Current’s town for shared development projects. Each building begins with starter code; residents join its dev team to add features, fix bugs and improve the project together.

That page explicitly links to this article.