From ac3eb98c7c634cbf2ca8c3117a193c7ef9f6f93f Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Thu, 23 Apr 2026 23:53:44 -0700 Subject: [PATCH 01/27] refactor(list): expose reusable scheme json generation --- src/operations/list.rs | 54 +++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 24 deletions(-) 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) } From 83bfd23cdd612471f49dc81f7b18898a7f6e7dc9 Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Thu, 23 Apr 2026 23:54:05 -0700 Subject: [PATCH 02/27] feat(gallery): add static gallery command --- src/cli.rs | 24 ++ src/main.rs | 12 + src/operations/gallery.rs | 796 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 832 insertions(+) create mode 100644 src/operations/gallery.rs 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..2ca7583 --- /dev/null +++ b/src/operations/gallery.rs @@ -0,0 +1,796 @@ +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::{ + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +const GALLERY_DIR_NAME: &str = "gallery"; + +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"); + + ensure_directory_exists(output_dir)?; + ensure_directory_exists(&assets_dir)?; + + write_to_file(output_dir.join("index.html"), INDEX_HTML)?; + write_to_file(assets_dir.join("gallery.css"), GALLERY_CSS)?; + let gallery_js = format!("const SCHEMES = {schemes_json};\n\n{GALLERY_JS}"); + write_to_file(assets_dir.join("gallery.js"), &gallery_js)?; + + 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 +} + +const INDEX_HTML: &str = r#" + + + + + Tinty Gallery + + + +
+
+

Tinty Gallery

+
+
+
+ + + +
+ +
+
+ +
+
+ +
+ + + + +
+
+ + + +
+
+ + + +
+ + + + + + +"#; + +const GALLERY_CSS: &str = r#":root { + color-scheme: light dark; + --page: #f5f6f8; + --ink: #1f2933; + --muted: #64717f; + --border: #d9dee5; + --panel: #ffffff; + --accent: #1b6fd8; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--page); + color: var(--ink); + font: 15px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; +} + +.topbar, +main { + width: min(1440px, calc(100% - 32px)); + margin: 0 auto; +} + +.topbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + padding: 30px 0 20px; +} + +.topbar-actions { + display: flex; + align-items: end; + flex-direction: column; + gap: 10px; +} + +.theme-switcher { + display: flex; + gap: 8px; +} + +.icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} + +.chip, +.search span, +.meta-pill { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.card-title p, +.metadata, +#result-count { + color: var(--muted); +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: clamp(32px, 4vw, 56px); + font-weight: 760; + line-height: .95; + letter-spacing: 0; +} + +.controls { + display: grid; + grid-template-columns: minmax(240px, 1fr) auto auto; + gap: 12px; + align-items: end; + padding: 16px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.search span { + margin-bottom: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.search input { + width: 100%; + min-height: 40px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + background: transparent; + color: inherit; + font: inherit; +} + +.filter-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + min-height: 40px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 11px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; +} + +.chip:hover { + transform: translateY(-1px); +} + +.chip.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} + +.gallery { + column-count: 4; + column-gap: 16px; + padding: 20px 0 40px; +} + +.card { + display: inline-block; + width: 100%; + margin: 0 0 16px; + break-inside: avoid; + page-break-inside: avoid; + overflow: hidden; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; + view-transition-name: match-element; +} + +.card:hover, +.card.expanded { + border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); + box-shadow: 0 12px 30px rgb(0 0 0 / 10%); +} + +.preview-button { + display: block; + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.card-header { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + color: var(--preview-muted); + background: var(--preview-bg); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.code-preview { + min-height: 188px; + margin: 0; + padding: 16px; + overflow: auto; + background: var(--preview-bg); + color: var(--preview-fg); + font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.comment { + color: var(--preview-comment); +} + +.keyword { + color: var(--preview-keyword); +} + +.function { + color: var(--preview-function); +} + +.string { + color: var(--preview-string); +} + +.number { + color: var(--preview-number); +} + +.card-title { + padding: 14px 16px 16px; +} + +.card-title h2 { + overflow-wrap: anywhere; + font-size: 16px; + font-weight: 720; + line-height: 1.2; + letter-spacing: 0; +} + +.details { + max-height: 0; + overflow: hidden; + border-top: 0 solid transparent; + padding: 0 16px; + opacity: 0; + transition: max-height 190ms cubic-bezier(.2, .8, .2, 1), opacity 120ms ease, padding 190ms cubic-bezier(.2, .8, .2, 1), border-color 160ms ease; +} + +.card.expanded .details { + border-top-width: 1px; + border-top-color: var(--border); + padding: 14px 16px 16px; + opacity: 1; +} + +.metadata { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 14px; + font-size: 13px; +} + +.metadata dd { + margin: 0; + color: var(--ink); + overflow-wrap: anywhere; +} + +.palette { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); + gap: 8px; +} + +.swatch { + min-width: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--panel); +} + +.swatch-color { + height: 34px; +} + +.swatch-label { + padding: 6px 7px; + font: 12px/1.3 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.swatch-label span { + display: block; + overflow-wrap: anywhere; + color: var(--muted); +} + +.empty { + padding: 40px 0; + color: var(--muted); + text-align: center; +} + +@media (max-width: 860px) { + .topbar, + main { + width: min(100% - 20px, 720px); + } + + .topbar { + align-items: start; + flex-direction: column; + } + + .topbar-actions { + align-items: start; + } + + .controls { + grid-template-columns: 1fr; + } + + .gallery { + column-count: 2; + } +} + +@media (max-width: 560px) { + .gallery { + column-count: 1; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --page: #161a1f; + --ink: #e7ebef; + --muted: #9ca8b4; + --border: #303842; + --panel: #1e242b; + --accent: #7db4ff; + } +} + +@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: 160ms; + animation-timing-function: cubic-bezier(.2, .8, .2, 1); +} + +:root[data-theme="light"] { + color-scheme: light; + --page: #f5f6f8; + --ink: #1f2933; + --muted: #64717f; + --border: #d9dee5; + --panel: #ffffff; + --accent: #1b6fd8; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --page: #161a1f; + --ink: #e7ebef; + --muted: #9ca8b4; + --border: #303842; + --panel: #1e242b; + --accent: #7db4ff; +} +"#; + +const GALLERY_JS: &str = r##"const state = { + search: "", + system: "all", + appearance: "all", + pageTheme: "system", +}; + +const fallbackPalette = { + base00: "#101418", + base03: "#5f6b76", + base05: "#d8dee9", + base08: "#d35f5f", + base09: "#d08f4f", + base0A: "#c6a84f", + base0B: "#72a65a", + base0C: "#5aa6a6", + base0D: "#5f8fd3", + base0E: "#9f7ad3", +}; + +function color(scheme, key) { + return scheme.palette[key]?.hex_str || fallbackPalette[key] || fallbackPalette.base05; +} + +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, + scheme.author, + scheme.system, + scheme.variant, + appearance(scheme), + ].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) { + card.style.setProperty("--preview-bg", color(scheme, "base00")); + card.style.setProperty("--preview-fg", color(scheme, "base05")); + card.style.setProperty("--preview-muted", color(scheme, "base04")); + card.style.setProperty("--preview-comment", color(scheme, "base03")); + card.style.setProperty("--preview-keyword", color(scheme, "base0E")); + card.style.setProperty("--preview-function", color(scheme, "base0D")); + card.style.setProperty("--preview-string", color(scheme, "base0B")); + card.style.setProperty("--preview-number", color(scheme, "base09")); +} + +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 setExpanded(card, details, expanded) { + card.classList.toggle("expanded", expanded); + details.setAttribute("aria-hidden", String(!expanded)); + details.style.maxHeight = expanded ? `${details.scrollHeight}px` : "0px"; +} + +function createCard(scheme) { + const template = document.getElementById("card-template"); + const card = template.content.firstElementChild.cloneNode(true); + const details = card.querySelector(".details"); + const metadata = card.querySelector(".metadata"); + + setPreviewColors(card, scheme); + 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); + + metadata.append( + metadataItem("ID", scheme.id), + metadataItem("Author", scheme.author), + metadataItem("System", scheme.system), + metadataItem("Variant", scheme.variant), + metadataItem("Background L*", scheme.lightness?.background?.toFixed(2)), + metadataItem("Foreground L*", scheme.lightness?.foreground?.toFixed(2)), + ); + renderPalette(card.querySelector(".palette"), scheme); + + card.querySelector(".preview-button").addEventListener("click", () => { + const expanded = !card.classList.contains("expanded"); + transitionLayout(() => setExpanded(card, details, expanded)); + }); + + return card; +} + +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.textContent = ""; + visible.forEach((scheme) => fragment.append(createCard(scheme))); + gallery.append(fragment); + + empty.hidden = visible.length !== 0; + count.textContent = `${visible.length} of ${SCHEMES.length} schemes`; +} + +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; + document.documentElement.dataset.theme = theme === "system" ? "" : theme; + if (theme === "system") { + document.documentElement.removeAttribute("data-theme"); + setFilter("appearance", "all"); + } else { + setFilter("appearance", theme); + } + + document + .querySelectorAll("[data-page-theme]") + .forEach((candidate) => candidate.classList.toggle("active", candidate.dataset.pageTheme === theme)); + + render(); +} + +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)); + }); +}); + +render(); +"##; From 132325fae298684b0c4f0e14591100bc5a26ce18 Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Thu, 23 Apr 2026 23:54:11 -0700 Subject: [PATCH 03/27] docs(gallery): document gallery command --- README.md | 5 ++++- USAGE.md | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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 From 470638c829bca6e60be500a4b98b7cfd91fdce73 Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Thu, 23 Apr 2026 23:54:19 -0700 Subject: [PATCH 04/27] test(gallery): cover static artifact generation --- tests/cli_gallery_subcommand_tests.rs | 141 ++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tests/cli_gallery_subcommand_tests.rs 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) +} From 961e9d6a08213fbfd4d58f2722e42aeec3859fdd Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Fri, 24 Apr 2026 13:31:53 -0700 Subject: [PATCH 05/27] feat(gallery): refine branded theme preview sheet --- assets/favicon.png | Bin 0 -> 2725 bytes assets/tinted-theming-logo.png | Bin 0 -> 27301 bytes src/operations/gallery.rs | 446 ++++++++++++++++++++++++++++----- 3 files changed, 383 insertions(+), 63 deletions(-) create mode 100644 assets/favicon.png create mode 100644 assets/tinted-theming-logo.png diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..908a736177882020f534b73606bc1ddb4591b362 GIT binary patch literal 2725 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 Vuoq{wZ?gW|Obsm!s`W0${s&!_uv`ED literal 0 HcmV?d00001 diff --git a/assets/tinted-theming-logo.png b/assets/tinted-theming-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c8d0d0e3b3baa033c91ecf99e7fdcf76146df677 GIT binary patch literal 27301 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 BWPbnv literal 0 HcmV?d00001 diff --git a/src/operations/gallery.rs b/src/operations/gallery.rs index 2ca7583..ec04515 100644 --- a/src/operations/gallery.rs +++ b/src/operations/gallery.rs @@ -5,11 +5,15 @@ use crate::{ }; use anyhow::{Context, Result}; use std::{ + fs::File, + io::Write, path::{Path, PathBuf}, process::{Command, Stdio}, }; const GALLERY_DIR_NAME: &str = "gallery"; +const LOGO_BYTES: &[u8] = include_bytes!("../../assets/tinted-theming-logo.png"); +const FAVICON_BYTES: &[u8] = include_bytes!("../../assets/favicon.png"); pub fn gallery( data_path: &Path, @@ -46,6 +50,18 @@ fn write_gallery_files(output_dir: &Path, schemes_json: &str) -> Result<()> { write_to_file(assets_dir.join("gallery.css"), GALLERY_CSS)?; let gallery_js = format!("const SCHEMES = {schemes_json};\n\n{GALLERY_JS}"); 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)?; + + 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(()) } @@ -99,11 +115,13 @@ const INDEX_HTML: &str = r#" Tinty Gallery +
-
+
+

Tinty Gallery

@@ -169,6 +187,51 @@ const INDEX_HTML: &str = r#" + + + @@ -241,6 +300,19 @@ main { padding: 30px 0 20px; } +.brand { + display: flex; + align-items: center; + gap: 14px; +} + +.brand-logo { + width: clamp(42px, 5vw, 68px); + height: clamp(42px, 5vw, 68px); + object-fit: contain; + flex: 0 0 auto; +} + .topbar-actions { display: flex; align-items: end; @@ -248,11 +320,6 @@ main { gap: 10px; } -.theme-switcher { - display: flex; - gap: 8px; -} - .icon { width: 16px; height: 16px; @@ -266,7 +333,11 @@ main { .chip, .search span, -.meta-pill { +.meta-pill, +.icon-button, +.command-row, +.sheet-title-group, +.section-label { display: inline-flex; align-items: center; gap: 7px; @@ -320,16 +391,24 @@ h1 { font: inherit; } -.filter-group { +.filter-group, +.theme-switcher, +.preview-toolbar { display: flex; flex-wrap: wrap; - gap: 8px; + overflow: hidden; + width: fit-content; + max-width: 100%; + border: 1px solid var(--border); + border-radius: 7px; + background: color-mix(in srgb, var(--panel) 88%, var(--ink)); } .chip { min-height: 40px; - border: 1px solid var(--border); - border-radius: 6px; + border: 0; + border-left: 1px solid var(--border); + border-radius: 0; padding: 8px 11px; background: transparent; color: inherit; @@ -338,28 +417,27 @@ h1 { transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; } +.chip:first-child { + border-left: 0; +} + .chip:hover { - transform: translateY(-1px); + background: color-mix(in srgb, var(--accent) 8%, transparent); } .chip.active { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, transparent); + background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); } .gallery { - column-count: 4; - column-gap: 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; padding: 20px 0 40px; } .card { - display: inline-block; - width: 100%; - margin: 0 0 16px; - break-inside: avoid; - page-break-inside: avoid; overflow: hidden; background: var(--panel); border: 1px solid var(--border); @@ -369,7 +447,7 @@ h1 { } .card:hover, -.card.expanded { +.card:focus-within { border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); box-shadow: 0 12px 30px rgb(0 0 0 / 10%); } @@ -439,22 +517,6 @@ h1 { letter-spacing: 0; } -.details { - max-height: 0; - overflow: hidden; - border-top: 0 solid transparent; - padding: 0 16px; - opacity: 0; - transition: max-height 190ms cubic-bezier(.2, .8, .2, 1), opacity 120ms ease, padding 190ms cubic-bezier(.2, .8, .2, 1), border-color 160ms ease; -} - -.card.expanded .details { - border-top-width: 1px; - border-top-color: var(--border); - padding: 14px 16px 16px; - opacity: 1; -} - .metadata { display: grid; grid-template-columns: max-content 1fr; @@ -463,6 +525,11 @@ h1 { font-size: 13px; } +.metadata dt { + color: var(--ink); + font-weight: 760; +} + .metadata dd { margin: 0; color: var(--ink); @@ -504,6 +571,138 @@ h1 { text-align: center; } +.sheet-backdrop { + position: fixed; + inset: 0; + z-index: 20; + background: rgb(0 0 0 / 26%); + backdrop-filter: blur(8px) saturate(110%); + opacity: 0; + transition: opacity 170ms ease; +} + +.sheet-backdrop.open { + opacity: 1; +} + +@supports not (backdrop-filter: blur(1px)) { + .sheet-backdrop { + background: rgb(0 0 0 / 42%); + } +} + +.detail-sheet { + position: fixed; + left: 50%; + bottom: 0; + z-index: 21; + width: min(920px, calc(100% - 24px)); + max-height: calc(100vh - 18px); + overflow: auto; + padding: 10px 20px 22px; + background: var(--panel); + border: 1px solid var(--border); + border-bottom: 0; + border-radius: 12px 12px 0 0; + box-shadow: 0 -18px 48px rgb(0 0 0 / 20%); + transform: translate(-50%, calc(100% + 18px)); + transition: transform 220ms cubic-bezier(.2, .8, .2, 1); +} + +.detail-sheet.open { + transform: translate(-50%, 0); +} + +.sheet-handle { + width: 48px; + height: 4px; + margin: 0 auto 14px; + border-radius: 999px; + background: var(--border); +} + +.sheet-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.sheet-header h2 { + overflow-wrap: anywhere; + font-size: clamp(22px, 3vw, 34px); + font-weight: 760; + line-height: 1.05; +} + +.sheet-title-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex: 0 0 36px; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.icon-button { + min-width: 40px; + min-height: 40px; + justify-content: center; + border: 1px solid var(--border); + border-radius: 7px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.command-row { + justify-content: start; + width: 100%; + margin-bottom: 12px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 7px; + background: color-mix(in srgb, var(--panel) 86%, var(--ink)); +} + +.command-row code { + flex: 1 1 auto; + overflow: auto; + font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.command-row .chip { + min-height: 30px; + padding: 4px 9px; +} + +.sheet-preview { + margin-bottom: 10px; +} + +.section-label { + margin: 0 0 8px; + color: var(--muted); + font-size: 12px; + font-weight: 760; + text-transform: uppercase; +} + +.preview-toolbar { + margin-bottom: 0; + border-radius: 8px 8px 0 0; +} + +.sheet-code-preview { + min-height: 210px; + border-radius: 0 0 8px 8px; +} + @media (max-width: 860px) { .topbar, main { @@ -515,6 +714,10 @@ h1 { flex-direction: column; } + .brand { + gap: 10px; + } + .topbar-actions { align-items: start; } @@ -523,14 +726,31 @@ h1 { grid-template-columns: 1fr; } + .filter-group, + .theme-switcher, + .preview-toolbar { + width: 100%; + } + + .chip { + flex: 1 1 auto; + justify-content: center; + } + .gallery { - column-count: 2; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + } + + .detail-sheet { + width: 100%; + max-height: calc(100vh - 8px); + padding-inline: 14px; } } @media (max-width: 560px) { .gallery { - column-count: 1; + grid-template-columns: 1fr; } } @@ -602,6 +822,40 @@ const fallbackPalette = { base0E: "#9f7ad3", }; +SCHEMES.sort((a, b) => a.id.localeCompare(b.id)); + +const previewSnippets = { + rust: `// preview.rs +fn render_scheme() { + let name = "tinty"; + let colors = 16; + apply(name, colors); +}`, + kotlin: `// Preview.kt +fun renderScheme() { + val name = "tinty" + val colors = 16 + apply(name, colors) +}`, + javascript: `// preview.js +function renderScheme() { + const name = "tinty"; + const colors = 16; + apply(name, colors); +}`, + lisp: `;; preview.lisp +(defun render-scheme () + (let ((name "tinty") + (colors 16)) + (apply name colors)))`, + zsh: `# preview.zsh +function render_scheme() { + local name="tinty" + local colors=16 + apply "$name" "$colors" +}`, +}; + function color(scheme, key) { return scheme.palette[key]?.hex_str || fallbackPalette[key] || fallbackPalette.base05; } @@ -649,6 +903,13 @@ function setPreviewColors(card, scheme) { card.style.setProperty("--preview-number", color(scheme, "base09")); } +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 metadataItem(label, value) { const fragment = document.createDocumentFragment(); const dt = document.createElement("dt"); @@ -697,37 +958,64 @@ function transitionLayout(callback) { callback(); } -function setExpanded(card, details, expanded) { - card.classList.toggle("expanded", expanded); - details.setAttribute("aria-hidden", String(!expanded)); - details.style.maxHeight = expanded ? `${details.scrollHeight}px` : "0px"; -} - -function createCard(scheme) { - const template = document.getElementById("card-template"); - const card = template.content.firstElementChild.cloneNode(true); - const details = card.querySelector(".details"); - const metadata = card.querySelector(".metadata"); +function openSheet(scheme) { + const sheet = document.getElementById("detail-sheet"); + const backdrop = document.getElementById("sheet-backdrop"); + const command = `tinty apply ${scheme.id}`; - setPreviewColors(card, scheme); - 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); + setPreviewColors(sheet, scheme); + setPreviewLanguage("rust"); + document.getElementById("sheet-title").textContent = scheme.name; + document.getElementById("sheet-command").textContent = command; + document.getElementById("copy-command").dataset.command = 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(card.querySelector(".palette"), scheme); + renderPalette(document.getElementById("sheet-palette"), scheme); + + backdrop.hidden = false; + requestAnimationFrame(() => { + backdrop.classList.add("open"); + sheet.classList.add("open"); + sheet.setAttribute("aria-hidden", "false"); + }); +} + +function closeSheet() { + const sheet = document.getElementById("detail-sheet"); + const backdrop = document.getElementById("sheet-backdrop"); + + sheet.classList.remove("open"); + backdrop.classList.remove("open"); + sheet.setAttribute("aria-hidden", "true"); + 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.querySelector("h2").textContent = scheme.slug; + card.querySelector(".card-title p").textContent = scheme.name; + card.querySelector(".scheme-system span").textContent = scheme.system; + card.querySelector(".scheme-appearance span").textContent = appearance(scheme); card.querySelector(".preview-button").addEventListener("click", () => { - const expanded = !card.classList.contains("expanded"); - transitionLayout(() => setExpanded(card, details, expanded)); + openSheet(scheme); }); return card; @@ -792,5 +1080,37 @@ document.querySelectorAll("[data-page-theme]").forEach((button) => { }); }); +document.querySelectorAll("[data-preview-language]").forEach((button) => { + button.addEventListener("click", () => { + setPreviewLanguage(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; + const originalText = button.textContent; + + try { + await navigator.clipboard.writeText(button.dataset.command); + button.textContent = "Copied"; + window.setTimeout(() => { + button.textContent = originalText; + }, 1100); + } catch (_error) { + button.textContent = "Copy failed"; + window.setTimeout(() => { + button.textContent = originalText; + }, 1400); + } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeSheet(); + } +}); + render(); "##; From 7b6e06e1a8ef7eb75d4daca8dbf0f88bcb92b549 Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Fri, 24 Apr 2026 14:08:31 -0700 Subject: [PATCH 06/27] refactor(gallery): separate the assets into their own files --- src/operations/gallery.rs | 1011 +--------------------------- src/operations/gallery/gallery.css | 627 +++++++++++++++++ src/operations/gallery/gallery.js | 388 +++++++++++ src/operations/gallery/index.html | 163 +++++ 4 files changed, 1182 insertions(+), 1007 deletions(-) create mode 100644 src/operations/gallery/gallery.css create mode 100644 src/operations/gallery/gallery.js create mode 100644 src/operations/gallery/index.html diff --git a/src/operations/gallery.rs b/src/operations/gallery.rs index ec04515..2550a5e 100644 --- a/src/operations/gallery.rs +++ b/src/operations/gallery.rs @@ -12,6 +12,9 @@ use std::{ }; 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"); @@ -48,7 +51,7 @@ fn write_gallery_files(output_dir: &Path, schemes_json: &str) -> Result<()> { write_to_file(output_dir.join("index.html"), INDEX_HTML)?; write_to_file(assets_dir.join("gallery.css"), GALLERY_CSS)?; - let gallery_js = format!("const SCHEMES = {schemes_json};\n\n{GALLERY_JS}"); + 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)?; @@ -108,1009 +111,3 @@ fn browser_command(path: &Path) -> Command { command.arg(path); command } - -const INDEX_HTML: &str = r#" - - - - - Tinty Gallery - - - - -
-
- -

Tinty Gallery

-
-
-
- - - -
- -
-
- -
-
- -
- - - - -
-
- - - -
-
- - - -
- - - - - - - - - -"#; - -const GALLERY_CSS: &str = r#":root { - color-scheme: light dark; - --page: #f5f6f8; - --ink: #1f2933; - --muted: #64717f; - --border: #d9dee5; - --panel: #ffffff; - --accent: #1b6fd8; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--page); - color: var(--ink); - font: 15px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-feature-settings: "cv02", "cv03", "cv04", "cv11"; -} - -.topbar, -main { - width: min(1440px, calc(100% - 32px)); - margin: 0 auto; -} - -.topbar { - display: flex; - align-items: end; - justify-content: space-between; - gap: 24px; - padding: 30px 0 20px; -} - -.brand { - display: flex; - align-items: center; - gap: 14px; -} - -.brand-logo { - width: clamp(42px, 5vw, 68px); - height: clamp(42px, 5vw, 68px); - object-fit: contain; - flex: 0 0 auto; -} - -.topbar-actions { - display: flex; - align-items: end; - flex-direction: column; - gap: 10px; -} - -.icon { - width: 16px; - height: 16px; - flex: 0 0 16px; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - fill: none; -} - -.chip, -.search span, -.meta-pill, -.icon-button, -.command-row, -.sheet-title-group, -.section-label { - display: inline-flex; - align-items: center; - gap: 7px; -} - -.card-title p, -.metadata, -#result-count { - color: var(--muted); -} - -h1, -h2, -p { - margin: 0; -} - -h1 { - font-size: clamp(32px, 4vw, 56px); - font-weight: 760; - line-height: .95; - letter-spacing: 0; -} - -.controls { - display: grid; - grid-template-columns: minmax(240px, 1fr) auto auto; - gap: 12px; - align-items: end; - padding: 16px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; -} - -.search span { - margin-bottom: 6px; - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.search input { - width: 100%; - min-height: 40px; - border: 1px solid var(--border); - border-radius: 6px; - padding: 8px 10px; - background: transparent; - color: inherit; - font: inherit; -} - -.filter-group, -.theme-switcher, -.preview-toolbar { - display: flex; - flex-wrap: wrap; - overflow: hidden; - width: fit-content; - max-width: 100%; - border: 1px solid var(--border); - border-radius: 7px; - background: color-mix(in srgb, var(--panel) 88%, var(--ink)); -} - -.chip { - min-height: 40px; - border: 0; - border-left: 1px solid var(--border); - border-radius: 0; - padding: 8px 11px; - background: transparent; - color: inherit; - font: inherit; - cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; -} - -.chip:first-child { - border-left: 0; -} - -.chip:hover { - background: color-mix(in srgb, var(--accent) 8%, transparent); -} - -.chip.active { - background: color-mix(in srgb, var(--accent) 16%, transparent); - color: var(--accent); -} - -.gallery { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 16px; - padding: 20px 0 40px; -} - -.card { - overflow: hidden; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; - view-transition-name: match-element; -} - -.card:hover, -.card:focus-within { - border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - box-shadow: 0 12px 30px rgb(0 0 0 / 10%); -} - -.preview-button { - display: block; - width: 100%; - border: 0; - padding: 0; - background: transparent; - color: inherit; - text-align: left; - cursor: pointer; -} - -.card-header { - display: flex; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - color: var(--preview-muted); - background: var(--preview-bg); - font-size: 12px; - font-weight: 700; - text-transform: uppercase; -} - -.code-preview { - min-height: 188px; - margin: 0; - padding: 16px; - overflow: auto; - background: var(--preview-bg); - color: var(--preview-fg); - font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; -} - -.comment { - color: var(--preview-comment); -} - -.keyword { - color: var(--preview-keyword); -} - -.function { - color: var(--preview-function); -} - -.string { - color: var(--preview-string); -} - -.number { - color: var(--preview-number); -} - -.card-title { - padding: 14px 16px 16px; -} - -.card-title h2 { - overflow-wrap: anywhere; - font-size: 16px; - font-weight: 720; - line-height: 1.2; - letter-spacing: 0; -} - -.metadata { - display: grid; - grid-template-columns: max-content 1fr; - gap: 6px 12px; - margin: 0 0 14px; - font-size: 13px; -} - -.metadata dt { - color: var(--ink); - font-weight: 760; -} - -.metadata dd { - margin: 0; - color: var(--ink); - overflow-wrap: anywhere; -} - -.palette { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); - gap: 8px; -} - -.swatch { - min-width: 0; - border: 1px solid var(--border); - border-radius: 6px; - overflow: hidden; - background: var(--panel); -} - -.swatch-color { - height: 34px; -} - -.swatch-label { - padding: 6px 7px; - font: 12px/1.3 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; -} - -.swatch-label span { - display: block; - overflow-wrap: anywhere; - color: var(--muted); -} - -.empty { - padding: 40px 0; - color: var(--muted); - text-align: center; -} - -.sheet-backdrop { - position: fixed; - inset: 0; - z-index: 20; - background: rgb(0 0 0 / 26%); - backdrop-filter: blur(8px) saturate(110%); - opacity: 0; - transition: opacity 170ms ease; -} - -.sheet-backdrop.open { - opacity: 1; -} - -@supports not (backdrop-filter: blur(1px)) { - .sheet-backdrop { - background: rgb(0 0 0 / 42%); - } -} - -.detail-sheet { - position: fixed; - left: 50%; - bottom: 0; - z-index: 21; - width: min(920px, calc(100% - 24px)); - max-height: calc(100vh - 18px); - overflow: auto; - padding: 10px 20px 22px; - background: var(--panel); - border: 1px solid var(--border); - border-bottom: 0; - border-radius: 12px 12px 0 0; - box-shadow: 0 -18px 48px rgb(0 0 0 / 20%); - transform: translate(-50%, calc(100% + 18px)); - transition: transform 220ms cubic-bezier(.2, .8, .2, 1); -} - -.detail-sheet.open { - transform: translate(-50%, 0); -} - -.sheet-handle { - width: 48px; - height: 4px; - margin: 0 auto 14px; - border-radius: 999px; - background: var(--border); -} - -.sheet-header { - display: flex; - align-items: start; - justify-content: space-between; - gap: 16px; - margin-bottom: 12px; -} - -.sheet-header h2 { - overflow-wrap: anywhere; - font-size: clamp(22px, 3vw, 34px); - font-weight: 760; - line-height: 1.05; -} - -.sheet-title-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - flex: 0 0 36px; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--accent); - background: color-mix(in srgb, var(--accent) 10%, transparent); -} - -.icon-button { - min-width: 40px; - min-height: 40px; - justify-content: center; - border: 1px solid var(--border); - border-radius: 7px; - background: transparent; - color: inherit; - cursor: pointer; -} - -.command-row { - justify-content: start; - width: 100%; - margin-bottom: 12px; - padding: 6px 8px; - border: 1px solid var(--border); - border-radius: 7px; - background: color-mix(in srgb, var(--panel) 86%, var(--ink)); -} - -.command-row code { - flex: 1 1 auto; - overflow: auto; - font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; -} - -.command-row .chip { - min-height: 30px; - padding: 4px 9px; -} - -.sheet-preview { - margin-bottom: 10px; -} - -.section-label { - margin: 0 0 8px; - color: var(--muted); - font-size: 12px; - font-weight: 760; - text-transform: uppercase; -} - -.preview-toolbar { - margin-bottom: 0; - border-radius: 8px 8px 0 0; -} - -.sheet-code-preview { - min-height: 210px; - border-radius: 0 0 8px 8px; -} - -@media (max-width: 860px) { - .topbar, - main { - width: min(100% - 20px, 720px); - } - - .topbar { - align-items: start; - flex-direction: column; - } - - .brand { - gap: 10px; - } - - .topbar-actions { - align-items: start; - } - - .controls { - grid-template-columns: 1fr; - } - - .filter-group, - .theme-switcher, - .preview-toolbar { - width: 100%; - } - - .chip { - flex: 1 1 auto; - justify-content: center; - } - - .gallery { - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - } - - .detail-sheet { - width: 100%; - max-height: calc(100vh - 8px); - padding-inline: 14px; - } -} - -@media (max-width: 560px) { - .gallery { - grid-template-columns: 1fr; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --page: #161a1f; - --ink: #e7ebef; - --muted: #9ca8b4; - --border: #303842; - --panel: #1e242b; - --accent: #7db4ff; - } -} - -@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: 160ms; - animation-timing-function: cubic-bezier(.2, .8, .2, 1); -} - -:root[data-theme="light"] { - color-scheme: light; - --page: #f5f6f8; - --ink: #1f2933; - --muted: #64717f; - --border: #d9dee5; - --panel: #ffffff; - --accent: #1b6fd8; -} - -:root[data-theme="dark"] { - color-scheme: dark; - --page: #161a1f; - --ink: #e7ebef; - --muted: #9ca8b4; - --border: #303842; - --panel: #1e242b; - --accent: #7db4ff; -} -"#; - -const GALLERY_JS: &str = r##"const state = { - search: "", - system: "all", - appearance: "all", - pageTheme: "system", -}; - -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: `// preview.rs -fn render_scheme() { - let name = "tinty"; - let colors = 16; - apply(name, colors); -}`, - kotlin: `// Preview.kt -fun renderScheme() { - val name = "tinty" - val colors = 16 - apply(name, colors) -}`, - javascript: `// preview.js -function renderScheme() { - const name = "tinty"; - const colors = 16; - apply(name, colors); -}`, - lisp: `;; preview.lisp -(defun render-scheme () - (let ((name "tinty") - (colors 16)) - (apply name colors)))`, - zsh: `# preview.zsh -function render_scheme() { - local name="tinty" - local colors=16 - apply "$name" "$colors" -}`, -}; - -function color(scheme, key) { - return scheme.palette[key]?.hex_str || fallbackPalette[key] || fallbackPalette.base05; -} - -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, - scheme.author, - scheme.system, - scheme.variant, - appearance(scheme), - ].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) { - card.style.setProperty("--preview-bg", color(scheme, "base00")); - card.style.setProperty("--preview-fg", color(scheme, "base05")); - card.style.setProperty("--preview-muted", color(scheme, "base04")); - card.style.setProperty("--preview-comment", color(scheme, "base03")); - card.style.setProperty("--preview-keyword", color(scheme, "base0E")); - card.style.setProperty("--preview-function", color(scheme, "base0D")); - card.style.setProperty("--preview-string", color(scheme, "base0B")); - card.style.setProperty("--preview-number", color(scheme, "base09")); -} - -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 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 openSheet(scheme) { - const sheet = document.getElementById("detail-sheet"); - const backdrop = document.getElementById("sheet-backdrop"); - const command = `tinty apply ${scheme.id}`; - - setPreviewColors(sheet, scheme); - setPreviewLanguage("rust"); - document.getElementById("sheet-title").textContent = scheme.name; - document.getElementById("sheet-command").textContent = command; - document.getElementById("copy-command").dataset.command = 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); - - backdrop.hidden = false; - requestAnimationFrame(() => { - backdrop.classList.add("open"); - sheet.classList.add("open"); - sheet.setAttribute("aria-hidden", "false"); - }); -} - -function closeSheet() { - const sheet = document.getElementById("detail-sheet"); - const backdrop = document.getElementById("sheet-backdrop"); - - sheet.classList.remove("open"); - backdrop.classList.remove("open"); - sheet.setAttribute("aria-hidden", "true"); - 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.querySelector("h2").textContent = scheme.slug; - card.querySelector(".card-title p").textContent = scheme.name; - card.querySelector(".scheme-system span").textContent = scheme.system; - card.querySelector(".scheme-appearance span").textContent = appearance(scheme); - - card.querySelector(".preview-button").addEventListener("click", () => { - openSheet(scheme); - }); - - return card; -} - -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.textContent = ""; - visible.forEach((scheme) => fragment.append(createCard(scheme))); - gallery.append(fragment); - - empty.hidden = visible.length !== 0; - count.textContent = `${visible.length} of ${SCHEMES.length} schemes`; -} - -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; - document.documentElement.dataset.theme = theme === "system" ? "" : theme; - if (theme === "system") { - document.documentElement.removeAttribute("data-theme"); - setFilter("appearance", "all"); - } else { - setFilter("appearance", theme); - } - - document - .querySelectorAll("[data-page-theme]") - .forEach((candidate) => candidate.classList.toggle("active", candidate.dataset.pageTheme === theme)); - - render(); -} - -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.querySelectorAll("[data-preview-language]").forEach((button) => { - button.addEventListener("click", () => { - setPreviewLanguage(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; - const originalText = button.textContent; - - try { - await navigator.clipboard.writeText(button.dataset.command); - button.textContent = "Copied"; - window.setTimeout(() => { - button.textContent = originalText; - }, 1100); - } catch (_error) { - button.textContent = "Copy failed"; - window.setTimeout(() => { - button.textContent = originalText; - }, 1400); - } -}); - -document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - closeSheet(); - } -}); - -render(); -"##; diff --git a/src/operations/gallery/gallery.css b/src/operations/gallery/gallery.css new file mode 100644 index 0000000..694d0a3 --- /dev/null +++ b/src/operations/gallery/gallery.css @@ -0,0 +1,627 @@ +:root { + color-scheme: light dark; + --page: #f5f6f8; + --ink: #1f2933; + --muted: #64717f; + --border: #d9dee5; + --panel: #ffffff; + --accent: #1b6fd8; + --tooltip-bg: #11161c; + --tooltip-ink: #f5f7fa; + --tooltip-border: #2d3945; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--page); + color: var(--ink); + font: 15px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; +} + +.page-scene { + transform-origin: center top; + transform: translateY(0) scale(1); + transition: transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease, filter 220ms ease; + will-change: transform; +} + +body.sheet-open .page-scene { + transform: translateY(-8px) scale(.985); + opacity: .92; + filter: saturate(.92) blur(.6px); +} + +body.sheet-open { + overflow: hidden; +} + +.topbar, +main { + width: min(1440px, calc(100% - 32px)); + margin: 0 auto; +} + +.topbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + padding: 30px 0 20px; +} + +.brand { + display: flex; + align-items: center; + gap: 14px; +} + +.brand-logo { + width: clamp(42px, 5vw, 68px); + height: clamp(42px, 5vw, 68px); + object-fit: contain; + flex: 0 0 auto; +} + +.topbar-actions { + display: flex; + align-items: end; + flex-direction: column; + gap: 10px; +} + +.icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; +} + +.chip, +.search span, +.meta-pill, +.icon-button, +.command-row, +.sheet-title-group, +.section-label, +.sheet-actions { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.card-title p, +.metadata, +#result-count { + color: var(--muted); +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: clamp(32px, 4vw, 56px); + font-weight: 760; + line-height: .95; + letter-spacing: 0; +} + +.controls { + display: grid; + grid-template-columns: minmax(240px, 1fr) auto auto; + gap: 12px; + align-items: end; + padding: 16px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} + +.search span { + margin-bottom: 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.search input { + width: 100%; + min-height: 40px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + background: transparent; + color: inherit; + font: inherit; +} + +.filter-group, +.theme-switcher, +.preview-toolbar { + display: flex; + flex-wrap: wrap; + overflow: hidden; + width: fit-content; + max-width: 100%; + border: 1px solid var(--border); + border-radius: 7px; + background: color-mix(in srgb, var(--panel) 88%, var(--ink)); +} + +.chip { + min-height: 40px; + border: 0; + border-left: 1px solid var(--border); + border-radius: 0; + padding: 8px 11px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; +} + +.chip:first-child { + border-left: 0; +} + +.chip:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.chip.active { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} + +.gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + padding: 20px 0 40px; +} + +.card { + overflow: hidden; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; + view-transition-name: match-element; +} + +.card:hover, +.card:focus-within { + border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); + box-shadow: 0 12px 30px rgb(0 0 0 / 10%); +} + +.preview-button { + display: block; + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.card-header { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + color: var(--preview-muted); + background: var(--preview-bg); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.code-preview { + min-height: 188px; + margin: 0; + padding: 16px; + overflow: auto; + background: var(--preview-bg); + color: var(--preview-fg); + font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.comment { + color: var(--preview-comment); +} + +.keyword { + color: var(--preview-keyword); +} + +.function { + color: var(--preview-function); +} + +.string { + color: var(--preview-string); +} + +.number { + color: var(--preview-number); +} + +.card-title { + padding: 14px 16px 16px; +} + +.card-title h2 { + overflow-wrap: anywhere; + font-size: 16px; + font-weight: 720; + line-height: 1.2; + letter-spacing: 0; +} + +.metadata { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 12px; + margin: 0 0 14px; + font-size: 13px; +} + +.metadata dt { + color: var(--ink); + font-weight: 760; +} + +.metadata dd { + margin: 0; + color: var(--ink); + overflow-wrap: anywhere; +} + +.palette { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); + gap: 8px; +} + +.swatch { + min-width: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--panel); +} + +.swatch-color { + height: 34px; +} + +.swatch-label { + padding: 6px 7px; + font: 12px/1.3 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.swatch-label span { + display: block; + overflow-wrap: anywhere; + color: var(--muted); +} + +.empty { + padding: 40px 0; + color: var(--muted); + text-align: center; +} + +.sheet-backdrop { + position: fixed; + inset: 0; + z-index: 20; + background: rgb(0 0 0 / 26%); + backdrop-filter: blur(8px) saturate(110%); + opacity: 0; + transition: opacity 170ms ease; +} + +.sheet-backdrop.open { + opacity: 1; +} + +@supports not (backdrop-filter: blur(1px)) { + .sheet-backdrop { + background: rgb(0 0 0 / 42%); + } +} + +.detail-sheet { + position: fixed; + left: 50%; + bottom: 0; + z-index: 21; + width: min(920px, calc(100% - 24px)); + max-height: calc(100vh - 18px); + overflow: auto; + padding: 20px 20px 22px; + background: var(--panel); + border: 1px solid var(--border); + border-bottom: 0; + border-radius: 12px 12px 0 0; + box-shadow: 0 -18px 48px rgb(0 0 0 / 20%); + transform: translate(-50%, calc(100% + 18px)); + transition: transform 220ms cubic-bezier(.2, .8, .2, 1); +} + +.detail-sheet.open { + transform: translate(-50%, 0); +} + +.sheet-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.sheet-header h2 { + overflow-wrap: anywhere; + font-size: clamp(22px, 3vw, 34px); + font-weight: 760; + line-height: 1.05; +} + +.sheet-title-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex: 0 0 36px; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.sheet-actions { + flex: 0 0 auto; +} + +.icon-button { + position: relative; + min-width: 40px; + min-height: 40px; + justify-content: center; + border: 1px solid var(--border); + border-radius: 7px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.icon-button::after { + content: attr(data-tooltip); + position: absolute; + right: 0; + bottom: calc(100% + 8px); + padding: 6px 8px; + border: 1px solid var(--tooltip-border); + border-radius: 7px; + background: var(--tooltip-bg); + color: var(--tooltip-ink); + font: 12px/1.2 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity 120ms ease, transform 140ms ease; + z-index: 1; +} + +.icon-button.show-tooltip::after, +.icon-button:hover::after { + opacity: 1; + transform: translateY(0); +} + +.sheet-actions .icon-button::after { + top: calc(100% + 8px); + right: 0; + bottom: auto; +} + +.icon-button-accent { + border-color: color-mix(in srgb, var(--accent) 72%, var(--border)); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 20%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.icon-button-accent:hover { + background: color-mix(in srgb, var(--accent) 28%, transparent); +} + +.command-row { + justify-content: start; + width: 100%; + margin-bottom: 12px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 7px; + background: color-mix(in srgb, var(--panel) 86%, var(--ink)); +} + +.command-row code { + flex: 1 1 auto; + overflow: auto; + font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +.command-row .icon-button { + min-width: 30px; + min-height: 30px; +} + +.sheet-preview { + margin-bottom: 10px; +} + +.section-label { + margin: 0 0 8px; + color: var(--muted); + font-size: 12px; + font-weight: 760; + text-transform: uppercase; +} + +.preview-toolbar { + margin-bottom: 0; + border-radius: 8px 8px 0 0; +} + +.sheet-code-preview { + min-height: 210px; + border-radius: 0 0 8px 8px; +} + +.toast { + position: fixed; + left: 50%; + bottom: 22px; + z-index: 30; + padding: 10px 14px; + border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--panel) 84%, var(--ink)); + color: var(--ink); + box-shadow: 0 10px 24px rgb(0 0 0 / 14%); + transform: translate(-50%, 18px); + opacity: 0; + transition: opacity 140ms ease, transform 180ms cubic-bezier(.2, .8, .2, 1); +} + +.toast.open { + transform: translate(-50%, 0); + opacity: 1; +} + +@media (max-width: 860px) { + .topbar, + main { + width: min(100% - 20px, 720px); + } + + .topbar { + align-items: start; + flex-direction: column; + } + + .brand { + gap: 10px; + } + + .topbar-actions { + align-items: start; + } + + .controls { + grid-template-columns: 1fr; + } + + .filter-group, + .theme-switcher, + .preview-toolbar { + width: 100%; + } + + .chip { + flex: 1 1 auto; + justify-content: center; + } + + .gallery { + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + } + + .detail-sheet { + width: 100%; + max-height: calc(100vh - 8px); + padding: 14px 14px 18px; + } + + body.sheet-open .page-scene { + transform: translateY(-4px) scale(.992); + } +} + +@media (max-width: 560px) { + .gallery { + grid-template-columns: 1fr; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --page: #161a1f; + --ink: #e7ebef; + --muted: #9ca8b4; + --border: #303842; + --panel: #1e242b; + --accent: #7db4ff; + } +} + +@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: 160ms; + animation-timing-function: cubic-bezier(.2, .8, .2, 1); +} + +:root[data-theme="light"] { + color-scheme: light; + --page: #f5f6f8; + --ink: #1f2933; + --muted: #64717f; + --border: #d9dee5; + --panel: #ffffff; + --accent: #1b6fd8; + --tooltip-bg: #11161c; + --tooltip-ink: #f5f7fa; + --tooltip-border: #2d3945; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --page: #161a1f; + --ink: #e7ebef; + --muted: #9ca8b4; + --border: #303842; + --panel: #1e242b; + --accent: #7db4ff; + --tooltip-bg: #f3f6f9; + --tooltip-ink: #16202a; + --tooltip-border: #c8d2dc; +} diff --git a/src/operations/gallery/gallery.js b/src/operations/gallery/gallery.js new file mode 100644 index 0000000..c828919 --- /dev/null +++ b/src/operations/gallery/gallery.js @@ -0,0 +1,388 @@ +const SCHEMES = __TINTY_SCHEMES__; + +const state = { + search: "", + system: "all", + appearance: "all", + pageTheme: "system", +}; +let currentSheetId = null; +let tooltipTimeoutId = null; + +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: `// preview.rs +fn render_scheme() { + let name = "tinty"; + let colors = 16; + apply(name, colors); +}`, + kotlin: `// Preview.kt +fun renderScheme() { + val name = "tinty" + val colors = 16 + apply(name, colors) +}`, + javascript: `// preview.js +function renderScheme() { + const name = "tinty"; + const colors = 16; + apply(name, colors); +}`, + lisp: `;; preview.lisp +(defun render-scheme () + (let ((name "tinty") + (colors 16)) + (apply name colors)))`, + zsh: `# preview.zsh +function render_scheme() { + local name="tinty" + local colors=16 + apply "$name" "$colors" +}`, +}; + +function color(scheme, key) { + return scheme.palette[key]?.hex_str || fallbackPalette[key] || fallbackPalette.base05; +} + +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, + scheme.author, + scheme.system, + scheme.variant, + appearance(scheme), + ].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) { + card.style.setProperty("--preview-bg", color(scheme, "base00")); + card.style.setProperty("--preview-fg", color(scheme, "base05")); + card.style.setProperty("--preview-muted", color(scheme, "base04")); + card.style.setProperty("--preview-comment", color(scheme, "base03")); + card.style.setProperty("--preview-keyword", color(scheme, "base0E")); + card.style.setProperty("--preview-function", color(scheme, "base0D")); + card.style.setProperty("--preview-string", color(scheme, "base0B")); + card.style.setProperty("--preview-number", color(scheme, "base09")); +} + +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 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 sheetLinkForId(id) { + const url = new URL(window.location.href); + url.hash = id; + return url.toString(); +} + +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); +} + +function openSheet(scheme, updateHash = true) { + const sheet = document.getElementById("detail-sheet"); + const backdrop = document.getElementById("sheet-backdrop"); + const command = `tinty apply ${scheme.id}`; + + currentSheetId = scheme.id; + setPreviewColors(sheet, scheme); + setPreviewLanguage("rust"); + document.getElementById("sheet-title").textContent = scheme.name; + document.getElementById("sheet-command").textContent = command; + document.getElementById("copy-command").dataset.command = command; + document.getElementById("copy-command").dataset.tooltip = "Copy command"; + document.getElementById("copy-link").dataset.link = sheetLinkForId(scheme.id); + document.getElementById("copy-link").dataset.tooltip = "Copy link"; + + 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"); + requestAnimationFrame(() => { + backdrop.classList.add("open"); + sheet.classList.add("open"); + sheet.setAttribute("aria-hidden", "false"); + }); +} + +function closeSheet(updateHash = true) { + const sheet = document.getElementById("detail-sheet"); + const backdrop = document.getElementById("sheet-backdrop"); + + currentSheetId = null; + if (updateHash) { + clearSheetHash(); + } + document.body.classList.remove("sheet-open"); + sheet.classList.remove("open"); + backdrop.classList.remove("open"); + sheet.setAttribute("aria-hidden", "true"); + 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.querySelector("h2").textContent = scheme.slug; + card.querySelector(".card-title p").textContent = scheme.name; + card.querySelector(".scheme-system span").textContent = scheme.system; + card.querySelector(".scheme-appearance span").textContent = appearance(scheme); + + card.querySelector(".preview-button").addEventListener("click", () => { + openSheet(scheme); + }); + + 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.textContent = ""; + visible.forEach((scheme) => fragment.append(createCard(scheme))); + gallery.append(fragment); + + empty.hidden = visible.length !== 0; + count.textContent = `${visible.length} of ${SCHEMES.length} schemes`; +} + +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; + document.documentElement.dataset.theme = theme === "system" ? "" : theme; + if (theme === "system") { + document.documentElement.removeAttribute("data-theme"); + setFilter("appearance", "all"); + } else { + setFilter("appearance", theme); + } + + document + .querySelectorAll("[data-page-theme]") + .forEach((candidate) => candidate.classList.toggle("active", candidate.dataset.pageTheme === theme)); + + render(); +} + +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.querySelectorAll("[data-preview-language]").forEach((button) => { + button.addEventListener("click", () => { + setPreviewLanguage(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.getElementById("copy-link").addEventListener("click", async (event) => { + const button = event.currentTarget; + + try { + await navigator.clipboard.writeText(button.dataset.link); + showButtonTooltip(button, "Copied"); + } catch (_error) { + showButtonTooltip(button, "Copy failed"); + } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeSheet(); + } +}); + +window.addEventListener("hashchange", syncSheetToHash); + +syncSheetToHash(); +render(); diff --git a/src/operations/gallery/index.html b/src/operations/gallery/index.html new file mode 100644 index 0000000..b1955d5 --- /dev/null +++ b/src/operations/gallery/index.html @@ -0,0 +1,163 @@ + + + + + + Tinty Gallery + + + + +
+
+
+ +

Tinty Gallery

+
+
+
+ + + +
+ +
+
+ +
+
+ +
+ + + + +
+
+ + + +
+
+ + + +
+
+ + + + + + + + + + + From 40aea32ac3dc03cdedb3d3f564ab39d08d8f8820 Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Fri, 24 Apr 2026 14:17:25 -0700 Subject: [PATCH 07/27] feat: paper texture --- src/operations/gallery/gallery.css | 17 ++++++++++++++++- src/operations/gallery/gallery.js | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/operations/gallery/gallery.css b/src/operations/gallery/gallery.css index 694d0a3..ff146af 100644 --- a/src/operations/gallery/gallery.css +++ b/src/operations/gallery/gallery.css @@ -9,6 +9,10 @@ --tooltip-bg: #11161c; --tooltip-ink: #f5f7fa; --tooltip-border: #2d3945; + --paper-wash-start: rgb(255 255 255 / .22); + --paper-wash-end: rgb(17 22 28 / .03); + --paper-grain-dark: rgb(17 22 28 / .05); + --paper-grain-light: rgb(255 255 255 / .1); } * { @@ -17,7 +21,14 @@ body { margin: 0; - background: var(--page); + background-color: var(--page); + background-image: + linear-gradient(180deg, var(--paper-wash-start), var(--paper-wash-end)), + radial-gradient(circle at 1px 1px, var(--paper-grain-dark) 1px, transparent 0), + radial-gradient(circle at 2px 2px, var(--paper-grain-light) 1px, transparent 0); + background-size: auto, 14px 14px, 19px 19px; + background-position: 0 0, 0 0, 7px 9px; + background-attachment: fixed; color: var(--ink); font: 15px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-feature-settings: "cv02", "cv03", "cv04", "cv11"; @@ -624,4 +635,8 @@ h1 { --tooltip-bg: #f3f6f9; --tooltip-ink: #16202a; --tooltip-border: #c8d2dc; + --paper-wash-start: rgb(255 255 255 / .04); + --paper-wash-end: rgb(0 0 0 / .12); + --paper-grain-dark: rgb(0 0 0 / .12); + --paper-grain-light: rgb(255 255 255 / .04); } diff --git a/src/operations/gallery/gallery.js b/src/operations/gallery/gallery.js index c828919..399e969 100644 --- a/src/operations/gallery/gallery.js +++ b/src/operations/gallery/gallery.js @@ -8,6 +8,7 @@ const state = { }; let currentSheetId = null; let tooltipTimeoutId = null; +const PAGE_THEME_STORAGE_KEY = "tinty-gallery-page-theme"; const fallbackPalette = { base00: "#101418", @@ -311,6 +312,7 @@ function setFilter(group, value) { function setPageTheme(theme) { state.pageTheme = theme; + window.localStorage.setItem(PAGE_THEME_STORAGE_KEY, theme); document.documentElement.dataset.theme = theme === "system" ? "" : theme; if (theme === "system") { document.documentElement.removeAttribute("data-theme"); @@ -326,6 +328,18 @@ function setPageTheme(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(); @@ -384,5 +398,6 @@ document.addEventListener("keydown", (event) => { window.addEventListener("hashchange", syncSheetToHash); +loadSavedPageTheme(); syncSheetToHash(); render(); From 1a6a0009ec79c94ee1badbd936499ffa2a796adb Mon Sep 17 00:00:00 2001 From: Bez Hermoso Date: Fri, 24 Apr 2026 17:27:23 -0700 Subject: [PATCH 08/27] refactor: better frontend --- src/operations/gallery/gallery.css | 746 ++++++++++++++++++++--------- src/operations/gallery/gallery.js | 87 ++-- src/operations/gallery/index.html | 99 ++-- 3 files changed, 632 insertions(+), 300 deletions(-) diff --git a/src/operations/gallery/gallery.css b/src/operations/gallery/gallery.css index ff146af..f47b608 100644 --- a/src/operations/gallery/gallery.css +++ b/src/operations/gallery/gallery.css @@ -1,43 +1,78 @@ :root { color-scheme: light dark; - --page: #f5f6f8; - --ink: #1f2933; - --muted: #64717f; - --border: #d9dee5; - --panel: #ffffff; - --accent: #1b6fd8; - --tooltip-bg: #11161c; - --tooltip-ink: #f5f7fa; - --tooltip-border: #2d3945; - --paper-wash-start: rgb(255 255 255 / .22); - --paper-wash-end: rgb(17 22 28 / .03); - --paper-grain-dark: rgb(17 22 28 / .05); - --paper-grain-light: rgb(255 255 255 / .1); + + --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: #0d7680; + --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); + --paper-grain-dark: rgb(18 20 27 / .025); + --paper-grain-light: rgb(255 255 255 / .06); + + --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-mono: ui-monospace, "JetBrains Mono", "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; } * { box-sizing: border-box; } +html, +body { + height: 100%; +} + body { margin: 0; - background-color: var(--page); + 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)), radial-gradient(circle at 1px 1px, var(--paper-grain-dark) 1px, transparent 0), radial-gradient(circle at 2px 2px, var(--paper-grain-light) 1px, transparent 0); - background-size: auto, 14px 14px, 19px 19px; - background-position: 0 0, 0 0, 7px 9px; + background-size: auto, auto, 16px 16px, 22px 22px; + background-position: 0 0, 0 0, 0 0, 8px 11px; background-attachment: fixed; - color: var(--ink); - font: 15px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + 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; } .page-scene { transform-origin: center top; transform: translateY(0) scale(1); - transition: transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease, filter 220ms ease; + transition: transform 160ms cubic-bezier(.2, .8, .2, 1), opacity 130ms ease, filter 160ms ease; will-change: transform; } @@ -53,44 +88,86 @@ body.sheet-open { .topbar, main { - width: min(1440px, calc(100% - 32px)); + width: min(1280px, calc(100% - 40px)); margin: 0 auto; } .topbar { + position: sticky; + top: 0; + z-index: 10; display: flex; - align-items: end; + align-items: center; justify-content: space-between; gap: 24px; - padding: 30px 0 20px; + 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: clamp(42px, 5vw, 68px); - height: clamp(42px, 5vw, 68px); + width: 38px; + height: 38px; object-fit: contain; flex: 0 0 auto; + filter: drop-shadow(0 4px 10px rgb(18 20 27 / 10%)); } -.topbar-actions { +.brand-text { display: flex; - align-items: end; flex-direction: column; - gap: 10px; + 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: 16px; - height: 16px; - flex: 0 0 16px; + width: 15px; + height: 15px; + flex: 0 0 15px; stroke: currentColor; - stroke-width: 2; + stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; fill: none; @@ -101,20 +178,12 @@ main { .meta-pill, .icon-button, .command-row, -.sheet-title-group, -.section-label, -.sheet-actions { +.section-label { display: inline-flex; align-items: center; gap: 7px; } -.card-title p, -.metadata, -#result-count { - color: var(--muted); -} - h1, h2, p { @@ -122,137 +191,216 @@ p { } h1 { - font-size: clamp(32px, 4vw, 56px); - font-weight: 760; - line-height: .95; - letter-spacing: 0; + font-size: clamp(18px, 2vw, 22px); + font-weight: 680; + line-height: 1.15; + letter-spacing: -.01em; + color: var(--ink-1); } .controls { display: grid; - grid-template-columns: minmax(240px, 1fr) auto auto; - gap: 12px; - align-items: end; - padding: 16px; - background: var(--panel); + 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: 8px; + border-radius: var(--radius-lg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: var(--shadow-sm); } -.search span { - margin-bottom: 6px; - color: var(--muted); - font-size: 12px; - font-weight: 700; +.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%; - min-height: 40px; + height: 40px; + padding: 8px 12px 8px 40px; + background: var(--surface); border: 1px solid var(--border); - border-radius: 6px; - padding: 8px 10px; - background: transparent; + 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: flex; + display: inline-flex; flex-wrap: wrap; - overflow: hidden; - width: fit-content; - max-width: 100%; + gap: 2px; + padding: 3px; + background: color-mix(in oklab, var(--ink-1) 4%, transparent); border: 1px solid var(--border); - border-radius: 7px; - background: color-mix(in srgb, var(--panel) 88%, var(--ink)); + border-radius: var(--radius-full); +} + +.preview-toolbar { + border-radius: var(--radius-md); } .chip { - min-height: 40px; + position: relative; + min-height: 32px; + padding: 4px 12px; border: 0; - border-left: 1px solid var(--border); - border-radius: 0; - padding: 8px 11px; + border-radius: inherit; background: transparent; - color: inherit; + color: var(--ink-2); font: inherit; + font-size: 13px; + font-weight: 500; cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; + transition: background-color 140ms ease, color 140ms ease, transform 140ms ease; } -.chip:first-child { - border-left: 0; +.filter-group .chip, +.theme-switcher .chip, +.preview-toolbar .chip { + border-radius: var(--radius-full); +} + +.preview-toolbar .chip { + border-radius: var(--radius-xs); } .chip:hover { - background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--ink-1); + background: color-mix(in oklab, var(--ink-1) 6%, transparent); } .chip.active { - background: color-mix(in srgb, var(--accent) 16%, transparent); - color: var(--accent); + 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 40px; + padding: 20px 0 60px; } .card { + position: relative; overflow: hidden; - background: var(--panel); + background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; - transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; - view-transition-name: match-element; + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + transition: border-color 160ms ease, box-shadow 200ms ease, transform 200ms ease; } .card:hover, .card:focus-within { - border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - box-shadow: 0 12px 30px rgb(0 0 0 / 10%); + 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%; - border: 0; 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; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - color: var(--preview-muted); - background: var(--preview-bg); - font-size: 12px; - font-weight: 700; + 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); +} + +.meta-pill .icon { + width: 11px; + height: 11px; + flex-basis: 11px; + stroke-width: 2; } .code-preview { - min-height: 188px; + min-height: 180px; margin: 0; - padding: 16px; + padding: 18px 18px 16px; overflow: auto; background: var(--preview-bg); color: var(--preview-fg); - font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font: 12.5px/1.6 var(--font-mono); } .comment { color: var(--preview-comment); + font-style: italic; } .keyword { @@ -273,67 +421,88 @@ h1 { .card-title { padding: 14px 16px 16px; + border-top: 1px solid var(--border); + background: var(--surface); } .card-title h2 { overflow-wrap: anywhere; - font-size: 16px; - font-weight: 720; - line-height: 1.2; - letter-spacing: 0; + font-size: 15px; + font-weight: 650; + line-height: 1.25; + letter-spacing: -.005em; + color: var(--ink-1); +} + +.card-title p { + margin-top: 2px; + font-size: 12.5px; + color: var(--ink-3); } .metadata { display: grid; grid-template-columns: max-content 1fr; - gap: 6px 12px; - margin: 0 0 14px; + gap: 8px 14px; + margin: 0; font-size: 13px; } .metadata dt { - color: var(--ink); - font-weight: 760; + color: var(--ink-3); + font-weight: 500; + letter-spacing: .01em; } .metadata dd { margin: 0; - color: var(--ink); + 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(92px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); gap: 8px; } .swatch { min-width: 0; - border: 1px solid var(--border); - border-radius: 6px; overflow: hidden; - background: var(--panel); + 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: 34px; + height: 42px; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 8%); } .swatch-label { - padding: 6px 7px; - font: 12px/1.3 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + 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(--muted); + color: var(--ink-3); + font-size: 10.5px; } .empty { - padding: 40px 0; - color: var(--muted); + padding: 60px 0; + color: var(--ink-3); text-align: center; } @@ -343,8 +512,9 @@ h1 { z-index: 20; background: rgb(0 0 0 / 26%); backdrop-filter: blur(8px) saturate(110%); + -webkit-backdrop-filter: blur(8px) saturate(110%); opacity: 0; - transition: opacity 170ms ease; + transition: opacity 120ms ease; } .sheet-backdrop.open { @@ -360,168 +530,213 @@ h1 { .detail-sheet { position: fixed; left: 50%; - bottom: 0; + top: 50%; z-index: 21; - width: min(920px, calc(100% - 24px)); - max-height: calc(100vh - 18px); + display: grid; + grid-template-columns: 1fr; + gap: 18px; + width: min(1040px, calc(100% - 24px)); + max-height: calc(100vh - 48px); overflow: auto; - padding: 20px 20px 22px; - background: var(--panel); + padding: 22px 24px 26px; + background: var(--surface); border: 1px solid var(--border); - border-bottom: 0; - border-radius: 12px 12px 0 0; - box-shadow: 0 -18px 48px rgb(0 0 0 / 20%); - transform: translate(-50%, calc(100% + 18px)); - transition: transform 220ms cubic-bezier(.2, .8, .2, 1); + 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%, 0); + 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-header { + grid-column: 1 / -1; display: flex; align-items: start; justify-content: space-between; gap: 16px; - margin-bottom: 12px; } .sheet-header h2 { overflow-wrap: anywhere; - font-size: clamp(22px, 3vw, 34px); - font-weight: 760; - line-height: 1.05; + font-size: clamp(22px, 2.4vw, 30px); + font-weight: 720; + line-height: 1.1; + letter-spacing: -.015em; } -.sheet-title-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - flex: 0 0 36px; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--accent); - background: color-mix(in srgb, var(--accent) 10%, transparent); +.sheet-title-block { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; } -.sheet-actions { - flex: 0 0 auto; +.sheet-title-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; } .icon-button { position: relative; - min-width: 40px; - min-height: 40px; + min-width: 36px; + min-height: 36px; justify-content: center; border: 1px solid var(--border); - border-radius: 7px; - background: transparent; - color: inherit; + 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::after { +.icon-button:hover { + color: var(--ink-1); + background: var(--surface-2); + border-color: var(--border-strong); +} + +.icon-button:active { + transform: scale(.97); +} + +.icon-button[data-tooltip]::after { content: attr(data-tooltip); position: absolute; right: 0; bottom: calc(100% + 8px); - padding: 6px 8px; + padding: 6px 9px; border: 1px solid var(--tooltip-border); - border-radius: 7px; + border-radius: var(--radius-sm); background: var(--tooltip-bg); color: var(--tooltip-ink); - font: 12px/1.2 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + 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.show-tooltip::after, -.icon-button:hover::after { +.icon-button[data-tooltip].show-tooltip::after, +.icon-button[data-tooltip]:hover::after { opacity: 1; transform: translateY(0); } -.sheet-actions .icon-button::after { - top: calc(100% + 8px); - right: 0; - bottom: auto; +.sheet-body { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: 20px; + align-items: start; } -.icon-button-accent { - border-color: color-mix(in srgb, var(--accent) 72%, var(--border)); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 20%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); +.sheet-body > .sheet-preview { + min-width: 0; } -.icon-button-accent:hover { - background: color-mix(in srgb, var(--accent) 28%, transparent); +.sheet-body > .sheet-details { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; } .command-row { justify-content: start; + gap: 10px; width: 100%; - margin-bottom: 12px; - padding: 6px 8px; + padding: 8px 10px 8px 12px; + background: color-mix(in oklab, var(--ink-1) 4%, transparent); border: 1px solid var(--border); - border-radius: 7px; - background: color-mix(in srgb, var(--panel) 86%, var(--ink)); + border-radius: var(--radius-md); +} + +.command-row > .icon:first-child { + color: var(--ink-3); } .command-row code { flex: 1 1 auto; overflow: auto; - font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + 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); } .sheet-preview { - margin-bottom: 10px; + display: flex; + flex-direction: column; + gap: 10px; } .section-label { - margin: 0 0 8px; - color: var(--muted); - font-size: 12px; - font-weight: 760; + 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; - border-radius: 8px 8px 0 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: 210px; - border-radius: 0 0 8px 8px; + min-height: 260px; + padding: 20px; + border-radius: var(--radius-lg); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--preview-fg, #000) 5%, transparent); } .toast { position: fixed; left: 50%; - bottom: 22px; + bottom: 24px; z-index: 30; - padding: 10px 14px; - border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); - border-radius: 999px; - background: color-mix(in srgb, var(--panel) 84%, var(--ink)); - color: var(--ink); - box-shadow: 0 10px 24px rgb(0 0 0 / 14%); + 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 140ms ease, transform 180ms cubic-bezier(.2, .8, .2, 1); + transition: opacity 160ms ease, transform 220ms cubic-bezier(.2, .8, .2, 1); } .toast.open { @@ -529,6 +744,12 @@ h1 { opacity: 1; } +@media (max-width: 960px) { + .sheet-body { + grid-template-columns: 1fr; + } +} + @media (max-width: 860px) { .topbar, main { @@ -536,16 +757,13 @@ h1 { } .topbar { - align-items: start; flex-direction: column; - } - - .brand { - gap: 10px; + align-items: stretch; + gap: 12px; } .topbar-actions { - align-items: start; + justify-content: space-between; } .controls { @@ -556,25 +774,28 @@ h1 { .theme-switcher, .preview-toolbar { width: 100%; + justify-content: stretch; } - .chip { + .filter-group .chip, + .theme-switcher .chip { flex: 1 1 auto; justify-content: center; } - .gallery { - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - } - .detail-sheet { + top: auto; + bottom: 0; width: 100%; max-height: calc(100vh - 8px); - padding: 14px 14px 18px; + padding: 16px 16px 20px; + border-bottom: 0; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + transform: translate(-50%, calc(100% + 18px)); } - body.sheet-open .page-scene { - transform: translateY(-4px) scale(.992); + .detail-sheet.open { + transform: translate(-50%, 0); } } @@ -582,16 +803,40 @@ h1 { .gallery { grid-template-columns: 1fr; } + + h1 { + font-size: 18px; + } } @media (prefers-color-scheme: dark) { :root { - --page: #161a1f; - --ink: #e7ebef; - --muted: #9ca8b4; - --border: #303842; - --panel: #1e242b; - --accent: #7db4ff; + --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: #5dc7bd; + --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); + --paper-grain-dark: rgb(0 0 0 / .22); + --paper-grain-light: rgb(255 255 255 / .025); + --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%); } } @@ -607,36 +852,77 @@ h1 { ::view-transition-old(root), ::view-transition-new(root) { - animation-duration: 160ms; - animation-timing-function: cubic-bezier(.2, .8, .2, 1); + 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; - --page: #f5f6f8; - --ink: #1f2933; - --muted: #64717f; - --border: #d9dee5; - --panel: #ffffff; - --accent: #1b6fd8; - --tooltip-bg: #11161c; - --tooltip-ink: #f5f7fa; - --tooltip-border: #2d3945; + --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: #0d7680; + --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); + --paper-grain-dark: rgb(18 20 27 / .025); + --paper-grain-light: rgb(255 255 255 / .06); + --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; - --page: #161a1f; - --ink: #e7ebef; - --muted: #9ca8b4; - --border: #303842; - --panel: #1e242b; - --accent: #7db4ff; - --tooltip-bg: #f3f6f9; - --tooltip-ink: #16202a; - --tooltip-border: #c8d2dc; - --paper-wash-start: rgb(255 255 255 / .04); - --paper-wash-end: rgb(0 0 0 / .12); - --paper-grain-dark: rgb(0 0 0 / .12); - --paper-grain-light: rgb(255 255 255 / .04); + --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: #5dc7bd; + --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); + --paper-grain-dark: rgb(0 0 0 / .22); + --paper-grain-light: rgb(255 255 255 / .025); + --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%); } diff --git a/src/operations/gallery/gallery.js b/src/operations/gallery/gallery.js index 399e969..cfbaeb7 100644 --- a/src/operations/gallery/gallery.js +++ b/src/operations/gallery/gallery.js @@ -159,12 +159,6 @@ function transitionLayout(callback) { callback(); } -function sheetLinkForId(id) { - const url = new URL(window.location.href); - url.hash = id; - return url.toString(); -} - function schemeForHash() { const targetId = window.location.hash.replace(/^#/, ""); if (!targetId) { @@ -199,7 +193,10 @@ function showButtonTooltip(button, message) { }, 1100); } -function openSheet(scheme, updateHash = true) { +const SHARED_TRANSITION_NAME = "scheme-shared"; +let originCard = null; + +function applySheetState(scheme, updateHash) { const sheet = document.getElementById("detail-sheet"); const backdrop = document.getElementById("sheet-backdrop"); const command = `tinty apply ${scheme.id}`; @@ -208,11 +205,11 @@ function openSheet(scheme, updateHash = true) { setPreviewColors(sheet, scheme); setPreviewLanguage("rust"); 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"; - document.getElementById("copy-link").dataset.link = sheetLinkForId(scheme.id); - document.getElementById("copy-link").dataset.tooltip = "Copy link"; const metadata = document.getElementById("sheet-metadata"); metadata.textContent = ""; @@ -233,14 +230,15 @@ function openSheet(scheme, updateHash = true) { backdrop.hidden = false; document.body.classList.add("sheet-open"); - requestAnimationFrame(() => { - backdrop.classList.add("open"); - sheet.classList.add("open"); - sheet.setAttribute("aria-hidden", "false"); - }); + // 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 closeSheet(updateHash = true) { +function clearSheetState(updateHash) { const sheet = document.getElementById("detail-sheet"); const backdrop = document.getElementById("sheet-backdrop"); @@ -252,6 +250,51 @@ function closeSheet(updateHash = true) { 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; @@ -264,13 +307,14 @@ function createCard(scheme) { const card = template.content.firstElementChild.cloneNode(true); setPreviewColors(card, scheme); + card.dataset.schemeId = scheme.id; card.querySelector("h2").textContent = scheme.slug; card.querySelector(".card-title p").textContent = scheme.name; card.querySelector(".scheme-system span").textContent = scheme.system; card.querySelector(".scheme-appearance span").textContent = appearance(scheme); card.querySelector(".preview-button").addEventListener("click", () => { - openSheet(scheme); + openSheet(scheme, true, card); }); return card; @@ -379,17 +423,6 @@ document.getElementById("copy-command").addEventListener("click", async (event) } }); -document.getElementById("copy-link").addEventListener("click", async (event) => { - const button = event.currentTarget; - - try { - await navigator.clipboard.writeText(button.dataset.link); - showButtonTooltip(button, "Copied"); - } catch (_error) { - showButtonTooltip(button, "Copy failed"); - } -}); - document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closeSheet(); diff --git a/src/operations/gallery/index.html b/src/operations/gallery/index.html index b1955d5..a4d4e10 100644 --- a/src/operations/gallery/index.html +++ b/src/operations/gallery/index.html @@ -12,9 +12,13 @@
-

Tinty Gallery

+
+

Tinty Gallery

+ Browse & preview base16, base24, and tinted8 schemes +
+
-
@@ -81,52 +84,62 @@

Tinty Gallery