Blogging

ReaderSettings

A post's text the way each reader likes it: size, serif or sans, line length, line spacing and light or dark, kept for their next visit.

#blogging#settings#typography#accessibility#interactive

Demo

Preview

The case for small websites

A small website loads before you have finished deciding to visit it. It has one author, who answers email, and an archive that goes back further than any platform has lasted. Nobody optimises it for engagement, so it is free to be odd.

Most of mine is text. The rest is a list of other people's small websites, which is the oldest way of finding things on the web and still the best one.

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/reader_settings.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/reader_settings.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/reader_settings.qwe
view ReaderSettingsDemo() =    <ReaderSettings id="reader-settings-demo" store="reader-settings-demo">        <h2 style="margin-top: 0">The case for small websites</h2>        <p>A small website loads before you have finished deciding to visit it. It has one author, who answers email, and an archive            that goes back further than any platform has lasted. Nobody optimises it for engagement, so it is free to be odd.</p>        <p>Most of mine is text. The rest is a <a href="#reader_settings">list of other people's small websites</a>, which is the            oldest way of finding things on the web and still the best one.</p>    </ReaderSettings>

Source

ui/reader_settings.qwe
// ReaderSettings: a post's text the way each reader likes it: an "Aa" button over it// opens settings for the text size, the font (serif, sans or mono), the line length,// the line spacing and the colours (as the system has them, light or dark), and Reset.// The post is its children; only it changes, not the rest of the page. With `store`,// the choice is kept in the browser (localStorage) for the reader's next visit.////   <ReaderSettings store="reader">//       <h1>{post.title}</h1>//       {markdown(post.body)}//   </ReaderSettings>//   <ReaderSettings size={20} font="sans" width="narrow" theme="dark" open={true}>...</ReaderSettings>//// children: the text. size: its size in pixels to start with (18; 14 to 26 by 2). font:// "serif" (default), "sans" or "mono". width: the line length, "narrow" (56// characters), "medium" (default, 68) or "wide" (84). spacing: "tight", "normal"// (default) or "loose". theme: "auto" (default, as the system has it), "light" or "dark"// (the theme's own colours, by the ui-light and ui-dark classes). store: the// localStorage key to keep the settings under (none keeps nothing). open: the// settings shown to start with (false). label: the button's name ("Reading settings").// id: a prefix for its ids ("reader-settings"); give each on a page its own. Escape// closes the settings and puts the focus back on the button.import ui/theme.qwe use ui_clslet reader_settings_css = css`.ui-breader { display: grid; gap: 12px; min-width: 0; font-family: var(--ui-font); color: var(--ui-fg); }.ui-breader-bar { position: relative; display: flex; justify-content: flex-end; }.ui-breader-toggle { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border: 1px solid var(--ui-border);  border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 13px var(--ui-font); cursor: pointer; }.ui-breader-toggle b { font-family: Georgia, "Times New Roman", serif; font-size: 16px; letter-spacing: -.02em; }.ui-breader-toggle:hover, .ui-breader-toggle[aria-expanded="true"] { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-breader-toggle:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-breader-panel { position: absolute; z-index: 20; top: calc(100% + 6px); right: 0; display: grid; gap: 12px; width: min(300px, 100vw - 32px);  padding: 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-lg);  font: 13px/1.3 var(--ui-font); }.ui-breader-panel[hidden] { display: none; }.ui-breader-row { display: grid; gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }.ui-breader-row legend, .ui-breader-name { padding: 0; margin-bottom: 6px; color: var(--ui-muted); font-size: 11px; font-weight: 650; letter-spacing: .06em;  text-transform: uppercase; }.ui-breader-size { display: grid; grid-template-columns: 38px 1fr 38px; align-items: center; gap: 6px; text-align: center; }.ui-breader-size button { height: 32px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  color: var(--ui-fg); font: 600 13px var(--ui-font); cursor: pointer; }.ui-breader-size button:last-child { font-size: 17px; }.ui-breader-size button:hover:not(:disabled) { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-breader-size button:disabled { opacity: .4; cursor: not-allowed; }.ui-breader-size button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-breader-size output { font-weight: 600; font-variant-numeric: tabular-nums; }.ui-breader-seg { display: grid; grid-auto-columns: 1fr; grid-auto-flow: column; gap: 2px; padding: 3px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-breader-seg label { position: relative; display: grid; place-items: center; height: 28px; border-radius: calc(var(--ui-radius-sm) - 2px); color: var(--ui-muted);  font-weight: 600; cursor: pointer; }.ui-breader-seg input { position: absolute; opacity: 0; pointer-events: none; }.ui-breader-seg label:has(input:checked) { background: var(--ui-surface); color: var(--ui-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .08); }.ui-breader-seg label:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-breader-reset { justify-self: start; padding: 2px 0; border: 0; background: none; color: var(--ui-accent); font: 600 13px var(--ui-font); cursor: pointer; border-radius: 3px; }.ui-breader-reset:hover { text-decoration: underline; }.ui-breader-reset:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-breader-body { max-width: var(--ui-breader-measure, 68ch); margin: 0 auto; width: 100%; padding: 4px 0; border-radius: var(--ui-radius);  background: var(--ui-bg); color: var(--ui-fg); font-size: var(--ui-breader-size, 18px); line-height: var(--ui-breader-leading, 1.7);  transition: background .2s, color .2s; }.ui-breader-body.ui-light, .ui-breader-body.ui-dark { padding: 20px 24px; }.ui-breader-serif { font-family: Charter, "Iowan Old Style", Georgia, Cambria, "Times New Roman", serif; }.ui-breader-sans { font-family: var(--ui-font); }.ui-breader-mono { font-family: var(--ui-mono); font-size: calc(var(--ui-breader-size, 18px) * .9); }.ui-breader-body h1, .ui-breader-body h2, .ui-breader-body h3 { line-height: 1.25; }.ui-breader-body a { color: var(--ui-accent); }@media (prefers-reduced-motion: reduce) { .ui-breader-body { transition: none; } }`let reader_settings_widths = { narrow: "56ch", medium: "68ch", wide: "84ch" }let reader_settings_leading = { tight: "1.45", normal: "1.7", loose: "1.95" }component ReaderSettings(size: int = 18, font: str = "serif", width: str = "medium", spacing: str = "normal", theme: str = "auto",                         store: str? = null, open: bool = false, label: str = "Reading settings", id: str = "reader-settings",                         class: str? = null, children) {    style global reader_settings_css    state shown = open    state sz = size    state ff = font    state ww = width    state ls = spacing    state th = theme    let keep = () => {        if store != null { js("localStorage.setItem", store, json.stringify({ size: sz, font: ff, width: ww, spacing: ls, theme: th })) }    }    let seg = (key: str, now: str, options: list, set_to) =>        <fieldset.ui-breader-row>            <legend>{key}</legend>            <div.ui-breader-seg>                {for o in options => <label key={o.value}>                    <input type="radio" name={`${id}-${lower(replace(key, " ", "-"))}`} value={o.value} checked={now == o.value}                           @change={() => { set_to(o.value); keep() }}/>                    {o.name}                </label>}            </div>        </fieldset>    <div class={ui_cls(["ui-breader", class])} id={id}         @mount={(_el) => {             if store != null {                 let saved = json.parse(js("localStorage.getItem", store) ?? "null")                 if type_of(saved) == "map" {                     if type_of(saved.size) == "int" && saved.size >= 14 && saved.size <= 26 { sz = saved.size }                     if contains(["serif", "sans", "mono"], saved.font) { ff = saved.font }                     if contains(["narrow", "medium", "wide"], saved.width) { ww = saved.width }                     if contains(["tight", "normal", "loose"], saved.spacing) { ls = saved.spacing }                     if contains(["auto", "light", "dark"], saved.theme) { th = saved.theme }                 }             }         }}>        <div.ui-breader-bar>            <button.ui-breader-toggle type="button" id={`${id}-toggle`} aria-expanded={shown ? "true" : "false"} aria-controls={`${id}-panel`}                    @click={() => { shown = !shown }}>                <b aria-hidden="true">Aa</b>{label}            </button>            <div.ui-breader-panel id={`${id}-panel`} role="group" aria-label={label} hidden={!shown}                 @keydown={(e) => {                     if e.key == "Escape" {                         e.preventDefault()                         shown = false                         js("document.getElementById", `${id}-toggle`).focus()                     }                 }}>                <div.ui-breader-row>                    <span.ui-breader-name id={`${id}-size`}>Text size</span>                    <div.ui-breader-size role="group" aria-labelledby={`${id}-size`}>                        <button type="button" aria-label="Smaller text" disabled={sz <= 14} @click={() => { sz = sz - 2; keep() }}>A−</button>                        <output aria-live="polite">{sz}px</output>                        <button type="button" aria-label="Larger text" disabled={sz >= 26} @click={() => { sz = sz + 2; keep() }}>A+</button>                    </div>                </div>                {seg("Font", ff, [{ value: "serif", name: "Serif" }, { value: "sans", name: "Sans" }, { value: "mono", name: "Mono" }], (v) => { ff = v })}                {seg("Line length", ww, [{ value: "narrow", name: "Narrow" }, { value: "medium", name: "Medium" }, { value: "wide", name: "Wide" }], (v) => { ww = v })}                {seg("Line spacing", ls, [{ value: "tight", name: "Tight" }, { value: "normal", name: "Normal" }, { value: "loose", name: "Loose" }], (v) => { ls = v })}                {seg("Colours", th, [{ value: "auto", name: "Auto" }, { value: "light", name: "Light" }, { value: "dark", name: "Dark" }], (v) => { th = v })}                <button.ui-breader-reset type="button" @click={() => {                    sz = 18                    ff = "serif"                    ww = "medium"                    ls = "normal"                    th = "auto"                    if store != null { js("localStorage.removeItem", store) }                }}>Reset</button>            </div>        </div>        <div class={ui_cls(["ui-breader-body", `ui-breader-${ff}`, th != "auto" && `ui-${th}`])}             style={`--ui-breader-size: ${sz}px; --ui-breader-measure: ${reader_settings_widths[ww] ?? "68ch"}; --ui-breader-leading: ${reader_settings_leading[ls] ?? "1.7"}`}             data-size={sz} data-width={ww} data-spacing={ls}>            {children}        </div>    </div>}
Thread
96Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.