UX, tilgjengelighet og design etter gjennomgang

WCAG:
- Mørk modus: --on-accent-token gir mørk tekst på lys aksent (knapper,
  badges, valgt fane, skip-lenke) – var 1.8:1, nå ~11:1.
- Editor-feltet har label og aria-describedby; forhåndsvisningen er
  ikke lenger aria-live (leste opp hele innlegget for hvert tastetrykk).
- Bilder settes inn med alt-plassholder markert for overskriving, som
  eget avsnitt etter gjeldende linje.
- Berøringsmål ≥ 44px (nav, verktøylinje, lenkeknapper, select).
- Faner: roving tabindex + piltaster. Knapper med tekst i stedet for
  emoji, aria-keyshortcuts på fet/kursiv. h1 på forsiden.
- code bryter lange stier (innstillinger rullet horisontalt på mobil).

UX:
- Ulagret-vakt dekker også tittelen, med synlig «Ulagrede endringer».
- 413 ved for stor fil gir lesbar melding; brukernavn beholdes ved
  feil innlogging; full publiserings-URL vises i editoren; historikk-
  lenke i meta-linjen; norsk datoformat i historikken; passord-hint.

Design:
- Header med favicon, større brødtekst/overskrifter, smalere måltekst,
  bedre avstander i innlegg og liste, footer som flex uten løse «·»,
  nøytral sekundærknapp, innrammet faresone, editor-textarea fyller
  panelet, valgt fane henger sammen med panelet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
Ole-Morten Duesund 2026-08-26 14:12:18 +02:00
commit ae3d6a4c0c
12 changed files with 182 additions and 92 deletions

View file

@ -149,7 +149,12 @@ def owner_required(view):
@bp.context_processor @bp.context_processor
def _inject(): def _inject():
return {"csrf": csrf_token(), "user": getattr(g, "user", None), "site": _cfg().site} return {
"csrf": csrf_token(),
"user": getattr(g, "user", None),
"site": _cfg().site,
"cfg": _cfg(),
}
@bp.route("/login", methods=["GET", "POST"]) @bp.route("/login", methods=["GET", "POST"])
@ -477,5 +482,5 @@ def settings():
@owner_required @owner_required
def rebuild(): def rebuild():
n = build_site(_cfg()) n = build_site(_cfg())
flash(f"Bygde siden på nytt ({n} innlegg).", "ok") flash(f"Bygde nettstedet på nytt ({n} innlegg).", "ok")
return redirect(url_for("admin.settings")) return redirect(url_for("admin.settings"))

View file

