feat: give the classroom a desktop instead of a shell

The whole appliance UI is now configuration for a desktop Debian already
ships: a Plasma 6.3 session with a colour scheme, a look and feel package
that builds the bar, window rules, and KDE's own Kiosk restrictions.

Two things were measured rather than assumed, and both changed a decision.

Plasma restarts a crashed panel three times a minute and then gives up,
which is the failure a classroom has already had. Four lines of systemd
drop-in make it unconditional; the panel then survived eight SIGKILLs a
second apart, back each time in 2.4s.

Plasma draws a minimised task and a background task identically, so the
five states the room needs cannot all be shown. Rather than write a task
manager, the classroom removes minimise: clicking a launcher always means
show me this.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-10 08:35:12 -05:00
co-authored by Claude Opus 5
parent bfc7caf28f
commit 87b55761c6
51 changed files with 1352 additions and 36 deletions
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Render the classroom's idle desktop background.
This is a STAND-IN. The classroom's chosen wallpaper is an illustrated
landscape that is not in this repository; when it arrives it replaces the
output of this script at the same path and nothing else changes.
It exists so the desktop can be judged in the right palette rather than
against the bright confetti background it is replacing: sage, teal, blue
and lavender, a river leading into the picture, and a large quiet area in
the upper middle where nothing happens.
No image library is installed on the development host, so the PNG is
written by hand — zlib and struct are enough for a truecolour image.
"""
import math
import pathlib
import struct
import zlib
WIDTH = 1920
HEIGHT = 1080
OUT = pathlib.Path(__file__).resolve().parent.parent / "config" / "wallpapers"
def mix(a, b, t):
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return tuple(a[i] + (b[i] - a[i]) * t for i in range(3))
def ease(t):
"""Smoothstep. Bands in a gradient are the one thing that would read as
a rendering fault rather than as a picture."""
t = 0.0 if t < 0 else 1.0 if t > 1 else t
return t * t * (3 - 2 * t)
# The palette, top of the sky downwards.
SKY = [
(0.00, (34, 38, 58)), # deep lavender-slate
(0.30, (58, 68, 96)), # lavender
(0.55, (86, 108, 130)), # blue
(0.78, (139, 165, 168)), # sage haze at the horizon
(1.00, (178, 198, 192)),
]
HORIZON = 0.56 # where the land starts, as a fraction of the height
GLOW_X, GLOW_Y = 0.56, 0.545
GLOW = (232, 232, 214)
RIDGES = [
# (base height, amplitude, wavelength, phase, colour, haze)
(0.575, 0.028, 2.1, 0.4, (74, 96, 110), 0.55),
(0.615, 0.040, 1.5, 2.2, (60, 84, 92), 0.34),
(0.665, 0.052, 1.1, 4.1, (48, 72, 74), 0.18),
]
RIVER_TOP = 0.585
RIVER_COLOUR = (96, 140, 146)
RIVER_LIGHT = (186, 212, 202)
BANK = (34, 52, 48) # the sage foreground the river runs between
def sky_colour(y):
t = y / HEIGHT
for i in range(len(SKY) - 1):
t0, c0 = SKY[i]
t1, c1 = SKY[i + 1]
if t <= t1 or i == len(SKY) - 2:
return mix(c0, c1, ease((t - t0) / (t1 - t0)))
return SKY[-1][1]
def river_edges(y):
"""The river narrows towards the horizon and opens towards the viewer,
which is what makes it lead into the picture rather than across it."""
t = (y / HEIGHT - RIVER_TOP) / (1 - RIVER_TOP)
if t < 0:
return None
t = ease(t)
centre = 0.56 - 0.14 * t * t
half = 0.004 + 0.30 * t * t
return (centre - half) * WIDTH, (centre + half) * WIDTH
def render():
rows = []
for y in range(HEIGHT):
base = sky_colour(y)
row = bytearray()
edges = river_edges(y)
yn = y / HEIGHT
# Ridge heights for this column are cheap to evaluate, so the land
# is computed per pixel rather than filled as polygons.
for x in range(WIDTH):
xn = x / WIDTH
r, g, b = base
# A soft light low in the sky, off centre. It is the only
# bright thing in the picture.
dx = (xn - GLOW_X) * 1.7
dy = yn - GLOW_Y
d = math.sqrt(dx * dx + dy * dy)
glow = math.exp(-(d * d) / 0.055) * 0.55
if glow > 0.002:
r, g, b = mix((r, g, b), GLOW, glow)
land = None
for height, amp, wavelength, phase, colour, haze in RIDGES:
ridge = height - amp * (
0.6 * math.sin(xn * math.pi * 2 * wavelength + phase)
+ 0.4 * math.sin(xn * math.pi * 2 * wavelength * 2.3 + phase * 1.7)
)
if yn >= ridge:
# Nearer ridges are darker and less hazy, which is the
# whole of the depth in this picture.
shade = mix(colour, (r, g, b), haze)
depth = ease((yn - ridge) / 0.16)
land = mix(shade, tuple(c * 0.82 for c in shade), depth)
if land is not None:
r, g, b = land
if edges is not None:
left, right = edges
if left <= x <= right:
width = max(right - left, 1.0)
across = (x - left) / width
# Light gathers down the middle of the water and the
# banks stay dark, so the river reads as a surface.
sheen = math.exp(-((across - 0.5) ** 2) / 0.06)
ripple = 0.5 + 0.5 * math.sin(
yn * 130 + math.sin(xn * 18) * 1.4
)
water = mix(RIVER_COLOUR, RIVER_LIGHT, 0.30 * sheen + 0.10 * ripple)
water = mix(water, (r, g, b), max(0.0, 0.45 - 0.45 * ease((yn - RIVER_TOP) / 0.25)))
edge = min(across, 1 - across)
r, g, b = mix(BANK, water, ease(edge / 0.05))
elif yn > RIVER_TOP + 0.10:
near = ease((yn - RIVER_TOP - 0.10) / 0.34)
r, g, b = mix((r, g, b), BANK, near * 0.85)
# The bottom of the screen carries the bar. Settling it down
# keeps the panel legible without putting a band across the
# picture.
if yn > 0.78:
r, g, b = mix((r, g, b), (16, 24, 28), ease((yn - 0.78) / 0.24) * 0.62)
row += bytes((int(r + 0.5) & 255, int(g + 0.5) & 255, int(b + 0.5) & 255))
rows.append(bytes(row))
return rows
def write_png(path, rows):
raw = b"".join(b"\x00" + row for row in rows)
def chunk(tag, data):
payload = tag + data
return (
struct.pack(">I", len(data))
+ payload
+ struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF)
)
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", WIDTH, HEIGHT, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw, 9))
png += chunk(b"IEND", b"")
path.write_bytes(png)
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
target = OUT / "classroom-calm.png"
write_png(target, render())
print(f"wrote {target} ({target.stat().st_size} bytes)")