From 2aa0600b6fb96054274eb846cd83a7f27eac6620 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Fri, 29 May 2026 15:49:37 +0200 Subject: [PATCH 01/31] feat: color datatype Adds a color datatype to Verso, as the basis for a theme feature. --- src/tests/TestMain.lean | 31 +++++ src/tests/Tests.lean | 1 + src/tests/Tests/Color.lean | 56 +++++++++ src/verso/Verso.lean | 1 + src/verso/Verso/Color.lean | 11 ++ src/verso/Verso/Color/Basic.lean | 94 ++++++++++++++ src/verso/Verso/Color/Syntax.lean | 92 ++++++++++++++ src/verso/Verso/Color/Types.lean | 19 +++ src/verso/Verso/Color/Widget.lean | 34 +++++ src/verso/Verso/Color/color-swatch.js | 25 ++++ .../literate-config/lake-manifest.json | 119 ++++++++---------- .../literate-multi-root/lake-manifest.json | 119 ++++++++---------- 12 files changed, 470 insertions(+), 132 deletions(-) create mode 100644 src/tests/Tests/Color.lean create mode 100644 src/verso/Verso/Color.lean create mode 100644 src/verso/Verso/Color/Basic.lean create mode 100644 src/verso/Verso/Color/Syntax.lean create mode 100644 src/verso/Verso/Color/Types.lean create mode 100644 src/verso/Verso/Color/Widget.lean create mode 100644 src/verso/Verso/Color/color-swatch.js diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean index f77fde9bc..56f73455b 100644 --- a/src/tests/TestMain.lean +++ b/src/tests/TestMain.lean @@ -349,9 +349,40 @@ def testBuildLog (_ : Config) : IO Unit := do throw <| IO.userError "redirected logging should still accumulate into the logger's buffers" IO.println " All build-log tests passed." +open Verso in +def testColor (_ : Config) : IO Unit := do + IO.println "Running color tests..." + let check (name got expected : String) : IO Unit := + unless got == expected do + throw <| IO.userError s!"{name}: got \"{got}\", expected \"{expected}\"" + -- Opaque colors render as lowercase `#rrggbb`. + check "black.css" Color.black.css "#000000" + check "white.css" Color.white.css "#ffffff" + check "gray.css" Color.gray.css "#808080" + check "red.css" Color.red.css "#ff0000" + check "green.css" Color.green.css "#008000" + check "blue.css" Color.blue.css "#0000ff" + check "transparent.css" Color.transparent.css "#00000000" + check "6-digit literal css" (color%#4777ff).css "#4777ff" + -- A 3-digit literal doubles each digit. + check "3-digit literal css" (color%#fff).css "#ffffff" + -- A color with alpha renders as `rgba(...)` with the alpha in [0, 1]. + check "alpha literal css" (color%#aabbcc80).css "#aabbcc80" + -- TeX rendering is six uppercase hex digits with no alpha. + check "red.tex" Color.red.tex "FF0000" + check "literal tex" (color%#4777ff).tex "4777FF" + check "alpha literal tex" (color%#aabbcc80).tex "AABBCC" + -- The literal parses to the expected channels. + unless (color%#4777ff) = Color.rgba 0x47 0x77 0xff 255 do + throw <| IO.userError "6-digit literal parsed to the wrong channels" + unless (color%#fff) = Color.rgba 255 255 255 255 do + throw <| IO.userError "3-digit literal parsed to the wrong channels" + IO.println " All color tests passed." + open Verso.Integration in def tests := [ testBuildLog, + testColor, testSerialization, testSearchJs, testBlog, diff --git a/src/tests/Tests.lean b/src/tests/Tests.lean index 9b25d3df0..f28bc799c 100644 --- a/src/tests/Tests.lean +++ b/src/tests/Tests.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ import Tests.Basic +import Tests.Color import Tests.Elab import Tests.GenericCode import Tests.Golden diff --git a/src/tests/Tests/Color.lean b/src/tests/Tests/Color.lean new file mode 100644 index 000000000..9e892cee0 --- /dev/null +++ b/src/tests/Tests/Color.lean @@ -0,0 +1,56 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +import Verso.Color +import Lean.Elab.Command + +/-! +Compile-time tests for the `color%` literal: the three accepted hex lengths elaborate, and other +lengths are rejected with a clear error. Value-level `css`/`tex` checks live in the runtime suite +(`testColor` in `TestMain`). +-/ + +open Verso + +-- The three accepted lengths elaborate and render as expected (a 3-digit literal doubles each +-- digit; 8 digits keep the alpha byte). +/-- info: #ffffff -/ +#guard_msgs in +#eval IO.println (color%#fff).css + +/-- info: #4777ff -/ +#guard_msgs in +#eval IO.println (color%#4777ff).css + +/-- info: #aabbcc80 -/ +#guard_msgs in +#eval IO.println (color%#aabbcc80).css + +/-- error: expected 3, 6, or 8 hex digits, got 0 -/ +#guard_msgs in +example : Color := color%# + +/-- error: expected 3, 6, or 8 hex digits, got 4 -/ +#guard_msgs in +example : Color := color%#1234 + +/-- error: expected 3, 6, or 8 hex digits, got 5 -/ +#guard_msgs in +example : Color := color%#12345 + +/-- error: expected 3, 6, or 8 hex digits, got 7 -/ +#guard_msgs in +example : Color := color%#1234567 + +-- The parser registers trailing whitespace on the hex token, so the original source (including the +-- spaces after the literal) can be reconstructed from the parse tree by `Syntax.reprint`. +open Lean Parser in +/-- info: round-trips: true -/ +#guard_msgs in +#eval show Lean.Elab.Command.CommandElabM Unit from do + let input := "color%#abc " + match runParserCategory (← getEnv) `term input with + | .ok stx => IO.println s!"round-trips: {stx.reprint == some input}" + | .error e => throwError e diff --git a/src/verso/Verso.lean b/src/verso/Verso.lean index 328400a72..29c24bf84 100644 --- a/src/verso/Verso.lean +++ b/src/verso/Verso.lean @@ -8,6 +8,7 @@ module -- Import modules here that should be built as part of the library. public import Verso.CLI public import Verso.Code +public import Verso.Color public import Verso.Doc public import Verso.Doc.ArgParse public import Verso.Doc.Concrete diff --git a/src/verso/Verso/Color.lean b/src/verso/Verso/Color.lean new file mode 100644 index 000000000..434913b12 --- /dev/null +++ b/src/verso/Verso/Color.lean @@ -0,0 +1,11 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Verso.Color.Types +public import Verso.Color.Basic +public import Verso.Color.Widget +public import Verso.Color.Syntax diff --git a/src/verso/Verso/Color/Basic.lean b/src/verso/Verso/Color/Basic.lean new file mode 100644 index 000000000..ca3df7b4c --- /dev/null +++ b/src/verso/Verso/Color/Basic.lean @@ -0,0 +1,94 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Verso.Color.Types + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +The pure Lean API for {name (full := Verso.Color)}`Color`: a small set of named colors, CSS and TeX +rendering, and parsing of hex color strings. +-/ + +namespace Verso.Color + +/-- +Constructs an opaque color. +-/ +public def rgb (red green blue : UInt8) : Color := .rgba red green blue 255 + +/-- Opaque black. -/ +public def black : Color := .rgb 0 0 0 +/-- Opaque white. -/ +public def white : Color := .rgb 255 255 255 +/-- A medium gray, matching the CSS {lit}`gray` keyword ({lit}`#808080`). -/ +public def gray : Color := .rgb 128 128 128 +/-- Opaque red, matching the CSS {lit}`red` keyword ({lit}`#ff0000`). -/ +public def red : Color := .rgb 255 0 0 +/-- Opaque green, matching the CSS {lit}`green` keyword ({lit}`#008000`, not full-intensity {lit}`#00ff00`). -/ +public def green : Color := .rgb 0 128 0 +/-- Opaque blue, matching the CSS {lit}`blue` keyword ({lit}`#0000ff`). -/ +public def blue : Color := .rgb 0 0 255 +/-- Fully transparent (zero alpha). -/ +public def transparent : Color := .rgba 0 0 0 0 + +private def hexDigit (upper : Bool) (n : Nat) : Char := + if n < 10 then Char.ofNat ('0'.toNat + n) + else Char.ofNat ((if upper then 'A' else 'a').toNat + (n - 10)) + +private def hexByte (upper : Bool) (b : UInt8) : String := + let n := b.toNat + String.ofList [hexDigit upper (n / 16), hexDigit upper (n % 16)] + +/-- +Renders a color for CSS as lowercase hex: `#rrggbb` when fully opaque, otherwise `#rrggbbaa`. Using +hex for both keeps one format and maps the alpha byte exactly, with no decimal rounding. +-/ +public def css : Color → String + | .rgba r g b a => + let rgb := "#" ++ hexByte false r ++ hexByte false g ++ hexByte false b + if a == 255 then rgb else rgb ++ hexByte false a + +/-- +Renders a color for TeX as six uppercase hex digits {lit}`RRGGBB`, suitable for xcolor's +{lit}`\definecolor{…}{HTML}{…}`. The alpha channel is dropped, since {lit}`xcolor`'s {lit}`HTML` +model is opaque RGB. +-/ +public def tex : Color → String + | .rgba r g b _ => hexByte true r ++ hexByte true g ++ hexByte true b + +end Color + +private def hexValue (c : Char) : Option Nat := + if c.isDigit then some (c.toNat - '0'.toNat) + else if 'a' ≤ c ∧ c ≤ 'f' then some (c.toNat - 'a'.toNat + 10) + else if 'A' ≤ c ∧ c ≤ 'F' then some (c.toNat - 'A'.toNat + 10) + else none + +/-- +Parses the hex digits of a color (no leading `#`) into RGBA byte channels. Accepts 3 digits +({lit}`rgb`, each digit doubled, opaque), 6 digits ({lit}`rrggbb`, opaque), or 8 digits +({lit}`rrggbbaa`). +-/ +public def fromHexString (hex : String) : Except String (UInt8 × UInt8 × UInt8 × UInt8) := do + let digit (c : Char) : Except String Nat := + match hexValue c with + | some v => pure v + | none => throw s!"'{c}' is not a hex digit" + let byte (hi lo : Char) : Except String UInt8 := do + return UInt8.ofNat ((← digit hi) * 16 + (← digit lo)) + match hex.toList with + | [r, g, b] => + -- Each digit is doubled: `c` becomes `0xcc`, i.e. `c * 17`. + return (UInt8.ofNat ((← digit r) * 17), UInt8.ofNat ((← digit g) * 17), + UInt8.ofNat ((← digit b) * 17), 255) + | [r1, r2, g1, g2, b1, b2] => + return (← byte r1 r2, ← byte g1 g2, ← byte b1 b2, 255) + | [r1, r2, g1, g2, b1, b2, a1, a2] => + return (← byte r1 r2, ← byte g1 g2, ← byte b1 b2, ← byte a1 a2) + | ds => throw s!"expected 3, 6, or 8 hex digits, got {ds.length}" diff --git a/src/verso/Verso/Color/Syntax.lean b/src/verso/Verso/Color/Syntax.lean new file mode 100644 index 000000000..eec0946d1 --- /dev/null +++ b/src/verso/Verso/Color/Syntax.lean @@ -0,0 +1,92 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +meta import Lean.Parser +meta import Lean.Parser.Types +public import Lean.Parser.Types +public meta import Lean.Parser.Basic +public import Lean.PrettyPrinter.Parenthesizer +public import Lean.PrettyPrinter.Formatter +public import Lean.Elab.Term +meta import Lean.PrettyPrinter +meta import Lean.Elab.Term +public meta import Verso.Color.Basic +public meta import Verso.Color.Widget + +namespace Verso + +public section + +open Lean Parser PrettyPrinter + +/-- The syntax node kind produced by the `colorHex` parser. -/ +meta def colorHexKind : SyntaxNodeKind := `Verso.colorHex + +private meta def isHexDigit (c : Char) : Bool := + if c.isDigit then true + else if 'a' ≤ c ∧ c ≤ 'f' then true + else if 'A' ≤ c ∧ c ≤ 'F' then true + else false + +/-- +Parses a hexadecimal color: a `#` followed by the maximal run of hex digits (upper- or lowercase), +terminated by the first non-hex character or end of input. The run length is checked by the +elaborator, not the parser, in order to provide better error messages. +-/ +private meta def colorHexFn : ParserFn := fun c s => + let initStackSz := s.stackSize + let iniPos := s.pos + let curr := c.get iniPos + if curr != '#' then + s.mkErrorAt "'#' to begin a color literal" iniPos initStackSz + else + let s := s.next c iniPos + let s := takeWhileFn isHexDigit c s + mkNodeToken colorHexKind iniPos true c s + +/-- A parser for the `#`-prefixed hexadecimal section of a `color%` literal. -/ +meta def colorHex : Parser := + withAntiquot (mkAntiquot "colorHex" colorHexKind) { + fn := colorHexFn + info := mkAtomicInfo "colorHex" + } + +@[combinator_parenthesizer colorHex] +meta def colorHex.parenthesizer : Parenthesizer := Parenthesizer.visitToken +@[combinator_formatter colorHex] +meta def colorHex.formatter : Formatter := Formatter.visitAtom colorHexKind + +meta initialize register_parser_alias colorHex + +/-- The hex digits of a `colorHex` syntax, without the leading `#`. -/ +private meta def asHexString (stx : TSyntax colorHexKind) : String := + let val := (stx.raw.ifNode (·.getArg 0) (fun _ => stx.raw)).getAtomVal + match val.toList with + | '#' :: rest => String.ofList rest + | cs => String.ofList cs + +/-- +A color literal: `color%#rgb`, `color%#rrggbb`, or `color%#rrggbbaa` (case-insensitive hex, no space +before the `#`). It is an ordinary term, usable anywhere a `Color` is expected, including in +structure-field defaults. +-/ +syntax (name := colorLit) "color%" noWs colorHex : term + +open Elab Term in +@[term_elab colorLit] +meta def elabColorLit : TermElab := fun stx expectedType? => do + let hexStx := stx.getArg 1 + let hex := asHexString ⟨hexStx⟩ + let (r, g, b, a) ← + match fromHexString hex with + | .ok v => pure v + | .error msg => throwErrorAt hexStx msg + saveColorWidget (.rgba r g b a) stx + let lit (n : UInt8) : Term := ⟨Syntax.mkNumLit (toString n.toNat)⟩ + elabTerm (← `(Verso.Color.rgba $(lit r) $(lit g) $(lit b) $(lit a))) expectedType? + +end diff --git a/src/verso/Verso/Color/Types.lean b/src/verso/Verso/Color/Types.lean new file mode 100644 index 000000000..80d8fed72 --- /dev/null +++ b/src/verso/Verso/Color/Types.lean @@ -0,0 +1,19 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +namespace Verso + +-- This is an inductive with a single `rgba` constructor rather than a structure so that other color +-- models (such as wide-gamut `oklch`) can be added later. The byte channels give a canonical +-- `DecidableEq`, so colors round-trip through hex exactly and themes, fonts, and assets deduplicate +-- by value. +/-- +A color in sRGB with an alpha channel, each channel a byte. +-/ +public inductive Color where + | rgba (red green blue alpha : UInt8) +deriving DecidableEq, Repr, Inhabited diff --git a/src/verso/Verso/Color/Widget.lean b/src/verso/Verso/Color/Widget.lean new file mode 100644 index 000000000..5931d3536 --- /dev/null +++ b/src/verso/Verso/Color/Widget.lean @@ -0,0 +1,34 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public meta import Lean.Widget.UserWidget +public meta import Verso.Color.Basic + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +The InfoView preview widget for colors. The CSS form is computed in Lean and handed to the widget, +so the JavaScript only has to draw the swatch. +-/ + +namespace Verso + +public section + +open Lean Widget + +/-- A read-only InfoView swatch that previews a color. -/ +@[widget_module] +meta def colorWidget : Lean.Widget.Module where + javascript := include_str "color-swatch.js" + +/-- +Attaches the color-preview widget to {name}`stx`, rendering {name}`color` as CSS. +-/ +meta def saveColorWidget (color : Color) (stx : Syntax) : CoreM Unit := + savePanelWidgetInfo colorWidget.javascriptHash (pure (Json.mkObj [("css", .str color.css)])) stx diff --git a/src/verso/Verso/Color/color-swatch.js b/src/verso/Verso/Color/color-swatch.js new file mode 100644 index 000000000..e242c0a55 --- /dev/null +++ b/src/verso/Verso/Color/color-swatch.js @@ -0,0 +1,25 @@ +import * as React from "react"; + +const e = React.createElement; + +// A read-only preview of a `color%` literal: a swatch filled with the color next to its CSS +// rendering. The pre-rendered CSS string is supplied as `props.css` by the `colorLit` elaborator. +export default function (props) { + const swatch = e("span", { + style: { + display: "inline-block", + width: "1.4em", + height: "1.4em", + borderRadius: "3px", + border: "1px solid rgba(0, 0, 0, 0.25)", + backgroundColor: props.css, + verticalAlign: "middle", + }, + }); + const label = e( + "code", + { style: { marginLeft: "0.5em", verticalAlign: "middle" } }, + props.css, + ); + return e("div", { style: { padding: "0.4em" } }, swatch, label); +} diff --git a/test-projects/literate-config/lake-manifest.json b/test-projects/literate-config/lake-manifest.json index d03c6ee10..8ccd52764 100644 --- a/test-projects/literate-config/lake-manifest.json +++ b/test-projects/literate-config/lake-manifest.json @@ -1,66 +1,53 @@ -{ - "version": "1.2.0", - "packagesDir": ".lake/packages", - "packages": [ - { - "type": "path", - "scope": "", - "name": "verso", - "manifestFile": "lake-manifest.json", - "inherited": false, - "dir": "../..", - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover/illuminate", - "type": "git", - "subDir": null, - "scope": "", - "rev": "99ada816d9929a51132d5b5dc4f43c51f16d67d8", - "name": "illuminate", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover-community/plausible", - "type": "git", - "subDir": null, - "scope": "", - "rev": "86210d4ad1b08b086d0bd638637a75246523dbb8", - "name": "plausible", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/acmepjz/md4lean", - "type": "git", - "subDir": null, - "scope": "", - "rev": "6a3fb240133bcb7e1a066fdc784b3fdc304e3fc5", - "name": "MD4Lean", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover/subverso", - "type": "git", - "subDir": null, - "scope": "", - "rev": "ce893b9042128037e2d3c0158b9567fab9fae268", - "name": "subverso", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean" - } - ], - "name": "«literate-config-test»", - "lakeDir": ".lake", - "fixedToolchain": false -} +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": + [{"type": "path", + "scope": "", + "name": "verso", + "manifestFile": "lake-manifest.json", + "inherited": false, + "dir": "../..", + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover/illuminate", + "type": "git", + "subDir": null, + "scope": "", + "rev": "99ada816d9929a51132d5b5dc4f43c51f16d67d8", + "name": "illuminate", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover-community/plausible", + "type": "git", + "subDir": null, + "scope": "", + "rev": "a456461b368b71d2accd95234832cd9c174b5437", + "name": "plausible", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/acmepjz/md4lean", + "type": "git", + "subDir": null, + "scope": "", + "rev": "6a3fb240133bcb7e1a066fdc784b3fdc304e3fc5", + "name": "MD4Lean", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover/subverso", + "type": "git", + "subDir": null, + "scope": "", + "rev": "ce893b9042128037e2d3c0158b9567fab9fae268", + "name": "subverso", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}], + "name": "«literate-config-test»", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/test-projects/literate-multi-root/lake-manifest.json b/test-projects/literate-multi-root/lake-manifest.json index 9c84043e5..f67a53e6a 100644 --- a/test-projects/literate-multi-root/lake-manifest.json +++ b/test-projects/literate-multi-root/lake-manifest.json @@ -1,66 +1,53 @@ -{ - "version": "1.2.0", - "packagesDir": ".lake/packages", - "packages": [ - { - "type": "path", - "scope": "", - "name": "verso", - "manifestFile": "lake-manifest.json", - "inherited": false, - "dir": "../..", - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover/illuminate", - "type": "git", - "subDir": null, - "scope": "", - "rev": "99ada816d9929a51132d5b5dc4f43c51f16d67d8", - "name": "illuminate", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover-community/plausible", - "type": "git", - "subDir": null, - "scope": "", - "rev": "86210d4ad1b08b086d0bd638637a75246523dbb8", - "name": "plausible", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/acmepjz/md4lean", - "type": "git", - "subDir": null, - "scope": "", - "rev": "6a3fb240133bcb7e1a066fdc784b3fdc304e3fc5", - "name": "MD4Lean", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover/subverso", - "type": "git", - "subDir": null, - "scope": "", - "rev": "ce893b9042128037e2d3c0158b9567fab9fae268", - "name": "subverso", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean" - } - ], - "name": "«literate-multi-root-test»", - "lakeDir": ".lake", - "fixedToolchain": false -} +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": + [{"type": "path", + "scope": "", + "name": "verso", + "manifestFile": "lake-manifest.json", + "inherited": false, + "dir": "../..", + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover/illuminate", + "type": "git", + "subDir": null, + "scope": "", + "rev": "99ada816d9929a51132d5b5dc4f43c51f16d67d8", + "name": "illuminate", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover-community/plausible", + "type": "git", + "subDir": null, + "scope": "", + "rev": "a456461b368b71d2accd95234832cd9c174b5437", + "name": "plausible", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/acmepjz/md4lean", + "type": "git", + "subDir": null, + "scope": "", + "rev": "6a3fb240133bcb7e1a066fdc784b3fdc304e3fc5", + "name": "MD4Lean", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover/subverso", + "type": "git", + "subDir": null, + "scope": "", + "rev": "ce893b9042128037e2d3c0158b9567fab9fae268", + "name": "subverso", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}], + "name": "«literate-multi-root-test»", + "lakeDir": ".lake", + "fixedToolchain": false} From 147d1343d7e45783e8370062a4234d62ede3f889 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Fri, 29 May 2026 16:00:02 +0200 Subject: [PATCH 02/31] prettier --- src/verso/Verso/Color/color-swatch.js | 30 ++++++++++++--------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/verso/Verso/Color/color-swatch.js b/src/verso/Verso/Color/color-swatch.js index e242c0a55..d14b307ce 100644 --- a/src/verso/Verso/Color/color-swatch.js +++ b/src/verso/Verso/Color/color-swatch.js @@ -5,21 +5,17 @@ const e = React.createElement; // A read-only preview of a `color%` literal: a swatch filled with the color next to its CSS // rendering. The pre-rendered CSS string is supplied as `props.css` by the `colorLit` elaborator. export default function (props) { - const swatch = e("span", { - style: { - display: "inline-block", - width: "1.4em", - height: "1.4em", - borderRadius: "3px", - border: "1px solid rgba(0, 0, 0, 0.25)", - backgroundColor: props.css, - verticalAlign: "middle", - }, - }); - const label = e( - "code", - { style: { marginLeft: "0.5em", verticalAlign: "middle" } }, - props.css, - ); - return e("div", { style: { padding: "0.4em" } }, swatch, label); + const swatch = e("span", { + style: { + display: "inline-block", + width: "1.4em", + height: "1.4em", + borderRadius: "3px", + border: "1px solid rgba(0, 0, 0, 0.25)", + backgroundColor: props.css, + verticalAlign: "middle", + }, + }); + const label = e("code", { style: { marginLeft: "0.5em", verticalAlign: "middle" } }, props.css); + return e("div", { style: { padding: "0.4em" } }, swatch, label); } From 88336f85144823d161596516ea5ddab14d4e4c9d Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Fri, 29 May 2026 17:13:18 +0200 Subject: [PATCH 03/31] feat: accessibility checking color math Implements the color mathematics needed for automatic color theme accessibility checking (in particular, contrast and difference under colorlindness simulation). --- src/tests/TestMain.lean | 7 + src/tests/Tests.lean | 1 + src/tests/Tests/Arbitrary.lean | 16 ++ src/tests/Tests/ColorMath.lean | 313 ++++++++++++++++++++++++++ src/verso/Verso/Color.lean | 1 + src/verso/Verso/Color/Math.lean | 230 +++++++++++++++++++ src/verso/Verso/Color/Syntax.lean | 15 +- src/verso/Verso/Color/Types.lean | 3 + src/verso/Verso/Color/Widget.lean | 11 +- src/verso/Verso/Color/color-swatch.js | 54 ++++- 10 files changed, 636 insertions(+), 15 deletions(-) create mode 100644 src/tests/Tests/ColorMath.lean create mode 100644 src/verso/Verso/Color/Math.lean diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean index 56f73455b..118879c02 100644 --- a/src/tests/TestMain.lean +++ b/src/tests/TestMain.lean @@ -149,6 +149,12 @@ def testSerialization (_ : Config) : IO Unit := do if fails > 0 then throw <| IO.userError s!"{fails} serialization tests failed" +def testColorMath (_ : Config) : IO Unit := do + IO.println "Running color math tests..." + let fails ← runColorMathTests + if fails > 0 then + throw <| IO.userError s!"{fails} color math tests failed" + def testSearchJs (_ : Config) : IO Unit := do IO.println "Running search JS wire-format tests..." let fails ← Verso.Tests.SearchJs.runSearchJsTests @@ -383,6 +389,7 @@ open Verso.Integration in def tests := [ testBuildLog, testColor, + testColorMath, testSerialization, testSearchJs, testBlog, diff --git a/src/tests/Tests.lean b/src/tests/Tests.lean index f28bc799c..d41c95fd1 100644 --- a/src/tests/Tests.lean +++ b/src/tests/Tests.lean @@ -5,6 +5,7 @@ Author: David Thrane Christiansen -/ import Tests.Basic import Tests.Color +import Tests.ColorMath import Tests.Elab import Tests.GenericCode import Tests.Golden diff --git a/src/tests/Tests/Arbitrary.lean b/src/tests/Tests/Arbitrary.lean index bfe69e9a8..8e526decc 100644 --- a/src/tests/Tests/Arbitrary.lean +++ b/src/tests/Tests/Arbitrary.lean @@ -18,6 +18,7 @@ public meta import VersoManual.LicenseInfo public meta import VersoSearch public meta import VersoSearch.DomainSearch public meta import Verso.Output.Html +public meta import Verso.Color.Types public meta import MultiVerso.Manifest public meta import VersoManual.Basic import all VersoManual.Basic @@ -437,3 +438,18 @@ instance : Shrinkable System.FilePath where if let some parent := path.parent then parent :: (path.fileName.toList.flatMap shrink |>.map path.withFileName) else [] + +instance : Arbitrary Verso.Color where + arbitrary := do + -- Bias the alpha channel: fully opaque (the common case) 60% of the time, fully transparent 5%, + -- and uniformly random the rest. A uniform alpha would almost never be exactly opaque. + let a ← frequency (pure 255) [(60, pure 255), (5, pure 0), (35, arbitrary)] + return .rgba (← arbitrary) (← arbitrary) (← arbitrary) a + +instance : Shrinkable Verso.Color where + shrink + | .rgba r g b a => + (shrink r |>.map (Verso.Color.rgba · g b a)) ++ + (shrink g |>.map (Verso.Color.rgba r · b a)) ++ + (shrink b |>.map (Verso.Color.rgba r g · a)) ++ + (shrink a |>.map (Verso.Color.rgba r g b ·)) diff --git a/src/tests/Tests/ColorMath.lean b/src/tests/Tests/ColorMath.lean new file mode 100644 index 000000000..55c29afe1 --- /dev/null +++ b/src/tests/Tests/ColorMath.lean @@ -0,0 +1,313 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module +public import Plausible +public meta import Verso.Color +import all Verso.Color.Math +public meta import Tests.Arbitrary + +/-! +Unit and property tests for the color math in `Verso.Color.Math`. +-/ + +open Plausible Gen Arbitrary Shrinkable +open Verso Verso.Color + +meta section + +/-- Approximate float equality to a tolerance. -/ +def approx (tol a b : Float) : Bool := (a - b).abs ≤ tol + +/-- Whether two colors' channels are all within `tol` of each other. -/ +def channelsClose (tol : Nat) : Color → Color → Bool + | .rgba r1 g1 b1 a1, .rgba r2 g2 b2 a2 => + let near (x y : UInt8) : Bool := (Int.ofNat x.toNat - Int.ofNat y.toNat).natAbs ≤ tol + near r1 r2 && near g1 g2 && near b1 b2 && near a1 a2 + +instance : Arbitrary CVD where + arbitrary := Gen.elements [.protanopia, .deuteranopia, .tritanopia] (by simp) + +instance : Shrinkable CVD where + shrink _ := [] + +/-! ## Unit tests on reference values -/ + +-- Relative luminance of black is 0 and of white is 1, so their contrast ratio is the maximal 21. +/-- info: (0.000000, 1.000000, 21.000000) -/ +#guard_msgs in +#eval (relativeLuminance .black, relativeLuminance .white, contrastRatio .black .white) + +-- ΔE is zero for equal colors and large for black vs. white. +/-- info: (0.000000, 100.000004) -/ +#guard_msgs in +#eval (deltaE .black .black, deltaE .white .black) + +-- Dichromacy simulation leaves the gray axis unchanged. +/-- info: (Verso.Color.rgba 128 128 128 255, Verso.Color.rgba 128 128 128 255, Verso.Color.rgba 128 128 128 255) -/ +#guard_msgs in +#eval (dichromacy .protanopia .gray, dichromacy .deuteranopia .gray, dichromacy .tritanopia .gray) + +-- Red and green, far apart normally, collapse closer together under deuteranopia. +/-- info: true -/ +#guard_msgs in +#eval decide (deltaE (dichromacy .deuteranopia .red) (dichromacy .deuteranopia .green) < deltaE .red .green) + +/-! ## Property tests -/ + +open scoped Plausible.Decorations in +def testProp + (p : Prop) (cfg : Configuration := {}) + (p' : Decorations.DecorationsOf p := by mk_decorations) [Testable p'] : + IO (TestResult p') := + Testable.checkIO p' (cfg := cfg) + +/-- Relative luminance lies in [0, 1]. -/ +def testLuminanceRange := testProp <| ∀ (c : Color), + 0.0 ≤ relativeLuminance c ∧ relativeLuminance c ≤ 1.000000001 + +/-- Raises a channel by `d`, saturating at 255. -/ +private def raise (x d : UInt8) : UInt8 := UInt8.ofNat (Nat.min 255 (x.toNat + d.toNat)) + +/-- Raising any channel never lowers the relative luminance. -/ +def testLuminanceMonotone := testProp <| ∀ (r g b d0 d1 d2 : UInt8), + relativeLuminance (.rgba r g b 255) + ≤ relativeLuminance (.rgba (raise r d0) (raise g d1) (raise b d2) 255) + 0.000000001 + +/-- Contrast ratio is symmetric. -/ +def testContrastSymmetric := testProp <| ∀ (c d : Color), + approx 0.000000001 (contrastRatio c d) (contrastRatio d c) + +/-- Contrast ratio lies in [1, 21]. -/ +def testContrastRange := testProp <| ∀ (c d : Color), + 0.999999999 ≤ contrastRatio c d ∧ contrastRatio c d ≤ 21.000000001 + +/-- A color has contrast ratio 1 with itself. -/ +def testContrastSelf := testProp <| ∀ (c : Color), + approx 0.000000001 (contrastRatio c c) 1.0 + +/-- ΔE is non-negative. -/ +def testDeltaENonneg := testProp <| ∀ (c d : Color), deltaE c d ≥ 0.0 + +/-- ΔE is symmetric. -/ +def testDeltaESymmetric := testProp <| ∀ (c d : Color), + approx 0.0001 (deltaE c d) (deltaE d c) + +/-- ΔE is zero for equal colors. -/ +def testDeltaEZero := testProp <| ∀ (c : Color), approx 0.000001 (deltaE c c) 0.0 + +/-- Dichromacy maps the gray axis to itself (within one quantization step). -/ +def testDichromacyGray := testProp <| ∀ (cvd : CVD) (v : UInt8), + channelsClose 1 (dichromacy cvd (.rgba v v v 255)) (.rgba v v v 255) + +/-- The alpha channel of a color. -/ +private def alphaOf : Color → UInt8 + | .rgba _ _ _ a => a + +/-- +Dichromacy preserves the alpha channel. + +It is *not* idempotent on the quantized, gamut-clamped `Color`: clipping a channel to 0 or 255 +discards the exact projected point, so a second pass can drift. Idempotence holds only for the +continuous linear-light projection. +-/ +def testDichromacyAlphaPreserved := testProp <| ∀ (cvd : CVD) (c : Color), + alphaOf (dichromacy cvd c) == alphaOf c + +/-- Relative luminance ignores the alpha channel. -/ +def testLuminanceIgnoresAlpha := testProp <| ∀ (r g b a : UInt8), + relativeLuminance (.rgba r g b a) == relativeLuminance (.rgba r g b 255) + +/-- Contrast ratio ignores the alpha channel. -/ +def testContrastIgnoresAlpha := testProp <| ∀ (r1 g1 b1 a1 r2 g2 b2 a2 : UInt8), + contrastRatio (.rgba r1 g1 b1 a1) (.rgba r2 g2 b2 a2) + == contrastRatio (.rgba r1 g1 b1 255) (.rgba r2 g2 b2 255) + +/-- ΔE ignores the alpha channel. -/ +def testDeltaEIgnoresAlpha := testProp <| ∀ (r1 g1 b1 a1 r2 g2 b2 a2 : UInt8), + deltaE (.rgba r1 g1 b1 a1) (.rgba r2 g2 b2 a2) + == deltaE (.rgba r1 g1 b1 255) (.rgba r2 g2 b2 255) + +open Lean (Name) + +def colorMathTests : List (Name × (Σ p, IO <| TestResult p)) := [ + (`testLuminanceRange, ⟨_, testLuminanceRange⟩), + (`testLuminanceMonotone, ⟨_, testLuminanceMonotone⟩), + (`testContrastSymmetric, ⟨_, testContrastSymmetric⟩), + (`testContrastRange, ⟨_, testContrastRange⟩), + (`testContrastSelf, ⟨_, testContrastSelf⟩), + (`testDeltaENonneg, ⟨_, testDeltaENonneg⟩), + (`testDeltaESymmetric, ⟨_, testDeltaESymmetric⟩), + (`testDeltaEZero, ⟨_, testDeltaEZero⟩), + (`testDichromacyGray, ⟨_, testDichromacyGray⟩), + (`testDichromacyAlphaPreserved, ⟨_, testDichromacyAlphaPreserved⟩), + (`testLuminanceIgnoresAlpha, ⟨_, testLuminanceIgnoresAlpha⟩), + (`testContrastIgnoresAlpha, ⟨_, testContrastIgnoresAlpha⟩), + (`testDeltaEIgnoresAlpha, ⟨_, testDeltaEIgnoresAlpha⟩), +] + +public def runColorMathTests : IO Nat := do + let mut failures := 0 + for (name, test) in colorMathTests do + IO.print s!"{name}: " + let res ← test.2 + IO.println res + unless res matches .success .. do + failures := failures + 1 + return failures + +/-! +## Source-backed simulation cases + +`cvdReferenceVectors` are outputs of DaltonLens-Python's `Simulator_Brettel1997` (severity 1.0), the +reference implementation that libDaltonLens is validated against. Each row is +`(input, protanopia, deuteranopia, tritanopia)`. `dichromacy` is expected to match each within a +couple of byte units: the small difference comes from libDaltonLens's matrix coefficients being +rounded to five decimals, not from a different method. A large deviation would mean a transposed +matrix, wrong half-plane sign, wrong gamma direction, or swapped channels. +-/ + +def cvdReferenceVectors : List (Color × Color × Color × Color) := [ + (.rgba 0 0 0 255, .rgba 0 0 0 255, .rgba 0 0 0 255, .rgba 0 0 0 255), + (.rgba 255 255 255 255, .rgba 254 254 254 255, .rgba 254 254 254 255, .rgba 254 254 254 255), + (.rgba 128 128 128 255, .rgba 128 128 128 255, .rgba 128 128 128 255, .rgba 128 128 128 255), + (.rgba 255 0 0 255, .rgba 106 90 13 255, .rgba 163 138 0 255, .rgba 254 0 78 255), + (.rgba 0 128 0 255, .rgba 139 118 0 255, .rgba 120 103 17 255, .rgba 58 117 135 255), + (.rgba 0 0 255 255, .rgba 0 54 254 255, .rgba 0 86 254 255, .rgba 0 95 134 255), + (.rgba 255 255 0 255, .rgba 254 250 0 255, .rgba 254 242 21 255, .rgba 254 239 242 255), + (.rgba 0 255 255 255, .rgba 238 242 254 255, .rgba 209 223 254 255, .rgba 73 248 254 255), + (.rgba 255 0 255 255, .rgba 0 105 254 255, .rgba 101 160 251 255, .rgba 238 98 120 255), + (.rgba 29 3 65 255, .rgba 0 13 65 255, .rgba 0 23 64 255, .rgba 14 21 23 255), + (.rgba 220 20 60 255, .rgba 87 81 62 255, .rgba 139 121 49 255, .rgba 220 12 72 255), + (.rgba 0 128 255 255, .rgba 0 129 254 255, .rgba 0 132 254 255, .rgba 0 147 185 255), + (.rgba 217 163 130 255, .rgba 181 168 130 255, .rgba 191 176 128 255, .rgba 220 158 164 255), + (.rgba 69 78 10 255, .rgba 88 75 9 255, .rgba 84 71 12 255, .rgba 75 72 73 255), + (.rgba 19 4 44 255, .rgba 0 8 44 255, .rgba 0 15 43 255, .rgba 9 13 15 255), + (.rgba 208 166 233 255, .rgba 144 174 233 255, .rgba 164 185 231 255, .rgba 198 176 178 255), + (.rgba 128 155 248 255, .rgba 98 157 248 255, .rgba 107 160 247 255, .rgba 105 168 190 255), + (.rgba 186 161 139 255, .rgba 171 163 139 255, .rgba 175 165 138 255, .rgba 188 157 160 255), + (.rgba 143 239 71 255, .rgba 254 226 68 255, .rgba 237 207 80 255, .rgba 173 222 242 255), + (.rgba 208 171 0 255, .rgba 200 172 0 255, .rgba 202 173 0 255, .rgba 217 159 165 255), + (.rgba 100 219 141 255, .rgba 228 207 140 255, .rgba 204 189 144 255, .rgba 130 206 233 255), + (.rgba 8 195 186 255, .rgba 185 185 185 255, .rgba 162 169 187 255, .rgba 60 188 223 255), + (.rgba 216 44 22 255, .rgba 99 86 26 255, .rgba 142 121 0 255, .rgba 217 32 77 255), + (.rgba 220 5 138 255, .rgba 43 82 138 255, .rgba 124 126 133 255, .rgba 215 46 82 255), + (.rgba 20 76 123 255, .rgba 43 74 122 255, .rgba 38 73 123 255, .rgba 0 81 99 255), + (.rgba 108 103 7 255, .rgba 118 101 6 255, .rgba 116 99 9 255, .rgba 114 96 97 255), + (.rgba 1 31 2 255, .rgba 34 28 1 255, .rgba 28 23 3 255, .rgba 9 27 33 255), + (.rgba 171 134 165 255, .rgba 129 139 165 255, .rgba 141 147 164 255, .rgba 167 138 141 255), + (.rgba 65 157 195 255, .rgba 132 152 194 255, .rgba 119 144 195 255, .rgba 59 158 186 255), + (.rgba 98 117 255 255, .rgba 0 124 254 255, .rgba 0 134 254 255, .rgba 34 141 169 255), + (.rgba 206 251 97 255, .rgba 254 242 95 255, .rgba 254 229 102 255, .rgba 225 236 242 255), + (.rgba 175 243 166 255, .rgba 254 234 165 255, .rgba 237 220 168 255, .rgba 192 232 249 255), + (.rgba 215 176 180 255, .rgba 180 180 180 255, .rgba 190 187 179 255, .rgba 214 176 179 255), + (.rgba 99 224 34 255, .rgba 244 210 28 255, .rgba 218 189 51 255, .rgba 140 207 231 255), + (.rgba 148 184 216 255, .rgba 167 182 215 255, .rgba 162 179 216 255, .rgba 144 186 203 255), + (.rgba 134 96 79 255, .rgba 107 100 79 255, .rgba 115 106 77 255, .rgba 135 93 97 255), + (.rgba 108 124 184 255, .rgba 92 125 184 255, .rgba 96 127 183 255, .rgba 95 132 146 255), + (.rgba 227 18 239 255, .rgba 0 96 239 255, .rgba 82 145 236 255, .rgba 210 94 111 255), + (.rgba 136 91 172 255, .rgba 47 101 172 255, .rgba 82 114 171 255, .rgba 123 106 107 255), + (.rgba 146 65 82 255, .rgba 78 79 82 255, .rgba 101 95 79 255, .rgba 145 65 77 255), + (.rgba 184 152 129 255, .rgba 163 154 129 255, .rgba 169 159 128 255, .rgba 186 148 152 255), + (.rgba 86 194 100 255, .rgba 206 183 99 255, .rgba 184 166 104 255, .rgba 116 181 204 255), + (.rgba 84 227 67 255, .rgba 245 213 64 255, .rgba 217 190 77 255, .rgba 131 210 237 255), + (.rgba 58 182 159 255, .rgba 178 173 158 255, .rgba 157 158 160 255, .rgba 83 175 203 255), + (.rgba 12 21 96 255, .rgba 0 27 96 255, .rgba 0 34 95 255, .rgba 0 38 52 255), + (.rgba 213 102 201 255, .rgba 74 125 201 255, .rgba 129 152 198 255, .rgba 203 120 130 255), + (.rgba 81 61 202 255, .rgba 0 75 202 255, .rgba 0 92 201 255, .rgba 20 94 112 255), + (.rgba 224 20 14 255, .rgba 95 81 21 255, .rgba 144 122 0 255, .rgba 225 0 71 255), + (.rgba 171 86 146 255, .rgba 78 102 146 255, .rgba 112 121 144 255, .rgba 165 95 104 255), + (.rgba 38 220 115 255, .rgba 231 206 113 255, .rgba 202 184 120 255, .rgba 103 205 237 255), +] + +/-- The largest per-channel difference between two colors (ignoring alpha). -/ +private def chanMaxDiff : Color → Color → Nat + | .rgba r1 g1 b1 _, .rgba r2 g2 b2 _ => + let d (x y : UInt8) : Nat := (Int.ofNat x.toNat - Int.ofNat y.toNat).natAbs + Nat.max (d r1 r2) (Nat.max (d g1 g2) (d b1 b2)) + +/-- +The greatest per-channel deviation of `dichromacy` from the DaltonLens reference, over all vectors +and all three deficiencies. Pinned so a regression (or a larger deviation than the matrix rounding +explains) fails the test. +-/ +def cvdReferenceMaxDeviation : Nat := + cvdReferenceVectors.foldl (init := 0) fun acc (c, p, de, t) => + Nat.max acc <| + Nat.max (chanMaxDiff (dichromacy .protanopia c) p) <| + Nat.max (chanMaxDiff (dichromacy .deuteranopia c) de) (chanMaxDiff (dichromacy .tritanopia c) t) + +-- `dichromacy` matches the DaltonLens reference across all 50 vectors and all three deficiencies. The +-- largest per-channel deviation is 7, for one tritanopia color (`rgba 81 61 202`) that sits near the +-- half-plane boundary, where the hard plane switch and the 5-decimal-rounded matrices interact; the +-- rest are within about 3. A transposed matrix, flipped half-plane sign, or wrong gamma direction +-- would push this far higher. +/-- info: 7 -/ +#guard_msgs in +#eval cvdReferenceMaxDeviation + +/-! ## CIEDE2000 against Sharma's published Lab vectors + +`deltaE2000` (the CIELAB core of `deltaE`, reached via `import all`) is validated against the 34 +test pairs from Sharma, Wu & Dalal (2005), Table 1, the canonical data for checking a CIEDE2000 +implementation. These pairs deliberately exercise the hue-rotation and near-zero-chroma cases that +catch transcription errors the broad invariants miss. Values via the reference implementation at +https://github.com/gfiumara/CIEDE2000. +-/ + +def sharmaDeltaEVectors : List ((Float × Float × Float) × (Float × Float × Float) × Float) := [ + ((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425), + ((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615), + ((50.0, 2.8361, -74.0200), (50.0, 0.0, -82.7485), 3.4412), + ((50.0, -1.3802, -84.2814), (50.0, 0.0, -82.7485), 1.0000), + ((50.0, -1.1848, -84.8006), (50.0, 0.0, -82.7485), 1.0000), + ((50.0, -0.9009, -85.5211), (50.0, 0.0, -82.7485), 1.0000), + ((50.0, 0.0, 0.0), (50.0, -1.0, 2.0), 2.3669), + ((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669), + ((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0009), 7.1792), + ((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0010), 7.1792), + ((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0011), 7.2195), + ((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0012), 7.2195), + ((50.0, -0.0010, 2.4900), (50.0, 0.0009, -2.4900), 4.8045), + ((50.0, -0.0010, 2.4900), (50.0, 0.0010, -2.4900), 4.8045), + ((50.0, -0.0010, 2.4900), (50.0, 0.0011, -2.4900), 4.7461), + ((50.0, 2.5000, 0.0), (50.0, 0.0, -2.5000), 4.3065), + ((50.0, 2.5000, 0.0), (73.0, 25.0, -18.0), 27.1492), + ((50.0, 2.5000, 0.0), (61.0, -5.0, 29.0), 22.8977), + ((50.0, 2.5000, 0.0), (56.0, -27.0, -3.0), 31.9030), + ((50.0, 2.5000, 0.0), (58.0, 24.0, 15.0), 19.4535), + ((50.0, 2.5000, 0.0), (50.0, 3.1736, 0.5854), 1.0000), + ((50.0, 2.5000, 0.0), (50.0, 3.2972, 0.0), 1.0000), + ((50.0, 2.5000, 0.0), (50.0, 1.8634, 0.5757), 1.0000), + ((50.0, 2.5000, 0.0), (50.0, 3.2592, 0.3350), 1.0000), + ((60.2574, -34.0099, 36.2677), (60.4626, -34.1751, 39.4387), 1.2644), + ((63.0109, -31.0961, -5.8663), (62.8187, -29.7946, -4.0864), 1.2630), + ((61.2901, 3.7196, -5.3901), (61.4292, 2.2480, -4.9620), 1.8731), + ((35.0831, -44.1164, 3.7933), (35.0232, -40.0716, 1.5901), 1.8645), + ((22.7233, 20.0904, -46.6940), (23.0331, 14.9730, -42.5619), 2.0373), + ((36.4612, 47.8580, 18.3852), (36.2715, 50.5065, 21.2231), 1.4146), + ((90.8027, -2.0831, 1.4410), (91.1528, -1.6435, 0.0447), 1.4441), + ((90.9257, -0.5406, -0.9208), (88.6381, -0.8985, -0.7239), 1.5381), + ((6.7747, -0.2908, -2.4247), (5.8714, -0.0985, -2.2286), 0.6377), + ((2.0776, 0.0795, -1.1350), (0.9033, -0.0636, -0.5514), 0.9082), +] + +/-- +The greatest absolute deviation of `deltaE2000` from Sharma's published ΔE₀₀, over all 34 vectors. +Sharma publishes four decimal places, so a correct implementation lands within rounding. +-/ +def sharmaMaxDeviation : Float := + sharmaDeltaEVectors.foldl (init := 0.0) fun acc (lab1, lab2, exp) => + let (l1, a1, b1) := lab1 + let (l2, a2, b2) := lab2 + let d := (deltaE2000 l1 a1 b1 l2 a2 b2 - exp).abs + if acc < d then d else acc + +-- `deltaE2000` reproduces every Sharma reference value to within their published precision (four +-- decimals), confirming the CIEDE2000 formula is transcribed correctly (including the tricky +-- hue-rotation and near-zero-chroma cases). +/-- info: true -/ +#guard_msgs in +#eval decide (sharmaMaxDeviation < 0.0001) diff --git a/src/verso/Verso/Color.lean b/src/verso/Verso/Color.lean index 434913b12..17dd7a0f6 100644 --- a/src/verso/Verso/Color.lean +++ b/src/verso/Verso/Color.lean @@ -7,5 +7,6 @@ module public import Verso.Color.Types public import Verso.Color.Basic +public import Verso.Color.Math public import Verso.Color.Widget public import Verso.Color.Syntax diff --git a/src/verso/Verso/Color/Math.lean b/src/verso/Verso/Color/Math.lean new file mode 100644 index 000000000..c25f6fe03 --- /dev/null +++ b/src/verso/Verso/Color/Math.lean @@ -0,0 +1,230 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Verso.Color.Types + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +Color math for accessibility checking, using the following: + +* Relative luminance and contrast ratio, from WCAG 2.1 ([relative + luminance](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance), [contrast + ratio](https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio)). A theme has to keep text readable + against its background, and the WCAG contrast ratio is the accepted way to measure that. + +* Color-vision-deficiency simulation, using the Brettel, Viénot & Mollon (1997) dichromacy method as + reproduced by [libDaltonLens](https://github.com/DaltonLens/libDaltonLens). Brettel 1997 is used + for all three dichromacies because the simpler Viénot 1999 single-matrix variant is inaccurate for + tritanopia. Simulating how a palette looks to a color-blind reader lets the checker catch colors + that collapse together and stop carrying meaning. + +* Color difference, using CIEDE2000 (Sharma, Wu & Dalal 2005, "The CIEDE2000 color-difference + formula"; [reference implementation and test + data](https://hajim.rochester.edu/ece/sites/gsharma/ciede2000/)). It is a perceptually uniform + measure of how distinct two colors look, used to decide whether two theme colors stay far enough + apart, including under the simulations above. + +All channels convert to {name}`Float` at the boundary (`/255`) and the math runs in {name}`Float`; +the {name (full := Verso.Color)}`Color` type itself stays byte-exact. +-/ + +namespace Verso + +namespace Color + +private def pi : Float := 3.141592653589793 + +private def fmin (a b : Float) : Float := if a < b then a else b +private def fmax (a b : Float) : Float := if a > b then a else b +private def clamp01 (v : Float) : Float := fmax 0.0 (fmin 1.0 v) + +private def channelToFloat (x : UInt8) : Float := x.toNat.toFloat / 255.0 + +/-- Quantizes a {lit}`[0, 1]` channel back to a byte, rounding to nearest and clamping. -/ +private def floatToChannel (v : Float) : UInt8 := + (Nat.min 255 ((clamp01 v * 255.0 + 0.5).toUInt64.toNat)).toUInt8 + +/-- +The sRGB electro-optical transfer function: decodes a gamma-encoded {lit}`[0, 1]` channel to +linear-light. WCAG 2.1 and the sRGB standard use the {lit}`0.04045` threshold and the +{lit}`((c + 0.055) / 1.055) ^ 2.4` curve. +-/ +private def srgbToLinear (c : Float) : Float := + if c < 0.04045 then c / 12.92 + else Float.pow ((c + 0.055) / 1.055) 2.4 + +/-- +The inverse of {name}`srgbToLinear`: encodes a linear-light value back to a gamma-encoded +{lit}`[0, 1]` channel (clamping out-of-gamut values). +-/ +private def linearToSrgb (v : Float) : Float := + if v ≤ 0.0 then 0.0 + else if v ≥ 1.0 then 1.0 + else if v < 0.0031308 then v * 12.92 + else Float.pow v (1.0 / 2.4) * 1.055 - 0.055 + +/-- +The WCAG 2.1 relative luminance of a color, in {lit}`[0, 1]`. The alpha channel is ignored; +luminance is defined for opaque colors. Coefficients are the sRGB/Rec.709 luminance weights. +-/ +public def relativeLuminance : Color → Float + | .rgba r g b _ => + let rl := srgbToLinear (channelToFloat r) + let gl := srgbToLinear (channelToFloat g) + let bl := srgbToLinear (channelToFloat b) + 0.2126 * rl + 0.7152 * gl + 0.0722 * bl + +/-- +The WCAG 2.1 contrast ratio between two colors, in {lit}`[1, 21]`. The luminances are ordered by +max/min, so the ratio is symmetric in its arguments. +-/ +public def contrastRatio (c1 c2 : Color) : Float := + let l1 := relativeLuminance c1 + let l2 := relativeLuminance c2 + (fmax l1 l2 + 0.05) / (fmin l1 l2 + 0.05) + +/-- A type of color vision deficiency (dichromacy). -/ +public inductive CVD where + | protanopia + | deuteranopia + | tritanopia +deriving DecidableEq, Repr + +/-- A row-major 3x3 matrix in linear RGB. -/ +private abbrev Mat3 := Float × Float × Float × Float × Float × Float × Float × Float × Float + +private def applyMat3 (m : Mat3) (r g b : Float) : Float × Float × Float := + let (m00, m01, m02, m10, m11, m12, m20, m21, m22) := m + (m00 * r + m01 * g + m02 * b, m10 * r + m11 * g + m12 * b, m20 * r + m21 * g + m22 * b) + +/-- +The Brettel, Viénot & Mollon (1997) simulation parameters for a dichromacy: two linear-RGB matrices, +one per half-plane of the dichromat's reduced gamut, and the separation plane normal (also in linear +RGB) that selects between them. Each matrix's rows sum to one, so the gray axis is preserved. Values +from libDaltonLens (https://github.com/DaltonLens/libDaltonLens). +-/ +private def brettelParams : CVD → Mat3 × Mat3 × (Float × Float × Float) + | .protanopia => + ((0.14980, 1.19548, -0.34528, 0.10764, 0.84864, 0.04372, 0.00384, -0.00540, 1.00156), + (0.14570, 1.16172, -0.30742, 0.10816, 0.85291, 0.03892, 0.00386, -0.00524, 1.00139), + (0.00048, 0.00393, -0.00441)) + | .deuteranopia => + ((0.36477, 0.86381, -0.22858, 0.26294, 0.64245, 0.09462, -0.02006, 0.02728, 0.99278), + (0.37298, 0.88166, -0.25464, 0.25954, 0.63506, 0.10540, -0.01980, 0.02784, 0.99196), + (-0.00281, -0.00611, 0.00892)) + | .tritanopia => + ((1.01277, 0.13548, -0.14826, -0.01243, 0.86812, 0.14431, 0.07589, 0.80500, 0.11911), + (0.93678, 0.18979, -0.12657, 0.06154, 0.81526, 0.12320, -0.37562, 1.12767, 0.24796), + (0.03901, -0.02788, -0.01113)) + +/-- +Simulates how a color appears to someone with the given color vision deficiency, using the Brettel +1997 method: decode sRGB to linear light, pick the half-plane matrix whose side of the separation +plane the color is on, apply it, then re-encode and quantize. +-/ +public def dichromacy (cvd : CVD) : Color → Color + | .rgba r g b a => + let rl := srgbToLinear (channelToFloat r) + let gl := srgbToLinear (channelToFloat g) + let bl := srgbToLinear (channelToFloat b) + let (plane1, plane2, n0, n1, n2) := brettelParams cvd + let m := if rl * n0 + gl * n1 + bl * n2 ≥ 0.0 then plane1 else plane2 + let (rl', gl', bl') := applyMat3 m rl gl bl + .rgba (floatToChannel (linearToSrgb rl')) (floatToChannel (linearToSrgb gl')) + (floatToChannel (linearToSrgb bl')) a + +/-! # CIELAB color difference (CIEDE2000) -/ + +/-- +Converts a color to CIELAB ({lit}`L*`, {lit}`a*`, {lit}`b*`) under the D65 white point, ignoring +alpha. +-/ +private def toLab : Color → Float × Float × Float + | .rgba r g b _ => + -- sRGB → linear → CIE XYZ (D65), the standard sRGB matrix. + let rl := srgbToLinear (channelToFloat r) + let gl := srgbToLinear (channelToFloat g) + let bl := srgbToLinear (channelToFloat b) + let x := 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl + let y := 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl + let z := 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl + -- XYZ → L*a*b* with the D65 reference white. + let f := fun t => + if t > 0.008856451679035631 then Float.pow t (1.0 / 3.0) + else 7.787037037037035 * t + 16.0 / 116.0 + let fx := f (x / 0.95047) + let fy := f (y / 1.00000) + let fz := f (z / 1.08883) + (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)) + +/-- +The CIEDE2000 color difference ΔE₀₀ between two CIELAB colors, each given as {lit}`L*`, {lit}`a*`, +{lit}`b*`. Non-negative, symmetric, and zero for equal inputs. Implements the formulation of Sharma, +Wu & Dalal (2005), "The CIEDE2000 color-difference formula", with `kL = kC = kH = 1`. Factored out +of the color-level difference so it can be validated directly against Sharma's published Lab test +vectors (the test suite reaches it through `import all`). +-/ +private def deltaE2000 (l1 a1 b1 l2 a2 b2 : Float) : Float := Id.run do + let rad := fun d => d * pi / 180.0 + let deg := fun r => r * 180.0 / pi + -- atan2 in degrees, in [0, 360). + let atan2deg := fun y x => + let a := deg (Float.atan2 y x) + if a < 0.0 then a + 360.0 else a + let cosd := fun d => Float.cos (rad d) + let sind := fun d => Float.sin (rad d) + let pow7 := fun (t : Float) => Float.pow t 7.0 + let c25_7 := pow7 25.0 + let cstar1 := Float.sqrt (a1 * a1 + b1 * b1) + let cstar2 := Float.sqrt (a2 * a2 + b2 * b2) + let cbar := (cstar1 + cstar2) / 2.0 + let g := 0.5 * (1.0 - Float.sqrt (pow7 cbar / (pow7 cbar + c25_7))) + let a1' := (1.0 + g) * a1 + let a2' := (1.0 + g) * a2 + let c1' := Float.sqrt (a1' * a1' + b1 * b1) + let c2' := Float.sqrt (a2' * a2' + b2 * b2) + let h1' := if a1' == 0.0 && b1 == 0.0 then 0.0 else atan2deg b1 a1' + let h2' := if a2' == 0.0 && b2 == 0.0 then 0.0 else atan2deg b2 a2' + let dL' := l2 - l1 + let dC' := c2' - c1' + let dh' := + if c1' * c2' == 0.0 then 0.0 + else if (h2' - h1') > 180.0 then h2' - h1' - 360.0 + else if (h2' - h1') < -180.0 then h2' - h1' + 360.0 + else h2' - h1' + let dH' := 2.0 * Float.sqrt (c1' * c2') * sind (dh' / 2.0) + let lbar' := (l1 + l2) / 2.0 + let cbar' := (c1' + c2') / 2.0 + let hbar' := + if c1' * c2' == 0.0 then h1' + h2' + else if (fmax h1' h2' - fmin h1' h2') ≤ 180.0 then (h1' + h2') / 2.0 + else if (h1' + h2') < 360.0 then (h1' + h2' + 360.0) / 2.0 + else (h1' + h2' - 360.0) / 2.0 + let t := 1.0 - 0.17 * cosd (hbar' - 30.0) + 0.24 * cosd (2.0 * hbar') + + 0.32 * cosd (3.0 * hbar' + 6.0) - 0.20 * cosd (4.0 * hbar' - 63.0) + let dtheta := 30.0 * Float.exp (-(((hbar' - 275.0) / 25.0) * ((hbar' - 275.0) / 25.0))) + let rc := 2.0 * Float.sqrt (pow7 cbar' / (pow7 cbar' + c25_7)) + let sl := 1.0 + (0.015 * (lbar' - 50.0) * (lbar' - 50.0)) + / Float.sqrt (20.0 + (lbar' - 50.0) * (lbar' - 50.0)) + let sc := 1.0 + 0.045 * cbar' + let sh := 1.0 + 0.015 * cbar' * t + let rt := -(sind (2.0 * dtheta)) * rc + let termL := dL' / sl + let termC := dC' / sc + let termH := dH' / sh + return Float.sqrt (fmax 0.0 (termL * termL + termC * termC + termH * termH + rt * termC * termH)) + +/-- +The CIEDE2000 color difference ΔE₀₀ between two colors, each first converted to CIELAB under the D65 +white point. Non-negative, symmetric, and zero for equal colors. +-/ +public def deltaE (c1 c2 : Color) : Float := + let (l1, a1, b1) := toLab c1 + let (l2, a2, b2) := toLab c2 + deltaE2000 l1 a1 b1 l2 a2 b2 diff --git a/src/verso/Verso/Color/Syntax.lean b/src/verso/Verso/Color/Syntax.lean index eec0946d1..713c71af6 100644 --- a/src/verso/Verso/Color/Syntax.lean +++ b/src/verso/Verso/Color/Syntax.lean @@ -17,13 +17,16 @@ meta import Lean.Elab.Term public meta import Verso.Color.Basic public meta import Verso.Color.Widget +set_option linter.missingDocs true +set_option doc.verso true + namespace Verso public section open Lean Parser PrettyPrinter -/-- The syntax node kind produced by the `colorHex` parser. -/ +/-- The syntax node kind for the hexadecimal body of a `color%` literal. -/ meta def colorHexKind : SyntaxNodeKind := `Verso.colorHex private meta def isHexDigit (c : Char) : Bool := @@ -55,14 +58,16 @@ meta def colorHex : Parser := info := mkAtomicInfo "colorHex" } +/-- Parenthesizer for {name}`colorHex`, which prints as a single token. -/ @[combinator_parenthesizer colorHex] meta def colorHex.parenthesizer : Parenthesizer := Parenthesizer.visitToken +/-- Formatter for {name}`colorHex`, which prints as a single token. -/ @[combinator_formatter colorHex] meta def colorHex.formatter : Formatter := Formatter.visitAtom colorHexKind meta initialize register_parser_alias colorHex -/-- The hex digits of a `colorHex` syntax, without the leading `#`. -/ +/-- The hex digits of a {name}`colorHex` syntax, without the leading `#`. -/ private meta def asHexString (stx : TSyntax colorHexKind) : String := let val := (stx.raw.ifNode (·.getArg 0) (fun _ => stx.raw)).getAtomVal match val.toList with @@ -71,12 +76,14 @@ private meta def asHexString (stx : TSyntax colorHexKind) : String := /-- A color literal: `color%#rgb`, `color%#rrggbb`, or `color%#rrggbbaa` (case-insensitive hex, no space -before the `#`). It is an ordinary term, usable anywhere a `Color` is expected, including in -structure-field defaults. +before the `#`). It is an ordinary term, usable anywhere a {name (full := Verso.Color)}`Color` is +expected, including in structure-field defaults. -/ syntax (name := colorLit) "color%" noWs colorHex : term open Elab Term in +/-- Elaborates a {lit}`color%` literal to a {name (full := Verso.Color)}`Color` and attaches the +preview widget. -/ @[term_elab colorLit] meta def elabColorLit : TermElab := fun stx expectedType? => do let hexStx := stx.getArg 1 diff --git a/src/verso/Verso/Color/Types.lean b/src/verso/Verso/Color/Types.lean index 80d8fed72..10ae38417 100644 --- a/src/verso/Verso/Color/Types.lean +++ b/src/verso/Verso/Color/Types.lean @@ -5,6 +5,9 @@ Author: David Thrane Christiansen -/ module +set_option linter.missingDocs true +set_option doc.verso true + namespace Verso -- This is an inductive with a single `rgba` constructor rather than a structure so that other color diff --git a/src/verso/Verso/Color/Widget.lean b/src/verso/Verso/Color/Widget.lean index 5931d3536..28c880903 100644 --- a/src/verso/Verso/Color/Widget.lean +++ b/src/verso/Verso/Color/Widget.lean @@ -7,6 +7,7 @@ module public meta import Lean.Widget.UserWidget public meta import Verso.Color.Basic +public meta import Verso.Color.Math set_option linter.missingDocs true set_option doc.verso true @@ -28,7 +29,13 @@ meta def colorWidget : Lean.Widget.Module where javascript := include_str "color-swatch.js" /-- -Attaches the color-preview widget to {name}`stx`, rendering {name}`color` as CSS. +Attaches the color-preview widget to {name}`stx`. The widget shows {name}`color` together with how +it appears under each of the three dichromacies, all rendered to CSS in Lean. -/ meta def saveColorWidget (color : Color) (stx : Syntax) : CoreM Unit := - savePanelWidgetInfo colorWidget.javascriptHash (pure (Json.mkObj [("css", .str color.css)])) stx + let props := Json.mkObj [ + ("css", .str color.css), + ("protanopia", .str (Color.dichromacy .protanopia color).css), + ("deuteranopia", .str (Color.dichromacy .deuteranopia color).css), + ("tritanopia", .str (Color.dichromacy .tritanopia color).css)] + savePanelWidgetInfo colorWidget.javascriptHash (pure props) stx diff --git a/src/verso/Verso/Color/color-swatch.js b/src/verso/Verso/Color/color-swatch.js index d14b307ce..ef9928ad2 100644 --- a/src/verso/Verso/Color/color-swatch.js +++ b/src/verso/Verso/Color/color-swatch.js @@ -2,20 +2,56 @@ import * as React from "react"; const e = React.createElement; -// A read-only preview of a `color%` literal: a swatch filled with the color next to its CSS -// rendering. The pre-rendered CSS string is supplied as `props.css` by the `colorLit` elaborator. -export default function (props) { - const swatch = e("span", { +function swatch(css, size) { + return e("span", { style: { display: "inline-block", - width: "1.4em", - height: "1.4em", + width: size, + height: size, borderRadius: "3px", border: "1px solid rgba(0, 0, 0, 0.25)", - backgroundColor: props.css, + backgroundColor: css, verticalAlign: "middle", }, }); - const label = e("code", { style: { marginLeft: "0.5em", verticalAlign: "middle" } }, props.css); - return e("div", { style: { padding: "0.4em" } }, swatch, label); +} + +function cvdRow(label, css) { + return e( + "div", + { style: { display: "flex", alignItems: "center", gap: "0.4em", padding: "0.1em 0.4em" } }, + swatch(css, "1em"), + e("span", { style: { fontSize: "0.85em" } }, label), + e("code", { style: { fontSize: "0.8em", opacity: 0.7 } }, css), + ); +} + +// A read-only preview of a `color%` literal: the color itself, plus how it looks under each of the +// three dichromacies. All CSS strings are pre-rendered by the `colorLit` elaborator. +export default function (props) { + const main = e( + "div", + { style: { padding: "0.4em" } }, + swatch(props.css, "1.4em"), + e("code", { style: { marginLeft: "0.5em", verticalAlign: "middle" } }, props.css), + ); + const cvd = e( + "div", + { + style: { + borderTop: "1px solid rgba(0, 0, 0, 0.1)", + marginTop: "0.3em", + paddingTop: "0.2em", + }, + }, + e( + "div", + { style: { fontSize: "0.75em", opacity: 0.6, padding: "0 0.4em 0.1em" } }, + "Color vision deficiency", + ), + cvdRow("Protanopia", props.protanopia), + cvdRow("Deuteranopia", props.deuteranopia), + cvdRow("Tritanopia", props.tritanopia), + ); + return e("div", {}, main, cvd); } From aef96d772b094b472b895e4439dbe2bd5b9adb71 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Fri, 29 May 2026 20:32:58 +0200 Subject: [PATCH 04/31] feat: accessibility test core logic Adds the core logic for accessibility calculations. --- src/tests/TestMain.lean | 7 ++ src/tests/Tests.lean | 1 + src/tests/Tests/ColorAccessibility.lean | 106 +++++++++++++++++++ src/verso/Verso/Color.lean | 1 + src/verso/Verso/Color/Accessibility.lean | 127 +++++++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 src/tests/Tests/ColorAccessibility.lean create mode 100644 src/verso/Verso/Color/Accessibility.lean diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean index 118879c02..42452a3dd 100644 --- a/src/tests/TestMain.lean +++ b/src/tests/TestMain.lean @@ -155,6 +155,12 @@ def testColorMath (_ : Config) : IO Unit := do if fails > 0 then throw <| IO.userError s!"{fails} color math tests failed" +def testColorAccessibility (_ : Config) : IO Unit := do + IO.println "Running color accessibility tests..." + let fails ← runColorAccessibilityTests + if fails > 0 then + throw <| IO.userError s!"{fails} color accessibility tests failed" + def testSearchJs (_ : Config) : IO Unit := do IO.println "Running search JS wire-format tests..." let fails ← Verso.Tests.SearchJs.runSearchJsTests @@ -390,6 +396,7 @@ def tests := [ testBuildLog, testColor, testColorMath, + testColorAccessibility, testSerialization, testSearchJs, testBlog, diff --git a/src/tests/Tests.lean b/src/tests/Tests.lean index d41c95fd1..5fadb14d4 100644 --- a/src/tests/Tests.lean +++ b/src/tests/Tests.lean @@ -5,6 +5,7 @@ Author: David Thrane Christiansen -/ import Tests.Basic import Tests.Color +import Tests.ColorAccessibility import Tests.ColorMath import Tests.Elab import Tests.GenericCode diff --git a/src/tests/Tests/ColorAccessibility.lean b/src/tests/Tests/ColorAccessibility.lean new file mode 100644 index 000000000..62c5ebc1a --- /dev/null +++ b/src/tests/Tests/ColorAccessibility.lean @@ -0,0 +1,106 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module +public import Plausible +public meta import Verso.Color +public meta import Tests.Arbitrary + +/-! +Unit and property tests for the accessibility predicates and checks in `Verso.Color.Accessibility`. +-/ + +open Plausible Gen Arbitrary Shrinkable +open Verso Verso.Color + +meta section + +/-- The Okabe-Ito colorblind-safe palette, used to validate the distinguishability threshold. -/ +def okabeIto : Array (String × Color) := #[ + ("black", color%#000000), + ("orange", color%#e69f00), + ("sky blue", color%#56b4e9), + ("bluish green", color%#009e73), + ("yellow", color%#f0e442), + ("blue", color%#0072b2), + ("vermillion", color%#d55e00), + ("reddish purple", color%#cc79a7), +] + +/-! ## Unit tests on curated palettes -/ + +-- A high-contrast pair (black on white) passes the text contrast check. +/-- info: true -/ +#guard_msgs in +#eval (contrastIssues textContrastThreshold "black on white" .black .white).isEmpty + +-- A low-contrast pair (gray on white, ratio ≈ 3.95) fails the text contrast check. +/-- info: false -/ +#guard_msgs in +#eval (contrastIssues textContrastThreshold "gray on white" .gray .white).isEmpty + +-- A nearly-opaque (99%) black composited over white is essentially black, so it passes easily. +/-- info: true -/ +#guard_msgs in +#eval (contrastIssues textContrastThreshold "99% black on white" (color%#000000fc) .white).isEmpty + +-- A half-transparent black over white composites to mid-gray, which fails the text threshold. +/-- info: false -/ +#guard_msgs in +#eval (contrastIssues textContrastThreshold "50% black on white" (color%#00000080) .white).isEmpty + +-- A translucent background cannot be contrast-checked: the effective backdrop is unknown. +/-- info: false -/ +#guard_msgs in +#eval (contrastIssues textContrastThreshold "black on translucent" .black (color%#ffffff80)).isEmpty + +-- A red and a green of matched lightness collapse together under deuteranopia (ΔE ≈ 2.1). +/-- info: false -/ +#guard_msgs in +#eval (colorblindIssues distinguishableThreshold #[("red", color%#e60000), ("green", color%#00a000)]).isEmpty + +-- Okabe-Ito blue and orange stay distinct under every dichromacy. +/-- info: true -/ +#guard_msgs in +#eval (colorblindIssues distinguishableThreshold #[("blue", color%#0072b2), ("orange", color%#e69f00)]).isEmpty + +-- The whole colorblind-safe Okabe-Ito palette passes the distinguishability check. +/-- info: true -/ +#guard_msgs in +#eval (colorblindIssues distinguishableThreshold okabeIto).isEmpty + +/-! ## Property tests -/ + +open scoped Plausible.Decorations in +def testProp + (p : Prop) (cfg : Configuration := {}) + (p' : Decorations.DecorationsOf p := by mk_decorations) [Testable p'] : + IO (TestResult p') := + Testable.checkIO p' (cfg := cfg) + +/-- Contrast is monotone in the threshold: passing at 4.5 implies passing at 3.0. -/ +def testContrastMonotone := testProp <| ∀ (fg bg : Color), + !meetsContrast 4.5 fg bg || meetsContrast 3.0 fg bg + +/-- When the contrast check reports no problem, the composited foreground meets the contrast. -/ +def testNoContrastIssueMeansMeets := testProp <| ∀ (fg bg : Color), + !(contrastIssues 4.5 "pair" fg bg).isEmpty || meetsContrast 4.5 (over fg bg) bg + +open Lean (Name) + +def colorAccessibilityTests : List (Name × (Σ p, IO <| TestResult p)) := [ + (`testContrastMonotone, ⟨_, testContrastMonotone⟩), + (`testNoContrastIssueMeansMeets, ⟨_, testNoContrastIssueMeansMeets⟩), +] + +public def runColorAccessibilityTests : IO Nat := do + let mut failures := 0 + for (name, test) in colorAccessibilityTests do + IO.print s!"{name}: " + let res ← test.2 + IO.println res + unless res matches .success .. do + failures := failures + 1 + return failures diff --git a/src/verso/Verso/Color.lean b/src/verso/Verso/Color.lean index 17dd7a0f6..0b3dfe7b5 100644 --- a/src/verso/Verso/Color.lean +++ b/src/verso/Verso/Color.lean @@ -8,5 +8,6 @@ module public import Verso.Color.Types public import Verso.Color.Basic public import Verso.Color.Math +public import Verso.Color.Accessibility public import Verso.Color.Widget public import Verso.Color.Syntax diff --git a/src/verso/Verso/Color/Accessibility.lean b/src/verso/Verso/Color/Accessibility.lean new file mode 100644 index 000000000..70fa21000 --- /dev/null +++ b/src/verso/Verso/Color/Accessibility.lean @@ -0,0 +1,127 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Verso.Color.Types +public import Verso.Color.Math + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +This module contains genre-neutral accessibility predicates and checks built on the color math: + + * WCAG contrast + + * CIEDE2000 distinguishability (including under simulated color vision deficiency) + +-/ + +namespace Verso + +namespace Color + +/-- The kind of accessibility problem found, so a caller can route each to its own severity. -/ +public inductive IssueKind where + | contrast + | colorblind +deriving DecidableEq, Repr + +/-- +An accessibility problem found while checking a set of colors. It carries no severity; the caller +maps {name}`IssueKind` to a severity. {lit}`offending` lists the colors involved, for error +messages. +-/ +public structure Issue where + /-- Whether this is a contrast or a color-vision-deficiency problem. -/ + kind : IssueKind + /-- A human-readable description of the problem. -/ + message : String + /-- The colors involved, for inclusion in error messages. -/ + offending : Array Color := #[] + +/-- The WCAG AA contrast threshold for normal-size text. -/ +public def textContrastThreshold : Float := 4.5 + +/-- The WCAG AA contrast threshold for large text and user-interface components. -/ +public def largeContrastThreshold : Float := 3.0 + +/-- +The CIEDE2000 ΔE above which two colors count as distinguishable. Set in the "clearly perceptible" +range: high enough to be a real difference, low enough not to flag the colorblind-safe Okabe-Ito +palette (which stays above it under all three dichromacies). +-/ +public def distinguishableThreshold : Float := 5.0 + +/-- Whether {lit}`fg` on {lit}`bg` meets the given WCAG contrast ratio. -/ +public def meetsContrast (threshold : Float) (fg bg : Color) : Bool := + contrastRatio fg bg ≥ threshold + +/-- Whether two colors differ by at least {lit}`threshold` in CIEDE2000 ΔE. -/ +public def distinguishable? (threshold : Float) (c1 c2 : Color) : Bool := + deltaE c1 c2 ≥ threshold + +/-- Whether a color is fully opaque. Contrast can only be judged for opaque colors. -/ +public def isOpaque : Color → Bool + | .rgba _ _ _ a => a == 255 + +/-- +Composites {lit}`fg` over {lit}`bg` using {lit}`fg`'s alpha (straight alpha in sRGB, matching how a +browser paints translucent content on a solid background). The result is opaque; the background's +own alpha is ignored. +-/ +public def over (fg bg : Color) : Color := + match fg, bg with + | .rgba rf gf bf af, .rgba rb gb bb _ => + let a := af.toNat.toFloat / 255.0 + let mix (f b : UInt8) : UInt8 := + .ofNat (Nat.min 255 ((a * f.toNat.toFloat + (1.0 - a) * b.toNat.toFloat + 0.5).toUInt64.toNat)) + .rgba (mix rf rb) (mix gf gb) (mix bf bb) 255 + +private def cvdName : CVD → String + | .protanopia => "protanopia" + | .deuteranopia => "deuteranopia" + | .tritanopia => "tritanopia" + +/-- +Checks that {name}`fg` has enough contrast against {name}`bg` for {name}`threshold`. A translucent +{name}`fg` is first composited over {name}`bg` (with {name}`over`), since that is the color actually +seen. The background must be opaque, though: contrast against a translucent background depends on an +unknown backdrop, so a non-opaque {name}`bg` is itself reported as a problem. {name}`description` +names the pair in any issue. +-/ +public def contrastIssues (threshold : Float) (description : String) (fg bg : Color) : Array Issue := + if !isOpaque bg then + #[{ kind := .contrast, + message := s!"{description}: contrast cannot be judged because the background is not opaque", + offending := #[bg] }] + else + let seen := over fg bg + if meetsContrast threshold seen bg then + #[] + else + #[{ kind := .contrast, + message := s!"{description}: contrast ratio {contrastRatio seen bg} is below {threshold}", + offending := #[fg, bg] }] + +/-- +Checks that the given named colors stay mutually distinguishable under each of the three +dichromacies. Reports a {name (full := IssueKind.colorblind)}`colorblind` issue for any pair that +collapses (ΔE below {name}`threshold`) under some simulation. +-/ +public def colorblindIssues (threshold : Float) (colors : Array (String × Color)) : Array Issue := Id.run do + let mut out := #[] + for i in [0:colors.size] do + for j in [i + 1:colors.size] do + let (n1, c1) := colors[i]! + let (n2, c2) := colors[j]! + for cvd in [CVD.protanopia, CVD.deuteranopia, CVD.tritanopia] do + unless distinguishable? threshold (dichromacy cvd c1) (dichromacy cvd c2) do + out := out.push + { kind := .colorblind, + message := s!"{n1} and {n2} are indistinguishable under {cvdName cvd}", + offending := #[c1, c2] } + return out From f7adb2fb28109e3c4f7b0ff37f402ecaf148bc78 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Fri, 29 May 2026 22:07:24 +0200 Subject: [PATCH 05/31] feat: data model and syntax for fonts as Lean values Adds a representation of fonts for use in themes. --- src/tests/Tests.lean | 1 + src/tests/Tests/Font.lean | 70 +++++++++ src/verso-manual/VersoManual/Basic.lean | 13 +- src/verso/Verso.lean | 1 + src/verso/Verso/Font.lean | 194 ++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 src/tests/Tests/Font.lean create mode 100644 src/verso/Verso/Font.lean diff --git a/src/tests/Tests.lean b/src/tests/Tests.lean index 5fadb14d4..136521bd2 100644 --- a/src/tests/Tests.lean +++ b/src/tests/Tests.lean @@ -8,6 +8,7 @@ import Tests.Color import Tests.ColorAccessibility import Tests.ColorMath import Tests.Elab +import Tests.Font import Tests.GenericCode import Tests.Golden import Tests.CommentSkipping diff --git a/src/tests/Tests/Font.lean b/src/tests/Tests/Font.lean new file mode 100644 index 000000000..b311d70cf --- /dev/null +++ b/src/tests/Tests/Font.lean @@ -0,0 +1,70 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +import Verso.Font + +/-! +Compile-time tests for `Verso.Font`: the `define_font_face` command embeds file bytes, `Weight` +literals clamp into range, and `Typeface.cssFamily` renders (and escapes) as expected. There is no +general font file to bundle yet, so an existing KaTeX font stands in for the embedding test. +-/ + +open Verso + +-- `define_font_face` embeds the file's bytes at compile time. +define_font_face katexMono where + format := .woff2 + file := "../../../vendored-js/katex/fonts/KaTeX_Typewriter-Regular.woff2" + +/-- info: true -/ +#guard_msgs in #eval decide (katexMono.bytes.size > 1000) +/-- info: Verso.FontFormat.woff2 -/ +#guard_msgs in #eval katexMono.format +/-- info: Verso.FontStyle.normal -/ +#guard_msgs in #eval katexMono.style + +-- `weight :=` is sugar for `weights := .fixed _`; `style` and the file format are honored. +define_font_face boldItalicFace where + weight := .bold + style := .italic + format := .ttf + file := "../../../vendored-js/katex/fonts/KaTeX_Typewriter-Regular.ttf" + +/-- info: some 700 -/ +#guard_msgs in +#eval match boldItalicFace.weights with + | .fixed w => some w.val + | _ => none +/-- info: true -/ +#guard_msgs in #eval boldItalicFace.style = .italic + +-- Named weights and the clamping `OfNat`. +/-- info: 400 -/ +#guard_msgs in #eval Weight.regular.val +/-- info: 600 -/ +#guard_msgs in #eval Weight.semibold.val +/-- info: 1000 -/ +#guard_msgs in #eval (1500 : Weight).val +/-- info: 1 -/ +#guard_msgs in #eval (0 : Weight).val + +-- Default typefaces expand to system stacks; a custom family is quoted and escaped. +/-- info: "ui-sans-serif, system-ui, sans-serif" -/ +#guard_msgs in #eval Typeface.sans.cssFamily +/-- info: "\"Fancy\"" -/ +#guard_msgs in #eval (Typeface.files "Fancy" #[]).cssFamily +/-- info: "\"a\\\"b\"" -/ +#guard_msgs in #eval (Typeface.files "a\"b" #[]).cssFamily + +-- A missing required field is an error. +/-- error: `define_font_face` requires a `format` field -/ +#guard_msgs in +define_font_face noFormat where + file := "x.woff2" + +/-- error: `define_font_face` requires a `file` field -/ +#guard_msgs in +define_font_face noFile where + format := .ttf diff --git a/src/verso-manual/VersoManual/Basic.lean b/src/verso-manual/VersoManual/Basic.lean index f40cf1f54..cb31d5f40 100644 --- a/src/verso-manual/VersoManual/Basic.lean +++ b/src/verso-manual/VersoManual/Basic.lean @@ -7,6 +7,7 @@ module import Std.Data.HashSet import Std.Data.TreeSet import Verso.Doc +public import Verso.Font public import Verso.Instances public import Verso.Doc.Html public import Verso.Doc.TeX @@ -83,16 +84,8 @@ def toCss (family : FontFamily) : String := s!"font-family: var({family.toCssVar end FontFamily -inductive FontStyle where - | normal - | italic -deriving DecidableEq, Repr, Hashable - -def FontStyle.toCss (s : FontStyle) : String := - "font-style: " ++ - match s with - | .normal => "normal;" - | .italic => "italic;" +-- `FontStyle` was previously defined here; it is re-exported here for backwards compatibility +export Verso (FontStyle) inductive FontWeight where | lighter diff --git a/src/verso/Verso.lean b/src/verso/Verso.lean index 29c24bf84..5b19f8209 100644 --- a/src/verso/Verso.lean +++ b/src/verso/Verso.lean @@ -21,6 +21,7 @@ public import Verso.Doc.Lsp public import Verso.Doc.Suggestion public import Verso.Doc.TeX public import Verso.ExpectString +public import Verso.Font public import Verso.Hover public import Verso.Instances public import Verso.Linters diff --git a/src/verso/Verso/Font.lean b/src/verso/Verso/Font.lean new file mode 100644 index 000000000..4998ab6b9 --- /dev/null +++ b/src/verso/Verso/Font.lean @@ -0,0 +1,194 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public meta import Lean.Elab.Command +public import VersoUtil.BinFiles + +set_option linter.missingDocs true +set_option doc.verso true + +namespace Verso + +/-- Whether a font is upright or italic. -/ +public inductive FontStyle where + | normal + | italic +deriving DecidableEq, Repr, Hashable + +/-- The CSS {lit}`font-style` declaration for a style. -/ +public def FontStyle.toCss (s : FontStyle) : String := + "font-style: " ++ + match s with + | .normal => "normal;" + | .italic => "italic;" + +/-- +A font weight in the CSS range 1–1000 (e.g. 400 for regular, 700 for bold). +-/ +public structure Weight where + /-- The numeric weight, between 1 and 1000 inclusive. -/ + val : Nat + /-- The weight is at least 1. -/ + lo : 1 ≤ val := by grind + /-- The weight is at most 1000. -/ + hi : val ≤ 1000 := by grind + +/-- +Numeric literals can be use for weights. + +They are clamped into the valid 1–1000 range, so a weight can be written as a plain number with no +proof at the call site. +-/ +public instance (n : Nat) : OfNat Weight n where + ofNat.val := max 1 (min 1000 n) + +namespace Weight + +/-- Thin (100). -/ +public abbrev thin : Weight := 100 +/-- Extra light (200). -/ +public abbrev extraLight : Weight := 200 +/-- Light (300). -/ +public abbrev light : Weight := 300 +/-- Regular (400). -/ +public abbrev regular : Weight := 400 +/-- Medium (500). -/ +public abbrev medium : Weight := 500 +/-- Semibold (600). -/ +public abbrev semibold : Weight := 600 +/-- Bold (700). -/ +public abbrev bold : Weight := 700 +/-- Extra bold (800). -/ +public abbrev extraBold : Weight := 800 +/-- Black (900). -/ +public abbrev black : Weight := 900 + +end Weight + +/-- A font file format. -/ +public inductive FontFormat where + | woff2 + | woff + | otf + | ttf +deriving DecidableEq, Repr + +/-- +The format hint for this file kind: the string written inside CSS {lit}`format("...")` in the +{lit}`src` entry of a {lit}`@font-face` rule (for example, {lit}`"woff2"` or {lit}`"opentype"`). +-/ +public def FontFormat.css : FontFormat → String + | .woff2 => "woff2" + | .woff => "woff" + | .otf => "opentype" + | .ttf => "truetype" + +/-- +The weight or weights provided by a single font file: one fixed weight, or a variable-font range +carrying a proof that the low weight does not exceed the high weight. +-/ +public inductive FaceWeights where + | fixed (weight : Weight) + | variable (lo hi : Weight) (le : lo.val ≤ hi.val := by grind) + +/-- One font file: the weights and style it provides, its format, and its embedded bytes. -/ +public structure FontFace where + /-- The weight or weight range this file provides. -/ + weights : FaceWeights := .fixed .regular + /-- The style (upright or italic) this file provides. -/ + style : FontStyle := .normal + /-- The file's format. -/ + format : FontFormat + /-- The file's contents, embedded at compile time. -/ + bytes : ByteArray + +/-- +{open Typeface} + +A concrete font for theme text. The {name}`sans`, {name}`serif`, and {name}`mono` constructors refer +to some arbitrary built-in family that may vary from system to system, while {name}`files` is used +to refer to specific files. +-/ +public inductive Typeface where + /-- Some sans-serif font -/ + | sans + /-- Some serif font -/ + | serif + /-- Some monospace font -/ + | mono + /-- A specific font, provided as files -/ + | files (family : String) (faces : Array FontFace) + +/-- Quotes a CSS string, escaping backslashes and double quotes so the name cannot break out. -/ +private def cssQuote (s : String) : String := + "\"" ++ (s.replace "\\" "\\\\" |>.replace "\"" "\\\"") ++ "\"" + +/-- +The CSS {lit}`font-family` value for a typeface. +-/ +public def Typeface.cssFamily : Typeface → String + | .sans => "ui-sans-serif, system-ui, sans-serif" + | .serif => "ui-serif, Georgia, Cambria, serif" + | .mono => "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" + | .files family _ => cssQuote family + +/-! # Defining font faces -/ + +/-- +Defines a {name}`FontFace` whose {name}`FontFace.bytes` are embedded from a file at compile time. + +The fields, in any order: {lit}`format` (required), {lit}`file` (required, a string literal path +relative to the current source file), {lit}`weights` (defaults to {lit}`.fixed .regular`), +{lit}`weight` (sugar: {lit}`weight := w` means {lit}`weights := .fixed w`), and {lit}`style` +(defaults to {lit}`.normal`). For example: + +``` +define_font_face sourceSansVariable where + weights := .variable .thin .black + format := .ttf + file := "fonts/SourceSans3-VF.ttf" +``` +-/ +syntax (name := defineFontFace) "define_font_face " ident " where" manyIndent(group(withPosition(ident " := " colGt term))) : command + +open Lean Elab Command in +/-- Elaborates a {lit}`define_font_face` command into a {name}`FontFace` definition. -/ +@[command_elab defineFontFace] +public meta def elabDefineFontFace : CommandElab := fun stx => do + let nameIdent := stx[1] + let mut weights? : Option Term := none + let mut style? : Option Term := none + let mut format? : Option Term := none + let mut file? : Option (TSyntax `str) := none + for f in stx[3].getArgs do + let key := f[0].getId + let val : Term := ⟨f[2]⟩ + match key with + | `weights => weights? := some val + | `weight => weights? := some (← `(Verso.FaceWeights.fixed $val)) + | `style => style? := some val + | `format => format? := some val + | `file => + if f[2].isStrLit?.isSome then file? := some ⟨f[2]⟩ + else throwErrorAt f[2] "the `file` field must be a string literal" + | other => throwErrorAt f[0] s!"unknown field `{other}`; expected weights, weight, style, format, or file" + let some format := format? + | throwError "`define_font_face` requires a `format` field" + let some file := file? + | throwError "`define_font_face` requires a `file` field" + let weights ← + match weights? with + | some w => pure w + | none => ``(Verso.FaceWeights.fixed Verso.Weight.regular) + let style ← + match style? with + | some s => pure s + | none => ``(Verso.FontStyle.normal) + let name : Ident := ⟨nameIdent⟩ + elabCommand <| ← + `(public def $name : Verso.FontFace := + { weights := $weights, style := $style, format := $format, bytes := include_bin $file }) From aa9ed3ad722389e50f214604d6a802adf787ff02 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Sat, 30 May 2026 03:19:15 +0200 Subject: [PATCH 06/31] feat: code themes Adds a first-class Lean representation of themes for rendered Lean code. --- .github/workflows/ci.yml | 8 + browser-tests/theme-customization/__init__.py | 0 .../test_theme_customization.py | 149 ++++ lakefile.lean | 9 + src/tests/TestMain.lean | 28 +- src/tests/Tests/Arbitrary.lean | 14 +- src/tests/Tests/Color.lean | 2 +- src/tests/Tests/ColorAccessibility.lean | 6 +- src/tests/Tests/ColorMath.lean | 10 +- src/tests/ThemeTestDoc.lean | 40 + src/tests/ThemeTestMain.lean | 57 ++ src/tests/golden/.gitignore | 1 + src/tests/golden/theme-css/default.expected | 693 ++++++++++++++++++ src/tests/golden/theme-css/default.input | 1 + src/verso-manual/VersoManual.lean | 45 ++ src/verso-manual/VersoManual/Html.lean | 1 + src/verso-search/VersoSearch.lean | 2 +- src/verso/Verso.lean | 4 +- src/verso/Verso/Code/Highlighted.lean | 103 ++- src/verso/Verso/Color.lean | 13 - src/verso/Verso/Font.lean | 10 +- src/verso/Verso/Theme/Code.lean | 445 +++++++++++ src/verso/Verso/Theme/Code/Defaults.lean | 27 + src/verso/Verso/Theme/Code/Ext.lean | 28 + src/verso/Verso/Theme/Color.lean | 13 + .../{ => Theme}/Color/Accessibility.lean | 19 +- src/verso/Verso/{ => Theme}/Color/Basic.lean | 6 +- src/verso/Verso/{ => Theme}/Color/Math.lean | 6 +- src/verso/Verso/{ => Theme}/Color/Syntax.lean | 12 +- src/verso/Verso/{ => Theme}/Color/Types.lean | 2 +- src/verso/Verso/{ => Theme}/Color/Widget.lean | 6 +- .../Verso/{ => Theme}/Color/color-swatch.js | 0 32 files changed, 1643 insertions(+), 117 deletions(-) create mode 100644 browser-tests/theme-customization/__init__.py create mode 100644 browser-tests/theme-customization/test_theme_customization.py create mode 100644 src/tests/ThemeTestDoc.lean create mode 100644 src/tests/ThemeTestMain.lean create mode 100644 src/tests/golden/.gitignore create mode 100644 src/tests/golden/theme-css/default.expected create mode 100644 src/tests/golden/theme-css/default.input delete mode 100644 src/verso/Verso/Color.lean create mode 100644 src/verso/Verso/Theme/Code.lean create mode 100644 src/verso/Verso/Theme/Code/Defaults.lean create mode 100644 src/verso/Verso/Theme/Code/Ext.lean create mode 100644 src/verso/Verso/Theme/Color.lean rename src/verso/Verso/{ => Theme}/Color/Accessibility.lean (88%) rename src/verso/Verso/{ => Theme}/Color/Basic.lean (95%) rename src/verso/Verso/{ => Theme}/Color/Math.lean (98%) rename src/verso/Verso/{ => Theme}/Color/Syntax.lean (91%) rename src/verso/Verso/{ => Theme}/Color/Types.lean (97%) rename src/verso/Verso/{ => Theme}/Color/Widget.lean (91%) rename src/verso/Verso/{ => Theme}/Color/color-swatch.js (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d4e8b25c..363616b03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,6 +191,14 @@ jobs: browser-tests/test_redirect.py \ browser-tests/test_katex.py -v + - name: Run theme customization browser test + run: | + lake build theme-test-site + rm -rf _out/theme-test + lake exe theme-test-site --output _out/theme-test + uv run --project browser-tests --extra test pytest \ + browser-tests/theme-customization -v + - name: Build the VersoHtml site for browser tests run: | # The verso-html genre renders literate JSON into a standalone HTML site. diff --git a/browser-tests/theme-customization/__init__.py b/browser-tests/theme-customization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/browser-tests/theme-customization/test_theme_customization.py b/browser-tests/theme-customization/test_theme_customization.py new file mode 100644 index 000000000..6fe98da99 --- /dev/null +++ b/browser-tests/theme-customization/test_theme_customization.py @@ -0,0 +1,149 @@ +""" +Browser test that pins a representative subset of themed CSS variables to their rendered DOM +values. The test currently exercises the four token color fields (keyword, const, var, fallback) +plus the error indicator border. Other themed fields are emitted to `verso-themes.css` but are +not all asserted here; expanding coverage is straightforward as more rendered features land in +the small test document. + +Workflow: + +1. Build the `theme-test-site` Lean exe, which renders a small Manual document with a + customized `CodeTheme` whose every color field holds a distinct sentinel value. +2. Serve the resulting `_out/theme-test/html-multi` directory. +3. For each themed element in the generated HTML, read its computed style and assert it + matches the sentinel hex value the Lean theme set for that field. + +If the rendered color drifts from the Lean theme value, the theme pipeline +(`CodeTheme.cssVariables` -> generated `verso-themes.css` -> `highlightingStyle` +`var(--verso-*)` lookups) is broken end-to-end. +""" + +import socket +import subprocess +import time +from pathlib import Path + +import pytest +from playwright.sync_api import sync_playwright + + +HERE = Path(__file__).parent +REPO_ROOT = HERE.parent.parent +SITE_DIR = REPO_ROOT / "_out" / "theme-test" / "html-multi" + + +def _hex_to_rgb(h: str) -> str: + h = h.lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4)) + return f"rgb({r}, {g}, {b})" + + +# Sentinel colors mirroring `src/tests/ThemeTestMain.lean`. +THEME = { + "background": "#000101", + "codeBlockBackground": "#000202", + "textColor": "#000303", + "codeColor": "#000404", + "selectedColor": "#000606", + "infoIndicatorColor": "#000808", + "warningIndicatorColor": "#000a0a", + "errorIndicatorColor": "#000c0c", + "hoverBackground": "#000d0d", + "constColor": "#001717", + "keywordColor": "#001818", + "varColor": "#001919", +} + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture(scope="module") +def built_site(): + """Rebuilds the customized-theme test site under `_out/theme-test`.""" + subprocess.check_call( + ["lake", "build", "theme-test-site"], + cwd=REPO_ROOT, + ) + if SITE_DIR.exists(): + # Wipe so a stale build can't pass a renamed/deleted assertion. + subprocess.check_call(["rm", "-rf", str(SITE_DIR.parent)], cwd=REPO_ROOT) + subprocess.check_call( + ["lake", "exe", "theme-test-site", "--output", "_out/theme-test"], + cwd=REPO_ROOT, + ) + assert SITE_DIR.exists(), f"Manual build did not produce {SITE_DIR}" + return SITE_DIR + + +@pytest.fixture(scope="module") +def server(built_site): + port = _find_free_port() + proc = subprocess.Popen( + ["python", "-m", "http.server", str(port), "--bind", "127.0.0.1"], + cwd=built_site, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(0.5) + try: + yield f"http://127.0.0.1:{port}" + finally: + proc.terminate() + proc.wait() + + +@pytest.fixture(scope="module") +def playwright_instance(): + with sync_playwright() as p: + yield p + + +@pytest.fixture(scope="module", params=["chromium", "firefox"]) +def page(request, playwright_instance, server): + browser = getattr(playwright_instance, request.param).launch() + page = browser.new_page() + page.goto(server + "/Code-samples/") + yield page + browser.close() + + +def _color(page, selector: str, prop: str = "color") -> str: + return page.evaluate( + "([sel, p]) => getComputedStyle(document.querySelector(sel)).getPropertyValue(p)", + [selector, prop], + ).strip() + + +def _expect(actual: str, hex_value: str, description: str) -> None: + expected = _hex_to_rgb(hex_value) + assert actual == expected, f"{description}: expected {expected} ({hex_value}), got {actual}" + + +def test_token_colors(page): + # The first `.keyword` in the only code block is `def`. + _expect(_color(page, "code.hl.lean .keyword"), THEME["keywordColor"], "keyword color") + # `.const` on `hello` (the function name) and `String` (the type). + _expect(_color(page, "code.hl.lean .const"), THEME["constColor"], "const color") + # `.var` on `name` (the parameter binding). + _expect(_color(page, "code.hl.lean .var"), THEME["varColor"], "var color") + # `.unknown` (operator-like tokens) falls back to `--verso-code-color`. + _expect(_color(page, "code.hl.lean .unknown"), THEME["codeColor"], "fallback code color") + + +def _goto_diagnostics(page, server): + page.goto(server + "/Diagnostics/") + + +def test_error_indicator(page, server): + _goto_diagnostics(page, server) + _expect( + _color(page, "pre.lean-output.error", "border-left-color"), + THEME["errorIndicatorColor"], + "lean-output.error indicator border", + ) diff --git a/lakefile.lean b/lakefile.lean index cea6eac61..89750d2af 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -134,6 +134,15 @@ lean_exe «verso-tests» where srcDir := "src/tests" supportInterpreter := true +lean_lib ThemeTestDoc where + srcDir := "src/tests" + roots := #[`ThemeTestDoc] + +lean_exe «theme-test-site» where + root := `ThemeTestMain + srcDir := "src/tests" + supportInterpreter := true + lean_lib UsersGuide where srcDir := "doc" leanOptions := #[⟨`weak.linter.verso.manual.headerTags, true⟩] diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean index 42452a3dd..8e3059af5 100644 --- a/src/tests/TestMain.lean +++ b/src/tests/TestMain.lean @@ -167,6 +167,31 @@ def testSearchJs (_ : Config) : IO Unit := do if fails > 0 then throw <| IO.userError s!"{fails} search JS tests failed" +open Verso in +/-- +Golden test for the default code theme's generated CSS. The expected fixture lives at +`src/tests/golden/theme-css/default.expected` and is regenerated with `--update-expected`. +-/ +def testThemeCss (cfg : Config) : IO Unit := do + IO.println "Running theme CSS golden test..." + let runTest (input : String) : IO String := do + let name := input.trimAscii + if name == "default" then + let varsBlock := s!":root \{\n{Theme.CodeTheme.Default.cssVariables}}\n" + let combined := varsBlock ++ "\n" ++ Code.highlightingStyle + -- Trim the trailing blank lines `highlightingStyle` ships with so the golden file + -- ends with a single newline (otherwise `git diff --check` flags the EOF blank). + let mut out := combined + while out.endsWith "\n\n" do out := (out.dropEnd 1).copy + return out + else + throw <| IO.userError s!"Unknown theme: {name}" + GoldenTest.runTests { + testDir := "src/tests/golden/theme-css", + updateExpected := cfg.updateExpected, + runTest + } + def testBlog (_ : Config) : IO Unit := do IO.println "Running blog tests with Plausible..." let fails ← runBlogTests @@ -361,7 +386,7 @@ def testBuildLog (_ : Config) : IO Unit := do throw <| IO.userError "redirected logging should still accumulate into the logger's buffers" IO.println " All build-log tests passed." -open Verso in +open Verso Theme in def testColor (_ : Config) : IO Unit := do IO.println "Running color tests..." let check (name got expected : String) : IO Unit := @@ -397,6 +422,7 @@ def tests := [ testColor, testColorMath, testColorAccessibility, + testThemeCss, testSerialization, testSearchJs, testBlog, diff --git a/src/tests/Tests/Arbitrary.lean b/src/tests/Tests/Arbitrary.lean index 8e526decc..decd6b82c 100644 --- a/src/tests/Tests/Arbitrary.lean +++ b/src/tests/Tests/Arbitrary.lean @@ -18,7 +18,7 @@ public meta import VersoManual.LicenseInfo public meta import VersoSearch public meta import VersoSearch.DomainSearch public meta import Verso.Output.Html -public meta import Verso.Color.Types +public meta import Verso.Theme.Color.Types public meta import MultiVerso.Manifest public meta import VersoManual.Basic import all VersoManual.Basic @@ -439,17 +439,17 @@ instance : Shrinkable System.FilePath where parent :: (path.fileName.toList.flatMap shrink |>.map path.withFileName) else [] -instance : Arbitrary Verso.Color where +instance : Arbitrary Verso.Theme.Color where arbitrary := do -- Bias the alpha channel: fully opaque (the common case) 60% of the time, fully transparent 5%, -- and uniformly random the rest. A uniform alpha would almost never be exactly opaque. let a ← frequency (pure 255) [(60, pure 255), (5, pure 0), (35, arbitrary)] return .rgba (← arbitrary) (← arbitrary) (← arbitrary) a -instance : Shrinkable Verso.Color where +instance : Shrinkable Verso.Theme.Color where shrink | .rgba r g b a => - (shrink r |>.map (Verso.Color.rgba · g b a)) ++ - (shrink g |>.map (Verso.Color.rgba r · b a)) ++ - (shrink b |>.map (Verso.Color.rgba r g · a)) ++ - (shrink a |>.map (Verso.Color.rgba r g b ·)) + (shrink r |>.map (Verso.Theme.Color.rgba · g b a)) ++ + (shrink g |>.map (Verso.Theme.Color.rgba r · b a)) ++ + (shrink b |>.map (Verso.Theme.Color.rgba r g · a)) ++ + (shrink a |>.map (Verso.Theme.Color.rgba r g b ·)) diff --git a/src/tests/Tests/Color.lean b/src/tests/Tests/Color.lean index 9e892cee0..834db3ef4 100644 --- a/src/tests/Tests/Color.lean +++ b/src/tests/Tests/Color.lean @@ -3,7 +3,7 @@ Copyright (c) 2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -import Verso.Color +import Verso.Theme.Color import Lean.Elab.Command /-! diff --git a/src/tests/Tests/ColorAccessibility.lean b/src/tests/Tests/ColorAccessibility.lean index 62c5ebc1a..d868b4794 100644 --- a/src/tests/Tests/ColorAccessibility.lean +++ b/src/tests/Tests/ColorAccessibility.lean @@ -5,15 +5,15 @@ Author: David Thrane Christiansen -/ module public import Plausible -public meta import Verso.Color +public meta import Verso.Theme.Color public meta import Tests.Arbitrary /-! -Unit and property tests for the accessibility predicates and checks in `Verso.Color.Accessibility`. +Unit and property tests for the accessibility predicates and checks in `Verso.Theme.Color.Accessibility`. -/ open Plausible Gen Arbitrary Shrinkable -open Verso Verso.Color +open Verso Verso.Theme Verso.Theme.Color meta section diff --git a/src/tests/Tests/ColorMath.lean b/src/tests/Tests/ColorMath.lean index 55c29afe1..f3bd1ec78 100644 --- a/src/tests/Tests/ColorMath.lean +++ b/src/tests/Tests/ColorMath.lean @@ -5,16 +5,16 @@ Author: David Thrane Christiansen -/ module public import Plausible -public meta import Verso.Color -import all Verso.Color.Math +public meta import Verso.Theme.Color +import all Verso.Theme.Color.Math public meta import Tests.Arbitrary /-! -Unit and property tests for the color math in `Verso.Color.Math`. +Unit and property tests for the color math in `Verso.Theme.Color.Math`. -/ open Plausible Gen Arbitrary Shrinkable -open Verso Verso.Color +open Verso Verso.Theme Verso.Theme.Color meta section @@ -46,7 +46,7 @@ instance : Shrinkable CVD where #eval (deltaE .black .black, deltaE .white .black) -- Dichromacy simulation leaves the gray axis unchanged. -/-- info: (Verso.Color.rgba 128 128 128 255, Verso.Color.rgba 128 128 128 255, Verso.Color.rgba 128 128 128 255) -/ +/-- info: (Verso.Theme.Color.rgba 128 128 128 255, Verso.Theme.Color.rgba 128 128 128 255, Verso.Theme.Color.rgba 128 128 128 255) -/ #guard_msgs in #eval (dichromacy .protanopia .gray, dichromacy .deuteranopia .gray, dichromacy .tritanopia .gray) diff --git a/src/tests/ThemeTestDoc.lean b/src/tests/ThemeTestDoc.lean new file mode 100644 index 000000000..7cc2456f4 --- /dev/null +++ b/src/tests/ThemeTestDoc.lean @@ -0,0 +1,40 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ + +import VersoManual + +open Verso.Genre Manual +open Verso.Genre.Manual.InlineLean + +set_option pp.rawOnError true + +#doc (Manual) "Theme test" => + +# Code samples + +A line of prose, followed by code that mixes a keyword, a const, and a literal. + +```lean +def hello (name : String) : String := s!"hello, {name}" +``` + +# Diagnostics + +A block that errors, so the rendered HTML carries the `.lean-output.error` rule used by the +theme's error indicator color: + +```lean +error (name := badProof) +example : 2 + 2 = 5 := by rfl +``` + +```leanOutput badProof +Tactic `rfl` failed: The left-hand side + 2 + 2 +is not definitionally equal to the right-hand side + 5 + +⊢ 2 + 2 = 5 +``` diff --git a/src/tests/ThemeTestMain.lean b/src/tests/ThemeTestMain.lean new file mode 100644 index 000000000..8b2834ae2 --- /dev/null +++ b/src/tests/ThemeTestMain.lean @@ -0,0 +1,57 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ + +import Verso +import VersoManual +import ThemeTestDoc + +open Verso Verso.Theme +open Verso.Genre Manual + +/-! +The customized {Lean.Doc.name}`Verso.Theme.CodeTheme` used by the browser test. Each color field +holds a distinct sentinel hex value so Playwright can identify which theme field a rendered DOM +color comes from. +-/ +def testTheme : CodeTheme := { + name := "ThemeTest", + appearance := .light, + background := color%#000101, + codeBlockBackground := color%#000202, + textColor := color%#000303, + codeColor := color%#000404, + structureColor := color%#000505, + selectedColor := color%#000606, + infoColor := color%#000707, + infoIndicatorColor := color%#000808, + warningColor := color%#000909, + warningIndicatorColor := color%#000a0a, + errorColor := color%#000b0b, + errorIndicatorColor := color%#000c0c, + hoverBackground := color%#000d0d, + hoverBorderColor := color%#000e0e, + hoverText := color%#000f0f, + hoverSeparatorColor := color%#001010, + tokenHighlightBackground := color%#001111, + tacticStateBackground := color%#001212, + tacticStateBorderColor := color%#001313, + highlightOnCode := color%#001414, + highlightOnText := color%#001515, + uiOnCode := color%#001616, + const := { color := color%#001717, weight := 500, style := .italic, face := .sans }, + keyword := { color := color%#001818, weight := 800, style := .italic, face := .serif }, + «var» := { color := color%#001919, weight := 300, style := .normal, face := .mono }, +} + +def config : Config where + emitTeX := false + emitHtmlSingle := .no + emitHtmlMulti := .immediately + htmlDepth := 1 + +def main : List String → IO UInt32 := + manualMain (%doc ThemeTestDoc) + (config := { config with codeTheme := testTheme }) diff --git a/src/tests/golden/.gitignore b/src/tests/golden/.gitignore new file mode 100644 index 000000000..7de0b67ed --- /dev/null +++ b/src/tests/golden/.gitignore @@ -0,0 +1 @@ +*.output diff --git a/src/tests/golden/theme-css/default.expected b/src/tests/golden/theme-css/default.expected new file mode 100644 index 000000000..47a7eaea4 --- /dev/null +++ b/src/tests/golden/theme-css/default.expected @@ -0,0 +1,693 @@ +:root { + --verso-background-color: #ffffff; + --verso-code-background-color: #ffffff; + --verso-text-color: #000000; + --verso-code-color: #000000; + --verso-structure-color: #000000; + --verso-selected-color: #ddeeff; + --verso-info-color: #000000; + --verso-info-indicator-color: #4777ff; + --verso-warning-color: #000000; + --verso-warning-indicator-color: #e7a71d; + --verso-error-color: #cc0000; + --verso-error-indicator-color: #ff0000; + --verso-code-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --verso-code-const-color: #000000; + --verso-code-const-weight: 400; + --verso-code-const-style: normal; + --verso-code-const-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --verso-code-keyword-color: #000000; + --verso-code-keyword-weight: 700; + --verso-code-keyword-style: normal; + --verso-code-keyword-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --verso-code-var-color: #000000; + --verso-code-var-weight: 400; + --verso-code-var-style: italic; + --verso-code-var-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --verso-hover-background-color: #e5e5e5; + --verso-hover-border-color: #000000; + --verso-hover-text-color: #000000; + --verso-hover-separator-color: #cccccc; + --verso-token-highlight-background-color: #eeeeee; + --verso-tactic-state-background-color: #ffffff; + --verso-tactic-state-border-color: #888888; + --verso-highlight-on-code-color: #fff3b0; + --verso-highlight-on-text-color: #fff3b0; + --verso-ui-on-code-color: #888888; +} + + + +.hl.lean { + white-space: pre; + font-weight: normal; + font-style: normal; + font-size: inherit; +} + +.hl.lean .keyword { + color: var(--verso-code-keyword-color,); + font-weight: var(--verso-code-keyword-weight, bold); + font-style: var(--verso-code-keyword-style, normal); + font-family: var(--verso-code-keyword-font-family,); +} + +.hl.lean .const { + color: var(--verso-code-const-color,); + font-weight: var(--verso-code-const-weight, normal); + font-style: var(--verso-code-const-style, normal); + font-family: var(--verso-code-const-font-family,); +} + +.hl.lean .var { + color: var(--verso-code-var-color,); + font-weight: var(--verso-code-var-weight, normal); + font-style: var(--verso-code-var-style, italic); + font-family: var(--verso-code-var-font-family,); + + position: relative; +} + +.hl.lean .literal, .hl.lean .unknown { + color: var(--verso-code-color,); + font-weight: normal; + font-style: normal; + font-family: var(--verso-code-font-family,); +} + +.hover-container { + width: 0; + height: 0; + position: relative; + display: inline; +} + +.hl.lean a { + color: inherit; + text-decoration: currentcolor underline dotted; +} + +.hl.lean a:hover { + text-decoration: currentcolor underline solid; +} + +.hl.lean .hover-info { + white-space: normal; +} + +.hl.lean .token .hover-info { + display: none; + position: absolute; + background-color: var(--verso-hover-background-color, #e5e5e5); + border: 1px solid var(--verso-hover-border-color, black); + padding: 0.5rem; + z-index: 300; +} + +.hl.lean .hover-info.messages { + max-height: 10rem; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + padding: 0 0.5rem 0 0; + display: block; +} + +.hl.lean .hover-info code { + white-space: pre-wrap; + background: none; + color: var(--verso-hover-text-color, black); +} + +.hl.lean .hover-info.messages > code { + padding: 0.5rem; + display: block; + width: fit-content; +} + +.hl.lean .hover-info.messages > code:only-child { + margin: 0; +} + +.hl.lean .hover-info.messages > code { + margin: 0.1rem; +} + +.hl.lean .hover-info.messages > code:not(:first-child) { + margin-top: 0rem; +} + +.hl.lean { +} + +.hl.lean.block { + display: block; +} + +.hl.lean.inline { + display: inline; + white-space: pre-wrap; +} + +.hl.lean * { +} + +.hl.lean .token { + transition: all 0.25s; /* Slight fade for highlights */ +} + +@media (hover: hover) { + .hl.lean .token.binding-hl, .hl.lean .literal.string:hover, .hl.lean .token.typed:hover { + background-color: var(--verso-token-highlight-background-color, #eeeeee); + border-radius: 2px; + transition: none; + } +} + + +.hl.lean .has-info .token:not(.tactic-state):not(.tactic-state *), .hl.lean .has-info .inter-text:not(.tactic-state):not(.tactic-state *) { + text-decoration-style: wavy; + text-decoration-line: underline; + text-decoration-thickness: from-font; + text-decoration-skip-ink: none; +} + +.hl.lean .has-info .hover-info { + display: none; + position: absolute; + transform: translate(0.25rem, 0.3rem); + border: 1px solid var(--verso-hover-border-color, black); + padding: 0.5rem; + z-index: 400; + text-align: left; +} + +.hl.lean .has-info.error :not(.tactic-state):not(.tactic-state *){ + text-decoration-color: var(--verso-error-indicator-color, red); +} + +@media (hover: hover) { + .hl.lean .has-info.error:hover { + background-color: var(--verso-token-highlight-background-color, #eeeeee); + } +} + +.hl.lean .hover-info.messages > code.error { + background-color: var(--verso-hover-background-color, #e5e5e5); + border-left: 0.2rem solid var(--verso-error-indicator-color, red); +} + +.tippy-box[data-theme~='error'] .hl.lean .hover-info.messages > code.error { + background: none; + border: none; +} + +.error .verso-message, .error .verso-message .token, .error .verso-message label { + color: var(--verso-error-color); +} + +.error .verso-message .case-label:has(input[type="checkbox"])::before { + background-color: var(--verso-error-color) !important; +} + +.hl.lean .has-info.warning :not(.tactic-state):not(.tactic-state *) { + text-decoration-color: var(--verso-warning-indicator-color); +} + +@media (hover: hover) { + .hl.lean .has-info.warning:hover { + background-color: var(--verso-token-highlight-background-color, #eeeeee); + } +} + +.hl.lean .hover-info.messages > code.warning { + background-color: var(--verso-hover-background-color, #e5e5e5); +} + +.lean-output { + border-left: 0.2em solid transparent; + padding: 0 0 0 0.5em; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.lean-output.error { + border-color: var(--verso-error-indicator-color); +} + +.lean-output.information { + border-color: var(--verso-info-indicator-color); +} + +.lean-output.warning { + border-color: var(--verso-warning-indicator-color); +} + +.tippy-box[data-theme~='warning'] .hl.lean .hover-info.messages > code.warning { + background: none; + border: none; +} + + +.hl.lean .has-info.information :not(.tactic-state):not(.tactic-state *) { + text-decoration-color: var(--verso-info-indicator-color, blue); +} + +@media (hover: hover) { + .hl.lean .has-info.information:hover { + background-color: var(--verso-token-highlight-background-color, #eeeeee); + } +} + + +.hl.lean .hover-info.messages > code.information { + background-color: var(--verso-hover-background-color, #e5e5e5); + border-left: 0.2rem solid var(--verso-info-indicator-color, blue); +} + +.tippy-box[data-theme~='info'] .hl.lean .hover-info.messages > code.information { + background: none; + border: none; +} + +.hl.lean div.docstring { + font-family: var(--verso-text-font-family, sans-serif); + white-space: normal; + max-width: calc(min(40rem, 90vw)); + width: max-content; +} + +.hl.lean div.docstring > :last-child { + margin-bottom: 0; +} + +.hl.lean div.docstring > :first-child { + margin-top: 0; +} + +.hl.lean .hover-info .sep { + display: block; + width: auto; + margin-left: 1rem; + margin-right: 1rem; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + padding: 0; + height: 1px; + border-top: 1px solid var(--verso-hover-separator-color, #cccccc); +} + +.hl.lean code { + font-family: var(--verso-code-font-family); +} + +.hl.lean .tactic-state { + display: none; + position: relative; + width: fit-content; + border: 1px solid var(--verso-tactic-state-border-color, #888888); + border-radius: 0.1rem; + padding: 0.5rem; + font-family: sans-serif; + background-color: var(--verso-tactic-state-background-color, #ffffff); +} + +.hl.lean.popup .tactic-state { + position: static; + display: block; + width: auto; + border: none; + padding: 0.5rem; + font-family: sans-serif; + background-color: var(--verso-tactic-state-background-color, #ffffff); +} + + +.hl.lean .tactic { + position: relative; + display: inline; + vertical-align: top; + /* Without these, mobile Safari will start making font sizes inconsistent when its text size adjustment feature is triggered.*/ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +.hl.lean .tactic:has(.tactic-toggle:checked) { + display: inline-grid; + grid-template-columns: 1fr; +} + +.hl.lean .tactic-toggle:checked ~ .tactic-state { + display: inline-block; + vertical-align: top; + grid-row: 2; + justify-self: start; +} + +.hl.lean .tactic > label { + position: relative; + grid-row: 1; + display: inline; +} + +@media (hover: hover) { + .hl.lean .tactic:has(.tactic-toggle:not(:checked)) > label:hover { + background-color: var(--verso-token-highlight-background-color, #eeeeee); + } +} + +.hl.lean .tactic-toggle { + position: absolute; + top: 0; + left: 0; + opacity: 0; + height: 0; + width: 0; + z-index: -10; +} + +.hl.lean .tactic > label::after { + content: ""; + border: 1px solid var(--verso-ui-on-code-color, #888888); + /* These need to be em, not rem, to scale with the font */ + border-radius: 1em; + height: 0.25em; + vertical-align: middle; + width: 0.6em; + margin-left: 0.1em; + margin-right: 0.1em; + display: inline-block; + transition: all 0.5s; +} + +/* +@media (hover: hover) { + .hl.lean .tactic > label:hover::after { + border: 1px solid #aaaaaa; + background-color: #aaaaaa; + transition: all 0.5s; + } +} +*/ + +.hl.lean .tactic > label:has(+ .tactic-toggle:checked)::after { + border: 1px solid var(--verso-ui-on-code-color, #888888); + background-color: var(--verso-ui-on-code-color, #888888); + transition: all 0.5s; +} + +.hl.lean .tactic-state .goal + .goal { + margin-top: 1.5em; +} + +/* +Some CSS frameworks customize details/summary in ways not compatible with Verso's output. +*/ + +.hl.lean details { + display: block !important; + margin: 0; +} + +.hl.lean details summary { + display: list-item !important; + margin: 0; +} + +.hl.lean details summary:focus { + outline: none; + outline-offset: none; + color: inherit; +} + +.hl.lean ul > li { + margin-bottom: 0; +} + +.hl.lean details summary::marker { + display: inline !important; +} + +.hl.lean details > summary:first-of-type { + list-style-type: disclosure-closed; + list-style-position: inside; +} + +.hl.lean details[open] > summary:first-of-type { + list-style-type: disclosure-open; +} + +.hl.lean details summary::before, .hl.lean details summary::after { + content: "" !important; + background: none; + display: none; +} + +.hl.lean .tactic-state summary { + /* These need to be em, not rem, to scale with the font */ + margin-left: -0.5em; +} + +.hl.lean .tactic-state details { + /* These need to be em, not rem, to scale with the font */ + padding-left: 0.5em; +} + +.hl.lean .case-label { + display: block; + position: relative; +} + +.hl.lean .case-label input[type="checkbox"] { + position: absolute; + top: 0; + left: 0; + opacity: 0; + height: 0; + width: 0; + z-index: -10; +} + +.hl.lean .case-label:has(input[type="checkbox"])::before { + display: inline-block; + background-color: var(--verso-code-color, black); + content: ' '; + transition: ease 0.2s; + margin-right: 0.7em; + clip-path: polygon(100% 0, 0 0, 50% 100%); + width: 0.6em; + height: 0.6em; + vertical-align: middle; +} + +.hl.lean .case-label:has(input[type="checkbox"]:not(:checked))::before { + transform: rotate(-90deg); +} + +.hl.lean .case-label:has(input[type="checkbox"]) { + +} + +.hl.lean .case-label:has(input[type="checkbox"]:checked) { + +} + + +.hl.lean .labeled-case > :not(:first-child) { + max-height: 0px; + display: block; + overflow: hidden; + transition: max-height 0.1s ease-in; + /* These need to be em, not rem, to scale with the font */ + margin-left: 0.5em; + margin-top: 0.1em; +} + +.hl.lean .labeled-case:has(.case-label input[type="checkbox"]:checked) > :not(:first-child) { + max-height: 100%; +} + + +.hl.lean .goal-name::before { + font-style: normal; + content: "case "; +} + +.hl.lean .goal-name { + font-style: italic; + font-family: var(--verso-code-font-family); + color: inherit; +} + +.hl.lean .hypotheses { + display: table; +} + +.hl.lean .hypothesis { + display: table-row; +} + +.hl.lean .hypothesis > * { + display: table-cell; +} + + +.hl.lean .hypotheses .colon { + text-align: center; + /* This needs to be em, not rem, to scale with the font */ + min-width: 1em; +} + +.hl.lean .hypotheses .name { + text-align: right; +} + +.hl.lean .hypotheses .name, +.hl.lean .hypotheses .type, +.hl.lean .conclusion .type { + font-family: var(--verso-code-font-family); +} + +.tippy-box { + /* Without these, mobile Safari will start making font sizes inconsistent when its text size adjustment feature is triggered.*/ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +.tippy-box[data-theme~='lean'] { + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 1px solid var(--verso-hover-border-color, black); +} +.tippy-box[data-theme~='lean'][data-placement^='top'] > .tippy-arrow::before { + border-top-color: var(--verso-hover-background-color, #e5e5e5); +} +.tippy-box[data-theme~='lean'][data-placement^='bottom'] > .tippy-arrow::before { + border-bottom-color: var(--verso-hover-background-color, #e5e5e5); +} +.tippy-box[data-theme~='lean'][data-placement^='left'] > .tippy-arrow::before { + border-left-color: var(--verso-hover-background-color, #e5e5e5); +} +.tippy-box[data-theme~='lean'][data-placement^='right'] > .tippy-arrow::before { + border-right-color: var(--verso-hover-background-color, #e5e5e5); +} + +.tippy-box[data-theme~='message'][data-placement^='top'] > .tippy-arrow::before { + border-top-color: var(--verso-hover-background-color, #e5e5e5); + border-width: 11px 11px 0; +} +.tippy-box[data-theme~='message'][data-placement^='top'] > .tippy-arrow::after { + bottom: -11px; + border-width: 11px 11px 0; +} +.tippy-box[data-theme~='message'][data-placement^='bottom'] > .tippy-arrow::before { + border-width: 0 11px 11px; +} +.tippy-box[data-theme~='message'][data-placement^='bottom'] > .tippy-arrow::after { + top: -11px; + border-width: 0 11px 11px; +} +.tippy-box[data-theme~='message'][data-placement^='left'] > .tippy-arrow::before { + border-left-color: var(--verso-hover-background-color, #e5e5e5); + border-width: 11px 0 11px 11px; +} +.tippy-box[data-theme~='message'][data-placement^='left'] > .tippy-arrow::after { + right: -11px; + border-width: 11px 0 11px 11px; +} + +.tippy-box[data-theme~='message'][data-placement^='right'] > .tippy-arrow::before { + border-right-color: var(--verso-hover-background-color, #e5e5e5); + border-width: 11px 11px 11px 0; +} +.tippy-box[data-theme~='message'][data-placement^='right'] > .tippy-arrow::after { + left: -11px; + border-width: 11px 11px 11px 0; +} + + + +.tippy-box[data-theme~='warning'] { + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 3px solid var(--verso-warning-indicator-color, #e7a71d); +} + +.tippy-box[data-theme~='error'] { + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 3px solid var(--verso-error-indicator-color, red); +} + +.tippy-box[data-theme~='info'] { + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 3px solid var(--verso-info-indicator-color, blue); +} + +.tippy-box[data-theme~='tactic'] { + background-color: var(--verso-tactic-state-background-color, #ffffff); + color: var(--verso-hover-text-color, black); + border: 1px solid var(--verso-hover-border-color, black); +} +.tippy-box[data-theme~='tactic'][data-placement^='top'] > .tippy-arrow::before { + border-top-color: var(--verso-tactic-state-background-color, #ffffff); +} +.tippy-box[data-theme~='tactic'][data-placement^='bottom'] > .tippy-arrow::before { + border-bottom-color: var(--verso-tactic-state-background-color, #ffffff); +} +.tippy-box[data-theme~='tactic'][data-placement^='left'] > .tippy-arrow::before { + border-left-color: var(--verso-tactic-state-background-color, #ffffff); +} +.tippy-box[data-theme~='tactic'][data-placement^='right'] > .tippy-arrow::before { + border-right-color: var(--verso-tactic-state-background-color, #ffffff); +} + +.extra-doc-links { + list-style-type: none; + margin-left: 0; + padding: 0; +} + +.extra-doc-links > li { + display: inline-block; +} + +.extra-doc-links > li:not(:last-child)::after { + content: '|'; + display: inline-block; + margin: 0 0.25em; +} + +.verso-message .trace { + display: block; +} + +.verso-message .trace > summary::marker { + color: var(--verso-text-color); +} + +.verso-message .trace-children { + margin: 0; + padding: 0; +} + +.verso-message .trace-children > li { + list-style-type: none; + margin-left: 1.5em; +} + +.verso-message .trace-children > li:not(:has(.trace)) { + margin-left: 0; +} + +.verso-message .trace-class { + color: color-mix(in srgb, currentColor 70%, transparent); + font-weight: bold; + margin: 0; + padding: 0; +} + +.verso-message .text { + white-space: pre-wrap; +} diff --git a/src/tests/golden/theme-css/default.input b/src/tests/golden/theme-css/default.input new file mode 100644 index 000000000..331d858ce --- /dev/null +++ b/src/tests/golden/theme-css/default.input @@ -0,0 +1 @@ +default \ No newline at end of file diff --git a/src/verso-manual/VersoManual.lean b/src/verso-manual/VersoManual.lean index 7156ff13e..f6c55cfda 100644 --- a/src/verso-manual/VersoManual.lean +++ b/src/verso-manual/VersoManual.lean @@ -11,6 +11,8 @@ import Verso.Doc.Html import Verso.Output.TeX import Verso.Output.Html import Verso.Output.Html.CssVars +import Verso.Theme.Code +import Verso.Theme.Code.Defaults import Verso.Output.Html.KaTeX import Verso.Output.Html.ElasticLunr import Verso.Doc.Lsp @@ -242,6 +244,11 @@ structure RenderConfig extends Config where How to insert links in rendered code -/ linkTargets : TraverseState → Multi.AllRemotes → LinkTargets Manual.TraverseContext := (·.localTargets ++ ·.remoteTargets) + /-- + The active {Lean.Doc.name}`Verso.Theme.CodeTheme`. Its CSS-variable block is written to + {lit}`verso-themes.css` so the page-level highlighting rules read the chosen colors. + -/ + codeTheme : Verso.Theme.CodeTheme := Verso.Theme.CodeTheme.Default namespace Config @@ -762,6 +769,24 @@ where h.putStrLn Html.«verso-vars.css» IO.FS.withFile (dir.join "book.css") .write fun h => do h.putStrLn Html.Css.pageStyle + IO.FS.withFile (dir.join "verso-themes.css") .write fun h => do + let assetRoot := s!"-verso-data/themes/{config.codeTheme.name}" + let faceRules := config.codeTheme.fontFaceRules assetRoot + unless faceRules.isEmpty do + h.putStrLn faceRules + h.putStrLn s!":root \{\n{config.codeTheme.cssVariables}}" + let extra := config.codeTheme.extraCss assetRoot + unless extra.isEmpty do + h.putStrLn "" + h.putStrLn extra + for (path, bytes, _, _) in config.codeTheme.fontAssets s!"-verso-data/themes/{config.codeTheme.name}" do + let abs := dir.join path + if let some p := abs.parent then ensureDir p + IO.FS.writeBinFile abs bytes + for a in config.codeTheme.assets do + let path := dir.join "-verso-data" |>.join "themes" |>.join config.codeTheme.name |>.join a.path + if let some p := path.parent then ensureDir p + IO.FS.writeBinFile path a.contents for (src, dest) in config.extraFiles do copyRecursively src (dir.join dest) for (src, dest) in config.extraFilesHtml do @@ -830,6 +855,24 @@ where h.putStrLn Html.«verso-vars.css» IO.FS.withFile (root / "book.css") .write fun h => do h.putStrLn Html.Css.pageStyle + IO.FS.withFile (root / "verso-themes.css") .write fun h => do + let assetRoot := s!"-verso-data/themes/{config.codeTheme.name}" + let faceRules := config.codeTheme.fontFaceRules assetRoot + unless faceRules.isEmpty do + h.putStrLn faceRules + h.putStrLn s!":root \{\n{config.codeTheme.cssVariables}}" + let extra := config.codeTheme.extraCss assetRoot + unless extra.isEmpty do + h.putStrLn "" + h.putStrLn extra + for (path, bytes, _, _) in config.codeTheme.fontAssets s!"-verso-data/themes/{config.codeTheme.name}" do + let abs := root.join path + if let some p := abs.parent then ensureDir p + IO.FS.writeBinFile abs bytes + for a in config.codeTheme.assets do + let path := root.join "-verso-data" |>.join "themes" |>.join config.codeTheme.name |>.join a.path + if let some p := path.parent then ensureDir p + IO.FS.writeBinFile path a.contents for (src, dest) in config.extraFiles do copyRecursively src (root.join dest) for (src, dest) in config.extraFilesHtml do @@ -944,9 +987,11 @@ open Verso.CLI def manualMain (text : Part Manual) (extensionImpls : ExtensionImpls := by exact extension_impls%) + (codeThemes : Verso.Theme.CodeThemeTable := by exact code_themes%) (options : List String) (config : RenderConfig := {}) (extraSteps : List ExtraStep := []) : IO UInt32 := + let _ := codeThemes ReaderT.run go extensionImpls where diff --git a/src/verso-manual/VersoManual/Html.lean b/src/verso-manual/VersoManual/Html.lean index 9d5d54892..18550c0a2 100644 --- a/src/verso-manual/VersoManual/Html.lean +++ b/src/verso-manual/VersoManual/Html.lean @@ -437,6 +437,7 @@ public def page {{textTitle}} + {{ searchAssetTags }} {{extraJsFiles.map fun f => ({{}})}} diff --git a/src/verso-search/VersoSearch.lean b/src/verso-search/VersoSearch.lean index f4f8a464a..fd8f964fd 100644 --- a/src/verso-search/VersoSearch.lean +++ b/src/verso-search/VersoSearch.lean @@ -798,7 +798,7 @@ public class Indexable (genre : Verso.Doc.Genre) where /-- Computes the full-text search priority for a part, using the same centered-at-50 convention as the quick-jump side. Returning {lean}`none` leaves the document at neutral; returning a signed integer - lets a genre fold section metadata, ancestor inheritance, or other emission-time adjustments into + lets a genre fold section metadata, ancestor inheritance, or other HTMl-generation-time adjustments into full-text scoring. This is an {lean}`Int` to allow it to accumulate adjustments that put it outside the usual range. -/ diff --git a/src/verso/Verso.lean b/src/verso/Verso.lean index 5b19f8209..4735bd49f 100644 --- a/src/verso/Verso.lean +++ b/src/verso/Verso.lean @@ -8,7 +8,9 @@ module -- Import modules here that should be built as part of the library. public import Verso.CLI public import Verso.Code -public import Verso.Color +public import Verso.Theme.Code +public import Verso.Theme.Code.Defaults +public import Verso.Theme.Color public import Verso.Doc public import Verso.Doc.ArgParse public import Verso.Doc.Concrete diff --git a/src/verso/Verso/Code/Highlighted.lean b/src/verso/Verso/Code/Highlighted.lean index 8fbe0c57d..18526d4d6 100644 --- a/src/verso/Verso/Code/Highlighted.lean +++ b/src/verso/Verso/Code/Highlighted.lean @@ -709,8 +709,8 @@ public def highlightingStyle : String := " .hl.lean .token .hover-info { display: none; position: absolute; - background-color: #e5e5e5; - border: 1px solid black; + background-color: var(--verso-hover-background-color, #e5e5e5); + border: 1px solid var(--verso-hover-border-color, black); padding: 0.5rem; z-index: 300; } @@ -727,7 +727,7 @@ public def highlightingStyle : String := " .hl.lean .hover-info code { white-space: pre-wrap; background: none; - color: black; + color: var(--verso-hover-text-color, black); } .hl.lean .hover-info.messages > code { @@ -769,7 +769,7 @@ public def highlightingStyle : String := " @media (hover: hover) { .hl.lean .token.binding-hl, .hl.lean .literal.string:hover, .hl.lean .token.typed:hover { - background-color: #eee; + background-color: var(--verso-token-highlight-background-color, #eeeeee); border-radius: 2px; transition: none; } @@ -787,25 +787,25 @@ public def highlightingStyle : String := " display: none; position: absolute; transform: translate(0.25rem, 0.3rem); - border: 1px solid black; + border: 1px solid var(--verso-hover-border-color, black); padding: 0.5rem; z-index: 400; text-align: left; } .hl.lean .has-info.error :not(.tactic-state):not(.tactic-state *){ - text-decoration-color: red; + text-decoration-color: var(--verso-error-indicator-color, red); } @media (hover: hover) { .hl.lean .has-info.error:hover { - background-color: #ffb3b3; + background-color: var(--verso-token-highlight-background-color, #eeeeee); } } .hl.lean .hover-info.messages > code.error { - background-color: #e5e5e5; - border-left: 0.2rem solid #ffb3b3; + background-color: var(--verso-hover-background-color, #e5e5e5); + border-left: 0.2rem solid var(--verso-error-indicator-color, red); } .tippy-box[data-theme~='error'] .hl.lean .hover-info.messages > code.error { @@ -827,12 +827,12 @@ public def highlightingStyle : String := " @media (hover: hover) { .hl.lean .has-info.warning:hover { - background-color:var(--verso-warning-color); + background-color: var(--verso-token-highlight-background-color, #eeeeee); } } .hl.lean .hover-info.messages > code.warning { - background-color: var(--verso-warning-color); + background-color: var(--verso-hover-background-color, #e5e5e5); } .lean-output { @@ -854,11 +854,6 @@ public def highlightingStyle : String := " border-color: var(--verso-warning-indicator-color); } -.hl.lean .hover-info.messages > code.error { - background-color: #e5e5e5; - border-left: 0.2rem solid var(--verso-warning-color); -} - .tippy-box[data-theme~='warning'] .hl.lean .hover-info.messages > code.warning { background: none; border: none; @@ -871,14 +866,14 @@ public def highlightingStyle : String := " @media (hover: hover) { .hl.lean .has-info.information:hover { - background-color: #4777ff; + background-color: var(--verso-token-highlight-background-color, #eeeeee); } } .hl.lean .hover-info.messages > code.information { - background-color: #e5e5e5; - border-left: 0.2rem solid #4777ff; + background-color: var(--verso-hover-background-color, #e5e5e5); + border-left: 0.2rem solid var(--verso-info-indicator-color, blue); } .tippy-box[data-theme~='info'] .hl.lean .hover-info.messages > code.information { @@ -910,7 +905,7 @@ public def highlightingStyle : String := " margin-bottom: 0.5rem; padding: 0; height: 1px; - border-top: 1px solid #ccc; + border-top: 1px solid var(--verso-hover-separator-color, #cccccc); } .hl.lean code { @@ -921,11 +916,11 @@ public def highlightingStyle : String := " display: none; position: relative; width: fit-content; - border: 1px solid #888888; + border: 1px solid var(--verso-tactic-state-border-color, #888888); border-radius: 0.1rem; padding: 0.5rem; font-family: sans-serif; - background-color: #ffffff; + background-color: var(--verso-tactic-state-background-color, #ffffff); } .hl.lean.popup .tactic-state { @@ -935,7 +930,7 @@ public def highlightingStyle : String := " border: none; padding: 0.5rem; font-family: sans-serif; - background-color: #ffffff; + background-color: var(--verso-tactic-state-background-color, #ffffff); } @@ -968,7 +963,7 @@ public def highlightingStyle : String := " @media (hover: hover) { .hl.lean .tactic:has(.tactic-toggle:not(:checked)) > label:hover { - background-color: #eeeeee; + background-color: var(--verso-token-highlight-background-color, #eeeeee); } } @@ -984,7 +979,7 @@ public def highlightingStyle : String := " .hl.lean .tactic > label::after { content: \"\"; - border: 1px solid #bbbbbb; + border: 1px solid var(--verso-ui-on-code-color, #888888); /* These need to be em, not rem, to scale with the font */ border-radius: 1em; height: 0.25em; @@ -1007,8 +1002,8 @@ public def highlightingStyle : String := " */ .hl.lean .tactic > label:has(+ .tactic-toggle:checked)::after { - border: 1px solid #999999; - background-color: #999999; + border: 1px solid var(--verso-ui-on-code-color, #888888); + background-color: var(--verso-ui-on-code-color, #888888); transition: all 0.5s; } @@ -1086,7 +1081,7 @@ Some CSS frameworks customize details/summary in ways not compatible with Verso' .hl.lean .case-label:has(input[type=\"checkbox\"])::before { display: inline-block; - background-color: black; + background-color: var(--verso-code-color, black); content: ' '; transition: ease 0.2s; margin-right: 0.7em; @@ -1171,25 +1166,25 @@ Some CSS frameworks customize details/summary in ways not compatible with Verso' } .tippy-box[data-theme~='lean'] { - background-color: #e5e5e5; - color: black; - border: 1px solid black; + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 1px solid var(--verso-hover-border-color, black); } .tippy-box[data-theme~='lean'][data-placement^='top'] > .tippy-arrow::before { - border-top-color: #e5e5e5; + border-top-color: var(--verso-hover-background-color, #e5e5e5); } .tippy-box[data-theme~='lean'][data-placement^='bottom'] > .tippy-arrow::before { - border-bottom-color: #e5e5e5; + border-bottom-color: var(--verso-hover-background-color, #e5e5e5); } .tippy-box[data-theme~='lean'][data-placement^='left'] > .tippy-arrow::before { - border-left-color: #e5e5e5; + border-left-color: var(--verso-hover-background-color, #e5e5e5); } .tippy-box[data-theme~='lean'][data-placement^='right'] > .tippy-arrow::before { - border-right-color: #e5e5e5; + border-right-color: var(--verso-hover-background-color, #e5e5e5); } .tippy-box[data-theme~='message'][data-placement^='top'] > .tippy-arrow::before { - border-top-color: #e5e5e5; + border-top-color: var(--verso-hover-background-color, #e5e5e5); border-width: 11px 11px 0; } .tippy-box[data-theme~='message'][data-placement^='top'] > .tippy-arrow::after { @@ -1204,7 +1199,7 @@ Some CSS frameworks customize details/summary in ways not compatible with Verso' border-width: 0 11px 11px; } .tippy-box[data-theme~='message'][data-placement^='left'] > .tippy-arrow::before { - border-left-color: #e5e5e5; + border-left-color: var(--verso-hover-background-color, #e5e5e5); border-width: 11px 0 11px 11px; } .tippy-box[data-theme~='message'][data-placement^='left'] > .tippy-arrow::after { @@ -1213,7 +1208,7 @@ Some CSS frameworks customize details/summary in ways not compatible with Verso' } .tippy-box[data-theme~='message'][data-placement^='right'] > .tippy-arrow::before { - border-right-color: #e5e5e5; + border-right-color: var(--verso-hover-background-color, #e5e5e5); border-width: 11px 11px 11px 0; } .tippy-box[data-theme~='message'][data-placement^='right'] > .tippy-arrow::after { @@ -1224,39 +1219,39 @@ Some CSS frameworks customize details/summary in ways not compatible with Verso' .tippy-box[data-theme~='warning'] { - background-color: #e5e5e5; - color: black; - border: 3px solid var(--verso-warning-color); + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 3px solid var(--verso-warning-indicator-color, #e7a71d); } .tippy-box[data-theme~='error'] { - background-color: #e5e5e5; - color: black; - border: 3px solid #f7a7af; + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 3px solid var(--verso-error-indicator-color, red); } .tippy-box[data-theme~='info'] { - background-color: #e5e5e5; - color: black; - border: 3px solid #99b3c2; + background-color: var(--verso-hover-background-color, #e5e5e5); + color: var(--verso-hover-text-color, black); + border: 3px solid var(--verso-info-indicator-color, blue); } .tippy-box[data-theme~='tactic'] { - background-color: white; - color: black; - border: 1px solid black; + background-color: var(--verso-tactic-state-background-color, #ffffff); + color: var(--verso-hover-text-color, black); + border: 1px solid var(--verso-hover-border-color, black); } .tippy-box[data-theme~='tactic'][data-placement^='top'] > .tippy-arrow::before { - border-top-color: white; + border-top-color: var(--verso-tactic-state-background-color, #ffffff); } .tippy-box[data-theme~='tactic'][data-placement^='bottom'] > .tippy-arrow::before { - border-bottom-color: white; + border-bottom-color: var(--verso-tactic-state-background-color, #ffffff); } .tippy-box[data-theme~='tactic'][data-placement^='left'] > .tippy-arrow::before { - border-left-color: white; + border-left-color: var(--verso-tactic-state-background-color, #ffffff); } .tippy-box[data-theme~='tactic'][data-placement^='right'] > .tippy-arrow::before { - border-right-color: white; + border-right-color: var(--verso-tactic-state-background-color, #ffffff); } .extra-doc-links { diff --git a/src/verso/Verso/Color.lean b/src/verso/Verso/Color.lean deleted file mode 100644 index 0b3dfe7b5..000000000 --- a/src/verso/Verso/Color.lean +++ /dev/null @@ -1,13 +0,0 @@ -/- -Copyright (c) 2025 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ -module - -public import Verso.Color.Types -public import Verso.Color.Basic -public import Verso.Color.Math -public import Verso.Color.Accessibility -public import Verso.Color.Widget -public import Verso.Color.Syntax diff --git a/src/verso/Verso/Font.lean b/src/verso/Verso/Font.lean index 4998ab6b9..f5c5ea9af 100644 --- a/src/verso/Verso/Font.lean +++ b/src/verso/Verso/Font.lean @@ -124,7 +124,7 @@ public inductive Typeface where | files (family : String) (faces : Array FontFace) /-- Quotes a CSS string, escaping backslashes and double quotes so the name cannot break out. -/ -private def cssQuote (s : String) : String := +public def cssQuote (s : String) : String := "\"" ++ (s.replace "\\" "\\\\" |>.replace "\"" "\\\"") ++ "\"" /-- @@ -142,9 +142,9 @@ public def Typeface.cssFamily : Typeface → String Defines a {name}`FontFace` whose {name}`FontFace.bytes` are embedded from a file at compile time. The fields, in any order: {lit}`format` (required), {lit}`file` (required, a string literal path -relative to the current source file), {lit}`weights` (defaults to {lit}`.fixed .regular`), -{lit}`weight` (sugar: {lit}`weight := w` means {lit}`weights := .fixed w`), and {lit}`style` -(defaults to {lit}`.normal`). For example: +relative to the current source file), {lit}`weights` (defaults to {lean}`(.fixed .regular : FaceWeights)`), +{lit}`weight` (sugar: {lit}`weight := w` is rewritten to a fixed-weight value), and {lit}`style` +(defaults to {lean}`(.normal : FontStyle)`). For example: ``` define_font_face sourceSansVariable where @@ -156,7 +156,7 @@ define_font_face sourceSansVariable where syntax (name := defineFontFace) "define_font_face " ident " where" manyIndent(group(withPosition(ident " := " colGt term))) : command open Lean Elab Command in -/-- Elaborates a {lit}`define_font_face` command into a {name}`FontFace` definition. -/ +/-- Elaborates a {kw (of := Verso.defineFontFace)}`define_font_face` command into a {name}`FontFace` definition. -/ @[command_elab defineFontFace] public meta def elabDefineFontFace : CommandElab := fun stx => do let nameIdent := stx[1] diff --git a/src/verso/Verso/Theme/Code.lean b/src/verso/Verso/Theme/Code.lean new file mode 100644 index 000000000..356a75675 --- /dev/null +++ b/src/verso/Verso/Theme/Code.lean @@ -0,0 +1,445 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public meta import Verso.Theme.Code.Ext +public import Verso.Theme.Color +public import Verso.Theme.Color.Accessibility +public import Verso.Font +public meta import Lean.Elab.Term + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +The genre-neutral code theme: a typed set of colors, token styles, and font choices that the +manual genre uses to render code blocks and inline code. Default values reproduce today's +hardcoded chrome. +-/ + +namespace Verso.Theme + +/-- Whether a theme is intended for a light or a dark display. -/ +public inductive Appearance where + | light + | dark +deriving DecidableEq, Repr + +/-- Styling for a single token kind: its color, weight, font style, and the face it uses. -/ +public structure TokenStyle where + /-- The token's text color. -/ + color : Color + /-- The token's font weight (CSS 1–1000). -/ + weight : Weight := .regular + /-- The token's font style (upright or italic). -/ + style : FontStyle := .normal + /-- The font used for this token. -/ + face : Typeface + +/-- A bundled asset (such as an image) shipped with a theme. -/ +public structure ThemeAsset where + /-- The output-relative path the asset will be written to. -/ + path : String + /-- The asset bytes, embedded at compile time. -/ + contents : ByteArray + +/-- +A typed code theme. Defaults reproduce today's hardcoded chrome, so a default-constructed theme is +visually unchanged from the pre-theming look. + +Cascade-style defaults: fields that today read a CSS `var()` chain (for example a token color +defaulting to the body code color) reference the earlier field directly. Lean evaluates the default +at construction, so overriding a field overrides every later field that defaulted from it. +-/ +public structure CodeTheme where + /-- A human-readable name for the theme (shown in the picker). -/ + name : String + /-- Whether this theme is intended for a light or a dark display. -/ + appearance : Appearance + + /-- The font used for code blocks and inline code. -/ + codeFace : Typeface := .mono + + /-- The page and content background color. The contrast reference for body text. -/ + background : Color := Color.white + /-- The background behind code blocks. The contrast reference for token colors. -/ + codeBlockBackground : Color := background + /-- The background behind inline code in prose. When set, inline code gets padding and rounding. -/ + inlineBackground : Option Color := none + /-- The color of body prose text. -/ + textColor : Color := Color.black + /-- The color of code text. -/ + codeColor : Color := textColor + /-- The color used for structural decoration (such as case labels). -/ + structureColor : Color := textColor + /-- The background color used to highlight a selected token in code. -/ + selectedColor : Color := color%#ddeeff + + /-- The message-text color for informational diagnostics. -/ + infoColor : Color := textColor + /-- The accent color (left border, underline) for informational diagnostics. -/ + infoIndicatorColor : Color := color%#4777ff + /-- The message-text color for warning diagnostics. -/ + warningColor : Color := textColor + /-- The accent color (left border, underline) for warning diagnostics. -/ + warningIndicatorColor : Color := color%#e7a71d + /-- The message-text color for error diagnostics. -/ + errorColor : Color := color%#cc0000 + /-- The accent color (left border, underline) for error diagnostics. -/ + errorIndicatorColor : Color := color%#ff0000 + + /-- Token styling for constants. -/ + const : TokenStyle := { color := codeColor, weight := .regular, style := .normal, face := codeFace } + /-- Token styling for keywords. -/ + keyword : TokenStyle := { color := codeColor, weight := .bold, style := .normal, face := codeFace } + /-- Token styling for variables (bound names). -/ + «var» : TokenStyle := { color := codeColor, weight := .regular, style := .italic, face := codeFace } + + /-- The background of hover popups, diagnostic boxes, and tooltips. -/ + hoverBackground : Color := color%#e5e5e5 + /-- The border color of hover popups and plain tooltips. -/ + hoverBorderColor : Color := Color.black + /-- The text color inside hover popups. -/ + hoverText : Color := textColor + /-- The separator line color inside hover popups. -/ + hoverSeparatorColor : Color := color%#cccccc + /-- The background tint applied to a token on hover (independent of severity). -/ + tokenHighlightBackground : Color := color%#eeeeee + /-- The background of a displayed tactic state. -/ + tacticStateBackground : Color := Color.white + /-- The border color of a displayed tactic state. -/ + tacticStateBorderColor : Color := color%#888888 + + /-- + An accent background drawn behind highlighted code. + {name (full := Verso.Theme.CodeTheme.codeColor)}`codeColor` must read on it. + -/ + highlightOnCode : Color := color%#fff3b0 + /-- An accent background drawn behind highlighted code or prose. Both code and text must read on it. -/ + highlightOnText : Color := highlightOnCode + /-- + A neutral UI element color drawn against + {name (full := Verso.Theme.CodeTheme.codeBlockBackground)}`codeBlockBackground` (e.g. a toggle pill). + -/ + uiOnCode : Color := color%#888888 + + /-- + Theme-specific CSS appended after the standard variable block. The asset root path the function + receives is relative to {lit}`verso-themes.css` so {lit}`url()` references resolve from there. + -/ + extraCss : (assetRoot : String) → String := fun _ => "" + /-- Non-font assets (such as images) bundled with the theme. -/ + assets : Array ThemeAsset := #[] + +/-! # Attribute, environment extension, and materialization -/ + +public section + +/-- +Attribute that registers a {Lean.Doc.name}`CodeTheme` declaration as an available theme. The +declaration must be in the current module (not imported), and its registration name is the decl's +name with macro scopes erased. +-/ +syntax (name := code_theme) "code_theme" : attr + +open Lean in +meta initialize + registerBuiltinAttribute { + name := `code_theme, + ref := by exact decl_name%, + add := fun decl _stx kind => do + unless kind == AttributeKind.global do + throwError "invalid attribute 'code_theme', must be global" + unless ((← getEnv).getModuleIdxFor? decl).isNone do + throwError "invalid attribute 'code_theme', declaration is in an imported module" + modifyEnv fun env => codeThemeExt.addEntry env decl.eraseMacroScopes + descr := "Registers a definition as an available code theme" + } + +end section + +/-- +A materialized table of registered {Lean.Doc.name}`CodeTheme` values, keyed by registration name. +Built at runtime by the {lit}`code_themes%` term elaborator from the set of +{Lean.Doc.name}`CodeTheme` declarations tagged with the {lit}`@[code_theme]` attribute. +-/ +public structure CodeThemeTable where + /-- The map from a theme's registration name to its value. -/ + themes : Lean.NameMap CodeTheme := {} + +namespace CodeThemeTable + +/-- The empty table. -/ +public def empty : CodeThemeTable := {} + +public instance : EmptyCollection CodeThemeTable := ⟨empty⟩ + +/-- Looks up a theme by its registration name. -/ +public def find? (t : CodeThemeTable) (n : Lean.Name) : Option CodeTheme := + t.themes.find? n + +/-- Inserts a theme under the given registration name. -/ +public def insert (t : CodeThemeTable) (n : Lean.Name) (theme : CodeTheme) : CodeThemeTable := + ⟨t.themes.insert n theme⟩ + +/-- Builds a table from a list of pairs. -/ +public def fromList (xs : List (Lean.Name × CodeTheme)) : CodeThemeTable := + xs.foldl (fun (acc : CodeThemeTable) (p : Lean.Name × CodeTheme) => acc.insert p.1 p.2) empty + +end CodeThemeTable + +public section + +/-- Term elaborator that materializes the registered code-theme table at compile time. -/ +syntax (name := code_themes) "code_themes%" : term + +open Lean Elab Term in +private meta def themePair [Monad m] [MonadRef m] [MonadQuotation m] (n : Name) : m Term := do + let quoted : Term := quote n + let ident ← mkCIdentFromRef n + `(($quoted, $(⟨ident⟩))) + +open Lean Elab Term in +/-- Elaborator for the {lit}`code_themes%` macro: emits a {Lean.Doc.name}`Verso.Theme.CodeThemeTable` +literal whose entries are every registered {Lean.Doc.name}`Verso.Theme.CodeTheme` decl. -/ +@[term_elab code_themes] +meta def elabCodeThemes : TermElab := fun _stx expected? => do + let env ← getEnv + let mut names : Array Name := #[] + for n in codeThemeExt.getState env do + names := names.push n + for imported in codeThemeExt.toEnvExtension.getState env |>.importedEntries do + for n in imported do + names := names.push n + let stx ← `(Verso.Theme.CodeThemeTable.fromList [$[($(← names.mapM themePair) : Lean.Name × Verso.Theme.CodeTheme)],*]) + elabTerm stx expected? + +end section + +/-! # Accessibility checking -/ + +namespace CodeTheme + +/-- The display name and color of a token style, for accessibility reporting. -/ +private def tokenSummaries (theme : CodeTheme) : Array (String × Color) := #[ + ("const", theme.const.color), + ("keyword", theme.keyword.color), + ("var", theme.«var».color) + ] + +/-- +Checks a theme for contrast and color-vision-deficiency problems. The checker is pure and genre +neutral: it returns an {Lean.Doc.name}`Array` of {Lean.Doc.name}`Verso.Theme.Color.Issue` values +whose {Lean.Doc.name}`Verso.Theme.Color.Issue.kind` field a caller routes to its severity flag. + +Body text uses the WCAG AA 4.5 threshold; UI accents and large-text positions use 3.0. Token +distinguishability uses the CIEDE2000 threshold from +{Lean.Doc.name}`Verso.Theme.Color.distinguishableThreshold`. +-/ +public def checkAccessibility (theme : CodeTheme) : Array Color.Issue := Id.run do + let mut issues := #[] + -- Body and message text on the page background. + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "body text on background" theme.textColor theme.background + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "error message text on background" theme.errorColor theme.background + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "warning message text on background" theme.warningColor theme.background + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "info message text on background" theme.infoColor theme.background + -- Code tokens against the code-block background. + for (n, c) in theme.tokenSummaries do + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + s!"{n} token on code background" c theme.codeBlockBackground + -- Inline-code background, when set, must read with the code color. + if let some bg := theme.inlineBackground then + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "code on inline background" theme.codeColor bg + -- Indicator accents against the page background. + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "error indicator on background" theme.errorIndicatorColor theme.background + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "warning indicator on background" theme.warningIndicatorColor theme.background + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "info indicator on background" theme.infoIndicatorColor theme.background + -- Highlight backgrounds. + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "code on highlightOnCode" theme.codeColor theme.highlightOnCode + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "code on highlightOnText" theme.codeColor theme.highlightOnText + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "text on highlightOnText" theme.textColor theme.highlightOnText + -- UI element on code background (large/UI threshold). + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "uiOnCode against code background" theme.uiOnCode theme.codeBlockBackground + -- Tactic state border on its background. + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "tactic-state border on its background" + theme.tacticStateBorderColor theme.tacticStateBackground + -- Hover popup readability. + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "hover-popup text on hover background" theme.hoverText theme.hoverBackground + -- Tactic popups draw the same hover text on the tactic-state background. + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "hover-popup text on tactic-state background" + theme.hoverText theme.tacticStateBackground + -- Code drawn on the tactic-state background (for example a hypothesis). + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "code on tactic-state background" theme.codeColor theme.tacticStateBackground + -- Colorblindness: tokens and indicators stay mutually distinguishable. + issues := issues ++ Color.colorblindIssues Color.distinguishableThreshold + (theme.tokenSummaries ++ #[ + ("error indicator", theme.errorIndicatorColor), + ("warning indicator", theme.warningIndicatorColor), + ("info indicator", theme.infoIndicatorColor)]) + return issues + +end CodeTheme + +/-! # CSS variable block -/ + +namespace CodeTheme + +/-- Renders a single `--name: value;` CSS declaration. -/ +private def cssDecl (name value : String) : String := + s!" --{name}: {value};\n" + +private def colorDecl (name : String) (c : Color) : String := + cssDecl name (Color.css c) + +private def styleDecls (prefix' : String) (s : TokenStyle) : String := + String.join [ + colorDecl s!"{prefix'}-color" s.color, + cssDecl s!"{prefix'}-weight" (toString s.weight.val), + cssDecl s!"{prefix'}-style" (match s.style with | .normal => "normal" | .italic => "italic"), + cssDecl s!"{prefix'}-font-family" s.face.cssFamily + ] + +/-- +Renders the theme as the body of a CSS `:root { ... }` block: one `--verso-*` custom property per +themed value. The output drives the page-level theme stylesheet and replaces the resolved values +that today are hardcoded into {lit}`verso-vars.css` and {lit}`highlightingStyle`. +-/ +public def cssVariables (theme : CodeTheme) : String := + String.join [ + colorDecl "verso-background-color" theme.background, + colorDecl "verso-code-background-color" theme.codeBlockBackground, + (match theme.inlineBackground with + | some c => colorDecl "verso-inline-code-background-color" c + | none => ""), + colorDecl "verso-text-color" theme.textColor, + colorDecl "verso-code-color" theme.codeColor, + colorDecl "verso-structure-color" theme.structureColor, + colorDecl "verso-selected-color" theme.selectedColor, + colorDecl "verso-info-color" theme.infoColor, + colorDecl "verso-info-indicator-color" theme.infoIndicatorColor, + colorDecl "verso-warning-color" theme.warningColor, + colorDecl "verso-warning-indicator-color" theme.warningIndicatorColor, + colorDecl "verso-error-color" theme.errorColor, + colorDecl "verso-error-indicator-color" theme.errorIndicatorColor, + cssDecl "verso-code-font-family" theme.codeFace.cssFamily, + styleDecls "verso-code-const" theme.const, + styleDecls "verso-code-keyword" theme.keyword, + styleDecls "verso-code-var" theme.«var», + colorDecl "verso-hover-background-color" theme.hoverBackground, + colorDecl "verso-hover-border-color" theme.hoverBorderColor, + colorDecl "verso-hover-text-color" theme.hoverText, + colorDecl "verso-hover-separator-color" theme.hoverSeparatorColor, + colorDecl "verso-token-highlight-background-color" theme.tokenHighlightBackground, + colorDecl "verso-tactic-state-background-color" theme.tacticStateBackground, + colorDecl "verso-tactic-state-border-color" theme.tacticStateBorderColor, + colorDecl "verso-highlight-on-code-color" theme.highlightOnCode, + colorDecl "verso-highlight-on-text-color" theme.highlightOnText, + colorDecl "verso-ui-on-code-color" theme.uiOnCode + ] + +end CodeTheme + +/-! # Font assets and {lit}`@font-face` writing -/ + +namespace CodeTheme + +/-- +Every {Lean.Doc.name}`Verso.Typeface` referenced by a theme. Built-in +{Lean.Doc.name (full := Verso.Typeface.sans)}`sans`/{Lean.Doc.name (full := Verso.Typeface.serif)}`serif`/{Lean.Doc.name (full := Verso.Typeface.mono)}`mono` +are skipped: only {Lean.Doc.name (full := Verso.Typeface.files)}`files` typefaces contribute font assets. +-/ +public def fileTypefaces (theme : CodeTheme) : Array Typeface := + let faces := #[theme.codeFace, theme.const.face, theme.keyword.face, theme.«var».face] + faces.filter fun + | .files _ _ => true + | _ => false + +/-- The output file extension for a font format. -/ +public def _root_.Verso.FontFormat.ext : FontFormat → String + | .woff2 => "woff2" + | .woff => "woff" + | .otf => "otf" + | .ttf => "ttf" + +/-- +Sanitizes a font-family name into a path-safe slug. Letters, digits, hyphens, underscores, and +dots are preserved; every other character becomes a hyphen, runs of hyphens collapse, and the +result is trimmed of leading/trailing hyphens. An empty result falls back to {lit}`font`. +-/ +public def slugFamily (family : String) : String := Id.run do + let mut buf := "" + let mut prevHyphen := true + for c in family.toList do + let safe := c.isAlphanum || c == '-' || c == '_' || c == '.' + if safe then + buf := buf.push c + prevHyphen := false + else if !prevHyphen then + buf := buf.push '-' + prevHyphen := true + let mut s := buf + while s.endsWith "-" do s := (String.dropEnd s 1).copy + if s.isEmpty then s := "font" + return s + +/-- +Output-relative paths and bytes of every font file the theme uses. Each file is named +{lit}`-.` where {lit}`` is {Lean.Doc.name}`Verso.Theme.CodeTheme.slugFamily` +applied to the family. The {lit}`assetRoot` is the directory the paths are relative to (no +leading or trailing slash); the generated {lit}`@font-face` {lit}`url()`s also resolve from there. +-/ +public def fontAssets (theme : CodeTheme) (assetRoot : String) : + Array (String × ByteArray × FontFace × String) := Id.run do + let mut out := #[] + for tf in theme.fileTypefaces do + if let .files family faces := tf then + let slug := slugFamily family + for (face, i) in faces.zipIdx do + let path := s!"{assetRoot}/fonts/{slug}-{i}.{face.format.ext}" + out := out.push (path, face.bytes, face, family) + return out + +private def weightDecl : FaceWeights → String + | .fixed w => toString w.val + | .variable lo hi _ => s!"{lo.val} {hi.val}" + +private def styleString : FontStyle → String + | .normal => "normal" + | .italic => "italic" + +/-- +The {lit}`@font-face` rules for every {Lean.Doc.name (full := Verso.Typeface.files)}`files` +typeface the theme uses. The {lit}`url()` paths are relative, so the rules resolve from whatever +stylesheet they end up in (in the manual genre, {lit}`verso-themes.css` at the site root). +-/ +public def fontFaceRules (theme : CodeTheme) (assetRoot : String) : String := Id.run do + let mut out := "" + for (path, _bytes, face, family) in theme.fontAssets assetRoot do + out := out ++ s!"@font-face \{\n" + out := out ++ s!" font-family: {cssQuote family};\n" + out := out ++ s!" font-weight: {weightDecl face.weights};\n" + out := out ++ s!" font-style: {styleString face.style};\n" + out := out ++ s!" src: url({cssQuote path}) format({cssQuote face.format.css});\n" + out := out ++ "}\n" + return out + +end CodeTheme diff --git a/src/verso/Verso/Theme/Code/Defaults.lean b/src/verso/Verso/Theme/Code/Defaults.lean new file mode 100644 index 000000000..d5488e7ba --- /dev/null +++ b/src/verso/Verso/Theme/Code/Defaults.lean @@ -0,0 +1,27 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Verso.Theme.Code + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +The built-in {Lean.Doc.name}`Verso.Theme.CodeTheme` values. The default theme reproduces today's +hardcoded look so existing manuals render unchanged when no override is configured. +-/ + +namespace Verso.Theme + +/-- +The default code theme: typography-only styling that reproduces today's hardcoded look. Other +built-in themes live alongside it in the {Lean.Doc.name}`Verso.Theme.CodeTheme` namespace. +-/ +@[code_theme] +public def CodeTheme.Default : CodeTheme where + name := "Default" + appearance := .light diff --git a/src/verso/Verso/Theme/Code/Ext.lean b/src/verso/Verso/Theme/Code/Ext.lean new file mode 100644 index 000000000..2deb49c99 --- /dev/null +++ b/src/verso/Verso/Theme/Code/Ext.lean @@ -0,0 +1,28 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module +public import Lean.Environment + +public section + +open Lean + +namespace Verso.Theme + +/-- +Environment extension that records every declaration tagged with the `@[code_theme]` attribute. +Each entry maps the registration name (the decl, erased of macro scopes) to itself, so the table +is keyed and iterated by registration name. +-/ +initialize codeThemeExt : + PersistentEnvExtension Name Name (NameSet) ← + registerPersistentEnvExtension { + mkInitial := pure {}, + addImportedFn := fun _ => pure {}, + addEntryFn := fun s n => s.insert n, + exportEntriesFn := fun s => + s.toArray.qsort Name.quickLt + } diff --git a/src/verso/Verso/Theme/Color.lean b/src/verso/Verso/Theme/Color.lean new file mode 100644 index 000000000..16e17c2ee --- /dev/null +++ b/src/verso/Verso/Theme/Color.lean @@ -0,0 +1,13 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Verso.Theme.Color.Types +public import Verso.Theme.Color.Basic +public import Verso.Theme.Color.Math +public import Verso.Theme.Color.Accessibility +public import Verso.Theme.Color.Widget +public import Verso.Theme.Color.Syntax diff --git a/src/verso/Verso/Color/Accessibility.lean b/src/verso/Verso/Theme/Color/Accessibility.lean similarity index 88% rename from src/verso/Verso/Color/Accessibility.lean rename to src/verso/Verso/Theme/Color/Accessibility.lean index 70fa21000..2c59cef5b 100644 --- a/src/verso/Verso/Color/Accessibility.lean +++ b/src/verso/Verso/Theme/Color/Accessibility.lean @@ -5,8 +5,8 @@ Author: David Thrane Christiansen -/ module -public import Verso.Color.Types -public import Verso.Color.Math +public import Verso.Theme.Color.Types +public import Verso.Theme.Color.Math set_option linter.missingDocs true set_option doc.verso true @@ -20,7 +20,7 @@ This module contains genre-neutral accessibility predicates and checks built on -/ -namespace Verso +namespace Verso.Theme namespace Color @@ -32,8 +32,7 @@ deriving DecidableEq, Repr /-- An accessibility problem found while checking a set of colors. It carries no severity; the caller -maps {name}`IssueKind` to a severity. {lit}`offending` lists the colors involved, for error -messages. +maps {name}`IssueKind` to a severity. The offending colors are recorded for error messages. -/ public structure Issue where /-- Whether this is a contrast or a color-vision-deficiency problem. -/ @@ -56,11 +55,11 @@ palette (which stays above it under all three dichromacies). -/ public def distinguishableThreshold : Float := 5.0 -/-- Whether {lit}`fg` on {lit}`bg` meets the given WCAG contrast ratio. -/ +/-- Whether a foreground on a background meets the given WCAG contrast ratio. -/ public def meetsContrast (threshold : Float) (fg bg : Color) : Bool := contrastRatio fg bg ≥ threshold -/-- Whether two colors differ by at least {lit}`threshold` in CIEDE2000 ΔE. -/ +/-- Whether two colors differ by at least the given threshold in CIEDE2000 ΔE. -/ public def distinguishable? (threshold : Float) (c1 c2 : Color) : Bool := deltaE c1 c2 ≥ threshold @@ -69,9 +68,9 @@ public def isOpaque : Color → Bool | .rgba _ _ _ a => a == 255 /-- -Composites {lit}`fg` over {lit}`bg` using {lit}`fg`'s alpha (straight alpha in sRGB, matching how a -browser paints translucent content on a solid background). The result is opaque; the background's -own alpha is ignored. +Composites a foreground over a background using the foreground's alpha (straight alpha in sRGB, +matching how a browser paints translucent content on a solid background). The result is opaque; the +background's own alpha is ignored. -/ public def over (fg bg : Color) : Color := match fg, bg with diff --git a/src/verso/Verso/Color/Basic.lean b/src/verso/Verso/Theme/Color/Basic.lean similarity index 95% rename from src/verso/Verso/Color/Basic.lean rename to src/verso/Verso/Theme/Color/Basic.lean index ca3df7b4c..191a0bbf2 100644 --- a/src/verso/Verso/Color/Basic.lean +++ b/src/verso/Verso/Theme/Color/Basic.lean @@ -5,17 +5,17 @@ Author: David Thrane Christiansen -/ module -public import Verso.Color.Types +public import Verso.Theme.Color.Types set_option linter.missingDocs true set_option doc.verso true /-! -The pure Lean API for {name (full := Verso.Color)}`Color`: a small set of named colors, CSS and TeX +The pure Lean API for {name (full := Verso.Theme.Color)}`Color`: a small set of named colors, CSS and TeX rendering, and parsing of hex color strings. -/ -namespace Verso.Color +namespace Verso.Theme.Color /-- Constructs an opaque color. diff --git a/src/verso/Verso/Color/Math.lean b/src/verso/Verso/Theme/Color/Math.lean similarity index 98% rename from src/verso/Verso/Color/Math.lean rename to src/verso/Verso/Theme/Color/Math.lean index c25f6fe03..66746c93a 100644 --- a/src/verso/Verso/Color/Math.lean +++ b/src/verso/Verso/Theme/Color/Math.lean @@ -5,7 +5,7 @@ Author: David Thrane Christiansen -/ module -public import Verso.Color.Types +public import Verso.Theme.Color.Types set_option linter.missingDocs true set_option doc.verso true @@ -31,10 +31,10 @@ Color math for accessibility checking, using the following: apart, including under the simulations above. All channels convert to {name}`Float` at the boundary (`/255`) and the math runs in {name}`Float`; -the {name (full := Verso.Color)}`Color` type itself stays byte-exact. +the {name (full := Verso.Theme.Color)}`Color` type itself stays byte-exact. -/ -namespace Verso +namespace Verso.Theme namespace Color diff --git a/src/verso/Verso/Color/Syntax.lean b/src/verso/Verso/Theme/Color/Syntax.lean similarity index 91% rename from src/verso/Verso/Color/Syntax.lean rename to src/verso/Verso/Theme/Color/Syntax.lean index 713c71af6..3ff0f8d76 100644 --- a/src/verso/Verso/Color/Syntax.lean +++ b/src/verso/Verso/Theme/Color/Syntax.lean @@ -14,13 +14,13 @@ public import Lean.PrettyPrinter.Formatter public import Lean.Elab.Term meta import Lean.PrettyPrinter meta import Lean.Elab.Term -public meta import Verso.Color.Basic -public meta import Verso.Color.Widget +public meta import Verso.Theme.Color.Basic +public meta import Verso.Theme.Color.Widget set_option linter.missingDocs true set_option doc.verso true -namespace Verso +namespace Verso.Theme public section @@ -76,13 +76,13 @@ private meta def asHexString (stx : TSyntax colorHexKind) : String := /-- A color literal: `color%#rgb`, `color%#rrggbb`, or `color%#rrggbbaa` (case-insensitive hex, no space -before the `#`). It is an ordinary term, usable anywhere a {name (full := Verso.Color)}`Color` is +before the `#`). It is an ordinary term, usable anywhere a {name (full := Verso.Theme.Color)}`Color` is expected, including in structure-field defaults. -/ syntax (name := colorLit) "color%" noWs colorHex : term open Elab Term in -/-- Elaborates a {lit}`color%` literal to a {name (full := Verso.Color)}`Color` and attaches the +/-- Elaborates a {lit}`color%` literal to a {name (full := Verso.Theme.Color)}`Color` and attaches the preview widget. -/ @[term_elab colorLit] meta def elabColorLit : TermElab := fun stx expectedType? => do @@ -94,6 +94,6 @@ meta def elabColorLit : TermElab := fun stx expectedType? => do | .error msg => throwErrorAt hexStx msg saveColorWidget (.rgba r g b a) stx let lit (n : UInt8) : Term := ⟨Syntax.mkNumLit (toString n.toNat)⟩ - elabTerm (← `(Verso.Color.rgba $(lit r) $(lit g) $(lit b) $(lit a))) expectedType? + elabTerm (← `(Verso.Theme.Color.rgba $(lit r) $(lit g) $(lit b) $(lit a))) expectedType? end diff --git a/src/verso/Verso/Color/Types.lean b/src/verso/Verso/Theme/Color/Types.lean similarity index 97% rename from src/verso/Verso/Color/Types.lean rename to src/verso/Verso/Theme/Color/Types.lean index 10ae38417..3f4419f03 100644 --- a/src/verso/Verso/Color/Types.lean +++ b/src/verso/Verso/Theme/Color/Types.lean @@ -8,7 +8,7 @@ module set_option linter.missingDocs true set_option doc.verso true -namespace Verso +namespace Verso.Theme -- This is an inductive with a single `rgba` constructor rather than a structure so that other color -- models (such as wide-gamut `oklch`) can be added later. The byte channels give a canonical diff --git a/src/verso/Verso/Color/Widget.lean b/src/verso/Verso/Theme/Color/Widget.lean similarity index 91% rename from src/verso/Verso/Color/Widget.lean rename to src/verso/Verso/Theme/Color/Widget.lean index 28c880903..e04ed33ec 100644 --- a/src/verso/Verso/Color/Widget.lean +++ b/src/verso/Verso/Theme/Color/Widget.lean @@ -6,8 +6,8 @@ Author: David Thrane Christiansen module public meta import Lean.Widget.UserWidget -public meta import Verso.Color.Basic -public meta import Verso.Color.Math +public meta import Verso.Theme.Color.Basic +public meta import Verso.Theme.Color.Math set_option linter.missingDocs true set_option doc.verso true @@ -17,7 +17,7 @@ The InfoView preview widget for colors. The CSS form is computed in Lean and han so the JavaScript only has to draw the swatch. -/ -namespace Verso +namespace Verso.Theme public section diff --git a/src/verso/Verso/Color/color-swatch.js b/src/verso/Verso/Theme/Color/color-swatch.js similarity index 100% rename from src/verso/Verso/Color/color-swatch.js rename to src/verso/Verso/Theme/Color/color-swatch.js From a08d0d98ea10c29448bdd5a4b9e1ba90255494ae Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Sat, 30 May 2026 13:33:27 +0200 Subject: [PATCH 07/31] feat: code TeX themes Extends code themes to TeX output --- src/tests/TestMain.lean | 2 +- src/tests/Tests/Font.lean | 22 +++ src/tests/Tests/HighlightedToTeX.lean | 65 ++++++++ .../code-content-doc/expected/tex/main.tex | 145 ++++++++++-------- .../diagram-doc/expected/tex/main.tex | 29 +++- .../extra-files-doc/expected/tex/main.tex | 29 +++- .../front-matter-doc/expected/tex/main.tex | 29 +++- .../inheritance-doc/expected/tex/main.tex | 31 +++- .../sample-doc/expected/tex/main.tex | 31 +++- src/verso-manual/VersoManual.lean | 8 +- src/verso-manual/VersoManual/TeX.lean | 22 ++- src/verso-search/VersoSearch.lean | 2 +- src/verso/Verso/Code/HighlightedToTex.lean | 53 +++++-- src/verso/Verso/Font.lean | 13 ++ src/verso/Verso/Theme/Code.lean | 87 ++++++++++- 15 files changed, 437 insertions(+), 131 deletions(-) diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean index 8e3059af5..0d4f19b75 100644 --- a/src/tests/TestMain.lean +++ b/src/tests/TestMain.lean @@ -57,7 +57,7 @@ def testTexOutput let runTest : IO Unit := open Verso Genre Manual in do let logger ← Verso.Logger.new - emitTeX versoConfig doc.toPart |>.run extension_impls% |>.run logger + emitTeX ({ versoConfig with : RenderConfig }) doc.toPart |>.run extension_impls% |>.run logger Verso.Integration.runTests { config with testDir := "src/tests/integration" / dir, diff --git a/src/tests/Tests/Font.lean b/src/tests/Tests/Font.lean index b311d70cf..49f88dac3 100644 --- a/src/tests/Tests/Font.lean +++ b/src/tests/Tests/Font.lean @@ -4,6 +4,8 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ import Verso.Font +import Verso.Theme.Code +import Verso.Theme.Code.Defaults /-! Compile-time tests for `Verso.Font`: the `define_font_face` command embeds file bytes, `Weight` @@ -68,3 +70,23 @@ define_font_face noFormat where #guard_msgs in define_font_face noFile where format := .ttf + +-- `slugFamily` keeps a usable hyphen-separated tail and falls back to "font" for empty input. +/-- info: "Source-Sans-3" -/ +#guard_msgs in #eval Verso.Theme.CodeTheme.slugFamily "Source Sans 3" +/-- info: "Fira-Mono" -/ +#guard_msgs in #eval Verso.Theme.CodeTheme.slugFamily "Fira/Mono" +/-- info: "font" -/ +#guard_msgs in #eval Verso.Theme.CodeTheme.slugFamily "///" + +-- Two distinct families that slug to the same string get distinct asset paths via the typeface +-- index, so one font's bytes can never overwrite the other. +def collidingTheme : Verso.Theme.CodeTheme := { + Verso.Theme.CodeTheme.Default with + codeFace := .files "A B" #[katexMono], + const := { color := color%#000000, weight := .regular, style := .normal, + face := .files "A/B" #[katexMono] } +} + +/-- info: #["assets/fonts/A-B-0-0.woff2", "assets/fonts/A-B-1-0.woff2"] -/ +#guard_msgs in #eval (collidingTheme.fontAssets "assets").map (·.1) diff --git a/src/tests/Tests/HighlightedToTeX.lean b/src/tests/Tests/HighlightedToTeX.lean index dba4aa078..caaf2a817 100644 --- a/src/tests/Tests/HighlightedToTeX.lean +++ b/src/tests/Tests/HighlightedToTeX.lean @@ -5,6 +5,11 @@ Author: Jason Reed -/ module meta import all Verso.Code.HighlightedToTex +public import Verso.Theme.Code +public import Verso.Theme.Code.Defaults +public import Verso.Font +meta import all Verso.Theme.Code +meta import all Verso.Theme.Code.Defaults open Verso.Doc.TeX (escapeForVerbatim) open SubVerso.Highlighting @@ -12,3 +17,63 @@ open SubVerso.Highlighting /-- info: "\\symbol{123}\\symbol{124}\\symbol{125}\\symbol{92}" -/ #guard_msgs in #eval escapeForVerbatim "{|}\\" + +/-! Token rendering wraps each semantic category in a `\verso…` macro. The four categories cover +keywords, constants (including anonymous constructors and options), variables, and a catch-all +literal bucket. -/ + +/-- info: "\\versoKeyword{def}" -/ +#guard_msgs in #eval (highlightToken "def" (.keyword none none "")).asString + +/-- info: "\\versoConst{foo}" -/ +#guard_msgs in #eval (highlightToken "foo" (.const `foo "" none false none)).asString + +/-- info: "\\versoVar{x}" -/ +#guard_msgs in #eval (highlightToken "x" (.var ⟨`x⟩ "" none)).asString + +/-- info: "\\versoLiteral{42}" -/ +#guard_msgs in #eval (highlightToken "42" .unknown).asString + +/-! The fallback macro block defines the four `\verso…` macros with `\providecommand`, so a +preamble that defines its own (theme-driven) versions wins without an explicit `\renewcommand`. -/ + +/-- +info: "\\providecommand{\\versoKeyword}[1]{\\textbf{#1}}\n\\providecommand{\\versoConst}[1]{#1}\n\\providecommand{\\versoVar}[1]{\\textit{#1}}\n\\providecommand{\\versoLiteral}[1]{#1}\n" +-/ +#guard_msgs in #eval texMacroFallbacks + +/-! The default code theme emits `\definecolor` blocks for its token and severity colors and +redefines each `\verso…` macro to apply the resolved color, weight, and style. -/ + +private def hasSub (s sub : String) : Bool := (s.splitOn sub).length > 1 + +/-- info: true -/ +#guard_msgs in +#eval + let p := Verso.Theme.CodeTheme.Default.texPreamble + -- Message-text and accent colors are emitted under distinct names: `errorColor` is the + -- message-body color (#cc0000 by default), `errorIndicatorColor` is the wavy-underline + -- accent (#ff0000). The keyword macro picks up bold (NFSS `eb`), and the mono font is + -- the bundled DejaVu Sans Mono. + hasSub p "\\definecolor{errorColor}{HTML}{CC0000}" && + hasSub p "\\definecolor{errorIndicatorColor}{HTML}{FF0000}" && + hasSub p "\\renewcommand{\\versoKeyword}[1]{\\textcolor{versoKeywordColor}{\\fontseries{eb}\\fontshape{n}\\selectfont #1}}" && + hasSub p "\\setmonofont{DejaVu Sans Mono}" + +/-! A deliberately colorful theme really does color and style the keyword and const tokens. -/ + +open Verso Verso.Theme in +private def colorfulTheme : CodeTheme := { + CodeTheme.Default with + keyword := { color := color%#aa3300, weight := 600, style := .normal, face := .mono }, + const := { color := color%#0044bb, weight := .regular, style := .italic, face := .mono } +} + +/-- info: true -/ +#guard_msgs in +#eval + let p := colorfulTheme.texPreamble + hasSub p "\\definecolor{versoKeywordColor}{HTML}{AA3300}" && + hasSub p "\\renewcommand{\\versoKeyword}[1]{\\textcolor{versoKeywordColor}{\\fontseries{b}\\fontshape{n}\\selectfont #1}}" && + hasSub p "\\definecolor{versoConstColor}{HTML}{0044BB}" && + hasSub p "\\renewcommand{\\versoConst}[1]{\\textcolor{versoConstColor}{\\fontseries{m}\\fontshape{it}\\selectfont #1}}" diff --git a/src/tests/integration/code-content-doc/expected/tex/main.tex b/src/tests/integration/code-content-doc/expected/tex/main.tex index 2c081701c..9b8486643 100644 --- a/src/tests/integration/code-content-doc/expected/tex/main.tex +++ b/src/tests/integration/code-content-doc/expected/tex/main.tex @@ -51,12 +51,11 @@ % Work around missing U+2011 (non-breaking hyphen) in Source Serif Pro \newunicodechar{‑}{-} -\definecolor{errorColor}{HTML}{B91C1C} -\definecolor{infoColor}{HTML}{1E6BB8} -\definecolor{warningColor}{HTML}{D97706} -\newcommand{\errorDecorate}[1]{\coloredwave{errorColor}{#1}} -\newcommand{\infoDecorate}[1]{\coloredwave{infoColor}{#1}} -\newcommand{\warningDecorate}[1]{\coloredwave{warningColor}{#1}} +% Decoration accents (wavy underlines) use the *indicator* colors, distinct from the message +% text colors. The theme block below redefines both. +\newcommand{\errorDecorate}[1]{\coloredwave{errorIndicatorColor}{#1}} +\newcommand{\infoDecorate}[1]{\coloredwave{infoIndicatorColor}{#1}} +\newcommand{\warningDecorate}[1]{\coloredwave{warningIndicatorColor}{#1}} \DefineVerbatimEnvironment{LeanVerbatim}{Verbatim} {commandchars=\\\{\},fontsize=\small,breaklines=true} \DefineVerbatimEnvironment{FileVerbatim}{Verbatim}{commandchars=\\\{\},fontsize=\small,breaklines=true,frame=single,framesep=2mm,numbers=left} @@ -124,6 +123,24 @@ \renewcommand{\cftsectionfont}{\normalfont\sffamily} \renewcommand{\cftchapterpagefont}{\normalfont\sffamily} \renewcommand{\cftsectionpagefont}{\normalfont\sffamily} +\providecommand{\versoKeyword}[1]{\textbf{#1}} +\providecommand{\versoConst}[1]{#1} +\providecommand{\versoVar}[1]{\textit{#1}} +\providecommand{\versoLiteral}[1]{#1} +\definecolor{versoCodeColor}{HTML}{000000} +\definecolor{versoConstColor}{HTML}{000000} +\definecolor{versoKeywordColor}{HTML}{000000} +\definecolor{versoVarColor}{HTML}{000000} +\definecolor{errorColor}{HTML}{CC0000} +\definecolor{warningColor}{HTML}{000000} +\definecolor{infoColor}{HTML}{000000} +\definecolor{errorIndicatorColor}{HTML}{FF0000} +\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{infoIndicatorColor}{HTML}{4777FF} +\renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} +\renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} +\renewcommand{\versoVar}[1]{\textcolor{versoVarColor}{\fontseries{m}\fontshape{it}\selectfont #1}} +\renewcommand{\versoLiteral}[1]{\textcolor{versoCodeColor}{#1}} \setmonofont{DejaVu Sans Mono} \title{\sffamily Title of the Doc} @@ -144,72 +161,72 @@ \cleardoublepage Here is some code with vertical bars: \begin{LeanVerbatim} -\textbf{def} or := (· \symbol{124}\symbol{124} ·) - +\versoKeyword{def} \versoConst{or} \versoLiteral{:=} \versoLiteral{(}\versoLiteral{·} \versoLiteral{\symbol{124}\symbol{124}} \versoLiteral{·}\versoLiteral{)} +\versoLiteral{} \end{LeanVerbatim} Here is some with a variety of interesting Unicode, including characters where UTF-16 is funky: \begin{LeanVerbatim} -\textbf{def} Set (\textit{α} : Type u) : Type u := \textit{α} → Prop - -\textbf{instance} : EmptyCollection (Set \textit{α}) \textbf{where} - emptyCollection := \textbf{fun} _ => False - -\textbf{instance} : Union (Set \textit{α}) \textbf{where} - union \textit{a} \textit{b} := \textbf{fun} \textit{x} => \textit{a} \textit{x} ∨ \textit{b} \textit{x} - -\textbf{instance} : Inter (Set \textit{α}) \textbf{where} - inter \textit{a} \textit{b} := \textbf{fun} \textit{x} => \textit{a} \textit{x} ∧ \textit{b} \textit{x} - -\textbf{instance} : Membership \textit{α} (Set \textit{α}) \textbf{where} - mem \textit{a} \textit{x} := \textit{a} \textit{x} - -@[\textbf{ext}] -\textbf{theorem} Set.ext \symbol{123}\textit{a} \textit{b} : Set \textit{α}\symbol{125} : - (∀ \textit{x}, \textit{x} ∈ \textit{a} ↔ \textit{x} ∈ \textit{b}) → \textit{a} = \textit{b} := \textbf{by} - \textbf{intro} \textit{h} - \textbf{funext} \textit{x} - \textbf{exact} propext (\textit{h} \textit{x}) - -\textbf{instance} : HasSubset (Set \textit{α}) \textbf{where} - Subset \textit{a} \textit{b} := ∀ \textit{x}, \textit{x} ∈ \textit{a} → \textit{x} ∈ \textit{b} - -@[\textbf{simp}, \textbf{grind} .] -\textbf{theorem} Set.subset_refl \symbol{123}\textit{a} : Set \textit{α}\symbol{125} : \textit{a} ⊆ \textit{a} := \textbf{by} - \textbf{simp} [(· ⊆ ·)] - -@[\textbf{grind} ←] -\textbf{theorem} Set.subset_union \symbol{123}\textit{a} \textit{b} \textit{c} : Set \textit{α}\symbol{125} : - \textit{a} ⊆ \textit{b} → \textit{a} ⊆ \textit{b} ∪ \textit{c} := \textbf{by} - \textbf{simp} [(· ⊆ ·), (· ∪ ·), (· ∈ ·)] - \textbf{intro} \textit{h} - \textbf{solve_by_elim} - -\textbf{def} Set.powerset (\textit{a} : Set \textit{α}) : Set (Set \textit{α}) := - \textbf{fun} (\textit{x} : Set \textit{α}) => \textit{x} ⊆ \textit{a} - -\textbf{notation} "𝒫 " \textit{x} => Set.powerset \textit{x} - -\textbf{theorem} Set.powerset_empty_nonempty : - ∃ (\textit{a} : Set \textit{α}), \textit{a} ∈ 𝒫 \symbol{123}\symbol{125} := \textbf{by} - \textbf{constructor} - \textbf{case} w => \textbf{exact} \symbol{123}\symbol{125} - \textbf{simp} [(· ∈ ·), powerset] - -@[\infoDecorate{\textbf{grind?} →}] -\textbf{theorem} Set.powerset_empty_unique (\textit{x} \textit{y} : Set \textit{α}) : - \textit{x} ∈ (𝒫 \symbol{123}\symbol{125}) → \textit{y} ∈ (𝒫 \symbol{123}\symbol{125}) → \textit{x} = \textit{y} := \textbf{by} - \textbf{intro} \textit{hx} \textit{hy} - \textbf{ext} \textit{x'} - \textbf{exact} (iff_false_right (\textit{hy} \textit{x'})).mpr (\textit{hx} \textit{x'}) - +\versoKeyword{def} \versoConst{Set} \versoLiteral{(}\versoVar{α} \versoLiteral{:} \versoLiteral{Type} \versoLiteral{u}\versoLiteral{)} \versoLiteral{:} \versoLiteral{Type} \versoLiteral{u} \versoLiteral{:=} \versoVar{α} \versoLiteral{→} \versoLiteral{Prop} + +\versoKeyword{instance} \versoLiteral{:} \versoConst{EmptyCollection} \versoLiteral{(}\versoConst{Set} \versoVar{α}\versoLiteral{)} \versoKeyword{where} + \versoConst{emptyCollection} \versoLiteral{:=} \versoKeyword{fun} \versoLiteral{_} \versoLiteral{=>} \versoConst{False} + +\versoKeyword{instance} \versoLiteral{:} \versoConst{Union} \versoLiteral{(}\versoConst{Set} \versoVar{α}\versoLiteral{)} \versoKeyword{where} + \versoConst{union} \versoVar{a} \versoVar{b} \versoLiteral{:=} \versoKeyword{fun} \versoVar{x} \versoLiteral{=>} \versoVar{a} \versoVar{x} \versoLiteral{∨} \versoVar{b} \versoVar{x} + +\versoKeyword{instance} \versoLiteral{:} \versoConst{Inter} \versoLiteral{(}\versoConst{Set} \versoVar{α}\versoLiteral{)} \versoKeyword{where} + \versoConst{inter} \versoVar{a} \versoVar{b} \versoLiteral{:=} \versoKeyword{fun} \versoVar{x} \versoLiteral{=>} \versoVar{a} \versoVar{x} \versoLiteral{∧} \versoVar{b} \versoVar{x} + +\versoKeyword{instance} \versoLiteral{:} \versoConst{Membership} \versoVar{α} \versoLiteral{(}\versoConst{Set} \versoVar{α}\versoLiteral{)} \versoKeyword{where} + \versoConst{mem} \versoVar{a} \versoVar{x} \versoLiteral{:=} \versoVar{a} \versoVar{x} + +\versoLiteral{@[}\versoKeyword{ext}\versoLiteral{]} +\versoKeyword{theorem} \versoConst{Set.ext} \versoLiteral{\symbol{123}}\versoVar{a} \versoVar{b} \versoLiteral{:} \versoConst{Set} \versoVar{α}\versoLiteral{\symbol{125}} \versoLiteral{:} + \versoLiteral{(}\versoLiteral{∀} \versoVar{x}\versoLiteral{,} \versoVar{x} \versoLiteral{∈} \versoVar{a} \versoLiteral{↔} \versoVar{x} \versoLiteral{∈} \versoVar{b}\versoLiteral{)} \versoLiteral{→} \versoVar{a} \versoLiteral{=} \versoVar{b} \versoLiteral{:=} \versoKeyword{by} + \versoKeyword{intro} \versoVar{h} + \versoKeyword{funext} \versoVar{x} + \versoKeyword{exact} \versoConst{propext} \versoLiteral{(}\versoVar{h} \versoVar{x}\versoLiteral{)} + +\versoKeyword{instance} \versoLiteral{:} \versoConst{HasSubset} \versoLiteral{(}\versoConst{Set} \versoVar{α}\versoLiteral{)} \versoKeyword{where} + \versoConst{Subset} \versoVar{a} \versoVar{b} \versoLiteral{:=} \versoLiteral{∀} \versoVar{x}\versoLiteral{,} \versoVar{x} \versoLiteral{∈} \versoVar{a} \versoLiteral{→} \versoVar{x} \versoLiteral{∈} \versoVar{b} + +\versoLiteral{@[}\versoKeyword{simp}\versoLiteral{,} \versoKeyword{grind} \versoLiteral{.}\versoLiteral{]} +\versoKeyword{theorem} \versoConst{Set.subset_refl} \versoLiteral{\symbol{123}}\versoVar{a} \versoLiteral{:} \versoConst{Set} \versoVar{α}\versoLiteral{\symbol{125}} \versoLiteral{:} \versoVar{a} \versoLiteral{⊆} \versoVar{a} \versoLiteral{:=} \versoKeyword{by} + \versoKeyword{simp} \versoLiteral{[}\versoLiteral{(}\versoLiteral{·} \versoLiteral{⊆} \versoLiteral{·}\versoLiteral{)}\versoLiteral{]} + +\versoLiteral{@[}\versoKeyword{grind} \versoLiteral{←}\versoLiteral{]} +\versoKeyword{theorem} \versoConst{Set.subset_union} \versoLiteral{\symbol{123}}\versoVar{a} \versoVar{b} \versoVar{c} \versoLiteral{:} \versoConst{Set} \versoVar{α}\versoLiteral{\symbol{125}} \versoLiteral{:} + \versoVar{a} \versoLiteral{⊆} \versoVar{b} \versoLiteral{→} \versoVar{a} \versoLiteral{⊆} \versoVar{b} \versoLiteral{∪} \versoVar{c} \versoLiteral{:=} \versoKeyword{by} + \versoKeyword{simp} \versoLiteral{[}\versoLiteral{(}\versoLiteral{·} \versoLiteral{⊆} \versoLiteral{·}\versoLiteral{)}\versoLiteral{,} \versoLiteral{(}\versoLiteral{·} \versoLiteral{∪} \versoLiteral{·}\versoLiteral{)}\versoLiteral{,} \versoLiteral{(}\versoLiteral{·} \versoLiteral{∈} \versoLiteral{·}\versoLiteral{)}\versoLiteral{]} + \versoKeyword{intro} \versoVar{h} + \versoKeyword{solve_by_elim} + +\versoKeyword{def} \versoConst{Set.powerset} \versoLiteral{(}\versoVar{a} \versoLiteral{:} \versoConst{Set} \versoVar{α}\versoLiteral{)} \versoLiteral{:} \versoConst{Set} \versoLiteral{(}\versoConst{Set} \versoVar{α}\versoLiteral{)} \versoLiteral{:=} + \versoKeyword{fun} \versoLiteral{(}\versoVar{x} \versoLiteral{:} \versoConst{Set} \versoVar{α}\versoLiteral{)} \versoLiteral{=>} \versoVar{x} \versoLiteral{⊆} \versoVar{a} + +\versoKeyword{notation} \versoLiteral{"𝒫 "} \versoVar{x} \versoLiteral{=>} \versoConst{Set.powerset} \versoVar{x} + +\versoKeyword{theorem} \versoConst{Set.powerset_empty_nonempty} \versoLiteral{:} + \versoLiteral{∃} \versoLiteral{(}\versoVar{a} \versoLiteral{:} \versoConst{Set} \versoVar{α}\versoLiteral{)}\versoLiteral{,} \versoVar{a} \versoLiteral{∈} \versoLiteral{𝒫} \versoLiteral{\symbol{123}}\versoLiteral{\symbol{125}} \versoLiteral{:=} \versoKeyword{by} + \versoKeyword{constructor} + \versoKeyword{case} \versoLiteral{w} \versoLiteral{=>} \versoKeyword{exact} \versoLiteral{\symbol{123}}\versoLiteral{\symbol{125}} + \versoKeyword{simp} \versoLiteral{[}\versoLiteral{(}\versoLiteral{·} \versoLiteral{∈} \versoLiteral{·}\versoLiteral{)}\versoLiteral{,} \versoConst{powerset}\versoLiteral{]} + +\versoLiteral{@[}\infoDecorate{\versoKeyword{grind?} \versoLiteral{→}}\versoLiteral{]} +\versoKeyword{theorem} \versoConst{Set.powerset_empty_unique} \versoLiteral{(}\versoVar{x} \versoVar{y} \versoLiteral{:} \versoConst{Set} \versoVar{α}\versoLiteral{)} \versoLiteral{:} + \versoVar{x} \versoLiteral{∈} \versoLiteral{(}\versoLiteral{𝒫} \versoLiteral{\symbol{123}}\versoLiteral{\symbol{125}}\versoLiteral{)} \versoLiteral{→} \versoVar{y} \versoLiteral{∈} \versoLiteral{(}\versoLiteral{𝒫} \versoLiteral{\symbol{123}}\versoLiteral{\symbol{125}}\versoLiteral{)} \versoLiteral{→} \versoVar{x} \versoLiteral{=} \versoVar{y} \versoLiteral{:=} \versoKeyword{by} + \versoKeyword{intro} \versoVar{hx} \versoVar{hy} + \versoKeyword{ext} \versoVar{x'} + \versoKeyword{exact} \versoLiteral{(}\versoConst{iff_false_right} \versoLiteral{(}\versoVar{hy} \versoVar{x'}\versoLiteral{)}\versoLiteral{)}\versoLiteral{.}\versoConst{mpr} \versoLiteral{(}\versoVar{hx} \versoVar{x'}\versoLiteral{)} +\versoLiteral{} \end{LeanVerbatim} And now some inline code: \begin{itemize} -\item \LeanVerb|∀\textit{x} \textit{y} : Set _, \textit{x} ∈ ((𝒫 \textit{x}) ∪ (𝒫 \textit{y}))| -\item \LeanVerb|true \symbol{124}\symbol{124} false| -\item \LeanVerb|False → True| +\item \LeanVerb|\versoLiteral{∀}\versoVar{x} \versoVar{y} \versoLiteral{:} \versoConst{Set} \versoLiteral{_}\versoLiteral{,} \versoVar{x} \versoLiteral{∈} \versoLiteral{(}\versoLiteral{(}\versoLiteral{𝒫} \versoVar{x}\versoLiteral{)} \versoLiteral{∪} \versoLiteral{(}\versoLiteral{𝒫} \versoVar{y}\versoLiteral{)}\versoLiteral{)}| +\item \LeanVerb|\versoConst{true} \versoLiteral{\symbol{124}\symbol{124}} \versoConst{false}| +\item \LeanVerb|\versoConst{False} \versoLiteral{→} \versoConst{True}| \end{itemize} diff --git a/src/tests/integration/diagram-doc/expected/tex/main.tex b/src/tests/integration/diagram-doc/expected/tex/main.tex index 47d521914..09cd3746b 100644 --- a/src/tests/integration/diagram-doc/expected/tex/main.tex +++ b/src/tests/integration/diagram-doc/expected/tex/main.tex @@ -51,12 +51,11 @@ % Work around missing U+2011 (non-breaking hyphen) in Source Serif Pro \newunicodechar{‑}{-} -\definecolor{errorColor}{HTML}{B91C1C} -\definecolor{infoColor}{HTML}{1E6BB8} -\definecolor{warningColor}{HTML}{D97706} -\newcommand{\errorDecorate}[1]{\coloredwave{errorColor}{#1}} -\newcommand{\infoDecorate}[1]{\coloredwave{infoColor}{#1}} -\newcommand{\warningDecorate}[1]{\coloredwave{warningColor}{#1}} +% Decoration accents (wavy underlines) use the *indicator* colors, distinct from the message +% text colors. The theme block below redefines both. +\newcommand{\errorDecorate}[1]{\coloredwave{errorIndicatorColor}{#1}} +\newcommand{\infoDecorate}[1]{\coloredwave{infoIndicatorColor}{#1}} +\newcommand{\warningDecorate}[1]{\coloredwave{warningIndicatorColor}{#1}} \DefineVerbatimEnvironment{LeanVerbatim}{Verbatim} {commandchars=\\\{\},fontsize=\small,breaklines=true} \DefineVerbatimEnvironment{FileVerbatim}{Verbatim}{commandchars=\\\{\},fontsize=\small,breaklines=true,frame=single,framesep=2mm,numbers=left} @@ -124,6 +123,24 @@ \renewcommand{\cftsectionfont}{\normalfont\sffamily} \renewcommand{\cftchapterpagefont}{\normalfont\sffamily} \renewcommand{\cftsectionpagefont}{\normalfont\sffamily} +\providecommand{\versoKeyword}[1]{\textbf{#1}} +\providecommand{\versoConst}[1]{#1} +\providecommand{\versoVar}[1]{\textit{#1}} +\providecommand{\versoLiteral}[1]{#1} +\definecolor{versoCodeColor}{HTML}{000000} +\definecolor{versoConstColor}{HTML}{000000} +\definecolor{versoKeywordColor}{HTML}{000000} +\definecolor{versoVarColor}{HTML}{000000} +\definecolor{errorColor}{HTML}{CC0000} +\definecolor{warningColor}{HTML}{000000} +\definecolor{infoColor}{HTML}{000000} +\definecolor{errorIndicatorColor}{HTML}{FF0000} +\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{infoIndicatorColor}{HTML}{4777FF} +\renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} +\renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} +\renewcommand{\versoVar}[1]{\textcolor{versoVarColor}{\fontseries{m}\fontshape{it}\selectfont #1}} +\renewcommand{\versoLiteral}[1]{\textcolor{versoCodeColor}{#1}} \setmonofont{DejaVu Sans Mono} \title{\sffamily Diagrams in the Manual genre} diff --git a/src/tests/integration/extra-files-doc/expected/tex/main.tex b/src/tests/integration/extra-files-doc/expected/tex/main.tex index c9aa89ea8..a4f708321 100644 --- a/src/tests/integration/extra-files-doc/expected/tex/main.tex +++ b/src/tests/integration/extra-files-doc/expected/tex/main.tex @@ -51,12 +51,11 @@ % Work around missing U+2011 (non-breaking hyphen) in Source Serif Pro \newunicodechar{‑}{-} -\definecolor{errorColor}{HTML}{B91C1C} -\definecolor{infoColor}{HTML}{1E6BB8} -\definecolor{warningColor}{HTML}{D97706} -\newcommand{\errorDecorate}[1]{\coloredwave{errorColor}{#1}} -\newcommand{\infoDecorate}[1]{\coloredwave{infoColor}{#1}} -\newcommand{\warningDecorate}[1]{\coloredwave{warningColor}{#1}} +% Decoration accents (wavy underlines) use the *indicator* colors, distinct from the message +% text colors. The theme block below redefines both. +\newcommand{\errorDecorate}[1]{\coloredwave{errorIndicatorColor}{#1}} +\newcommand{\infoDecorate}[1]{\coloredwave{infoIndicatorColor}{#1}} +\newcommand{\warningDecorate}[1]{\coloredwave{warningIndicatorColor}{#1}} \DefineVerbatimEnvironment{LeanVerbatim}{Verbatim} {commandchars=\\\{\},fontsize=\small,breaklines=true} \DefineVerbatimEnvironment{FileVerbatim}{Verbatim}{commandchars=\\\{\},fontsize=\small,breaklines=true,frame=single,framesep=2mm,numbers=left} @@ -124,6 +123,24 @@ \renewcommand{\cftsectionfont}{\normalfont\sffamily} \renewcommand{\cftchapterpagefont}{\normalfont\sffamily} \renewcommand{\cftsectionpagefont}{\normalfont\sffamily} +\providecommand{\versoKeyword}[1]{\textbf{#1}} +\providecommand{\versoConst}[1]{#1} +\providecommand{\versoVar}[1]{\textit{#1}} +\providecommand{\versoLiteral}[1]{#1} +\definecolor{versoCodeColor}{HTML}{000000} +\definecolor{versoConstColor}{HTML}{000000} +\definecolor{versoKeywordColor}{HTML}{000000} +\definecolor{versoVarColor}{HTML}{000000} +\definecolor{errorColor}{HTML}{CC0000} +\definecolor{warningColor}{HTML}{000000} +\definecolor{infoColor}{HTML}{000000} +\definecolor{errorIndicatorColor}{HTML}{FF0000} +\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{infoIndicatorColor}{HTML}{4777FF} +\renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} +\renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} +\renewcommand{\versoVar}[1]{\textcolor{versoVarColor}{\fontseries{m}\fontshape{it}\selectfont #1}} +\renewcommand{\versoLiteral}[1]{\textcolor{versoCodeColor}{#1}} \setmonofont{DejaVu Sans Mono} \title{\sffamily Extra Files Test Document} diff --git a/src/tests/integration/front-matter-doc/expected/tex/main.tex b/src/tests/integration/front-matter-doc/expected/tex/main.tex index 371a84f8e..716878d92 100644 --- a/src/tests/integration/front-matter-doc/expected/tex/main.tex +++ b/src/tests/integration/front-matter-doc/expected/tex/main.tex @@ -51,12 +51,11 @@ % Work around missing U+2011 (non-breaking hyphen) in Source Serif Pro \newunicodechar{‑}{-} -\definecolor{errorColor}{HTML}{B91C1C} -\definecolor{infoColor}{HTML}{1E6BB8} -\definecolor{warningColor}{HTML}{D97706} -\newcommand{\errorDecorate}[1]{\coloredwave{errorColor}{#1}} -\newcommand{\infoDecorate}[1]{\coloredwave{infoColor}{#1}} -\newcommand{\warningDecorate}[1]{\coloredwave{warningColor}{#1}} +% Decoration accents (wavy underlines) use the *indicator* colors, distinct from the message +% text colors. The theme block below redefines both. +\newcommand{\errorDecorate}[1]{\coloredwave{errorIndicatorColor}{#1}} +\newcommand{\infoDecorate}[1]{\coloredwave{infoIndicatorColor}{#1}} +\newcommand{\warningDecorate}[1]{\coloredwave{warningIndicatorColor}{#1}} \DefineVerbatimEnvironment{LeanVerbatim}{Verbatim} {commandchars=\\\{\},fontsize=\small,breaklines=true} \DefineVerbatimEnvironment{FileVerbatim}{Verbatim}{commandchars=\\\{\},fontsize=\small,breaklines=true,frame=single,framesep=2mm,numbers=left} @@ -124,6 +123,24 @@ \renewcommand{\cftsectionfont}{\normalfont\sffamily} \renewcommand{\cftchapterpagefont}{\normalfont\sffamily} \renewcommand{\cftsectionpagefont}{\normalfont\sffamily} +\providecommand{\versoKeyword}[1]{\textbf{#1}} +\providecommand{\versoConst}[1]{#1} +\providecommand{\versoVar}[1]{\textit{#1}} +\providecommand{\versoLiteral}[1]{#1} +\definecolor{versoCodeColor}{HTML}{000000} +\definecolor{versoConstColor}{HTML}{000000} +\definecolor{versoKeywordColor}{HTML}{000000} +\definecolor{versoVarColor}{HTML}{000000} +\definecolor{errorColor}{HTML}{CC0000} +\definecolor{warningColor}{HTML}{000000} +\definecolor{infoColor}{HTML}{000000} +\definecolor{errorIndicatorColor}{HTML}{FF0000} +\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{infoIndicatorColor}{HTML}{4777FF} +\renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} +\renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} +\renewcommand{\versoVar}[1]{\textcolor{versoVarColor}{\fontseries{m}\fontshape{it}\selectfont #1}} +\renewcommand{\versoLiteral}[1]{\textcolor{versoCodeColor}{#1}} \setmonofont{DejaVu Sans Mono} \title{\sffamily Front Matter Test Document} diff --git a/src/tests/integration/inheritance-doc/expected/tex/main.tex b/src/tests/integration/inheritance-doc/expected/tex/main.tex index 0600aab1b..c42d0377d 100644 --- a/src/tests/integration/inheritance-doc/expected/tex/main.tex +++ b/src/tests/integration/inheritance-doc/expected/tex/main.tex @@ -51,12 +51,11 @@ % Work around missing U+2011 (non-breaking hyphen) in Source Serif Pro \newunicodechar{‑}{-} -\definecolor{errorColor}{HTML}{B91C1C} -\definecolor{infoColor}{HTML}{1E6BB8} -\definecolor{warningColor}{HTML}{D97706} -\newcommand{\errorDecorate}[1]{\coloredwave{errorColor}{#1}} -\newcommand{\infoDecorate}[1]{\coloredwave{infoColor}{#1}} -\newcommand{\warningDecorate}[1]{\coloredwave{warningColor}{#1}} +% Decoration accents (wavy underlines) use the *indicator* colors, distinct from the message +% text colors. The theme block below redefines both. +\newcommand{\errorDecorate}[1]{\coloredwave{errorIndicatorColor}{#1}} +\newcommand{\infoDecorate}[1]{\coloredwave{infoIndicatorColor}{#1}} +\newcommand{\warningDecorate}[1]{\coloredwave{warningIndicatorColor}{#1}} \DefineVerbatimEnvironment{LeanVerbatim}{Verbatim} {commandchars=\\\{\},fontsize=\small,breaklines=true} \DefineVerbatimEnvironment{FileVerbatim}{Verbatim}{commandchars=\\\{\},fontsize=\small,breaklines=true,frame=single,framesep=2mm,numbers=left} @@ -124,6 +123,24 @@ \renewcommand{\cftsectionfont}{\normalfont\sffamily} \renewcommand{\cftchapterpagefont}{\normalfont\sffamily} \renewcommand{\cftsectionpagefont}{\normalfont\sffamily} +\providecommand{\versoKeyword}[1]{\textbf{#1}} +\providecommand{\versoConst}[1]{#1} +\providecommand{\versoVar}[1]{\textit{#1}} +\providecommand{\versoLiteral}[1]{#1} +\definecolor{versoCodeColor}{HTML}{000000} +\definecolor{versoConstColor}{HTML}{000000} +\definecolor{versoKeywordColor}{HTML}{000000} +\definecolor{versoVarColor}{HTML}{000000} +\definecolor{errorColor}{HTML}{CC0000} +\definecolor{warningColor}{HTML}{000000} +\definecolor{infoColor}{HTML}{000000} +\definecolor{errorIndicatorColor}{HTML}{FF0000} +\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{infoIndicatorColor}{HTML}{4777FF} +\renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} +\renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} +\renewcommand{\versoVar}[1]{\textcolor{versoVarColor}{\fontseries{m}\fontshape{it}\selectfont #1}} +\renewcommand{\versoLiteral}[1]{\textcolor{versoCodeColor}{#1}} \setmonofont{DejaVu Sans Mono} \title{\sffamily Title of the Doc} @@ -143,7 +160,7 @@ \cleardoublepage \begin{docstringBox}{structure} -\LeanVerb|Verso.\allowbreak{}Integration.\allowbreak{}Inheritance\-Doc.\allowbreak{}Foo\-Extends : Type|\tcblower Documentation for FooExtends\par\noindent\textbf{Constructor}\par \par \LeanVerb|Verso.\allowbreak{}Integration.\allowbreak{}Inheritance\-Doc.\allowbreak{}Foo\-Extends.\allowbreak{}mk|\par\noindent\textbf{Extends}\par Verso.Integration.InheritanceDoc.FooExtends\par\noindent\textbf{Fields}\par \par \LeanVerb|bar\-Field1| : \LeanVerb|Bool|\par Inherited from \LeanVerb|Bar\-Extended|\par \LeanVerb|bar\-Field2| : \LeanVerb|Unit|\par Inherited from \LeanVerb|Bar\-Extended|\par \LeanVerb|foo\-Field1| : \LeanVerb|Nat|\par Documentation for fooField1\par \LeanVerb|foo\-Field2| : \LeanVerb|String|\par Documentation for fooField2 +\LeanVerb|\versoConst{Verso.\allowbreak{}Integration.\allowbreak{}Inheritance\-Doc.\allowbreak{}Foo\-Extends} \versoLiteral{:} \versoLiteral{Type}|\tcblower Documentation for FooExtends\par\noindent\textbf{Constructor}\par \par \LeanVerb|\versoConst{Verso.\allowbreak{}Integration.\allowbreak{}Inheritance\-Doc.\allowbreak{}Foo\-Extends.\allowbreak{}mk}|\par\noindent\textbf{Extends}\par Verso.Integration.InheritanceDoc.FooExtends\par\noindent\textbf{Fields}\par \par \LeanVerb|\versoLiteral{bar\-Field1}| : \LeanVerb|\versoConst{Bool}|\par Inherited from \LeanVerb|\versoConst{Bar\-Extended}|\par \LeanVerb|\versoLiteral{bar\-Field2}| : \LeanVerb|\versoConst{Unit}|\par Inherited from \LeanVerb|\versoConst{Bar\-Extended}|\par \LeanVerb|\versoConst{foo\-Field1}| : \LeanVerb|\versoConst{Nat}|\par Documentation for fooField1\par \LeanVerb|\versoConst{foo\-Field2}| : \LeanVerb|\versoConst{String}|\par Documentation for fooField2 \end{docstringBox} diff --git a/src/tests/integration/sample-doc/expected/tex/main.tex b/src/tests/integration/sample-doc/expected/tex/main.tex index 7d0f48a24..cbcc6c95b 100644 --- a/src/tests/integration/sample-doc/expected/tex/main.tex +++ b/src/tests/integration/sample-doc/expected/tex/main.tex @@ -51,12 +51,11 @@ % Work around missing U+2011 (non-breaking hyphen) in Source Serif Pro \newunicodechar{‑}{-} -\definecolor{errorColor}{HTML}{B91C1C} -\definecolor{infoColor}{HTML}{1E6BB8} -\definecolor{warningColor}{HTML}{D97706} -\newcommand{\errorDecorate}[1]{\coloredwave{errorColor}{#1}} -\newcommand{\infoDecorate}[1]{\coloredwave{infoColor}{#1}} -\newcommand{\warningDecorate}[1]{\coloredwave{warningColor}{#1}} +% Decoration accents (wavy underlines) use the *indicator* colors, distinct from the message +% text colors. The theme block below redefines both. +\newcommand{\errorDecorate}[1]{\coloredwave{errorIndicatorColor}{#1}} +\newcommand{\infoDecorate}[1]{\coloredwave{infoIndicatorColor}{#1}} +\newcommand{\warningDecorate}[1]{\coloredwave{warningIndicatorColor}{#1}} \DefineVerbatimEnvironment{LeanVerbatim}{Verbatim} {commandchars=\\\{\},fontsize=\small,breaklines=true} \DefineVerbatimEnvironment{FileVerbatim}{Verbatim}{commandchars=\\\{\},fontsize=\small,breaklines=true,frame=single,framesep=2mm,numbers=left} @@ -124,6 +123,24 @@ \renewcommand{\cftsectionfont}{\normalfont\sffamily} \renewcommand{\cftchapterpagefont}{\normalfont\sffamily} \renewcommand{\cftsectionpagefont}{\normalfont\sffamily} +\providecommand{\versoKeyword}[1]{\textbf{#1}} +\providecommand{\versoConst}[1]{#1} +\providecommand{\versoVar}[1]{\textit{#1}} +\providecommand{\versoLiteral}[1]{#1} +\definecolor{versoCodeColor}{HTML}{000000} +\definecolor{versoConstColor}{HTML}{000000} +\definecolor{versoKeywordColor}{HTML}{000000} +\definecolor{versoVarColor}{HTML}{000000} +\definecolor{errorColor}{HTML}{CC0000} +\definecolor{warningColor}{HTML}{000000} +\definecolor{infoColor}{HTML}{000000} +\definecolor{errorIndicatorColor}{HTML}{FF0000} +\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{infoIndicatorColor}{HTML}{4777FF} +\renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} +\renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} +\renewcommand{\versoVar}[1]{\textcolor{versoVarColor}{\fontseries{m}\fontshape{it}\selectfont #1}} +\renewcommand{\versoLiteral}[1]{\textcolor{versoCodeColor}{#1}} \setmonofont{DejaVu Sans Mono} \title{\sffamily Title of the Doc} @@ -143,7 +160,7 @@ \cleardoublepage \begin{docstringBox}{def} -\LeanVerb|Verso.\allowbreak{}Integration.\allowbreak{}Sample\-Doc.\allowbreak{}sample_constant : Type|\tcblower This is a docstring.Here's some more text with a \LeanVerb|code inline| in it. +\LeanVerb|\versoConst{Verso.\allowbreak{}Integration.\allowbreak{}Sample\-Doc.\allowbreak{}sample_constant} \versoLiteral{:} \versoLiteral{Type}|\tcblower This is a docstring.Here's some more text with a \LeanVerb|code inline| in it. Here's when a \LeanVerb|code inline| occurs right before a line break.And then here's a paragraph break. \end{docstringBox} diff --git a/src/verso-manual/VersoManual.lean b/src/verso-manual/VersoManual.lean index f6c55cfda..acd9272f8 100644 --- a/src/verso-manual/VersoManual.lean +++ b/src/verso-manual/VersoManual.lean @@ -379,8 +379,8 @@ where isUnnumbered (p : Part Manual) : Bool := p.metadata.map (·.number) |>.isEqSome false open IO.FS in -def emitTeX (config : Config) (text : Part Manual) : EmitM Unit := do - let (text, state) ← traverse text config +def emitTeX (config : RenderConfig) (text : Part Manual) : EmitM Unit := do + let (text, state) ← traverse text config.toConfig let opts : TeX.Options Manual := { headerLevels := #["chapter", "section", "subsection", "subsubsection", "paragraph"], headerLevel := some ⟨0, by grind⟩ @@ -402,7 +402,7 @@ def emitTeX (config : Config) (text : Part Manual) : EmitM Unit := do withFile (dir.join "main.tex") .write fun h => do if config.verbose then IO.println s!"Saving {dir.join "main.tex"}" - h.putStrLn (preamble text.titleString authors date packages.toList preambleItems.toList) + h.putStrLn (preamble text.titleString authors date packages.toList preambleItems.toList config.codeTheme) -- \frontmatter is inserted by our hardcoded preamble before the ToC, so it doesn't get inserted -- here. If there's any text at the start of the front matter, then we need to clear it to a new -- recto page after the ToC @@ -1049,7 +1049,7 @@ where if cfg.emitTeX then if cfg.verbose then IO.println s!"Saving TeX" - emitTeX cfg.toConfig text + emitTeX cfg text emitHtml cfg.emitHtmlSingle .single cfg text traverseHtmlSingle emitHtmlSingle emitHtml cfg.emitHtmlMulti .multi cfg text traverseHtmlMulti emitHtmlMulti diff --git a/src/verso-manual/VersoManual/TeX.lean b/src/verso-manual/VersoManual/TeX.lean index 96ff529b9..7619d35c8 100644 --- a/src/verso-manual/VersoManual/TeX.lean +++ b/src/verso-manual/VersoManual/TeX.lean @@ -4,9 +4,15 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module + +public import Verso.Code.HighlightedToTex +public import Verso.Theme.Code + namespace Verso.Genre.Manual.TeX -public def preamble (title : String) (authors : List String) (date : String) (packages : List String) (extraPreamble : List String) : String := +public def preamble (title : String) (authors : List String) (date : String) + (packages : List String) (extraPreamble : List String) + (codeTheme : Verso.Theme.CodeTheme) : String := r##" \documentclass{memoir} @@ -60,12 +66,11 @@ r##" % Work around missing U+2011 (non-breaking hyphen) in Source Serif Pro \newunicodechar{‑}{-} -\definecolor{errorColor}{HTML}{B91C1C} -\definecolor{infoColor}{HTML}{1E6BB8} -\definecolor{warningColor}{HTML}{D97706} -\newcommand{\errorDecorate}[1]{\coloredwave{errorColor}{#1}} -\newcommand{\infoDecorate}[1]{\coloredwave{infoColor}{#1}} -\newcommand{\warningDecorate}[1]{\coloredwave{warningColor}{#1}} +% Decoration accents (wavy underlines) use the *indicator* colors, distinct from the message +% text colors. The theme block below redefines both. +\newcommand{\errorDecorate}[1]{\coloredwave{errorIndicatorColor}{#1}} +\newcommand{\infoDecorate}[1]{\coloredwave{infoIndicatorColor}{#1}} +\newcommand{\warningDecorate}[1]{\coloredwave{warningIndicatorColor}{#1}} \DefineVerbatimEnvironment{LeanVerbatim}{Verbatim} {commandchars=\\\{\},fontsize=\small,breaklines=true} \DefineVerbatimEnvironment{FileVerbatim}{Verbatim}{commandchars=\\\{\},fontsize=\small,breaklines=true,frame=single,framesep=2mm,numbers=left} @@ -135,8 +140,9 @@ r##" \renewcommand{\cftsectionfont}{\normalfont\sffamily} \renewcommand{\cftchapterpagefont}{\normalfont\sffamily} \renewcommand{\cftsectionpagefont}{\normalfont\sffamily} -\setmonofont{DejaVu Sans Mono} "## ++ +SubVerso.Highlighting.texMacroFallbacks ++ +codeTheme.texPreamble ++ "\n".intercalate extraPreamble ++ r##" \title{\sffamily "## ++ title ++ r##"} diff --git a/src/verso-search/VersoSearch.lean b/src/verso-search/VersoSearch.lean index fd8f964fd..2324ac9a6 100644 --- a/src/verso-search/VersoSearch.lean +++ b/src/verso-search/VersoSearch.lean @@ -798,7 +798,7 @@ public class Indexable (genre : Verso.Doc.Genre) where /-- Computes the full-text search priority for a part, using the same centered-at-50 convention as the quick-jump side. Returning {lean}`none` leaves the document at neutral; returning a signed integer - lets a genre fold section metadata, ancestor inheritance, or other HTMl-generation-time adjustments into + lets a genre fold section metadata, ancestor inheritance, or other HTML-generation-time adjustments into full-text scoring. This is an {lean}`Int` to allow it to accumulate adjustments that put it outside the usual range. -/ diff --git a/src/verso/Verso/Code/HighlightedToTex.lean b/src/verso/Verso/Code/HighlightedToTex.lean index 0229fb43f..be4f43ad5 100644 --- a/src/verso/Verso/Code/HighlightedToTex.lean +++ b/src/verso/Verso/Code/HighlightedToTex.lean @@ -20,24 +20,39 @@ open Std (HashMap) namespace SubVerso.Highlighting /-- -Given an already escaped-for-verbatim string, and a token kind, -returns TeX to display that token appropriately syntax-highlighted. +Given an already escaped-for-verbatim string, and a token kind, returns TeX that wraps the +content in the matching semantic macro: `\versoKeyword`, `\versoConst`, `\versoVar`, or +`\versoLiteral`. The macros are defined by the consuming genre's preamble (the manual genre +uses {Lean.Doc.name}`Verso.Theme.CodeTheme` to style them). For a fallback definition that +reproduces the pre-theming look, see {Lean.Doc.name}`SubVerso.Highlighting.texMacroFallbacks`. -/ public def highlightToken : String → Token.Kind → TeX -| c, .keyword _ _ _ => .raw s!"\\textbf\{{c}}" -| c, .const .. => .raw c -| c, .anonCtor .. => .raw c -| c, .option _ _ _ => .raw c -| c, .var .. => .raw s!"\\textit\{{c}}" -| c, .str _ => .raw c -| c, .docComment => .raw c -| c, .sort _ => .raw c -| c, .levelVar _ => .raw c -| c, .levelConst _ => .raw c -| c, .moduleName _ => .raw c -| c, .levelOp _ => .raw c -| c, .withType _ => .raw c -| c, .unknown => .raw c +| c, .keyword _ _ _ => .raw s!"\\versoKeyword\{{c}}" +| c, .const .. => .raw s!"\\versoConst\{{c}}" +| c, .anonCtor .. => .raw s!"\\versoConst\{{c}}" +| c, .option _ _ _ => .raw s!"\\versoConst\{{c}}" +| c, .var .. => .raw s!"\\versoVar\{{c}}" +| c, .str _ => .raw s!"\\versoLiteral\{{c}}" +| c, .docComment => .raw s!"\\versoLiteral\{{c}}" +| c, .sort _ => .raw s!"\\versoLiteral\{{c}}" +| c, .levelVar _ => .raw s!"\\versoLiteral\{{c}}" +| c, .levelConst _ => .raw s!"\\versoLiteral\{{c}}" +| c, .moduleName _ => .raw s!"\\versoLiteral\{{c}}" +| c, .levelOp _ => .raw s!"\\versoLiteral\{{c}}" +| c, .withType _ => .raw s!"\\versoLiteral\{{c}}" +| c, .unknown => .raw s!"\\versoLiteral\{{c}}" + +/-- +Fallback definitions for the four semantic token macros emitted by +{Lean.Doc.name}`SubVerso.Highlighting.highlightToken`. Each uses `\providecommand`, so a genre +preamble that defines its own (theme-driven) versions wins. The fallbacks reproduce today's +unthemed look: keywords bold, variables italic, constants and literals plain. +-/ +public def texMacroFallbacks : String := +"\\providecommand{\\versoKeyword}[1]{\\textbf{#1}}\n" ++ +"\\providecommand{\\versoConst}[1]{#1}\n" ++ +"\\providecommand{\\versoVar}[1]{\\textit{#1}}\n" ++ +"\\providecommand{\\versoLiteral}[1]{#1}\n" defmethod Highlighting.Token.toVerbatimTeX (t : Highlighting.Token) (lineBreaks : Bool := false) : Verso.Output.TeX := highlightToken (escapeForVerbatim t.content lineBreaks) t.kind @@ -48,6 +63,12 @@ Returns TeX that is appropriate for the content of a `\Verb` environment (from p with command characters `\`, `{`, and `}`. When `lineBreaks` is true, inserts line break opportunities in identifiers. + +**Preamble contract.** Output uses the four `\verso…` semantic macros emitted by +{Lean.Doc.name}`SubVerso.Highlighting.highlightToken`. Any consumer that compiles the result must +ensure those macros are defined, either by including +{Lean.Doc.name}`SubVerso.Highlighting.texMacroFallbacks` in the preamble or by defining its own +theme-driven versions (the manual genre installs both). -/ public defmethod Highlighted.toVerbatimTeX (h : Highlighted) (lineBreaks : Bool := false) : Verso.Output.TeX := match h with diff --git a/src/verso/Verso/Font.lean b/src/verso/Verso/Font.lean index f5c5ea9af..fca4ab13a 100644 --- a/src/verso/Verso/Font.lean +++ b/src/verso/Verso/Font.lean @@ -136,6 +136,19 @@ public def Typeface.cssFamily : Typeface → String | .mono => "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" | .files family _ => cssQuote family +/-- +The fontspec family name that LuaLaTeX should pick for a typeface. The built-in +{name (full := Verso.Typeface.sans)}`sans`/{name (full := Verso.Typeface.serif)}`serif`/{name (full := Verso.Typeface.mono)}`mono` +typefaces fall back to a system family known to ship with TeXLive's LuaLaTeX bundle; a +{name (full := Verso.Typeface.files)}`files` typeface uses its declared family name directly so +the corresponding fontspec files loaded elsewhere in the preamble resolve. +-/ +public def Typeface.texFamily : Typeface → String + | .sans => "DejaVu Sans" + | .serif => "DejaVu Serif" + | .mono => "DejaVu Sans Mono" + | .files family _ => family + /-! # Defining font faces -/ /-- diff --git a/src/verso/Verso/Theme/Code.lean b/src/verso/Verso/Theme/Code.lean index 356a75675..e7eb3f9c8 100644 --- a/src/verso/Verso/Theme/Code.lean +++ b/src/verso/Verso/Theme/Code.lean @@ -358,6 +358,79 @@ public def cssVariables (theme : CodeTheme) : String := end CodeTheme +/-! # TeX preamble -/ + +namespace CodeTheme + +private def fontSeries (w : Weight) : String := + -- The NFSS series codes that are widely supported by fontspec/luaotfload. We map the requested + -- numeric weight to the nearest standard series; LuaLaTeX uses the series to pick a face. A + -- finer mapping is unnecessary because the variable/extra weights are handled by the font + -- loader, not by `\fontseries`. + let v := w.val + if v ≤ 200 then "ul" + else if v ≤ 300 then "el" + else if v ≤ 350 then "l" + else if v ≤ 450 then "m" + else if v ≤ 550 then "sb" + else if v ≤ 650 then "b" + else if v ≤ 750 then "eb" + else "ub" + +private def fontShape : FontStyle → String + | .normal => "n" + | .italic => "it" + +private def tokenMacro (name : String) (s : TokenStyle) (colorName : String) : String := + s!"\\renewcommand\{\\verso{name}}[1]\{\\textcolor\{{colorName}}\{\\fontseries\{{fontSeries s.weight}}\\fontshape\{{fontShape s.style}}\\selectfont #1}}\n" + +/-- +Returns a TeX preamble fragment that defines a theme-specific {lit}`xcolor` palette, redefines +the four {lit}`\verso…` token macros so they apply the theme's per-token color, weight, and +style, and sets the document mono font to {Lean.Doc.name (full := Verso.Typeface.texFamily)}`Verso.Typeface.texFamily` applied to +{Lean.Doc.name (full := Verso.Theme.CodeTheme.codeFace)}`codeFace`. + +Two color names are emitted per severity: {lit}`errorColor`/{lit}`warningColor`/{lit}`infoColor` +are the message-text colors, and {lit}`errorIndicatorColor`/{lit}`warningIndicatorColor`/{lit}`infoIndicatorColor` +are the accent colors used for the wavy underlines and frames. The output is intended to be +appended after Verso's manual preamble (which carries the {lit}`\providecommand` fallbacks for +the {lit}`\verso…` macros). It uses {lit}`xcolor`'s {lit}`HTML` model and NFSS series/shape +codes that fontspec understands under LuaLaTeX. +-/ +public def texPreamble (theme : CodeTheme) : String := + let codeColor := Color.tex theme.codeColor + let constColor := Color.tex theme.const.color + let keywordColor := Color.tex theme.keyword.color + let varColor := Color.tex theme.«var».color + -- Per the structure's semantics, the `*Color` fields are message-text colors and + -- `*IndicatorColor` are accent colors (underlines, frames, dingbats). Emit both as separate + -- `\definecolor` entries so the preamble can reference whichever the rule needs. + let errorTextColor := Color.tex theme.errorColor + let warningTextColor := Color.tex theme.warningColor + let infoTextColor := Color.tex theme.infoColor + let errorAccent := Color.tex theme.errorIndicatorColor + let warningAccent := Color.tex theme.warningIndicatorColor + let infoAccent := Color.tex theme.infoIndicatorColor + String.join [ + s!"\\definecolor\{versoCodeColor}\{HTML}\{{codeColor}}\n", + s!"\\definecolor\{versoConstColor}\{HTML}\{{constColor}}\n", + s!"\\definecolor\{versoKeywordColor}\{HTML}\{{keywordColor}}\n", + s!"\\definecolor\{versoVarColor}\{HTML}\{{varColor}}\n", + s!"\\definecolor\{errorColor}\{HTML}\{{errorTextColor}}\n", + s!"\\definecolor\{warningColor}\{HTML}\{{warningTextColor}}\n", + s!"\\definecolor\{infoColor}\{HTML}\{{infoTextColor}}\n", + s!"\\definecolor\{errorIndicatorColor}\{HTML}\{{errorAccent}}\n", + s!"\\definecolor\{warningIndicatorColor}\{HTML}\{{warningAccent}}\n", + s!"\\definecolor\{infoIndicatorColor}\{HTML}\{{infoAccent}}\n", + tokenMacro "Keyword" theme.keyword "versoKeywordColor", + tokenMacro "Const" theme.const "versoConstColor", + tokenMacro "Var" theme.«var» "versoVarColor", + s!"\\renewcommand\{\\versoLiteral}[1]\{\\textcolor\{versoCodeColor}\{#1}}\n", + s!"\\setmonofont\{{theme.codeFace.texFamily}}\n" + ] + +end CodeTheme + /-! # Font assets and {lit}`@font-face` writing -/ namespace CodeTheme @@ -403,18 +476,22 @@ public def slugFamily (family : String) : String := Id.run do /-- Output-relative paths and bytes of every font file the theme uses. Each file is named -{lit}`-.` where {lit}`` is {Lean.Doc.name}`Verso.Theme.CodeTheme.slugFamily` -applied to the family. The {lit}`assetRoot` is the directory the paths are relative to (no -leading or trailing slash); the generated {lit}`@font-face` {lit}`url()`s also resolve from there. +{lit}`--.`, where {lit}`` is +{Lean.Doc.name}`Verso.Theme.CodeTheme.slugFamily` applied to the family and {lit}`` is +the index of the typeface within the theme's {Lean.Doc.name}`Verso.Theme.CodeTheme.fileTypefaces` +array. The typeface index keeps paths distinct even when two families slug to the same string +(such as {lit}`"A B"` and {lit}`"A/B"` both becoming {lit}`A-B`). The {lit}`assetRoot` is the +directory the paths are relative to (no leading or trailing slash); the generated +{lit}`@font-face` {lit}`url()`s also resolve from there. -/ public def fontAssets (theme : CodeTheme) (assetRoot : String) : Array (String × ByteArray × FontFace × String) := Id.run do let mut out := #[] - for tf in theme.fileTypefaces do + for (tf, ti) in theme.fileTypefaces.zipIdx do if let .files family faces := tf then let slug := slugFamily family for (face, i) in faces.zipIdx do - let path := s!"{assetRoot}/fonts/{slug}-{i}.{face.format.ext}" + let path := s!"{assetRoot}/fonts/{slug}-{ti}-{i}.{face.format.ext}" out := out.push (path, face.bytes, face, family) return out From 3710eb14b724d94c15b99342ff035ed279913ace Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Sat, 30 May 2026 23:14:14 +0200 Subject: [PATCH 08/31] feat: use a theme for the Manual genre Instead of custom CSS, themes are now represented as Lean values that can be imported as libraries and selected by authors. --- .../test_theme_customization.py | 129 +++++++++ src/tests/Tests/ColorAccessibility.lean | 56 ++++ src/tests/ThemeTestDoc.lean | 3 +- src/tests/ThemeTestMain.lean | 23 +- src/tests/golden/theme-css/default.expected | 2 +- .../code-content-doc/expected/tex/main.tex | 2 +- .../diagram-doc/expected/tex/main.tex | 2 +- .../extra-files-doc/expected/tex/main.tex | 2 +- .../front-matter-doc/expected/tex/main.tex | 2 +- .../inheritance-doc/expected/tex/main.tex | 2 +- .../sample-doc/expected/tex/main.tex | 2 +- src/verso-manual/VersoManual.lean | 77 ++++-- src/verso-manual/VersoManual/Html.lean | 1 - src/verso-manual/VersoManual/Html/Style.lean | 31 ++- src/verso-manual/VersoManual/Theme.lean | 246 ++++++++++++++++++ .../VersoManual/Theme/Defaults.lean | 27 ++ src/verso-manual/VersoManual/Theme/Ext.lean | 29 +++ src/verso/Verso/Theme/Code.lean | 16 +- .../Verso/Theme/Color/Accessibility.lean | 6 + static-web/search/search-box.css | 6 +- static-web/search/search-highlight.css | 2 +- static-web/search/search-page.css | 4 +- 22 files changed, 611 insertions(+), 59 deletions(-) create mode 100644 src/verso-manual/VersoManual/Theme.lean create mode 100644 src/verso-manual/VersoManual/Theme/Defaults.lean create mode 100644 src/verso-manual/VersoManual/Theme/Ext.lean diff --git a/browser-tests/theme-customization/test_theme_customization.py b/browser-tests/theme-customization/test_theme_customization.py index 6fe98da99..87cca718f 100644 --- a/browser-tests/theme-customization/test_theme_customization.py +++ b/browser-tests/theme-customization/test_theme_customization.py @@ -54,6 +54,14 @@ def _hex_to_rgb(h: str) -> str: "constColor": "#001717", "keywordColor": "#001818", "varColor": "#001919", + # ManualTheme additions (chrome). + "headerBackground": "#001b1b", + "tocBackground": "#001c1c", + "linkColor": "#002020", + "tocTextColor": "#002222", + "borderColor": "#001d1d", + "mutedColor": "#001e1e", + "highlightColor": "#001f1f", } @@ -147,3 +155,124 @@ def test_error_indicator(page, server): THEME["errorIndicatorColor"], "lean-output.error indicator border", ) + + +def test_page_background(page): + _expect(_color(page, "body", "background-color"), THEME["background"], "body background") + + +def test_body_text_color(page): + # `body` now sets `color: var(--verso-text-color)`. Inherited text in prose elements + # (paragraphs in `main`) should resolve to the theme's textColor rather than the + # browser-default black. + _expect(_color(page, "main p"), THEME["textColor"], "body prose color") + + +def test_header_background(page): + _expect( + _color(page, "header", "background-color"), + THEME["headerBackground"], + "header background", + ) + + +def test_header_title_color(page): + # `.header-title` previously hardcoded `color: black`; the theme's `textColor` is now what + # the rendered title actually uses, so a theme with textColor != black is readable on + # a non-white header background. + _expect( + _color(page, ".header-title"), + THEME["textColor"], + "header title color", + ) + + +def test_toc_background_and_link_color(page): + _expect( + _color(page, "#toc", "background-color"), + THEME["tocBackground"], + "toc background", + ) + # `#toc a` previously hardcoded `color: #333`; the theme's `tocTextColor` is now used, + # so a dark ToC background plus a light tocTextColor stays readable. + _expect( + _color(page, "#toc a"), + THEME["tocTextColor"], + "toc link color", + ) + + +def test_content_link_color(page, server): + # The doc has an `` content link inside `main`. The new + # `main a { color: var(--verso-link-color) }` rule should pick up the theme's linkColor. + page.goto(server + "/Code-samples/") + _expect( + _color(page, 'main a[href^="https://example.com"]'), + THEME["linkColor"], + "content link color", + ) + + +def _root_var(page, name: str) -> str: + """Resolves a CSS custom property at the document-root level to its computed `rgb(...)`.""" + return page.evaluate( + "(name) => {" + " const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();" + " if (raw.startsWith('rgb')) return raw;" + " const probe = document.createElement('span');" + " probe.style.color = raw;" + " document.body.appendChild(probe);" + " const out = getComputedStyle(probe).color;" + " probe.remove();" + " return out;" + "}", + name, + ).strip() + + +def test_border_var(page): + # `borderColor` is the search-input border color (and other chrome borders); the search box + # is mounted by JS so the variable is the most direct verification that the theme value + # reaches the page. (The CSS rules in search-box.css / search-page.css use + # `var(--verso-border-color, gray)` so a missing variable would fall back to gray.) + _expect(_root_var(page, "--verso-border-color"), THEME["borderColor"], "borderColor var") + + +def test_prev_next_nav_color(page, server): + # The `.prev-next-buttons > *` rule previously forced `color: black`, ignoring the theme. + # It now reads `var(--verso-link-color)` so a dark page can still surface readable nav links. + page.goto(server + "/Code-samples/") + _expect( + _color(page, ".prev-next-buttons > a"), + THEME["linkColor"], + "prev/next nav link color", + ) + + +def test_search_placeholder_muted(page, server): + # The quick-search placeholder previously hardcoded `#888` and the "more results" row `#777`. + # Both now route through `--verso-muted-color`. The search box itself is mounted by JS; + # waiting for the placeholder text confirms the rule reaches a real rendered element. + page.goto(server + "/Code-samples/") + placeholder = page.locator("#search-wrapper .cb_edit:empty").first + placeholder.wait_for(state="attached", timeout=10000) + color = page.evaluate( + "() => getComputedStyle(document.querySelector('#search-wrapper .cb_edit:empty')," + " '::before').getPropertyValue('color')" + ).strip() + _expect(color, THEME["mutedColor"], "search placeholder color") + + +def test_search_match_highlight(page, server): + # Full-page search results render each matched term inside an ``, whose background + # comes from `--verso-highlight-color` via `search-page.css`. Driving an actual search + # confirms a rendered match `` consumes the theme value (root variable existence is + # not enough; the CSS rule has to actually read it). + page.goto(server + "/search/?q=hello") + em = page.locator(".search-page-list li.search-result em").first + em.wait_for(state="attached", timeout=10000) + _expect( + _color(page, ".search-page-list li.search-result em", "background-color"), + THEME["highlightColor"], + "search-result match highlight", + ) diff --git a/src/tests/Tests/ColorAccessibility.lean b/src/tests/Tests/ColorAccessibility.lean index d868b4794..bb4355042 100644 --- a/src/tests/Tests/ColorAccessibility.lean +++ b/src/tests/Tests/ColorAccessibility.lean @@ -6,6 +6,8 @@ Author: David Thrane Christiansen module public import Plausible public meta import Verso.Theme.Color +public meta import VersoManual.Theme +public meta import VersoManual.Theme.Defaults public meta import Tests.Arbitrary /-! @@ -71,6 +73,60 @@ def okabeIto : Array (String × Color) := #[ #guard_msgs in #eval (colorblindIssues distinguishableThreshold okabeIto).isEmpty +-- A pair of identical colors is not a colorblind issue: the theme is not relying on color to +-- distinguish them (it might be using weight or style instead, as the default theme does). +/-- info: true -/ +#guard_msgs in +#eval (colorblindIssues distinguishableThreshold #[("a", .black), ("b", .black)]).isEmpty + +/-! ## `ManualTheme.checkAccessibility` -/ + +-- The shipped default theme passes its own accessibility check. +/-- info: true -/ +#guard_msgs in +#eval ManualTheme.Default.checkAccessibility.isEmpty + +-- A low-contrast override (gray text on a near-white background) is flagged as a contrast +-- problem (one per evaluated text pair against the page background). +private def lowContrastTheme : ManualTheme := { + ManualTheme.Default with + textColor := color%#bbbbbb, +} + +/-- info: true -/ +#guard_msgs in +#eval (lowContrastTheme.checkAccessibility.any (·.kind == .contrast)) + +-- A token palette that collapses under deuteranopia (a red and a green of matched lightness) +-- is flagged as a CVD problem. +private def cvdTheme : ManualTheme := { + ManualTheme.Default with + const := { ManualTheme.Default.const with color := color%#e60000 }, + keyword := { ManualTheme.Default.keyword with color := color%#00a000 }, +} + +/-- info: true -/ +#guard_msgs in +#eval (cvdTheme.checkAccessibility.any (·.kind == .colorblind)) + +-- The contrast and colorblind checks are independent: the low-contrast theme has no colorblind +-- issues, and the CVD theme has no contrast issues against the page background. +/-- info: false -/ +#guard_msgs in +#eval (lowContrastTheme.checkAccessibility.any (·.kind == .colorblind)) + +-- A theme whose `highlightColor` is too close to `textColor` is flagged: search results render +-- matched terms with `highlightColor` as their background, so the body text must read on it. +private def badHighlightTheme : ManualTheme := { + ManualTheme.Default with + highlightColor := color%#333333, +} + +/-- info: true -/ +#guard_msgs in +#eval (badHighlightTheme.checkAccessibility.any (fun i => + i.kind == .contrast && (i.message.splitOn "highlight").length > 1)) + /-! ## Property tests -/ open scoped Plausible.Decorations in diff --git a/src/tests/ThemeTestDoc.lean b/src/tests/ThemeTestDoc.lean index 7cc2456f4..9fbfedf64 100644 --- a/src/tests/ThemeTestDoc.lean +++ b/src/tests/ThemeTestDoc.lean @@ -15,7 +15,8 @@ set_option pp.rawOnError true # Code samples -A line of prose, followed by code that mixes a keyword, a const, and a literal. +A line of prose with an [external content link](https://example.com/) that the browser test +checks against the theme's `linkColor`. Then code that mixes a keyword, a const, and a literal. ```lean def hello (name : String) : String := s!"hello, {name}" diff --git a/src/tests/ThemeTestMain.lean b/src/tests/ThemeTestMain.lean index 8b2834ae2..910b01b8a 100644 --- a/src/tests/ThemeTestMain.lean +++ b/src/tests/ThemeTestMain.lean @@ -52,6 +52,27 @@ def config : Config where emitHtmlMulti := .immediately htmlDepth := 1 +def testManualTheme : ManualTheme := { + ManualTheme.Default with + toCodeTheme := testTheme, + surfaceColor := color%#001a1a, + headerBackground := color%#001b1b, + tocBackground := color%#001c1c, + borderColor := color%#001d1d, + mutedColor := color%#001e1e, + highlightColor := color%#001f1f, + linkColor := color%#002020, + visitedLinkColor := color%#002121, + tocTextColor := color%#002222, + burgerVisibleColor := color%#002323, + burgerVisibleShadowColor := color%#002424, + burgerHiddenColor := color%#002525, + burgerHiddenShadowColor := color%#002626 +} + def main : List String → IO UInt32 := manualMain (%doc ThemeTestDoc) - (config := { config with codeTheme := testTheme }) + (config := { config with + manualTheme := testManualTheme, + strictThemeContrast := false, + strictThemeColorblind := false }) diff --git a/src/tests/golden/theme-css/default.expected b/src/tests/golden/theme-css/default.expected index 47a7eaea4..5a79290b1 100644 --- a/src/tests/golden/theme-css/default.expected +++ b/src/tests/golden/theme-css/default.expected @@ -8,7 +8,7 @@ --verso-info-color: #000000; --verso-info-indicator-color: #4777ff; --verso-warning-color: #000000; - --verso-warning-indicator-color: #e7a71d; + --verso-warning-indicator-color: #d97706; --verso-error-color: #cc0000; --verso-error-indicator-color: #ff0000; --verso-code-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; diff --git a/src/tests/integration/code-content-doc/expected/tex/main.tex b/src/tests/integration/code-content-doc/expected/tex/main.tex index 9b8486643..ff617593e 100644 --- a/src/tests/integration/code-content-doc/expected/tex/main.tex +++ b/src/tests/integration/code-content-doc/expected/tex/main.tex @@ -135,7 +135,7 @@ \definecolor{warningColor}{HTML}{000000} \definecolor{infoColor}{HTML}{000000} \definecolor{errorIndicatorColor}{HTML}{FF0000} -\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{warningIndicatorColor}{HTML}{D97706} \definecolor{infoIndicatorColor}{HTML}{4777FF} \renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} \renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} diff --git a/src/tests/integration/diagram-doc/expected/tex/main.tex b/src/tests/integration/diagram-doc/expected/tex/main.tex index 09cd3746b..9926868b4 100644 --- a/src/tests/integration/diagram-doc/expected/tex/main.tex +++ b/src/tests/integration/diagram-doc/expected/tex/main.tex @@ -135,7 +135,7 @@ \definecolor{warningColor}{HTML}{000000} \definecolor{infoColor}{HTML}{000000} \definecolor{errorIndicatorColor}{HTML}{FF0000} -\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{warningIndicatorColor}{HTML}{D97706} \definecolor{infoIndicatorColor}{HTML}{4777FF} \renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} \renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} diff --git a/src/tests/integration/extra-files-doc/expected/tex/main.tex b/src/tests/integration/extra-files-doc/expected/tex/main.tex index a4f708321..3a1f744b4 100644 --- a/src/tests/integration/extra-files-doc/expected/tex/main.tex +++ b/src/tests/integration/extra-files-doc/expected/tex/main.tex @@ -135,7 +135,7 @@ \definecolor{warningColor}{HTML}{000000} \definecolor{infoColor}{HTML}{000000} \definecolor{errorIndicatorColor}{HTML}{FF0000} -\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{warningIndicatorColor}{HTML}{D97706} \definecolor{infoIndicatorColor}{HTML}{4777FF} \renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} \renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} diff --git a/src/tests/integration/front-matter-doc/expected/tex/main.tex b/src/tests/integration/front-matter-doc/expected/tex/main.tex index 716878d92..813348b4f 100644 --- a/src/tests/integration/front-matter-doc/expected/tex/main.tex +++ b/src/tests/integration/front-matter-doc/expected/tex/main.tex @@ -135,7 +135,7 @@ \definecolor{warningColor}{HTML}{000000} \definecolor{infoColor}{HTML}{000000} \definecolor{errorIndicatorColor}{HTML}{FF0000} -\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{warningIndicatorColor}{HTML}{D97706} \definecolor{infoIndicatorColor}{HTML}{4777FF} \renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} \renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} diff --git a/src/tests/integration/inheritance-doc/expected/tex/main.tex b/src/tests/integration/inheritance-doc/expected/tex/main.tex index c42d0377d..256fd484c 100644 --- a/src/tests/integration/inheritance-doc/expected/tex/main.tex +++ b/src/tests/integration/inheritance-doc/expected/tex/main.tex @@ -135,7 +135,7 @@ \definecolor{warningColor}{HTML}{000000} \definecolor{infoColor}{HTML}{000000} \definecolor{errorIndicatorColor}{HTML}{FF0000} -\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{warningIndicatorColor}{HTML}{D97706} \definecolor{infoIndicatorColor}{HTML}{4777FF} \renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} \renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} diff --git a/src/tests/integration/sample-doc/expected/tex/main.tex b/src/tests/integration/sample-doc/expected/tex/main.tex index cbcc6c95b..cd3eaf602 100644 --- a/src/tests/integration/sample-doc/expected/tex/main.tex +++ b/src/tests/integration/sample-doc/expected/tex/main.tex @@ -135,7 +135,7 @@ \definecolor{warningColor}{HTML}{000000} \definecolor{infoColor}{HTML}{000000} \definecolor{errorIndicatorColor}{HTML}{FF0000} -\definecolor{warningIndicatorColor}{HTML}{E7A71D} +\definecolor{warningIndicatorColor}{HTML}{D97706} \definecolor{infoIndicatorColor}{HTML}{4777FF} \renewcommand{\versoKeyword}[1]{\textcolor{versoKeywordColor}{\fontseries{eb}\fontshape{n}\selectfont #1}} \renewcommand{\versoConst}[1]{\textcolor{versoConstColor}{\fontseries{m}\fontshape{n}\selectfont #1}} diff --git a/src/verso-manual/VersoManual.lean b/src/verso-manual/VersoManual.lean index acd9272f8..6d1feb477 100644 --- a/src/verso-manual/VersoManual.lean +++ b/src/verso-manual/VersoManual.lean @@ -13,6 +13,8 @@ import Verso.Output.Html import Verso.Output.Html.CssVars import Verso.Theme.Code import Verso.Theme.Code.Defaults +import VersoManual.Theme +import VersoManual.Theme.Defaults import Verso.Output.Html.KaTeX import Verso.Output.Html.ElasticLunr import Verso.Doc.Lsp @@ -237,6 +239,20 @@ structure Config extends HtmlConfig, TeXConfig, OutputConfig where both sides. -/ searchPriorities : SearchPriorities := {} + + /-- + When true (the default), contrast problems reported by + {Lean.Doc.name}`Verso.Theme.ManualTheme.checkAccessibility` fail the build. When false they are + logged as warnings and the build proceeds. + -/ + strictThemeContrast : Bool := true + + /-- + When true (the default), color-vision-deficiency problems reported by + {Lean.Doc.name}`Verso.Theme.ManualTheme.checkAccessibility` fail the build. When false they are + logged as warnings and the build proceeds. + -/ + strictThemeColorblind : Bool := true deriving ToJson, FromJson structure RenderConfig extends Config where @@ -245,10 +261,12 @@ structure RenderConfig extends Config where -/ linkTargets : TraverseState → Multi.AllRemotes → LinkTargets Manual.TraverseContext := (·.localTargets ++ ·.remoteTargets) /-- - The active {Lean.Doc.name}`Verso.Theme.CodeTheme`. Its CSS-variable block is written to - {lit}`verso-themes.css` so the page-level highlighting rules read the chosen colors. + The active {Lean.Doc.name}`Verso.Theme.ManualTheme`. Its CSS-variable block (the + inherited {Lean.Doc.name}`Verso.Theme.CodeTheme` variables plus the manual-chrome + additions) is written to {lit}`verso-themes.css` so the page-level highlighting rules and + chrome read the chosen values. -/ - codeTheme : Verso.Theme.CodeTheme := Verso.Theme.CodeTheme.Default + manualTheme : Verso.Theme.ManualTheme := Verso.Theme.ManualTheme.Default namespace Config @@ -402,7 +420,7 @@ def emitTeX (config : RenderConfig) (text : Part Manual) : EmitM Unit := do withFile (dir.join "main.tex") .write fun h => do if config.verbose then IO.println s!"Saving {dir.join "main.tex"}" - h.putStrLn (preamble text.titleString authors date packages.toList preambleItems.toList config.codeTheme) + h.putStrLn (preamble text.titleString authors date packages.toList preambleItems.toList config.manualTheme.toCodeTheme) -- \frontmatter is inserted by our hardcoded preamble before the ToC, so it doesn't get inserted -- here. If there's any text at the start of the front matter, then we need to clear it to a new -- recto page after the ToC @@ -765,26 +783,24 @@ where emitFindHtml toc dir state xrefJson config.toConfig if .search ∈ config.features then emitSearchResultsHtml toc dir titleToShow state config.toConfig - IO.FS.withFile (dir.join "verso-vars.css") .write fun h => do - h.putStrLn Html.«verso-vars.css» IO.FS.withFile (dir.join "book.css") .write fun h => do h.putStrLn Html.Css.pageStyle IO.FS.withFile (dir.join "verso-themes.css") .write fun h => do - let assetRoot := s!"-verso-data/themes/{config.codeTheme.name}" - let faceRules := config.codeTheme.fontFaceRules assetRoot + let assetRoot := s!"-verso-data/themes/{config.manualTheme.name}" + let faceRules := config.manualTheme.fontFaceRules assetRoot unless faceRules.isEmpty do h.putStrLn faceRules - h.putStrLn s!":root \{\n{config.codeTheme.cssVariables}}" - let extra := config.codeTheme.extraCss assetRoot + h.putStrLn s!":root \{\n{config.manualTheme.cssVariables}}" + let extra := config.manualTheme.extraCss assetRoot unless extra.isEmpty do h.putStrLn "" h.putStrLn extra - for (path, bytes, _, _) in config.codeTheme.fontAssets s!"-verso-data/themes/{config.codeTheme.name}" do + for (path, bytes, _, _) in config.manualTheme.fontAssets s!"-verso-data/themes/{config.manualTheme.name}" do let abs := dir.join path if let some p := abs.parent then ensureDir p IO.FS.writeBinFile abs bytes - for a in config.codeTheme.assets do - let path := dir.join "-verso-data" |>.join "themes" |>.join config.codeTheme.name |>.join a.path + for a in config.manualTheme.assets do + let path := dir.join "-verso-data" |>.join "themes" |>.join config.manualTheme.name |>.join a.path if let some p := path.parent then ensureDir p IO.FS.writeBinFile path a.contents for (src, dest) in config.extraFiles do @@ -851,26 +867,24 @@ where if let some alt := text.metadata.bind (·.shortTitle) then alt else titleHtml - IO.FS.withFile (root / "verso-vars.css") .write fun h => do - h.putStrLn Html.«verso-vars.css» IO.FS.withFile (root / "book.css") .write fun h => do h.putStrLn Html.Css.pageStyle IO.FS.withFile (root / "verso-themes.css") .write fun h => do - let assetRoot := s!"-verso-data/themes/{config.codeTheme.name}" - let faceRules := config.codeTheme.fontFaceRules assetRoot + let assetRoot := s!"-verso-data/themes/{config.manualTheme.name}" + let faceRules := config.manualTheme.fontFaceRules assetRoot unless faceRules.isEmpty do h.putStrLn faceRules - h.putStrLn s!":root \{\n{config.codeTheme.cssVariables}}" - let extra := config.codeTheme.extraCss assetRoot + h.putStrLn s!":root \{\n{config.manualTheme.cssVariables}}" + let extra := config.manualTheme.extraCss assetRoot unless extra.isEmpty do h.putStrLn "" h.putStrLn extra - for (path, bytes, _, _) in config.codeTheme.fontAssets s!"-verso-data/themes/{config.codeTheme.name}" do + for (path, bytes, _, _) in config.manualTheme.fontAssets s!"-verso-data/themes/{config.manualTheme.name}" do let abs := root.join path if let some p := abs.parent then ensureDir p IO.FS.writeBinFile abs bytes - for a in config.codeTheme.assets do - let path := root.join "-verso-data" |>.join "themes" |>.join config.codeTheme.name |>.join a.path + for a in config.manualTheme.assets do + let path := root.join "-verso-data" |>.join "themes" |>.join config.manualTheme.name |>.join a.path if let some p := path.parent then ensureDir p IO.FS.writeBinFile path a.contents for (src, dest) in config.extraFiles do @@ -988,10 +1002,12 @@ open Verso.CLI def manualMain (text : Part Manual) (extensionImpls : ExtensionImpls := by exact extension_impls%) (codeThemes : Verso.Theme.CodeThemeTable := by exact code_themes%) + (manualThemes : Verso.Theme.ManualThemeTable := by exact manual_themes%) (options : List String) (config : RenderConfig := {}) (extraSteps : List ExtraStep := []) : IO UInt32 := let _ := codeThemes + let _ := manualThemes ReaderT.run go extensionImpls where @@ -1043,9 +1059,26 @@ where fixBase (base : String) : String := if base.endsWith "/" then base else base ++ "/" + /-- + Runs the theme's accessibility check and routes each {name (full := Verso.Color.Issue)}`Issue` + through {name}`MonadBuildLog`: contrast issues use {name (full := Verso.Genre.Manual.RenderConfig.strictThemeContrast)}`strictThemeContrast` + and colorblindness issues use {name (full := Verso.Genre.Manual.RenderConfig.strictThemeColorblind)}`strictThemeColorblind` + to decide error vs. warning. The build proceeds either way; logged errors set the exit code. + -/ + runThemeAccessibilityCheck (cfg : RenderConfig) : ReaderT ExtensionImpls (BuildLogT IO) Unit := do + for issue in cfg.manualTheme.checkAccessibility do + let strict := match issue.kind with + | .contrast => cfg.strictThemeContrast + | .colorblind => cfg.strictThemeColorblind + let colors := issue.offending.toList.map Verso.Theme.Color.css |> ", ".intercalate + let suffix := if colors.isEmpty then "" else s!" ({colors})" + let msg := s!"theme '{cfg.manualTheme.name}': {issue.message}{suffix}" + if strict then Verso.reportError msg else Verso.reportWarning msg + go (extensionImpls : ExtensionImpls) : IO UInt32 := do let cfg ← opts config options runWithLogger <| flip ReaderT.run extensionImpls do + runThemeAccessibilityCheck cfg if cfg.emitTeX then if cfg.verbose then IO.println s!"Saving TeX" diff --git a/src/verso-manual/VersoManual/Html.lean b/src/verso-manual/VersoManual/Html.lean index 18550c0a2..ff5c37cb3 100644 --- a/src/verso-manual/VersoManual/Html.lean +++ b/src/verso-manual/VersoManual/Html.lean @@ -436,7 +436,6 @@ public def page {{textTitle}} - {{ searchAssetTags }} diff --git a/src/verso-manual/VersoManual/Html/Style.lean b/src/verso-manual/VersoManual/Html/Style.lean index 9338eac02..172f8434f 100644 --- a/src/verso-manual/VersoManual/Html/Style.lean +++ b/src/verso-manual/VersoManual/Html/Style.lean @@ -24,9 +24,6 @@ public def pageStyle : String := r####" --verso-logo-height: var(--verso-header-height); /** Table of Contents appearance **/ - --verso-toc-background-color: #fafafa; - --verso-toc-text-color: var(--verso-text-color); - /* How long should the ToC animation take? */ --verso-toc-transition-time: 0.4s; @@ -38,10 +35,6 @@ public def pageStyle : String := r####" --verso-burger-width: 1.25rem; --verso-burger-line-width: 0.2rem; --verso-burger-line-radius: 0.2rem; - --verso-burger-toc-visible-color: var(--verso-toc-text-color); - --verso-burger-toc-visible-shadow-color: #ffffff; - --verso-burger-toc-hidden-color: #0e2431; - --verso-burger-toc-hidden-shadow-color: #ffffff; /* The "burger menu" may need to get bigger for mobile screens */ --verso-mobile-burger-height: 1.5rem; @@ -85,6 +78,8 @@ html { body { margin: 0; padding: 0; + background: var(--verso-background-color); + color: var(--verso-text-color); } /******** Theme ********/ @@ -124,7 +119,7 @@ header { z-index: 99; left: 0; right: 0; - background: white; + background: var(--verso-header-background); display: flex; align-items: center; height: var(--verso-header-height); @@ -158,7 +153,7 @@ header { .header-title { text-decoration: none; - color: black; + color: var(--verso-text-color); font-size: 2rem; font-weight: bold; display: block; @@ -275,13 +270,13 @@ main [id] { } #toc a { - color: #333; + color: var(--verso-toc-text-color); text-decoration: none; } #toc a:hover { text-decoration: underline; - color: #000; + color: var(--verso-toc-text-color); } .toc-title { @@ -345,7 +340,7 @@ main [id] { width: calc(var(--verso-toc-triangle-width) + var(--verso-toc-triangle-left-space)); height: var(--verso-toc-triangle-height); display: inline-block; - background-color: black; + background-color: var(--verso-toc-text-color); content: ' '; transition: ease 0.2s; margin-right: var(--verso-toc-triangle-margin); @@ -443,7 +438,7 @@ main [id] { flex: 1; justify-content: center; align-items: center; - color: black; + color: var(--verso-link-color); text-decoration: none; } @@ -738,6 +733,16 @@ main .section-toc a:hover { text-decoration: underline; } +/* Content links pick up the theme's link colors. Scoped to `main` so chrome links (header, + ToC, search, permalinks) keep their own scoped rules. */ +main a { + color: var(--verso-link-color); +} + +main a:visited { + color: var(--verso-visited-link-color); +} + /******** Manual-specific changes to highlighted Lean code ********/ /* Don't scroll horizontally due to long identifiers (e.g. option names) */ diff --git a/src/verso-manual/VersoManual/Theme.lean b/src/verso-manual/VersoManual/Theme.lean new file mode 100644 index 000000000..4d4c0d992 --- /dev/null +++ b/src/verso-manual/VersoManual/Theme.lean @@ -0,0 +1,246 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public meta import VersoManual.Theme.Ext +public import Verso.Theme.Code +public meta import Lean.Elab.Term + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +The manual genre's theme. {lit}`ManualTheme` extends {lit}`CodeTheme` with the additional color +and font fields the manual chrome (page, header, ToC, search box, burger menu, content links) +needs. The manual genre emits every {lit}`--verso-*` chrome variable from a single chosen manual +theme. +-/ + +namespace Verso.Theme + +/-- +A manual-genre theme: a {Lean.Doc.name}`Verso.Theme.CodeTheme` plus the color and font fields the +chrome needs (header background, ToC, burger menu, search box, content links). Defaults reproduce +today's chrome. + +The cascade rule from {Lean.Doc.name}`Verso.Theme.CodeTheme` continues: a field that today reads +the page background or text color defaults from the inherited field, so overriding the inherited +field carries through. +-/ +public structure ManualTheme extends CodeTheme where + /-- The font used for body prose. -/ + textFace : Typeface := .sans + /-- The font used for headings, the ToC, navigation, and other structural text. -/ + structureFace : Typeface := .sans + + /-- A raised chrome surface tint (default for the ToC background and the search box). -/ + surfaceColor : Color := color%#f8f9fa + /-- The header bar background. Defaults to the page background. -/ + headerBackground : Color := background + /-- The ToC background. -/ + tocBackground : Color := color%#fafafa + + /-- The default border color for chrome elements such as the search box outline. Defaults to + a slate gray that clears WCAG 1.4.11 (3:1) against both white and the surface color. -/ + borderColor : Color := color%#878787 + /-- The default muted text color for chrome elements such as search hints. -/ + mutedColor : Color := color%#777777 + /-- The color used to highlight matched search terms. Defaults to the code theme's selection. -/ + highlightColor : Color := selectedColor + + /-- The color of content links in body prose. -/ + linkColor : Color := color%#0066cc + /-- The color of visited content links. -/ + visitedLinkColor : Color := linkColor + + /-- The text color used inside the ToC. -/ + tocTextColor : Color := textColor + + /-- The color of the burger-menu lines while the ToC is visible. -/ + burgerVisibleColor : Color := tocTextColor + /-- The shadow color drawn under the burger-menu lines while the ToC is visible. -/ + burgerVisibleShadowColor : Color := Color.white + /-- The color of the burger-menu lines while the ToC is hidden. -/ + burgerHiddenColor : Color := color%#0e2431 + /-- The shadow color drawn under the burger-menu lines while the ToC is hidden. -/ + burgerHiddenShadowColor : Color := Color.white + +/-! # Attribute and materialization -/ + +public section + +/-- +Attribute that registers a {Lean.Doc.name}`ManualTheme` declaration as an available theme. The +declaration must be in the current module (not imported), and its registration name is the decl's +name with macro scopes erased. +-/ +syntax (name := manual_theme) "manual_theme" : attr + +open Lean in +meta initialize + registerBuiltinAttribute { + name := `manual_theme, + ref := by exact decl_name%, + add := fun decl _stx kind => do + unless kind == AttributeKind.global do + throwError "invalid attribute 'manual_theme', must be global" + unless ((← getEnv).getModuleIdxFor? decl).isNone do + throwError "invalid attribute 'manual_theme', declaration is in an imported module" + modifyEnv fun env => manualThemeExt.addEntry env decl.eraseMacroScopes + descr := "Registers a definition as an available manual theme" + } + +end section + +/-- +A materialized table of registered {Lean.Doc.name}`ManualTheme` values, keyed by registration +name. Built at runtime by the {lit}`manual_themes%` term elaborator from the set of +{Lean.Doc.name}`ManualTheme` declarations tagged with the {lit}`@[manual_theme]` attribute. +-/ +public structure ManualThemeTable where + /-- The map from a theme's registration name to its value. -/ + themes : Lean.NameMap ManualTheme := {} + +namespace ManualThemeTable + +/-- The empty table. -/ +public def empty : ManualThemeTable := {} + +public instance : EmptyCollection ManualThemeTable := ⟨empty⟩ + +/-- Looks up a theme by its registration name. -/ +public def find? (t : ManualThemeTable) (n : Lean.Name) : Option ManualTheme := + t.themes.find? n + +/-- Inserts a theme under the given registration name. -/ +public def insert (t : ManualThemeTable) (n : Lean.Name) (theme : ManualTheme) : ManualThemeTable := + ⟨t.themes.insert n theme⟩ + +/-- Builds a table from a list of pairs. -/ +public def fromList (xs : List (Lean.Name × ManualTheme)) : ManualThemeTable := + xs.foldl (fun (acc : ManualThemeTable) (p : Lean.Name × ManualTheme) => acc.insert p.1 p.2) empty + +end ManualThemeTable + +public section + +/-- Term elaborator that materializes the registered manual-theme table at compile time. -/ +syntax (name := manual_themes) "manual_themes%" : term + +open Lean Elab Term in +private meta def manualThemePair [Monad m] [MonadRef m] [MonadQuotation m] (n : Name) : m Term := do + let quoted : Term := quote n + let ident ← mkCIdentFromRef n + `(($quoted, $(⟨ident⟩))) + +open Lean Elab Term in +/-- Elaborator for the {lit}`manual_themes%` macro: emits a +{Lean.Doc.name}`Verso.Theme.ManualThemeTable` literal whose entries are every registered +{Lean.Doc.name}`Verso.Theme.ManualTheme` decl. -/ +@[term_elab manual_themes] +meta def elabManualThemes : TermElab := fun _stx expected? => do + let env ← getEnv + let mut names : Array Name := #[] + for n in manualThemeExt.getState env do + names := names.push n + for imported in manualThemeExt.toEnvExtension.getState env |>.importedEntries do + for n in imported do + names := names.push n + let stx ← `(Verso.Theme.ManualThemeTable.fromList [$[($(← names.mapM manualThemePair) : Lean.Name × Verso.Theme.ManualTheme)],*]) + elabTerm stx expected? + +end section + +/-! # CSS variables -/ + +namespace ManualTheme + +private def cssDecl (name value : String) : String := + s!" --{name}: {value};\n" + +private def colorDecl (name : String) (c : Color) : String := + cssDecl name (Color.css c) + +/-- +The CSS-variable body for the manual-chrome fields a {Lean.Doc.name}`ManualTheme` adds on top of +its inherited {Lean.Doc.name}`Verso.Theme.CodeTheme`. The manual genre concatenates this with +{Lean.Doc.name}`Verso.Theme.CodeTheme.cssVariables` to produce the contents of its +{lit}`:root` block. +-/ +public def manualCssVariables (theme : ManualTheme) : String := + String.join [ + cssDecl "verso-text-font-family" theme.textFace.cssFamily, + cssDecl "verso-structure-font-family" theme.structureFace.cssFamily, + colorDecl "verso-surface-color" theme.surfaceColor, + colorDecl "verso-header-background" theme.headerBackground, + colorDecl "verso-toc-background-color" theme.tocBackground, + colorDecl "verso-border-color" theme.borderColor, + colorDecl "verso-muted-color" theme.mutedColor, + colorDecl "verso-highlight-color" theme.highlightColor, + colorDecl "verso-link-color" theme.linkColor, + colorDecl "verso-visited-link-color" theme.visitedLinkColor, + colorDecl "verso-toc-text-color" theme.tocTextColor, + colorDecl "verso-burger-toc-visible-color" theme.burgerVisibleColor, + colorDecl "verso-burger-toc-visible-shadow-color" theme.burgerVisibleShadowColor, + colorDecl "verso-burger-toc-hidden-color" theme.burgerHiddenColor, + colorDecl "verso-burger-toc-hidden-shadow-color" theme.burgerHiddenShadowColor + ] + +/-- +The full CSS-variable body for a {Lean.Doc.name}`ManualTheme`: the inherited +{Lean.Doc.name}`Verso.Theme.CodeTheme` variables followed by the manual-chrome additions. This is +what the manual genre writes into the body of {lit}`verso-themes.css`'s {lit}`:root` block. +-/ +public def cssVariables (theme : ManualTheme) : String := + theme.toCodeTheme.cssVariables ++ theme.manualCssVariables + +end ManualTheme + +/-! # Accessibility -/ + +namespace ManualTheme + +/-- +Runs the inherited {Lean.Doc.name}`Verso.Theme.CodeTheme.checkAccessibility` and then verifies +the manual-chrome pairs: + +- content {Lean.Doc.name (full := Verso.Theme.ManualTheme.linkColor)}`linkColor` and + {Lean.Doc.name (full := Verso.Theme.ManualTheme.visitedLinkColor)}`visitedLinkColor` against + the page background +- {Lean.Doc.name (full := Verso.Theme.ManualTheme.tocTextColor)}`tocTextColor` against the + {Lean.Doc.name (full := Verso.Theme.ManualTheme.tocBackground)}`tocBackground` +- the header title color (body text) against + {Lean.Doc.name (full := Verso.Theme.ManualTheme.headerBackground)}`headerBackground` +- the search-box muted and border colors against the surface and page backgrounds +- body text against the + {Lean.Doc.name (full := Verso.Theme.ManualTheme.highlightColor)}`highlightColor`, since search + results render matched terms with that color as their background +-/ +public def checkAccessibility (theme : ManualTheme) : Array Color.Issue := Id.run do + let mut issues := theme.toCodeTheme.checkAccessibility + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "link color on page background" theme.linkColor theme.background + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "visited link color on page background" theme.visitedLinkColor theme.background + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "toc text on toc background" theme.tocTextColor theme.tocBackground + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "header title on header background" theme.textColor theme.headerBackground + issues := issues ++ Color.contrastIssues Color.textContrastThreshold + "body text on highlight background" theme.textColor theme.highlightColor + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "border color on surface" theme.borderColor theme.surfaceColor + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "border color on page background" theme.borderColor theme.background + -- "Muted text" is for incidental hints (search placeholder, secondary metadata) and so + -- uses the AA Large / UI 3.0 threshold rather than 4.5 for primary body text. + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "muted text on surface" theme.mutedColor theme.surfaceColor + issues := issues ++ Color.contrastIssues Color.largeContrastThreshold + "muted text on page background" theme.mutedColor theme.background + return issues + +end ManualTheme diff --git a/src/verso-manual/VersoManual/Theme/Defaults.lean b/src/verso-manual/VersoManual/Theme/Defaults.lean new file mode 100644 index 000000000..58a71b686 --- /dev/null +++ b/src/verso-manual/VersoManual/Theme/Defaults.lean @@ -0,0 +1,27 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import VersoManual.Theme +public import Verso.Theme.Code.Defaults + +set_option linter.missingDocs true +set_option doc.verso true + +/-! +Built-in {Lean.Doc.name}`Verso.Theme.ManualTheme` values. The default theme reproduces today's +chrome so existing manuals render unchanged when no override is configured. +-/ + +namespace Verso.Theme + +/-- +The default manual theme: typography and chrome that reproduce today's hardcoded look. Other +built-in themes live alongside it in the {Lean.Doc.name}`Verso.Theme.ManualTheme` namespace. +-/ +@[manual_theme] +public def ManualTheme.Default : ManualTheme where + toCodeTheme := CodeTheme.Default diff --git a/src/verso-manual/VersoManual/Theme/Ext.lean b/src/verso-manual/VersoManual/Theme/Ext.lean new file mode 100644 index 000000000..03d750aa6 --- /dev/null +++ b/src/verso-manual/VersoManual/Theme/Ext.lean @@ -0,0 +1,29 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module +public import Lean.Environment + +public section + +open Lean + +namespace Verso.Theme + +/-- +Environment extension that records every declaration tagged with the `@[manual_theme]` +attribute, keyed by the declaration name (with macro scopes erased). Kept distinct from the +code-theme registry so a consumer that enumerates code themes does not see a duplicate for every +manual theme. +-/ +initialize manualThemeExt : + PersistentEnvExtension Name Name NameSet ← + registerPersistentEnvExtension { + mkInitial := pure {}, + addImportedFn := fun _ => pure {}, + addEntryFn := fun s n => s.insert n, + exportEntriesFn := fun s => + s.toArray.qsort Name.quickLt + } diff --git a/src/verso/Verso/Theme/Code.lean b/src/verso/Verso/Theme/Code.lean index e7eb3f9c8..1887d1be6 100644 --- a/src/verso/Verso/Theme/Code.lean +++ b/src/verso/Verso/Theme/Code.lean @@ -84,8 +84,9 @@ public structure CodeTheme where infoIndicatorColor : Color := color%#4777ff /-- The message-text color for warning diagnostics. -/ warningColor : Color := textColor - /-- The accent color (left border, underline) for warning diagnostics. -/ - warningIndicatorColor : Color := color%#e7a71d + /-- The accent color (left border, underline) for warning diagnostics. Defaults to a darker + amber than the legacy {lit}`#e7a71d` so it clears WCAG 1.4.11 (3:1) against white. -/ + warningIndicatorColor : Color := color%#d97706 /-- The message-text color for error diagnostics. -/ errorColor : Color := color%#cc0000 /-- The accent color (left border, underline) for error diagnostics. -/ @@ -289,12 +290,11 @@ public def checkAccessibility (theme : CodeTheme) : Array Color.Issue := Id.run -- Code drawn on the tactic-state background (for example a hypothesis). issues := issues ++ Color.contrastIssues Color.textContrastThreshold "code on tactic-state background" theme.codeColor theme.tacticStateBackground - -- Colorblindness: tokens and indicators stay mutually distinguishable. - issues := issues ++ Color.colorblindIssues Color.distinguishableThreshold - (theme.tokenSummaries ++ #[ - ("error indicator", theme.errorIndicatorColor), - ("warning indicator", theme.warningIndicatorColor), - ("info indicator", theme.infoIndicatorColor)]) + -- Colorblindness: token colors stay mutually distinguishable. Severity indicators are + -- intentionally excluded here: error red and warning yellow/amber routinely collapse under + -- protanopia and deuteranopia, but they are visually paired with their distinct semantics + -- (text content, icons) so the color collision is not the only signal. + issues := issues ++ Color.colorblindIssues Color.distinguishableThreshold theme.tokenSummaries return issues end CodeTheme diff --git a/src/verso/Verso/Theme/Color/Accessibility.lean b/src/verso/Verso/Theme/Color/Accessibility.lean index 2c59cef5b..f072fb868 100644 --- a/src/verso/Verso/Theme/Color/Accessibility.lean +++ b/src/verso/Verso/Theme/Color/Accessibility.lean @@ -110,6 +110,11 @@ public def contrastIssues (threshold : Float) (description : String) (fg bg : Co Checks that the given named colors stay mutually distinguishable under each of the three dichromacies. Reports a {name (full := IssueKind.colorblind)}`colorblind` issue for any pair that collapses (ΔE below {name}`threshold`) under some simulation. + +Pairs that are already identical in normal vision are skipped: the theme is not relying on color +to distinguish them, so a color-vision-deficient reader is in no worse a position than a +normal-vision one. Such pairs typically encode their distinction through weight or style (e.g. a +bold keyword versus an italic variable, both black). -/ public def colorblindIssues (threshold : Float) (colors : Array (String × Color)) : Array Issue := Id.run do let mut out := #[] @@ -117,6 +122,7 @@ public def colorblindIssues (threshold : Float) (colors : Array (String × Color for j in [i + 1:colors.size] do let (n1, c1) := colors[i]! let (n2, c2) := colors[j]! + if c1 == c2 then continue for cvd in [CVD.protanopia, CVD.deuteranopia, CVD.tritanopia] do unless distinguishable? threshold (dichromacy cvd c1) (dichromacy cvd c2) do out := out.push diff --git a/static-web/search/search-box.css b/static-web/search/search-box.css index b97787e0d..40f37fab4 100644 --- a/static-web/search/search-box.css +++ b/static-web/search/search-box.css @@ -37,7 +37,7 @@ margin: 0; vertical-align: bottom; border: none; - border-bottom: 1px solid gray; + border-bottom: 1px solid var(--verso-border-color, gray); position: relative; cursor: pointer; width: var(--search-bar-width); @@ -60,7 +60,7 @@ #search-wrapper .cb_edit:empty:before { content: attr(placeholder); pointer-events: none; - color: #888; + color: var(--verso-muted-color, #888); font-family: sans-serif; display: block; } @@ -228,7 +228,7 @@ #search-wrapper .more-results { text-align: center; - color: #777; + color: var(--verso-muted-color, #777); font-size: 0.7rem; } diff --git a/static-web/search/search-highlight.css b/static-web/search/search-highlight.css index cbe2378dd..3a0929f3a 100644 --- a/static-web/search/search-highlight.css +++ b/static-web/search/search-highlight.css @@ -1,5 +1,5 @@ .text-search-results { - background-color: var(--verso-selected-color); + background-color: var(--verso-highlight-color); } .text-search-results.focused { diff --git a/static-web/search/search-page.css b/static-web/search/search-page.css index 711c1fbe8..49df40d25 100644 --- a/static-web/search/search-page.css +++ b/static-web/search/search-page.css @@ -17,7 +17,7 @@ font-family: inherit; color: var(--verso-text-color, black); background-color: var(--verso-background-color, white); - border: 1px solid gray; + border: 1px solid var(--verso-border-color, gray); border-radius: 0.2rem; outline: none; } @@ -128,7 +128,7 @@ .search-page-list li.search-result em { font-style: normal; text-decoration: none; - background-color: var(--verso-selected-color, #def); + background-color: var(--verso-highlight-color, #def); border-radius: 0.15em; padding: 0 0.1em; } From b1d6dc9164f4972f5768425390adc8bbdadfcb78 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Sun, 31 May 2026 14:10:03 +0200 Subject: [PATCH 09/31] feat: theme picker UI --- .github/workflows/ci.yml | 8 + UsersGuideMain.lean | 10 +- .../test_theme_customization.py | 25 +- browser-tests/theme-picker/__init__.py | 0 .../theme-picker/test_theme_picker.py | 508 ++++++++++++++++++ src/tests/TestMain.lean | 2 +- src/tests/Tests/ColorAccessibility.lean | 12 +- src/tests/Tests/Font.lean | 2 +- src/tests/Tests/HighlightedToTeX.lean | 4 +- src/tests/ThemeTestMain.lean | 24 +- src/verso-manual/VersoManual.lean | 319 ++++++++--- src/verso-manual/VersoManual/Html.lean | 34 +- src/verso-manual/VersoManual/Html/Config.lean | 12 +- src/verso-manual/VersoManual/Html/Style.lean | 16 +- src/verso-manual/VersoManual/Theme.lean | 82 +++ .../VersoManual/Theme/Assets.lean | 35 ++ .../VersoManual/Theme/Defaults.lean | 480 ++++++++++++++++- src/verso-manual/VersoManual/Theme/Emit.lean | 153 ++++++ src/verso/Verso/Theme/Code/Defaults.lean | 9 +- static-web/theme/theme-picker.css | 176 ++++++ static-web/theme/theme-picker.js | 309 +++++++++++ 21 files changed, 2113 insertions(+), 107 deletions(-) create mode 100644 browser-tests/theme-picker/__init__.py create mode 100644 browser-tests/theme-picker/test_theme_picker.py create mode 100644 src/verso-manual/VersoManual/Theme/Assets.lean create mode 100644 src/verso-manual/VersoManual/Theme/Emit.lean create mode 100644 static-web/theme/theme-picker.css create mode 100644 static-web/theme/theme-picker.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 363616b03..a1d50c9ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,6 +199,14 @@ jobs: uv run --project browser-tests --extra test pytest \ browser-tests/theme-customization -v + - name: Run theme picker browser test + run: | + # The picker test rebuilds the user's guide so it sees every shipped + # @[manual_theme] (Default, DefaultDark, ChromaticLight/Dark, BeaconLight/Dark) + # and can exercise switching between them. + uv run --project browser-tests --extra test pytest \ + browser-tests/theme-picker -v + - name: Build the VersoHtml site for browser tests run: | # The verso-html genre renders literate JSON into a standalone HTML site. diff --git a/UsersGuideMain.lean b/UsersGuideMain.lean index c3f44a417..e4efe7150 100644 --- a/UsersGuideMain.lean +++ b/UsersGuideMain.lean @@ -7,6 +7,14 @@ open Verso.Genre.Manual def config : Config := { sourceLink := some "https://github.com/leanprover/verso", issueLink := some "https://github.com/leanprover/verso/issues" + -- The user's guide ships every theme as a live example, including ones with documented + -- accessibility trade-offs — most notably the canonical Solarized palette, whose token + -- colors are below WCAG AA's 4.5:1 contrast threshold for normal text by design. The + -- coverage and default-accessibility checks still fire (the default theme pair must be + -- accessible, and the registered set must offer an accessible choice on both + -- appearances), and per-theme warnings still surface the individual issues; the per-theme + -- warnings are silenced here to keep the build log readable. + warnPerThemeAccessibility := false } -def main := manualMain (%doc UsersGuide.Basic) +def main := manualMain (%doc UsersGuide.Basic) (config := { config with : RenderConfig }) diff --git a/browser-tests/theme-customization/test_theme_customization.py b/browser-tests/theme-customization/test_theme_customization.py index 87cca718f..f506f9f19 100644 --- a/browser-tests/theme-customization/test_theme_customization.py +++ b/browser-tests/theme-customization/test_theme_customization.py @@ -239,13 +239,26 @@ def test_border_var(page): def test_prev_next_nav_color(page, server): - # The `.prev-next-buttons > *` rule previously forced `color: black`, ignoring the theme. - # It now reads `var(--verso-link-color)` so a dark page can still surface readable nav links. + # The `.prev-next-buttons > *` rule previously hardcoded `color: black`. It now reads + # `var(--verso-text-color)` — section navigation is body-text colored, not link-colored, + # so it stays readable on any themed background without competing with content links. + # Both `:link` and `:visited` must land on text color; the visited-link rule for `main a` + # has higher specificity than `.prev-next-buttons > *` and would otherwise paint visited + # prev/next links in the visited-link color. page.goto(server + "/Code-samples/") - _expect( - _color(page, ".prev-next-buttons > a"), - THEME["linkColor"], - "prev/next nav link color", + # Force the prev page into the visited-link state by navigating to it once and back. + page.evaluate("history.replaceState({}, '', '/Diagnostics/')") + page.goto(server + "/Diagnostics/") + page.goto(server + "/Code-samples/") + colors = page.evaluate( + """() => { + const links = Array.from(document.querySelectorAll('.prev-next-buttons > a')); + return links.map(a => getComputedStyle(a).color); + }""" + ) + expected = _hex_to_rgb(THEME["textColor"]) + assert colors and all(c == expected for c in colors), ( + f"all prev/next links should be {expected}; got {colors}" ) diff --git a/browser-tests/theme-picker/__init__.py b/browser-tests/theme-picker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/browser-tests/theme-picker/test_theme_picker.py b/browser-tests/theme-picker/test_theme_picker.py new file mode 100644 index 000000000..e619f7a58 --- /dev/null +++ b/browser-tests/theme-picker/test_theme_picker.py @@ -0,0 +1,508 @@ +""" +Browser tests for the theme picker (gear button + popover + dropdowns). + +Builds the user's guide (which ships multiple themes via @[manual_theme]) and exercises: + * gear placement in header-tools, left of the search box + * popover open/close, role + aria-* attributes, Escape returns focus to the gear + * focus trap inside the popover + * theme switching via dropdown sets data-verso-theme and data-verso-appearance + * persistence across reloads via localStorage + * "match system" auto-mode follows matchMedia(prefers-color-scheme), single mode does not + * graceful degradation when localStorage throws (page still loads, default theme applied) +""" + +import socket +import subprocess +import time +from pathlib import Path + +import pytest +from playwright.sync_api import sync_playwright + + +HERE = Path(__file__).parent +REPO_ROOT = HERE.parent.parent +SITE_DIR = REPO_ROOT / "_out" / "usersguide" / "html-multi" + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture(scope="module") +def built_site(): + subprocess.check_call( + ["lake", "build", "usersguide"], + cwd=REPO_ROOT, + ) + if SITE_DIR.parent.exists(): + subprocess.check_call(["rm", "-rf", str(SITE_DIR.parent)], cwd=REPO_ROOT) + subprocess.check_call( + ["lake", "exe", "usersguide", "--output", "_out/usersguide", + "--without-tex", "--without-html-single", "--with-html-multi"], + cwd=REPO_ROOT, + ) + assert SITE_DIR.exists(), f"Manual build did not produce {SITE_DIR}" + return SITE_DIR + + +@pytest.fixture(scope="module") +def server(built_site): + port = _find_free_port() + proc = subprocess.Popen( + ["python", "-m", "http.server", str(port), "--bind", "127.0.0.1"], + cwd=built_site, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(0.5) + try: + yield f"http://127.0.0.1:{port}" + finally: + proc.terminate() + proc.wait() + + +@pytest.fixture(scope="module") +def playwright_instance(): + with sync_playwright() as p: + yield p + + +@pytest.fixture(params=["chromium", "firefox"]) +def page(request, playwright_instance, server): + """Per-test page so localStorage / focus state don't leak between tests.""" + browser = getattr(playwright_instance, request.param).launch() + context = browser.new_context() + p = context.new_page() + p.goto(server + "/") + yield p + context.close() + browser.close() + + +def _picker_button(page): + return page.locator("#theme-picker-button") + + +def _dialog(page): + return page.locator("#theme-picker-dialog") + + +def test_gear_height_and_centering_match_search(page): + """The gear glyph's ink height should be about 90% of the search input's outer height, + and the two centers should be aligned vertically. (The user asked for "90% as tall as + the search field and vertically centered with respect to the search field".)""" + page.set_viewport_size({"width": 1400, "height": 800}) + page.locator("#theme-picker-button").wait_for(state="visible", timeout=5000) + m = page.evaluate( + """() => { + const gear = document.querySelector('#theme-picker-button .theme-picker-gear'); + const input = document.querySelector('#search-wrapper .cb_edit'); + // The visible glyph rect, not the line-box, is what reads as "the gear's + // height" — `Range.getBoundingClientRect()` snaps to the rendered ink box. + const r = document.createRange(); + r.selectNodeContents(gear); + const ink = r.getBoundingClientRect(); + const ir = input.getBoundingClientRect(); + return { + ink_h: ink.height, + input_h: ir.height, + ink_mid: (ink.top + ink.bottom) / 2, + input_mid: (ir.top + ir.bottom) / 2, + }; + }""" + ) + ratio = m["ink_h"] / m["input_h"] + offset = abs(m["ink_mid"] - m["input_mid"]) + # The Unicode `⚙` glyph's visible cog is about 70-75% of its ink rect — i.e. an ink + # rect that matches the input optically reads smaller than the input. To get the gear + # to *look* like it matches the search field, the rendered ink rect needs to overshoot + # the input by ~15-25%. The acceptance band brackets that, with a little extra + # tolerance for cross-browser font metrics (Firefox renders the glyph metrics a couple + # of percent above Chromium at the same `font-size`). + assert 1.10 <= ratio <= 1.35, ( + f"gear glyph height {m['ink_h']:.2f}px is {ratio:.2%} of the search input height " + f"({m['input_h']:.2f}px); want ~115–125% so the optical cog matches the field" + ) + # Visual centering tolerance: one pixel is below the perceptual threshold for "off + # center" at standard zoom. + assert offset <= 1.0, ( + f"gear glyph center is {offset:.2f}px from the search input center; want <1px" + ) + + +def test_gear_in_header_tools_left_of_search(page): + btn = _picker_button(page) + btn.wait_for(state="attached", timeout=5000) + assert btn.is_visible() + # The gear sits inside `.header-tools` and that block is ordered before the search + # box by the `order: -1` rule in theme-picker.css. Verify the gear is geometrically + # to the left of the search box on a desktop viewport. + page.set_viewport_size({"width": 1200, "height": 800}) + gear_box = btn.bounding_box() + search = page.locator("#search-wrapper") + if search.count() > 0: + search_box = search.first.bounding_box() + if search_box is not None and gear_box is not None: + assert gear_box["x"] < search_box["x"], ( + f"gear at x={gear_box['x']}, search at x={search_box['x']}" + ) + + +def test_popover_open_close_aria(page): + btn = _picker_button(page) + btn.wait_for(state="attached", timeout=5000) + assert btn.get_attribute("aria-expanded") == "false" + btn.click() + d = _dialog(page) + d.wait_for(state="attached", timeout=5000) + assert btn.get_attribute("aria-expanded") == "true" + assert d.get_attribute("role") == "dialog" + assert d.get_attribute("aria-label") is not None + # Escape closes and returns focus to the gear. + page.keyboard.press("Escape") + page.wait_for_function( + "document.getElementById('theme-picker-button').getAttribute('aria-expanded') === 'false'" + ) + assert page.evaluate("document.activeElement.id") == "theme-picker-button" + + +def test_focus_trap(page): + btn = _picker_button(page) + btn.click() + dialog = _dialog(page) + dialog.wait_for(state="attached", timeout=5000) + # The focus trap pushes Tab from the last focusable back to the first; just check that + # repeated Tab presses keep focus within the dialog. + for _ in range(10): + page.keyboard.press("Tab") + in_dialog = page.evaluate( + "document.getElementById('theme-picker-dialog').contains(document.activeElement)" + ) + assert in_dialog, "focus escaped the dialog" + + +def test_theme_switching_persists(page, server): + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + # Force single mode (the toggle starts on auto/checked when the picker has both + # light and dark themes; unchecking it switches to single). + mode = page.locator("#theme-picker-mode") + if mode.is_checked(): + mode.uncheck() + # Pick the second option in the single dropdown. + single = page.locator("#theme-picker-single") + options = single.locator("option").all_text_contents() + if len(options) < 2: + pytest.skip("not enough themes registered to test switching") + chosen = single.locator("option").nth(1).get_attribute("value") + single.select_option(value=chosen) + # The data attribute should reflect the choice immediately. + assert page.evaluate("document.documentElement.getAttribute('data-verso-theme')") == chosen + # localStorage persists it. + assert page.evaluate("localStorage.getItem('verso-theme-single')") == chosen + # Reload: the no-flash script reads localStorage and applies the same theme before paint. + page.goto(server + "/") + page.wait_for_load_state("domcontentloaded") + assert page.evaluate("document.documentElement.getAttribute('data-verso-theme')") == chosen + + +def test_match_system_follows_media(page): + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + mode = page.locator("#theme-picker-mode") + if not mode.is_checked(): + mode.check() + # Emulate dark, then light; the inline script's matchMedia listener should swap themes. + page.emulate_media(color_scheme="dark") + page.wait_for_timeout(50) + dark_id = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + page.emulate_media(color_scheme="light") + page.wait_for_timeout(50) + light_id = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + assert dark_id != light_id, "auto mode should pick different themes for light vs dark" + + +def test_single_mode_ignores_media(page): + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + mode = page.locator("#theme-picker-mode") + if mode.is_checked(): + mode.uncheck() + # Pick a specific theme. + chosen = page.locator("#theme-picker-single option").nth(0).get_attribute("value") + page.locator("#theme-picker-single").select_option(value=chosen) + # Emulating a media change must not override the chosen theme. + page.emulate_media(color_scheme="dark") + page.wait_for_timeout(50) + assert page.evaluate("document.documentElement.getAttribute('data-verso-theme')") == chosen + page.emulate_media(color_scheme="light") + page.wait_for_timeout(50) + assert page.evaluate("document.documentElement.getAttribute('data-verso-theme')") == chosen + + +def test_auto_commit_applies_dropdown_value(page): + """In auto mode, changing the light dropdown while `prefers-color-scheme: light` is active + should immediately apply the chosen light theme. The committed `data-verso-theme` must + match the dropdown's value, not whatever was previously painted.""" + page.emulate_media(color_scheme="light") + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + mode = page.locator("#theme-picker-mode") + if not mode.is_checked(): + mode.check() + # Find a light-appearance option that differs from the currently visible theme. + light = page.locator("#theme-picker-light") + current = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + chosen = None + for opt in light.locator("option").all(): + v = opt.get_attribute("value") + if v and v != current: + chosen = v + break + assert chosen is not None, "need at least two light themes to test commit" + light.select_option(value=chosen) + # The select-change handler fires `commit()`, which must apply the dropdown's value. + after = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + assert after == chosen, ( + f"auto-mode commit should apply dropdown value {chosen!r}, got {after!r}" + ) + + +def test_switching_themes_has_no_intermediate_state(page): + """Selecting a new theme in the dropdown must take `data-verso-theme` directly from the + old value to the new value. Any intermediate state — e.g. a hover/focus preview on the + *other* mode's dropdown firing during the click, or a transient default — would cause a + visible flash to an unrelated theme.""" + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + # Single mode keeps the test deterministic: only one dropdown is visible, so the test + # exercises the user path of "open the dialog, pick a different theme, see only the new + # theme paint." + mode = page.locator("#theme-picker-mode") + if mode.is_checked(): + mode.uncheck() + # Install a MutationObserver that logs every value `data-verso-theme` takes from this + # point on, so we can assert the sequence after the switch. + page.evaluate( + """() => { + window.__versoThemeStates = []; + const obs = new MutationObserver(records => { + for (const r of records) { + if (r.attributeName === 'data-verso-theme') { + window.__versoThemeStates.push( + document.documentElement.getAttribute('data-verso-theme') + ); + } + } + }); + obs.observe(document.documentElement, { attributes: true }); + window.__versoStopObserver = () => obs.disconnect(); + }""" + ) + single = page.locator("#theme-picker-single") + initial = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + target = None + for opt in single.locator("option").all(): + v = opt.get_attribute("value") + if v and v != initial: + target = v + break + assert target is not None, "need at least two themes to test a switch" + single.select_option(value=target) + # Give the change handler a moment to run any cascaded events (focus/mouseenter previews, + # outside-click handler, etc.) so they show up in the recorded sequence. + page.wait_for_timeout(100) + page.evaluate("window.__versoStopObserver()") + states = page.evaluate("window.__versoThemeStates") + # The only value `data-verso-theme` should take during the switch is the chosen target. + # Any other id (e.g. PageTheme, defaultDark, etc.) means there was an intermediate paint. + bad = [s for s in states if s != target] + assert not bad, ( + f"intermediate theme states during single-mode switch from {initial!r} to {target!r}: {bad}" + ) + + +def test_switching_light_themes_in_auto_has_no_dark_flash(page): + """In auto mode under `prefers-color-scheme: light`, switching the *light* dropdown must + not paint a dark theme in between. The hover/focus preview handlers were attached to the + dark dropdown too, so a stray mouseenter (Playwright's `select_option` walks the cursor + over the dialog) could fire `previewTheme(darkSel.value)` mid-switch.""" + page.emulate_media(color_scheme="light") + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + mode = page.locator("#theme-picker-mode") + if not mode.is_checked(): + mode.check() + light = page.locator("#theme-picker-light") + dark = page.locator("#theme-picker-dark") + page.evaluate( + """() => { + window.__versoThemeStates = []; + const obs = new MutationObserver(records => { + for (const r of records) { + if (r.attributeName === 'data-verso-theme') { + window.__versoThemeStates.push( + document.documentElement.getAttribute('data-verso-theme') + ); + } + } + }); + obs.observe(document.documentElement, { attributes: true }); + window.__versoStopObserver = () => obs.disconnect(); + }""" + ) + initial = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + target = None + for opt in light.locator("option").all(): + v = opt.get_attribute("value") + if v and v != initial: + target = v + break + assert target is not None, "need at least two light themes to test a switch" + dark_value = dark.locator("option").first.get_attribute("value") + # Real-user flow: focus the light dropdown (as a normal user would click on it) and + # pick a new option. Hovering the *other* dropdown is also part of the picked-up bug, + # but a previous version fired the dark preview because a stray mouseenter on the dark + # dropdown landed during Playwright's option-click; both paths should land on the + # target theme and only on the target theme. + light.focus() + light.select_option(value=target) + page.wait_for_timeout(100) + page.evaluate("window.__versoStopObserver()") + states = page.evaluate("window.__versoThemeStates") + # The forbidden state is the *dark* one. Re-applying `initial` (the value the page + # already had when the test snapshotted it) is invisible to the user; only a flash to + # a different appearance, or to a third unrelated theme, is a real flicker. + assert dark_value not in states, ( + f"dark theme {dark_value!r} appeared during a light-to-light switch; full sequence: {states}" + ) + bad = [s for s in states if s not in (target, initial)] + assert not bad, ( + f"unexpected intermediate themes during auto-mode light switch: {bad} (full: {states})" + ) + + +def test_single_mode_marks_single_default(page): + """The single-mode dropdown marks `data.defaultSingle` with " (default)", not the light + default unconditionally. Authors who set `defaultSingleAppearance := .dark` should see + the dark theme labelled as the default in single mode.""" + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + mode = page.locator("#theme-picker-mode") + if mode.is_checked(): + mode.uncheck() + single_default_id = page.evaluate("window.versoThemes.defaultSingle") + # The option text for the single-default id is the only one with " (default)" appended. + marked = page.evaluate( + """(id) => { + const opts = Array.from(document.querySelectorAll('#theme-picker-single option')); + return opts + .filter(o => o.textContent.endsWith(' (default)')) + .map(o => o.value); + }""", + single_default_id, + ) + assert marked == [single_default_id], ( + f"single-mode (default) should mark only {single_default_id!r}, got {marked!r}" + ) + + +def test_themes_are_alphabetized(page): + """Every dropdown lists its themes in alphabetical order by display name.""" + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + mode = page.locator("#theme-picker-mode") + for sel_id, ensure_auto in [ + ("#theme-picker-single", False), + ("#theme-picker-light", True), + ("#theme-picker-dark", True), + ]: + if ensure_auto and not mode.is_checked(): + mode.check() + if not ensure_auto and mode.is_checked(): + mode.uncheck() + texts = page.locator(f"{sel_id} option").all_text_contents() + assert texts == sorted(texts), ( + f"{sel_id} options not alphabetised: {texts}" + ) + + +def test_match_system_toggle_hides_rows(page): + """The 'Theme' (single) row is visible only when Match system is OFF; Light and Dark are + visible only when Match system is ON. The `[hidden]` attribute on the row elements must + actually hide them — earlier CSS made the `.theme-picker-row { display: flex }` rule + win over the default `[hidden] { display: none }`, so the toggle did nothing.""" + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + mode = page.locator("#theme-picker-mode") + + # Auto on -> Light + Dark visible, Theme hidden. + if not mode.is_checked(): + mode.check() + assert page.locator("#theme-picker-light").is_visible() + assert page.locator("#theme-picker-dark").is_visible() + assert not page.locator("#theme-picker-single").is_visible() + + # Auto off -> Theme visible, Light + Dark hidden. + mode.uncheck() + assert page.locator("#theme-picker-single").is_visible() + assert not page.locator("#theme-picker-light").is_visible() + assert not page.locator("#theme-picker-dark").is_visible() + + +def test_outside_click_dismisses_popover(page): + """Clicking outside the popover closes the dialog and leaves the page on whatever theme + is currently committed. (The picker commits on `change` and no longer has a hover/focus + preview path, so dismissal is purely a "close the popover" action.)""" + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + initial = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + page.evaluate("document.body.click()") + page.wait_for_function( + "document.getElementById('theme-picker-button').getAttribute('aria-expanded') === 'false'" + ) + assert page.evaluate("document.documentElement.getAttribute('data-verso-theme')") == initial + + +def test_gear_toggle_close_dismisses_popover(page): + """Clicking the gear a second time closes the dialog without affecting the active theme.""" + btn = _picker_button(page) + btn.click() + _dialog(page).wait_for(state="attached", timeout=5000) + initial = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + btn.click() + page.wait_for_function( + "document.getElementById('theme-picker-button').getAttribute('aria-expanded') === 'false'" + ) + assert page.evaluate("document.documentElement.getAttribute('data-verso-theme')") == initial + + +def test_localStorage_disabled_still_loads(page, server): + """When localStorage throws, the page still renders and the default theme is applied.""" + # Stub localStorage *before* navigation so the no-flash script sees the throwing version. + page.add_init_script(""" + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { throw new Error('storage disabled'); } + }); + """) + page.goto(server + "/") + page.wait_for_load_state("domcontentloaded") + theme = page.evaluate("document.documentElement.getAttribute('data-verso-theme')") + appearance = page.evaluate("document.documentElement.getAttribute('data-verso-appearance')") + assert theme is not None and theme != "", "data-verso-theme should be set even without storage" + assert appearance in ("light", "dark"), f"unexpected appearance {appearance!r}" diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean index 0d4f19b75..9557c17e7 100644 --- a/src/tests/TestMain.lean +++ b/src/tests/TestMain.lean @@ -177,7 +177,7 @@ def testThemeCss (cfg : Config) : IO Unit := do let runTest (input : String) : IO String := do let name := input.trimAscii if name == "default" then - let varsBlock := s!":root \{\n{Theme.CodeTheme.Default.cssVariables}}\n" + let varsBlock := s!":root \{\n{Theme.CodeTheme.ink.cssVariables}}\n" let combined := varsBlock ++ "\n" ++ Code.highlightingStyle -- Trim the trailing blank lines `highlightingStyle` ships with so the golden file -- ends with a single newline (otherwise `git diff --check` flags the EOF blank). diff --git a/src/tests/Tests/ColorAccessibility.lean b/src/tests/Tests/ColorAccessibility.lean index bb4355042..c0a7fcd95 100644 --- a/src/tests/Tests/ColorAccessibility.lean +++ b/src/tests/Tests/ColorAccessibility.lean @@ -84,12 +84,12 @@ def okabeIto : Array (String × Color) := #[ -- The shipped default theme passes its own accessibility check. /-- info: true -/ #guard_msgs in -#eval ManualTheme.Default.checkAccessibility.isEmpty +#eval ManualTheme.ink.checkAccessibility.isEmpty -- A low-contrast override (gray text on a near-white background) is flagged as a contrast -- problem (one per evaluated text pair against the page background). private def lowContrastTheme : ManualTheme := { - ManualTheme.Default with + ManualTheme.ink with textColor := color%#bbbbbb, } @@ -100,9 +100,9 @@ private def lowContrastTheme : ManualTheme := { -- A token palette that collapses under deuteranopia (a red and a green of matched lightness) -- is flagged as a CVD problem. private def cvdTheme : ManualTheme := { - ManualTheme.Default with - const := { ManualTheme.Default.const with color := color%#e60000 }, - keyword := { ManualTheme.Default.keyword with color := color%#00a000 }, + ManualTheme.ink with + const.color := color%#e60000, + keyword.color := color%#00a000, } /-- info: true -/ @@ -118,7 +118,7 @@ private def cvdTheme : ManualTheme := { -- A theme whose `highlightColor` is too close to `textColor` is flagged: search results render -- matched terms with `highlightColor` as their background, so the body text must read on it. private def badHighlightTheme : ManualTheme := { - ManualTheme.Default with + ManualTheme.ink with highlightColor := color%#333333, } diff --git a/src/tests/Tests/Font.lean b/src/tests/Tests/Font.lean index 49f88dac3..4673c1068 100644 --- a/src/tests/Tests/Font.lean +++ b/src/tests/Tests/Font.lean @@ -82,7 +82,7 @@ define_font_face noFile where -- Two distinct families that slug to the same string get distinct asset paths via the typeface -- index, so one font's bytes can never overwrite the other. def collidingTheme : Verso.Theme.CodeTheme := { - Verso.Theme.CodeTheme.Default with + Verso.Theme.CodeTheme.ink with codeFace := .files "A B" #[katexMono], const := { color := color%#000000, weight := .regular, style := .normal, face := .files "A/B" #[katexMono] } diff --git a/src/tests/Tests/HighlightedToTeX.lean b/src/tests/Tests/HighlightedToTeX.lean index caaf2a817..fdab34cc0 100644 --- a/src/tests/Tests/HighlightedToTeX.lean +++ b/src/tests/Tests/HighlightedToTeX.lean @@ -50,7 +50,7 @@ private def hasSub (s sub : String) : Bool := (s.splitOn sub).length > 1 /-- info: true -/ #guard_msgs in #eval - let p := Verso.Theme.CodeTheme.Default.texPreamble + let p := Verso.Theme.CodeTheme.ink.texPreamble -- Message-text and accent colors are emitted under distinct names: `errorColor` is the -- message-body color (#cc0000 by default), `errorIndicatorColor` is the wavy-underline -- accent (#ff0000). The keyword macro picks up bold (NFSS `eb`), and the mono font is @@ -64,7 +64,7 @@ private def hasSub (s sub : String) : Bool := (s.splitOn sub).length > 1 open Verso Verso.Theme in private def colorfulTheme : CodeTheme := { - CodeTheme.Default with + CodeTheme.ink with keyword := { color := color%#aa3300, weight := 600, style := .normal, face := .mono }, const := { color := color%#0044bb, weight := .regular, style := .italic, face := .mono } } diff --git a/src/tests/ThemeTestMain.lean b/src/tests/ThemeTestMain.lean index 910b01b8a..de0d8ccab 100644 --- a/src/tests/ThemeTestMain.lean +++ b/src/tests/ThemeTestMain.lean @@ -52,8 +52,9 @@ def config : Config where emitHtmlMulti := .immediately htmlDepth := 1 +@[manual_theme] def testManualTheme : ManualTheme := { - ManualTheme.Default with + ManualTheme.ink with toCodeTheme := testTheme, surfaceColor := color%#001a1a, headerBackground := color%#001b1b, @@ -70,9 +71,24 @@ def testManualTheme : ManualTheme := { burgerHiddenShadowColor := color%#002626 } +/-- +Dark counterpart to `testManualTheme`. The validation pass requires a registered dark theme +for `defaultDarkTheme`, but the test only inspects the unscoped `:root` block (the +single-mode default, here `.light`), so the same sentinel palette under `.dark` is fine. +-/ +@[manual_theme] +def testManualThemeDark : ManualTheme := { + testManualTheme with + toCodeTheme := { testTheme with name := "ThemeTest Dark", appearance := .dark } +} + def main : List String → IO UInt32 := manualMain (%doc ThemeTestDoc) (config := { config with - manualTheme := testManualTheme, - strictThemeContrast := false, - strictThemeColorblind := false }) + defaultLightTheme := ``testManualTheme, + defaultDarkTheme := ``testManualThemeDark, + -- The sentinel palette deliberately violates accessibility; the test exercises rendering, + -- not the accessibility checks. + strictThemeCoverage := false, + strictDefaultThemeAccessibility := false, + warnPerThemeAccessibility := false }) diff --git a/src/verso-manual/VersoManual.lean b/src/verso-manual/VersoManual.lean index 6d1feb477..8b27a1590 100644 --- a/src/verso-manual/VersoManual.lean +++ b/src/verso-manual/VersoManual.lean @@ -15,6 +15,8 @@ import Verso.Theme.Code import Verso.Theme.Code.Defaults import VersoManual.Theme import VersoManual.Theme.Defaults +import VersoManual.Theme.Emit +import VersoManual.Theme.Assets import Verso.Output.Html.KaTeX import Verso.Output.Html.ElasticLunr import Verso.Doc.Lsp @@ -235,24 +237,42 @@ structure Config extends HtmlConfig, TeXConfig, OutputConfig where /-- Global priorities that control the relative ranking of the semantic (quick-jump) and full-text - search result streams, each on a scale from {lit}`0` to {lit}`99`. Defaults are {lit}`50` on + search result streams, each on a scale from `0` to `99`. Defaults are `50` on both sides. -/ searchPriorities : SearchPriorities := {} /-- - When true (the default), contrast problems reported by - {Lean.Doc.name}`Verso.Theme.ManualTheme.checkAccessibility` fail the build. When false they are - logged as warnings and the build proceeds. + When true (the default), it is an error if no theme is accessible. When false the same problems + become warnings. + + A theme counts as "accessible" if its `Verso.Theme.ManualTheme.checkAccessibility` returns no + issues. In other words: + * Every checked color pair meets the WCAG AA contrast threshold + * Every pair of token colors stays mutually distinguishable under each of the three dichromacies + (`protanopia`, `deuteranopia`, `tritanopia`). + + With a single registered theme, that theme must be accessible. With multiple themes, there must be + at least one accessible light theme and one accessible dark theme so a reader on either appearance + can pick a usable theme. -/ - strictThemeContrast : Bool := true + strictThemeCoverage : Bool := true /-- - When true (the default), color-vision-deficiency problems reported by - {Lean.Doc.name}`Verso.Theme.ManualTheme.checkAccessibility` fail the build. When false they are - logged as warnings and the build proceeds. + When `true` (the default), the build errors if the configured `defaultLightTheme` or + `defaultDarkTheme` has any accessibility issues. When false the same problems become build-log + warnings and the build proceeds. -/ - strictThemeColorblind : Bool := true + strictDefaultThemeAccessibility : Bool := true + + /-- + When `true` (the default), every registered theme that has accessibility issues emits a build-log + warning that names the theme and the specific issues. Setting this to `false` silences these + per-theme warnings — useful when shipping a documented trade-off (for example the canonical + Solarized palette, whose token colors are below WCAG AA's 4.5:1 contrast threshold for normal text + by design). + -/ + warnPerThemeAccessibility : Bool := true deriving ToJson, FromJson structure RenderConfig extends Config where @@ -261,12 +281,41 @@ structure RenderConfig extends Config where -/ linkTargets : TraverseState → Multi.AllRemotes → LinkTargets Manual.TraverseContext := (·.localTargets ++ ·.remoteTargets) /-- - The active {Lean.Doc.name}`Verso.Theme.ManualTheme`. Its CSS-variable block (the - inherited {Lean.Doc.name}`Verso.Theme.CodeTheme` variables plus the manual-chrome - additions) is written to {lit}`verso-themes.css` so the page-level highlighting rules and - chrome read the chosen values. + The subset of registered manual themes that should be available in the picker. When + `none` (the default), every registered theme is available. + -/ + availableThemes : Option (Array Lean.Name) := none + /-- + The default light-appearance theme. Its registration name must be a registered + `Verso.Theme.ManualTheme` whose appearance is `.light`. + -/ + defaultLightTheme : Lean.Name := ``Verso.Theme.ManualTheme.ink + /-- + The default dark-appearance theme. Its registration name must be a registered + `Verso.Theme.ManualTheme` whose appearance is `.dark`. + -/ + defaultDarkTheme : Lean.Name := ``Verso.Theme.ManualTheme.argent + /-- + The default for readers who do not follow the system appearance (the "single" picker mode): + choose `.light` so single-mode falls back to + `defaultLightTheme`, + or `.dark` so it falls back to + `defaultDarkTheme`. + + Auto-mode behavior is unchanged either way: a reader who keeps "Match system" on still gets + the light or dark default depending on their OS preference. This setting only governs the + single-mode fallback (and what the picker marks as ` (default)` in the single-mode + dropdown). + -/ + defaultSingleAppearance : Verso.Theme.Appearance := .light + /-- + The materialized set of themes available to the picker. `manualMain` populates + this from `manualThemes%` filtered by + `availableThemes`; the + emit functions read it. Stored as an array so it can be threaded through the read-only + configuration without an extra reader layer. -/ - manualTheme : Verso.Theme.ManualTheme := Verso.Theme.ManualTheme.Default + themeRegistry : Array (Lean.Name × Verso.Theme.ManualTheme) := #[] namespace Config @@ -396,6 +445,73 @@ def DividedDoc.ofPart (part : Part Manual) : DividedDoc := where isUnnumbered (p : Part Manual) : Bool := p.metadata.map (·.number) |>.isEqSome false +/-- +Resolves the single-mode default theme name from the configured +`Verso.Genre.Manual.RenderConfig.defaultSingleAppearance` — either +`Verso.Genre.Manual.RenderConfig.defaultLightTheme` or +`Verso.Genre.Manual.RenderConfig.defaultDarkTheme`. +-/ +def defaultSingleName (config : RenderConfig) : Lean.Name := + match config.defaultSingleAppearance with + | .light => config.defaultLightTheme + | .dark => config.defaultDarkTheme + +/-- +The single-default `Verso.Theme.ManualTheme` resolved from `themeRegistry`. This is the +theme that drives `verso-themes.css`'s unscoped `:root` block — what the server-rendered +HTML paints on first visit (before the no-flash script attaches a `data-verso-theme`) — and +the TeX code styling. + +Falls back to a bare default `ManualTheme.ink` if neither the resolved single-default name +nor either configured default appears in the registry (a defensive case; the validation pass +in `manualMain` rejects unregistered defaults before this is called). +-/ +def singleDefaultTheme (config : RenderConfig) : Verso.Theme.ManualTheme := + let find (n : Lean.Name) : Option Verso.Theme.ManualTheme := + config.themeRegistry.findSome? (fun (m, t) => if m == n then some t else none) + (find (defaultSingleName config) <|> find config.defaultLightTheme + <|> find config.defaultDarkTheme).getD Verso.Theme.ManualTheme.ink + +open IO.FS in +/-- +Writes every theme-related asset for an output root: the multi-theme `verso-themes.css`, the +picker `.js`/`.css`, the `window.versoThemes` data file, every theme's font bytes and bundled +assets. Content-addressed font filenames in the theme registry ensure that two themes sharing the +same font end up with one byte payload on disk. +-/ +def writeThemeAssets (dir : System.FilePath) (config : RenderConfig) : IO Unit := do + ensureDir (dir / "-verso-data") + let themes := config.themeRegistry + let single := singleDefaultTheme config + -- verso-themes.css + withFile (dir / "verso-themes.css") .write fun h => do + h.putStrLn (Verso.Theme.«verso-themes.css» single themes + config.defaultLightTheme config.defaultDarkTheme) + -- Font bytes, deduplicated by output path: a theme's @font-face rules embed the per-theme + -- asset-root path, so writing one path and skipping a structurally-identical-bytes path under + -- a different theme root would leave that rule pointing at a missing file. + let mut writtenPaths : Std.HashSet String := {} + for (n, t) in themes do + let assetRoot := s!"-verso-data/themes/{n.toString}" + for (path, bytes, _, _) in t.fontAssets assetRoot do + if writtenPaths.contains path then continue + writtenPaths := writtenPaths.insert path + let abs := dir.join path + if let some p := abs.parent then ensureDir p + writeBinFile abs bytes + -- Theme-bundled assets (images, etc.). + for (n, t) in themes do + for a in t.assets do + let path := dir / "-verso-data" / "themes" / n.toString / a.path + if let some p := path.parent then ensureDir p + writeBinFile path a.contents + -- Picker assets + data file. + writeFile (dir / "-verso-data" / "theme-picker.js") Manual.Theme.«theme-picker.js» + writeFile (dir / "-verso-data" / "theme-picker.css") Manual.Theme.«theme-picker.css» + writeFile (dir / "-verso-data" / "verso-themes.js") + (Verso.Theme.windowVersoThemesJs themes config.defaultLightTheme config.defaultDarkTheme + (defaultSingleName config) Manual.Theme.codeSampleHtml) + open IO.FS in def emitTeX (config : RenderConfig) (text : Part Manual) : EmitM Unit := do let (text, state) ← traverse text config.toConfig @@ -420,7 +536,7 @@ def emitTeX (config : RenderConfig) (text : Part Manual) : EmitM Unit := do withFile (dir.join "main.tex") .write fun h => do if config.verbose then IO.println s!"Saving {dir.join "main.tex"}" - h.putStrLn (preamble text.titleString authors date packages.toList preambleItems.toList config.manualTheme.toCodeTheme) + h.putStrLn (preamble text.titleString authors date packages.toList preambleItems.toList (singleDefaultTheme config).toCodeTheme) -- \frontmatter is inserted by our hardcoded preamble before the ToC, so it doesn't get inserted -- here. If there's any text at the start of the front matter, then we need to clear it to a new -- recto page after the ToC @@ -507,7 +623,8 @@ def page (toc : List Html.Toc) (state : TraverseState) (config : Config) (localItems : Array Html) (showNavButtons : Bool := true) (extraJs : List JS := []) - (extraHead : Html := .empty) : Html := + (extraHead : Html := .empty) + (themeInitScript : String := "") (showThemePicker : Bool := false) : Html := let toc := { title := htmlBookTitle, path := #[], id := "" , sectionNum := some #[], children := toc } @@ -529,6 +646,7 @@ def page (toc : List Html.Toc) state.extraCss (state.extraJs.insertMany extraJs) (showNavButtons := showNavButtons) (logo := config.logo) + (logoDark := config.logoDark) (logoLink := config.logoLink) (repoLink := config.sourceLink) (issueLink := config.issueLink) @@ -537,6 +655,8 @@ def page (toc : List Html.Toc) (extraJsFiles := featureJsFiles ++ extraJsFiles) (extraHead := config.extraHead |>.push extraHead) (extraContents := config.extraContents) + (themeInitScript := themeInitScript) + (showThemePicker := showThemePicker) def relativizeLinks (html : Html) : Html := -- Make all absolute URLS be relative to the site root, because that'll make them ``-relative @@ -785,24 +905,7 @@ where emitSearchResultsHtml toc dir titleToShow state config.toConfig IO.FS.withFile (dir.join "book.css") .write fun h => do h.putStrLn Html.Css.pageStyle - IO.FS.withFile (dir.join "verso-themes.css") .write fun h => do - let assetRoot := s!"-verso-data/themes/{config.manualTheme.name}" - let faceRules := config.manualTheme.fontFaceRules assetRoot - unless faceRules.isEmpty do - h.putStrLn faceRules - h.putStrLn s!":root \{\n{config.manualTheme.cssVariables}}" - let extra := config.manualTheme.extraCss assetRoot - unless extra.isEmpty do - h.putStrLn "" - h.putStrLn extra - for (path, bytes, _, _) in config.manualTheme.fontAssets s!"-verso-data/themes/{config.manualTheme.name}" do - let abs := dir.join path - if let some p := abs.parent then ensureDir p - IO.FS.writeBinFile abs bytes - for a in config.manualTheme.assets do - let path := dir.join "-verso-data" |>.join "themes" |>.join config.manualTheme.name |>.join a.path - if let some p := path.parent then ensureDir p - IO.FS.writeBinFile path a.contents + writeThemeAssets dir config for (src, dest) in config.extraFiles do copyRecursively src (dir.join dest) for (src, dest) in config.extraFilesHtml do @@ -819,8 +922,19 @@ where if config.verbose then IO.println s!"Saving {dir.join "index.html"}" h.putStrLn Html.doctype + -- Offer the picker only when the reader has a real choice. A registry with one entry + -- (or none) means the unscoped `:root` block already paints the only available theme. + let showThemePicker := config.themeRegistry.size > 1 + let themeInitScript := + if showThemePicker then + Verso.Theme.themeInitScript config.themeRegistry + config.defaultLightTheme config.defaultDarkTheme (defaultSingleName config) + else "" h.putStrLn <| Html.asString <| relativizeLinks <| - page toc ctxt.path text.titleString titleToShow pageContent state config.toConfig thisPageToc (showNavButtons := false) + page toc ctxt.path text.titleString titleToShow pageContent state config.toConfig thisPageToc + (showNavButtons := false) + (themeInitScript := themeInitScript) + (showThemePicker := showThemePicker) /-- @@ -869,24 +983,7 @@ where else titleHtml IO.FS.withFile (root / "book.css") .write fun h => do h.putStrLn Html.Css.pageStyle - IO.FS.withFile (root / "verso-themes.css") .write fun h => do - let assetRoot := s!"-verso-data/themes/{config.manualTheme.name}" - let faceRules := config.manualTheme.fontFaceRules assetRoot - unless faceRules.isEmpty do - h.putStrLn faceRules - h.putStrLn s!":root \{\n{config.manualTheme.cssVariables}}" - let extra := config.manualTheme.extraCss assetRoot - unless extra.isEmpty do - h.putStrLn "" - h.putStrLn extra - for (path, bytes, _, _) in config.manualTheme.fontAssets s!"-verso-data/themes/{config.manualTheme.name}" do - let abs := root.join path - if let some p := abs.parent then ensureDir p - IO.FS.writeBinFile abs bytes - for a in config.manualTheme.assets do - let path := root.join "-verso-data" |>.join "themes" |>.join config.manualTheme.name |>.join a.path - if let some p := path.parent then ensureDir p - IO.FS.writeBinFile path a.contents + writeThemeAssets root config for (src, dest) in config.extraFiles do copyRecursively src (root.join dest) for (src, dest) in config.extraFilesHtml do @@ -955,8 +1052,18 @@ where if config.verbose then IO.println s!"Saving {dir.join "index.html"}" h.putStrLn Html.doctype + -- Offer the picker only when the reader has a real choice. A registry with one entry + -- (or none) means the unscoped `:root` block already paints the only available theme. + let showThemePicker := config.themeRegistry.size > 1 + let themeInitScript := + if showThemePicker then + Verso.Theme.themeInitScript config.themeRegistry + config.defaultLightTheme config.defaultDarkTheme (defaultSingleName config) + else "" h.putStrLn <| Html.asString <| relativizeLinks <| page bookContents ctxt.path part.titleString bookTitle pageContent state config.toConfig thisPageToc + (themeInitScript := themeInitScript) + (showThemePicker := showThemePicker) if depth > 0 ∧ part.htmlSplit != .never then for p in part.subParts do let nextFile := p.metadata.bind (·.file) |>.getD (p.titleString.sluggify.toString) @@ -1007,8 +1114,7 @@ def manualMain (text : Part Manual) (config : RenderConfig := {}) (extraSteps : List ExtraStep := []) : IO UInt32 := let _ := codeThemes - let _ := manualThemes - ReaderT.run go extensionImpls + go extensionImpls manualThemes where @@ -1060,24 +1166,97 @@ where if base.endsWith "/" then base else base ++ "/" /-- - Runs the theme's accessibility check and routes each {name (full := Verso.Color.Issue)}`Issue` - through {name}`MonadBuildLog`: contrast issues use {name (full := Verso.Genre.Manual.RenderConfig.strictThemeContrast)}`strictThemeContrast` - and colorblindness issues use {name (full := Verso.Genre.Manual.RenderConfig.strictThemeColorblind)}`strictThemeColorblind` - to decide error vs. warning. The build proceeds either way; logged errors set the exit code. + Runs the theme set's accessibility checks at three tiers: + + - **Coverage** (gated by + `strictThemeCoverage`): + the build must offer a usable theme. With a single registered theme it must be accessible; + with multiple themes at least one accessible light *and* one accessible dark must exist. + + - **Default theme** (gated by + `strictDefaultThemeAccessibility`): + the configured `defaultLightTheme` + and `defaultDarkTheme` + must themselves be accessible. + + - **Per-theme advisory** (gated by + `warnPerThemeAccessibility`): + every registered theme with any accessibility issues emits a build-log warning naming the + theme and the specific issues. + + A theme counts as "accessible" iff its + `Verso.Theme.ManualTheme.checkAccessibility` returns no issues. -/ runThemeAccessibilityCheck (cfg : RenderConfig) : ReaderT ExtensionImpls (BuildLogT IO) Unit := do - for issue in cfg.manualTheme.checkAccessibility do - let strict := match issue.kind with - | .contrast => cfg.strictThemeContrast - | .colorblind => cfg.strictThemeColorblind - let colors := issue.offending.toList.map Verso.Theme.Color.css |> ", ".intercalate - let suffix := if colors.isEmpty then "" else s!" ({colors})" - let msg := s!"theme '{cfg.manualTheme.name}': {issue.message}{suffix}" - if strict then Verso.reportError msg else Verso.reportWarning msg - - go (extensionImpls : ExtensionImpls) : IO UInt32 := do - let cfg ← opts config options + let issuesOf (t : Verso.Theme.ManualTheme) := t.checkAccessibility + let isAccessible (t : Verso.Theme.ManualTheme) : Bool := (issuesOf t).isEmpty + -- Per-theme advisory. + if cfg.warnPerThemeAccessibility then + for (n, t) in cfg.themeRegistry do + for issue in issuesOf t do + let colors := issue.offending.toList.map Verso.Theme.Color.css |> ", ".intercalate + let suffix := if colors.isEmpty then "" else s!" ({colors})" + Verso.reportWarning s!"theme '{n.toString}' ({t.name}): {issue.message}{suffix}" + -- Coverage. + let routeCoverage (msg : String) : ReaderT ExtensionImpls (BuildLogT IO) Unit := + if cfg.strictThemeCoverage then Verso.reportError msg else Verso.reportWarning msg + let accessible := cfg.themeRegistry.filter (fun (_, t) => isAccessible t) + if accessible.isEmpty then + routeCoverage "no registered theme is accessible; readers cannot pick a usable theme" + else if cfg.themeRegistry.size > 1 then + let anyLight := accessible.any (fun (_, t) => t.appearance == Verso.Theme.Appearance.light) + let anyDark := accessible.any (fun (_, t) => t.appearance == Verso.Theme.Appearance.dark) + unless anyLight do + routeCoverage "no registered light theme is accessible; readers on a light system cannot pick a usable theme" + unless anyDark do + routeCoverage "no registered dark theme is accessible; readers on a dark system cannot pick a usable theme" + -- Default theme accessibility. + let routeDefault (msg : String) : ReaderT ExtensionImpls (BuildLogT IO) Unit := + if cfg.strictDefaultThemeAccessibility then Verso.reportError msg else Verso.reportWarning msg + let checkDefault (slot : String) (name : Lean.Name) : + ReaderT ExtensionImpls (BuildLogT IO) Unit := do + match cfg.themeRegistry.findSome? (fun (n, t) => if n == name then some t else none) with + | none => pure () -- already reported by validate + | some t => + let issues := issuesOf t + unless issues.isEmpty do + routeDefault s!"{slot} '{name.toString}' ({t.name}) has {issues.size} accessibility issue{if issues.size == 1 then "" else "s"}" + checkDefault "defaultLightTheme" cfg.defaultLightTheme + checkDefault "defaultDarkTheme" cfg.defaultDarkTheme + + /-- + Builds the materialized theme registry for the build from the registered + `Verso.Theme.ManualTheme` table, filters it by the configured + `availableThemes`, + routes every `Verso.Theme.ManualThemeTable.ValidationError` through + `MonadBuildLog` as an error, and returns the populated config. + -/ + resolveThemeRegistry (cfg : RenderConfig) + (table : Verso.Theme.ManualThemeTable) : + ReaderT ExtensionImpls (BuildLogT IO) RenderConfig := do + for e in table.validate cfg.defaultLightTheme cfg.defaultDarkTheme cfg.availableThemes do + Verso.reportError e.format + -- `availableThemes` semantics: + -- `none` → every registered theme is available + -- `some [..xs]` → exactly those themes, with `defaultLightTheme` and + -- `defaultDarkTheme` implicitly added if missing so the picker always + -- contains the resolved default for each appearance. + -- The result always contains at least `defaultLightTheme` and `defaultDarkTheme` if those + -- are registered, so `singleDefaultTheme` can always resolve. + let expanded := + match cfg.availableThemes with + | none => table.themes.foldl (init := #[]) (fun acc n t => acc.push (n, t)) + | some xs => + let withDefaults := xs + |>.append (if xs.contains cfg.defaultLightTheme then #[] else #[cfg.defaultLightTheme]) + |>.append (if xs.contains cfg.defaultDarkTheme then #[] else #[cfg.defaultDarkTheme]) + withDefaults.filterMap (fun n => (table.find? n).map (fun t => (n, t))) + return { cfg with themeRegistry := expanded } + + go (extensionImpls : ExtensionImpls) (manualThemes : Verso.Theme.ManualThemeTable) : IO UInt32 := do + let baseCfg ← opts config options runWithLogger <| flip ReaderT.run extensionImpls do + let cfg ← resolveThemeRegistry baseCfg manualThemes runThemeAccessibilityCheck cfg if cfg.emitTeX then if cfg.verbose then diff --git a/src/verso-manual/VersoManual/Html.lean b/src/verso-manual/VersoManual/Html.lean index ff5c37cb3..3b8e10ffa 100644 --- a/src/verso-manual/VersoManual/Html.lean +++ b/src/verso-manual/VersoManual/Html.lean @@ -418,11 +418,14 @@ public def page (extraContents : Array Html := #[]) (showNavButtons : Bool := true) (logo : Option String := none) + (logoDark : Option String := none) (logoLink : Option String := none) (repoLink : Option String := none) (issueLink : Option String := none) (extraStylesheets : List String := []) - (extraJsFiles : Array (String × Bool) := #[]) : Html := + (extraJsFiles : Array (String × Bool) := #[]) + (themeInitScript : String := "") + (showThemePicker : Bool := false) : Html := let relativeRoot := String.join <| "./" :: path.toList.map (fun _ => "../") let defer := #[("defer", "defer")] {{ @@ -435,8 +438,19 @@ public def page {{textTitle}} + {{if themeInitScript.isEmpty then .empty else + {{}} }} + {{if showThemePicker then + {{}} + else .empty }} + {{if showThemePicker then + {{}} + else .empty }} + {{if showThemePicker then + {{}} + else .empty }} {{ searchAssetTags }} {{extraJsFiles.map fun f => ({{}})}} @@ -449,16 +463,30 @@ public def page
{{if let some url := logo then - let logoHtml := {{}} let logoDest := if let some root := logoLink then root else "/" - {{}} + let lightImg := + if logoDark.isSome then + {{}} + else + {{}} + let darkImg := + if let some d := logoDark then {{}} + else .empty + {{}} else .empty }}
+ {{if showThemePicker then + {{
+ +
}} + else .empty }}