diff --git a/Cargo.lock b/Cargo.lock index 7c5c9a24..a6f32db7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper", diff --git a/Cargo.toml b/Cargo.toml index 80a69530..b29cee78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ include = ["/src", "/skills", "/askama.toml", "/README.md", "/LICENSE"] anyhow = "1.0.104" argon2 = "0.5.3" askama = "0.16.1" -axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "form"] } +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "form", "json"] } # axoupdater needs axoasset's `remote` feature, which always pulls in `image` # (with moxcms and pxfm) and turns on reqwest's `default-tls`, which is rustls # with aws-lc. No flag turns either off, and features unify, so switching diff --git a/console/package-lock.json b/console/package-lock.json index 0e519e65..6ed5c888 100644 --- a/console/package-lock.json +++ b/console/package-lock.json @@ -5,9 +5,27 @@ "packages": { "": { "devDependencies": { + "apca-w3": "0.1.9", "playwright": "1.64.0" } }, + "node_modules/apca-w3": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/apca-w3/-/apca-w3-0.1.9.tgz", + "integrity": "sha512-Zrf6AeBeQjNe/fxK7U1jCo5zfdjDl6T4/kdw5Xlky3G7u+EJTZkyItjMYQGtwf9pkftsINxcYyOpuLkzKf1ITQ==", + "dev": true, + "license": "Limited W3 License", + "dependencies": { + "colorparsley": "^0.1.8" + } + }, + "node_modules/colorparsley": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/colorparsley/-/colorparsley-0.1.8.tgz", + "integrity": "sha512-rObESTTTE6G5qO5WFwFxWPggpw4KfpxnLC6Ssl8bITBnNVRhDsyCeTRAUxWQNVTx2pRknKlO2nddYTxjkdNFaw==", + "dev": true, + "license": "AGPL v3" + }, "node_modules/playwright": { "version": "1.64.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", diff --git a/console/package.json b/console/package.json index 79602d45..b8399e53 100644 --- a/console/package.json +++ b/console/package.json @@ -1,5 +1,10 @@ { "private": true, - "scripts": { "test": "node --test tests/*.test.cjs" }, - "devDependencies": { "playwright": "1.64.0" } + "scripts": { + "test": "node --test tests/*.test.cjs" + }, + "devDependencies": { + "apca-w3": "0.1.9", + "playwright": "1.64.0" + } } diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index a1629d63..69c3e814 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -64,6 +64,106 @@ async function pageFor(t, options) { return page; } +test("Shift+T switches all built-in Themes and saves hidden sections across browsers", async t => { + const page = await pageFor(t); + const remote = []; + page.on('request', request => { if (!request.url().startsWith(origin)) remote.push(request.url()); }); + await page.goto(origin); + await page.keyboard.press('Shift+T'); + await page.locator('#themes[open]').waitFor({ timeout: 2000 }); + assert.equal(await page.locator('#look-status').textContent(), ''); + try { + for (const family of ['focus', 'plain', 'press', 'signal', 'garden', 'factory']) { + for (const mode of ['auto', 'light', 'dark']) { + const name = `${family}-${mode}`; + await page.selectOption('#theme-name', name); + const saved = page.waitForResponse(response => response.url() === `${origin}/theme`); + await page.getByRole('button', { name: 'Apply Theme', exact: true }).click(); + const response = await saved; + assert.equal(response.status(), 200, await response.text()); + assert.equal((await response.json()).theme, name); + await page.waitForFunction(name => document.documentElement.dataset.theme === name, name); + assert.match(await readFile(join(home, '.thirdshift/config.toml'), 'utf8'), new RegExp(`theme = "${name}"`)); + await page.evaluate(() => document.fonts.ready); + assert.equal(await page.evaluate(() => [...document.fonts].filter(f => f.status === 'error').length), 0); + } + } + await page.getByRole('button', { name: 'Close Themes' }).click(); + await page.getByRole('button', { name: 'Sections', exact: true }).click(); + await page.getByLabel('Show Done', { exact: true }).uncheck(); + await page.getByRole('button', { name: 'Save sections' }).click(); + await page.locator('[data-group="done"][hidden]').waitFor({ state: 'attached' }); + const other = await pageFor(t); + await other.goto(origin); + await other.locator('[data-group="done"][hidden]').waitFor({ state: 'attached' }); + assert.equal(await other.locator('html').getAttribute('data-theme'), 'factory-dark'); + assert.deepEqual(remote, [], 'Themes must load fonts and assets locally'); + } finally { await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); } +}); + +test("every built-in Theme text pair meets AA in both system modes, including solid tags", async t => { + const page = await pageFor(t); + const failures = []; + const { APCAcontrast, sRGBtoY } = await import('apca-w3'); + const luminance = rgb => rgb.map(v => v / 255).map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0); + const ratio = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05); + await page.goto(origin); + const backgrounds = { focus: ['rgb(247, 247, 245)', 'rgb(18, 18, 20)'], plain: ['rgb(252, 252, 251)', 'rgb(17, 18, 20)'], press: ['rgb(232, 230, 225)', 'rgb(10, 10, 10)'], signal: ['rgb(250, 250, 250)', 'rgb(17, 19, 21)'], garden: ['rgb(246, 240, 225)', 'rgb(22, 26, 20)'], factory: ['rgb(227, 230, 232)', 'rgb(28, 29, 31)'] }; + for (const family of ['focus', 'plain', 'press', 'signal', 'garden', 'factory']) { + for (const mode of ['auto', 'light', 'dark']) { + for (const system of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: system }); + const theme = `${family}-${mode}`; + const { pairs, background } = await page.evaluate(theme => { + document.documentElement.dataset.theme = theme; + const probe = document.createElement('span'); + document.body.append(probe); + const color = token => { + probe.style.color = `var(--${token})`; + return getComputedStyle(probe).color.match(/[\d.]+/g).slice(0, 3).map(Number); + }; + const pairs = []; + for (const background of ['bg', 'panel', 'panel-2', 'sel-bg']) { + for (const text of ['text', 'head', 'dim', 'link', 'urgent-text', 'look-text', 'working-text', 'ready-text']) { + pairs.push({ name: `${text} on ${background}`, text: color(text), background: color(background) }); + } + } + for (const state of ['urgent', 'look', 'working', 'landing', 'ready', 'done']) { + pairs.push({ name: `on-${state}`, text: color(`on-${state}`), background: color(state) }); + } + probe.remove(); + const texts = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + while (texts.nextNode()) { + const node = texts.currentNode; + if (!node.textContent.trim()) continue; + const range = document.createRange(); range.selectNode(node); + if (!range.getBoundingClientRect().width || !range.getBoundingClientRect().height) continue; + const style = getComputedStyle(node.parentElement); + let parent = node.parentElement; + while (parent && getComputedStyle(parent).backgroundColor === 'rgba(0, 0, 0, 0)') parent = parent.parentElement; + if (!parent) continue; + pairs.push({ name: `rendered ${node.textContent.trim().slice(0, 30)}`, text: style.color.match(/[\d.]+/g).slice(0, 3).map(Number), background: getComputedStyle(parent).backgroundColor.match(/[\d.]+/g).slice(0, 3).map(Number) }); + } + return { pairs, background: getComputedStyle(document.body).backgroundColor }; + }, theme); + assert.equal(background, backgrounds[family][(mode === 'auto' ? system : mode) === 'dark' ? 1 : 0], `${theme} must respect the chosen mode on ${system}`); + for (const pair of pairs) { + if (ratio(pair.text, pair.background) < 4.5) failures.push(`${theme} (${system}): ${pair.name} = ${ratio(pair.text, pair.background)}`); + if (family === 'focus' && !pair.name.startsWith('on-')) { + assert.ok(Math.abs(APCAcontrast(sRGBtoY(pair.text), sRGBtoY(pair.background))) >= 45, `${theme}: ${pair.name} below APCA Lc 45`); + } + if (pair.name.startsWith('on-')) { + const best = Math.max(ratio([0, 0, 0], pair.background), ratio([255, 255, 255], pair.background)); + assert.equal(ratio(pair.text, pair.background), best, `${theme}: ${pair.name} must use the better black or white`); + } + } + } + } + } + assert.deepEqual(failures, []); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0, 'idle Commands must not animate'); +}); + test("Inbox folds, loads only its own resources and fits a 390 px phone", async t => { const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); const remote = [], errors = []; @@ -296,20 +396,50 @@ test("heartbeat beats only for server-observed log growth and is flat when Silen await page.locator('[data-group="working"] > summary').click(); const row = page.locator('.command').filter({ has: page.locator('.subject', { hasText: /^acme\/widgets#202$/ }) }); const id = await row.getAttribute('id'); + await page.waitForFunction(id => document.getElementById(id).querySelector('.state').getAnimations({ subtree: true }).some(a => a.playState === 'running'), id, { timeout: 2000 }); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.reload(); + assert.equal(await page.getByRole('button', { name: 'Pause motion', exact: true }).getAttribute('aria-pressed'), 'true'); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); await appendFile(session, ' growth'); await page.waitForFunction(id => document.getElementById(id).querySelector('.heartbeat').getAnimations().some(animation => animation.playState === 'running'), id); await page.waitForFunction(id => document.getElementById(id).querySelector('.heartbeat').getAnimations().length === 0, id); + for (const family of ['garden', 'factory']) { + await writeFile(join(home, '.thirdshift/config.toml'), `[console]\nport = 0\ntheme = "${family}-auto"\n`); + await page.waitForFunction(theme => document.documentElement.dataset.theme === theme, `${family}-auto`); + assert.notEqual(await page.locator('header').evaluate(header => getComputedStyle(header, '::after').animationName), 'none'); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + await appendFile(session, ' paused growth'); + await page.waitForTimeout(350); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + } const old = new Date(Date.now() - 20 * 60 * 1000); await utimes(log, old, old); await utimes(session, old, old); await page.waitForFunction(id => document.getElementById(id).dataset.state === 'Silent', id); + assert.equal(await row.locator('.state').evaluate(state => getComputedStyle(state).animationIterationCount), '3'); assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); await page.emulateMedia({ reducedMotion: 'reduce' }); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); await appendFile(log, ' more'); await page.waitForFunction(id => document.getElementById(id).dataset.logging === 'true', id); assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); - } finally { await Promise.all([path, log, session].map(path => rm(path, { force: true }))); } + fixture.outcome = 'merged'; fixture.ended_at = new Date().toISOString(); + await writeFile(path, JSON.stringify(fixture)); + await page.waitForFunction(() => document.documentElement.dataset.live === 'false'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0, 'an ended Command must stop the garden and conveyor'); + } finally { + await Promise.all([path, log, session].map(path => rm(path, { force: true }))); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + } }); test("a push keeps a Pass dispatch selected instead of its parent", async t => { diff --git a/console/tests/stop.test.cjs b/console/tests/stop.test.cjs index 00e46861..66775673 100644 --- a/console/tests/stop.test.cjs +++ b/console/tests/stop.test.cjs @@ -55,7 +55,7 @@ for (const requeue of [false, true]) { await rm(request, { force: true }); await writeFile(path, JSON.stringify(record)); await writeFile(join(folder, '7-fixture.log'), 'Waiting for Stop\n'); - const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' }); + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: requeue ? 'reduce' : 'no-preference' }); t.after(() => context.close()); const page = await context.newPage(); const errors = []; @@ -68,7 +68,10 @@ for (const requeue of [false, true]) { // route directly to verify the live Command's action is available. await page.goto(`${origin}/?page=waterfall`); } - if (route === '/') await page.locator('[data-group="working"] > summary').click(); + if (route === '/') { + await page.locator('[data-group="working"] > summary').click(); + if (!requeue) await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + } await page.locator('.peek-link').first().click(); await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); } diff --git a/console/tests/themes-regressions.test.cjs b/console/tests/themes-regressions.test.cjs new file mode 100644 index 00000000..c89468ba --- /dev/null +++ b/console/tests/themes-regressions.test.cjs @@ -0,0 +1,82 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises'); +const { join, resolve } = require('node:path'); +const { tmpdir } = require('node:os'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); +const { test } = require('node:test'); +const fs = require('node:fs'); + +test('state tag aliases resolve at the command that supplies their state', async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const assets = resolve(__dirname, '../../src/console/assets'); + const css = fs.readFileSync(join(assets, 'themes.css'), 'utf8') + fs.readFileSync(join(assets, 'console.css'), 'utf8').replace('@import url("/assets/themes.css");', ''); + await page.setContent(`
  • Failed
  • `); + 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 @@ -
    +

    + +

    Themes

    thirdshift console theme <name>

    +
    + +
    + +

    Sections

    thirdshift console sections --hide done --show working

    +
    Visible Inbox sections
    + +

    Keyboard

    @@ -11,5 +21,6 @@

    Keyboard

    g, then a letter
    Jump to a section or page; g lists the letters
    z
    Fold the current section
    ?
    Show this help
    +

    Shift+T opens Themes. Sections and Pause motion are in the footer.

    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() %}

    Nothing here.

    {% endif %} {# Only the rendered, auto-escaped RowTemplate is trusted as HTML. #} diff --git a/src/console/look.rs b/src/console/look.rs new file mode 100644 index 00000000..6687940e --- /dev/null +++ b/src/console/look.rs @@ -0,0 +1,145 @@ +//! The CLI and authenticated page share the same restricted look writes. +use anyhow::{Context, Result, ensure}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Form, Json}; +use serde::Deserialize; +use serde_json::json; + +use super::accounts::{Accounts, EventKind}; +use super::auth::Viewer; +use crate::config::{ConsoleSettings, UserConfig, console}; + +fn write_theme(name: &str, actor: &str) -> Result { + console::validate_theme(name)?; + let accounts = Accounts::load()?; + let settings = console::write_look(Some(name), &[], &[])?; + accounts.record(actor, EventKind::ThemeChanged)?; + Ok(settings) +} + +fn write_sections(hide: &[String], show: &[String], actor: &str) -> Result { + console::validate_sections(hide)?; + console::validate_sections(show)?; + let accounts = Accounts::load()?; + let settings = console::write_look(None, hide, show)?; + accounts.record(actor, EventKind::SectionsChanged)?; + Ok(settings) +} + +fn cli_actor() -> Result { + let user = std::process::Command::new("id") + .arg("-un") + .output() + .context("could not identify the Unix account changing the Console look")?; + ensure!( + user.status.success(), + "could not identify the Unix account changing the Console look" + ); + Ok(format!("CLI:{}", String::from_utf8(user.stdout)?.trim())) +} + +pub fn theme(name: Option<&str>, json_output: bool) -> Result { + let Some(name) = name else { + return Ok(if json_output { + serde_json::to_string(&console::theme_names())? + } else { + console::theme_names().join("\n") + }); + }; + let settings = write_theme(name, &cli_actor()?)?; + Ok(if json_output { + serde_json::to_string(&settings)? + } else { + format!("Theme: {}", settings.theme) + }) +} + +pub fn sections(hide: &[String], show: &[String], json_output: bool) -> Result { + let settings = if hide.is_empty() && show.is_empty() { + UserConfig::load()?.console + } else { + write_sections(hide, show, &cli_actor()?)? + }; + Ok(if json_output { + serde_json::to_string(&settings)? + } else { + format!("Hidden sections: {}", settings.hidden_sections.join(", ")) + }) +} + +pub(super) async fn get(Extension(viewer): Extension) -> Response { + match tokio::task::spawn_blocking(UserConfig::load).await { + Ok(Ok(config)) => Json(json!({ + "theme": config.console.theme, + "hidden_sections": config.console.hidden_sections, + "themes": console::theme_names(), "sections": console::SECTIONS, + "csrf": viewer.csrf, + })) + .into_response(), + error => failed(error), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ThemeForm { + theme: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SectionsForm { + #[serde(default)] + hide: String, + #[serde(default)] + show: String, +} + +pub(super) async fn set_theme( + Extension(viewer): Extension, + Form(form): Form, +) -> Response { + if let Err(error) = console::validate_theme(&form.theme) { + return (StatusCode::BAD_REQUEST, error.to_string()).into_response(); + } + written(tokio::task::spawn_blocking(move || write_theme(&form.theme, &viewer.email)).await) +} + +pub(super) async fn set_sections( + Extension(viewer): Extension, + Form(form): Form, +) -> Response { + let split = |value: &str| { + value + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect::>() + }; + let (hide, show) = (split(&form.hide), split(&form.show)); + if let Err(error) = + console::validate_sections(&hide).and_then(|()| console::validate_sections(&show)) + { + return (StatusCode::BAD_REQUEST, error.to_string()).into_response(); + } + written(tokio::task::spawn_blocking(move || write_sections(&hide, &show, &viewer.email)).await) +} + +fn written( + result: std::result::Result, tokio::task::JoinError>, +) -> Response { + match result { + Ok(Ok(settings)) => Json(settings).into_response(), + error => failed(error), + } +} + +fn failed(error: impl std::fmt::Debug) -> Response { + eprintln!("thirdshift Console: look operation failed: {error:?}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't read or save the Console look.", + ) + .into_response() +} diff --git a/src/console/reader.html b/src/console/reader.html index c7eb3ce1..bc80d019 100644 --- a/src/console/reader.html +++ b/src/console/reader.html @@ -6,5 +6,5 @@

    Command details

    {% if log.live %}Live{% else %}Complete{% endif %}

    {# Only the conversation renderer's escaped HTML is trusted. #} -
    {{ html|safe }}
    +
    {{ html|safe }}
    {% include "console/chrome.html" %} diff --git a/src/console/row.html b/src/console/row.html index 7551504c..53bd88a2 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,4 +1,4 @@ -
  • +
  • {% if row.live %}{% endif %}{{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}

    {{ row.now_line }}

    Run and Session logs diff --git a/src/console/time.html b/src/console/time.html index 7d8d803a..8c6e0d1f 100644 --- a/src/console/time.html +++ b/src/console/time.html @@ -6,6 +6,7 @@ {{ title }} · {{ hostname }} · thirdshift + @@ -13,7 +14,7 @@ {% if !consoles.is_empty() %}{% endif %} {% include "console/nav.html" %} -
    +

    {{ title }}

    thirdshift status --all --json
    {% if title == "Lanes" %}
    @@ -26,13 +27,13 @@ {% for lane in views.lanes %}
    {% for run in lane %} -
    +

    {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    {% if run.stages.is_empty() %}

    No stage times retained.

    {% endif %} -
      {% for stage in run.stages %}
    1. {{ stage.name }}: {{ stage.text }}
    2. {% endfor %}
    +
      {% for stage in run.stages %}
    1. {{ stage.name }}: {{ stage.text }}
    2. {% endfor %}
    {% endfor %}
    @@ -43,8 +44,8 @@

    {{ run.subject }} · {{ r {% for grid in views.grids %} - {% for run in grid.runs %}{% endfor %} - {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %} + {% for run in grid.runs %}{% endfor %} + {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %}
    {{ grid.repository }}
    Stage
    Usual range
    {{ run.subject }}
    {{ run.state }}
    {{ run.title }}
    {{ row.name }}
    Usual range: {{ row.usual }}
    {{ grid.runs[loop.index0].subject }}: {{ cell }}
    Stage
    Usual range
    {{ run.subject }}
    {{ run.state }}
    {{ run.title }}
    {{ row.name }}
    Usual range: {{ row.usual }}
    {{ grid.runs[loop.index0].subject }}: {{ cell.text }}
    {% if grid.rows.is_empty() %}

    No stage times retained.

    {% endif %} {% endfor %} diff --git a/src/console/waterfall.html b/src/console/waterfall.html index 38e4e83d..8977c15d 100644 --- a/src/console/waterfall.html +++ b/src/console/waterfall.html @@ -6,6 +6,7 @@ Waterfall · {{ hostname }} · thirdshift + @@ -13,19 +14,19 @@ {% if !consoles.is_empty() %}{% endif %} {% include "console/nav.html" %} -
    +

    Waterfall

    thirdshift show <command> --json · thirdshift logs <command>
    {% if view.runs.is_empty() %}

    No Commands yet.

    {% else %}

    Shared time axis: {{ view.start }} to {{ view.end }}. Tool intervals run until the next matched call in that Session or the stage boundary; they include waiting and are not measured execution times. Unknown times have no bar.

    {% for run in view.runs %}
    -

    {{ run.kind }} · {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    +

    {{ run.kind }} · {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    {{ run.span.start }} · {{ run.span.text }}

    {% if run.stages.is_empty() %}

    No stage times retained.

    {% endif %}
      {% for stage in run.stages %} -
    1. {{ stage.name }}

      {{ stage.span.start }} · {{ stage.span.text }}

      +
    2. {{ stage.name }}

      {{ stage.span.start }} · {{ stage.span.text }}

        {% for tool in stage.tools %}
      1. {{ tool.label }}

        {{ tool.span.start }} · {{ tool.span.text }}

        diff --git a/src/logs/status/time.rs b/src/logs/status/time.rs index 297bbb5d..c719b188 100644 --- a/src/logs/status/time.rs +++ b/src/logs/status/time.rs @@ -32,6 +32,7 @@ pub(crate) struct Run { #[derive(Clone)] pub(crate) struct Stage { pub name: String, + pub running: bool, pub text: String, pub seconds: String, pub usual: String, @@ -48,7 +49,12 @@ pub(crate) struct Grid { pub(crate) struct GridRow { pub name: String, pub usual: String, - pub cells: Vec, + pub cells: Vec, +} + +pub(crate) struct Cell { + pub text: String, + pub running: bool, } fn timestamp(value: &str) -> Option { @@ -100,6 +106,7 @@ fn run(command: &Command, now: DateTime, cutoff: i64) -> Run { }); Stage { name: stage.name.clone(), + running: command.state == "live" && index + 1 == record.stages.len(), text: format!("{state} · {duration}"), seconds: seconds .map(|seconds| seconds.to_string()) @@ -237,18 +244,25 @@ pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { cells: batch .iter() .map(|run| { - let times: Vec<_> = run + let stages: Vec<_> = run .stages .iter() .filter(|stage| &stage.name == name) - .map(|stage| stage.text.as_str()) .collect(); - if !run.stage_metadata { + let text = if !run.stage_metadata { "Unknown · no stage times retained".into() - } else if times.is_empty() { + } else if stages.is_empty() { "Not reached · —".into() } else { - times.join("; ") + stages + .iter() + .map(|stage| stage.text.as_str()) + .collect::>() + .join("; ") + }; + Cell { + text, + running: stages.iter().any(|stage| stage.running), } }) .collect(), diff --git a/src/main.rs b/src/main.rs index a7469b8d..d64b53e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,8 @@ usage: thirdshift Run the factory on the iss thirdshift console password [--email
        ] Set a Console password privately thirdshift console sign-out [--email
        ] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log + thirdshift console theme [--json] Choose or list Console Themes + thirdshift console sections [--hide
        ] [--show
        ] [--json] Choose visible sections thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] [--json] Stop a live Command and wait for cleanup thirdshift logs [--command ] [--json] List this machine's logs @@ -150,6 +152,12 @@ fn main() -> ExitCode { }; } Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), + Ok(Command::ConsoleTheme { name, json }) => { + return printed(console::theme(name.as_deref(), json)); + } + Ok(Command::ConsoleSections { hide, show, json }) => { + return printed(console::sections(&hide, &show, json)); + } Ok(Command::ConsoleSignOut { email, json }) => { return match console::sign_out(email.as_deref(), json) { Ok(()) => ExitCode::SUCCESS, @@ -376,6 +384,17 @@ fn release_summary() -> ExitCode { } } +/// A Console settings view or action, with machine-readable output on stdout. +fn printed(result: anyhow::Result) -> ExitCode { + match result { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(error) => failure(&error), + } +} + /// The end of a command other than a Run: the line that says how it went, /// or its error, on stderr. fn outcome(result: anyhow::Result) -> ExitCode { diff --git a/src/setup.rs b/src/setup.rs index 90d351c6..2c1f185f 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1157,6 +1157,7 @@ port = 8711 allowed_emails = [] other_consoles = [] theme = \"focus-auto\" +hidden_sections = [] [activity] quiet_skips = true diff --git a/tests/console.rs b/tests/console.rs index e2aae349..fa263fcc 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -15,6 +15,9 @@ use serde_json::{Value, json}; #[path = "console/accounts.rs"] mod accounts; +#[path = "console/look.rs"] +mod look; + struct Console { child: Child, address: String, diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index f4337cc1..49f5e6a3 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -158,6 +158,7 @@ fn sign_in_guards_pages_streams_and_actions_and_pairs_the_console_log_with_cli() "/events", "/console-log", "/account", + "/look", ] { assert_eq!( get(&console, path, "").status(), @@ -268,6 +269,65 @@ fn sign_in_guards_pages_streams_and_actions_and_pairs_the_console_log_with_cli() assert_eq!(get(&console, "/", &session).status(), 303); } +#[test] +fn signed_in_look_writes_name_the_account_and_keep_access_settings() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let look: Value = get(&console, "/look", &session).json().unwrap(); + for (path, fields) in [ + ("/theme", vec![("theme", "signal-light")]), + ("/sections", vec![("hide", "done")]), + ] { + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .header("cookie", &session) + .form(&fields) + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .header("cookie", &session) + .header("x-csrf-token", look["csrf"].as_str().unwrap()) + .form(&fields) + .send() + .unwrap() + .status(), + 200 + ); + } + let output = cli(home.path(), &["console", "log", "--json"]); + let events: Vec = serde_json::from_slice(&output.stdout).unwrap(); + for kind in ["theme-changed", "sections-changed"] { + let changed: Vec<_> = events + .iter() + .filter(|event| event["event"] == kind) + .collect(); + assert_eq!(changed.len(), 1); + assert_eq!(changed[0]["email"], EMAIL); + } + assert_eq!(get(&console, "/", &session).status(), 200); + let config: Value = serde_json::to_value( + toml::from_str::( + &fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(config["console"]["allowed_emails"], json!([EMAIL])); + assert_eq!(config["console"]["port"], 0); +} + #[test] fn cli_and_allowlist_changes_revoke_http_sessions_without_deleting_the_account() { let home = configured(); diff --git a/tests/console/look.rs b/tests/console/look.rs new file mode 100644 index 00000000..6e10df3c --- /dev/null +++ b/tests/console/look.rs @@ -0,0 +1,119 @@ +use super::*; + +fn cli(home: &Path, arguments: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(arguments) + .env("HOME", home) + .output() + .unwrap() +} + +#[test] +fn look_cli_preserves_other_settings_and_comments() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let path = home.path().join(".thirdshift/config.toml"); + let original = "# my machine\n[console] # reader\nport = 0 # local\nallowed_emails = [\"owner@example.test\"] # access\ntheme = \"focus-auto\" # look\n\n[merge]\nalways = true # keep\n"; + fs::write(&path, original).unwrap(); + let output = cli(home.path(), &["console", "theme", "garden-dark"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(&path).unwrap(), + original.replace("focus-auto", "garden-dark") + ); + let output = cli( + home.path(), + &[ + "console", "sections", "--hide", "done", "--hide", "resolved", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let output = cli( + home.path(), + &["console", "sections", "--show", "done", "--json"], + ); + assert!(output.status.success()); + let settings: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(settings["hidden_sections"], json!(["resolved"])); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("allowed_emails = [\"owner@example.test\"] # access")); + assert!(text.contains("always = true # keep")); + let output = cli(home.path(), &["console", "log", "--json"]); + assert!(output.status.success()); + let events: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(events.as_array().unwrap().len(), 3); + assert!( + events + .as_array() + .unwrap() + .iter() + .all(|event| event["email"].as_str().unwrap().starts_with("CLI:")) + ); + let output = cli(home.path(), &["console", "theme", "list", "--json"]); + let themes: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(themes.as_array().unwrap().len(), 18); +} + +#[test] +fn page_can_only_write_look_keys_with_csrf() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let response = console.get("/look"); + assert_eq!(response.status(), 200); + let look: Value = response.json().unwrap(); + let client = reqwest::blocking::Client::new(); + let post = |path: &str, body: &str, csrf: &str| { + client + .post(format!("{}{path}", console.address)) + .header("x-csrf-token", csrf) + .header("content-type", "application/x-www-form-urlencoded") + .body(body.to_owned()) + .send() + .unwrap() + }; + assert_eq!(post("/theme", "theme=press-dark", "wrong").status(), 403); + let csrf = look["csrf"].as_str().unwrap(); + let before = fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(); + for (path, body) in [ + ( + "/theme", + "theme=press-dark&allowed_emails=attacker%40example.test", + ), + ("/sections", "hide=done&port=9000"), + ("/theme", "theme=unknown-dark"), + ("/sections", "hide=unknown"), + ] { + assert!( + post(path, body, csrf).status().is_client_error(), + "{path}: {body}" + ); + assert_eq!( + fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(), + before + ); + } + assert_eq!(post("/theme", "theme=press-dark", csrf).status(), 200); + assert_eq!(post("/sections", "hide=done,resolved", csrf).status(), 200); + let look: Value = console.get("/look").json().unwrap(); + assert_eq!(look["theme"], "press-dark"); + assert_eq!(look["hidden_sections"], json!(["done", "resolved"])); + let output = cli(home.path(), &["console", "log", "--json"]); + let events: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(events.as_array().unwrap().len(), 2); + assert!( + events + .as_array() + .unwrap() + .iter() + .all(|event| event["email"] == "localhost (no sign-in)") + ); +}