@ -1,56 +1,69 @@
/* Admin-grensesnitt. Mobil først; editor og forhåndsvisning side om side fra 900px. */ /* Admin-grensesnitt. Mobil først; editor og forhåndsvisning side om side fra 900px.
Berøringsmål 44px alle knapper og lenker i navigasjon/verktøylinje. */
:root { :root {
--bg: #f4f8f3; --fg: #1c2a1e; --muted: #4d6350; --accent: #1f6b3a; --accent-strong: #154d29; --bg: #f4f8f3; --fg: #1c2a1e; --muted: #4d6350; --accent: #1f6b3a; --accent-strong: #154d29;
--surface: #fff; --border: #c9dccb; --code-bg: #e6efe6; --danger: #a12626; --on-accent: #fff; --surface: #fff; --border: #c9dccb; --code-bg: #e6efe6; --danger: #a12626;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #101a12; --fg: #e4ede4; --muted: #a7bda9; --accent: #7fd39a; --accent-strong: #a9e6bb; --bg: #101a12; --fg: #e4ede4; --muted: #a7bda9; --accent: #7fd39a; --accent-strong: #a9e6bb;
--surface: #182619; --border: #2f4532; --code-bg: #1e2f20; --danger: #ff8a8a; --on-accent: #0f1f13; --surface: #182619; --border: #2f4532; --code-bg: #1e2f20; --danger: #ff8a8a;
} }
} }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.5; } body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.5; }
.skip { position: absolute; left: -999px; } .skip:focus { left: 0; background: var(--accent); color: #fff; padding: .5rem; z-index: 10; } .skip { position: absolute; left: -999px; } .skip:focus { left: 0; background: var(--accent); color: var(--on-accent); padding: .5rem; z-index: 10; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
a { color: var(--accent); } a { color: var(--accent); }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } :focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.topbar { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center; padding: .6rem 1rem; border-bottom: 3px solid var(--accent); } code { overflow-wrap: anywhere; }
.topbar nav { display: flex; flex-wrap: wrap; gap: .25rem .9rem; align-items: center; } .topbar { display: flex; flex-wrap: wrap; gap: 0 1rem; align-items: center; padding: .4rem 1rem; border-bottom: 3px solid var(--accent); }
.brand { font-weight: 700; color: var(--accent-strong); text-decoration: none; font-size: 1.15rem; } .topbar nav { display: flex; flex-wrap: wrap; gap: 0 .5rem; align-items: center; }
.topbar nav a, .topbar nav button.link { display: inline-block; padding: .7rem .25rem; min-height: 44px; }
.brand { font-weight: 700; color: var(--accent-strong); text-decoration: none; font-size: 1.15rem; padding: .7rem 0; }
main { max-width: 70rem; margin: 0 auto; padding: 1rem; } main { max-width: 70rem; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.5rem; margin: .5rem 0; } h1 { font-size: 1.75rem; line-height: 1.2; margin: .5rem 0; }
h2 { font-size: 1.25rem; margin: 1.5rem 0 .5rem; }
.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; justify-content: space-between; } .row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; justify-content: space-between; }
.grow { flex: 1 1 18rem; } .grow { flex: 1 1 18rem; }
.stack { display: flex; flex-direction: column; gap: .75rem; } .stack { display: flex; flex-direction: column; gap: .75rem; }
.narrow { max-width: 28rem; } .narrow { max-width: 28rem; }
label { display: flex; flex-direction: column; gap: .25rem; font-weight: 600; } label { display: flex; flex-direction: column; gap: .25rem; font-weight: 600; }
input, textarea, button { font: inherit; } .hint { font-weight: normal; color: var(--muted); font-size: .9rem; }
input[type=text], input:not([type]), input[type=password], textarea { input, textarea, button, select { font: inherit; }
padding: .6rem; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--fg); width: 100%; input[type=text], input:not([type]), input[type=password], textarea, select {
padding: .6rem; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--fg); width: 100%; min-height: 44px;
} }
button { padding: .55rem .9rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--accent-strong); cursor: pointer; min-height: 44px; } button {
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } padding: .55rem .9rem; border: 1px solid var(--border); border-radius: 4px;
button.link { border: none; background: none; padding: .25rem; min-height: auto; text-decoration: underline; color: var(--accent); } background: var(--surface); color: var(--accent-strong); cursor: pointer; min-height: 44px;
}
button.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; }
button.link { border: none; background: none; padding: .6rem .25rem; text-decoration: underline; color: var(--accent); }
button.danger { color: var(--danger); } button.danger { color: var(--danger); }
.inline { display: inline; } .inline { display: inline; }
.meta { color: var(--muted); font-size: .9rem; } .meta { color: var(--muted); font-size: .9rem; }
.badge { background: var(--accent); color: #fff; padding: .05rem .45rem; border-radius: 999px; font-size: .8rem; } .badge { background: var(--accent); color: var(--on-accent); padding: .05rem .45rem; border-radius: 999px; font-size: .8rem; }
.flash { padding: .6rem .9rem; border-radius: 4px; border-left: 4px solid var(--accent); background: var(--surface); } .flash { padding: .6rem .9rem; border-radius: 4px; border-left: 4px solid var(--accent); background: var(--surface); }
.flash.error { border-color: var(--danger); } .flash.error { border-color: var(--danger); }
.posts, .users { list-style: none; padding: 0; } .posts, .users { list-style: none; padding: 0; max-width: 48rem; }
.posts li, .users li { padding: .6rem 0; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; } .posts li, .users li { padding: .6rem 0; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; }
.posts li a { padding: .4rem 0; display: inline-block; }
/* Editor */ /* Editor */
.tabs { display: flex; gap: .25rem; margin-top: 1rem; } .tabs { display: flex; gap: .25rem; margin-top: 1rem; position: relative; z-index: 1; margin-bottom: -1px; }
.tabs button { border-radius: 4px 4px 0 0; border-bottom: none; } .tabs button { border-radius: 4px 4px 0 0; }
.tabs button[aria-selected=true] { background: var(--accent); color: #fff; } .tabs button[aria-selected=true] { background: var(--surface); border-color: var(--accent); border-bottom-color: var(--surface); font-weight: 600; }
.panes section { border: 1px solid var(--border); background: var(--surface); padding: .5rem; border-radius: 0 4px 4px 4px; } .panes section { border: 1px solid var(--border); background: var(--surface); padding: .5rem; border-radius: 0 4px 4px 4px; display: flex; flex-direction: column; }
.panes section[hidden] { display: none; } /* display:flex over ville ellers slått ut [hidden] */
.toolbar { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; } .toolbar { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; }
.toolbar button { min-height: 40px; padding: .3rem .6rem; } .toolbar button { min-width: 44px; padding: .3rem .6rem; }
textarea#body { min-height: 50vh; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1rem; line-height: 1.5; } textarea#body { min-height: 50vh; flex: 1; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1rem; line-height: 1.5; }
textarea#body.dragover { outline: 3px dashed var(--accent); } textarea#body.dragover { outline: 3px dashed var(--accent); }
.preview { min-height: 50vh; padding: 0 .5rem; overflow-wrap: anywhere; } #upload-status { min-height: 1.5rem; margin: .5rem 0 0; }
.preview { flex: 1; min-height: 50vh; padding: 0 .5rem; overflow-wrap: anywhere; }
.preview:has(> .meta:only-child) { display: grid; place-items: center; }
.preview img { max-width: 100%; height: auto; } .preview img { max-width: 100%; height: auto; }
.preview pre, .preview code { background: var(--code-bg); } .preview pre, .preview code { background: var(--code-bg); }
.preview pre { padding: 1rem; overflow-x: auto; } .preview pre { padding: 1rem; overflow-x: auto; }
@ -61,18 +74,21 @@ textarea#body.dragover { outline: 3px dashed var(--accent); }
.tabs { display: none; } .tabs { display: none; }
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; } .panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
.panes section { border-radius: 4px; } .panes section { border-radius: 4px; }
.panes section[hidden] { display: block; } .panes section[hidden] { display: flex; }
} }
.editor ~ * { margin-top: 1.25rem; }
.images ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 1rem; } .images ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 1rem; }
.images li { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; } .images li { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; }
.images img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); } .images img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); }
.danger-zone { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1rem; } .danger-zone { border: 1px solid var(--danger); border-radius: 4px; padding: .5rem 1rem; }
pre.log { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; } pre.log { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; }
dl.meta dt { font-weight: 600; margin-top: .5rem; } dl.meta dd { margin: 0; } dl.meta dt { font-weight: 600; margin-top: .5rem; } dl.meta dd { margin: 0; }
.history { padding-left: 1.2rem; } .history { padding-left: 1.5rem; }
.history li { padding: .4rem 0; border-bottom: 1px solid var(--border); } .history li { padding: .4rem .5rem; border-bottom: 1px solid var(--border); }
.history li[aria-current] { font-weight: 600; } .history li[aria-current] { font-weight: 600; border-left: 4px solid var(--accent); }
.history li[aria-current]::marker { color: var(--accent); }
.history .meta { display: block; font-weight: normal; } .history .meta { display: block; font-weight: normal; }
.history li a { display: inline-block; padding: .3rem 0; }
pre.diff { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; line-height: 1.4; } pre.diff { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; line-height: 1.4; }
pre.diff .add { color: #14632c; background: #d8f2df; display: inline-block; width: 100%; } pre.diff .add { color: #14632c; background: #d8f2df; display: inline-block; width: 100%; }
pre.diff .del { color: #9a1c1c; background: #f8dcdc; display: inline-block; width: 100%; } pre.diff .del { color: #9a1c1c; background: #f8dcdc; display: inline-block; width: 100%; }

View file

@ -7,21 +7,37 @@
const ta = document.getElementById("body"); const ta = document.getElementById("body");
const preview = document.getElementById("preview"); const preview = document.getElementById("preview");
const status = document.getElementById("upload-status"); const status = document.getElementById("upload-status");
const dirtyStatus = document.getElementById("dirty-status");
const csrf = form.dataset.csrf; const csrf = form.dataset.csrf;
let dirty = false; let dirty = false;
// ---- tabs (kun synlige på små skjermer) ---- function setDirty(v) {
const tabs = form.querySelectorAll('[role="tab"]'); dirty = v;
tabs.forEach((tab) => dirtyStatus.textContent = v ? "Ulagrede endringer" : "";
tab.addEventListener("click", () => { }
tabs.forEach((t) => {
const on = t === tab; // ---- faner (kun synlige på små skjermer). Roving tabindex + piltaster
t.setAttribute("aria-selected", on); // i henhold til WAI-ARIA-mønsteret for tabs. ----
document.getElementById(t.getAttribute("aria-controls")).hidden = !on; const tabs = [...form.querySelectorAll('[role="tab"]')];
}); function selectTab(tab) {
if (tab.id === "tab-preview") renderPreview(); tabs.forEach((t) => {
}) const on = t === tab;
); t.setAttribute("aria-selected", on);
t.tabIndex = on ? 0 : -1;
document.getElementById(t.getAttribute("aria-controls")).hidden = !on;
});
if (tab.id === "tab-preview") renderPreview();
}
tabs.forEach((tab, i) => {
tab.addEventListener("click", () => selectTab(tab));
tab.addEventListener("keydown", (ev) => {
if (ev.key !== "ArrowRight" && ev.key !== "ArrowLeft") return;
ev.preventDefault();
const next = tabs[(i + (ev.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length];
selectTab(next);
next.focus();
});
});
// ---- forhåndsvisning ---- // ---- forhåndsvisning ----
let timer = null; let timer = null;
@ -41,10 +57,10 @@
} }
} }
ta.addEventListener("input", () => { ta.addEventListener("input", () => {
dirty = true;
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(renderPreview, 400); timer = setTimeout(renderPreview, 400);
}); });
form.addEventListener("input", () => setDirty(true)); // også tittelfeltet
renderPreview(); renderPreview();
// ---- tekstinnsetting ---- // ---- tekstinnsetting ----
@ -54,7 +70,7 @@
ta.setRangeText(before + sel + after, s, e, "select"); ta.setRangeText(before + sel + after, s, e, "select");
ta.setSelectionRange(s + before.length, s + before.length + sel.length); ta.setSelectionRange(s + before.length, s + before.length + sel.length);
ta.focus(); ta.focus();
ta.dispatchEvent(new Event("input")); ta.dispatchEvent(new Event("input", { bubbles: true }));
} }
function prefixLines(prefix) { function prefixLines(prefix) {
const s = ta.selectionStart, e = ta.selectionEnd; const s = ta.selectionStart, e = ta.selectionEnd;
@ -63,7 +79,7 @@
const out = block.split("\n").map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : prefix + l)).join("\n"); const out = block.split("\n").map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : prefix + l)).join("\n");
ta.setRangeText(out, start, e, "end"); ta.setRangeText(out, start, e, "end");
ta.focus(); ta.focus();
ta.dispatchEvent(new Event("input")); ta.dispatchEvent(new Event("input", { bubbles: true }));
} }
form.querySelectorAll(".toolbar button").forEach((b) => { form.querySelectorAll(".toolbar button").forEach((b) => {
b.addEventListener("click", () => { b.addEventListener("click", () => {
@ -106,19 +122,33 @@
fd.append("file", file, file.name); fd.append("file", file, file.name);
try { try {
const res = await fetch(form.dataset.uploadUrl, { method: "POST", headers: { "X-CSRF": csrf }, body: fd }); const res = await fetch(form.dataset.uploadUrl, { method: "POST", headers: { "X-CSRF": csrf }, body: fd });
const data = await res.json(); if (res.status === 413) throw new Error("Filen er for stor (maks 32 MB).");
const data = res.ok ? await res.json() : await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText); if (!res.ok) throw new Error(data.error || res.statusText);
// Bilder skal stå på egen linje for å bli egne avsnitt i Markdown. insertImage(data.filename);
const before = ta.value.slice(0, ta.selectionStart); status.textContent = "Lastet opp " + data.filename + ". Skriv en bildebeskrivelse i klammene, og husk å lagre.";
const lead = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
insert(lead + data.markdown + "\n", "", "");
status.textContent = "Lastet opp " + data.filename + ". Husk å lagre.";
} catch (e) { } catch (e) {
status.textContent = "Opplasting feilet: " + e.message; status.textContent = "Opplasting feilet: " + e.message;
} }
} }
function insertImage(filename) {
// Bildet settes inn som eget avsnitt etter linjen markøren står i, så vi
// aldri splitter markup midt i et ord eller en markering. Alt-teksten er
// en plassholder som blir markert, klar til å skrives over.
const lineEnd = ta.value.indexOf("\n", ta.selectionEnd);
const pos = lineEnd === -1 ? ta.value.length : lineEnd;
const before = ta.value.slice(0, pos);
const lead = before === "" ? "" : before.endsWith("\n") ? "\n" : "\n\n";
const alt = "Beskriv bildet";
const md = lead + "![" + alt + "](" + filename + ")\n";
ta.setRangeText(md, pos, pos, "end");
const altStart = pos + lead.length + 2;
ta.setSelectionRange(altStart, altStart + alt.length);
ta.focus();
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
// ---- ikke mist tekst ved uhell ---- // ---- ikke mist tekst ved uhell ----
form.addEventListener("submit", () => { dirty = false; }); form.addEventListener("submit", () => setDirty(false));
window.addEventListener("beforeunload", (ev) => { if (dirty) { ev.preventDefault(); ev.returnValue = ""; } }); window.addEventListener("beforeunload", (ev) => { if (dirty) { ev.preventDefault(); ev.returnValue = ""; } });
})(); })();

View file

@ -5,36 +5,39 @@
data-upload-url="{{ url_for('admin.upload', post_id=post.id) }}" data-csrf="{{ csrf }}"> data-upload-url="{{ url_for('admin.upload', post_id=post.id) }}" data-csrf="{{ csrf }}">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<div class="row"> <div class="row">
<label class="grow">Tittel <input name="title" value="{{ post.title }}" required></label> <label class="grow">Tittel <input name="title" value="{{ post.title }}" required autofocus></label>
<span class="meta"> <span class="meta">
{% if post.draft %}<span class="badge">utkast</span> URL: /{{ post.url_path }} (følger tittelen) {% if post.draft %}<span class="badge">utkast</span> Publiseres på <code>{{ cfg.site_url }}/{{ post.url_path }}</code> (følger tittelen)
{% else %}Publisert på <a href="{{ url_for('public', path=post.url_path) }}">/{{ post.url_path }}</a>{% endif %} {% else %}Publisert på <a href="{{ url_for('public', path=post.url_path) }}">{{ cfg.site_url }}/{{ post.url_path }}</a>
· <a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk</a>{% endif %}
</span> </span>
</div> </div>
<div class="tabs" role="tablist" aria-label="Visning"> <div class="tabs" role="tablist" aria-label="Visning">
<button type="button" role="tab" aria-selected="true" aria-controls="pane-write" id="tab-write">Skriv</button> <button type="button" role="tab" aria-selected="true" aria-controls="pane-write" id="tab-write" tabindex="0">Skriv</button>
<button type="button" role="tab" aria-selected="false" aria-controls="pane-preview" id="tab-preview">Forhåndsvisning</button> <button type="button" role="tab" aria-selected="false" aria-controls="pane-preview" id="tab-preview" tabindex="-1">Forhåndsvisning</button>
</div> </div>
<div class="panes"> <div class="panes">
<section id="pane-write" role="tabpanel" aria-labelledby="tab-write"> <section id="pane-write" role="tabpanel" aria-labelledby="tab-write">
<div class="toolbar" role="toolbar" aria-label="Formatering"> <div class="toolbar" role="toolbar" aria-label="Formatering">
<button type="button" data-wrap="**" title="Fet (Ctrl+B)"><b>B</b></button> <button type="button" data-wrap="**" aria-label="Fet" aria-keyshortcuts="Control+B" title="Fet (Ctrl+B)"><b>B</b></button>
<button type="button" data-wrap="_" title="Kursiv (Ctrl+I)"><i>I</i></button> <button type="button" data-wrap="_" aria-label="Kursiv" aria-keyshortcuts="Control+I" title="Kursiv (Ctrl+I)"><i>I</i></button>
<button type="button" data-prefix="## " title="Overskrift">H2</button> <button type="button" data-prefix="## " aria-label="Overskrift">H2</button>
<button type="button" data-prefix="- " title="Liste">• liste</button> <button type="button" data-prefix="- ">Liste</button>
<button type="button" data-prefix="> " title="Sitat">“ sitat</button> <button type="button" data-prefix="> ">Sitat</button>
<button type="button" data-link title="Lenke">🔗</button> <button type="button" data-link>Lenke</button>
<button type="button" id="btn-image" title="Last opp bilde">📷 Bilde</button> <button type="button" id="btn-image">Bilde</button>
<input type="file" id="file-input" accept="image/*" multiple hidden> <input type="file" id="file-input" accept="image/*" multiple hidden>
</div> </div>
<textarea name="body" id="body" rows="18" spellcheck="true" <label for="body" class="visually-hidden">Innhold</label>
placeholder="Skriv i Markdown. Dra inn eller lim inn bilder for å laste dem opp.">{{ post.body }}</textarea> <textarea name="body" id="body" rows="18" spellcheck="true" aria-describedby="body-hint"
placeholder="Skriv i Markdown.">{{ post.body }}</textarea>
<p class="meta" id="body-hint">Markdown. Dra inn, lim inn eller trykk «Bilde» for å laste opp bilder.</p>
<p class="meta" id="upload-status" role="status" aria-live="polite"></p> <p class="meta" id="upload-status" role="status" aria-live="polite"></p>
</section> </section>
<section id="pane-preview" role="tabpanel" aria-labelledby="tab-preview" hidden> <section id="pane-preview" role="tabpanel" aria-labelledby="tab-preview" hidden>
<div class="preview body" id="preview" aria-live="polite"><p class="meta">Forhåndsvisningen kommer her.</p></div> <div class="preview body" id="preview"><p class="meta">Forhåndsvisningen kommer her.</p></div>
</section> </section>
</div> </div>
@ -45,6 +48,7 @@
{% else %} {% else %}
<button type="submit" name="action" value="unpublish">Avpubliser</button> <button type="submit" name="action" value="unpublish">Avpubliser</button>
{% endif %} {% endif %}
<span class="meta" id="dirty-status" role="status" aria-live="polite"></span>
</div> </div>
</form> </form>
@ -67,7 +71,7 @@
</details> </details>
{% endif %} {% endif %}
<p><a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk for dette innlegget</a></p> {% if post.draft %}<p><a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk for dette innlegget</a></p>{% endif %}
<form method="post" action="{{ url_for('admin.delete', post_id=post.id) }}" class="danger-zone" data-confirm="Slette hele innlegget «{{ post.title }}»?"> <form method="post" action="{{ url_for('admin.delete', post_id=post.id) }}" class="danger-zone" data-confirm="Slette hele innlegget «{{ post.title }}»?">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">

View file

@ -7,9 +7,9 @@
{% if not entries %}<p>Ingen historikk ennå.</p>{% endif %} {% if not entries %}<p>Ingen historikk ennå.</p>{% endif %}
<ol class="history"> <ol class="history">
{% for e in entries %} {% for e in entries %}
<li{% if e.sha == sha %} aria-current="true"{% endif %}> <li{% if e.sha == sha %} aria-current="page"{% endif %}>
<a href="{{ url_for('admin.history', post_id=post.id, sha=e.sha) }}">{{ e.subject }}</a> <a href="{{ url_for('admin.history', post_id=post.id, sha=e.sha) }}">{{ e.subject }}</a>
<span class="meta"><time datetime="{{ e.date }}">{{ e.date[:16].replace('T', ' ') }}</time> · {{ e.author }} · <code>{{ e.sha[:7] }}</code></span> <span class="meta"><time datetime="{{ e.date }}">{{ e.date[8:10] }}.{{ e.date[5:7] }}.{{ e.date[:4] }} {{ e.date[11:16] }}</time> · {{ e.author }} · <code>{{ e.sha[:7] }}</code></span>
</li> </li>
{% endfor %} {% endfor %}
</ol> </ol>

View file

@ -5,7 +5,7 @@
<form method="post" class="stack narrow"> <form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
<label>Brukernavn <input name="username" autocomplete="username" autocapitalize="none" required autofocus></label> <label>Brukernavn <input name="username" value="{{ request.form.username or '' }}" autocomplete="username" autocapitalize="none" required autofocus></label>
<label>Passord <input type="password" name="password" autocomplete="current-password" required></label> <label>Passord <input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit" class="primary">Logg inn</button> <button type="submit" class="primary">Logg inn</button>
</form> </form>

View file

@ -5,7 +5,8 @@
<form method="post" class="stack narrow"> <form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<label>Nåværende passord <input type="password" name="current" required autocomplete="current-password"></label> <label>Nåværende passord <input type="password" name="current" required autocomplete="current-password"></label>
<label>Nytt passord <input type="password" name="password" minlength="8" required autocomplete="new-password"></label> <label>Nytt passord <span class="hint" id="pw-hint">Minst 8 tegn.</span>
<input type="password" name="password" minlength="8" required autocomplete="new-password" aria-describedby="pw-hint"></label>
<button type="submit" class="primary">Bytt passord</button> <button type="submit" class="primary">Bytt passord</button>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -21,11 +21,11 @@
<dt>Base-path</dt><dd><code>{{ cfg.base_path or '/' }}</code></dd> <dt>Base-path</dt><dd><code>{{ cfg.base_path or '/' }}</code></dd>
<dt>Site-URL (for RSS)</dt><dd><code>{{ cfg.site_url }}</code></dd> <dt>Site-URL (for RSS)</dt><dd><code>{{ cfg.site_url }}</code></dd>
<dt>Tema</dt><dd><code>{{ cfg.theme }}</code> (standard fra miljø: <code>{{ cfg.default_theme }}</code>)</dd> <dt>Tema</dt><dd><code>{{ cfg.theme }}</code> (standard fra miljø: <code>{{ cfg.default_theme }}</code>)</dd>
<dt>Innhold</dt><dd><code>{{ cfg.content_dir }}</code> (git-repo)</dd> <dt>Innholdsmappe</dt><dd><code>{{ cfg.content_dir }}</code> (git-repo)</dd>
</dl> </dl>
<form method="post" action="{{ url_for('admin.rebuild') }}"> <form method="post" action="{{ url_for('admin.rebuild') }}">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit">Bygg siden på nytt</button> <button type="submit">Bygg nettstedet på nytt</button>
</form> </form>
<h2>Siste endringer i git</h2> <h2>Siste endringer i git</h2>

View file

@ -1,16 +1,19 @@
/* Tema: "grønt er skjønt". /* Tema: "grønt er skjønt".
Alle farger er CSS-variabler; et nytt tema kan nøye seg med å overstyre :root. Alle farger er CSS-variabler; et nytt tema kan nøye seg med å overstyre :root.
Kontrast: tekst/bakgrunn 7:1 (AAA), lenker 4.5:1 begge skjemaer. */ Kontrast: tekst/bakgrunn 7:1 (AAA), lenker 4.5:1 begge skjemaer.
--on-accent er tekstfargen oppå --accent og byttes i mørk modus, der
aksenten er lys. */
:root { :root {
--bg: #f4f8f3; --bg: #f4f8f3;
--fg: #1c2a1e; --fg: #1c2a1e;
--muted: #4d6350; --muted: #4d6350;
--accent: #1f6b3a; --accent: #1f6b3a;
--accent-strong: #154d29; --accent-strong: #154d29;
--on-accent: #ffffff;
--surface: #ffffff; --surface: #ffffff;
--border: #c9dccb; --border: #c9dccb;
--code-bg: #e6efe6; --code-bg: #e6efe6;
--max-width: 42rem; --max-width: 40rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@ -19,6 +22,7 @@
--muted: #a7bda9; --muted: #a7bda9;
--accent: #7fd39a; --accent: #7fd39a;
--accent-strong: #a9e6bb; --accent-strong: #a9e6bb;
--on-accent: #0f1f13;
--surface: #182619; --surface: #182619;
--border: #2f4532; --border: #2f4532;
--code-bg: #1e2f20; --code-bg: #1e2f20;
@ -34,13 +38,18 @@ body {
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.6; font-size: 1.0625rem;
line-height: 1.65;
} }
.skip { .skip {
position: absolute; left: -999px; top: 0; position: absolute; left: -999px; top: 0;
background: var(--accent); color: #fff; padding: .5rem 1rem; background: var(--accent); color: var(--on-accent); padding: .5rem 1rem;
} }
.skip:focus { left: 0; z-index: 10; } .skip:focus { left: 0; z-index: 10; }
.visually-hidden {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap;
}
.site-header, main, .site-footer { .site-header, main, .site-footer {
max-width: var(--max-width); max-width: var(--max-width);
margin: 0 auto; margin: 0 auto;
@ -48,28 +57,45 @@ body {
} }
.site-header { border-bottom: 3px solid var(--accent); padding-top: 1.5rem; } .site-header { border-bottom: 3px solid var(--accent); padding-top: 1.5rem; }
.site-title { .site-title {
font-size: 1.75rem; font-weight: 700; text-decoration: none; color: var(--accent-strong); display: inline-flex; align-items: center; gap: .6rem;
font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em;
text-decoration: none; color: var(--accent-strong);
}
.site-title::before {
content: ""; width: 2rem; height: 2rem; flex: none;
background: url("favicon.svg") center / contain no-repeat;
} }
.site-description { margin: .25rem 0 0; color: var(--muted); } .site-description { margin: .25rem 0 0; color: var(--muted); }
main { padding-top: 1.5rem; }
a { color: var(--accent); } a { color: var(--accent); }
a:hover { color: var(--accent-strong); } a:hover { color: var(--accent-strong); }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible { a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 3px solid var(--accent); outline-offset: 2px; outline: 3px solid var(--accent); outline-offset: 2px;
} }
h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .25rem; } h1 { font-size: 2.1rem; line-height: 1.15; letter-spacing: -.015em; margin: .5rem 0 .35rem; }
h2 { font-size: 1.35rem; margin: 1.25rem 0 .25rem; } h2 { font-size: 1.4rem; line-height: 1.25; margin: 2rem 0 .5rem; }
.meta { color: var(--muted); font-size: .9rem; margin: 0 0 1rem; } .meta { color: var(--muted); font-size: .9rem; margin: 0 0 1.5rem; font-variant-numeric: tabular-nums; }
.summary { padding: .5rem 0; border-bottom: 1px solid var(--border); } .summary { padding: 1.25rem 0; border-bottom: 1px solid var(--border); }
.summary h2 a { text-decoration: none; } .summary h2 { margin: 0 0 .25rem; }
.summary h2 a { color: var(--accent-strong); text-decoration: none; }
.summary h2 a:hover { text-decoration: underline; } .summary h2 a:hover { text-decoration: underline; }
.body img { max-width: 100%; height: auto; border-radius: 4px; } .summary .meta { margin: 0; }
.body p, .body ul, .body ol { margin: 0 0 1.1rem; }
.body img { max-width: 100%; height: auto; display: block; margin: 1.5rem 0; border-radius: 4px; }
.body pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; } .body pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; }
.body code { background: var(--code-bg); padding: .1em .3em; border-radius: 3px; font-size: .95em; } .body code { background: var(--code-bg); padding: .1em .3em; border-radius: 3px; font-size: .95em; overflow-wrap: anywhere; }
.body pre code { background: none; padding: 0; } .body pre code { background: none; padding: 0; }
.body blockquote { .body blockquote {
margin: 1rem 0; padding: .25rem 1rem; border-left: 4px solid var(--accent); color: var(--muted); margin: 1rem 0; padding: .25rem 1rem; border-left: 4px solid var(--accent); color: var(--muted);
} }
.body table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; } .body table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; }
.body th, .body td { border: 1px solid var(--border); padding: .4rem .6rem; text-align: left; } .body th, .body td { border: 1px solid var(--border); padding: .4rem .6rem; text-align: left; }
.site-footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .9rem; margin-top: 2rem; } .site-footer {
.colophon { margin: .5rem 0 0; font-size: .85rem; } border-top: 1px solid var(--border); color: var(--muted); font-size: .9rem;
margin-top: 3rem; padding-top: 1.25rem;
display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem 1rem;
}
.site-footer nav a { text-decoration: none; display: inline-block; padding: .5rem 0; }
.site-footer nav a:hover { text-decoration: underline; }
.site-footer nav a + a::before { content: "·"; margin: 0 .5rem; color: var(--border); }
.colophon { margin: 0; padding: .5rem 0; }

