feat: add accessible mobile surface finder

#6
killinchu_experience.py CHANGED
@@ -131,6 +131,13 @@ def build_manifest(ns: str = "killinchu") -> dict[str, Any]:
131
  "by_data_state": dict(sorted(states.items())),
132
  "safe_get_probes": sum(1 for row in surfaces if row["source"]["safe_probe"]),
133
  },
 
 
 
 
 
 
 
134
  "honesty": "Declared states are contracts, not reachability claims. The UI probes only allow-listed GET endpoints and reports HTTP/freshness separately.",
135
  "surfaces": surfaces,
136
  }
@@ -150,13 +157,16 @@ def register(app: "FastAPI", ns: str = "killinchu") -> dict[str, Any]:
150
  return {"module": __name__, "registered": [f"GET {path}"], "surface_count": len(EXPERIENCE_SURFACES)}
151
 
152
 
153
- EXPERIENCE_LAYER_HTML = r"""<!-- __KILLINCHU_EXPERIENCE_LAYER_V1__ -->
154
  <style>
155
  #szl-data-passport{display:grid;grid-template-columns:minmax(0,1.35fr) repeat(3,minmax(125px,1fr));gap:1px;margin:.65rem 0 .9rem;border:1px solid rgba(201,183,135,.28);border-radius:10px;overflow:hidden;background:#24282f}
156
  .szl-passport-cell{min-width:0;background:#101318;padding:.62rem .72rem}.szl-passport-k{display:block;color:#8f9aa8;font:600 9px/1.2 var(--mono,monospace);letter-spacing:.11em;text-transform:uppercase;margin-bottom:.3rem}.szl-passport-v{display:block;color:#d9dee5;font:500 11px/1.45 var(--mono,monospace);overflow-wrap:anywhere}.szl-passport-v strong{color:#7fd6c7}.szl-passport-boundary{grid-column:1/-1;border-top:1px solid #24282f}.szl-passport-boundary .szl-passport-v{font-family:var(--sans,system-ui,sans-serif);font-size:12px;color:#b8c0ca}.szl-passport-live{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:6px;background:#c9a05f;vertical-align:1px}.szl-passport-live[data-ok="true"]{background:#5fb3a3}.szl-passport-live[data-ok="false"]{background:#b06a5a}
 
 
157
  @media(max-width:880px){#szl-data-passport{grid-template-columns:1fr 1fr}.szl-passport-boundary{grid-column:1/-1}}
158
  @media(max-width:520px){#szl-data-passport{grid-template-columns:1fr}.szl-passport-boundary{grid-column:auto}}
159
- @media(prefers-reduced-motion:reduce){#szl-data-passport *{scroll-behavior:auto!important;transition:none!important}}
 
160
  </style>
161
  <script>
162
  (function(){
@@ -170,6 +180,26 @@ EXPERIENCE_LAYER_HTML = r"""<!-- __KILLINCHU_EXPERIENCE_LAYER_V1__ -->
170
  return manifestPromise;
171
  }
