Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gate on the two message rules that cannot be caught at runtime.
|
||||
|
||||
1. **Every locale carries every key.** Paraglide falls back to the base
|
||||
locale for a missing message, which means an untranslated string ships
|
||||
silently as German inside an English interface. A missing key is a build
|
||||
failure here instead.
|
||||
|
||||
2. **No em or en dashes in UI copy.** They are hard to type, inconsistent
|
||||
across the app when hand-written, and in German they collide with the
|
||||
Gedankenstrich convention. Commas, colons or a second sentence do the
|
||||
job. Prose in docs/ and comments is unaffected: this only reads the
|
||||
message files.
|
||||
|
||||
Run by `make lint`, so both rules hold for every route the migration
|
||||
touches rather than only where someone remembered.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MESSAGES = Path(__file__).resolve().parents[1] / "messages"
|
||||
BASE_LOCALE = "de"
|
||||
DASHES = {"—": "em dash", "–": "en dash"}
|
||||
|
||||
|
||||
def load(path: Path) -> dict[str, object]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {key: value for key, value in data.items() if not key.startswith("$")}
|
||||
|
||||
|
||||
def strings_in(value: object) -> list[str]:
|
||||
"""Every translatable string inside a message.
|
||||
|
||||
A message is either a plain string or a list of variants, each with a
|
||||
`match` object mapping a selector to a string (see docs/i18n.md,
|
||||
pluralization). Declarations and selectors are machinery, not copy, so
|
||||
only the match values are checked for dashes.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
text
|
||||
for variant in value
|
||||
if isinstance(variant, dict)
|
||||
for text in variant.get("match", {}).values()
|
||||
if isinstance(text, str)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def main() -> int:
|
||||
files = sorted(MESSAGES.glob("*.json"))
|
||||
if not files:
|
||||
print(f"check-messages: no message files in {MESSAGES}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
catalogs = {path.stem: load(path) for path in files}
|
||||
if BASE_LOCALE not in catalogs:
|
||||
print(f"check-messages: missing base locale {BASE_LOCALE}.json", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
problems: list[str] = []
|
||||
base_keys = set(catalogs[BASE_LOCALE])
|
||||
|
||||
for locale, catalog in sorted(catalogs.items()):
|
||||
if locale == BASE_LOCALE:
|
||||
continue
|
||||
for key in sorted(base_keys - set(catalog)):
|
||||
problems.append(
|
||||
f"{locale}.json: missing message '{key}' "
|
||||
f"(present in {BASE_LOCALE}.json): a missing translation "
|
||||
f"would silently ship as {BASE_LOCALE}"
|
||||
)
|
||||
for key in sorted(set(catalog) - base_keys):
|
||||
problems.append(
|
||||
f"{locale}.json: message '{key}' has no counterpart in "
|
||||
f"{BASE_LOCALE}.json: the source language defines the set"
|
||||
)
|
||||
|
||||
for locale, catalog in sorted(catalogs.items()):
|
||||
for key, value in sorted(catalog.items()):
|
||||
for char, name in DASHES.items():
|
||||
if any(char in text for text in strings_in(value)):
|
||||
problems.append(
|
||||
f"{locale}.json: message '{key}' contains an {name} "
|
||||
f"({char}): use a comma, a colon, or two sentences"
|
||||
)
|
||||
|
||||
if problems:
|
||||
print("check-messages: FAILED", file=sys.stderr)
|
||||
for problem in problems:
|
||||
print(f" {problem}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
total = len(base_keys)
|
||||
locales = ", ".join(sorted(catalogs))
|
||||
print(f"check-messages: {total} messages complete in {locales}, no dashes")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WCAG AA gate for the design tokens — runs as part of `make lint`.
|
||||
|
||||
Parses the light-dark() token definitions straight out of
|
||||
frontend/src/app.css, so palette edits are validated without keeping a
|
||||
copy of the values in sync here. Text pairings must reach 4.5:1, the
|
||||
focus ring 3:1, in BOTH modes. Exits non-zero on any violation.
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_CSS = Path(__file__).resolve().parents[1] / "src" / "app.css"
|
||||
|
||||
# Short names used in the pair lists → --pb-* token names.
|
||||
ALIASES = {
|
||||
"raised": "surface-raised",
|
||||
"sunken": "surface-sunken",
|
||||
}
|
||||
|
||||
# (foreground, background) — needs >= 4.5
|
||||
TEXT_PAIRS = [
|
||||
("ink", "surface"),
|
||||
("ink", "raised"),
|
||||
("ink", "sunken"),
|
||||
("ink-muted", "surface"),
|
||||
("ink-muted", "raised"),
|
||||
("ink-muted", "sunken"),
|
||||
("primary-fg", "primary"),
|
||||
("primary-fg", "primary-hover"),
|
||||
("secondary-fg", "secondary"),
|
||||
("secondary-fg", "secondary-hover"),
|
||||
("accent-fg", "accent"),
|
||||
("accent-fg", "accent-hover"),
|
||||
("secondary", "surface"),
|
||||
("secondary", "sunken"),
|
||||
# primary is a button surface (warm near-black), never body text, so it is
|
||||
# only checked as a background — see the primary-fg pairings above.
|
||||
("success", "surface"),
|
||||
("warning", "surface"),
|
||||
("danger", "surface"),
|
||||
("success", "success-muted"),
|
||||
("warning", "warning-muted"),
|
||||
("danger", "danger-muted"),
|
||||
("success-fg", "success"),
|
||||
("warning-fg", "warning"),
|
||||
("danger-fg", "danger"),
|
||||
("danger-fg", "danger-hover"),
|
||||
]
|
||||
|
||||
# Focus indicator vs adjacent surface — needs >= 3.0
|
||||
RING_PAIRS = [("secondary", "surface"), ("secondary", "raised")]
|
||||
|
||||
|
||||
COLOR = r"(#[0-9a-fA-F]{6}|oklch\([^)]*\))"
|
||||
|
||||
|
||||
def parse_palettes() -> tuple[dict[str, str], dict[str, str]]:
|
||||
css = APP_CSS.read_text()
|
||||
light: dict[str, str] = {}
|
||||
dark: dict[str, str] = {}
|
||||
pattern = rf"--pb-([a-z-]+):\s*light-dark\(\s*{COLOR}\s*,\s*{COLOR}\s*\)"
|
||||
for name, light_value, dark_value in re.findall(pattern, css):
|
||||
light[name] = light_value
|
||||
dark[name] = dark_value
|
||||
return light, dark
|
||||
|
||||
|
||||
def _hex_luminance(hex_color: str) -> float:
|
||||
hex_color = hex_color.lstrip("#")
|
||||
r, g, b = (int(hex_color[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
|
||||
def linear(channel: float) -> float:
|
||||
if channel <= 0.04045:
|
||||
return channel / 12.92
|
||||
return ((channel + 0.055) / 1.055) ** 2.4
|
||||
|
||||
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
|
||||
|
||||
|
||||
def _oklch_luminance(value: str) -> float:
|
||||
"""oklch(L C H) → relative luminance.
|
||||
|
||||
Goes OKLab → LMS → linear sRGB, which is already the space WCAG's
|
||||
luminance formula wants, so no gamma round-trip is needed. Out-of-gamut
|
||||
channels are clamped, the same way a browser renders them.
|
||||
"""
|
||||
body = value[value.index("(") + 1 : value.rindex(")")]
|
||||
parts = body.replace("/", " ").split()
|
||||
lightness = float(parts[0].rstrip("%")) / (100 if "%" in parts[0] else 1)
|
||||
chroma = float(parts[1])
|
||||
hue = math.radians(float(parts[2]))
|
||||
|
||||
a = chroma * math.cos(hue)
|
||||
b = chroma * math.sin(hue)
|
||||
|
||||
l_ = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
||||
m_ = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
||||
s_ = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3
|
||||
|
||||
red = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
|
||||
green = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
|
||||
blue = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
|
||||
|
||||
red, green, blue = (min(1.0, max(0.0, channel)) for channel in (red, green, blue))
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
|
||||
|
||||
def luminance(color: str) -> float:
|
||||
if color.startswith("oklch"):
|
||||
return _oklch_luminance(color)
|
||||
return _hex_luminance(color)
|
||||
|
||||
|
||||
def ratio(a: str, b: str) -> float:
|
||||
la, lb = luminance(a), luminance(b)
|
||||
hi, lo = max(la, lb), min(la, lb)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
light, dark = parse_palettes()
|
||||
if not light:
|
||||
print(f"contrast-check: no light-dark() tokens found in {APP_CSS}")
|
||||
return 1
|
||||
|
||||
failures = 0
|
||||
checked = 0
|
||||
for mode, palette in (("light", light), ("dark", dark)):
|
||||
for pairs, minimum, kind in (
|
||||
(TEXT_PAIRS, 4.5, "text"),
|
||||
(RING_PAIRS, 3.0, "ring"),
|
||||
):
|
||||
for fg, bg in pairs:
|
||||
fg_hex = palette[ALIASES.get(fg, fg)]
|
||||
bg_hex = palette[ALIASES.get(bg, bg)]
|
||||
r = ratio(fg_hex, bg_hex)
|
||||
checked += 1
|
||||
if r < minimum:
|
||||
failures += 1
|
||||
print(f"FAIL [{mode}] {fg} on {bg}: {r:.2f} < {minimum} ({kind})")
|
||||
|
||||
if failures:
|
||||
print(f"contrast-check: {failures} of {checked} pairings violate WCAG AA")
|
||||
return 1
|
||||
print(f"contrast-check: {checked} token pairings pass WCAG AA")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user