diff --git a/config/chrome/policies/managed/ekklesia-classroom.json b/config/chrome/policies/managed/ekklesia-classroom.json
index 80d99ff..7402b50 100644
--- a/config/chrome/policies/managed/ekklesia-classroom.json
+++ b/config/chrome/policies/managed/ekklesia-classroom.json
@@ -2,25 +2,20 @@
"DefaultBrowserSettingEnabled": false,
"PromotionalTabsEnabled": false,
"MetricsReportingEnabled": false,
-
"BrowserSignin": 0,
"SyncDisabled": true,
"SigninInterceptionEnabled": false,
-
"BrowserAddPersonEnabled": false,
"BrowserGuestModeEnabled": false,
"ProfilePickerOnStartupAvailability": 1,
-
"PasswordManagerEnabled": false,
"PasswordLeakDetectionEnabled": false,
"BackgroundModeEnabled": false,
"DisablePrintPreview": true,
-
"CookiesSessionOnlyForUrls": [
"[*.]wonderink.org"
],
-
"ShowHomeButton": true,
"HomepageIsNewTabPage": false,
- "HomepageLocation": "https://app.wonderink.org/"
+ "HomepageLocation": "https://www.youtube.com/"
}
diff --git a/config/chrome/policies/recommended/ekklesia-classroom.json b/config/chrome/policies/recommended/ekklesia-classroom.json
index a8e3ec9..ef344a6 100644
--- a/config/chrome/policies/recommended/ekklesia-classroom.json
+++ b/config/chrome/policies/recommended/ekklesia-classroom.json
@@ -1,6 +1,6 @@
{
"RestoreOnStartup": 4,
"RestoreOnStartupURLs": [
- "https://app.wonderink.org/"
+ "https://www.youtube.com/"
]
}
diff --git a/config/icons/ekklesia-parent-call.svg b/config/icons/ekklesia-parent-call.svg
new file mode 100644
index 0000000..f4661b8
--- /dev/null
+++ b/config/icons/ekklesia-parent-call.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/config/icons/ekklesia-wonderink.svg b/config/icons/ekklesia-wonderink.svg
new file mode 100644
index 0000000..eef7aa1
--- /dev/null
+++ b/config/icons/ekklesia-wonderink.svg
@@ -0,0 +1,27 @@
+
+
+
diff --git a/config/plasma/applications-local/google-chrome.desktop b/config/plasma/applications-local/google-chrome.desktop
new file mode 100644
index 0000000..8acee4e
--- /dev/null
+++ b/config/plasma/applications-local/google-chrome.desktop
@@ -0,0 +1,25 @@
+[Desktop Entry]
+# The room's browser, installed to /usr/local/share/applications, which
+# XDG_DATA_DIRS searches before /usr/share. Chrome's own entry is left
+# where Chrome's package put it and is simply never the one that is found.
+#
+# It has to carry Chrome's file name rather than a name of our own. Plasma
+# matches a window to its launcher by the application id the window
+# reports, and Chrome reports "google-chrome". A launcher under any other
+# name gives a teacher two buttons for one browser: a launcher that always
+# looks closed, and a window that appears beside it. That was measured, not
+# assumed — it is what the first build did.
+Type=Application
+Name=Browser
+GenericName=Web browser
+Comment=YouTube and the rest of the web
+Icon=google-chrome
+Exec=/usr/local/bin/classroom-browser %U
+Terminal=false
+Categories=Network;WebBrowser;
+StartupNotify=true
+StartupWMClass=google-chrome
+MimeType=text/html;x-scheme-handler/http;x-scheme-handler/https;
+# Chrome's own entry offers a new window and an incognito window from the
+# launcher's right-click menu. Neither belongs in a shared room.
+Actions=
diff --git a/config/plasma/applications/ekklesia-dvd.desktop b/config/plasma/applications/ekklesia-dvd.desktop
new file mode 100644
index 0000000..cda48f8
--- /dev/null
+++ b/config/plasma/applications/ekklesia-dvd.desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Type=Application
+Name=DVD
+Comment=Play the disc in the drive
+Exec=/usr/local/bin/classroom-dvd
+Icon=media-optical-dvd
+Terminal=false
+Categories=AudioVideo;Player;
+StartupNotify=true
+StartupWMClass=vlc
+# Never in a menu or a launcher by itself. The DVD appears when a disc is
+# in the drive, through Plasma's own device notifier, and goes away with it.
+NoDisplay=true
diff --git a/config/plasma/applications/ekklesia-parent-call.desktop b/config/plasma/applications/ekklesia-parent-call.desktop
new file mode 100644
index 0000000..1a32b1e
--- /dev/null
+++ b/config/plasma/applications/ekklesia-parent-call.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=Parent Call
+Comment=Ask for a child's parent to come to the room
+Exec=/usr/local/bin/classroom-parent-call
+Icon=ekklesia-parent-call
+Terminal=false
+Categories=Utility;
+StartupNotify=false
+NoDisplay=false
diff --git a/config/plasma/applications/ekklesia-wonderink.desktop b/config/plasma/applications/ekklesia-wonderink.desktop
new file mode 100644
index 0000000..56c7c4e
--- /dev/null
+++ b/config/plasma/applications/ekklesia-wonderink.desktop
@@ -0,0 +1,15 @@
+[Desktop Entry]
+Type=Application
+Name=Wonder Ink
+GenericName=Lesson
+Comment=The Wonder Ink lesson for this room
+Exec=/usr/local/bin/classroom-wonderink
+Icon=ekklesia-wonderink
+Terminal=false
+Categories=Education;
+StartupNotify=true
+# How the panel knows that the window on screen belongs to this launcher.
+# Chrome derives an app id from the --app URL, and this must match it
+# exactly or the launcher and the window become two separate buttons.
+StartupWMClass=chrome-app.wonderink.org__-Default
+X-GNOME-SingleWindow=true
diff --git a/config/plasma/colors/EkklesiaDark.colors b/config/plasma/colors/EkklesiaDark.colors
new file mode 100644
index 0000000..afcd446
--- /dev/null
+++ b/config/plasma/colors/EkklesiaDark.colors
@@ -0,0 +1,155 @@
+# Ekklesia Classroom — dark. The default.
+#
+# One set of tokens for the whole session: Plasma reads this file for the
+# panel, the notifications, the window decorations and every Qt dialog, so
+# there is one place to change a colour rather than a stylesheet per surface.
+#
+# The hues come from the classroom wallpaper — sage, teal, blue, lavender —
+# so the desktop sits on the landscape rather than in front of it.
+[General]
+Name=Ekklesia Classroom Dark
+ColorScheme=EkklesiaDark
+shadeSortColumn=true
+accentActiveTitlebar=false
+
+[ColorEffects:Disabled]
+ChangeSelectionColor=
+Color=56,66,71
+ColorAmount=0
+ColorEffect=0
+ContrastAmount=0.5
+ContrastEffect=1
+Enable=
+IntensityAmount=0
+IntensityEffect=0
+
+[ColorEffects:Inactive]
+ChangeSelectionColor=true
+Color=112,111,110
+ColorAmount=0.025
+ColorEffect=2
+ContrastAmount=0.1
+ContrastEffect=2
+Enable=false
+IntensityAmount=0
+IntensityEffect=0
+
+[Colors:Button]
+BackgroundAlternate=30,42,47
+BackgroundNormal=25,35,40
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=79,179,168
+ForegroundInactive=147,165,170
+ForegroundLink=126,196,255
+ForegroundNegative=227,106,106
+ForegroundNeutral=224,177,92
+ForegroundNormal=230,237,239
+ForegroundPositive=111,207,151
+ForegroundVisited=156,138,222
+
+[Colors:Selection]
+BackgroundAlternate=79,179,168
+BackgroundNormal=79,179,168
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=14,21,24
+ForegroundInactive=14,21,24
+ForegroundLink=14,21,24
+ForegroundNegative=14,21,24
+ForegroundNeutral=14,21,24
+ForegroundNormal=10,16,18
+ForegroundPositive=14,21,24
+ForegroundVisited=14,21,24
+
+[Colors:Tooltip]
+BackgroundAlternate=25,35,40
+BackgroundNormal=20,28,32
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=79,179,168
+ForegroundInactive=147,165,170
+ForegroundLink=126,196,255
+ForegroundNegative=227,106,106
+ForegroundNeutral=224,177,92
+ForegroundNormal=230,237,239
+ForegroundPositive=111,207,151
+ForegroundVisited=156,138,222
+
+[Colors:View]
+BackgroundAlternate=20,28,32
+BackgroundNormal=15,23,26
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=79,179,168
+ForegroundInactive=147,165,170
+ForegroundLink=126,196,255
+ForegroundNegative=227,106,106
+ForegroundNeutral=224,177,92
+ForegroundNormal=230,237,239
+ForegroundPositive=111,207,151
+ForegroundVisited=156,138,222
+
+[Colors:Window]
+BackgroundAlternate=25,35,40
+BackgroundNormal=20,28,32
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=79,179,168
+ForegroundInactive=147,165,170
+ForegroundLink=126,196,255
+ForegroundNegative=227,106,106
+ForegroundNeutral=224,177,92
+ForegroundNormal=230,237,239
+ForegroundPositive=111,207,151
+ForegroundVisited=156,138,222
+
+[Colors:Complementary]
+BackgroundAlternate=25,35,40
+BackgroundNormal=20,28,32
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=79,179,168
+ForegroundInactive=147,165,170
+ForegroundLink=126,196,255
+ForegroundNegative=227,106,106
+ForegroundNeutral=224,177,92
+ForegroundNormal=230,237,239
+ForegroundPositive=111,207,151
+ForegroundVisited=156,138,222
+
+[Colors:Header]
+BackgroundAlternate=25,35,40
+BackgroundNormal=18,26,30
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=79,179,168
+ForegroundInactive=147,165,170
+ForegroundLink=126,196,255
+ForegroundNegative=227,106,106
+ForegroundNeutral=224,177,92
+ForegroundNormal=230,237,239
+ForegroundPositive=111,207,151
+ForegroundVisited=156,138,222
+
+[Colors:Header][Inactive]
+BackgroundAlternate=20,28,32
+BackgroundNormal=16,23,26
+DecorationFocus=79,179,168
+DecorationHover=79,179,168
+ForegroundActive=79,179,168
+ForegroundInactive=125,142,147
+ForegroundLink=126,196,255
+ForegroundNegative=227,106,106
+ForegroundNeutral=224,177,92
+ForegroundNormal=170,186,191
+ForegroundPositive=111,207,151
+ForegroundVisited=156,138,222
+
+[WM]
+activeBackground=18,26,30
+activeBlend=79,179,168
+activeForeground=230,237,239
+inactiveBackground=16,23,26
+inactiveBlend=147,165,170
+inactiveForeground=147,165,170
diff --git a/config/plasma/colors/EkklesiaLight.colors b/config/plasma/colors/EkklesiaLight.colors
new file mode 100644
index 0000000..28e7a52
--- /dev/null
+++ b/config/plasma/colors/EkklesiaLight.colors
@@ -0,0 +1,158 @@
+# Ekklesia Classroom — light.
+#
+# GENERATED from EkklesiaDark.colors by tools/build-light-scheme.
+# Edit the dark scheme and regenerate; do not edit this file by hand.
+#
+# One set of tokens for the whole session: Plasma reads this file for the
+# panel, the notifications, the window decorations and every Qt dialog, so
+# there is one place to change a colour rather than a stylesheet per surface.
+#
+# The hues come from the classroom wallpaper — sage, teal, blue, lavender —
+# so the desktop sits on the landscape rather than in front of it.
+[General]
+Name=Ekklesia Classroom Light
+ColorScheme=EkklesiaLight
+shadeSortColumn=true
+accentActiveTitlebar=false
+
+[ColorEffects:Disabled]
+ChangeSelectionColor=
+Color=56,66,71
+ColorAmount=0
+ColorEffect=0
+ContrastAmount=0.5
+ContrastEffect=1
+Enable=
+IntensityAmount=0
+IntensityEffect=0
+
+[ColorEffects:Inactive]
+ChangeSelectionColor=true
+Color=112,111,110
+ColorAmount=0.025
+ColorEffect=2
+ContrastAmount=0.1
+ContrastEffect=2
+Enable=false
+IntensityAmount=0
+IntensityEffect=0
+
+[Colors:Button]
+BackgroundAlternate=225,231,230
+BackgroundNormal=233,238,237
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=46,140,130
+ForegroundInactive=96,116,122
+ForegroundLink=26,110,180
+ForegroundNegative=192,57,57
+ForegroundNeutral=166,116,24
+ForegroundNormal=22,35,42
+ForegroundPositive=38,142,86
+ForegroundVisited=104,80,180
+
+[Colors:Selection]
+BackgroundAlternate=46,140,130
+BackgroundNormal=46,140,130
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=255,255,255
+ForegroundInactive=255,255,255
+ForegroundLink=255,255,255
+ForegroundNegative=255,255,255
+ForegroundNeutral=255,255,255
+ForegroundNormal=255,255,255
+ForegroundPositive=255,255,255
+ForegroundVisited=255,255,255
+
+[Colors:Tooltip]
+BackgroundAlternate=233,238,237
+BackgroundNormal=242,245,244
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=46,140,130
+ForegroundInactive=96,116,122
+ForegroundLink=26,110,180
+ForegroundNegative=192,57,57
+ForegroundNeutral=166,116,24
+ForegroundNormal=22,35,42
+ForegroundPositive=38,142,86
+ForegroundVisited=104,80,180
+
+[Colors:View]
+BackgroundAlternate=242,245,244
+BackgroundNormal=255,255,255
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=46,140,130
+ForegroundInactive=96,116,122
+ForegroundLink=26,110,180
+ForegroundNegative=192,57,57
+ForegroundNeutral=166,116,24
+ForegroundNormal=22,35,42
+ForegroundPositive=38,142,86
+ForegroundVisited=104,80,180
+
+[Colors:Window]
+BackgroundAlternate=233,238,237
+BackgroundNormal=242,245,244
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=46,140,130
+ForegroundInactive=96,116,122
+ForegroundLink=26,110,180
+ForegroundNegative=192,57,57
+ForegroundNeutral=166,116,24
+ForegroundNormal=22,35,42
+ForegroundPositive=38,142,86
+ForegroundVisited=104,80,180
+
+[Colors:Complementary]
+BackgroundAlternate=233,238,237
+BackgroundNormal=242,245,244
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=46,140,130
+ForegroundInactive=96,116,122
+ForegroundLink=26,110,180
+ForegroundNegative=192,57,57
+ForegroundNeutral=166,116,24
+ForegroundNormal=22,35,42
+ForegroundPositive=38,142,86
+ForegroundVisited=104,80,180
+
+[Colors:Header]
+BackgroundAlternate=233,238,237
+BackgroundNormal=236,241,240
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=46,140,130
+ForegroundInactive=96,116,122
+ForegroundLink=26,110,180
+ForegroundNegative=192,57,57
+ForegroundNeutral=166,116,24
+ForegroundNormal=22,35,42
+ForegroundPositive=38,142,86
+ForegroundVisited=104,80,180
+
+[Colors:Header][Inactive]
+BackgroundAlternate=242,245,244
+BackgroundNormal=228,234,233
+DecorationFocus=46,140,130
+DecorationHover=46,140,130
+ForegroundActive=46,140,130
+ForegroundInactive=120,138,144
+ForegroundLink=26,110,180
+ForegroundNegative=192,57,57
+ForegroundNeutral=166,116,24
+ForegroundNormal=80,99,105
+ForegroundPositive=38,142,86
+ForegroundVisited=104,80,180
+
+[WM]
+activeBackground=236,241,240
+activeBlend=46,140,130
+activeForeground=22,35,42
+inactiveBackground=228,234,233
+inactiveBlend=96,116,122
+inactiveForeground=96,116,122
diff --git a/config/plasma/lookandfeel/contents/defaults b/config/plasma/lookandfeel/contents/defaults
new file mode 100644
index 0000000..08cde14
--- /dev/null
+++ b/config/plasma/lookandfeel/contents/defaults
@@ -0,0 +1,31 @@
+# Everything the classroom's appearance is made of, in one place.
+#
+# Plasma reads this when the look and feel is applied, so the panel, the
+# window decorations, the notifications and every Qt dialog in the session
+# are described here rather than in a stylesheet per surface.
+
+[kdeglobals][KDE]
+widgetStyle=Breeze
+
+[kdeglobals][General]
+ColorScheme=EkklesiaDark
+AccentColor=79,179,168
+
+[kdeglobals][Icons]
+Theme=breeze-dark
+
+[plasmarc][Theme]
+name=default
+
+[kcminputrc][Mouse]
+cursorTheme=breeze_cursors
+
+[kwinrc][org.kde.kdecoration2]
+library=org.kde.breeze
+theme=Breeze
+
+[Wallpaper]
+Image=/usr/share/backgrounds/ekklesia/classroom-calm.png
+
+[KSplash]
+Theme=None
diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js
new file mode 100644
index 0000000..5b511a3
--- /dev/null
+++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js
@@ -0,0 +1,124 @@
+// The Ekklesia Classroom desktop.
+//
+// Plasma runs this once, when a session has no panel yet, and again on
+// demand — `plasma-apply-lookandfeel -a org.ekklesia.classroom.desktop
+// --resetLayout` rebuilds the whole desktop from this file. That is the
+// classroom's recovery path for a damaged shell, and it is Plasma's own
+// mechanism rather than something written here.
+//
+// One full-width bar along the bottom. Left is auxiliary, the middle is
+// applications, the right is the small amount of status a teacher needs.
+// Nothing else is on the screen.
+
+var WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png";
+
+// Matches the 76px strip the classroom has always used, so muscle memory
+// and the room's viewing distance both carry over.
+var PANEL_HEIGHT = 76;
+
+// The idle desktop: the wallpaper, and nothing on it.
+var screens = desktops();
+for (var i = 0; i < screens.length; i++) {
+ var desktop = screens[i];
+ desktop.wallpaperPlugin = "org.kde.image";
+ desktop.currentConfigGroup = ["Wallpaper", "org.kde.image", "General"];
+ desktop.writeConfig("Image", "file://" + WALLPAPER);
+ desktop.writeConfig("SlidePaths", "");
+ // 2 is "scaled and cropped": the landscape fills the display at any
+ // aspect ratio without being stretched out of shape.
+ desktop.writeConfig("FillMode", 2);
+ desktop.reloadConfig();
+}
+
+var panel = new Panel();
+panel.location = "bottom";
+panel.height = PANEL_HEIGHT;
+panel.hiding = "none";
+panel.floating = false;
+panel.lengthMode = "fill";
+panel.alignment = "center";
+panel.opacity = "adaptive";
+
+// LEFT — auxiliary. Parent Call is not one of the room's applications and
+// is deliberately not in the row with them.
+var parentCall = panel.addWidget("org.kde.plasma.icon");
+parentCall.currentConfigGroup = ["General"];
+parentCall.writeConfig(
+ "url",
+ "file:///usr/share/applications/ekklesia-parent-call.desktop"
+);
+
+var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
+leftGap.currentConfigGroup = ["General"];
+leftGap.writeConfig("expanding", true);
+
+// CENTRE — the applications, and the whole of the running/minimised/active
+// story. This applet is the reason the classroom stops writing a dock.
+var tasks = panel.addWidget("org.kde.plasma.icontasks");
+tasks.currentConfigGroup = ["General"];
+tasks.writeConfig("launchers", [
+ "applications:ekklesia-wonderink.desktop",
+ "applications:google-chrome.desktop"
+].join(","));
+// Every window gets its own button. Teachers should never have to open a
+// group to find out that a second window exists.
+tasks.writeConfig("groupingStrategy", 0);
+tasks.writeConfig("showOnlyCurrentDesktop", false);
+tasks.writeConfig("showOnlyCurrentActivity", false);
+tasks.writeConfig("showOnlyCurrentScreen", false);
+// Clicking a button always means "show me this", and never "hide it".
+//
+// This was measured before it was decided. Plasma 6.3 draws a minimised
+// task and a task that is merely behind another window identically: no
+// indicator for closed, a grey indicator for running, a teal one for
+// active — three marks for four states. Rather than write a task manager
+// that draws a fourth, the classroom removes the state. There is nothing
+// on the idle desktop for a teacher to want to see, so minimising an
+// application in this room has no purpose and only ever loses it.
+//
+// A window minimised some other way is still shown as running and comes
+// back with one click, so it can never be lost even then.
+tasks.writeConfig("minimizeActiveTaskOnClick", false);
+tasks.writeConfig("fill", false);
+tasks.writeConfig("maxStripes", 1);
+tasks.writeConfig("indicateAudioStreams", false);
+tasks.writeConfig("wheelEnabled", false);
+tasks.writeConfig("middleClickAction", "NewInstance");
+tasks.writeConfig("showToolTips", true);
+tasks.writeConfig("iconSpacing", 2);
+
+var rightGap = panel.addWidget("org.kde.plasma.panelspacer");
+rightGap.currentConfigGroup = ["General"];
+rightGap.writeConfig("expanding", true);
+
+// RIGHT — status.
+//
+// Four ordinary applets straight on the bar, and no system tray around
+// them. A tray is a container that discovers whatever is installed and
+// then hides most of it behind an arrow; measured in the lab it collected
+// battery, brightness, clipboard, keyboard layout, camera, vaults and
+// screen management on a machine that has none of those to say anything
+// about. Naming the four the classroom actually uses is both smaller and
+// deterministic, and it leaves nothing for a teacher to expand.
+panel.addWidget("org.kde.plasma.networkmanagement");
+panel.addWidget("org.kde.plasma.volume");
+
+// Present only while there is something in a drive. Plasma's own Solid
+// device notifier is what makes the DVD appear with the disc and leave
+// with it, so nothing in the classroom polls for media.
+panel.addWidget("org.kde.plasma.devicenotifier");
+
+panel.addWidget("org.kde.plasma.notifications");
+
+var clock = panel.addWidget("org.kde.plasma.digitalclock");
+clock.currentConfigGroup = ["Appearance"];
+clock.writeConfig("showDate", true);
+clock.writeConfig("dateFormat", "custom");
+clock.writeConfig("customDateFormat", "ddd d MMM");
+clock.writeConfig("showSeconds", 0);
+clock.writeConfig("dateDisplayFormat", "BelowTime");
+// Left to itself the clock grows to the height of the bar and becomes the
+// largest text in the room. It is status, not a headline.
+clock.writeConfig("autoFontAndSize", false);
+clock.writeConfig("fontSize", 15);
+clock.writeConfig("fontWeight", 500);
diff --git a/config/plasma/lookandfeel/metadata.json b/config/plasma/lookandfeel/metadata.json
new file mode 100644
index 0000000..99cf8dc
--- /dev/null
+++ b/config/plasma/lookandfeel/metadata.json
@@ -0,0 +1,17 @@
+{
+ "KPackageStructure": "Plasma/LookAndFeel",
+ "KPlugin": {
+ "Authors": [
+ {
+ "Name": "Ekklesia"
+ }
+ ],
+ "Category": "",
+ "Description": "The classroom appliance desktop",
+ "Id": "org.ekklesia.classroom.desktop",
+ "License": "GPLv2+",
+ "Name": "Ekklesia Classroom",
+ "Website": "https://riverofglorymn.org"
+ },
+ "X-Plasma-APIVersion": "2"
+}
diff --git a/config/plasma/solid/ekklesia-dvd.desktop b/config/plasma/solid/ekklesia-dvd.desktop
new file mode 100644
index 0000000..d4e7811
--- /dev/null
+++ b/config/plasma/solid/ekklesia-dvd.desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Type=Service
+# Solid decides when this exists. Plasma's device notifier offers it while
+# a video DVD is in the drive and withdraws it when the disc is ejected, so
+# a room without a drive shows nothing and a room with one needs no setting
+# changed. Nothing polls.
+X-KDE-Solid-Predicate=[[ StorageVolume.ignored == false AND StorageVolume.usage == 'Other' ] AND [ OpticalDisc.availableContent & 'VideoDvd' ]]
+Actions=Play;
+
+[Desktop Action Play]
+Name=Play the DVD
+Icon=media-optical-dvd
+Exec=/usr/local/bin/classroom-dvd %f
diff --git a/config/plasma/xdg/kdeglobals b/config/plasma/xdg/kdeglobals
new file mode 100644
index 0000000..7fb90b8
--- /dev/null
+++ b/config/plasma/xdg/kdeglobals
@@ -0,0 +1,60 @@
+# Ekklesia Classroom session policy.
+#
+# Installed to /etc/xdg/kdeglobals, where it is the bottom of Plasma's
+# configuration cascade. Every group is marked [$i] — immutable — so a
+# teacher, a stray click or a misbehaving application cannot override it
+# from the user's own configuration. This is KDE's own Kiosk mechanism:
+# nothing here is achieved by deleting a binary or hiding a menu entry.
+#
+# Lifting a restriction for maintenance is a one-line edit to this file
+# followed by a session restart, which is what makes the appliance
+# serviceable rather than merely locked.
+
+[KDE Action Restrictions][$i]
+# No way to reach a shell, a command line, or an arbitrary program.
+action/kdesktop_rmb=false
+action/kwin_rmb=false
+action/openwith=false
+action/switch_user=false
+action/start_new_session=false
+action/lock_screen=false
+run_command=false
+shell_access=false
+logout=false
+# The desktop and the panel are a fixed part of the appliance.
+editable_desktop_icons=false
+editable_bookmarks=false
+movable_toolbars=false
+plasma/plasmashell/unlockedDesktop=false
+plasma-desktop/scripting_console=false
+# Nothing in the classroom should offer to file a bug or open a manual.
+action/help_report_bug=false
+action/help_about_kde=false
+action/help_contents=false
+
+[General][$i]
+ColorScheme=EkklesiaDark
+AccentColor=79,179,168
+# The teacher's own words are not decoration: keep text rendering plain
+# and readable rather than styled.
+XftAntialias=true
+XftHintStyle=hintslight
+XftSubPixel=rgb
+
+[KDE][$i]
+widgetStyle=Breeze
+LookAndFeelPackage=org.ekklesia.classroom.desktop
+SingleClick=false
+AnimationDurationFactor=0.5
+
+[Icons][$i]
+Theme=breeze-dark
+
+[KFileDialog Settings][$i]
+Allow Expansion=false
+Automatically select filename extension=true
+Show Bookmarks=false
+Show Full Path=false
+Show Inline Previews=false
+Show Speedbar=false
+Sort directories first=true
diff --git a/config/plasma/xdg/krunnerrc b/config/plasma/xdg/krunnerrc
new file mode 100644
index 0000000..a3ce7c6
--- /dev/null
+++ b/config/plasma/xdg/krunnerrc
@@ -0,0 +1,9 @@
+# KRunner is a command line with a nicer face. The classroom has neither.
+[General][$i]
+Enabled=false
+ActivateWhenTypingOnDesktop=false
+[Plugins][$i]
+shellEnabled=false
+baloosearchEnabled=false
+krunner_appstreamEnabled=false
+krunner_shellEnabled=false
diff --git a/config/plasma/xdg/kscreenlockerrc b/config/plasma/xdg/kscreenlockerrc
new file mode 100644
index 0000000..f49e422
--- /dev/null
+++ b/config/plasma/xdg/kscreenlockerrc
@@ -0,0 +1,9 @@
+# A classroom appliance must never present a password prompt to a teacher.
+# There is no lock screen, and nothing can bring one up: the action itself
+# is disabled in kdeglobals as well, so a keyboard shortcut cannot reach it.
+[Daemon][$i]
+Autolock=false
+LockOnResume=false
+LockOnStart=false
+LockGrace=0
+Timeout=0
diff --git a/config/plasma/xdg/ksmserverrc b/config/plasma/xdg/ksmserverrc
new file mode 100644
index 0000000..533c7e2
--- /dev/null
+++ b/config/plasma/xdg/ksmserverrc
@@ -0,0 +1,8 @@
+# The room is not somebody's account. Every session starts from the same
+# empty desktop, so what the last teacher left open is never the first
+# thing the next one sees.
+[General][$i]
+loginMode=emptySession
+confirmLogout=false
+offerShutdown=false
+shutdownType=0
diff --git a/config/plasma/xdg/kwinrc b/config/plasma/xdg/kwinrc
new file mode 100644
index 0000000..14df027
--- /dev/null
+++ b/config/plasma/xdg/kwinrc
@@ -0,0 +1,102 @@
+# The classroom's window manager.
+#
+# The shape of this file is the argument for the whole architecture: window
+# stacking, minimising, dialog placement and full-screen media are settings
+# here, not code somewhere in the classroom.
+
+[Compositing][$i]
+Enabled=true
+# Let KWin choose between OpenGL and software rendering by itself. A
+# classroom machine that loses acceleration after a kernel update should
+# come up slower, not blank.
+WindowsBlockCompositing=true
+AllowTearing=false
+LatencyPolicy=Medium
+
+[Desktops][$i]
+Number=1
+Rows=1
+# One desktop, so a teacher cannot land on an empty one and conclude the
+# computer has lost their work.
+
+[Windows][$i]
+Placement=Centered
+FocusPolicy=ClickToFocus
+AutoRaise=false
+DelayFocus=0
+FocusStealingPreventionLevel=1
+# A dialog belongs to the window that opened it and stays with it. This is
+# what keeps a print dialog in front of the page it is printing without
+# anything in the classroom arranging it.
+SeparateScreenFocus=false
+ActiveMouseScreen=false
+TitlebarDoubleClickCommand=Maximize
+MaximizeButtonLeftClickCommand=Maximize
+MaximizeButtonMiddleClickCommand=Maximize
+MaximizeButtonRightClickCommand=Maximize
+
+[MouseBindings][$i]
+CommandAllKey=Meta
+CommandAll1=Move
+CommandAll2=Nothing
+CommandAll3=Nothing
+CommandTitlebarWheel=Nothing
+CommandAllWheel=Nothing
+
+[TabBox][$i]
+# No window switcher overlay. The bottom bar is the way between windows.
+ShowTabBox=false
+
+[Plugins][$i]
+# Effects the classroom uses, and nothing else. Every line that is false is
+# an effect that will not be loaded at all.
+blurEnabled=true
+contrastEnabled=true
+kwin4_effect_fadeEnabled=true
+kwin4_effect_translucencyEnabled=false
+kwin4_effect_dialogparentEnabled=true
+kwin4_effect_squashEnabled=true
+kwin4_effect_maximizeEnabled=true
+kwin4_effect_scaleEnabled=true
+kwin4_effect_fullscreenEnabled=true
+slidingpopupsEnabled=true
+screenedgeEnabled=false
+cubeEnabled=false
+desktopgridEnabled=false
+overviewEnabled=false
+windowviewEnabled=false
+wobblywindowsEnabled=false
+magiclampEnabled=false
+slideEnabled=false
+zoomEnabled=false
+mouseclickEnabled=false
+showfpsEnabled=false
+thumbnailasideEnabled=false
+kwin4_effect_dimscreenEnabled=false
+kwin4_effect_morphingpopupsEnabled=false
+
+[Effect-blur][$i]
+BlurStrength=8
+NoiseStrength=2
+
+[org.kde.kdecoration2][$i]
+library=org.kde.breeze
+theme=Breeze
+# A pin and a close button, and nothing else.
+#
+# The pin is the Live View story: keeping a window above the others is a
+# window-manager state, not a feature the classroom has to invent. There is
+# deliberately no minimise button, because a minimised window is the one
+# thing a teacher cannot see the state of — measured, not assumed. Double
+# clicking the title bar still maximises, so nothing is unreachable.
+ButtonsOnLeft=
+ButtonsOnRight=FX
+ShowToolTips=false
+BorderSize=None
+BorderSizeAuto=false
+
+[Script-desktopchangeosd][$i]
+Enabled=false
+
+[Xwayland][$i]
+Scale=1
diff --git a/config/plasma/xdg/kwinrulesrc b/config/plasma/xdg/kwinrulesrc
new file mode 100644
index 0000000..d46f6fa
--- /dev/null
+++ b/config/plasma/xdg/kwinrulesrc
@@ -0,0 +1,48 @@
+# Window rules.
+#
+# There are three, they are all about size, and none of them is about
+# stacking. Dialogs staying in front of the window that opened them,
+# secondary windows floating over the main one instead of resizing it, and
+# a print dialog being the window in front are all ordinary window-manager
+# behaviour here, so there is nothing to write down.
+#
+# `types=1` is NET::Normal only. Without it a rule that maximises would
+# also maximise the dialogs, which is exactly the behaviour the classroom
+# is moving away from.
+
+[General]
+count=3
+rules=classroom-browser,classroom-wonderink,classroom-video
+
+[classroom-browser]
+Description=The classroom browser fills the screen
+wmclass=google-chrome
+wmclassmatch=1
+wmclasscomplete=false
+types=1
+maximizehoriz=true
+maximizehorizrule=3
+maximizevert=true
+maximizevertrule=3
+
+[classroom-wonderink]
+Description=Wonder Ink fills the screen
+# Chrome derives this from the --app URL. Measured on a running session
+# rather than guessed; it is also what the launcher's StartupWMClass says,
+# and the two have to agree or the panel shows two buttons for one app.
+wmclass=chrome-app.wonderink.org__-Default
+wmclassmatch=1
+wmclasscomplete=false
+types=1
+maximizehoriz=true
+maximizehorizrule=3
+maximizevert=true
+maximizevertrule=3
+
+[classroom-video]
+Description=Video is an appliance screen, not a window
+wmclass=vlc
+wmclassmatch=2
+wmclasscomplete=false
+noborder=true
+noborderrule=2
diff --git a/config/plasma/xdg/plasmanotifyrc b/config/plasma/xdg/plasmanotifyrc
new file mode 100644
index 0000000..06e4006
--- /dev/null
+++ b/config/plasma/xdg/plasmanotifyrc
@@ -0,0 +1,16 @@
+# Small cards, top right, out of the way of the bottom bar and of anything
+# a teacher is reading. Plasma's own notification service does this; the
+# classroom does not run a notification daemon of its own.
+[Notifications][$i]
+PopupPosition=TopRight
+PopupTimeout=6000
+LowPriorityPopups=false
+LowPriorityHistory=false
+
+[Jobs][$i]
+PermanentPopups=false
+InNotifications=true
+
+[DoNotDisturb][$i]
+WhenFullscreen=true
+WhenScreensMirrored=false
diff --git a/config/wallpapers/classroom-calm.png b/config/wallpapers/classroom-calm.png
new file mode 100644
index 0000000..04f8463
Binary files /dev/null and b/config/wallpapers/classroom-calm.png differ
diff --git a/docs/screenshots/click-probe.png b/docs/screenshots/click-probe.png
new file mode 100644
index 0000000..f25d534
Binary files /dev/null and b/docs/screenshots/click-probe.png differ
diff --git a/docs/screenshots/click-test.png b/docs/screenshots/click-test.png
new file mode 100644
index 0000000..24739ec
Binary files /dev/null and b/docs/screenshots/click-test.png differ
diff --git a/docs/screenshots/labelled-tasks.png b/docs/screenshots/labelled-tasks.png
new file mode 100644
index 0000000..69bd3e9
Binary files /dev/null and b/docs/screenshots/labelled-tasks.png differ
diff --git a/docs/screenshots/m-closed.png b/docs/screenshots/m-closed.png
new file mode 100644
index 0000000..8f3d400
Binary files /dev/null and b/docs/screenshots/m-closed.png differ
diff --git a/docs/screenshots/m-minimized.png b/docs/screenshots/m-minimized.png
new file mode 100644
index 0000000..36d4945
Binary files /dev/null and b/docs/screenshots/m-minimized.png differ
diff --git a/docs/screenshots/recovery-after-8-kills.png b/docs/screenshots/recovery-after-8-kills.png
new file mode 100644
index 0000000..b8922e0
Binary files /dev/null and b/docs/screenshots/recovery-after-8-kills.png differ
diff --git a/docs/screenshots/recovery-after-kwin-kill.png b/docs/screenshots/recovery-after-kwin-kill.png
new file mode 100644
index 0000000..1c26bd5
Binary files /dev/null and b/docs/screenshots/recovery-after-kwin-kill.png differ
diff --git a/docs/screenshots/state-1-closed-crop.png b/docs/screenshots/state-1-closed-crop.png
new file mode 100644
index 0000000..165bb36
Binary files /dev/null and b/docs/screenshots/state-1-closed-crop.png differ
diff --git a/docs/screenshots/state-1-closed.png b/docs/screenshots/state-1-closed.png
new file mode 100644
index 0000000..c090901
Binary files /dev/null and b/docs/screenshots/state-1-closed.png differ
diff --git a/docs/screenshots/state-2-active.png b/docs/screenshots/state-2-active.png
new file mode 100644
index 0000000..1be3a8d
Binary files /dev/null and b/docs/screenshots/state-2-active.png differ
diff --git a/docs/screenshots/state-3-behind.png b/docs/screenshots/state-3-behind.png
new file mode 100644
index 0000000..f94e655
Binary files /dev/null and b/docs/screenshots/state-3-behind.png differ
diff --git a/docs/screenshots/state-3-zoom.png b/docs/screenshots/state-3-zoom.png
new file mode 100644
index 0000000..91126af
Binary files /dev/null and b/docs/screenshots/state-3-zoom.png differ
diff --git a/docs/screenshots/state-4-minimized.png b/docs/screenshots/state-4-minimized.png
new file mode 100644
index 0000000..36d4945
Binary files /dev/null and b/docs/screenshots/state-4-minimized.png differ
diff --git a/docs/screenshots/state-5-restored.png b/docs/screenshots/state-5-restored.png
new file mode 100644
index 0000000..f94e655
Binary files /dev/null and b/docs/screenshots/state-5-restored.png differ
diff --git a/docs/screenshots/v2-browser-running.png b/docs/screenshots/v2-browser-running.png
new file mode 100644
index 0000000..2f059b5
Binary files /dev/null and b/docs/screenshots/v2-browser-running.png differ
diff --git a/docs/screenshots/v2-idle-first.png b/docs/screenshots/v2-idle-first.png
new file mode 100644
index 0000000..b6cb769
Binary files /dev/null and b/docs/screenshots/v2-idle-first.png differ
diff --git a/docs/screenshots/v2-idle.png b/docs/screenshots/v2-idle.png
new file mode 100644
index 0000000..8e473cc
Binary files /dev/null and b/docs/screenshots/v2-idle.png differ
diff --git a/docs/screenshots/v2-minimized-full.png b/docs/screenshots/v2-minimized-full.png
new file mode 100644
index 0000000..6720f50
Binary files /dev/null and b/docs/screenshots/v2-minimized-full.png differ
diff --git a/docs/screenshots/v2-two-apps.png b/docs/screenshots/v2-two-apps.png
new file mode 100644
index 0000000..1cd5c45
Binary files /dev/null and b/docs/screenshots/v2-two-apps.png differ
diff --git a/docs/screenshots/v2-wonderink.png b/docs/screenshots/v2-wonderink.png
new file mode 100644
index 0000000..0c166ac
Binary files /dev/null and b/docs/screenshots/v2-wonderink.png differ
diff --git a/scripts/classroom-browser b/scripts/classroom-browser
index 015325d..3afccfc 100755
--- a/scripts/classroom-browser
+++ b/scripts/classroom-browser
@@ -1,32 +1,33 @@
#!/bin/bash
set -euo pipefail
-# The dock's Browser button. classroom-window decides whether this is a
-# launch, a focus, a minimise or a restore.
+# The room's ordinary browser: YouTube, and anywhere else a lesson goes.
#
-# Chrome stays a full browser: Wonder Ink is only the starting page, and
-# the profile in ~/.config/google-chrome is never touched here so logins
-# and cookies survive reboots and updates.
+# It is a full browser with a tab strip and an address bar, in the room's
+# own profile, so the church YouTube sign-in survives reboots and updates.
+# Wonder Ink is a separate application in a separate profile and is not
+# reachable from this one.
+#
+# There is no launch/focus/minimise/restore logic here any more. The panel
+# owns that: it knows this window by StartupWMClass and does the right
+# thing with a click, including telling the teacher which of those four
+# states the application is in.
#
# --hide-crash-restore-bubble is what suppresses "Restore pages? Chrome
-# didn't shut down correctly" after a reboot. Note the switch name: the
+# didn't shut down correctly" after a power cut. Note the switch name: the
# older --disable-session-crashed-bubble is not present in Chrome 152 at
-# all, so it would have silently done nothing. Verified against the
-# strings of the installed binary.
+# all, so it would have silently done nothing.
-HOME_PAGE='https://app.wonderink.org/'
+# Not Wonder Ink. Wonder Ink is its own application in its own profile,
+# and a second way in through this window would put a teacher's lesson
+# session in the room's shared browser profile — the exact leak the split
+# exists to prevent.
+HOME_PAGE='https://www.youtube.com/'
-exec /usr/local/bin/classroom-window toggle \
- --match 'google-chrome' \
- --match 'chrome' \
- --match 'chrome-.*' \
- --match 'google-chrome-.*' \
- -- \
- google-chrome \
- --ozone-platform=wayland \
- --start-maximized \
- --no-first-run \
- --no-default-browser-check \
- --password-store=basic \
- --hide-crash-restore-bubble \
- "$HOME_PAGE"
+exec google-chrome \
+ --ozone-platform=wayland \
+ --no-first-run \
+ --no-default-browser-check \
+ --password-store=basic \
+ --hide-crash-restore-bubble \
+ "${@:-$HOME_PAGE}"
diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink
new file mode 100755
index 0000000..0412094
--- /dev/null
+++ b/scripts/classroom-wonderink
@@ -0,0 +1,39 @@
+#!/bin/bash
+set -euo pipefail
+
+# Wonder Ink as an application, not a tab.
+#
+# Two things make it one. It runs in Chrome's app mode, so the window has
+# no tab strip and no address bar and reads as a program. And it runs in
+# its own Chrome profile, so the teacher signed into Wonder Ink and the
+# room's shared YouTube account are separate sessions that cannot reach
+# each other:
+#
+# * closing this window ends the Wonder Ink session, because the
+# wonderink.org cookies are session-only by policy and this profile's
+# browser process exits with the window;
+# * a Google sign-in performed here cannot be used to re-enter the
+# previous teacher's Wonder Ink account from the ordinary browser,
+# because the ordinary browser's Google session is in a different
+# profile;
+# * and the church YouTube login in the ordinary browser survives all of
+# it, because nothing here touches that profile.
+#
+# The machine-wide policy in /etc/opt/chrome/policies applies to every
+# profile, so this window is governed by exactly the same rules as the
+# browser.
+
+HOME_PAGE='https://app.wonderink.org/'
+PROFILE="${XDG_DATA_HOME:-$HOME/.local/share}/ekklesia-classroom/wonderink"
+
+mkdir -p "$PROFILE"
+
+exec google-chrome \
+ --user-data-dir="$PROFILE" \
+ --app="$HOME_PAGE" \
+ --ozone-platform=wayland \
+ --no-first-run \
+ --no-default-browser-check \
+ --password-store=basic \
+ --hide-crash-restore-bubble \
+ "$@"
diff --git a/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf b/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
new file mode 100644
index 0000000..240e77f
--- /dev/null
+++ b/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
@@ -0,0 +1,18 @@
+# The panel is the only way a teacher has of getting anywhere, so it is
+# never allowed to stay dead.
+#
+# Plasma's own unit already restarts plasmashell on a crash, but it gives
+# up after three restarts in sixty seconds and the panel is then gone until
+# somebody reboots the room. That is measurably the failure a classroom
+# machine has already had once, with the old hand-written dock, which had
+# no supervision at all.
+#
+# Measured in the lab: with these four lines, plasmashell survived eight
+# consecutive SIGKILLs one second apart and came back every time in about
+# 2.4 seconds. Without them it was permanently gone on the third.
+[Unit]
+StartLimitIntervalSec=0
+
+[Service]
+Restart=always
+RestartSec=2
diff --git a/tools/build-light-scheme b/tools/build-light-scheme
new file mode 100755
index 0000000..46a896e
--- /dev/null
+++ b/tools/build-light-scheme
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+"""Generate the light colour scheme from the dark one.
+
+There is one set of design tokens, not two themes. The light scheme is the
+same file with the roles swapped, so a colour can only be changed in one
+place and the two can never drift apart.
+"""
+
+import pathlib
+import re
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+COLORS = ROOT / "config" / "plasma" / "colors"
+
+# dark value -> light value
+ROLES = {
+ "20,28,32": "242,245,244", # window background
+ "25,35,40": "233,238,237", # alternate
+ "15,23,26": "255,255,255", # view background
+ "18,26,30": "236,241,240", # header
+ "16,23,26": "228,234,233", # header, inactive
+ "30,42,47": "225,231,230", # button alternate
+ "230,237,239": "22,35,42", # normal text
+ "147,165,170": "96,116,122", # inactive text
+ "170,186,191": "80,99,105", # inactive header text
+ "125,142,147": "120,138,144",
+ "79,179,168": "46,140,130", # accent
+ "126,196,255": "26,110,180", # link
+ "227,106,106": "192,57,57", # negative
+ "224,177,92": "166,116,24", # neutral
+ "111,207,151": "38,142,86", # positive
+ "156,138,222": "104,80,180", # visited
+ "14,21,24": "255,255,255", # text on a selection
+ "10,16,18": "255,255,255",
+}
+
+HEADER = (
+ "# Ekklesia Classroom — light.\n"
+ "#\n"
+ "# GENERATED from EkklesiaDark.colors by tools/build-light-scheme.\n"
+ "# Edit the dark scheme and regenerate; do not edit this file by hand."
+)
+
+
+def main():
+ dark = (COLORS / "EkklesiaDark.colors").read_text()
+
+ light = dark
+ for old, new in sorted(ROLES.items(), key=lambda item: -len(item[0])):
+ light = re.sub(rf"(? 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)")
diff --git a/tools/lab/Dockerfile.plasma b/tools/lab/Dockerfile.plasma
index 58c6acd..c9c84ae 100644
--- a/tools/lab/Dockerfile.plasma
+++ b/tools/lab/Dockerfile.plasma
@@ -50,3 +50,23 @@ CMD ["/sbin/init"]
# sanctioned way to capture a KWin session that has no outer compositor.
RUN apt-get update && apt-get install -y --no-install-recommends kde-spectacle \
&& rm -rf /var/lib/apt/lists/*
+
+# The classroom's browser, from Google's own repository, the same way a
+# classroom machine gets it. Testing the browser question on a different
+# browser would not answer it.
+RUN apt-get update && apt-get install -y --no-install-recommends gnupg \
+ && install -d -m 0755 /etc/apt/keyrings \
+ && curl -fsS https://dl.google.com/linux/linux_signing_key.pub \
+ | gpg --dearmor --batch --yes --output /etc/apt/keyrings/google-chrome.gpg \
+ && echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main' \
+ > /etc/apt/sources.list.d/google-chrome.list \
+ && apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \
+ && rm -rf /var/lib/apt/lists/*
+
+# VLC, CUPS and PipeWire: the rest of the classroom's runtime, so the
+# desktop is judged with the room's real applications in it.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ vlc vlc-plugin-base cups cups-client cups-filters \
+ pipewire pipewire-pulse wireplumber pulseaudio-utils \
+ libnotify-bin \
+ && rm -rf /var/lib/apt/lists/*
diff --git a/tools/lab/install-desktop b/tools/lab/install-desktop
new file mode 100755
index 0000000..a5c542c
--- /dev/null
+++ b/tools/lab/install-desktop
@@ -0,0 +1,70 @@
+#!/bin/bash
+# Put the classroom desktop onto a machine, from the repository, at the
+# paths the package will use. Run as root inside the lab.
+#
+# Every destination here is a normal system path that Plasma already reads.
+# There is no classroom daemon in this list, and nothing is generated: the
+# desktop is a set of files.
+set -euo pipefail
+
+SRC="${1:-/srv/classroom}"
+
+install -Dm644 "$SRC/config/plasma/xdg/kdeglobals" /etc/xdg/kdeglobals
+install -Dm644 "$SRC/config/plasma/xdg/kwinrc" /etc/xdg/kwinrc
+install -Dm644 "$SRC/config/plasma/xdg/kwinrulesrc" /etc/xdg/kwinrulesrc
+install -Dm644 "$SRC/config/plasma/xdg/kscreenlockerrc" /etc/xdg/kscreenlockerrc
+install -Dm644 "$SRC/config/plasma/xdg/ksmserverrc" /etc/xdg/ksmserverrc
+install -Dm644 "$SRC/config/plasma/xdg/krunnerrc" /etc/xdg/krunnerrc
+install -Dm644 "$SRC/config/plasma/xdg/plasmanotifyrc" /etc/xdg/plasmanotifyrc
+
+install -Dm644 "$SRC/config/plasma/colors/EkklesiaDark.colors" \
+ /usr/share/color-schemes/EkklesiaDark.colors
+install -Dm644 "$SRC/config/plasma/colors/EkklesiaLight.colors" \
+ /usr/share/color-schemes/EkklesiaLight.colors
+
+LNF=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop
+rm -rf "$LNF"
+install -Dm644 "$SRC/config/plasma/lookandfeel/metadata.json" "$LNF/metadata.json"
+install -Dm644 "$SRC/config/plasma/lookandfeel/contents/defaults" \
+ "$LNF/contents/defaults"
+install -Dm644 \
+ "$SRC/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js" \
+ "$LNF/contents/layouts/org.kde.plasma.desktop-layout.js"
+
+for entry in "$SRC"/config/plasma/applications/*.desktop; do
+ install -Dm644 "$entry" "/usr/share/applications/$(basename "$entry")"
+done
+
+# Entries that deliberately shadow a vendor one. XDG_DATA_DIRS searches
+# /usr/local/share before /usr/share, so these win without touching, and
+# without being overwritten by, the package that provides the original.
+for entry in "$SRC"/config/plasma/applications-local/*.desktop; do
+ install -Dm644 "$entry" "/usr/local/share/applications/$(basename "$entry")"
+done
+
+install -Dm644 "$SRC/config/plasma/solid/ekklesia-dvd.desktop" \
+ /usr/share/solid/actions/ekklesia-dvd.desktop
+
+for icon in "$SRC"/config/icons/ekklesia-*.svg; do
+ [ -e "$icon" ] || continue
+ install -Dm644 "$icon" \
+ "/usr/share/icons/hicolor/scalable/apps/$(basename "$icon")"
+done
+
+install -Dm644 "$SRC/config/wallpapers/classroom-calm.png" \
+ /usr/share/backgrounds/ekklesia/classroom-calm.png
+
+install -Dm644 "$SRC/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf" \
+ /etc/systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf
+
+for script in classroom-browser classroom-wonderink; do
+ [ -e "$SRC/scripts/$script" ] || continue
+ install -Dm755 "$SRC/scripts/$script" "/usr/local/bin/$script"
+done
+
+command -v gtk-update-icon-cache >/dev/null 2>&1 \
+ && gtk-update-icon-cache -qtf /usr/share/icons/hicolor || true
+command -v update-desktop-database >/dev/null 2>&1 \
+ && update-desktop-database -q /usr/share/applications || true
+
+echo "classroom desktop installed"
diff --git a/tools/lab/kwin-script b/tools/lab/kwin-script
new file mode 100755
index 0000000..5a6fe91
--- /dev/null
+++ b/tools/lab/kwin-script
@@ -0,0 +1,37 @@
+#!/bin/bash
+# Run a KWin script in the session under test and print what it logged.
+#
+# KWin's scripting interface is the only way to ask the compositor what it
+# actually believes about a window — class, caption, desktop file, geometry,
+# minimised, keep-above. Everything this experiment claims about window
+# behaviour is measured through here rather than inferred from a screenshot.
+set -euo pipefail
+
+NAME="${LAB_NAME:-classroom-v2-lab}"
+SCRIPT="${1:?usage: kwin-script FILE}"
+ID="labscript$RANDOM"
+
+# /tmp inside the lab is a tmpfs created after the container started, which
+# `docker cp` cannot see, so the script is piped in instead.
+docker exec -i "$NAME" sh -c "cat > /tmp/$ID.js && chown classroom /tmp/$ID.js" \
+ < "$SCRIPT"
+
+run() {
+ docker exec -u classroom \
+ -e XDG_RUNTIME_DIR=/run/user/1000 \
+ -e DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ -e HOME=/home/classroom \
+ "$NAME" "$@"
+}
+
+since=$(docker exec "$NAME" date '+%Y-%m-%d %H:%M:%S')
+number=$(run qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript \
+ "/tmp/$ID.js" "$ID" 2>/dev/null | tr -d '\r')
+run qdbus6 org.kde.KWin "/Scripting/Script$number" org.kde.kwin.Script.run >/dev/null 2>&1
+sleep 1
+run qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript "$ID" >/dev/null 2>&1 || true
+
+# KWin's print() reaches the journal, tagged by the wrapper process.
+docker exec "$NAME" journalctl --since "$since" --no-pager -o cat 2>/dev/null \
+ | grep -a "LAB:" | sed 's/.*LAB://'
+
diff --git a/tools/lab/lab b/tools/lab/lab
index 714865c..d435406 100755
--- a/tools/lab/lab
+++ b/tools/lab/lab
@@ -75,8 +75,19 @@ down)
;;
outer-up)
+ # A headless compositor has no input devices, and a wl_seat with no
+ # pointer capability never gives the session under test a pointer to
+ # click with — sway's cursor commands then report success and move
+ # nothing. So the lab runs wlroots' libinput backend as well, over the
+ # devices the privileged container inherits, with seatd standing in for
+ # the login session it does not have. Clicks in this experiment are
+ # real pointer events arriving through libinput.
+ docker exec "$NAME" sh -c 'pgrep -x seatd >/dev/null || \
+ (SEATD_VTBOUND=0 setsid seatd -g input > /tmp/seatd.log 2>&1 &)'
+ docker exec "$NAME" usermod -aG input classroom 2>/dev/null || true
+ sleep 2
session_env \
- -e WLR_BACKENDS=headless -e WLR_LIBINPUT_NO_DEVICES=1 \
+ -e WLR_BACKENDS=headless,libinput -e LIBSEAT_BACKEND=seatd \
-e WLR_RENDERER=pixman -e LIBGL_ALWAYS_SOFTWARE=1 \
-d "$NAME" sh -c 'exec sway -c /srv/classroom/tools/lab/outer-sway.conf \
> /tmp/outer-sway.log 2>&1'
@@ -91,6 +102,7 @@ plasma-up)
session_env \
-e WAYLAND_DISPLAY="$OUTER" -e XDG_SESSION_TYPE=wayland \
-e XDG_CURRENT_DESKTOP=KDE -e LIBGL_ALWAYS_SOFTWARE=1 \
+ -e QT_LOGGING_RULES="kwin_scripting.debug=true" \
-d "$NAME" sh -c 'exec startplasma-wayland > /tmp/plasma.log 2>&1'
for _ in $(seq 1 60); do
docker exec "$NAME" pgrep -x plasmashell >/dev/null 2>&1 && break
@@ -114,27 +126,45 @@ session)
;;
shot)
- # shot NAME [--delay SECONDS]
+ # shot NAME [GEOMETRY] — geometry as grim wants it, e.g. "0,980 1920x100"
name="${2:?usage: lab shot NAME}"
- outer grim /tmp/lab-shot.png >/dev/null
+ if [[ -n "${3:-}" ]]; then
+ outer grim -g "$3" /tmp/lab-shot.png >/dev/null
+ else
+ outer grim /tmp/lab-shot.png >/dev/null
+ fi
mkdir -p "$SHOTS"
docker exec "$NAME" cat /tmp/lab-shot.png > "$SHOTS/$name.png"
echo "$SHOTS/$name.png"
;;
click)
- # click X Y — a real pointer press in the session under test
+ # Absolute, through sway's own seat. This only works because the seat
+ # has a pointer capability at all, which is what the uinput device in
+ # `outer-up` is for.
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
- sleep 0.3
+ sleep 0.4
outer swaymsg seat - cursor press button1 >/dev/null
- sleep 0.1
+ sleep 0.15
outer swaymsg seat - cursor release button1 >/dev/null
+ sleep 0.5
;;
move)
outer swaymsg seat - cursor set "$2" "$3" >/dev/null
;;
+drag)
+ outer swaymsg seat - cursor set "$2" "$3" >/dev/null
+ sleep 0.3
+ outer swaymsg seat - cursor press button1 >/dev/null
+ sleep 0.3
+ outer swaymsg seat - cursor set "$4" "$5" >/dev/null
+ sleep 0.3
+ outer swaymsg seat - cursor release button1 >/dev/null
+ sleep 0.4
+ ;;
+
key)
shift
outer wtype "$@"
@@ -148,6 +178,8 @@ sh)
run) shift; session_env "$NAME" "$@" ;;
irun) shift; session_env -it "$NAME" "$@" ;;
inner) shift; inner "$@" ;;
+inner-d) shift; session_env -d -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE \
+ -e QT_QPA_PLATFORM=wayland "$NAME" "$@" ;;
inner-it) shift; session_env -it -e WAYLAND_DISPLAY=wayland-0 -e XDG_CURRENT_DESKTOP=KDE "$NAME" "$@" ;;
outer) shift; outer "$@" ;;
@@ -164,9 +196,11 @@ usage: tools/lab/lab COMMAND
plasma-down stop the Plasma session
shot NAME capture the screen to docs/screenshots/NAME.png
click X Y press the pointer at a screen position
+ drag X1 Y1 X2 Y2 drag from one position to another
move X Y move the pointer
key ARGS... wtype, aimed at the session
inner CMD... run CMD as a client of the Plasma session
+ inner-d CMD... the same, detached
outer CMD... run CMD against the lab compositor
run CMD... run CMD as the classroom user, no display
sh root shell in the lab