ErrorScreen
A full screen for failure with the reason, retry, and offline told apart from server trouble.
Demo
0 calls. 0 homes.
Something went wrong on our side
The server had a problem. It is not your connection.
The price list could not be loaded.
Details for support
- Status
- 500
- Reference
- req-7f3a2c
Get it
It needs theme.qwe, as every component does, and button.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/button.qwe$ curl -O https://quillolang.org/components/ui/error_screen.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/error_screen.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/error_screen.qwe
let error_screen_demo_css = css`.error-screen-demo-keys { display: flex; flex-wrap: wrap; gap: 8px; }`component ErrorScreenDemoLive() { style global error_screen_demo_css state status = 500 state plan = "fail" state calls = 0 state back = false state homes = 0 let answer = () => { calls += 1 match plan { "fail" => false "status" => { status: 503, reason: "Maintenance until noon." } "wait" => { status: 429, retry_after: 2 } "promise-fail" => js("Promise.resolve", { status: 504 }) "reject" => js("Promise.reject", "no") "err" => err("broke") _ => { back = true; true } } } <div.demo-stack> <div.error-screen-demo-keys> {for s in [500, 503, 404, 401, 429, 0] => <Button key={s} size="lg" variant={status == s ? "solid" : "outline"} id={`error-screen-demo-s${s}`} on_click={() => { status = s; back = false }}>{s == 0 ? "No answer" : `${s}`}</Button>} {for p in ["fail", "status", "wait", "promise-fail", "reject", "err", "ok"] => <Button key={p} size="lg" variant={plan == p ? "soft" : "ghost"} id={`error-screen-demo-p${p}`} on_click={() => { plan = p }}>{p}</Button>} </div> <p.demo-note id="error-screen-demo-out">{calls} calls. {back ? "It worked." : ""} {homes} homes.</p> <div.demo-phone id="error-screen-demo-frame"> <ErrorScreen id="error-screen-demo" status={status} reference="req-7f3a2c" home_label="Back to the shop" reason={status == 500 ? "The price list could not be loaded." : null} on_retry={answer} on_home={() => { homes += 1 }}/> </div> </div>}view ErrorScreenDemo() = <div><ErrorScreenDemoLive/></div>Source
ui/error_screen.qwe
// ErrorScreen: a full screen for when something failed: what happened in words a person// can use, what to do, and a way to try again. It tells being offline apart from the// server's trouble, as the two need different things from the person: offline there is// nothing wrong but the connection, and it tries again by itself when that comes back.// It watches the connection all the while, so a server error that was really the phone// losing its signal changes to offline, and back.//// <ErrorScreen status={503} on_retry={() => load()}/>// <ErrorScreen status={r.status} reason="The price list could not be loaded." reference={r.id}// on_retry={() => { reload() }} home_label="Back to the shop" on_home={() => { go_home() }}/>// <ErrorScreen kind="offline" fill={true} on_retry={() => sync()}/>//// status: the HTTP status, or null or 0 for no answer at all. From that and the// connection it works out what happened: offline, unreachable (online, but no answer),// timeout (408, 504), busy (429), down (503), server (other 5xx), signin (401), denied// (403), missing (404, 410), request (other 4xx). kind: one of those to say so outright,// or "auto" (default). title, text: in place of the words chosen. reason: a sentence of// your own, shown under them. reference: a short id to quote, with a button to copy it.// retry_label ("Try again"). retry_after: seconds before it may be tried, counted down on// the button, for a 429 or a 503. auto_retry: try again by itself when the connection// returns (true). home_label, home: a second button; home is a link, for a plain page.// fill: it is the screen: fixed to the window and given the focus; else it fills its// parent, which needs a height. id, class: for the outer element.// children: anything more, under the buttons.//// on_retry(): inside one of your components. Try the work again, and give back how it// went, at once or as a Promise (a `server fn`'s call is one): false, an error value, or// { status, reason?, retry_after? } means it failed again, and the screen says so, with// the new status; anything else means it worked, and the parent shows what it has. A// kind that trying again cannot help (signin, denied, missing) has no such button.// on_home(): inside one of your components.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet error_screen_css = css`.ui-error-screen { container-type: inline-size; box-sizing: border-box; display: flex; width: 100%; height: 100%; min-height: 100%; overflow-y: auto; overscroll-behavior: contain; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font); padding: max(16px, var(--ui-safe-top, env(safe-area-inset-top, 0px))) max(20px, var(--ui-safe-right, env(safe-area-inset-right, 0px))) max(16px, var(--ui-safe-bottom, env(safe-area-inset-bottom, 0px))) max(20px, var(--ui-safe-left, env(safe-area-inset-left, 0px)));}.ui-error-screen-fill { position: fixed; inset: 0; z-index: 60; height: auto; }.ui-error-screen-page { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 100%; }.ui-error-screen-say { flex: 1; display: grid; align-content: center; justify-items: center; gap: 10px; padding: 16px 0; text-align: center; }.ui-error-screen-icon { display: grid; place-items: center; width: 88px; height: 88px; border-radius: 50%; background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-error-screen-offline .ui-error-screen-icon { background: var(--ui-warning-soft); color: var(--ui-warning); }.ui-error-screen-icon svg { width: 44px; height: 44px; }.ui-error-screen-title { margin: 6px 0 0; font-size: 24px; font-weight: 650; line-height: 1.25; }.ui-error-screen-title:focus { outline: none; }.ui-error-screen-text { max-width: 44ch; margin: 0; color: var(--ui-muted); }.ui-error-screen-reason { max-width: 44ch; margin: 0; }.ui-error-screen-state { min-height: 1.5em; margin: 0; color: var(--ui-muted); font-size: 14px; }.ui-error-screen-more { width: 100%; max-width: 420px; margin-top: 4px; text-align: left; }.ui-error-screen-more summary { display: flex; align-items: center; min-height: 44px; color: var(--ui-muted); cursor: pointer; }.ui-error-screen-more summary:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: var(--ui-radius-sm); }.ui-error-screen-more dl { display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; margin: 0 0 8px; font-size: 14px; }.ui-error-screen-more dt { color: var(--ui-muted); }.ui-error-screen-more dd { margin: 0; overflow-wrap: anywhere; font-family: var(--ui-mono); user-select: all; -webkit-user-select: all; }/* The buttons sit low, where a thumb is. */.ui-error-screen-actions { display: grid; gap: 10px; flex: none; }.ui-error-screen-actions .ui-btn { width: 100%; }@container (min-width: 640px) { .ui-error-screen-page { align-items: center; justify-content: center; } .ui-error-screen-say { flex: none; grid-template-columns: 148px minmax(0, 460px); justify-items: start; column-gap: 28px; align-items: start; text-align: left; } .ui-error-screen-icon { grid-row: 1 / span 6; width: 148px; height: 148px; } .ui-error-screen-icon svg { width: 72px; height: 72px; } .ui-error-screen-say > :not(.ui-error-screen-icon) { grid-column: 2; } .ui-error-screen-title { margin-top: 0; font-size: 28px; } .ui-error-screen-actions { display: flex; justify-content: flex-end; width: min(100%, 636px); } .ui-error-screen-actions .ui-btn { width: auto; min-width: 160px; }}`let error_screen_kinds = ["offline", "unreachable", "timeout", "busy", "down", "server", "signin", "denied", "missing", "request"]// What happened, from what is known. Being offline comes first: a status from before the// signal went says nothing about now.fn error_screen_kind(kind, status, online) -> str { if kind != null && contains(error_screen_kinds, kind) { return kind } if online == false { return "offline" } let code = type_of(status) == "int" ? status : 0 if code <= 0 { return "unreachable" } if code == 408 || code == 504 { return "timeout" } if code == 429 { return "busy" } if code == 503 { return "down" } if code == 401 { return "signin" } if code == 403 { return "denied" } if code == 404 || code == 410 { return "missing" } if code >= 500 { return "server" } if code >= 400 { return "request" } "server"}fn error_screen_words(kind: str) = match kind { "offline" => { title: "You are offline", text: "This device has no connection right now. Nothing you have done is lost.", retry: true, icon: "offline" } "unreachable" => { title: "Cannot reach the server", text: "Your connection is working, but the server did not answer.", retry: true, icon: "cloud" } "timeout" => { title: "That took too long", text: "The server did not answer in time. Your connection is working.", retry: true, icon: "clock" } "busy" => { title: "Too many tries", text: "The server asked us to slow down. Wait a moment, then try again.", retry: true, icon: "clock" } "down" => { title: "Down for a moment", text: "The server is not taking requests just now. It is not your connection.", retry: true, icon: "cloud" } "signin" => { title: "Sign in again", text: "You have been signed out. Sign in to carry on.", retry: false, icon: "lock" } "denied" => { title: "No access", text: "Your account cannot open this. Ask whoever runs it for access.", retry: false, icon: "lock" } "missing" => { title: "Not found", text: "This has moved, or it is no longer here.", retry: false, icon: "search" } "request" => { title: "That could not be done", text: "The server would not take this request. Trying again may not help.", retry: true, icon: "cloud" } _ => { title: "Something went wrong on our side", text: "The server had a problem. It is not your connection.", retry: true, icon: "cloud" } }// How a try went: what on_retry gave back, once it has come.fn error_screen_outcome(r) { if is_err(r) || r == false { return { failed: true, status: null, reason: null, wait: 0 } } if type_of(r) != "map" { return { failed: false } } let code = get(r, "status") let bad = get(r, "ok") == false || get(r, "error") != null || (type_of(code) == "int" && code >= 400) if !bad { return { failed: false } } let wait_for = get(r, "retry_after") { failed: true, status: code, reason: get(r, "reason"), wait: type_of(wait_for) == "int" && wait_for > 0 ? wait_for : 0 }}fn error_screen_icon(name: str) = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> {match name { "offline" => <path d="M3 3l18 18M8.5 16.5a5 5 0 0 1 7 0M5 13a10 10 0 0 1 5-2.7M19 13a10 10 0 0 0-3-2.1M2 8.8a15 15 0 0 1 4.2-2.6M22 8.8A15 15 0 0 0 10.7 5M12 20h.01"/> "clock" => <path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7v5l3 2"/> "lock" => <path d="M6 11h12v9H6zM8.5 11V7.5a3.5 3.5 0 0 1 7 0V11"/> "search" => <path d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14ZM16.5 16.5 21 21"/> _ => <path d="M7 18a4.5 4.5 0 0 1-.6-9A6 6 0 0 1 18 9.5a4.2 4.2 0 0 1-.5 8.5H7ZM12 9.5v4M12 16h.01"/> }} </svg>component ErrorScreen(kind: str = "auto", status = null, title: str? = null, text: str? = null, reason: str? = null, reference: str? = null, retry_label: str = "Try again", retry_after = null, auto_retry: bool = true, home_label: str? = null, home: str? = null, fill: bool = false, id: str? = null, class: str? = null, on_retry = null, on_home = null, children) { style global error_screen_css state online = true state code = status state last_status = status state said = reason state last_reason = reason state trying = false state tries = 0 state failed = false state left = type_of(retry_after) == "int" && retry_after > 0 ? retry_after : 0 state copied = "" if status != last_status { last_status = status code = status failed = false tries = 0 } if reason != last_reason { last_reason = reason said = reason } if left > 0 { every(1000, () => { left = max([0, left - 1]) }) } let what = error_screen_kind(kind, code, online) let words = error_screen_words(what) let can_retry = on_retry != null && words.retry let attempt = () => { if !trying && left == 0 && on_retry != null { trying = true tries += 1 // Written so, it holds an answer at once, a Promise, a refusal and a throw alike. let got = js("Promise.resolve", js_new("Promise", (done) => { done(on_retry()) })) let how = error_screen_outcome(got) trying = false failed = how.failed if how.failed { if how.status != null { code = how.status } if how.reason != null { said = how.reason } if how.wait > 0 { left = how.wait } } else { tries = 0 } } } let state_line = trying ? "Trying again…" : (what == "offline" && auto_retry && can_retry ? "It will try again when the connection is back." : (failed ? (tries > 1 ? `Still not working, after ${tries} tries.` : "Still not working.") : "")) <section class={ui_cls(["ui-error-screen", what == "offline" && "ui-error-screen-offline", fill && "ui-error-screen-fill", class])} id={id} data-kind={what} aria-labelledby={id != null ? `${id}-title` : null} @mount={(el) => { let win = js("window") let nav = win.navigator let on_line = () => { let was = online online = true if !was && auto_retry { attempt() } } let off_line = () => { online = false } win.addEventListener("online", on_line) win.addEventListener("offline", off_line) online = nav.onLine != false if fill { el.querySelector(".ui-error-screen-title").focus({ preventScroll: true }) } () => { win.removeEventListener("online", on_line) win.removeEventListener("offline", off_line) } }}> <div.ui-error-screen-page> <div.ui-error-screen-say role="alert"> <div.ui-error-screen-icon>{error_screen_icon(words.icon)}</div> <h1.ui-error-screen-title id={id != null ? `${id}-title` : null} tabindex="-1">{title ?? words.title}</h1> <p.ui-error-screen-text>{text ?? words.text}</p> {said != null && said != "" && <p.ui-error-screen-reason>{said}</p>} <p.ui-error-screen-state role="status">{state_line}</p> {(reference != null || (type_of(code) == "int" && code > 0)) && <details.ui-error-screen-more> <summary>Details for support</summary> <dl> {type_of(code) == "int" && code > 0 && <div style="display: contents"><dt>Status</dt><dd>{code}</dd></div>} {reference != null && <div style="display: contents"><dt>Reference</dt><dd>{reference}</dd></div>} </dl> {reference != null && <Button size="lg" variant="outline" on_click={() => { let board = js("navigator").clipboard if board == null { copied = "This browser cannot copy here: the reference is above." } else { let wrote = js("Promise.resolve", js_new("Promise", (done) => { done(board.writeText(reference)) })) copied = is_err(wrote) ? "It could not be copied: the reference is above." : "Copied." } }}>Copy the reference</Button>} <p.ui-error-screen-state role="status">{copied}</p> </details>} </div> <div.ui-error-screen-actions> {can_retry && <Button size="lg" loading={trying} disabled={left > 0} on_click={() => { attempt() }}>{left > 0 ? `${retry_label} in ${left} s` : retry_label}</Button>} {home_label != null && <Button size="lg" variant="outline" href={home} on_click={on_home}>{home_label}</Button>} </div> {children} </div> </section>}Related
RetryBannerFailed loads with a back-off shown as a countdown, a Try now, cleared on success.3 views · 6 downloadsAssetLookupScan or type an asset tag to see its record, history and what to do, with near matches and a report for a tag nobody knows.3 views · 7 downloadsConflictSheetThis device's version against the server's, chosen field by field, explained in plain words.2 views · 6 downloadsDataSaverToggleA setting to keep lighter images and off autoplay, following the phone's save-data, with what it changes.4 views · 5 downloadsDraftKeeperKeeps what was typed on this device through a reload and a crash, offers it back, and clears on send.2 views · 7 downloadsInspectionFormA checklist of pass, fail and N/A with big targets, a note and stamped photo needed on a fail, a score, and a draft kept.2 views · 5 downloads
Thread
Thread 0 posts
On ErrorScreen: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.