`);
+ const actual = await page.locator('.state').evaluate(el => ({ background: getComputedStyle(el).backgroundColor, color: getComputedStyle(el).color }));
+ assert.equal(actual.background, 'rgb(236, 0, 140)', 'Press Failed tags should use --urgent as their solid background');
+ assert.equal(actual.color, 'rgb(0, 0, 0)', 'Press Failed tags should use --on-urgent text');
+ } finally { await browser.close(); }
+});
+async function reproduce(mode) {
+ const home = await mkdtemp(join(tmpdir(), 'thirdshift824-spec-'));
+ let server, browser, exited;
+ try {
+ const root = join(home, '.thirdshift');
+ const records = join(root, 'logs/acme/widgets/commands/issue');
+ await mkdir(records, { recursive: true });
+ await writeFile(join(root, 'config.toml'), `[console]\nport = 0\ntheme = "${mode === 'tags' ? 'press-light' : 'garden-light'}"\n`);
+ const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8');
+ const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`;
+ const began = new Date(Date.now() - 60_000).toISOString();
+ const record = {version: 1, kind: 'Run', name: '1-fixture', repository: 'acme/widgets', hostname: 'fixture', issue: { number: 1, url: 'https://github.com/acme/widgets/issues/1', title: 'Fixture'}, process_id: process.pid, process_start: start, harness: 'codex', started_at: began, ended_at: mode === 'tags' ? new Date().toISOString() : null, outcome: mode === 'tags' ? 'failed' : null, session_log: null, stages: [{name: 'implement', began_at: began, session_log: null}]};
+ const recordPath = join(records, '1-fixture.command.json');
+ await writeFile(recordPath, JSON.stringify(record));
+ await writeFile(join(records, '1-fixture.log'), 'Command output\n');
+ server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], {env: {...process.env, HOME: home}, stdio: ['ignore', 'pipe', 'inherit']});
+ exited = once(server, 'exit');
+ const lines = createInterface({input: server.stdout});
+ const [origin] = await once(lines, 'line', {signal: AbortSignal.timeout(10_000)});
+ lines.close();
+ browser = await chromium.launch({headless: true});
+ if (mode === 'tags') {
+ const page = await browser.newPage();
+ await page.goto(origin);
+ const tag = await page.locator('.command[data-state="Failed"] .state').first().evaluate(el => {
+ const style = getComputedStyle(el);
+ return {background: style.backgroundColor, color: style.color};
+ });
+ console.log({tag});
+ assert.deepEqual(tag, {background: 'rgb(236, 0, 140)', color: 'rgb(0, 0, 0)'}, 'Press solid urgent tag must render the Theme token fill and its best black/white text');
+ } else {
+ const pages = [];
+ for (const name of ['lanes', 'stages', 'waterfall']) {
+ const page = await browser.newPage();
+ await page.goto(`${origin}/?page=${name}`);
+ await page.waitForFunction(() => document.documentElement.dataset.live === 'true');
+ assert.ok(await page.evaluate(() => document.getAnimations().some(a => a.playState === 'running')), `${name} starts activity motion`);
+ pages.push([name, page]);
+ }
+ record.ended_at = new Date().toISOString(); record.outcome = 'merged';
+ await writeFile(recordPath, JSON.stringify(record));
+ const fresh = await (await fetch(`${origin}/?page=lanes`)).text();
+ assert.ok(!fresh.includes('data-live="true"'), 'server now observes no live Command');
+ await pages[0][1].waitForTimeout(6500);
+ const moving = [];
+ for (const [name, page] of pages) if (await page.evaluate(() => document.getAnimations().some(a => a.playState === 'running'))) moving.push(name);
+ console.log({moving});
+ assert.deepEqual(moving, [], 'motion must stop after the only Command ends');
+ }
+ } finally {
+ await browser?.close();
+ if (server && server.exitCode === null) server.kill();
+ if (exited) await exited;
+ await rm(home, {recursive: true, force: true});
+ }
+}
+
+test('time-page motion stops after the last Command ends', { skip: process.platform !== 'linux' }, () => reproduce('motion'));
+test('served solid tags use their Theme fill and black or white text', { skip: process.platform !== 'linux' }, () => reproduce('tags'));
diff --git a/docs/code-navigation.md b/docs/code-navigation.md
index a775ff58..67f2aa3b 100644
--- a/docs/code-navigation.md
+++ b/docs/code-navigation.md
@@ -27,6 +27,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their
| Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` |
| Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` |
| Console account, Console log, sign-in | `src/console/accounts.rs` — durable accounts, sessions, slowdown and account events; `src/console/auth.rs` — HTTP authorization and CSRF; `src/console/account_commands.rs` — CLI; `src/terminal.rs` — shared hidden terminal input | `src/console/accounts/tests.rs`, `tests/console/accounts.rs` |
+| Theme, hidden sections, Pause motion | `src/config/console.rs` — built-in names and restricted User config writes; `src/console/look.rs` — CLI/HTTP look actions; `src/console/assets/themes.css` — tokens; `src/console/assets/look.js` — picker and browser motion preference | `tests/console/look.rs`, `console/tests/inbox.test.cjs` |
| Console installation, proxy site, system service | `src/console/install.rs` — printed Linux installation script; `src/config/console.rs` — address and port | `tests/console_install.rs` |
| Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — shared request, signaling, waiting and inspection; `src/console/stop.rs` — confirmation and authenticated Console action; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`, `tests/console/stop.rs`, `console/tests/stop.test.cjs`; inline Claim and Stop tests |
| User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` |
diff --git a/src/args.rs b/src/args.rs
index 3d2c9b4b..a3083534 100644
--- a/src/args.rs
+++ b/src/args.rs
@@ -6,7 +6,7 @@ use std::iter::Peekable;
use std::mem::discriminant;
use std::num::NonZeroUsize;
-use anyhow::{Result, bail};
+use anyhow::{Context, Result, bail};
use crate::asks::Flags;
use crate::base_fix::BaseFixAsk;
@@ -34,6 +34,15 @@ pub enum Command {
ConsoleLog {
json: bool,
},
+ ConsoleTheme {
+ name: Option,
+ json: bool,
+ },
+ ConsoleSections {
+ hide: Vec,
+ show: Vec,
+ json: bool,
+ },
Status {
json: bool,
all: bool,
@@ -258,6 +267,42 @@ pub fn parse(args: &[String]) -> Result {
Some("version" | "--version" | "-V") => return Ok(Command::Version),
Some("update") => return Ok(Command::Update),
Some("console") => {
+ if args.get(1).is_some_and(|action| action == "theme") {
+ let json = args.last().is_some_and(|arg| arg == "--json");
+ let end = args.len() - usize::from(json);
+ let [name] = &args[2..end] else {
+ bail!("usage: thirdshift console theme [--json]");
+ };
+ return Ok(Command::ConsoleTheme {
+ name: (name != "list").then(|| name.clone()),
+ json,
+ });
+ }
+ if args.get(1).is_some_and(|action| action == "sections") {
+ let (mut hide, mut show, mut json) = (Vec::new(), Vec::new(), false);
+ let mut arguments = args[2..].iter();
+ while let Some(flag) = arguments.next() {
+ match flag.as_str() {
+ "--json" if !json => json = true,
+ "--hide" | "--show" => {
+ let section = arguments
+ .next()
+ .filter(|s| !s.starts_with('-'))
+ .context("--hide and --show need a section name")?;
+ let target = if flag == "--hide" {
+ &mut hide
+ } else {
+ &mut show
+ };
+ target.extend(section.split(',').map(str::to_owned));
+ }
+ _ => bail!(
+ "usage: thirdshift console sections [--hide ] [--show ] [--json]"
+ ),
+ }
+ }
+ return Ok(Command::ConsoleSections { hide, show, json });
+ }
if args.get(1).is_some_and(|action| action == "sign-out") {
let mut email = None;
let mut json = false;
diff --git a/src/config.rs b/src/config.rs
index 2915f00a..d8682998 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,7 +16,7 @@ use toml_edit::{DocumentMut, Item};
use crate::harness::{self, Harness, ModelAndEffort};
-mod console;
+pub mod console;
pub use console::{ConsoleAccess, ConsoleSettings};
/// The settings a User config can hold. Each is what a Run does when its
@@ -851,7 +851,8 @@ address = "" # HTTPS address for console install, e.g. https://console.ex
port = 8711 # localhost port for the Console; 0 chooses a free port; default 8711
allowed_emails = [] # addresses that may sign in; empty requires --no-sign-in; default []
other_consoles = [] # http or https addresses linked in the header, never fetched; default []
-theme = "focus-auto" # Focus follows the system, or focus-light / focus-dark; default focus-auto
+theme = "focus-auto" # focus/plain/press/signal/garden/factory, with -auto/-light/-dark; default focus-auto
+hidden_sections = [] # needs-you/ready-for-review/landing/working/done/resolved; default []
[activity]
quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false
@@ -1036,6 +1037,7 @@ mod tests {
"console.allowed_emails",
"console.other_consoles",
"console.theme",
+ "console.hidden_sections",
"activity.quiet_skips",
"spec.parallel",
"pickup.limit",
diff --git a/src/config/console.rs b/src/config/console.rs
index 7056f08a..eae89064 100644
--- a/src/config/console.rs
+++ b/src/config/console.rs
@@ -1,6 +1,42 @@
-//! Console settings stay in the User config; serving never writes them.
+//! Console settings and narrowly scoped, comment-preserving look writes.
use anyhow::{Result, ensure};
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
+
+pub const THEME_FAMILIES: &[&str] = &["focus", "plain", "press", "signal", "garden", "factory"];
+pub const SECTIONS: &[&str] = &[
+ "needs-you",
+ "ready-for-review",
+ "landing",
+ "working",
+ "done",
+ "resolved",
+];
+
+pub fn theme_names() -> Vec {
+ THEME_FAMILIES
+ .iter()
+ .flat_map(|family| ["auto", "light", "dark"].map(|mode| format!("{family}-{mode}")))
+ .collect()
+}
+
+pub fn validate_theme(theme: &str) -> Result<()> {
+ ensure!(
+ theme_names().iter().any(|name| name == theme),
+ "unknown Console Theme: {theme}"
+ );
+ Ok(())
+}
+
+pub fn validate_sections(sections: &[String]) -> Result<()> {
+ ensure!(
+ sections
+ .iter()
+ .all(|section| SECTIONS.contains(§ion.as_str())),
+ "unknown Console section; choose {}",
+ SECTIONS.join(", ")
+ );
+ Ok(())
+}
/// The Console settings Setup asks about; other Console settings stay put.
#[derive(Clone)]
@@ -9,7 +45,7 @@ pub struct ConsoleAccess {
pub allowed_emails: Vec,
}
-#[derive(Debug, PartialEq, Eq, Deserialize)]
+#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ConsoleSettings {
pub address: String,
@@ -17,6 +53,7 @@ pub struct ConsoleSettings {
pub allowed_emails: Vec,
pub other_consoles: Vec,
pub theme: String,
+ pub hidden_sections: Vec,
}
impl Default for ConsoleSettings {
@@ -27,6 +64,7 @@ impl Default for ConsoleSettings {
allowed_emails: Vec::new(),
other_consoles: Vec::new(),
theme: "focus-auto".into(),
+ hidden_sections: Vec::new(),
}
}
}
@@ -66,13 +104,8 @@ impl ConsoleSettings {
"console.allowed_emails must contain email addresses"
);
}
- ensure!(
- matches!(
- self.theme.as_str(),
- "focus-auto" | "focus-light" | "focus-dark"
- ),
- "console.theme must be focus-auto, focus-light or focus-dark"
- );
+ validate_theme(&self.theme)?;
+ validate_sections(&self.hidden_sections)?;
for address in &self.other_consoles {
let url = reqwest::Url::parse(address)?;
ensure!(
@@ -83,3 +116,83 @@ impl ConsoleSettings {
Ok(())
}
}
+
+/// The caller can change only the two look keys. Lock the read/edit/replace
+/// across CLI processes and HTTP requests so independent look writes compose.
+pub fn write_look(
+ theme: Option<&str>,
+ hide: &[String],
+ show: &[String],
+) -> Result {
+ use std::fs::{self, OpenOptions};
+ use std::io::Write;
+ use toml_edit::{DocumentMut, Item, Table, Value};
+
+ if let Some(theme) = theme {
+ validate_theme(theme)?;
+ }
+ validate_sections(hide)?;
+ validate_sections(show)?;
+ let (home, path) = super::home_and_path()?;
+ let root = path.parent().expect("User config directory");
+ fs::create_dir_all(root)?;
+ let lock = OpenOptions::new()
+ .create(true)
+ .truncate(false)
+ .write(true)
+ .open(root.join("config.lock"))?;
+ lock.lock()?;
+ let text = match fs::read_to_string(&path) {
+ Ok(text) => text,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
+ Err(error) => return Err(error.into()),
+ };
+ let mut settings = super::UserConfig::parse(&text, &path, &home)?.console;
+ let mut document: DocumentMut = text.parse()?;
+ if let Some(theme) = theme {
+ settings.theme = theme.into();
+ }
+ settings
+ .hidden_sections
+ .retain(|section| !show.contains(section));
+ for section in hide {
+ if !settings.hidden_sections.contains(section) {
+ settings.hidden_sections.push(section.clone());
+ }
+ }
+ if !document.contains_key("console") {
+ document["console"] = Item::Table(Table::new());
+ }
+ let mut set = |key: &str, value: Value| {
+ let table = document["console"]
+ .as_table_like_mut()
+ .expect("validated console table");
+ if table.contains_key(key) {
+ super::set(&mut document, "console", key, value);
+ } else {
+ table.insert(key, Item::Value(value));
+ }
+ };
+ if theme.is_some() {
+ set("theme", settings.theme.clone().into());
+ }
+ if !hide.is_empty() || !show.is_empty() {
+ set(
+ "hidden_sections",
+ settings
+ .hidden_sections
+ .iter()
+ .collect::()
+ .into(),
+ );
+ }
+ let rendered = document.to_string();
+ if path.exists() {
+ super::replace(&path, &rendered)?;
+ } else {
+ let mut file = tempfile::NamedTempFile::new_in(root)?;
+ file.write_all(rendered.as_bytes())?;
+ file.persist(&path)?;
+ }
+ Ok(settings)
+}
diff --git a/src/console.rs b/src/console.rs
index deaf2533..79a64b39 100644
--- a/src/console.rs
+++ b/src/console.rs
@@ -8,6 +8,8 @@ mod install;
pub use install::install;
mod live;
mod logs;
+mod look;
+pub use look::{sections, theme};
mod peek;
mod restart;
mod stop;
@@ -55,6 +57,9 @@ enum View {
SignOut,
SignInPage,
SignIn,
+ Look,
+ Theme,
+ Sections,
StopPage,
Stop,
SetPasswordPage,
@@ -63,6 +68,27 @@ enum View {
}
const PAGES: &[PageRoute] = &[
+ PageRoute {
+ path: "/look",
+ cli: &[
+ "thirdshift console theme list --json",
+ "thirdshift console sections --json",
+ ],
+ method: Method::GET,
+ view: View::Look,
+ },
+ PageRoute {
+ path: "/theme",
+ cli: &["thirdshift console theme focus-auto --json"],
+ method: Method::POST,
+ view: View::Theme,
+ },
+ PageRoute {
+ path: "/sections",
+ cli: &["thirdshift console sections --hide done --json"],
+ method: Method::POST,
+ view: View::Sections,
+ },
PageRoute {
path: "/stop",
cli: &[
@@ -294,6 +320,9 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Route
(View::SignOut, Method::POST) => post(auth::sign_out),
(View::SignInPage, Method::GET) => get(auth::sign_in_page),
(View::SignIn, Method::POST) => post(auth::sign_in),
+ (View::Look, Method::GET) => get(look::get),
+ (View::Theme, Method::POST) => post(look::set_theme),
+ (View::Sections, Method::POST) => post(look::set_sections),
(View::StopPage, Method::GET) => get(stop::show),
(View::Stop, Method::POST) => post(stop::submit),
(View::SetPasswordPage, Method::GET) => get(auth::set_password_page),
@@ -319,6 +348,7 @@ struct InboxPage {
hostname: String,
consoles: Vec,
theme: String,
+ hidden_sections: Vec,
cli: &'static str,
inbox: Inbox,
title: &'static str,
@@ -328,6 +358,11 @@ struct InboxPage {
}
impl InboxPage {
+ fn hidden(&self, group: &str) -> bool {
+ self.hidden_sections
+ .iter()
+ .any(|section| section == &group.replace(' ', "-"))
+ }
fn needs_you(&self) -> usize {
self.inbox.sections[0].rows.len()
}
@@ -413,6 +448,7 @@ async fn render_page(
hostname: crate::host::name().unwrap_or_else(|| "unknown".into()),
consoles: config.console.other_consoles.clone(),
theme: config.console.theme.clone(),
+ hidden_sections: config.console.hidden_sections.clone(),
cli,
inbox: status::inbox(&config)?,
title,
diff --git a/src/console/accounts.rs b/src/console/accounts.rs
index ba609b46..09bd12aa 100644
--- a/src/console/accounts.rs
+++ b/src/console/accounts.rs
@@ -40,6 +40,8 @@ pub(super) enum EventKind {
CodeSent,
SignOut,
BlockedAddress,
+ ThemeChanged,
+ SectionsChanged,
}
impl std::fmt::Display for EventKind {
@@ -51,6 +53,8 @@ impl std::fmt::Display for EventKind {
Self::CodeSent => "code-sent",
Self::SignOut => "sign-out",
Self::BlockedAddress => "blocked-address",
+ Self::ThemeChanged => "theme-changed",
+ Self::SectionsChanged => "sections-changed",
})
}
}
@@ -240,7 +244,7 @@ impl Accounts {
Ok(())
}
- fn record(&self, email: &str, event: EventKind) -> Result<()> {
+ pub(super) fn record(&self, email: &str, event: EventKind) -> Result<()> {
let mut file = private_file(&self.root.join("console.log"))?;
serde_json::to_writer(
&mut file,
diff --git a/src/console/assets/README.md b/src/console/assets/README.md
index 4a135548..b13c5678 100644
--- a/src/console/assets/README.md
+++ b/src/console/assets/README.md
@@ -1,8 +1,18 @@
# Embedded Console assets
All runtime assets are compiled in with `include_dir`; the release build uses
-neither Node nor npm. Focus tokens and the font choices follow
-`prototype/711-console` at `05b1b0a`, `themes.js`'s Focus light/dark definitions.
+neither Node nor npm. `themes.css` holds the six Themes' tokens, adapted from
+`prototype/711-console` at `05b1b0a`, `themes.js`. It is maintained directly,
+with shared layout and activity motion in `console.css`. Press's light link
+and Factory's dark dim/urgent text are adjusted to pass AA on raised and
+selected panels as well as the page. Focus also has an APCA Lc 45 floor.
+
+`console theme -` and `console sections --hide done
+--show working` share the page's restricted look writer. Sections are named
+`needs-you`, `ready-for-review`, `landing`, `working`, `done`, `resolved`.
+Theme and hidden sections are machine settings; Pause motion is stored only
+in the browser. `npm test --prefix console` checks Theme switching, contrast,
+font loading, and activity motion through the served Console.
Vendored, unmodified libraries (licenses beside them):
@@ -18,3 +28,22 @@ Latin-subset WOFF2 fonts, with SIL Open Font Licenses in `fonts/`:
These URLs document provenance; the Console loads only embedded local files.
+
+Additional Latin-subset WOFF2 files from Google Fonts (normal style):
+
+- Big Shoulders Display, normal, weight 100 900:
+- JetBrains Mono, normal, weight 100 800:
+- Inter, normal, weight 100 900:
+- Caprasimo, normal, weight 400:
+- Nunito, normal, weight 200 1000:
+- Barlow Condensed, normal, weight 400:
+- Barlow Condensed, normal, weight 600:
+- Barlow Condensed, normal, weight 700:
+- Titillium Web, normal, weight 400:
+- Titillium Web, normal, weight 600:
+- Titillium Web, normal, weight 700:
+- Atkinson Hyperlegible, normal, weight 400:
+- Atkinson Hyperlegible, normal, weight 700:
+
+The matching SIL Open Font Licenses are vendored in `fonts/*-OFL.txt`
+from `google/fonts` (`ofl//OFL.txt`). No remote URL is used at runtime.
diff --git a/src/console/assets/console.css b/src/console/assets/console.css
index a8276120..751e9e7a 100644
--- a/src/console/assets/console.css
+++ b/src/console/assets/console.css
@@ -1,29 +1,13 @@
+@import url("/assets/themes.css");
@font-face { font-family: "IBM Plex Sans"; src: url("/assets/fonts/ibm-plex-sans-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
@font-face { font-family: "IBM Plex Mono"; src: url("/assets/fonts/ibm-plex-mono-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; }
-/* Focus tokens from prototype/711-console themes.js (05b1b0a), ADR 0018. */
-:root {
- color-scheme: light dark;
- --bg: light-dark(#F7F7F5, #121214); --panel: light-dark(#FFFFFF, #19191C);
- --panel-2: light-dark(#F0F0EE, #212125); --sel-bg: light-dark(#EEEEEB, #2A2A2F);
- --text: light-dark(#18181A, #E8E8EA); --head: var(--text); --link: var(--text);
- --dim: light-dark(#6B6B70, #A0A0A8); --rule: light-dark(#E3E3E0, #2C2C31);
- --urgent: light-dark(#D7263D, #FF6B78); --urgent-text: light-dark(#9A1424, #FFB0B5);
- --look: light-dark(#B06800, #FFB020); --look-text: light-dark(#7E4C00, #FFB52E);
- --working: light-dark(#5E5E63, #A8A8B0); --working-text: light-dark(#4A4A4F, #B4B4BC);
- --landing: light-dark(#6B6B70, #9A9AA2); --ready: light-dark(#3F3F44, #C4C4CA);
- --ready-text: var(--ready); --done: var(--landing);
- --font-head: "IBM Plex Sans", sans-serif; --font-body: "IBM Plex Sans", sans-serif;
- --font-mono: "IBM Plex Mono", monospace; --body-size: 14px; --head-weight: 600; --radius: 4px;
-}
-:root[data-theme="focus-light"] { color-scheme: light; }
-:root[data-theme="focus-dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0 auto; max-width: 1120px; padding: 20px 28px; background: var(--bg); color: var(--text); font: var(--body-size)/1.4 var(--font-body); }
header { border-bottom: 2px solid var(--head); padding-bottom: 14px; }
.brand, .harness, .age, .empty, aside { color: var(--dim); }
.brand { margin: 0; }
-h1 { margin: 4px 0; font: var(--head-weight) clamp(24px, 4vw, 40px)/1.1 var(--font-head); overflow-wrap: anywhere; }
+h1 { margin: 4px 0; font: var(--head-weight) clamp(24px, 4vw, 40px)/1.1 var(--font-head); overflow-wrap: anywhere; text-transform: var(--head-case); text-shadow: var(--title-shadow); }
nav { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 10px; }
a { color: var(--link); overflow-wrap: anywhere; }
.page-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 16px 0 8px; }
@@ -46,7 +30,7 @@ summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; }
.now { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.command[data-state="Died"], .command[data-state="Failed"], .command[data-state="Silent"], .command[data-state="Finding"], .command[data-state="Triage"] { background: var(--panel); border-left: 3px solid var(--urgent); color: var(--urgent-text); }
.command[data-state="Slow"] { border-left: 3px solid var(--look); color: var(--look-text); }
-.command[data-state="Died"] .state, .command[data-state="Failed"] .state, .command[data-state="Silent"] .state, .command[data-state="Finding"] .state, .command[data-state="Triage"] .state { color: var(--urgent-text); font-weight: var(--head-weight); }
+.command[data-state="Died"] .state, .command[data-state="Failed"] .state, .command[data-state="Silent"] .state, .command[data-state="Finding"] .state, .command[data-state="Triage"] .state { color: var(--tag-text); font-weight: var(--head-weight); }
.plan { margin-top: 6px; }
.plan li::before { content: "☐ "; }
.children { border-left: 1px solid var(--rule); padding-left: 14px; margin-top: 8px; }
@@ -73,6 +57,34 @@ dd dl { border-left: 1px solid var(--rule); padding-left: 8px; }
#jumps button { margin: 3px; }
dialog::backdrop { background: #0006; }
footer { margin-top: 16px; }
+#section-options label { display: block; margin: 8px 0; }
+.command { box-shadow: var(--panel-shadow); --tag-bg: var(--tag-done-bg); --tag-text: var(--tag-done-text); }
+.command[data-state="live"] { --tag-bg: var(--tag-working-bg); --tag-text: var(--tag-working-text); }
+.command[data-state="Landing"] { --tag-bg: var(--tag-landing-bg); --tag-text: var(--tag-landing-text); }
+.command[data-state="Ready"] { --tag-bg: var(--tag-ready-bg); --tag-text: var(--tag-ready-text); }
+.command:is([data-state="Silent"], [data-state="Died"], [data-state="Failed"], [data-state="Finding"], [data-state="Triage"]) { --tag-bg: var(--tag-urgent-bg); --tag-text: var(--tag-urgent-text); }
+.command[data-state="Slow"] { --tag-bg: var(--tag-look-bg); --tag-text: var(--tag-look-text); }
+.command .state { background: var(--tag-bg); color: var(--tag-text); border-radius: var(--radius); padding: 0 3px; }
+.command[data-live="true"][data-state="live"] > .row-head > .state::before { content: ""; display: inline-block; width: 9px; height: 9px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; margin-right: 5px; }
+header::after { content: ""; display: block; height: 4px; background: var(--activity-decoration); background-size: 24px 4px; }
+#log-content[data-live="true"]::after { content: "▌"; color: var(--working-text); }
+.command[data-state="Stopping"]::after { content: ""; display: block; height: 3px; background: repeating-linear-gradient(90deg, var(--urgent) 0 12px, transparent 12px 24px); background-size: 48px 3px; }
+.running-stage { background-image: repeating-linear-gradient(120deg, transparent 0 8px, var(--rule) 8px 10px); background-size: 24px 24px; }
+@keyframes working { to { transform: rotate(360deg); } }
+@keyframes activity-drift { to { background-position: 24px 0; } }
+@keyframes alarm { 50% { outline: 2px solid var(--urgent); } }
+@keyframes cursor { 50% { opacity: 0; } }
+@keyframes latest { from { outline: 2px solid var(--working); } }
+@media (prefers-reduced-motion: no-preference) {
+ :root:not([data-paused="true"]) .command[data-live="true"][data-state="live"] > .row-head > .state::before { animation: working 2s linear infinite; }
+ :root:not([data-paused="true"]) .command[data-live="true"][data-state="Silent"] > .row-head > .state { animation: alarm 1.8s ease-in-out 3; }
+ :root:not([data-paused="true"]) #log-content[data-live="true"]::after { animation: cursor 1s steps(1) infinite; }
+ :root:not([data-paused="true"])[data-live="true"] header::after { animation: var(--activity-motion); }
+ :root:not([data-paused="true"]) .command[data-live="true"][data-state="Stopping"]::after, :root:not([data-paused="true"]) .running-stage { animation: activity-drift 1s linear infinite; }
+ :root:not([data-paused="true"]) #log-content[data-live="true"] > :last-child { animation: latest 900ms ease-out; }
+}
+:root[data-paused="true"] *, :root[data-paused="true"] *::before, :root[data-paused="true"] *::after { animation: none !important; transition: none !important; }
+@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
@media (max-width: 760px) {
body.peek-open { display: block; }
#peek { position: fixed; inset: auto 0 0; width: 100%; max-height: 65dvh; margin: 0; z-index: 10; border-radius: 12px 12px 0 0; box-shadow: 0 -4px 20px #0003; }
diff --git a/src/console/assets/console.js b/src/console/assets/console.js
index b4751d6b..0ddff7d3 100644
--- a/src/console/assets/console.js
+++ b/src/console/assets/console.js
@@ -1,3 +1,4 @@
+import { activity, loadLook, motionAllowed } from './look.js';
import { watchCommands } from './changes.js';
// Browser preferences are conveniences; malformed or unavailable storage hides nothing.
@@ -79,24 +80,50 @@ function morph(html) {
document.title = next.title;
document.querySelector('link[rel="icon"]').href = next.querySelector('link[rel="icon"]').getAttribute("href");
markNew();
+ activity();
+ loadLook();
for (const row of document.querySelectorAll('.command[data-logging="true"]')) {
const previous = previousGrowth.get(row.id) ?? [0, 0];
const increased = logLengths(row).some((size, index) => size > previous[index]);
- if (increased && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ if (increased && row.dataset.live === 'true' && motionAllowed()) {
row.querySelector(":scope > .row-head > .heartbeat")?.animate([
{ transform: "scale(1)", opacity: 0.4 },
{ transform: "scale(1.8)", opacity: 1, offset: 0.3 },
{ transform: "scale(1)", opacity: 0.4 }
], { duration: 900 });
+ row.querySelector(':scope > .now')?.animate([
+ { outline: '2px solid var(--working)' }, { outline: '2px solid transparent' }
+ ], { duration: 900 });
}
}
}
+const timePage = document.querySelector('main[data-time-page]');
+function morphTimePage(html) {
+ const next = new DOMParser().parseFromString(html, 'text/html');
+ const replacement = next.querySelector('main[data-time-page]');
+ if (!replacement) throw new Error('Missing time page');
+ const selection = selected?.dataset.command;
+ const focused = timePage.contains(document.activeElement);
+ const position = [scrollX, scrollY];
+ Idiomorph.morph(timePage, replacement);
+ if (selection) {
+ const row = [...timePage.querySelectorAll('.command')].find(row => row.dataset.command === selection);
+ select(row, { scroll: false });
+ if (focused) row?.focus({ preventScroll: true });
+ }
+ window.scrollTo(...position);
+ activity();
+ loadLook();
+}
+
async function refresh() {
- if (document.getElementById('inbox')) {
- const response = await fetch("/", { cache: "no-store" });
+ if (observesCommands) {
+ const response = await fetch(timePage ? location.href : '/', { cache: "no-store" });
if (!response.ok) throw new Error("Inbox unavailable");
- morph(await response.text());
+ const html = await response.text();
+ if (timePage) morphTimePage(html);
+ else morph(html);
}
await refreshPeek();
}
@@ -104,10 +131,11 @@ let stopWatching;
function startUpdates() {
stopWatching ??= watchCommands(refresh);
}
-if (document.getElementById('inbox')) startUpdates();
+const observesCommands = document.getElementById('inbox') || timePage;
+if (observesCommands) startUpdates();
function stopPeekUpdates() {
- if (document.getElementById('inbox')) return;
+ if (observesCommands) return;
stopWatching?.();
stopWatching = undefined;
}
@@ -178,7 +206,7 @@ function items() {
return [...document.querySelectorAll('main .group > summary, main .command, main .log-row')]
.filter(element => {
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
- if (parent.tagName === 'DETAILS' && !parent.open && element !== parent.firstElementChild) return false;
+ if (parent.hidden || (parent.tagName === 'DETAILS' && !parent.open && element !== parent.firstElementChild)) return false;
}
return true;
});
@@ -226,6 +254,8 @@ document.addEventListener('keydown', event => {
if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest('input, select, textarea, [contenteditable="true"]')) return;
if (event.key === 'Enter' && event.target.closest('a, button')) return;
if (event.key === ' ' && event.target.closest('button')) return;
+ if (document.querySelector('#themes[open], #sections-chooser[open]')) return;
+ if (event.key === 'T' && event.shiftKey) { event.preventDefault(); document.querySelector('[data-open-themes]').click(); return; }
if (event.key === 'Escape') {
event.preventDefault();
if (help.open) help.close();
diff --git a/src/console/assets/fonts/atkinsonhyperlegible-400-latin.woff2 b/src/console/assets/fonts/atkinsonhyperlegible-400-latin.woff2
new file mode 100644
index 00000000..2e43c90d
Binary files /dev/null and b/src/console/assets/fonts/atkinsonhyperlegible-400-latin.woff2 differ
diff --git a/src/console/assets/fonts/atkinsonhyperlegible-700-latin.woff2 b/src/console/assets/fonts/atkinsonhyperlegible-700-latin.woff2
new file mode 100644
index 00000000..bc31903a
Binary files /dev/null and b/src/console/assets/fonts/atkinsonhyperlegible-700-latin.woff2 differ
diff --git a/src/console/assets/fonts/atkinsonhyperlegible-OFL.txt b/src/console/assets/fonts/atkinsonhyperlegible-OFL.txt
new file mode 100644
index 00000000..2befc89b
--- /dev/null
+++ b/src/console/assets/fonts/atkinsonhyperlegible-OFL.txt
@@ -0,0 +1,92 @@
+Copyright 2020 Braille Institute of America, Inc.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/fonts/barlowcondensed-400-latin.woff2 b/src/console/assets/fonts/barlowcondensed-400-latin.woff2
new file mode 100644
index 00000000..ebb34ad5
Binary files /dev/null and b/src/console/assets/fonts/barlowcondensed-400-latin.woff2 differ
diff --git a/src/console/assets/fonts/barlowcondensed-600-latin.woff2 b/src/console/assets/fonts/barlowcondensed-600-latin.woff2
new file mode 100644
index 00000000..da60c9a9
Binary files /dev/null and b/src/console/assets/fonts/barlowcondensed-600-latin.woff2 differ
diff --git a/src/console/assets/fonts/barlowcondensed-700-latin.woff2 b/src/console/assets/fonts/barlowcondensed-700-latin.woff2
new file mode 100644
index 00000000..3726e9dc
Binary files /dev/null and b/src/console/assets/fonts/barlowcondensed-700-latin.woff2 differ
diff --git a/src/console/assets/fonts/barlowcondensed-OFL.txt b/src/console/assets/fonts/barlowcondensed-OFL.txt
new file mode 100644
index 00000000..2f22ba6c
--- /dev/null
+++ b/src/console/assets/fonts/barlowcondensed-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/fonts/bigshouldersdisplay-100-900-latin.woff2 b/src/console/assets/fonts/bigshouldersdisplay-100-900-latin.woff2
new file mode 100644
index 00000000..b1d217e0
Binary files /dev/null and b/src/console/assets/fonts/bigshouldersdisplay-100-900-latin.woff2 differ
diff --git a/src/console/assets/fonts/bigshouldersdisplay-OFL.txt b/src/console/assets/fonts/bigshouldersdisplay-OFL.txt
new file mode 100644
index 00000000..b5a4e0df
--- /dev/null
+++ b/src/console/assets/fonts/bigshouldersdisplay-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/fonts/caprasimo-400-latin.woff2 b/src/console/assets/fonts/caprasimo-400-latin.woff2
new file mode 100644
index 00000000..a1e50c3f
Binary files /dev/null and b/src/console/assets/fonts/caprasimo-400-latin.woff2 differ
diff --git a/src/console/assets/fonts/caprasimo-OFL.txt b/src/console/assets/fonts/caprasimo-OFL.txt
new file mode 100644
index 00000000..eec5a378
--- /dev/null
+++ b/src/console/assets/fonts/caprasimo-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2023 The Caprasimo Project Authors (https://github.com/docrepair-fonts/caprasimo-fonts).
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/fonts/inter-100-900-latin.woff2 b/src/console/assets/fonts/inter-100-900-latin.woff2
new file mode 100644
index 00000000..d15208de
Binary files /dev/null and b/src/console/assets/fonts/inter-100-900-latin.woff2 differ
diff --git a/src/console/assets/fonts/inter-OFL.txt b/src/console/assets/fonts/inter-OFL.txt
new file mode 100644
index 00000000..21f6aff9
--- /dev/null
+++ b/src/console/assets/fonts/inter-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/fonts/jetbrainsmono-100-800-latin.woff2 b/src/console/assets/fonts/jetbrainsmono-100-800-latin.woff2
new file mode 100644
index 00000000..cd5102a4
Binary files /dev/null and b/src/console/assets/fonts/jetbrainsmono-100-800-latin.woff2 differ
diff --git a/src/console/assets/fonts/jetbrainsmono-OFL.txt b/src/console/assets/fonts/jetbrainsmono-OFL.txt
new file mode 100644
index 00000000..821a3dac
--- /dev/null
+++ b/src/console/assets/fonts/jetbrainsmono-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/fonts/nunito-200-1000-latin.woff2 b/src/console/assets/fonts/nunito-200-1000-latin.woff2
new file mode 100644
index 00000000..3e909a16
Binary files /dev/null and b/src/console/assets/fonts/nunito-200-1000-latin.woff2 differ
diff --git a/src/console/assets/fonts/nunito-OFL.txt b/src/console/assets/fonts/nunito-OFL.txt
new file mode 100644
index 00000000..c8210f08
--- /dev/null
+++ b/src/console/assets/fonts/nunito-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/fonts/titilliumweb-400-latin.woff2 b/src/console/assets/fonts/titilliumweb-400-latin.woff2
new file mode 100644
index 00000000..47501963
Binary files /dev/null and b/src/console/assets/fonts/titilliumweb-400-latin.woff2 differ
diff --git a/src/console/assets/fonts/titilliumweb-600-latin.woff2 b/src/console/assets/fonts/titilliumweb-600-latin.woff2
new file mode 100644
index 00000000..ee2c4bbb
Binary files /dev/null and b/src/console/assets/fonts/titilliumweb-600-latin.woff2 differ
diff --git a/src/console/assets/fonts/titilliumweb-700-latin.woff2 b/src/console/assets/fonts/titilliumweb-700-latin.woff2
new file mode 100644
index 00000000..8672f69d
Binary files /dev/null and b/src/console/assets/fonts/titilliumweb-700-latin.woff2 differ
diff --git a/src/console/assets/fonts/titilliumweb-OFL.txt b/src/console/assets/fonts/titilliumweb-OFL.txt
new file mode 100644
index 00000000..4a44b9b3
--- /dev/null
+++ b/src/console/assets/fonts/titilliumweb-OFL.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2009-2011 by Accademia di Belle Arti di Urbino and students of MA course of Visual design. Some rights reserved.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/console/assets/logs.js b/src/console/assets/logs.js
index ee388ee7..e7dde82c 100644
--- a/src/console/assets/logs.js
+++ b/src/console/assets/logs.js
@@ -1,3 +1,4 @@
+import { activity } from './look.js';
const content = document.querySelector("#log-content");
const follow = document.querySelector("#follow-log");
const status = document.querySelector("#log-status");
@@ -22,6 +23,8 @@ stream.addEventListener("lines", event => {
scroll();
}
status.textContent = batch.done ? "Complete" : "Live";
+ content.dataset.live = String(!batch.done);
+ activity();
if (batch.done) stream.close();
});
stream.addEventListener("log-error", event => { status.textContent = event.data; stream.close(); });
diff --git a/src/console/assets/look.js b/src/console/assets/look.js
new file mode 100644
index 00000000..87e02cea
--- /dev/null
+++ b/src/console/assets/look.js
@@ -0,0 +1,94 @@
+// The machine owns its look; only Pause motion belongs to this browser.
+const root = document.documentElement;
+const themes = document.querySelector('#themes');
+const sections = document.querySelector('#sections-chooser');
+const message = document.querySelector('#look-status');
+const pause = document.querySelector('#pause-motion');
+const reduced = matchMedia('(prefers-reduced-motion: reduce)');
+let csrf;
+let revision = 0;
+let saving = false;
+let paused = false;
+try { paused = localStorage.getItem('thirdshift:console:pause-motion') === 'true'; } catch { /* optional storage */ }
+
+export function motionAllowed() { return !paused && !reduced.matches; }
+function motion() {
+ root.dataset.paused = String(paused);
+ pause?.setAttribute('aria-pressed', String(paused));
+ if (!motionAllowed()) for (const animation of document.getAnimations()) animation.cancel();
+}
+pause?.addEventListener('click', () => {
+ paused = !paused;
+ try { localStorage.setItem('thirdshift:console:pause-motion', String(paused)); } catch { /* optional storage */ }
+ motion();
+});
+reduced.addEventListener('change', motion);
+motion();
+
+export function activity() {
+ root.dataset.live = String(!!document.querySelector('.command[data-live="true"], #log-content[data-live="true"]'));
+}
+
+function apply(settings) {
+ root.dataset.theme = settings.theme;
+ for (const section of document.querySelectorAll('#inbox > details[data-group]')) {
+ section.hidden = settings.hidden_sections.includes(section.dataset.group.replaceAll(' ', '-'));
+ }
+ if (!sections.open) for (const checkbox of document.querySelectorAll('#section-options input')) checkbox.checked = !settings.hidden_sections.includes(checkbox.value);
+ if (!themes.open) document.querySelector('#theme-name').value = settings.theme;
+ activity();
+}
+
+export async function loadLook() {
+ if (!themes || saving) return;
+ const requestedRevision = revision;
+ try {
+ const response = await fetch('/look', { cache: 'no-store' });
+ if (!response.ok || response.redirected) throw new Error('Look unavailable');
+ const settings = await response.json();
+ if (requestedRevision !== revision || saving) return;
+ csrf = settings.csrf;
+ const picker = document.querySelector('#theme-name');
+ if (!picker.options.length) {
+ for (const name of settings.themes) picker.add(new Option(name.replace('-', ' · '), name));
+ for (const name of settings.sections) {
+ const label = document.createElement('label');
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox'; checkbox.value = name;
+ label.append(checkbox, ` Show ${name.replaceAll('-', ' ').replace(/^./, c => c.toUpperCase())}`);
+ document.querySelector('#section-options').append(label);
+ }
+ }
+ apply(settings);
+ } catch { message.textContent = 'Could not load the Console look. Reload to try again.'; }
+}
+
+async function save(path, values) {
+ revision++;
+ saving = true;
+ message.textContent = '';
+ try {
+ const response = await fetch(path, { method: 'POST', headers: { 'x-csrf-token': csrf }, body: new URLSearchParams(values) });
+ if (!response.ok || response.redirected) throw new Error('Save failed');
+ apply(await response.json());
+ message.textContent = 'Console look saved.';
+ } catch { message.textContent = 'Could not save the Console look. Reload to try again.'; }
+ finally { saving = false; }
+}
+
+if (themes) {
+ document.querySelector('[data-open-themes]').addEventListener('click', async () => { await loadLook(); themes.showModal(); });
+ document.querySelector('[data-close-themes]').addEventListener('click', () => themes.close());
+ document.querySelector('[data-open-sections]').addEventListener('click', async () => { await loadLook(); sections.showModal(); });
+ document.querySelector('[data-close-sections]').addEventListener('click', () => sections.close());
+ document.querySelector('#theme-form').addEventListener('submit', event => {
+ event.preventDefault(); save('/theme', { theme: document.querySelector('#theme-name').value });
+ });
+ document.querySelector('#sections-form').addEventListener('submit', event => {
+ event.preventDefault();
+ const options = [...document.querySelectorAll('#section-options input')];
+ save('/sections', { hide: options.filter(c => !c.checked).map(c => c.value).join(','), show: options.filter(c => c.checked).map(c => c.value).join(',') });
+ });
+ loadLook();
+}
+activity();
diff --git a/src/console/assets/stop.js b/src/console/assets/stop.js
index e0e8b00e..7d2027d3 100644
--- a/src/console/assets/stop.js
+++ b/src/console/assets/stop.js
@@ -1,3 +1,4 @@
+import './look.js';
import { watchCommands } from './changes.js';
watchCommands(async () => {
diff --git a/src/console/assets/themes.css b/src/console/assets/themes.css
new file mode 100644
index 00000000..a4e26ad2
--- /dev/null
+++ b/src/console/assets/themes.css
@@ -0,0 +1,320 @@
+@font-face { font-family: "Big Shoulders Display"; src: url("/assets/fonts/bigshouldersdisplay-100-900-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
+@font-face { font-family: "JetBrains Mono"; src: url("/assets/fonts/jetbrainsmono-100-800-latin.woff2") format("woff2"); font-weight: 100 800; font-display: swap; }
+@font-face { font-family: "Inter"; src: url("/assets/fonts/inter-100-900-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
+@font-face { font-family: "Caprasimo"; src: url("/assets/fonts/caprasimo-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; }
+@font-face { font-family: "Nunito"; src: url("/assets/fonts/nunito-200-1000-latin.woff2") format("woff2"); font-weight: 200 1000; font-display: swap; }
+@font-face { font-family: "Barlow Condensed"; src: url("/assets/fonts/barlowcondensed-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; }
+@font-face { font-family: "Barlow Condensed"; src: url("/assets/fonts/barlowcondensed-600-latin.woff2") format("woff2"); font-weight: 600; font-display: swap; }
+@font-face { font-family: "Barlow Condensed"; src: url("/assets/fonts/barlowcondensed-700-latin.woff2") format("woff2"); font-weight: 700; font-display: swap; }
+@font-face { font-family: "Titillium Web"; src: url("/assets/fonts/titilliumweb-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; }
+@font-face { font-family: "Titillium Web"; src: url("/assets/fonts/titilliumweb-600-latin.woff2") format("woff2"); font-weight: 600; font-display: swap; }
+@font-face { font-family: "Titillium Web"; src: url("/assets/fonts/titilliumweb-700-latin.woff2") format("woff2"); font-weight: 700; font-display: swap; }
+@font-face { font-family: "Atkinson Hyperlegible"; src: url("/assets/fonts/atkinsonhyperlegible-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; }
+@font-face { font-family: "Atkinson Hyperlegible"; src: url("/assets/fonts/atkinsonhyperlegible-700-latin.woff2") format("woff2"); font-weight: 700; font-display: swap; }
+
+/* Adapted from the approved #711 prototype (05b1b0a). Tokens only; layout lives in console.css. */
+:root { color-scheme: light dark; --activity-motion: none; }
+:root[data-theme$="-light"] { color-scheme: light; }
+:root[data-theme$="-dark"] { color-scheme: dark; }
+:root[data-theme^="focus-"] {
+ --bg: light-dark(#F7F7F5, #121214);
+ --panel: light-dark(#FFFFFF, #19191C);
+ --panel-2: light-dark(#F0F0EE, #212125);
+ --sel-bg: light-dark(#EEEEEB, #2A2A2F);
+ --text: light-dark(#18181A, #E8E8EA);
+ --head: light-dark(#18181A, #E8E8EA);
+ --dim: light-dark(#6B6B70, #A0A0A8);
+ --rule: light-dark(#E3E3E0, #2C2C31);
+ --link: light-dark(#18181A, #E8E8EA);
+ --urgent: light-dark(#D7263D, #FF6B78);
+ --urgent-text: light-dark(#9A1424, #FFB0B5);
+ --look: light-dark(#B06800, #FFB020);
+ --look-text: light-dark(#7E4C00, #FFB52E);
+ --working: light-dark(#5E5E63, #A8A8B0);
+ --working-text: light-dark(#4A4A4F, #B4B4BC);
+ --landing: light-dark(#6B6B70, #9A9AA2);
+ --ready: light-dark(#3F3F44, #C4C4CA);
+ --ready-text: light-dark(#3F3F44, #C4C4CA);
+ --done: light-dark(#6B6B70, #9A9AA2);
+ --on-urgent: light-dark(#FFFFFF, #000000);
+ --on-look: light-dark(#000000, #000000);
+ --on-working: light-dark(#FFFFFF, #000000);
+ --on-landing: light-dark(#FFFFFF, #000000);
+ --on-ready: light-dark(#FFFFFF, #000000);
+ --on-done: light-dark(#FFFFFF, #000000);
+ --font-head: "IBM Plex Sans", sans-serif;
+ --font-body: "IBM Plex Sans", sans-serif;
+ --font-mono: "IBM Plex Mono", monospace;
+ --body-size: 14px;
+ --head-weight: 600;
+ --head-case: none;
+ --radius: 4px;
+ --tag-urgent-bg: transparent;
+ --tag-urgent-text: var(--urgent-text);
+ --tag-look-bg: transparent;
+ --tag-look-text: var(--text);
+ --tag-working-bg: transparent;
+ --tag-working-text: var(--text);
+ --tag-landing-bg: transparent;
+ --tag-landing-text: var(--text);
+ --tag-ready-bg: transparent;
+ --tag-ready-text: var(--text);
+ --tag-done-bg: transparent;
+ --tag-done-text: var(--text);
+ --activity-decoration: none;
+ --activity-speed: 2s;
+ --panel-shadow: none;
+ --title-shadow: none;
+}
+:root[data-theme^="plain-"] {
+ --bg: light-dark(#FCFCFB, #111214);
+ --panel: light-dark(#FFFFFF, #15171A);
+ --panel-2: light-dark(#F3F4F6, #1C1F23);
+ --sel-bg: light-dark(#EEF3FD, #17223A);
+ --text: light-dark(#1C1D1F, #E6E7E9);
+ --head: light-dark(#62676E, #9096A0);
+ --dim: light-dark(#62676E, #9096A0);
+ --rule: light-dark(#E4E6E9, #26292E);
+ --link: light-dark(#1C1D1F, #E6E7E9);
+ --urgent: light-dark(#C4262E, #FF6B6B);
+ --urgent-text: light-dark(#C4262E, #FF6B6B);
+ --look: light-dark(#9A5B00, #F0B45A);
+ --look-text: light-dark(#9A5B00, #F0B45A);
+ --working: light-dark(#1F6FEB, #6AA8FF);
+ --working-text: light-dark(#1A5FCC, #6AA8FF);
+ --landing: light-dark(#7B4FD4, #B39CFF);
+ --ready: light-dark(#1A7F37, #57C97A);
+ --ready-text: light-dark(#1A7F37, #57C97A);
+ --done: light-dark(#62676E, #9096A0);
+ --on-urgent: light-dark(#FFFFFF, #000000);
+ --on-look: light-dark(#FFFFFF, #000000);
+ --on-working: light-dark(#FFFFFF, #000000);
+ --on-landing: light-dark(#FFFFFF, #000000);
+ --on-ready: light-dark(#FFFFFF, #000000);
+ --on-done: light-dark(#FFFFFF, #000000);
+ --font-head: "Inter", sans-serif;
+ --font-body: "Inter", sans-serif;
+ --font-mono: "JetBrains Mono", monospace;
+ --body-size: 13.5px;
+ --head-weight: 600;
+ --head-case: uppercase;
+ --radius: 6px;
+ --tag-urgent-bg: transparent;
+ --tag-urgent-text: var(--urgent-text);
+ --tag-look-bg: transparent;
+ --tag-look-text: var(--text);
+ --tag-working-bg: transparent;
+ --tag-working-text: var(--text);
+ --tag-landing-bg: transparent;
+ --tag-landing-text: var(--text);
+ --tag-ready-bg: transparent;
+ --tag-ready-text: var(--text);
+ --tag-done-bg: transparent;
+ --tag-done-text: var(--text);
+ --activity-decoration: none;
+ --activity-speed: 2s;
+ --panel-shadow: none;
+ --title-shadow: none;
+}
+:root[data-theme^="press-"] {
+ --bg: light-dark(#E8E6E1, #0A0A0A);
+ --panel: light-dark(#F4F2EE, #141414);
+ --panel-2: light-dark(#DCD9D2, #1E1E1E);
+ --sel-bg: light-dark(#DCD9D2, #1E1E1E);
+ --text: light-dark(#111111, #E8E6E1);
+ --head: light-dark(#111111, #E8E6E1);
+ --dim: light-dark(#55514A, #9A978F);
+ --rule: light-dark(#C6C1B6, #2A2A2A);
+ --link: light-dark(#005F86, #00AEEF);
+ --urgent: light-dark(#EC008C, #EC008C);
+ --urgent-text: light-dark(#A80064, #FF5CB8);
+ --look: light-dark(#FFF200, #FFF200);
+ --look-text: light-dark(#615500, #FFF200);
+ --working: light-dark(#00AEEF, #00AEEF);
+ --working-text: light-dark(#005A80, #00AEEF);
+ --landing: light-dark(#00AEEF, #00AEEF);
+ --ready: light-dark(#111111, #E8E6E1);
+ --ready-text: light-dark(#111111, #E8E6E1);
+ --done: light-dark(#68645C, #9A978F);
+ --on-urgent: light-dark(#000000, #000000);
+ --on-look: light-dark(#000000, #000000);
+ --on-working: light-dark(#000000, #000000);
+ --on-landing: light-dark(#000000, #000000);
+ --on-ready: light-dark(#FFFFFF, #000000);
+ --on-done: light-dark(#FFFFFF, #000000);
+ --font-head: "Big Shoulders Display", sans-serif;
+ --font-body: "JetBrains Mono", sans-serif;
+ --font-mono: "JetBrains Mono", monospace;
+ --body-size: 13px;
+ --head-weight: 600;
+ --head-case: none;
+ --radius: 0px;
+ --tag-urgent-bg: var(--urgent);
+ --tag-urgent-text: var(--on-urgent);
+ --tag-look-bg: var(--look);
+ --tag-look-text: var(--on-look);
+ --tag-working-bg: var(--working);
+ --tag-working-text: var(--on-working);
+ --tag-landing-bg: var(--landing);
+ --tag-landing-text: var(--on-landing);
+ --tag-ready-bg: var(--ready);
+ --tag-ready-text: var(--on-ready);
+ --tag-done-bg: var(--done);
+ --tag-done-text: var(--on-done);
+ --activity-decoration: none;
+ --activity-speed: 2s;
+ --panel-shadow: none;
+ --title-shadow: 1px 0 var(--urgent);
+}
+:root[data-theme^="signal-"] {
+ --bg: light-dark(#FAFAFA, #111315);
+ --panel: light-dark(#FFFFFF, #181B1E);
+ --panel-2: light-dark(#F1F2F4, #20242A);
+ --sel-bg: light-dark(#ECEDEF, #262B31);
+ --text: light-dark(#111316, #ECEEF0);
+ --head: light-dark(#111316, #ECEEF0);
+ --dim: light-dark(#4F555C, #A4AAB1);
+ --rule: light-dark(#DDE0E3, #2C3137);
+ --link: light-dark(#111316, #ECEEF0);
+ --urgent: light-dark(#D55E00, #FF7A33);
+ --urgent-text: light-dark(#B3470A, #FF8A4C);
+ --look: light-dark(#E69F00, #F0B429);
+ --look-text: light-dark(#7A4E00, #F2B53A);
+ --working: light-dark(#0072B2, #3FA2E0);
+ --working-text: light-dark(#00609A, #62B8F0);
+ --landing: light-dark(#9B3F76, #D97DB6);
+ --ready: light-dark(#009E73, #2CC79A);
+ --ready-text: light-dark(#00694A, #3CCB9E);
+ --done: light-dark(#5A6067, #9AA0A6);
+ --on-urgent: light-dark(#000000, #000000);
+ --on-look: light-dark(#000000, #000000);
+ --on-working: light-dark(#FFFFFF, #000000);
+ --on-landing: light-dark(#FFFFFF, #000000);
+ --on-ready: light-dark(#000000, #000000);
+ --on-done: light-dark(#FFFFFF, #000000);
+ --font-head: "Atkinson Hyperlegible", sans-serif;
+ --font-body: "Atkinson Hyperlegible", sans-serif;
+ --font-mono: "IBM Plex Mono", monospace;
+ --body-size: 14.5px;
+ --head-weight: 700;
+ --head-case: none;
+ --radius: 4px;
+ --tag-urgent-bg: var(--urgent);
+ --tag-urgent-text: var(--on-urgent);
+ --tag-look-bg: var(--look);
+ --tag-look-text: var(--on-look);
+ --tag-working-bg: var(--working);
+ --tag-working-text: var(--on-working);
+ --tag-landing-bg: var(--landing);
+ --tag-landing-text: var(--on-landing);
+ --tag-ready-bg: var(--ready);
+ --tag-ready-text: var(--on-ready);
+ --tag-done-bg: var(--done);
+ --tag-done-text: var(--on-done);
+ --activity-decoration: none;
+ --activity-speed: 2s;
+ --panel-shadow: none;
+ --title-shadow: none;
+}
+:root[data-theme^="garden-"] {
+ --bg: light-dark(#F6F0E1, #161A14);
+ --panel: light-dark(#FBF7EC, #1B2018);
+ --panel-2: light-dark(#EFE6CF, #1E241B);
+ --sel-bg: light-dark(#E6EEDA, #233020);
+ --text: light-dark(#2E2A1F, #E8E2CF);
+ --head: light-dark(#2E2A1F, #E8E2CF);
+ --dim: light-dark(#6B6250, #A59D84);
+ --rule: light-dark(#D9CCAA, #36402F);
+ --link: light-dark(#2F6B2A, #9BD480);
+ --urgent: light-dark(#A23B2A, #EC7A66);
+ --urgent-text: light-dark(#A23B2A, #EC7A66);
+ --look: light-dark(#F2C14E, #F2C14E);
+ --look-text: light-dark(#7A5200, #F2C14E);
+ --working: light-dark(#4F8A3C, #6FAE58);
+ --working-text: light-dark(#2F6B2A, #8BC970);
+ --landing: light-dark(#A0773F, #C39A5A);
+ --ready: light-dark(#5B8FB9, #8EC1E6);
+ --ready-text: light-dark(#2F5F86, #8EC1E6);
+ --done: light-dark(#6B6250, #A59D84);
+ --on-urgent: light-dark(#FFFFFF, #000000);
+ --on-look: light-dark(#000000, #000000);
+ --on-working: light-dark(#000000, #000000);
+ --on-landing: light-dark(#000000, #000000);
+ --on-ready: light-dark(#000000, #000000);
+ --on-done: light-dark(#FFFFFF, #000000);
+ --font-head: "Caprasimo", sans-serif;
+ --font-body: "Nunito", sans-serif;
+ --font-mono: "JetBrains Mono", monospace;
+ --body-size: 14px;
+ --head-weight: 400;
+ --head-case: none;
+ --radius: 8px;
+ --tag-urgent-bg: var(--urgent);
+ --tag-urgent-text: var(--on-urgent);
+ --tag-look-bg: var(--look);
+ --tag-look-text: var(--on-look);
+ --tag-working-bg: var(--working);
+ --tag-working-text: var(--on-working);
+ --tag-landing-bg: var(--landing);
+ --tag-landing-text: var(--on-landing);
+ --tag-ready-bg: var(--ready);
+ --tag-ready-text: var(--on-ready);
+ --tag-done-bg: var(--done);
+ --tag-done-text: var(--on-done);
+ --activity-decoration: repeating-linear-gradient(90deg, var(--working) 0 6px, transparent 6px 12px);
+ --activity-speed: 5s;
+ --activity-motion: activity-drift var(--activity-speed) linear infinite;
+ --panel-shadow: none;
+ --title-shadow: none;
+}
+:root[data-theme^="factory-"] {
+ --bg: light-dark(#E3E6E8, #1C1D1F);
+ --panel: light-dark(#EFF1F3, #2F3033);
+ --panel-2: light-dark(#E6E9EB, #3D3F42);
+ --sel-bg: light-dark(#F6F7F8, #3B3E43);
+ --text: light-dark(#1B1D1F, #E3E3E3);
+ --head: light-dark(#1B1D1F, #FFE6C0);
+ --dim: light-dark(#3E4246, #B0B3B8);
+ --rule: light-dark(#8E9397, #55575B);
+ --link: light-dark(#6A3A00, #FFA200);
+ --urgent: light-dark(#A8261C, #E03434);
+ --urgent-text: light-dark(#7E1912, #FF9288);
+ --look: light-dark(#E8B33A, #E8B33A);
+ --look-text: light-dark(#4F3B00, #F2C14E);
+ --working: light-dark(#2E7D32, #5EB663);
+ --working-text: light-dark(#164A1A, #7FD07F);
+ --landing: light-dark(#E08A00, #FFA200);
+ --ready: light-dark(#1F6FA8, #4AA8E5);
+ --ready-text: light-dark(#0E4A74, #5FB4EA);
+ --done: light-dark(#3C4044, #8F9195);
+ --on-urgent: light-dark(#FFFFFF, #000000);
+ --on-look: light-dark(#000000, #000000);
+ --on-working: light-dark(#FFFFFF, #000000);
+ --on-landing: light-dark(#000000, #000000);
+ --on-ready: light-dark(#FFFFFF, #000000);
+ --on-done: light-dark(#FFFFFF, #000000);
+ --font-head: "Barlow Condensed", sans-serif;
+ --font-body: "Titillium Web", sans-serif;
+ --font-mono: "JetBrains Mono", monospace;
+ --body-size: 14px;
+ --head-weight: 700;
+ --head-case: uppercase;
+ --radius: 2px;
+ --tag-urgent-bg: var(--urgent);
+ --tag-urgent-text: var(--on-urgent);
+ --tag-look-bg: var(--look);
+ --tag-look-text: var(--on-look);
+ --tag-working-bg: var(--working);
+ --tag-working-text: var(--on-working);
+ --tag-landing-bg: var(--landing);
+ --tag-landing-text: var(--on-landing);
+ --tag-ready-bg: var(--ready);
+ --tag-ready-text: var(--on-ready);
+ --tag-done-bg: var(--done);
+ --tag-done-text: var(--on-done);
+ --activity-decoration: repeating-linear-gradient(90deg, var(--working) 0 6px, transparent 6px 12px);
+ --activity-speed: 2s;
+ --panel-shadow: inset 0 1px #ffffff33, inset 0 -1px #00000033;
+ --activity-motion: activity-drift var(--activity-speed) linear infinite;
+ --title-shadow: none;
+}
diff --git a/src/console/chrome.html b/src/console/chrome.html
index d7b63b73..efda6ca1 100644
--- a/src/console/chrome.html
+++ b/src/console/chrome.html
@@ -1,5 +1,15 @@
-
+
+
+
diff --git a/src/console/inbox.html b/src/console/inbox.html
index 8df1510b..9193c260 100644
--- a/src/console/inbox.html
+++ b/src/console/inbox.html
@@ -40,7 +40,7 @@
{% when None %}
{% match inbox.warning %}{% when Some with (warning) %}
Warning: {{ warning }}
{% when None %}{% endmatch %}
{% for section in inbox.sections %}
-
+ {{ section.title }}{{ section.rows.len() }}
{% if section.rows.is_empty() && section.references.is_empty() %}