172
  function rowFor(key){var xs=(manifest&&manifest.surfaces)||[];for(var i=0;i<xs.length;i++)if(xs[i].id===key)return xs[i];return null;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  function stamp(d){return d&&(d.observed_at||d.fetched_at||d.as_of||d.ts||d.timestamp||d.generated_at)||null;}
174
  function sourceLabel(d){return d&&(d.data_label||d.status_label||d.mode||d.label||d.status)||null;}
175
  function cell(k,v,cls){return '<div class="szl-passport-cell '+(cls||'')+'"><span class="szl-passport-k">'+esc(k)+'</span><span class="szl-passport-v">'+v+'</span></div>';}
@@ -215,7 +245,7 @@ EXPERIENCE_LAYER_HTML = r"""<!-- __KILLINCHU_EXPERIENCE_LAYER_V1__ -->
215
  var original=window.go; window.go=function(view){var out=original.apply(this,arguments);activateNav();mount(view);return out;}; window.go.__kcExperience=true; return true;
216
  }
217
  function boot(){
218
- activateNav(); wrapGo(); loadManifest().then(function(){mount((location.hash||'#tracks').slice(1));}).catch(function(){});
219
  var attempts=0, iv=setInterval(function(){activateNav();if(wrapGo()||++attempts>40)clearInterval(iv);},100);
220
  try{new MutationObserver(function(){activateNav();}).observe(document.querySelector('.side')||document.body,{childList:true,subtree:true});}catch(e){}
221
  }
 
131
  "by_data_state": dict(sorted(states.items())),
132
  "safe_get_probes": sum(1 for row in surfaces if row["source"]["safe_probe"]),
133
  },
134
+ "operator_ui": {
135
+ "surface_finder": "ENABLED",
136
+ "keyboard_shortcut": "/",
137
+ "escape_clears_filter": True,
138
+ "minimum_touch_target_css_px": 44,
139
+ "read_only": True,
140
+ },
141
  "honesty": "Declared states are contracts, not reachability claims. The UI probes only allow-listed GET endpoints and reports HTTP/freshness separately.",
142
  "surfaces": surfaces,
143
  }
 
157
  return {"module": __name__, "registered": [f"GET {path}"], "surface_count": len(EXPERIENCE_SURFACES)}
158
 
159
 
160
+ EXPERIENCE_LAYER_HTML = r"""<!-- __KILLINCHU_EXPERIENCE_LAYER_V1__ --><!-- __KILLINCHU_SURFACE_FINDER_V2__ -->
161
  <style>
162
  #szl-data-passport{display:grid;grid-template-columns:minmax(0,1.35fr) repeat(3,minmax(125px,1fr));gap:1px;margin:.65rem 0 .9rem;border:1px solid rgba(201,183,135,.28);border-radius:10px;overflow:hidden;background:#24282f}
163
  .szl-passport-cell{min-width:0;background:#101318;padding:.62rem .72rem}.szl-passport-k{display:block;color:#8f9aa8;font:600 9px/1.2 var(--mono,monospace);letter-spacing:.11em;text-transform:uppercase;margin-bottom:.3rem}.szl-passport-v{display:block;color:#d9dee5;font:500 11px/1.45 var(--mono,monospace);overflow-wrap:anywhere}.szl-passport-v strong{color:#7fd6c7}.szl-passport-boundary{grid-column:1/-1;border-top:1px solid #24282f}.szl-passport-boundary .szl-passport-v{font-family:var(--sans,system-ui,sans-serif);font-size:12px;color:#b8c0ca}.szl-passport-live{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:6px;background:#c9a05f;vertical-align:1px}.szl-passport-live[data-ok="true"]{background:#5fb3a3}.szl-passport-live[data-ok="false"]{background:#b06a5a}
164
+ #szl-surface-finder{position:sticky;top:-1.1rem;z-index:4;margin:0 -.15rem .8rem;padding:.55rem .15rem .65rem;background:linear-gradient(180deg,var(--rail,#0b0b0b) 82%,rgba(11,11,11,0));}
165
+ .szl-finder-row{display:grid;grid-template-columns:minmax(0,1fr) 44px;gap:.35rem;align-items:center}.szl-finder-label{display:block;margin:0 0 .35rem;color:var(--gold,#c9b787);font:600 9px/1.2 var(--mono,monospace);letter-spacing:.13em;text-transform:uppercase}.szl-finder-input{width:100%;height:44px!important;min-width:0;padding:.55rem .65rem!important;border:1px solid var(--gold-line,rgba(201,183,135,.15))!important;border-radius:7px!important;background:#080808!important;color:var(--cream,#f5f5f5)!important;font:500 12px/1.2 var(--mono,monospace)!important}.szl-finder-input::placeholder{color:#7f8790}.szl-finder-clear{width:44px;height:44px;border:1px solid var(--gold-line,rgba(201,183,135,.15));border-radius:7px;background:var(--gold-soft,rgba(201,183,135,.04));color:var(--gold,#c9b787);font:600 16px/1 var(--mono,monospace);cursor:pointer}.szl-finder-status{display:block;min-height:1.3em;margin:.35rem .1rem 0;color:#8f9aa8;font:500 9px/1.3 var(--mono,monospace);letter-spacing:.04em}.side.szl-search-active .nav-group{display:none!important}.side.szl-search-active .nav-item:not(.szl-search-hidden){display:flex!important}.side .nav-item.szl-search-hidden{display:none!important}
166
  @media(max-width:880px){#szl-data-passport{grid-template-columns:1fr 1fr}.szl-passport-boundary{grid-column:1/-1}}
167
  @media(max-width:520px){#szl-data-passport{grid-template-columns:1fr}.szl-passport-boundary{grid-column:auto}}
168
+ @media(max-width:820px){#szl-surface-finder{top:-1.1rem}.szl-finder-input{font-size:16px!important}}
169
+ @media(prefers-reduced-motion:reduce){#szl-data-passport *,#szl-surface-finder *{scroll-behavior:auto!important;transition:none!important}}
170
  </style>
171
  <script>
172
  (function(){
 
180
  return manifestPromise;
181
  }
182
  function rowFor(key){var xs=(manifest&&manifest.surfaces)||[];for(var i=0;i<xs.length;i++)if(xs[i].id===key)return xs[i];return null;}
183
+ function mountFinder(){
184
+ var side=document.querySelector('.side');if(!side||document.getElementById('szl-surface-finder'))return;
185
+ var brand=side.querySelector('.brand'),box=document.createElement('section');box.id='szl-surface-finder';box.setAttribute('role','search');box.setAttribute('aria-label','Find a Killinchu surface');
186
+ box.innerHTML='<label class="szl-finder-label" for="szl-surface-query">Find a surface <span aria-hidden="true">/</span></label><div class="szl-finder-row"><input id="szl-surface-query" class="szl-finder-input" type="search" inputmode="search" enterkeyhint="go" autocomplete="off" placeholder="Search capabilities or routes" aria-describedby="szl-surface-count"><button id="szl-surface-clear" class="szl-finder-clear" type="button" aria-label="Clear surface filter">&times;</button></div><span id="szl-surface-count" class="szl-finder-status" role="status" aria-live="polite"></span>';
187
+ if(brand&&brand.nextSibling)side.insertBefore(box,brand.nextSibling);else side.insertBefore(box,side.firstChild);
188
+ var input=box.querySelector('#szl-surface-query'),clear=box.querySelector('#szl-surface-clear'),status=box.querySelector('#szl-surface-count');
189
+ function items(){return Array.prototype.slice.call(side.querySelectorAll('.nav-item'));}
190
+ function apply(){
191
+ var q=(input.value||'').trim().toLowerCase(),matches=0;
192
+ side.classList.toggle('szl-search-active',!!q);
193
+ items().forEach(function(node){var hay=(node.textContent+' '+(node.getAttribute('title')||'')+' '+(node.dataset.view||'')+' '+(node.getAttribute('href')||'')).toLowerCase(),hit=!q||hay.indexOf(q)>=0;node.classList.toggle('szl-search-hidden',!hit);if(hit)matches++;});
194
+ status.textContent=q?(matches+' matching surface'+(matches===1?'':'s')+' · Enter opens the first'):(items().length+' surfaces available · press / to search');
195
+ return matches;
196
+ }
197
+ input.addEventListener('input',apply);input.addEventListener('keydown',function(e){if(e.key==='Enter'&&input.value.trim()){var first=side.querySelector('.nav-item:not(.szl-search-hidden)');if(first){e.preventDefault();first.click();input.value='';apply();if(window.matchMedia&&window.matchMedia('(max-width:820px)').matches&&window.toggleSide)window.toggleSide(false);}}else if(e.key==='Escape'){input.value='';apply();input.blur();}});
198
+ clear.addEventListener('click',function(){input.value='';apply();input.focus();});
199
+ document.addEventListener('keydown',function(e){var tag=(e.target&&e.target.tagName||'').toLowerCase();if(e.key==='/'&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&tag!=='input'&&tag!=='textarea'&&tag!=='select'){e.preventDefault();input.focus();}else if(e.key==='Escape'&&document.activeElement!==input&&input.value){input.value='';apply();}});
200
+ try{new MutationObserver(function(records){if(records.some(function(r){return r.addedNodes&&r.addedNodes.length;}))apply();}).observe(side,{childList:true});}catch(e){}
201
+ apply();
202
+ }
203
  function stamp(d){return d&&(d.observed_at||d.fetched_at||d.as_of||d.ts||d.timestamp||d.generated_at)||null;}
204
  function sourceLabel(d){return d&&(d.data_label||d.status_label||d.mode||d.label||d.status)||null;}
205
  function cell(k,v,cls){return '<div class="szl-passport-cell '+(cls||'')+'"><span class="szl-passport-k">'+esc(k)+'</span><span class="szl-passport-v">'+v+'</span></div>';}
 
245
  var original=window.go; window.go=function(view){var out=original.apply(this,arguments);activateNav();mount(view);return out;}; window.go.__kcExperience=true; return true;
246
  }
247
  function boot(){
248
+ mountFinder(); activateNav(); wrapGo(); loadManifest().then(function(){mount((location.hash||'#tracks').slice(1));}).catch(function(){});
249
  var attempts=0, iv=setInterval(function(){activateNav();if(wrapGo()||++attempts>40)clearInterval(iv);},100);
250
  try{new MutationObserver(function(){activateNav();}).observe(document.querySelector('.side')||document.body,{childList:true,subtree:true});}catch(e){}
251
  }
tests/test_killinchu_experience_contract.py CHANGED
@@ -63,6 +63,27 @@ class ExperienceManifestTests(unittest.TestCase):
63
  self.assertNotIn("method:'POST'", html)
64
  self.assertIn("row.source.safe_probe", html)
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  if __name__ == "__main__":
68
  unittest.main()
 
63
  self.assertNotIn("method:'POST'", html)
64
  self.assertIn("row.source.safe_probe", html)
65
 
66
+ def test_mobile_surface_finder_is_keyboard_and_touch_accessible(self):
67
+ html = experience.EXPERIENCE_LAYER_HTML
68
+ self.assertIn("__KILLINCHU_SURFACE_FINDER_V2__", html)
69
+ self.assertIn("role','search'", html)
70
+ self.assertIn('type="search"', html)
71
+ self.assertIn('aria-label="Clear surface filter"', html)
72
+ self.assertIn('aria-live="polite"', html)
73
+ self.assertIn("if(e.key==='/'", html)
74
+ self.assertIn("e.key==='Escape'", html)
75
+ self.assertIn("height:44px", html)
76
+ self.assertIn("max-width:820px", html)
77
+ self.assertNotIn("method:'POST'", html)
78
+
79
+ def test_manifest_declares_the_operator_ui_contract(self):
80
+ contract = self.manifest["operator_ui"]
81
+ self.assertEqual("ENABLED", contract["surface_finder"])
82
+ self.assertEqual("/", contract["keyboard_shortcut"])
83
+ self.assertTrue(contract["escape_clears_filter"])
84
+ self.assertEqual(44, contract["minimum_touch_target_css_px"])
85
+ self.assertTrue(contract["read_only"])
86
+
87
 
88
  if __name__ == "__main__":
89
  unittest.main()