View file

@ -20,9 +20,7 @@
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<nav aria-label="Bunnmeny"> <nav aria-label="Bunnmeny">
<a href="{{ rel('') }}">Forsiden</a> · <a href="{{ rel('') }}">Forsiden</a><a href="{{ rel('feed.xml') }}">RSS</a><a href="{{ rel('admin/') }}">Logg inn</a>
<a href="{{ rel('feed.xml') }}">RSS</a> ·
<a href="{{ rel('admin/') }}">Logg inn</a>
</nav> </nav>
{# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #} {# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #}
<p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p> <p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p>

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if heading %}{{ heading }} {% endif %}{{ site.title }}{% endblock %} {% block title %}{% if heading %}{{ heading }} {% endif %}{{ site.title }}{% endblock %}
{% block content %} {% block content %}
{% if heading %}<h1>{{ heading }}</h1>{% endif %} {% if heading %}<h1>{{ heading }}</h1>{% else %}<h1 class="visually-hidden">Siste innlegg</h1>{% endif %}
{% if not posts %}<p>Ingen innlegg ennå.</p>{% endif %} {% if not posts %}<p>Ingen innlegg ennå.</p>{% endif %}
{% for post in posts %} {% for post in posts %}
<article class="summary"> <article class="summary">

View file

@ -247,3 +247,13 @@ def test_slug_uniqueness(client, cfg):
) )
ids.append(r.headers["Location"].split("/admin/edit/")[1]) ids.append(r.headers["Location"].split("/admin/edit/")[1])
assert ids == ["_owner/samme", "_owner/samme-2"] assert ids == ["_owner/samme", "_owner/samme-2"]
def test_editor_accessibility_markup(client):
post_id = _create_post(client)
html = client.get(f"/admin/edit/{post_id}").text
assert '<label for="body" class="visually-hidden">Innhold</label>' in html
assert "aria-live" not in html.split('id="preview"')[1].split("</div>")[0]
assert 'aria-keyshortcuts="Control+B"' in html
front = client.get("/").text
assert "<h1" in front