Class: OKF::Server::Hub::NotFound
- Inherits:
-
Object
- Object
- OKF::Server::Hub::NotFound
- Defined in:
- lib/okf/server/hub/not_found.rb
Overview
The page a stale bookmark lands on — and the only page in the product a
reader reaches by being wrong. So it is deliberately not a design of
its own: it is the app shell with nothing to show. The same 76px rail
with the ruby mark and the theme toggle, the same topbar, the same
.search component in the same position, the same row anatomy the
palette uses for a bundle, the same red-underlined head. Only the main
column differs. A 404 that looks like a different app tells a reader
they left the product, which is the opposite of the truth.
Everything a reader needs is rendered here, in Ruby: the asked path, the guess, the list. This is where someone lands when something has already gone wrong, and a page that needs JavaScript to say what happened has picked the worst moment to need it. The script adds the live filter, the count, and ↑↓/⏎/esc — enhancements, every one of which the page is complete without.
The tokens, rail and topbar rules are copied from the graph template. They are copied rather than shared because there is one other page that wants them and an abstraction for two callers is a worse answer than two copies; keep them in step if that template moves.
Constant Summary collapse
- STYLE =
<<~CSS :root{ --bg:#ffffff; --panel:#ffffff; --canvas:#f4f5f7; --ink:#1f2328; --ink-soft:#333333; --muted:#63697a; --faint:#9298a4; --line:#e6e8eb; --line-2:#eef0f2; --accent:#e21e1e; --accent-ink:#c81a1a; --accent-soft:#fdecec; --ok:#1a9e5f; --warn:#9a6700; --rail:#15171c; --rail-ink:#8b919c; --rail-ink-hi:#dfe2e7; } :root[data-theme=dark]{ --bg:#17191e; --panel:#1d2026; --canvas:#111318; --ink:#eceef1; --ink-soft:#d7dae0; --muted:#9aa0aa; --faint:#6b7178; --line:#2a2e36; --line-2:#232830; --accent:#f5433b; --accent-ink:#ff726b; --accent-soft:#3a1f1e; --ok:#37c07f; --warn:#d4a72c; --rail:#0d0e11; --rail-ink:#868c98; --rail-ink-hi:#e2e5ea; } *{box-sizing:border-box} html,body{margin:0;height:100%} body{background:var(--bg);color:var(--ink); font:14px/1.55 'Poppins',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased} /* ── app shell: rail + main ── */ #app{display:grid;grid-template-columns:76px 1fr;height:100vh;height:100dvh} #rail{grid-column:1;background:var(--rail);display:flex;flex-direction:column;align-items:center;gap:3px;padding:12px 8px;overflow:hidden} .rail-brand{width:34px;height:34px;margin:2px 0 10px;flex:none;display:block;border-radius:9px} .rail-brand svg{width:34px;height:34px;display:block} .rail-brand:focus-visible{outline:2px solid var(--accent);outline-offset:3px} .rail-sp{flex:1} .rail-tools{display:flex;flex-direction:column;gap:6px;align-items:center} .rail-tools .btn{color:var(--rail-ink);border-color:transparent;background:rgba(255,255,255,.05)} .rail-tools .btn:hover{color:var(--rail-ink-hi);background:rgba(255,255,255,.1);border-color:transparent} /* ── main column ── */ #main{grid-column:2;display:flex;flex-direction:column;min-width:0} #topbar{flex:none;display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)} .bar-brand{display:none;width:26px;height:26px;flex:none} .bar-brand svg{width:26px;height:26px;display:block} /* ── shared controls (verbatim from the template) ── */ .field{height:34px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--ink); font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s} .field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)} .field::placeholder{color:var(--muted)} /* Sits where the graph page's box sits — right after the mark, not flush to the far edge: same component, same place, found by habit. */ .search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px} .search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none} .search input{width:100%;padding-left:33px;padding-right:52px} /* The count sits on the control that filters, not in a heading that repeats it — the number belongs to what you are filtering. */ .s-cnt{position:absolute;right:10px;top:50%;transform:translateY(-50%);font-size:11px;color:var(--faint); font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none} /* ── the bridge, verbatim from the graph page ── The dead end is the same event here as there — the box in the topbar came up empty and there is somewhere else to look — so it is the same component in the same place, under the box that disappointed you, rather than a second dialect of the same idea two pages apart. ── */ .s-bridge{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:60;background:var(--panel); border:1px solid var(--line);border-radius:11px;box-shadow:0 10px 30px rgba(0,0,0,.16); padding:11px 13px;font-size:12.5px} .s-bridge[hidden]{display:none} .sb-msg{color:var(--muted);margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .sb-msg b{color:var(--ink);font-weight:600} .sb-row{display:flex;gap:8px;flex-wrap:wrap} .sb-act{cursor:pointer;display:inline-flex;align-items:center;gap:7px;font-family:inherit;font-size:12px; padding:5px 10px;border:1px solid var(--line);border-radius:8px;background:var(--line-2);color:var(--muted); transition:color .15s,border-color .15s} .sb-act:hover{color:var(--ink);border-color:var(--accent)} .sb-act:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .sb-act.primary{color:var(--accent-ink);border-color:var(--accent);background:var(--accent-soft)} .sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint); border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3} .btn{cursor:pointer;display:inline-grid;place-items:center;width:34px;height:34px;border-radius:9px;border:1px solid var(--line); background:var(--panel);color:var(--muted);transition:color .15s,border-color .15s,background .15s;font-family:inherit} .btn:hover{color:var(--ink);border-color:var(--accent)} .btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round} #btn-theme .sun{display:none} :root[data-theme=dark] #btn-theme .sun{display:block} :root[data-theme=dark] #btn-theme .moon{display:none} /* ── the column ── Left-anchored under the rail and the bar, never centred: a card floating in the middle would read as a different page's chrome, and the two things this page shares with every other view are anchored hard left. 880px because the rows below carry a right-aligned fact column, and at 620 those columns had nowhere to be. ── */ #views{flex:1;min-height:0;position:relative;background:var(--bg);overflow:auto;display:flex;flex-direction:column} #col{width:100%;max-width:880px;padding:40px 28px 28px;flex:1} /* ── what was asked for ── The heading and the path swap the usual sizes. A reader arrives here already knowing they are lost — the URL bar said so — so "not found" is the small word and the path they typed is the large one. Setting it in mono at 27px is what makes a dropped slash or a truncated slug legible as a *shape*, which is the one thing this page can do that the address bar cannot. ── */ .eyebrow{margin:0 0 6px;font-size:11px;font-weight:600;text-transform:uppercase; letter-spacing:.08em;color:var(--faint)} h1{font-size:27px;font-weight:600;letter-spacing:-.02em;margin:0;padding-bottom:12px;position:relative; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ink); overflow-wrap:anywhere;line-height:1.25} h1::after{content:"";position:absolute;left:0;bottom:0;width:34px;height:3px;border-radius:3px;background:var(--accent)} .fact{margin:14px 0 0;color:var(--muted);font-size:13.5px} .fact code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;color:var(--ink-soft)} /* ── the near miss ── The one place this page raises its voice, and it earns it by being the whole answer when it is right. A sentence in muted grey asks the reader to read, parse and then aim; a row asks them to press Enter. So the guess wears the same anatomy as the list below it, one step brighter, and the keyboard already points at it. ── */ .miss{margin:24px 0 0} .miss[hidden]{display:none} .miss .lbl{margin:0 0 6px} .lbl{margin:30px 0 8px;font-size:10.5px;font-weight:600;text-transform:uppercase; letter-spacing:.06em;color:var(--faint)} /* ── the rows: the /b/ list's anatomy, which carries the folder ── The folder is not decoration here. A server hosting `site/.okf`, `minifts/.okf` and `okf-core/.okf` has three bundles whose titles are nearly the same word, and the directory they came from is the only thing that tells them apart. The old row omitted exactly that. ── */ #blist{list-style:none;margin:0;padding:0} #blist li[hidden]{display:none} .brow{list-style:none} .brow a{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:1px 20px;align-items:center; padding:9px 13px;border-radius:10px;text-decoration:none;color:var(--ink); box-shadow:inset 3px 0 0 var(--edge,transparent);transition:background .12s} .brow a:hover{background:var(--line-2)} /* The cursor is one look wherever it stands — on the near miss, on a filtered bundle, on a concept hit — because ↑↓ walks all three as one list. The guess is not styled at all: it is simply where the cursor starts, which is why it is lit on arrival and stops being lit the moment you type. */ /* The palette's own selection is this neutral fill, so the cursor matches it rather than inventing a louder one; the accent *edge* is what makes the move visible, and it is already this page's device for saying which row to look at. */ .brow a.active{--edge:var(--accent);background:var(--line-2)} .brow a.active .dbadge{background:rgba(128,128,128,.22)} .brow a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} /* No edge at all on a healthy row. A rule per bundle is six marks saying "nothing to report", and a page where everything is marked is a page where the one thing that matters is not. */ .brow[data-health=warn] a{--edge:var(--warn)} .brow[data-health=error] a{--edge:var(--accent)} .brow[data-health=missing] a{--edge:var(--faint)} .b-id{grid-column:1;grid-row:1;display:flex;align-items:baseline;gap:9px;min-width:0} .b-title{font-weight:500;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .slug{color:var(--faint);font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;flex:none} .dbadge{padding:1px 7px;border-radius:99px;background:var(--line-2);color:var(--muted);font-size:10.5px;flex:none} /* Truncate a long path from the *left*: the tail (…/repo/.okf) is what identifies it, and clipping the tail identifies nothing. An rtl box puts the ellipsis at the front, and the inner <bdi> keeps the path itself one ltr run so a leading "/" cannot reorder to the far end. */ .b-dir{grid-column:1;grid-row:2;font-size:11.5px;color:var(--faint); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left} .b-dir bdi{direction:ltr} /* fixed slots, right-aligned: read down the page as much as across, and a ragged column of numbers is a column nobody scans */ .b-meta{grid-column:2;grid-row:1/3;display:flex;align-items:baseline;gap:20px;flex:none; font-size:12px;color:var(--faint)} .b-cnt{min-width:6.5rem;text-align:right;font-variant-numeric:tabular-nums} /* Colour marks the exception only. Six green "no problems" spends the page's whole palette on nothing to report — and makes the one real warning harder to find, which is the opposite of the job. */ .b-health{min-width:7rem;color:var(--muted)} .brow[data-health=warn] .b-health{color:var(--warn)} .brow[data-health=error] .b-health{color:var(--accent-ink)} .brow[data-health=missing] .b-title{color:var(--faint);font-weight:400} #hits[hidden]{display:none} #hitlist{list-style:none;margin:0;padding:0} #hitnote{margin:10px 0 0;padding:0 13px;color:var(--faint);font-size:12px} /* a snippet is prose, so it drops the path row's mono and rtl clipping */ .hit-s{font-family:inherit;direction:ltr;color:var(--muted);font-size:12.5px} #bnone code{background:var(--line-2);border-radius:5px;padding:1px 5px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px} /* ≤768px: the rail is a whole drawer on the graph page; a 404 has nothing to put in one, so it folds away and the mark moves into the bar. */ @media (max-width:768px){ #app{grid-template-columns:1fr} #rail{display:none} #main{grid-column:1} .bar-brand{display:block} .search{flex-basis:140px} #col{padding:28px 16px 20px} h1{font-size:20px} /* the fact columns stop being columns and become a third line */ .brow a{grid-template-columns:minmax(0,1fr)} .b-meta{grid-column:1;grid-row:3;margin-top:3px;gap:14px} .b-cnt,.b-health{min-width:0} } @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} CSS
- MARK =
The ruby mark, the graph template's own. It appears twice — once in the rail, once in the bar the rail folds into below 768px — because the two are never visible at the same time.
'<svg viewBox="0 0 100 100"><rect width="100" height="100" rx="24" fill="#1a1a1a"/>' \ '<polygon points="38,44 62,44 50,82" fill="#7a0a1e"/><polygon points="18,44 38,44 50,82" fill="#a8112c"/>' \ '<polygon points="62,44 82,44 50,82" fill="#a8112c"/><polygon points="35,28 18,44 38,44" fill="#dc1e3c"/>' \ '<polygon points="65,28 82,44 62,44" fill="#dc1e3c"/><polygon points="35,28 65,28 62,44 38,44" fill="#f43f5e"/>' \ '<polygon points="36,29 49,29 42,42" fill="#fff" opacity=".38"/>' \ '<polygon points="35,28 65,28 82,44 50,82 18,44" fill="none" stroke="#ff6b7f" stroke-width="2" stroke-linejoin="round"/></svg>'
- SCRIPT =
Filter, count and keyboard — the three things a static document cannot do. Every row is already in the DOM; this only hides some of them. Filter, count, keyboard, and the fallback to searching every bundle — the four things a static document cannot do. Everything else on this page is already in the DOM before this runs.
<<~JS (function(){ 'use strict'; var root=document.documentElement; document.getElementById('btn-theme').addEventListener('click',function(){ var next=root.getAttribute('data-theme')==='dark'?'light':'dark'; root.setAttribute('data-theme',next); try{localStorage.setItem('okf-theme',next);}catch(e){} }); var q=document.getElementById('q'); if(!q) return; var list=document.getElementById('blist'), chip=document.getElementById('bar-count'), total=list.children.length, missBox=document.querySelector('.miss'), miss=missBox?missBox.querySelector('a'):null, hits=document.getElementById('hits'), hitList=document.getElementById('hitlist'), hitNote=document.getElementById('hitnote'), goBtn=document.getElementById('go-search'), bridge=document.getElementById('s-bridge'), bridgeMsg=bridge.querySelector('.sb-msg'), goBtn=document.getElementById('sb-go'), clearBtn=document.getElementById('sb-clear'), endpoint=q.getAttribute('data-search'), mount=q.getAttribute('data-mount'), searched=null; /* Moving through the list is Tab's job, and Tab already does it: every row is an <a href>, a filtered-out row is display:none and drops out of the order on its own, and Shift-Tab goes back. A hand-rolled ↑↓ cursor was a second, worse focus model living beside the real one — it lit rows the browser did not consider focused, it was invisible to a screen reader, and keeping the two in step is what left two rows highlighted at once. What is left is not a cursor: one row is marked, it is whatever ⏎ would open *right now*, and it never moves on its own. It stands down the moment the caret leaves the box, because past that point ⏎ belongs to whatever Tab has focused. */ function shownRows(){ return Array.prototype.filter.call(list.children,function(li){return !li.hidden;}); } function target(){ if(miss&&q.value.trim()==='') return miss; var first=shownRows()[0]; if(first) return first.firstChild; return hitList.children.length ? hitList.children[0].firstChild : null; } function unmark(){ Array.prototype.forEach.call(document.querySelectorAll('a.active'),function(a){a.classList.remove('active');}); } function mark(){ unmark(); var t=target(); if(t) t.classList.add('active'); } /* Total while the box is empty, matched/total once it is filtering — the same grammar the graph page's own count chip uses. */ function count(shown){ chip.textContent = q.value.trim()==='' ? String(total) : shown+'/'+total; } function filter(){ var needle=q.value.trim().toLowerCase(); Array.prototype.forEach.call(list.children,function(li){ li.hidden = needle!=='' && li.getAttribute('data-hay').indexOf(needle)===-1; }); /* The guess answers "what did you *ask* for", so a query supersedes it outright rather than sitting above the answer to a different question. */ if(missBox) missBox.hidden = needle!==''; var vis=shownRows(); /* The bridge is the graph page's own dead-end panel, doing the same job for the same reason: the box came up empty and there is somewhere else to look. It drops under the box rather than into the list, because that is where the reader is looking when the filter disappoints them. */ var dead = needle!=='' && vis.length===0; bridge.hidden = !dead; if(dead){ bridgeMsg.innerHTML='No bundle matches \u201c<b></b>\u201d'; bridgeMsg.querySelector('b').textContent=q.value.trim(); } /* A new query invalidates the last search — the hits below belong to a word nobody is looking at any more. */ if(needle!==searched){hits.hidden=true;hitList.innerHTML='';hitNote.textContent='';searched=null;} count(vis.length); mark(); } /* The dead end, and the way through it. A bundle list cannot answer "where is the thing about decay?" — but the hub can, because it searches inside every bundle it hosts. So a query that matches no *bundle* is offered the search that does match, rather than being told no twice. Same escalation the graph page's search box makes when its own bundle comes up empty. */ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} function hitRow(c){ var href=mount+'/'+encodeURIComponent(c.slug)+'/?select='+encodeURIComponent(c.id); return '<li class="brow"><a href="'+esc(href)+'">'+ '<span class="b-id"><span class="b-title">'+esc(c.title||c.id)+'</span>'+ (c.type?'<span class="dbadge">'+esc(c.type)+'</span>':'')+ '<span class="slug">@'+esc(c.slug)+'</span></span>'+ (c.snippet?'<span class="b-dir hit-s">'+esc(c.snippet)+'</span>':'')+ '</a></li>'; } function search(){ var term=q.value.trim(); if(!term||!endpoint||searched===term.toLowerCase()) return; searched=term.toLowerCase(); hits.hidden=false;hitList.innerHTML='';hitNote.textContent='searching every bundle…'; var req=new XMLHttpRequest(); req.open('GET',endpoint+'?q='+encodeURIComponent(term)); req.onload=function(){ if(q.value.trim().toLowerCase()!==searched) return; var data; try{data=JSON.parse(req.responseText);}catch(e){data=null;} var found=(data&&data.results)||[]; hitList.innerHTML=found.map(hitRow).join(''); hitNote.textContent = found.length ? (data.truncated?'showing '+found.length+' of '+data.total+' — narrow the search':'') : 'No concept matches either.'; mark(); }; req.onerror=function(){hitNote.textContent='search is unavailable right now';}; req.send(); } goBtn.addEventListener('click',function(){search();q.focus();}); clearBtn.addEventListener('click',function(){q.value='';filter();q.focus();}); q.addEventListener('input',filter); /* The mark means "⏎ opens this", so it is only true while the caret is in the box. Tab away and ⏎ belongs to the focus ring instead. */ q.addEventListener('blur',unmark); q.addEventListener('focus',mark); q.addEventListener('keydown',function(ev){ if(ev.key==='Enter'){ ev.preventDefault(); var t=target(); /* Nothing to open means the list is the wrong place to be looking, so ⏎ escalates rather than doing nothing. */ if(t) location.href=t.href; else if(!bridge.hidden) search(); } else if(ev.key==='Escape'){q.value='';filter();} }); /* `/` reaches the box from anywhere on the page, the same key the graph page binds — a reader who tabbed into the list and changed their mind should not have to tab back out of it. */ addEventListener('keydown',function(ev){ if(ev.key!=='/'||ev.target===q||ev.metaKey||ev.ctrlKey||ev.altKey) return; if(ev.target.matches&&ev.target.matches('input,select,textarea')) return; ev.preventDefault();q.focus();q.select(); }); q.focus(); mark(); })(); JS
Class Method Summary collapse
Instance Method Summary collapse
-
#initialize(path, slug, rows, base, mount) ⇒ NotFound
constructor
pathis what was asked for,slugthe bundle name inside it (nil when the path was not under the mount at all),rowsthe hosted bundles in the shape the manager already describes them,basethe prefix a host mounted the hub under,mountthe /b prefix. - #page ⇒ Object
Constructor Details
#initialize(path, slug, rows, base, mount) ⇒ NotFound
path is what was asked for, slug the bundle name inside it (nil
when the path was not under the mount at all), rows the hosted
bundles in the shape the manager already describes them, base the
prefix a host mounted the hub under, mount the /b prefix.
394 395 396 397 398 399 400 |
# File 'lib/okf/server/hub/not_found.rb', line 394 def initialize(path, slug, rows, base, mount) @path = path @slug = slug @rows = rows @base = base @mount = mount end |
Class Method Details
.page(*args) ⇒ Object
402 403 404 |
# File 'lib/okf/server/hub/not_found.rb', line 402 def self.page(*args) new(*args).page end |
Instance Method Details
#page ⇒ Object
406 407 408 |
# File 'lib/okf/server/hub/not_found.rb', line 406 def page shell(head + guess + listing) end |