Mobile

ThemeSheet

Light, dark, system and accent in a sheet, with a live preview, kept, and no flash next time.

#mobile#interactive#tablet#theme#settings#dark-mode

Demo

Preview

Appearance

Theme
Accent
Preview

Your screens will look like this, with links in this colour.

A button

, closed 0.

Appearance

Theme
Accent
Preview

Your screens will look like this, with links in this colour.

A button

Get it

It needs theme.qwe, as every component does; 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/theme_sheet.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/theme_sheet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/theme_sheet.qwe
component ThemeSheetDemoLive(saved = null, start_open: bool = false) {    state showing = start_open    state said = ""    state closes = 0    <div.demo-stack>        <ThemeSheet id="theme-sheet-demo" saved={saved} open={showing} on_close={() => { showing = false; closes += 1 }}                    on_change={(t) => { said = t.value }}/>        <div>            <Button size="lg" id="theme-sheet-demo-open" on_click={() => { showing = true }}>Appearance</Button>        </div>        <p.demo-note id="theme-sheet-demo-out">{said}, closed {closes}.</p>    </div>}// A view cannot read `request`; a server fn can, and a route's own page would just use it.server fn theme_sheet_demo_saved() = get(request.cookies, "ui-theme")server fn theme_sheet_demo_open() = get(request.query, "sheet") == "open"view ThemeSheetDemo() {    let saved = theme_sheet_demo_saved()    <div.demo-stack>        <ThemeSheetStyle saved={saved}/>        <ThemeSheetDemoLive saved={saved} start_open={theme_sheet_demo_open()}/>        <ThemeSheet id="theme-sheet-demo-own" trigger="With its own button" saved={saved} remember={null}/>    </div>}

Source

