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
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"])
@ -477,5 +482,5 @@ def settings():
@owner_required
def rebuild():
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"))

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 {
--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) {
:root {
--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; } }
* { box-sizing: border-box; }
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); }
: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); }
.topbar nav { display: flex; flex-wrap: wrap; gap: .25rem .9rem; align-items: center; }
.brand { font-weight: 700; color: var(--accent-strong); text-decoration: none; font-size: 1.15rem; }
code { overflow-wrap: anywhere; }
.topbar { display: flex; flex-wrap: wrap; gap: 0 1rem; align-items: center; padding: .4rem 1rem; border-bottom: 3px solid var(--accent); }
.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; }
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; }
.grow { flex: 1 1 18rem; }
.stack { display: flex; flex-direction: column; gap: .75rem; }
.narrow { max-width: 28rem; }
label { display: flex; flex-direction: column; gap: .25rem; font-weight: 600; }
input, textarea, button { font: inherit; }
input[type=text], input:not([type]), input[type=password], textarea {
padding: .6rem; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--fg); width: 100%;
.hint { font-weight: normal; color: var(--muted); font-size: .9rem; }
input, textarea, button, select { font: inherit; }
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.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.link { border: none; background: none; padding: .25rem; min-height: auto; text-decoration: underline; color: var(--accent); }
button {
padding: .55rem .9rem; border: 1px solid var(--border); border-radius: 4px;
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); }
.inline { display: inline; }
.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.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 a { padding: .4rem 0; display: inline-block; }
/* Editor */
.tabs { display: flex; gap: .25rem; margin-top: 1rem; }
.tabs button { border-radius: 4px 4px 0 0; border-bottom: none; }
.tabs button[aria-selected=true] { background: var(--accent); color: #fff; }
.panes section { border: 1px solid var(--border); background: var(--surface); padding: .5rem; border-radius: 0 4px 4px 4px; }
.tabs { display: flex; gap: .25rem; margin-top: 1rem; position: relative; z-index: 1; margin-bottom: -1px; }
.tabs button { border-radius: 4px 4px 0 0; }
.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; 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 button { min-height: 40px; padding: .3rem .6rem; }
textarea#body { min-height: 50vh; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1rem; line-height: 1.5; }
.toolbar button { min-width: 44px; padding: .3rem .6rem; }
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); }
.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 pre, .preview code { background: var(--code-bg); }
.preview pre { padding: 1rem; overflow-x: auto; }
@ -61,18 +74,21 @@ textarea#body.dragover { outline: 3px dashed var(--accent); }
.tabs { display: none; }
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
.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 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); }
.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; }
dl.meta dt { font-weight: 600; margin-top: .5rem; } dl.meta dd { margin: 0; }
.history { padding-left: 1.2rem; }
.history li { padding: .4rem 0; border-bottom: 1px solid var(--border); }
.history li[aria-current] { font-weight: 600; }
.history { padding-left: 1.5rem; }
.history li { padding: .4rem .5rem; border-bottom: 1px solid var(--border); }
.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 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 .add { color: #14632c; background: #d8f2df; 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 preview = document.getElementById("preview");
const status = document.getElementById("upload-status");
const dirtyStatus = document.getElementById("dirty-status");
const csrf = form.dataset.csrf;
let dirty = false;
// ---- tabs (kun synlige på små skjermer) ----
const tabs = form.querySelectorAll('[role="tab"]');
tabs.forEach((tab) =>
tab.addEventListener("click", () => {
tabs.forEach((t) => {
const on = t === tab;
t.setAttribute("aria-selected", on);
document.getElementById(t.getAttribute("aria-controls")).hidden = !on;
});
if (tab.id === "tab-preview") renderPreview();
})
);
function setDirty(v) {
dirty = v;
dirtyStatus.textContent = v ? "Ulagrede endringer" : "";
}
// ---- faner (kun synlige på små skjermer). Roving tabindex + piltaster
// i henhold til WAI-ARIA-mønsteret for tabs. ----
const tabs = [...form.querySelectorAll('[role="tab"]')];
function selectTab(tab) {
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 ----
let timer = null;
@ -41,10 +57,10 @@
}
}
ta.addEventListener("input", () => {
dirty = true;
clearTimeout(timer);
timer = setTimeout(renderPreview, 400);
});
form.addEventListener("input", () => setDirty(true)); // også tittelfeltet
renderPreview();
// ---- tekstinnsetting ----
@ -54,7 +70,7 @@
ta.setRangeText(before + sel + after, s, e, "select");
ta.setSelectionRange(s + before.length, s + before.length + sel.length);
ta.focus();
ta.dispatchEvent(new Event("input"));
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
function prefixLines(prefix) {
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");
ta.setRangeText(out, start, e, "end");
ta.focus();
ta.dispatchEvent(new Event("input"));
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
form.querySelectorAll(".toolbar button").forEach((b) => {
b.addEventListener("click", () => {
@ -106,19 +122,33 @@
fd.append("file", file, file.name);
try {
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);
// Bilder skal stå på egen linje for å bli egne avsnitt i Markdown.
const before = ta.value.slice(0, ta.selectionStart);
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.";
insertImage(data.filename);
status.textContent = "Lastet opp " + data.filename + ". Skriv en bildebeskrivelse i klammene, og husk å lagre.";
} catch (e) {
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 ----
form.addEventListener("submit", () => { dirty = false; });
form.addEventListener("submit", () => setDirty(false));
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 }}">
<input type="hidden" name="csrf" value="{{ csrf }}">
<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">
{% if post.draft %}<span class="badge">utkast</span> URL: /{{ post.url_path }} (følger tittelen)
{% else %}Publisert på <a href="{{ url_for('public', path=post.url_path) }}">/{{ post.url_path }}</a>{% endif %}
{% 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) }}">{{ cfg.site_url }}/{{ post.url_path }}</a>
· <a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk</a>{% endif %}
</span>
</div>
<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="false" aria-controls="pane-preview" id="tab-preview">Forhåndsvisning</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" tabindex="-1">Forhåndsvisning</button>
</div>
<div class="panes">
<section id="pane-write" role="tabpanel" aria-labelledby="tab-write">
<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="_" title="Kursiv (Ctrl+I)"><i>I</i></button>
<button type="button" data-prefix="## " title="Overskrift">H2</button>
<button type="button" data-prefix="- " title="Liste">• liste</button>
<button type="button" data-prefix="> " title="Sitat">“ sitat</button>
<button type="button" data-link title="Lenke">🔗</button>
<button type="button" id="btn-image" title="Last opp bilde">📷 Bilde</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="_" aria-label="Kursiv" aria-keyshortcuts="Control+I" title="Kursiv (Ctrl+I)"><i>I</i></button>
<button type="button" data-prefix="## " aria-label="Overskrift">H2</button>
<button type="button" data-prefix="- ">Liste</button>
<button type="button" data-prefix="> ">Sitat</button>
<button type="button" data-link>Lenke</button>
<button type="button" id="btn-image">Bilde</button>
<input type="file" id="file-input" accept="image/*" multiple hidden>
</div>
<textarea name="body" id="body" rows="18" spellcheck="true"
placeholder="Skriv i Markdown. Dra inn eller lim inn bilder for å laste dem opp.">{{ post.body }}</textarea>
<label for="body" class="visually-hidden">Innhold</label>
<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>
</section>
<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>
</div>
@ -45,6 +48,7 @@
{% else %}
<button type="submit" name="action" value="unpublish">Avpubliser</button>
{% endif %}
<span class="meta" id="dirty-status" role="status" aria-live="polite"></span>
</div>
</form>
@ -67,7 +71,7 @@
</details>
{% 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 }}»?">
<input type="hidden" name="csrf" value="{{ csrf }}">

