diff --git a/README.md b/README.md index 73f9563..0fe6cc9 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ The following is a table of the available subcommands for the CLI tool (Tinty), |------------|-----------------------------------------------------|----------------------|--------------------------------------------| | `sync` | Installs and updates schemes and templates defined in `tinty/config.toml` | - | `tinty sync` | | `list` | Lists all available themes. | Optional argument `--custom-schemes` to list saved custom theme files using `tinty generate-scheme`.
Optional argument `--json` to output more info about each scheme in JSON form | `tinty list` | +| `gallery` | Opens an interactive browser gallery for available themes. | Optional argument `--dump ` to write a static site artifact suitable for GitHub Pages.
Optional argument `--custom-schemes` to use saved custom theme files.
Optional argument `--no-open` to skip opening a browser. | `tinty gallery` | | `apply` | Applies a specific theme. | `-`: Name of the system and scheme to apply. | `tinty apply base16-mocha` | | `cycle` | Applies the next theme among your preferred ones. See [Configuration](#configuration). | | `tinty cycle` | | `init` | Initializes the tool with the last applied theme otherwise `default-scheme` from `config.toml`. | - | `tinty init` | @@ -197,7 +198,9 @@ Some subcommands support additional flags and options to modify their behavior: | `--version` `-V` | Shows the version of tinty. | All | - | `tinty --version` | | `--config-path` | Shows the config.yml path. | `config` | - | `tinty config --config-path` | | `--data-dir-path` | Shows the data directory path. | `config` | - | `tinty config --data-dir-path` | -| `--custom-schemes` | Lists saved custom theme files manually created or generated by `tinty generate-scheme` | `list` | - | `tinty list --custom-schemes` | +| `--custom-schemes` | Uses saved custom theme files manually created or generated by `tinty generate-scheme` | `list`, `gallery` | - | `tinty gallery --custom-schemes` | +| `--dump` | Writes the gallery as a static website artifact | `gallery` | `$XDG_DATA_HOME/tinted-theming/tinty/artifacts/gallery` | `tinty gallery --dump ./public` | +| `--no-open` | Generates the gallery without opening a browser | `gallery` | `false` | `tinty gallery --no-open` | | `--quiet` | Boolean flag which silences stdout prints | `apply`, `build`, `install`, `update`, `sync` | `false` | `tinty build . --quiet` | ## Configuration diff --git a/USAGE.md b/USAGE.md index 580a7ad..6a58c5a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -187,6 +187,26 @@ Sort themes by background color, from darkest to lightest: tinty list --json | jq 'sort_by(.lightness.background)' -r ``` +## Gallery + +`tinty gallery` builds an interactive static gallery from the available +schemes and opens it in your browser: + +```sh +tinty gallery +``` + +To write a hostable static site artifact, use `--dump`: + +```sh +tinty gallery --dump ./public +``` + +The dumped directory contains `index.html` and static assets, so it can +be published with GitHub Pages. Use `--no-open` to generate the files +without launching a browser, and `--custom-schemes` to build the gallery +from saved custom schemes. + ## Shell When Tinty does not have any `[[items]]` set up in `config.toml`, Tinty diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000..908a736 Binary files /dev/null and b/assets/favicon.png differ diff --git a/assets/tinted-theming-logo.png b/assets/tinted-theming-logo.png new file mode 100644 index 0000000..c8d0d0e Binary files /dev/null and b/assets/tinted-theming-logo.png differ diff --git a/deny.toml b/deny.toml index 3a6e194..7cb8bde 100644 --- a/deny.toml +++ b/deny.toml @@ -45,12 +45,8 @@ deny = [ ] skip = [ { name = "bitflags" }, # Related to `tempfile` version lock - { name = "linux-raw-sys" }, # Related to `tempfile` version lock - { name = "rustix" }, # Related to `tempfile` version lock { name = "thiserror" }, { name = "thiserror-impl" }, - { name = "zune-core" }, - { name = "zune-jpeg" }, { name = "nom" }, ] diff --git a/src/cli.rs b/src/cli.rs index c8b6882..bf3a88a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -73,6 +73,30 @@ pub fn build_cli() -> Command { .required(true), ), ) + .subcommand( + Command::new("gallery") + .about("Opens an interactive gallery for available schemes") + .arg( + Arg::new("custom-schemes") + .help("Build gallery from available custom schemes") + .long("custom-schemes") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("dump") + .long("dump") + .help("Write a static gallery site to the provided directory") + .value_name("DIRECTORY") + .value_hint(ValueHint::DirPath) + .action(ArgAction::Set), + ) + .arg( + Arg::new("no-open") + .long("no-open") + .help("Do not open the generated gallery in a browser") + .action(ArgAction::SetTrue), + ), + ) .subcommand( Command::new("generate-scheme") .about("Generates a scheme based on an image") diff --git a/src/main.rs b/src/main.rs index 2b9f055..afb3147 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod operations { pub mod config; pub mod current; pub mod cycle; + pub mod gallery; pub mod generate_scheme; pub mod info; pub mod init; @@ -114,6 +115,17 @@ fn main() -> Result<()> { return Ok(()); } } + Some(("gallery", sub_matches)) => { + let is_custom = sub_matches + .get_one::("custom-schemes") + .is_some_and(ToOwned::to_owned); + let should_open = !sub_matches + .get_one::("no-open") + .is_some_and(ToOwned::to_owned); + let dump_dir = sub_matches.get_one::("dump").map(String::as_str); + + operations::gallery::gallery(&data_path, is_custom, dump_dir, should_open)?; + } Some(("info", sub_matches)) => { let is_custom = sub_matches .get_one::("custom-schemes") diff --git a/src/operations/gallery.rs b/src/operations/gallery.rs new file mode 100644 index 0000000..c856b2e --- /dev/null +++ b/src/operations/gallery.rs @@ -0,0 +1,136 @@ +use crate::{ + constants::ARTIFACTS_DIR, + operations::list::{scheme_entries_json, schemes_dir_path}, + utils::{ensure_directory_exists, write_to_file}, +}; +use anyhow::{Context, Result}; +use std::{ + fs::File, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +const GALLERY_DIR_NAME: &str = "gallery"; +const INDEX_HTML: &str = include_str!("gallery/index.html"); +const GALLERY_CSS: &str = include_str!("gallery/gallery.css"); +const GALLERY_JS: &str = include_str!("gallery/gallery.js"); +const LOGO_BYTES: &[u8] = include_bytes!("../../assets/tinted-theming-logo.png"); +const FAVICON_BYTES: &[u8] = include_bytes!("../../assets/favicon.png"); +const FONT_DM_SERIF_400: &[u8] = include_bytes!("gallery/fonts/dm-serif-display-400.woff2"); +const FONT_DM_SERIF_400_ITALIC: &[u8] = + include_bytes!("gallery/fonts/dm-serif-display-400-italic.woff2"); +const FONT_IBM_PLEX_MONO_400: &[u8] = include_bytes!("gallery/fonts/ibm-plex-mono-400.woff2"); +const FONT_IBM_PLEX_MONO_500: &[u8] = include_bytes!("gallery/fonts/ibm-plex-mono-500.woff2"); + +pub fn gallery( + data_path: &Path, + is_custom: bool, + dump_dir: Option<&str>, + should_open: bool, +) -> Result { + let schemes_path = schemes_dir_path(data_path, is_custom)?; + let schemes_json = scheme_entries_json(&schemes_path)?; + let output_dir = dump_dir.map_or_else( + || data_path.join(ARTIFACTS_DIR).join(GALLERY_DIR_NAME), + PathBuf::from, + ); + + write_gallery_files(&output_dir, &schemes_json)?; + + let index_path = output_dir.join("index.html"); + if should_open { + open_in_browser(&index_path)?; + } + + println!("Gallery written to {}", index_path.display()); + + Ok(index_path) +} + +fn write_gallery_files(output_dir: &Path, schemes_json: &str) -> Result<()> { + let assets_dir = output_dir.join("assets"); + let fonts_dir = assets_dir.join("fonts"); + + ensure_directory_exists(output_dir)?; + ensure_directory_exists(&assets_dir)?; + ensure_directory_exists(&fonts_dir)?; + + write_to_file(output_dir.join("index.html"), INDEX_HTML)?; + write_to_file(assets_dir.join("gallery.css"), GALLERY_CSS)?; + let gallery_js = GALLERY_JS.replace("__TINTY_SCHEMES__", schemes_json); + write_to_file(assets_dir.join("gallery.js"), &gallery_js)?; + write_binary_file(assets_dir.join("tinted-theming-logo.png"), LOGO_BYTES)?; + write_binary_file(assets_dir.join("favicon.png"), FAVICON_BYTES)?; + write_binary_file( + fonts_dir.join("dm-serif-display-400.woff2"), + FONT_DM_SERIF_400, + )?; + write_binary_file( + fonts_dir.join("dm-serif-display-400-italic.woff2"), + FONT_DM_SERIF_400_ITALIC, + )?; + write_binary_file( + fonts_dir.join("ibm-plex-mono-400.woff2"), + FONT_IBM_PLEX_MONO_400, + )?; + write_binary_file( + fonts_dir.join("ibm-plex-mono-500.woff2"), + FONT_IBM_PLEX_MONO_500, + )?; + + Ok(()) +} + +fn write_binary_file(path: impl AsRef, contents: &[u8]) -> Result<()> { + let mut file = File::create(path.as_ref()) + .map_err(anyhow::Error::new) + .with_context(|| format!("Unable to create file: {}", path.as_ref().display()))?; + + file.write_all(contents)?; + + Ok(()) +} + +fn open_in_browser(index_path: &Path) -> Result<()> { + let index_path = index_path + .canonicalize() + .with_context(|| format!("Unable to resolve {}", index_path.display()))?; + + let mut command = browser_command(&index_path); + let status = command + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("Unable to open gallery at {}", index_path.display()))?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Unable to open gallery at {}", + index_path.display() + )); + } + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn browser_command(path: &Path) -> Command { + let mut command = Command::new("open"); + command.arg(path); + command +} + +#[cfg(target_os = "windows")] +fn browser_command(path: &Path) -> Command { + let mut command = Command::new("cmd"); + command.args(["/C", "start", ""]).arg(path); + command +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +fn browser_command(path: &Path) -> Command { + let mut command = Command::new("xdg-open"); + command.arg(path); + command +} diff --git a/src/operations/gallery/fonts/dm-serif-display-400-italic.woff2 b/src/operations/gallery/fonts/dm-serif-display-400-italic.woff2 new file mode 100644 index 0000000..45d19db Binary files /dev/null and b/src/operations/gallery/fonts/dm-serif-display-400-italic.woff2 differ diff --git a/src/operations/gallery/fonts/dm-serif-display-400.woff2 b/src/operations/gallery/fonts/dm-serif-display-400.woff2 new file mode 100644 index 0000000..7c9fa83 Binary files /dev/null and b/src/operations/gallery/fonts/dm-serif-display-400.woff2 differ diff --git a/src/operations/gallery/fonts/ibm-plex-mono-400.woff2 b/src/operations/gallery/fonts/ibm-plex-mono-400.woff2 new file mode 100644 index 0000000..52b6c75 Binary files /dev/null and b/src/operations/gallery/fonts/ibm-plex-mono-400.woff2 differ diff --git a/src/operations/gallery/fonts/ibm-plex-mono-500.woff2 b/src/operations/gallery/fonts/ibm-plex-mono-500.woff2 new file mode 100644 index 0000000..3308bce Binary files /dev/null and b/src/operations/gallery/fonts/ibm-plex-mono-500.woff2 differ diff --git a/src/operations/gallery/gallery.css b/src/operations/gallery/gallery.css new file mode 100644 index 0000000..922c6af --- /dev/null +++ b/src/operations/gallery/gallery.css @@ -0,0 +1,1327 @@ +@font-face { + font-family: 'DM Serif Display'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('fonts/dm-serif-display-400.woff2') format('woff2'); +} + +@font-face { + font-family: 'DM Serif Display'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('fonts/dm-serif-display-400-italic.woff2') format('woff2'); +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('fonts/ibm-plex-mono-400.woff2') format('woff2'); +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('fonts/ibm-plex-mono-500.woff2') format('woff2'); +} + +:root { + color-scheme: light dark; + + --bg: #f7f7f5; + --bg-tint: #fdfcfa; + --surface: #ffffff; + --surface-2: #fafaf8; + --ink-1: #0f1115; + --ink-2: #2a2f38; + --ink-3: #6b7280; + --ink-4: #9aa2ad; + --border: #e6e5e0; + --border-strong: #d6d4cd; + --accent: #c95c17; + --accent-ink: #ffffff; + --accent-soft: color-mix(in oklab, var(--accent) 12%, transparent); + --accent-softer: color-mix(in oklab, var(--accent) 7%, transparent); + --accent-ring: color-mix(in oklab, var(--accent) 28%, transparent); + + --ring: 0 0 0 1px color-mix(in oklab, var(--ink-1) 7%, transparent); + --shadow-sm: 0 1px 2px rgb(18 20 27 / 5%); + --shadow-md: 0 6px 18px -8px rgb(18 20 27 / 12%), 0 2px 6px -2px rgb(18 20 27 / 6%); + --shadow-lg: 0 24px 60px -20px rgb(18 20 27 / 25%), 0 10px 24px -12px rgb(18 20 27 / 12%); + + --tooltip-bg: #0f1115; + --tooltip-ink: #f5f5f4; + --tooltip-border: #2a2f38; + + --paper-wash-start: rgb(255 255 255 / .45); + --paper-wash-end: rgb(18 20 27 / .02); + --grain-opacity: .12; + --grain-tile: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.40' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.09'/%3E%3C/svg%3E"); + + --radius-xs: 6px; + --radius-sm: 8px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 18px; + --radius-full: 999px; + + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-display: "DM Serif Display", Georgia, "Times New Roman", serif; + --font-mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + isolation: isolate; + background-color: var(--bg); + background-image: + radial-gradient(1100px 520px at 12% -10%, var(--accent-softer), transparent 62%), + linear-gradient(180deg, var(--paper-wash-start), var(--paper-wash-end)); + background-size: auto, auto; + background-position: 0 0, 0 0; + background-attachment: fixed; + color: var(--ink-1); + font: 15px/1.55 var(--font-sans); + font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.40' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 200px 200px; + opacity: var(--grain-opacity); +} + +.page-scene { + transform-origin: center top; + transform: translateY(0) scale(1); + transition: transform 160ms cubic-bezier(.2, .8, .2, 1), opacity 130ms ease; +} + +body.sheet-open .page-scene { + transform: translateY(-8px) scale(.985); + opacity: .92; +} + +body.sheet-open { + overflow: hidden; +} + +.topbar, +main { + width: min(1280px, calc(100% - 40px)); + margin: 0 auto; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: 18px; + padding: 14px 18px; + background: color-mix(in oklab, var(--surface) 72%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + backdrop-filter: blur(14px) saturate(130%); + -webkit-backdrop-filter: blur(14px) saturate(130%); + box-shadow: var(--shadow-sm); +} + +@supports not (backdrop-filter: blur(1px)) { + .topbar { + background: color-mix(in oklab, var(--surface) 94%, transparent); + } +} + +.brand { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.brand-logo { + width: 38px; + height: 38px; + object-fit: contain; + flex: 0 0 auto; + filter: drop-shadow(0 4px 10px rgb(18 20 27 / 10%)); +} + +.brand-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.brand-tagline { + color: var(--ink-3); + font-size: 12px; + font-weight: 500; + letter-spacing: .02em; +} + +.topbar-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 12px; +} + +#result-count { + padding: 4px 10px; + color: var(--ink-3); + background: color-mix(in oklab, var(--ink-1) 4%, transparent); + border-radius: var(--radius-full); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.icon { + width: 15px; + height: 15px; + flex: 0 0 15px; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} + +.chip, +.search span, +.meta-pill, +.icon-button, +.command-row, +.section-label { + display: inline-flex; + align-items: center; + gap: 7px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-family: var(--font-display); + font-size: clamp(24px, 2.4vw, 30px); + font-weight: 400; + line-height: 1.1; + letter-spacing: -.02em; + color: var(--ink-1); +} + +.controls { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto auto; + gap: 10px; + align-items: center; + margin: 20px 0 4px; + padding: 10px; + background: color-mix(in oklab, var(--surface) 80%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: var(--shadow-sm); +} + +.search { + position: relative; + display: block; +} + +.search > span { + position: absolute; + top: 50%; + left: 14px; + transform: translateY(-50%); + color: var(--ink-4); + font-size: 0; + pointer-events: none; +} + +.search > span .icon { + width: 16px; + height: 16px; + flex-basis: 16px; +} + +.search input { + width: 100%; + height: 40px; + padding: 8px 12px 8px 40px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: inherit; + font: inherit; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.search input::placeholder { + color: var(--ink-4); +} + +.search input:hover { + border-color: var(--border-strong); +} + +.search input:focus { + outline: none; + border-color: color-mix(in oklab, var(--accent) 55%, var(--border)); + box-shadow: 0 0 0 4px var(--accent-ring); +} + +.filter-group, +.theme-switcher, +.preview-toolbar { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + padding: 3px; + background: color-mix(in oklab, var(--ink-1) 4%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-full); +} + +.preview-toolbar { + border-radius: var(--radius-md); +} + +.chip { + position: relative; + min-height: 32px; + padding: 4px 12px; + border: 0; + border-radius: inherit; + background: transparent; + color: var(--ink-2); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 140ms ease, color 140ms ease, transform 140ms ease; +} + +.filter-group .chip, +.theme-switcher .chip, +.preview-toolbar .chip { + border-radius: var(--radius-full); +} + +.preview-toolbar .chip { + border-radius: var(--radius-xs); +} + +.chip:hover { + color: var(--ink-1); + background: color-mix(in oklab, var(--ink-1) 6%, transparent); +} + +.chip.active { + background: var(--surface); + color: var(--ink-1); + box-shadow: var(--shadow-sm), var(--ring); +} + +.chip:focus-visible, +.icon-button:focus-visible, +.preview-button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + padding: 20px 0 60px; +} + +.card { + position: relative; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + transition: border-color 160ms ease, box-shadow 200ms ease, transform 200ms ease; +} + +.card.is-sheet-source { + visibility: hidden; +} + +.card:hover, +.card:focus-within { + border-color: color-mix(in oklab, var(--accent) 35%, var(--border)); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.preview-button { + position: relative; + display: block; + width: 100%; + padding: 0; + background: transparent; + border: 0; + color: inherit; + text-align: left; + cursor: pointer; +} + +.card-preview { + position: relative; +} + +.card-header { + position: absolute; + top: 10px; + right: 10px; + z-index: 1; + display: flex; + gap: 6px; + pointer-events: none; +} + +.meta-pill { + padding: 3px 8px; + border-radius: var(--radius-full); + background: color-mix(in oklab, var(--preview-bg, var(--surface)) 82%, var(--preview-fg, var(--ink-2)) 18%); + color: var(--preview-fg, var(--ink-2)); + font-size: 11px; + font-weight: 600; + letter-spacing: .02em; + text-transform: uppercase; + box-shadow: 0 0 0 1px color-mix(in oklab, var(--preview-fg, #000) 12%, transparent); +} + +.card-header .meta-pill { + box-shadow: + 0 0 0 1px color-mix(in oklab, var(--preview-fg, #000) 12%, transparent), + 0 0 0 4px color-mix(in oklab, var(--preview-bg) 90%, transparent), + 0 0 10px 4px color-mix(in oklab, var(--preview-bg) 60%, transparent); +} + +.meta-pill .icon { + width: 11px; + height: 11px; + flex-basis: 11px; + stroke-width: 2; +} + +.code-preview { + min-height: 180px; + margin: 0; + padding: 18px 18px 16px; + overflow: auto; + background: var(--preview-bg); + color: var(--preview-fg); + font: 12.5px/1.6 var(--font-mono); +} + +.comment { + color: var(--preview-comment); + font-style: italic; +} + +.keyword { + color: var(--preview-keyword); +} + +.function { + color: var(--preview-function); +} + +.string { + color: var(--preview-string); +} + +.diff-add { + display: block; + color: var(--preview-added); + background: color-mix(in oklab, var(--preview-added) 14%, transparent); +} + +.diff-del { + display: block; + color: var(--preview-deleted); + background: color-mix(in oklab, var(--preview-deleted) 14%, transparent); +} + +.number { + color: var(--preview-number); +} + +.type { + color: var(--preview-type); +} + +.builtin { + color: var(--preview-builtin); +} + +.parameter { + color: var(--preview-parameter); + font-style: italic; +} + +.ansi-black { color: var(--preview-ansi-black); } +.ansi-red { color: var(--preview-ansi-red); } +.ansi-green { color: var(--preview-ansi-green); } +.ansi-yellow { color: var(--preview-ansi-yellow); } +.ansi-blue { color: var(--preview-ansi-blue); } +.ansi-magenta { color: var(--preview-ansi-magenta); } +.ansi-cyan { color: var(--preview-ansi-cyan); } +.ansi-white { color: var(--preview-ansi-white); } +.ansi-bright-black { color: var(--preview-ansi-bright-black); } +.ansi-bright-red { color: var(--preview-ansi-bright-red); } +.ansi-bright-green { color: var(--preview-ansi-bright-green); } +.ansi-bright-yellow { color: var(--preview-ansi-bright-yellow); } +.ansi-bright-blue { color: var(--preview-ansi-bright-blue); } +.ansi-bright-magenta { color: var(--preview-ansi-bright-magenta); } +.ansi-bright-cyan { color: var(--preview-ansi-bright-cyan); } +.ansi-bright-white { + color: var(--preview-ansi-bright-white); + font-weight: 500; +} + +.card-title { + padding: 16px 18px 18px; + border-top: 1px solid var(--border); + background: var(--surface); +} + +.card-title h2 { + overflow-wrap: anywhere; + font-family: var(--font-display); + font-size: clamp(20px, 1.9vw, 24px); + font-weight: 400; + line-height: 1.1; + letter-spacing: -.015em; + color: var(--ink-1); +} + +.card-title p { + margin-top: 6px; + font: 12px/1.4 var(--font-mono); + color: var(--ink-3); +} + +.metadata { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 14px; + margin: 0; + font-size: 13px; +} + +.metadata dt { + color: var(--ink-3); + font-weight: 500; + letter-spacing: .01em; +} + +.metadata dd { + margin: 0; + color: var(--ink-1); + font-weight: 500; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.palette { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); + gap: 8px; +} + +.sheet-glass-panel .palette { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.swatch { + min-width: 0; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: transform 140ms ease, box-shadow 140ms ease; +} + +.swatch:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.swatch-color { + height: 42px; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 8%); +} + +.swatch-label { + padding: 6px 8px; + font: 11.5px/1.3 var(--font-mono); + color: var(--ink-1); +} + +.swatch-label span { + display: block; + overflow-wrap: anywhere; + color: var(--ink-3); + font-size: 10.5px; +} + +.empty { + padding: 60px 0; + color: var(--ink-3); + text-align: center; +} + +.sheet-backdrop { + position: fixed; + inset: 0; + z-index: 20; + background: rgb(0 0 0 / 32%); + backdrop-filter: blur(0); + -webkit-backdrop-filter: blur(0); + opacity: 0; + transition: opacity 120ms ease, backdrop-filter 220ms ease, -webkit-backdrop-filter 220ms ease; +} + +.sheet-backdrop.open { + opacity: 1; +} + +body.sheet-open .sheet-backdrop { + backdrop-filter: blur(7px); + -webkit-backdrop-filter: blur(7px); + transition: + opacity 120ms ease, + backdrop-filter 280ms ease 260ms, + -webkit-backdrop-filter 280ms ease 260ms; +} + +.detail-sheet { + position: fixed; + left: 50%; + top: 50%; + z-index: 21; + display: flex; + flex-direction: column; + width: min(1100px, calc(100% - 24px)); + max-height: calc(100dvh - 48px); + overflow: hidden; + background: var(--preview-bg, var(--surface)); + color: var(--preview-fg, var(--ink-1)); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + transform: translate(-50%, -50%) scale(.96); + opacity: 0; + visibility: hidden; + transition: transform 150ms cubic-bezier(.2, .8, .2, 1), opacity 120ms ease, visibility 0ms 150ms; + will-change: transform, opacity; +} + +.detail-sheet.open { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + visibility: visible; + transition: transform 150ms cubic-bezier(.2, .8, .2, 1), opacity 120ms ease, visibility 0ms; +} + +.sheet-layout { + flex: 1 1 auto; + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(360px, 1fr); + grid-template-rows: 1fr auto; + min-height: 0; + overflow: hidden; +} + +.sheet-main { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.sheet-main-header { + flex: 0 0 auto; + padding: 22px 24px 16px; + padding-right: 60px; /* clear the absolute close button */ +} + +.sheet-title-block { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.sheet-title-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.sheet-main-header h2 { + overflow-wrap: anywhere; + font-family: var(--font-display); + font-size: clamp(28px, 3.2vw, 44px); + font-weight: 400; + line-height: 1.05; + letter-spacing: -.02em; + color: var(--preview-fg, var(--ink-1)); +} + +.sheet-code-area { + flex: 1 1 0; + min-height: 0; + overflow: auto; +} + +.sheet-main-footer { + grid-column: 1 / -1; + grid-row: 2; + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(360px, 1fr); + background: var(--surface); + color: var(--ink-1); + border-top: 1px solid var(--border); +} + +.sheet-main-footer > * { + grid-column: 1; + justify-self: start; + margin: 14px 24px 18px; +} + +.sheet-main-footer .command-row { + width: auto; + max-width: 100%; +} + +.sheet-glass-panel { + grid-column: 2; + grid-row: 1 / -1; + display: flex; + flex-direction: column; + gap: 18px; + margin: 12px 12px 12px 10px; + padding: 24px 18px 20px; + background: color-mix(in oklab, var(--surface) 66%, transparent); + color: var(--ink-1); + backdrop-filter: blur(22px) saturate(170%); + -webkit-backdrop-filter: blur(22px) saturate(170%); + border: 1px solid color-mix(in oklab, var(--preview-fg, #fff) 14%, transparent); + border-radius: var(--radius-lg); + box-shadow: + 0 8px 32px -8px rgb(0 0 0 / 28%), + 0 2px 8px -2px rgb(0 0 0 / 14%); + overflow-y: auto; +} + +.detail-sheet[data-contrast-mismatch="true"] { + border-color: color-mix(in oklab, var(--preview-fg, var(--border)) 14%, transparent); +} + +.detail-sheet[data-contrast-mismatch="true"] .sheet-glass-panel { + background: color-mix(in oklab, var(--surface) 88%, transparent); + border-color: color-mix(in oklab, var(--preview-fg, #fff) 18%, transparent); +} + +@supports not (backdrop-filter: blur(1px)) { + .sheet-glass-panel { + background: color-mix(in oklab, var(--surface) 96%, transparent); + } +} + + +.language-select-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.language-select-wrapper .icon { + position: absolute; + right: 8px; + width: 14px; + height: 14px; + flex-basis: 14px; + color: var(--ink-3); + pointer-events: none; +} + +.language-select { + height: 36px; + padding: 0 30px 0 10px; + appearance: none; + -webkit-appearance: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--ink-1); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.language-select:hover { + border-color: var(--border-strong); +} + +.language-select:focus { + outline: none; + border-color: color-mix(in oklab, var(--accent) 55%, var(--border)); + box-shadow: 0 0 0 4px var(--accent-ring); +} + +.icon-button { + position: relative; + min-width: 36px; + min-height: 36px; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--ink-2); + cursor: pointer; + transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +.icon-button:hover { + color: var(--ink-1); + background: var(--surface-2); + border-color: var(--border-strong); +} + +.icon-button:active { + transform: scale(.97); +} + +.theme-switcher .chip { + padding: 4px 8px; +} + +#sheet-close { + position: absolute; + top: 24px; + right: 24px; + z-index: 2; + width: 32px; + min-width: 32px; + height: 32px; + min-height: 32px; + border-radius: 50%; + background: color-mix(in oklab, var(--surface) 70%, transparent); + backdrop-filter: blur(10px) saturate(130%); + -webkit-backdrop-filter: blur(10px) saturate(130%); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + color: var(--ink-2); +} + +#sheet-close:hover { + background: var(--surface); + border-color: var(--border-strong); + color: var(--ink-1); +} + +.icon-button[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + right: 0; + bottom: calc(100% + 8px); + padding: 6px 9px; + border: 1px solid var(--tooltip-border); + border-radius: var(--radius-sm); + background: var(--tooltip-bg); + color: var(--tooltip-ink); + font: 11.5px/1.2 var(--font-sans); + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity 120ms ease, transform 140ms ease; + z-index: 1; + box-shadow: var(--shadow-md); +} + +.icon-button[data-tooltip].show-tooltip::after, +.icon-button[data-tooltip]:hover::after { + opacity: 1; + transform: translateY(0); +} + +.command-row { + justify-content: start; + gap: 10px; + width: 100%; + padding: 8px 10px 8px 12px; + background: color-mix(in oklab, var(--ink-1) 4%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.command-row > .icon:first-child { + color: var(--ink-3); +} + +.command-row code { + flex: 1 1 auto; + overflow: auto; + color: var(--ink-1); + font: 12.5px/1.4 var(--font-mono); + white-space: nowrap; +} + +.command-row .icon-button { + min-width: 30px; + min-height: 30px; + background: var(--surface); +} + +.section-label { + gap: 8px; + margin-bottom: 10px; + color: var(--ink-3); + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.section-label .icon { + width: 13px; + height: 13px; + flex-basis: 13px; +} + +.preview-toolbar { + align-self: flex-start; + margin-bottom: 0; + background: color-mix(in oklab, var(--ink-1) 4%, transparent); +} + +.preview-toolbar .chip { + font-size: 12px; + padding: 3px 10px; +} + +.sheet-code-preview { + min-height: 240px; + padding: 4px 24px 16px; + background: transparent; + border-radius: 0; + box-shadow: none; +} + +.sheet-lang-row { + flex: 0 0 auto; + padding: 0 24px 16px; +} + +.sheet-lang-row .preview-toolbar { + background: color-mix(in oklab, var(--preview-fg, var(--ink-1)) 9%, transparent); + border-color: color-mix(in oklab, var(--preview-fg, var(--border)) 16%, transparent); +} + +.sheet-lang-row .chip { + color: color-mix(in oklab, var(--preview-fg, var(--ink-2)) 65%, transparent); +} + +.sheet-lang-row .chip:hover { + color: var(--preview-fg, var(--ink-1)); + background: color-mix(in oklab, var(--preview-fg, var(--ink-1)) 12%, transparent); +} + +.sheet-lang-row .chip.active { + background: color-mix(in oklab, var(--preview-fg, var(--surface)) 18%, transparent); + color: var(--preview-fg, var(--ink-1)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--preview-fg, var(--ink-1)) 22%, transparent); +} + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + z-index: 30; + padding: 10px 16px; + background: color-mix(in oklab, var(--ink-1) 92%, transparent); + color: #fafafa; + border: 1px solid color-mix(in oklab, var(--ink-1) 80%, transparent); + border-radius: var(--radius-full); + box-shadow: var(--shadow-lg); + font-size: 13px; + font-weight: 500; + transform: translate(-50%, 18px); + opacity: 0; + transition: opacity 160ms ease, transform 220ms cubic-bezier(.2, .8, .2, 1); +} + +.toast.open { + transform: translate(-50%, 0); + opacity: 1; +} + +@media (max-width: 960px) { + .detail-sheet { + overflow: auto; + } + + .sheet-layout { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + overflow: visible; + } + + .sheet-main { + grid-column: 1; + grid-row: 1; + overflow: visible; + } + + .sheet-code-area { + flex: 0 0 auto; + overflow: visible; + } + + .sheet-glass-panel { + grid-column: 1; + grid-row: 2; + margin: 0 12px 12px; + padding: 22px 18px 20px; + max-height: none; + overflow: visible; + } + + #sheet-close { + top: 12px; + right: 12px; + background: color-mix(in oklab, var(--preview-fg, var(--surface)) 10%, transparent); + border-color: color-mix(in oklab, var(--preview-fg, var(--border)) 22%, transparent); + color: color-mix(in oklab, var(--preview-fg, var(--ink-3)) 70%, transparent); + } + + #sheet-close:hover { + background: color-mix(in oklab, var(--preview-fg, var(--surface)) 18%, transparent); + border-color: color-mix(in oklab, var(--preview-fg, var(--border-strong)) 38%, transparent); + color: var(--preview-fg, var(--ink-1)); + } + + .sheet-main-footer { + grid-column: 1; + grid-row: 3; + grid-template-columns: 1fr; + } +} + +@media (max-width: 860px) { + .topbar, + main { + width: min(100% - 20px, 720px); + } + + .topbar { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .topbar-actions { + justify-content: space-between; + } + + .controls { + grid-template-columns: 1fr; + } + + .filter-group, + .theme-switcher, + .preview-toolbar { + width: 100%; + justify-content: stretch; + } + + .filter-group .chip, + .theme-switcher .chip { + flex: 1 1 auto; + justify-content: center; + } + + .detail-sheet { + top: auto; + bottom: 0; + width: 100%; + max-height: calc(100dvh - 8px); + border-bottom: 0; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + transform: translate(-50%, calc(100% + 18px)); + } + + .detail-sheet.open { + transform: translate(-50%, 0); + } +} + +@media (max-width: 560px) { + .gallery { + grid-template-columns: 1fr; + } + + h1 { + font-size: 22px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b0d12; + --bg-tint: #0e1117; + --surface: #14171e; + --surface-2: #1a1e27; + --ink-1: #f1f2f4; + --ink-2: #c6cbd4; + --ink-3: #8a93a1; + --ink-4: #5c6473; + --border: #242935; + --border-strong: #2f3544; + --accent: #e87a35; + --accent-ink: #0b0d12; + --accent-soft: color-mix(in oklab, var(--accent) 18%, transparent); + --accent-softer: color-mix(in oklab, var(--accent) 10%, transparent); + --accent-ring: color-mix(in oklab, var(--accent) 35%, transparent); + --tooltip-bg: #f5f5f4; + --tooltip-ink: #0f1115; + --tooltip-border: #d6d4cd; + --paper-wash-start: rgb(255 255 255 / .025); + --paper-wash-end: rgb(0 0 0 / .15); + --grain-opacity: .13; + --grain-tile: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.1'/%3E%3C/svg%3E"); + --ring: 0 0 0 1px color-mix(in oklab, #fff 6%, transparent); + --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%); + --shadow-md: 0 10px 30px -10px rgb(0 0 0 / 50%), 0 4px 10px -4px rgb(0 0 0 / 30%); + --shadow-lg: 0 30px 70px -20px rgb(0 0 0 / 70%), 0 14px 30px -12px rgb(0 0 0 / 40%); + } + + body::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E"); + } +} + +@keyframes card-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes card-rise-in { + from { + opacity: 0; + transform: translateY(12px) scale(.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.card { + animation: card-fade-in 140ms ease-out both; +} + +.gallery.is-first-render .card { + animation: card-rise-in 420ms cubic-bezier(.22, .8, .28, 1) both; + animation-delay: var(--enter-delay, 0ms); +} + +@keyframes sheet-panel-reveal { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes sheet-pill-pop { + from { + opacity: 0; + transform: scale(.7) translateY(6px); + } + 60% { + transform: scale(1.06) translateY(0); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes sheet-title-rise { + from { + opacity: 0; + transform: translateY(10px); + letter-spacing: .04em; + } + to { + opacity: 1; + transform: translateY(0); + letter-spacing: -.02em; + } +} + +.detail-sheet.open .sheet-glass-panel { + animation: sheet-panel-reveal 380ms cubic-bezier(.22, .8, .28, 1) 460ms both; + will-change: opacity; +} + +.detail-sheet.open .sheet-main-header h2 { + animation: sheet-title-rise 520ms cubic-bezier(.22, .6, .25, 1) 280ms both; + will-change: transform, opacity; +} + +.detail-sheet.open .sheet-title-meta .meta-pill { + animation: sheet-pill-pop 260ms cubic-bezier(.22, .8, .28, 1) both; + will-change: transform, opacity; +} + +.detail-sheet.open .sheet-title-meta .meta-pill:nth-child(1) { + animation-delay: 480ms; +} + +.detail-sheet.open .sheet-title-meta .meta-pill:nth-child(2) { + animation-delay: 560ms; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 1ms !important; + animation-duration: 1ms !important; + } +} + +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 100ms; + animation-timing-function: ease-out; +} + +::view-transition-group(scheme-shared) { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(.22, .8, .28, 1); +} + +::view-transition-old(scheme-shared), +::view-transition-new(scheme-shared) { + animation-duration: 150ms; + animation-timing-function: cubic-bezier(.22, .8, .28, 1); +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #f7f7f5; + --bg-tint: #fdfcfa; + --surface: #ffffff; + --surface-2: #fafaf8; + --ink-1: #0f1115; + --ink-2: #2a2f38; + --ink-3: #6b7280; + --ink-4: #9aa2ad; + --border: #e6e5e0; + --border-strong: #d6d4cd; + --accent: #c95c17; + --accent-ink: #ffffff; + --accent-soft: color-mix(in oklab, var(--accent) 12%, transparent); + --accent-softer: color-mix(in oklab, var(--accent) 7%, transparent); + --accent-ring: color-mix(in oklab, var(--accent) 28%, transparent); + --tooltip-bg: #0f1115; + --tooltip-ink: #f5f5f4; + --tooltip-border: #2a2f38; + --paper-wash-start: rgb(255 255 255 / .45); + --paper-wash-end: rgb(18 20 27 / .02); + --grain-opacity: .12; + --ring: 0 0 0 1px color-mix(in oklab, var(--ink-1) 7%, transparent); + --shadow-sm: 0 1px 2px rgb(18 20 27 / 5%); + --shadow-md: 0 6px 18px -8px rgb(18 20 27 / 12%), 0 2px 6px -2px rgb(18 20 27 / 6%); + --shadow-lg: 0 24px 60px -20px rgb(18 20 27 / 25%), 0 10px 24px -12px rgb(18 20 27 / 12%); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0b0d12; + --bg-tint: #0e1117; + --surface: #14171e; + --surface-2: #1a1e27; + --ink-1: #f1f2f4; + --ink-2: #c6cbd4; + --ink-3: #8a93a1; + --ink-4: #5c6473; + --border: #242935; + --border-strong: #2f3544; + --accent: #e87a35; + --accent-ink: #0b0d12; + --accent-soft: color-mix(in oklab, var(--accent) 18%, transparent); + --accent-softer: color-mix(in oklab, var(--accent) 10%, transparent); + --accent-ring: color-mix(in oklab, var(--accent) 35%, transparent); + --tooltip-bg: #f5f5f4; + --tooltip-ink: #0f1115; + --tooltip-border: #d6d4cd; + --paper-wash-start: rgb(255 255 255 / .025); + --paper-wash-end: rgb(0 0 0 / .15); + --grain-opacity: .13; + --grain-tile: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.1'/%3E%3C/svg%3E"); + --ring: 0 0 0 1px color-mix(in oklab, #fff 6%, transparent); + --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%); + --shadow-md: 0 10px 30px -10px rgb(0 0 0 / 50%), 0 4px 10px -4px rgb(0 0 0 / 30%); + --shadow-lg: 0 30px 70px -20px rgb(0 0 0 / 70%), 0 14px 30px -12px rgb(0 0 0 / 40%); +} + +:root[data-theme="dark"] body::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E"); +} diff --git a/src/operations/gallery/gallery.js b/src/operations/gallery/gallery.js new file mode 100644 index 0000000..80bcf1e --- /dev/null +++ b/src/operations/gallery/gallery.js @@ -0,0 +1,643 @@ +const SCHEMES = __TINTY_SCHEMES__; + +const state = { + search: "", + system: "all", + appearance: "all", + pageTheme: "system", + language: "rust", +}; +let currentSheetId = null; +let tooltipTimeoutId = null; +let isFirstRender = true; +const PAGE_THEME_STORAGE_KEY = "tinty-gallery-page-theme"; +const LANGUAGE_STORAGE_KEY = "tinty-gallery-preview-language"; + +const fallbackPalette = { + base00: "#101418", + base03: "#5f6b76", + base05: "#d8dee9", + base08: "#d35f5f", + base09: "#d08f4f", + base0A: "#c6a84f", + base0B: "#72a65a", + base0C: "#5aa6a6", + base0D: "#5f8fd3", + base0E: "#9f7ad3", +}; + +SCHEMES.sort((a, b) => a.id.localeCompare(b.id)); + +const previewSnippets = { + rust: `use tinty::{Scheme, Theme}; + +// load and apply a color scheme +fn apply(name: &str) -> Option<Theme> { + let scheme = Scheme::load(name)?; + let theme = scheme.with_base(16).build(); + theme.apply(); + println!("applied: {}", theme.name()); + Some(theme) +}`, + kotlin: `import tinty.Scheme + +// load and apply a color scheme +fun apply(name: String) = runCatching { + val theme = Scheme.load(name) + .withBase(16) + .build() + theme.apply() + println("applied: \${theme.name}") +}`, + lisp:`;; load and apply a color scheme +(defpackage :tinty (:use :cl)) + +(defun apply-scheme (name) + (let* ((scheme (scheme:load name)) + (theme (scheme:build scheme :base 16))) + (theme:apply theme) + (format t "applied: ~a~%" + (theme:name theme))))`, + elixir:`defmodule Tinty do + # load and apply a color scheme + def apply(name) do + {:ok, theme} = + name + |> Scheme.load() + |> Theme.build(base: 16) + IO.puts("applied: #{theme.name}") + theme + end +end`, + diff: `diff --git a/apply.rs b/apply.rs +--- a/apply.rs+++ b/apply.rs@@ -3,7 +3,9 @@ use tinty; + +-fn apply(name: &str) { +- let colors = 8;+fn apply(name: &str) -> Theme { ++ let colors = 16; ++ println!("applying: {name}"); scheme.apply(colors); + }`, + haskell: `import Tinty (Scheme, Theme) + +-- load and apply a color scheme +apply :: String -> IO () +apply name = do + scheme <- loadScheme name + let theme = buildWith scheme 16 + applyTheme theme + putStrLn ("applied: " ++ themeName theme)`, + terminal: `user@host ~/dev/scheme main$ ls -F +Cargo.toml README.md current@ drafts/ scripts/ src/ + +user@host ~/dev/scheme main$ tree -L 2 src/ +src/ +├── lib.rs +├── scheme/ +│ ├── base16.rs +│ └── tinted8.rs +└── templates/ + ├── alacritty.tpl + └── kitty.tpl + +user@host ~/dev/scheme main$ grep -rn "TODO" src/ +src/scheme/tinted8.rs:42: // TODO: validate non-standard variants`, +}; + +function color(scheme, key) { + return scheme.palette[key]?.hex_str || fallbackPalette[key] || fallbackPalette.base05; +} + +const PREVIEW_ROLE_KEYS = { + base16: { + bg: "base00", + fg: "base05", + muted: "base04", + comment: "base03", + keyword: "base0E", + function: "base0D", + string: "base0B", + number: "base09", + deleted: "base08", + added: "base0B", + type: "base0A", + builtin: "base0D", + parameter: "base0C", + "ansi-black": "base00", + "ansi-red": "base08", + "ansi-green": "base0B", + "ansi-yellow": "base0A", + "ansi-blue": "base0D", + "ansi-magenta": "base0E", + "ansi-cyan": "base0C", + "ansi-white": "base05", + "ansi-bright-black": "base03", + "ansi-bright-red": "base08", + "ansi-bright-green": "base0B", + "ansi-bright-yellow": "base0A", + "ansi-bright-blue": "base0D", + "ansi-bright-magenta": "base0E", + "ansi-bright-cyan": "base0C", + "ansi-bright-white": "base07", + }, + base24: { + "ansi-bright-red": "base12", + "ansi-bright-yellow": "base13", + "ansi-bright-green": "base14", + "ansi-bright-cyan": "base15", + "ansi-bright-blue": "base16", + "ansi-bright-magenta": "base17", + }, + tinted8: { + dark: { + bg: "black-normal", + fg: "white-normal", + muted: "white-dim", + }, + light: { + bg: "white-normal", + fg: "black-normal", + muted: "black-dim", + }, + shared: { + comment: "gray-dim", + keyword: "magenta-normal", + function: "blue-normal", + string: "green-normal", + number: "orange-normal", + deleted: "red-bright", + added: "green-bright", + type: "yellow-normal", + builtin: "blue-bright", + parameter: "cyan-bright", + "ansi-black": "black-normal", + "ansi-red": "red-normal", + "ansi-green": "green-normal", + "ansi-yellow": "yellow-normal", + "ansi-blue": "blue-normal", + "ansi-magenta": "magenta-normal", + "ansi-cyan": "cyan-normal", + "ansi-white": "white-normal", + "ansi-bright-black": "black-bright", + "ansi-bright-red": "red-bright", + "ansi-bright-green": "green-bright", + "ansi-bright-yellow": "yellow-bright", + "ansi-bright-blue": "blue-bright", + "ansi-bright-magenta": "magenta-bright", + "ansi-bright-cyan": "cyan-bright", + "ansi-bright-white": "white-bright", + }, + }, +}; + +const PREVIEW_ROLES = [ + "bg", "fg", "muted", + "comment", "keyword", "function", "string", "number", + "deleted", "added", + "type", "builtin", "parameter", + "ansi-black", "ansi-red", "ansi-green", "ansi-yellow", + "ansi-blue", "ansi-magenta", "ansi-cyan", "ansi-white", + "ansi-bright-black", "ansi-bright-red", "ansi-bright-green", "ansi-bright-yellow", + "ansi-bright-blue", "ansi-bright-magenta", "ansi-bright-cyan", "ansi-bright-white", +]; + +function previewKey(scheme, role) { + const system = String(scheme.system).toLowerCase(); + if (system === "tinted8") { + const variant = String(scheme.variant || "").toLowerCase() === "light" ? "light" : "dark"; + const t8 = PREVIEW_ROLE_KEYS.tinted8; + return t8[variant][role] ?? t8.shared[role]; + } + if (system === "base24") { + return PREVIEW_ROLE_KEYS.base24[role] ?? PREVIEW_ROLE_KEYS.base16[role]; + } + return PREVIEW_ROLE_KEYS.base16[role]; +} + +function appearance(scheme) { + const background = scheme.lightness?.background; + if (typeof background !== "number") { + return String(scheme.variant || "unknown").toLowerCase(); + } + return background >= 50 ? "light" : "dark"; +} + +function searchableText(scheme) { + return [ + scheme.id, + scheme.name, + scheme.slug, + ].join(" ").toLowerCase(); +} + +function matchesFilters(scheme) { + if (state.system !== "all" && String(scheme.system).toLowerCase() !== state.system) { + return false; + } + + if (state.appearance !== "all" && appearance(scheme) !== state.appearance) { + return false; + } + + return searchableText(scheme).includes(state.search); +} + +function setPreviewColors(card, scheme) { + PREVIEW_ROLES.forEach((role) => { + card.style.setProperty(`--preview-${role}`, color(scheme, previewKey(scheme, role))); + }); +} + +function setPreviewLanguage(language) { + document.getElementById("sheet-code").innerHTML = previewSnippets[language] || previewSnippets.rust; + document + .querySelectorAll("[data-preview-language]") + .forEach((candidate) => candidate.classList.toggle("active", candidate.dataset.previewLanguage === language)); +} + +function setLanguage(lang) { + state.language = lang; + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, lang); + document.getElementById("language-select").value = lang; + setPreviewLanguage(lang); + document.querySelectorAll(".card .code-preview code").forEach((el) => { + el.innerHTML = previewSnippets[lang] || previewSnippets.rust; + }); +} + +function loadSavedLanguage() { + const saved = window.localStorage.getItem(LANGUAGE_STORAGE_KEY); + if (saved && previewSnippets[saved]) { + state.language = saved; + document.getElementById("language-select").value = saved; + } +} + +function metadataItem(label, value) { + const fragment = document.createDocumentFragment(); + const dt = document.createElement("dt"); + const dd = document.createElement("dd"); + dt.textContent = label; + dd.textContent = value || "n/a"; + fragment.append(dt, dd); + return fragment; +} + +function renderPalette(container, scheme) { + container.textContent = ""; + + Object.entries(scheme.palette) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([name, value]) => { + const swatch = document.createElement("div"); + const block = document.createElement("div"); + const label = document.createElement("div"); + const hex = document.createElement("span"); + + swatch.className = "swatch"; + block.className = "swatch-color"; + label.className = "swatch-label"; + block.style.background = value.hex_str; + label.textContent = name; + hex.textContent = value.hex_str; + + label.append(hex); + swatch.append(block, label); + container.append(swatch); + }); +} + +function transitionLayout(callback) { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + callback(); + return; + } + + if (document.startViewTransition) { + document.startViewTransition(callback); + return; + } + + callback(); +} + +function schemeForHash() { + const targetId = window.location.hash.replace(/^#/, ""); + if (!targetId) { + return null; + } + + return SCHEMES.find((candidate) => candidate.id === targetId) || null; +} + +function setSheetHash(id) { + const url = new URL(window.location.href); + url.hash = id; + window.history.replaceState(null, "", url); +} + +function clearSheetHash() { + const url = new URL(window.location.href); + url.hash = ""; + window.history.replaceState(null, "", url); +} + +function showButtonTooltip(button, message) { + button.dataset.tooltip = message; + button.classList.add("show-tooltip"); + + if (tooltipTimeoutId) { + window.clearTimeout(tooltipTimeoutId); + } + + tooltipTimeoutId = window.setTimeout(() => { + button.classList.remove("show-tooltip"); + }, 1100); +} + +const SHARED_TRANSITION_NAME = "scheme-shared"; +let originCard = null; + +function effectivePageTheme() { + if (state.pageTheme === "dark" || state.pageTheme === "light") { + return state.pageTheme; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function applySheetState(scheme, updateHash) { + const sheet = document.getElementById("detail-sheet"); + const backdrop = document.getElementById("sheet-backdrop"); + const command = `tinty apply ${scheme.id}`; + + currentSheetId = scheme.id; + document + .querySelectorAll(".card.is-sheet-source") + .forEach((c) => c.classList.remove("is-sheet-source")); + const matchingCard = document.querySelector(`.card[data-scheme-id="${CSS.escape(scheme.id)}"]`); + if (matchingCard) matchingCard.classList.add("is-sheet-source"); + + const schemeAppearance = appearance(scheme); + const themeAppearance = effectivePageTheme(); + sheet.dataset.contrastMismatch = + (schemeAppearance === "light" || schemeAppearance === "dark") && + schemeAppearance !== themeAppearance + ? "true" + : "false"; + setPreviewColors(sheet, scheme); + setPreviewLanguage(state.language); + document.getElementById("sheet-title").textContent = scheme.name; + document.querySelector("#sheet-system span").textContent = scheme.system; + document.querySelector("#sheet-appearance span").textContent = appearance(scheme); + document.getElementById("sheet-command").textContent = command; + document.getElementById("copy-command").dataset.command = command; + document.getElementById("copy-command").dataset.tooltip = "Copy command"; + + const metadata = document.getElementById("sheet-metadata"); + metadata.textContent = ""; + metadata.append( + metadataItem("ID", scheme.id), + metadataItem("Author", scheme.author), + metadataItem("System", scheme.system), + metadataItem("Variant", scheme.variant), + metadataItem("Appearance", appearance(scheme)), + metadataItem("Background L*", scheme.lightness?.background?.toFixed(2)), + metadataItem("Foreground L*", scheme.lightness?.foreground?.toFixed(2)), + ); + renderPalette(document.getElementById("sheet-palette"), scheme); + + if (updateHash) { + setSheetHash(scheme.id); + } + + backdrop.hidden = false; + document.body.classList.add("sheet-open"); + // Force layout flush so the opacity transition plays from the pre-`.open` state + // when no view transition is running. + void backdrop.offsetWidth; + backdrop.classList.add("open"); + sheet.classList.add("open"); + sheet.setAttribute("aria-hidden", "false"); +} + +function clearSheetState(updateHash) { + const sheet = document.getElementById("detail-sheet"); + const backdrop = document.getElementById("sheet-backdrop"); + + currentSheetId = null; + document + .querySelectorAll(".card.is-sheet-source") + .forEach((c) => c.classList.remove("is-sheet-source")); + if (updateHash) { + clearSheetHash(); + } + document.body.classList.remove("sheet-open"); + sheet.classList.remove("open"); + backdrop.classList.remove("open"); + sheet.setAttribute("aria-hidden", "true"); +} + +function openSheet(scheme, updateHash = true, sourceCard = null) { + const sheet = document.getElementById("detail-sheet"); + + if (sourceCard && document.startViewTransition) { + sourceCard.style.viewTransitionName = SHARED_TRANSITION_NAME; + const transition = document.startViewTransition(() => { + sourceCard.style.viewTransitionName = ""; + sheet.style.viewTransitionName = SHARED_TRANSITION_NAME; + applySheetState(scheme, updateHash); + }); + originCard = sourceCard; + transition.finished.finally(() => { + sheet.style.viewTransitionName = ""; + }); + return; + } + + originCard = sourceCard; + applySheetState(scheme, updateHash); +} + +function closeSheet(updateHash = true) { + const sheet = document.getElementById("detail-sheet"); + const backdrop = document.getElementById("sheet-backdrop"); + const card = originCard; + + if (card && document.body.contains(card) && document.startViewTransition) { + sheet.style.viewTransitionName = SHARED_TRANSITION_NAME; + const transition = document.startViewTransition(() => { + sheet.style.viewTransitionName = ""; + card.style.viewTransitionName = SHARED_TRANSITION_NAME; + clearSheetState(updateHash); + }); + transition.finished.finally(() => { + card.style.viewTransitionName = ""; + backdrop.hidden = true; + }); + originCard = null; + return; + } + + clearSheetState(updateHash); + originCard = null; + window.setTimeout(() => { + if (!sheet.classList.contains("open")) { + backdrop.hidden = true; + } + }, 220); +} + +function createCard(scheme) { + const template = document.getElementById("card-template"); + const card = template.content.firstElementChild.cloneNode(true); + + setPreviewColors(card, scheme); + card.dataset.schemeId = scheme.id; + card.querySelector("h2").textContent = scheme.name; + card.querySelector(".card-title p").textContent = scheme.id; + card.querySelector(".scheme-system span").textContent = scheme.system; + card.querySelector(".scheme-appearance span").textContent = appearance(scheme); + card.querySelector(".code-preview code").innerHTML = previewSnippets[state.language] || previewSnippets.rust; + + card.querySelector(".preview-button").addEventListener("click", () => { + openSheet(scheme, true, card); + }); + + if (scheme.id === currentSheetId) { + card.classList.add("is-sheet-source"); + } + + return card; +} + +function syncSheetToHash() { + const scheme = schemeForHash(); + if (!scheme) { + closeSheet(false); + return; + } + + if (currentSheetId !== scheme.id) { + openSheet(scheme, false); + } +} + +function render() { + const gallery = document.getElementById("gallery"); + const empty = document.getElementById("empty"); + const count = document.getElementById("result-count"); + const fragment = document.createDocumentFragment(); + const visible = SCHEMES.filter(matchesFilters); + + gallery.classList.toggle("is-first-render", isFirstRender); + gallery.textContent = ""; + visible.forEach((scheme) => fragment.append(createCard(scheme))); + gallery.append(fragment); + + if (isFirstRender) { + let rowIndex = -1; + let lastTop = null; + Array.from(gallery.children).forEach((card) => { + const top = card.getBoundingClientRect().top; + if (lastTop === null || Math.abs(top - lastTop) > 4) { + rowIndex++; + lastTop = top; + } + card.style.setProperty("--enter-delay", `${Math.min(rowIndex * 70, 700)}ms`); + }); + } + + empty.hidden = visible.length !== 0; + count.textContent = `${visible.length} of ${SCHEMES.length} schemes`; + + isFirstRender = false; +} + +function setFilter(group, value) { + state[group] = value; + document + .querySelectorAll(`[data-filter="${group}"]`) + .forEach((candidate) => candidate.classList.toggle("active", candidate.dataset.value === value)); +} + +function setPageTheme(theme) { + state.pageTheme = theme; + window.localStorage.setItem(PAGE_THEME_STORAGE_KEY, theme); + if (theme === "system") { + document.documentElement.removeAttribute("data-theme"); + } else { + document.documentElement.dataset.theme = theme; + } + + document + .querySelectorAll("[data-page-theme]") + .forEach((candidate) => candidate.classList.toggle("active", candidate.dataset.pageTheme === theme)); + + render(); +} + +function loadSavedPageTheme() { + const savedTheme = window.localStorage.getItem(PAGE_THEME_STORAGE_KEY); + const validThemes = new Set(["system", "dark", "light"]); + + if (savedTheme && validThemes.has(savedTheme)) { + setPageTheme(savedTheme); + return; + } + + setPageTheme("system"); +} + +document.getElementById("search").addEventListener("input", (event) => { + state.search = event.target.value.trim().toLowerCase(); + render(); +}); + +document.querySelectorAll("[data-filter]").forEach((button) => { + button.addEventListener("click", () => { + transitionLayout(() => { + setFilter(button.dataset.filter, button.dataset.value); + render(); + }); + }); +}); + +document.querySelectorAll("[data-page-theme]").forEach((button) => { + button.addEventListener("click", () => { + transitionLayout(() => setPageTheme(button.dataset.pageTheme)); + }); +}); + +document.getElementById("language-select").addEventListener("change", (event) => { + setLanguage(event.target.value); +}); + +document.querySelectorAll("[data-preview-language]").forEach((button) => { + button.addEventListener("click", () => { + setLanguage(button.dataset.previewLanguage); + }); +}); + +document.getElementById("sheet-close").addEventListener("click", closeSheet); +document.getElementById("sheet-backdrop").addEventListener("click", closeSheet); +document.getElementById("copy-command").addEventListener("click", async (event) => { + const button = event.currentTarget; + + try { + await navigator.clipboard.writeText(button.dataset.command); + showButtonTooltip(button, "Copied"); + } catch (_error) { + showButtonTooltip(button, "Copy failed"); + } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeSheet(); + } +}); + +window.addEventListener("hashchange", syncSheetToHash); + +loadSavedLanguage(); +loadSavedPageTheme(); +syncSheetToHash(); diff --git a/src/operations/gallery/index.html b/src/operations/gallery/index.html new file mode 100644 index 0000000..42ec659 --- /dev/null +++ b/src/operations/gallery/index.html @@ -0,0 +1,193 @@ + + + + + + Tinted Gallery + + + + +
+
+
+ +
+

Tinted Gallery

+ Browse & preview base16, base24, and tinted8 schemes +
+
+
+ +
+ + +
+
+ + + +
+
+
+ +
+
+ +
+ + + + +
+
+ + + +
+
+ + + +
+
+ + + + + + + + + + + diff --git a/src/operations/list.rs b/src/operations/list.rs index aef5bfc..ab1a280 100644 --- a/src/operations/list.rs +++ b/src/operations/list.rs @@ -21,31 +21,11 @@ use tinted_builder_rust::operation_build::utils::SchemeFile; /// Lists colorschemes file which is updated via scripts/install by getting a list of schemes /// available in pub fn list(data_path: &Path, is_custom: bool, is_json: bool) -> Result<()> { - let schemes_dir_path = if is_custom { - data_path.join(CUSTOM_SCHEMES_DIR_NAME) - } else { - data_path.join(format!("{REPO_DIR}/{SCHEMES_REPO_NAME}")) - }; - - match (schemes_dir_path.exists(), is_custom) { - (false, true) => { - return Err(anyhow!( - "You don't have any local custom schemes at: {}", - schemes_dir_path.display(), - )) - } - (false, false) => { - return Err(anyhow!( - "Schemes are missing, run install and then try again: `{REPO_NAME} install`", - )) - } - _ => {} - } + let schemes_dir_path = schemes_dir_path(data_path, is_custom)?; let stdout = io::stdout(); if is_json { - let scheme_files = get_all_scheme_file_paths(&schemes_dir_path, None)?; - let json = as_json(scheme_files)?; + let json = scheme_entries_json(&schemes_dir_path)?; let mut handle = stdout.lock(); let _ = writeln!(handle, "{json}"); return Ok(()); @@ -62,6 +42,32 @@ pub fn list(data_path: &Path, is_custom: bool, is_json: bool) -> Result<()> { Ok(()) } +pub fn schemes_dir_path(data_path: &Path, is_custom: bool) -> Result { + let schemes_dir_path = if is_custom { + data_path.join(CUSTOM_SCHEMES_DIR_NAME) + } else { + data_path.join(format!("{REPO_DIR}/{SCHEMES_REPO_NAME}")) + }; + + match (schemes_dir_path.exists(), is_custom) { + (false, true) => Err(anyhow!( + "You don't have any local custom schemes at: {}", + schemes_dir_path.display(), + )), + (false, false) => Err(anyhow!( + "Schemes are missing, run install and then try again: `{REPO_NAME} install`", + )), + _ => Ok(schemes_dir_path), + } +} + +pub fn scheme_entries_json(schemes_dir_path: &Path) -> Result { + let scheme_files = get_all_scheme_file_paths(schemes_dir_path, None)?; + let entries = scheme_entries(scheme_files)?; + + Ok(serde_json::to_string(&entries)?) +} + #[derive(Clone, Serialize)] pub struct SchemeEntry { id: String, @@ -275,7 +281,7 @@ impl Lightness { } } -fn as_json(scheme_files: HashMap) -> Result { +fn scheme_entries(scheme_files: HashMap) -> Result> { let mut keys: Vec = scheme_files.keys().cloned().collect(); // Create a thread-safe HashMap to collect results let mutex = Arc::new(Mutex::new(HashMap::new())); @@ -314,5 +320,5 @@ fn as_json(scheme_files: HashMap) -> Result { } } - Ok(serde_json::to_string(&*sorted_results)?) + Ok(sorted_results) } diff --git a/tests/cli_gallery_subcommand_tests.rs b/tests/cli_gallery_subcommand_tests.rs new file mode 100644 index 0000000..f675239 --- /dev/null +++ b/tests/cli_gallery_subcommand_tests.rs @@ -0,0 +1,141 @@ +mod utils; + +use anyhow::{ensure, Context, Result}; +use serde_json::Value; +use std::fs; +use utils::setup; + +#[test] +fn test_cli_gallery_subcommand_dump_creates_html_file() -> Result<()> { + // ------- + // Arrange + // ------- + let (_, data_path, mut command_vec, _temp_dir) = setup( + "test_cli_gallery_subcommand_dump_creates_html_file", + "gallery --custom-schemes --no-open", + )?; + let custom_base16_path = data_path.join("custom-schemes/base16"); + let dump_path = data_path.join("gallery-dump"); + + fs::create_dir_all(&custom_base16_path)?; + fs::copy( + "fixtures/tinty-city-dark.yaml", + custom_base16_path.join("tinty-city-dark.yaml"), + )?; + + command_vec.push("--dump".to_string()); + command_vec.push(dump_path.display().to_string()); + + // --- + // Act + // --- + let (_, stderr) = utils::run_command(&command_vec, &data_path, false)?; + + // ------ + // Assert + // ------ + ensure!( + stderr.is_empty(), + "Expected stderr to be empty, got: {stderr}" + ); + ensure!( + dump_path.join("index.html").is_file(), + "Expected gallery dump to produce index.html" + ); + + Ok(()) +} + +#[test] +fn test_cli_gallery_subcommand_embeds_complete_scheme_json() -> Result<()> { + // ------- + // Arrange + // ------- + let (config_path, data_path, mut command_vec, _temp_dir) = setup( + "test_cli_gallery_subcommand_embeds_complete_scheme_json", + "gallery --custom-schemes --no-open", + )?; + let custom_base16_path = data_path.join("custom-schemes/base16"); + let dump_path = data_path.join("gallery-dump"); + + fs::create_dir_all(&custom_base16_path)?; + fs::copy( + "fixtures/tinty-city-dark.yaml", + custom_base16_path.join("tinty-city-dark.yaml"), + )?; + fs::copy( + "tests/fixtures/schemes/tinty-generated.yaml", + custom_base16_path.join("tinty-generated.yaml"), + )?; + + command_vec.push("--dump".to_string()); + command_vec.push(dump_path.display().to_string()); + + // --- + // Act + // --- + let (_, gallery_stderr) = utils::run_command(&command_vec, &data_path, false)?; + let list_command_vec = + utils::build_command_vec("list --custom-schemes --json", &config_path, &data_path)?; + let (list_stdout, list_stderr) = utils::run_command(&list_command_vec, &data_path, false)?; + + // ------ + // Assert + // ------ + ensure!( + gallery_stderr.is_empty(), + "Expected gallery stderr to be empty, got: {gallery_stderr}" + ); + ensure!( + list_stderr.is_empty(), + "Expected list stderr to be empty, got: {list_stderr}" + ); + + let gallery_js = fs::read_to_string(dump_path.join("assets/gallery.js"))?; + let gallery_json = embedded_schemes_json(&gallery_js)?; + let gallery_schemes: Vec = serde_json::from_str(gallery_json)?; + let list_schemes: Vec = serde_json::from_str(&list_stdout)?; + + ensure!( + scheme_ids(&gallery_schemes)? == scheme_ids(&list_schemes)?, + "Expected gallery scheme ids to match list --json scheme ids" + ); + ensure!( + gallery_schemes.iter().all(scheme_has_gallery_data), + "Expected every embedded scheme to include palette and lightness data" + ); + + Ok(()) +} + +fn embedded_schemes_json(gallery_js: &str) -> Result<&str> { + let value = gallery_js + .strip_prefix("const SCHEMES = ") + .context("gallery.js did not start with embedded scheme data")?; + let (json, _) = value + .split_once(";\n\nconst state =") + .context("gallery.js did not contain the expected scheme data delimiter")?; + + Ok(json) +} + +fn scheme_ids(schemes: &[Value]) -> Result> { + schemes + .iter() + .map(|scheme| { + scheme + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .context("scheme entry did not include an id") + }) + .collect() +} + +fn scheme_has_gallery_data(scheme: &Value) -> bool { + scheme + .get("palette") + .and_then(Value::as_object) + .is_some_and(|palette| !palette.is_empty()) + && scheme.get("lightness").is_some_and(Value::is_object) +}