ui/theme_sheet.qwe
// ThemeSheet: light, dark or system, and an accent colour, in a sheet that rises from the// bottom of a phone and sits in the middle of a tablet. Each choice shows at once: the// page behind changes and the preview in the sheet with it. It is kept as it is made, and// on the next visit the page starts that way with no flash, as the server writes the// theme into the page: put ThemeSheetStyle in <head>, which needs no script.////   let kept = get(request.cookies, "ui-theme")//   <head> <style>{ui_css}</style> <ThemeSheetStyle saved={kept}/> </head>//   <ThemeSheet trigger="Appearance" saved={kept}/>//   <ThemeSheet open={showing} saved={theme} remember={null} on_close={() => { showing = false }}//               on_change={(t) => { save_theme(t.value) }}/>//// saved: what was kept, as `theme.accent` ("dark.teal"), from the cookie or your database.// Left out, the cookie is read in the browser, which is a flash on a page without// ThemeSheetStyle. trigger: the text of a button that opens it; inside one of your// components, open it from your own state: open and on_close(). accents: a list of// { id, name, light, dark } in hex, for the two themes, the first being the kit's own// and writing no rule. remember: the cookie's name ("ui-theme"), null for none. A// cookie, as it outlasts a phone app's launches where localStorage does not. title:// the sheet's heading ("Appearance"). id, class: for the outer element; id also names its// radio groups. on_change({ theme, accent, value }), on_close(): inside one of your// components. value is the text to keep. ThemeSheetStyle takes saved and accents.//// A page has one: each sets the page's theme as it starts, from what it was given.// The grip drags it down to close, Done and Escape do the same, and a tap outside it. It// sets `ui-light` or `ui-dark` on <html>, as ThemeToggle does, and sends `ui-theme-change`// on document, which StatusTint follows.import ui/theme.qwe use ui_cls, theme_csslet theme_sheet_css = css`.ui-theme-sheet-host { display: contents; }.ui-theme-sheet-open { min-height: 46px; padding: 0 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 15px/1 var(--ui-font); cursor: pointer; }.ui-theme-sheet-open:focus-visible { outline: none; box-shadow: var(--ui-focus); }/* The dialog is moved by hand in a drag, so it has no style attribute in its markup: one would be written again on any change, and the drag would jump home. */.ui-theme-sheet {  box-sizing: border-box; inset: auto 0 0 0; width: 100%; max-width: 100vw; max-height: calc(100dvh - 12px); margin: 0 auto; padding: 0; overflow: hidden; border: 1px solid var(--ui-border); border-bottom: 0;  border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0; background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 15px/1.45 var(--ui-font);  transition: transform .24s cubic-bezier(.2, .8, .2, 1); overscroll-behavior: contain;}.ui-theme-sheet[open] { display: flex; flex-direction: column; animation: ui-theme-sheet-up .26s cubic-bezier(.2, .8, .2, 1); }@keyframes ui-theme-sheet-up { from { transform: translateY(100%); } }.ui-theme-sheet::backdrop { background: var(--ui-overlay); }.ui-theme-sheet-dragging { transition: none; user-select: none; -webkit-user-select: none; }.ui-theme-sheet-grip { display: grid; place-items: center; flex: none; height: 28px; touch-action: none; cursor: grab; }.ui-theme-sheet-grip i { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-theme-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex: none; padding: 0 12px 8px 20px; touch-action: none; }.ui-theme-sheet-head h2 { margin: 0; font-size: 18px; font-weight: 600; }.ui-theme-sheet-done { min-width: 64px; min-height: 44px; padding: 0 12px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 600 16px/1 var(--ui-font); cursor: pointer; }.ui-theme-sheet-done:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-theme-sheet-body { flex: 1; display: grid; gap: 16px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 20px max(20px, var(--ui-safe-bottom, env(safe-area-inset-bottom, 0px))); }.ui-theme-sheet-group { min-width: 0; margin: 0; padding: 0; border: 0; }.ui-theme-sheet-group legend { margin-bottom: 6px; padding: 0; color: var(--ui-muted); font-size: 13px; font-weight: 600; }.ui-theme-sheet-modes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }.ui-theme-sheet-mode { position: relative; display: grid; place-items: center; min-height: 52px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-bg); cursor: pointer; font-weight: 500; }.ui-theme-sheet-mode:has(input:checked) { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-theme-sheet-mode:has(input:focus-visible), .ui-theme-sheet-swatch:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-theme-sheet-mode input, .ui-theme-sheet-swatch input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }.ui-theme-sheet-swatches { display: flex; flex-wrap: wrap; gap: 6px; }.ui-theme-sheet-swatch { position: relative; display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; }.ui-theme-sheet-swatch i { display: block; width: 34px; height: 34px; border-radius: 50%; background: var(--ui-theme-sheet-light); }.ui-dark .ui-theme-sheet-swatch i { background: var(--ui-theme-sheet-dark); }@media (prefers-color-scheme: dark) { :root:not(.ui-light) .ui-theme-sheet-swatch i { background: var(--ui-theme-sheet-dark); } }.ui-light .ui-theme-sheet-swatch i { background: var(--ui-theme-sheet-light); }.ui-theme-sheet-swatch:has(input:checked) i { box-shadow: 0 0 0 3px var(--ui-surface), 0 0 0 5px var(--ui-fg); }.ui-theme-sheet-preview { display: grid; gap: 8px; padding: 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-bg); color: var(--ui-fg); }.ui-theme-sheet-preview b { font-size: 16px; }.ui-theme-sheet-preview p { margin: 0; color: var(--ui-muted); font-size: 14px; }.ui-theme-sheet-sample { justify-self: start; padding: 8px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; }.ui-theme-sheet-link { color: var(--ui-accent); font-weight: 600; }@media (min-width: 700px) and (min-height: 500px) {  .ui-theme-sheet { inset: 0; width: 620px; height: fit-content; margin: auto; border-bottom: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 8px); }  .ui-theme-sheet-grip { display: none; }  .ui-theme-sheet-head { padding: 14px 12px 8px 20px; touch-action: auto; }  .ui-theme-sheet-body { grid-template-columns: minmax(0, 1fr) 220px; }  .ui-theme-sheet-preview { grid-column: 2; grid-row: 1 / span 2; align-self: start; }  @keyframes ui-theme-sheet-up { from { transform: translateY(12px); opacity: 0; } }}@media (prefers-reduced-motion: reduce) { .ui-theme-sheet { transition: none; } .ui-theme-sheet[open] { animation: none; } }`let theme_sheet_accents = [    { id: "indigo", name: "Indigo", light: "#5b4bdb", dark: "#b9a8ff" },    { id: "teal", name: "Teal", light: "#0b7470", dark: "#4fd1c5" },    { id: "green", name: "Green", light: "#18794e", dark: "#4cc38a" },    { id: "amber", name: "Amber", light: "#a35200", dark: "#f3b54a" },    { id: "rose", name: "Rose", light: "#be1e5a", dark: "#ff7aa8" },    { id: "blue", name: "Blue", light: "#0b6bcb", dark: "#6cb6ff" },]let theme_sheet_modes = [{ id: "light", name: "Light" }, { id: "dark", name: "Dark" }, { id: "system", name: "System" }]fn theme_sheet_list(accents) -> list = type_of(accents) == "list" && len(accents) > 0 ? accents : theme_sheet_accents// What was kept, from text that can say anything: a theme and an accent it knows, else// the defaults. `theme.accent`.fn theme_sheet_parse(saved, accents) {    let known = theme_sheet_list(accents)    let parts = type_of(saved) == "str" && len(saved) <= 64 ? split(saved, ".") : []    let theme = len(parts) > 0 && contains(["light", "dark", "system"], parts[0]) ? parts[0] : "system"    let accent = len(parts) > 1 && any(known, (a) => a.id == parts[1]) ? parts[1] : known[0].id    { theme, accent }}fn theme_sheet_hex(x) -> bool = type_of(x) == "str" && re.test(x, "^#[0-9a-fA-F]{6}$")// One channel of a colour in the formula for luminance.fn theme_sheet_channel(pair: str) {    let digits = split("0123456789abcdef", "")    let at = (c: str) => index_of(digits, c)    let low = lower(pair)    let v = (at(slice(low, 0, 1)) * 16 + at(slice(low, 1, 2))) / 255    v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)}fn theme_sheet_luminance(hex: str) =    0.2126 * theme_sheet_channel(slice(hex, 1, 3)) + 0.7152 * theme_sheet_channel(slice(hex, 3, 5)) + 0.0722 * theme_sheet_channel(slice(hex, 5, 7))// The text to put on an accent: whichever of white and near-black reads better.fn theme_sheet_on(hex: str) -> str {    let l = theme_sheet_luminance(hex)    let with_white = 1.05 / (l + 0.05)    let with_dark = (l + 0.05) / 0.0587    with_white >= with_dark ? "#ffffff" : "#17132e"}fn theme_sheet_vars(hex) -> str {    if !theme_sheet_hex(hex) { return "" }    `--ui-accent: ${hex}; --ui-accent-fg: ${theme_sheet_on(hex)}; --ui-accent-soft: color-mix(in srgb, ${hex} 16%, var(--ui-bg));`}// The rules for an accent, in step with the theme's own three places for a colour. The// first accent is the kit's own, and writes none.fn theme_sheet_rules(accent: str, accents) -> str {    let known = theme_sheet_list(accents)    let a = find(known, (x) => x.id == accent)    if a == null || a.id == known[0].id { return "" }    let l = theme_sheet_vars(a.light)    let d = theme_sheet_vars(a.dark)    `:root, .ui-light { ${l} } @media (prefers-color-scheme: dark) { :root:not(.ui-light) { ${d} } } .ui-dark { ${d} }`}// The block of the theme's own sheet that starts with `marker`, so that there is one// list of colours. Empty if the theme is ever written otherwise: then a flash, not a fault.fn theme_sheet_block(marker: str) -> str {    let parts = split(theme_css, marker)    len(parts) < 2 ? "" : split(parts[1], "}")[0]}// The first paint's sheet. With a theme chosen it holds that theme outright, as <html>// has no class yet, at a weight above the theme's own media query.fn theme_sheet_first(saved, accents) -> str {    let p = theme_sheet_parse(saved, accents)    let known = theme_sheet_list(accents)    let a = find(known, (x) => x.id == p.accent)    let rules = theme_sheet_rules(p.accent, accents)    if p.theme == "system" { return rules }    let block = theme_sheet_block(p.theme == "dark" ? ".ui-dark {" : ":root, .ui-light {")    let own = a.id != known[0].id ? theme_sheet_vars(p.theme == "dark" ? a.dark : a.light) : ""    `${rules} :root:root:root { ${block} ${own} }`}// Goes in <head>, after the theme, so that the page starts as it was left with no script.view ThemeSheetStyle(saved = null, accents = null) {    <style id="ui-theme-sheet-style">{theme_sheet_first(saved, accents)}</style>}fn theme_sheet_tag(doc) {    let found = doc.getElementById("ui-theme-sheet-style")    if found != null { return found }    let tag = doc.createElement("style")    tag.id = "ui-theme-sheet-style"    doc.head.append(tag)    tag}// From here the class on <html> holds the theme, so the sheet drops down to the accent.fn theme_sheet_apply(doc, theme: str, accent: str, accents) {    let root = doc.documentElement    root.classList.remove("ui-light")    root.classList.remove("ui-dark")    if theme != "system" { root.classList.add(`ui-${theme}`) }    theme_sheet_tag(doc).textContent = theme_sheet_rules(accent, accents)}fn theme_sheet_cookie(line, name: str) {    if type_of(line) != "str" { return null }    let found = split(line, ";") | map((p) => trim(p)) | find((p) => starts_with(p, `${name}=`))    found != null ? slice(found, len(name) + 1, len(found)) : null}// Whether a drag down this far, at this speed, closes it.fn theme_sheet_closes(dy, speed, height) -> bool = dy > height * 0.3 || (dy > 24 && speed > 0.5)component ThemeSheet(saved = null, trigger: str? = null, open: bool = false, accents = null, remember: str? = "ui-theme", title: str = "Appearance",                     id: str? = null, class: str? = null, on_change = null, on_close = null) {    style global theme_sheet_css    state first = theme_sheet_parse(saved, accents)    state theme = first.theme    state accent = first.accent    state dlg = null    state doc = null    state press = null    state group = id ?? uuid()    if open && dlg != null && !dlg.open { dlg.showModal() }    if !open && dlg != null && dlg.open && trigger == null { dlg.close() }    let known = theme_sheet_list(accents)    let choose = (t: str, a: str) => {        theme = t        accent = a        if doc != null {            theme_sheet_apply(doc, t, a, accents)            if remember != null { doc.cookie = `${remember}=${t}.${a}; Path=/; Max-Age=31536000; SameSite=Lax` }            doc.dispatchEvent(js_new("CustomEvent", "ui-theme-change"))        }        on_change && on_change({ theme: t, accent: a, value: `${t}.${a}` })    }    let release = () => {        dlg.classList.remove("ui-theme-sheet-dragging")        dlg.style.transform = ""        press = null    }    let start = (e) => {        // Read through the element, as js() would wait, and this must capture the pointer at once.        if e.button == 0 && !e.target.closest("button") && dlg.ownerDocument.defaultView.matchMedia("(min-width: 700px) and (min-height: 500px)").matches != true {            press = { id: e.pointerId, y: e.clientY, locked: false, dy: 0, was: e.clientY, then: e.timeStamp, speed: 0 }            e.target.setPointerCapture(e.pointerId)        }    }    let move = (e) => {        if press != null && e.pointerId == press.id {            let dy = e.clientY - press.y            let locked = press.locked || abs(dy) >= 6            if locked {                let gap = e.timeStamp - press.then                let speed = gap > 0 ? (e.clientY - press.was) / gap : press.speed                if !press.locked { dlg.classList.add("ui-theme-sheet-dragging") }                dlg.style.transform = `translateY(${max([0, dy])}px)`                press = { id: press.id, y: press.y, locked: true, dy, was: e.clientY, then: e.timeStamp, speed }            }        }    }    let end = (e) => {        if press != null && e.pointerId == press.id {            let closes = press.locked && theme_sheet_closes(press.dy, press.speed, dlg.offsetHeight)            release()            if closes { dlg.close() }        }    }    let cancel = () => { if press != null { release() } }    <div class={ui_cls(["ui-theme-sheet-host", class])} id={id}>        {trigger != null && <button.ui-theme-sheet-open type="button" aria-haspopup="dialog" @click={() => { dlg.showModal() }}>{trigger}</button>}        <dialog.ui-theme-sheet aria-label={title}                @mount={(el) => {                    let d = js("document")                    let got = theme_sheet_cookie(d.cookie, remember ?? "")                    dlg = el                    doc = d                    if saved == null && remember != null && got != null {                        let p = theme_sheet_parse(got, accents)                        theme = p.theme                        accent = p.accent                    }                    theme_sheet_apply(d, theme, accent, accents)                    if open { el.showModal() }                }}                @close={() => { on_close && on_close() }}                @click={(e) => { if e.target.tagName == "DIALOG" { dlg.close() } }}>            <div.ui-theme-sheet-grip aria-hidden="true" @pointerdown={start} @pointermove={move} @pointerup={end} @pointercancel={cancel}><i></i></div>            <div.ui-theme-sheet-head @pointerdown={start} @pointermove={move} @pointerup={end} @pointercancel={cancel}>                <h2>{title}</h2>                <button.ui-theme-sheet-done type="button" @click={() => { dlg.close() }}>Done</button>            </div>            <div.ui-theme-sheet-body>                <fieldset.ui-theme-sheet-group>                    <legend>Theme</legend>                    <div.ui-theme-sheet-modes>                        {for m in theme_sheet_modes => <label.ui-theme-sheet-mode key={m.id}>                            <input type="radio" name={`${group}-theme`} value={m.id} checked={theme == m.id} @change={() => { choose(m.id, accent) }}/>                            {m.name}                        </label>}                    </div>                </fieldset>                <fieldset.ui-theme-sheet-group>                    <legend>Accent</legend>                    <div.ui-theme-sheet-swatches>                        {for a in known => <label.ui-theme-sheet-swatch key={a.id} style={`--ui-theme-sheet-light: ${a.light}; --ui-theme-sheet-dark: ${a.dark}`}>                            <input type="radio" name={`${group}-accent`} value={a.id} aria-label={a.name} checked={accent == a.id} @change={() => { choose(theme, a.id) }}/>                            <i aria-hidden="true"></i>                        </label>}                    </div>                </fieldset>                <div.ui-theme-sheet-preview role="group" aria-label="Preview">                    <b>Preview</b>                    <p>Your screens will look like this, with <span.ui-theme-sheet-link>links</span> in this colour.</p>                    <span.ui-theme-sheet-sample>A button</span>                </div>            </div>        </dialog>    </div>}
Thread
27Useful? Like it, or add to its thread below.

Thread 0 posts

On ThemeSheet: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.