View file

@ -7,9 +7,9 @@
{% if not entries %}<p>Ingen historikk ennå.</p>{% endif %}
<ol class="history">
{% 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>
<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>
{% endfor %}
</ol>

View file

@ -5,7 +5,7 @@
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<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>
<button type="submit" class="primary">Logg inn</button>
</form>

View file

@ -5,7 +5,8 @@
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<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>
</form>
{% endblock %}

View file

@ -21,11 +21,11 @@
<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>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>
<form method="post" action="{{ url_for('admin.rebuild') }}">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit">Bygg siden på nytt</button>
<button type="submit">Bygg nettstedet på nytt</button>
</form>
<h2>Siste endringer i git</h2>

View file

@ -1,16 +1,19 @@
/* Tema: "grønt er skjønt".
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 {
--bg: #f4f8f3;
--fg: #1c2a1e;
--muted: #4d6350;
--accent: #1f6b3a;
--accent-strong: #154d29;
--on-accent: #ffffff;
--surface: #ffffff;
--border: #c9dccb;
--code-bg: #e6efe6;
--max-width: 42rem;
--max-width: 40rem;
}
@media (prefers-color-scheme: dark) {
:root {
@ -19,6 +22,7 @@
--muted: #a7bda9;
--accent: #7fd39a;
--accent-strong: #a9e6bb;
--on-accent: #0f1f13;
--surface: #182619;
--border: #2f4532;
--code-bg: #1e2f20;
@ -34,13 +38,18 @@ body {
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
font-size: 1.0625rem;
line-height: 1.65;
}
.skip {
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; }
.visually-hidden {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap;
}
.site-header, main, .site-footer {
max-width: var(--max-width);
margin: 0 auto;
@ -48,28 +57,45 @@ body {
}
.site-header { border-bottom: 3px solid var(--accent); padding-top: 1.5rem; }
.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); }
main { padding-top: 1.5rem; }
a { color: var(--accent); }
a:hover { color: var(--accent-strong); }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 3px solid var(--accent); outline-offset: 2px;
}
h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .25rem; }
h2 { font-size: 1.35rem; margin: 1.25rem 0 .25rem; }
.meta { color: var(--muted); font-size: .9rem; margin: 0 0 1rem; }
.summary { padding: .5rem 0; border-bottom: 1px solid var(--border); }
.summary h2 a { text-decoration: none; }
h1 { font-size: 2.1rem; line-height: 1.15; letter-spacing: -.015em; margin: .5rem 0 .35rem; }
h2 { font-size: 1.4rem; line-height: 1.25; margin: 2rem 0 .5rem; }
.meta { color: var(--muted); font-size: .9rem; margin: 0 0 1.5rem; font-variant-numeric: tabular-nums; }
.summary { padding: 1.25rem 0; border-bottom: 1px solid var(--border); }
.summary h2 { margin: 0 0 .25rem; }
.summary h2 a { color: var(--accent-strong); text-decoration: none; }
.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 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 blockquote {
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 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; }
.colophon { margin: .5rem 0 0; font-size: .85rem; }
.site-footer {
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>
<footer class="site-footer">
<nav aria-label="Bunnmeny">
<a href="{{ rel('') }}">Forsiden</a> ·
<a href="{{ rel('feed.xml') }}">RSS</a> ·
<a href="{{ rel('admin/') }}">Logg inn</a>
<a href="{{ rel('') }}">Forsiden</a><a href="{{ rel('feed.xml') }}">RSS</a><a href="{{ rel('admin/') }}">Logg inn</a>
</nav>
{# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #}
<p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p>

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}{% if heading %}{{ heading }} {% endif %}{{ site.title }}{% endblock %}
{% 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 %}
{% for post in posts %}
<article class="summary">

View file

@ -247,3 +247,13 @@ def test_slug_uniqueness(client, cfg):
)
ids.append(r.headers["Location"].split("/admin/edit/")[1])
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