diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000..ed9df7c --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,78 @@ +name: Deploy site + +# Only the landing page's own sources — including the pure cover core the wasm +# hero depends on. A GUI-only change (main.rs, ui/) does not trigger a redeploy. +on: + push: + branches: [main] + paths: + - "web/**" + - "examples/site.rs" + - "examples/build_site.rs" + - "src/wasm.rs" + - "src/lib.rs" + - "src/cover/**" + - "src/design/**" + - "assets/fonts/**" + - "assets/icons/icon.svg" + - "Cargo.toml" + - ".github/workflows/deploy-site.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build wasm hero engine + run: >- + wasm-pack build --target web --release + --out-dir web/wasm --out-name acag + -- --no-default-features + + - name: Generate images, icons, and webfonts (acag engine) + run: cargo run --release --example site --no-default-features --features render + + - name: Assemble dist/ + run: cargo run --release --example build_site --no-default-features + + - uses: actions/configure-pages@v5 + with: + enablement: true + + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index b7141ea..bd64e76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target **/*.rs.bk *.pdb +/dist diff --git a/AGENTS.md b/AGENTS.md index e01c361..16116da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,16 +26,24 @@ src/ raster.rs SVG → Pixmap/PNG by longest edge (resvg + embedded Montserrat) export.rs save SVG / PNG (2K or 4K) preset.rs save/load named presets as TOML + wasm.rs WebAssembly surface for the landing page (pure part tested natively) main.rs Slint GUI wiring (background export + spinner) ui/app.slint the editor + live preview -examples/ gallery.rs (docs/samples), icon.rs (app icon + icon.ico) -build.rs compiles the Slint UI; embeds the Windows icon resource +web/ the GitHub Pages landing (index.html · css · js · generated assets) +examples/ gallery.rs (docs/samples), icon.rs (app icon + icon.ico), + site.rs (web/assets), build_site.rs (web/ → dist/) +build.rs compiles the Slint UI (gui only); embeds the Windows icon resource ``` The library crate holds all logic and is what tests cover; `main.rs` is thin GUI glue. Fonts (Montserrat Black/Bold/Regular) are embedded via `include_bytes!`, so no system fonts are needed at runtime. +Cargo features mirror hypso's layering: `gui` (default) ⊃ `render` (resvg + file +I/O, no Slint) ⊃ the bare pure core (`cover` + `design` + `wasm`), which is what +`wasm-pack` compiles for the landing page. `export`/`preset`/`raster` only exist +under `render`; the `acag` binary requires `gui`. + ## Commands ```sh @@ -46,6 +54,11 @@ cargo clippy --all-targets -- -D warnings # lint gate cargo llvm-cov --lib --fail-under-lines 80 # coverage gate (library ≥ 80%) cargo run --example gallery # regenerate docs/samples cargo run --example icon # regenerate assets/icons/* (PNGs + icon.ico) + +# Landing page (web/ → GitHub Pages) +wasm-pack build --target web --release --out-dir web/wasm --out-name acag -- --no-default-features +cargo run --example site --no-default-features --features render # web/assets (og, poster, icons, fonts) +cargo run --example build_site --no-default-features # assemble dist/ ``` CI (`.github/workflows/ci.yml`) runs fmt, clippy `-D warnings`, `cargo test`, and @@ -81,13 +94,19 @@ change is done. `.github/workflows/release.yml` triggers on `v*` tags: builds a Linux AppImage + tarball and a standalone Windows `.exe` (static CRT), attached to the GitHub -release. The AppImage `.desktop` `Categories` must use only freedesktop-registered +release. `.github/workflows/deploy-site.yml` publishes the landing page to +GitHub Pages on pushes to `main` that touch the site or the pure core: wasm-pack +build → `site` example → `build_site` example → deploy `dist/`. The AppImage `.desktop` `Categories` must use only freedesktop-registered values (no unregistered `Design`, etc.) or `appimagetool` fails. ## Gotchas - Keep preview and export pixel-identical: only the rasterization size differs. - Regenerate `docs/samples` (`gallery`) and the icon set (`icon`) when their - inputs change; both are committed assets. + inputs change; both are committed assets. The same goes for `web/assets` + (`site` example); only `web/wasm/` is CI-built and gitignored. +- `web/js/main.js` mirrors `poster_config()` from `examples/site.rs` as + `FIRST_PLATE`/`FIRST_STYLE`; keep them in sync or the poster → live swap + flickers. - After editing `ui/app.slint`, the build re-runs `slint-build`; mismatched property names surface as compile errors in `main.rs`. diff --git a/Cargo.lock b/Cargo.lock index 17264e9..c94e055 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "slint-build", "toml 1.1.2+spec-1.1.0", "ttf-parser", + "wasm-bindgen", "winresource", ] diff --git a/Cargo.toml b/Cargo.toml index 40aaae1..a045f30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,22 +8,62 @@ license = "MIT" repository = "https://github.com/skvggor/acag" readme = "README.md" +[lib] +# `cdylib` is the WebAssembly artifact for the landing-page hero; `rlib` keeps +# the binary, tests, and examples linking against the native library. +crate-type = ["cdylib", "rlib"] + [[bin]] name = "acag" path = "src/main.rs" +required-features = ["gui"] + +[features] +default = ["gui"] +# `render`: rasterization + file I/O (resvg, no GUI). Enough for the asset +# examples (`gallery`, `icon`, `site`) — and it never pulls in Slint. +render = ["dep:anyhow", "dep:dirs", "dep:resvg", "dep:toml"] +# `gui`: the full desktop app on top of `render` (Slint + the Slint compiler). +gui = ["render", "dep:slint", "dep:slint-build"] +# Disabling both leaves only the pure cover core (design → typeset → render SVG) +# plus the `wasm` binding — what the browser build compiles. [dependencies] -anyhow = "1.0.102" -dirs = "6.0.0" +anyhow = { version = "1.0.102", optional = true } +dirs = { version = "6.0.0", optional = true } fastrand = "2.4.1" -resvg = "0.47.0" +resvg = { version = "0.47.0", optional = true } serde = { version = "1.0.228", features = ["derive"] } -slint = { version = "1.16.1", features = ["renderer-software"] } -toml = "1.1.2" +slint = { version = "1.16.1", features = ["renderer-software"], optional = true } +toml = { version = "1.1.2", optional = true } ttf-parser = "0.25.1" +# Only pulled in when compiling to wasm32; the native build never sees it. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" + +[[example]] +name = "gallery" +required-features = ["render"] + +[[example]] +name = "icon" +required-features = ["render"] + +[[example]] +name = "site" +required-features = ["render"] + +# Optional so the WebAssembly build (native off) skips compiling the Slint +# toolchain and its asset-processing tree entirely. [build-dependencies] -slint-build = "1.16.1" +slint-build = { version = "1.16.1", optional = true } + +# The wasm module embeds the Montserrat faces for title metrics, so its size is +# dominated by font data — and the wasm-opt binary wasm-pack bundles is too old +# for the bulk-memory ops modern rustc emits. Skip it for a reproducible build. +[package.metadata.wasm-pack.profile.release] +wasm-opt = false [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 922b770..9b2b8c9 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ the sumi-ê palette) with [skvggor.dev](https://skvggor.dev) and Only the **title** is required; category, date, number and brand are optional, keeping the cover generic enough for any platform (blog, dev.to, LinkedIn, X, OG image, thumbnail…). +## Try it in the browser + +The [landing page](https://skvggor.github.io/acag/) runs the real engine, +compiled to WebAssembly: the house plates a new cover every few seconds, and you +can type your own title and watch it typeset live. + ## Download Prebuilt binaries are attached to each [GitHub release](https://github.com/skvggor/acag/releases). No Rust toolchain or system dependencies required. @@ -155,6 +161,11 @@ cargo run --example gallery # regenerate docs/samples cargo run --example icon # regenerate the app icon ``` +The landing page lives in `web/` and deploys to GitHub Pages on pushes to +`main` (`deploy-site.yml`): `wasm-pack` compiles the pure cover core, the +`site` example renders the page's imagery with the engine itself, and +`build_site` assembles `dist/`. + ## Credits - **Montserrat** by Julieta Ulanovsky et al., under the [SIL Open Font License](https://github.com/JulietaUla/Montserrat). diff --git a/build.rs b/build.rs index dc845cc..e5b53fd 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,6 @@ fn main() { + // Only the desktop app compiles the Slint UI; render-only and wasm builds skip it. + #[cfg(feature = "gui")] slint_build::compile("ui/app.slint").expect("failed to compile slint UI"); // Embed the app icon as a Windows resource so the .exe shows it in Explorer diff --git a/examples/build_site.rs b/examples/build_site.rs new file mode 100644 index 0000000..2c06404 --- /dev/null +++ b/examples/build_site.rs @@ -0,0 +1,129 @@ +//! Assembles the landing page for GitHub Pages: minifies `index.html` and copies +//! everything else from `web/` into `dist/` verbatim. Dependency-free (std only) +//! so it stays on-stack and adds nothing to the library's test build. +//! +//! Output goes to `dist/` (not `docs/`, which holds the README gallery samples). +//! The GitHub Pages workflow publishes `dist/`. +//! +//! Run with: `cargo run --example build_site` (after `cargo run --example site` +//! and the wasm-pack build have populated `web/`). + +use std::fs; +use std::path::Path; + +/// Collapse whitespace and drop comments in HTML markup, leaving the content of +/// `" + } else { + "" + }; + let end = lower[start..] + .find(close) + .map(|position| start + position + close.len()) + .unwrap_or(html.len()); + out.push_str(&html[start..end]); + index = end; + } + None => { + out.push_str(&collapse_markup(&html[index..])); + break; + } + } + } + out +} + +fn collapse_markup(segment: &str) -> String { + // Strip `` comments. + let mut stripped = String::with_capacity(segment.len()); + let mut rest = segment; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + end + 3..], + None => { + rest = ""; + break; + } + } + } + stripped.push_str(rest); + + // Collapse every whitespace run to a single space. + let mut out = String::with_capacity(stripped.len()); + let mut previous_was_space = false; + for character in stripped.chars() { + if character.is_whitespace() { + if !previous_was_space { + out.push(' '); + previous_was_space = true; + } + } else { + out.push(character); + previous_was_space = false; + } + } + out +} + +fn copy_tree(source: &Path, destination: &Path, minified: &mut usize, copied: &mut usize) { + for entry in fs::read_dir(source).expect("read source dir") { + let entry = entry.expect("dir entry"); + let path = entry.path(); + let name = entry.file_name(); + + // Never carry wasm-pack's `.gitignore` (which would hide dist/ from git). + if name == ".gitignore" { + continue; + } + + let target = destination.join(&name); + if path.is_dir() { + fs::create_dir_all(&target).expect("create dir"); + copy_tree(&path, &target, minified, copied); + } else if name == "index.html" { + let html = fs::read_to_string(&path).expect("read html"); + let small = minify_html(&html); + fs::write(&target, &small).expect("write html"); + println!( + " minify {} : {} -> {} bytes", + name.to_string_lossy(), + html.len(), + small.len() + ); + *minified += 1; + } else { + fs::copy(&path, &target).expect("copy file"); + *copied += 1; + } + } +} + +fn main() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = root.join("web"); + let output = root.join("dist"); + + if output.exists() { + fs::remove_dir_all(&output).expect("clean dist"); + } + fs::create_dir_all(&output).expect("create dist"); + + let mut minified = 0; + let mut copied = 0; + copy_tree(&source, &output, &mut minified, &mut copied); + println!("built dist/: {minified} minified, {copied} copied verbatim"); +} diff --git a/examples/site.rs b/examples/site.rs new file mode 100644 index 0000000..6f52b7c --- /dev/null +++ b/examples/site.rs @@ -0,0 +1,102 @@ +//! Generates the landing page's static imagery with the app's own engine, the +//! way `gallery.rs` and `icon.rs` do: a 1200×630 Open Graph card (a real cover +//! in the 1.91:1 link format), the poster cover used as the no-JS / no-WASM +//! fallback, the favicon / PWA icon sizes, and the Montserrat webfont copies. +//! Deterministic — the same configs always produce the same files. +//! +//! Run with: `cargo run --example site --no-default-features --features render` + +use std::fs; +use std::path::Path; + +use anyhow::Result; + +use article_cover_art_generator::cover::config::CoverConfig; +use article_cover_art_generator::cover::format::Format; +use article_cover_art_generator::cover::layouts::Layout; +use article_cover_art_generator::cover::render_cover_svg; +use article_cover_art_generator::design::patterns::Pattern; +use article_cover_art_generator::design::themes::ThemeName; +use article_cover_art_generator::raster; + +/// The cover shown before the live engine takes over — mirrored by +/// `FIRST_PLATE` in `web/js/main.js`, so the swap to the live render is +/// invisible. +fn poster_config() -> CoverConfig { + CoverConfig { + title: "Design systems that scale".to_owned(), + category: "engineering".to_owned(), + date: String::new(), + number: "014".to_owned(), + brand: "skvggor.dev".to_owned(), + theme: ThemeName::Terracotta, + pattern: Pattern::Seigaiha, + layout: Layout::Editorial, + format: Format::Square, + grain: 0.25, + pattern_strength: 1.0, + } +} + +/// The Open Graph card is itself a cover in the link format — exactly the +/// artwork the app would export for sharing this page. +fn og_config() -> CoverConfig { + CoverConfig { + title: "Article cover art, plated by the house".to_owned(), + category: "omakase".to_owned(), + date: String::new(), + number: String::new(), + brand: "github.com/skvggor/acag".to_owned(), + theme: ThemeName::Terracotta, + pattern: Pattern::Seigaiha, + layout: Layout::Editorial, + format: Format::Social, + grain: 0.25, + pattern_strength: 1.0, + } +} + +/// Rasterize the app's own logo SVG into the favicon / PWA icon sizes the page +/// links. +fn write_icons(root: &Path) -> Result<()> { + let icons = root.join("web/assets/icons"); + fs::create_dir_all(&icons)?; + let logo = fs::read_to_string(root.join("assets/icons/icon.svg"))?; + for size in [32u32, 180, 192, 512] { + let path = icons.join(format!("icon-{size}.png")); + fs::write(&path, raster::png_bytes(&logo, size)?)?; + println!("wrote {}", path.display()); + } + Ok(()) +} + +/// The injected cover SVG asks for Montserrat by family name, so the page +/// serves the same faces the engine measures with. +fn write_fonts(root: &Path) -> Result<()> { + let fonts = root.join("web/assets/fonts"); + fs::create_dir_all(&fonts)?; + for weight in ["Regular", "Bold", "Black"] { + let name = format!("Montserrat-{weight}.ttf"); + fs::copy(root.join("assets/fonts").join(&name), fonts.join(&name))?; + println!("wrote {}", fonts.join(&name).display()); + } + Ok(()) +} + +fn main() -> Result<()> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let img = root.join("web/assets/img"); + fs::create_dir_all(&img)?; + + let og = raster::png_bytes(&render_cover_svg(&og_config()), 1200)?; + fs::write(img.join("og.png"), &og)?; + println!("wrote {}", img.join("og.png").display()); + + let poster = raster::png_bytes(&render_cover_svg(&poster_config()), 1080)?; + fs::write(img.join("poster.png"), &poster)?; + println!("wrote {}", img.join("poster.png").display()); + + write_icons(root)?; + write_fonts(root)?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 4f64663..6e95f39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,13 @@ pub mod cover; pub mod design; +pub mod wasm; + +// Rasterization, film grain, file I/O. Excluded from the wasm build, which needs +// just the pure cover core above. +#[cfg(feature = "render")] pub mod export; +#[cfg(feature = "render")] pub mod preset; +#[cfg(feature = "render")] pub mod raster; diff --git a/src/wasm.rs b/src/wasm.rs new file mode 100644 index 0000000..d9102d7 --- /dev/null +++ b/src/wasm.rs @@ -0,0 +1,221 @@ +//! WebAssembly surface for the landing-page hero. +//! +//! Re-exposes the native cover core unchanged to JavaScript: the exact +//! [`render_cover_svg`] the desktop app rasterizes, addressed by flat, +//! index-based parameters, plus a JSON catalog of themes / patterns / layouts / +//! formats so the page builds its controls from the same source of truth. The +//! pure builders are compiled on every target, so their parity with the core is +//! unit-tested natively; only the thin `#[wasm_bindgen]` entry points are +//! wasm-only. + +use crate::cover::config::CoverConfig; +use crate::cover::format::Format; +use crate::cover::layouts::Layout; +use crate::cover::render_cover_svg; +use crate::design::patterns::Pattern; +use crate::design::themes::ThemeName; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +/// Build a cover SVG from wasm-friendly flat parameters. Style indices address +/// the `ALL` arrays; out-of-range values fall back to the first variant so a +/// stale page can never panic the module. Sliders are clamped to `[0, 1]`. +#[allow(clippy::too_many_arguments)] +pub fn cover_svg( + title: &str, + category: &str, + date: &str, + number: &str, + brand: &str, + theme: usize, + pattern: usize, + layout: usize, + format: usize, + pattern_strength: f64, + grain: f64, +) -> String { + let config = CoverConfig { + title: title.to_owned(), + category: category.to_owned(), + date: date.to_owned(), + number: number.to_owned(), + brand: brand.to_owned(), + theme: *ThemeName::ALL.get(theme).unwrap_or(&ThemeName::ALL[0]), + pattern: *Pattern::ALL.get(pattern).unwrap_or(&Pattern::ALL[0]), + layout: *Layout::ALL.get(layout).unwrap_or(&Layout::ALL[0]), + format: *Format::ALL.get(format).unwrap_or(&Format::ALL[0]), + grain: grain.clamp(0.0, 1.0), + pattern_strength: pattern_strength.clamp(0.0, 1.0), + }; + render_cover_svg(&config) +} + +/// Every theme (with its palette), pattern, layout, and format the app offers, +/// as one JSON document — hand-assembled so the wasm build stays free of a JSON +/// dependency. Labels are romanized ASCII and hex colors, so no escaping is +/// needed. +pub fn catalog_json() -> String { + let themes: Vec = ThemeName::ALL + .iter() + .map(|name| { + let palette = name.palette(); + format!( + "{{\"label\":\"{}\",\"bg\":\"{}\",\"line\":\"{}\",\"text\":\"{}\",\"accent\":\"{}\"}}", + name.label(), + palette.bg.to_hex(), + palette.line.to_hex(), + palette.text.to_hex(), + palette.accent.to_hex(), + ) + }) + .collect(); + let patterns: Vec = Pattern::ALL + .iter() + .map(|pattern| format!("\"{}\"", pattern.label())) + .collect(); + let layouts: Vec = Layout::ALL + .iter() + .map(|layout| format!("\"{}\"", layout.label())) + .collect(); + let formats: Vec = Format::ALL + .iter() + .map(|format| { + let (width, height) = format.dimensions(); + format!( + "{{\"label\":\"{}\",\"width\":{width:.0},\"height\":{height:.0}}}", + format.label(), + ) + }) + .collect(); + format!( + "{{\"themes\":[{}],\"patterns\":[{}],\"layouts\":[{}],\"formats\":[{}]}}", + themes.join(","), + patterns.join(","), + layouts.join(","), + formats.join(","), + ) +} + +/// JavaScript entry point: plate one cover as an SVG string. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn render_cover( + title: &str, + category: &str, + date: &str, + number: &str, + brand: &str, + theme: usize, + pattern: usize, + layout: usize, + format: usize, + pattern_strength: f64, + grain: f64, +) -> String { + cover_svg( + title, + category, + date, + number, + brand, + theme, + pattern, + layout, + format, + pattern_strength, + grain, + ) +} + +/// JavaScript entry point: the style catalog as JSON. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn catalog() -> String { + catalog_json() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plain_cover(theme: usize, pattern: usize, layout: usize, format: usize) -> String { + cover_svg( + "Design systems that scale", + "engineering", + "", + "014", + "skvggor.dev", + theme, + pattern, + layout, + format, + 1.0, + 0.0, + ) + } + + #[test] + fn renders_the_same_svg_as_the_native_core() { + let config = CoverConfig { + title: "Design systems that scale".to_owned(), + category: "engineering".to_owned(), + date: String::new(), + number: "014".to_owned(), + brand: "skvggor.dev".to_owned(), + theme: ThemeName::Ai, + pattern: Pattern::Shippo, + layout: Layout::Ma, + format: Format::Social, + grain: 0.0, + pattern_strength: 1.0, + }; + let expected = render_cover_svg(&config); + let actual = plain_cover( + ThemeName::Ai.index(), + Pattern::Shippo.index(), + Layout::Ma.index(), + Format::Social.index(), + ); + assert_eq!(actual, expected); + } + + #[test] + fn format_index_drives_the_viewbox() { + assert!(plain_cover(0, 0, 0, 0).contains("viewBox=\"0 0 1080 1080\"")); + assert!(plain_cover(0, 0, 0, 1).contains("viewBox=\"0 0 1200 630\"")); + assert!(plain_cover(0, 0, 0, 3).contains("viewBox=\"0 0 1080 1350\"")); + } + + #[test] + fn out_of_range_indices_fall_back_to_the_first_variant() { + assert_eq!(plain_cover(99, 99, 99, 99), plain_cover(0, 0, 0, 0)); + } + + #[test] + fn sliders_are_clamped() { + let over = cover_svg("T", "", "", "", "", 0, 0, 0, 0, 7.0, 7.0); + let max = cover_svg("T", "", "", "", "", 0, 0, 0, 0, 1.0, 1.0); + assert_eq!(over, max); + } + + #[test] + fn catalog_lists_every_style_with_palettes() { + let json = catalog_json(); + for theme in ThemeName::ALL { + assert!(json.contains(&format!("\"label\":\"{}\"", theme.label()))); + assert!(json.contains(&theme.palette().bg.to_hex())); + } + for pattern in Pattern::ALL { + assert!(json.contains(&format!("\"{}\"", pattern.label()))); + } + for layout in Layout::ALL { + assert!(json.contains(&format!("\"{}\"", layout.label()))); + } + for format in Format::ALL { + assert!(json.contains(&format!("\"label\":\"{}\"", format.label()))); + } + assert!(json.contains("\"width\":1200") && json.contains("\"height\":630")); + } +} diff --git a/web/.nojekyll b/web/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/web/assets/fonts/Montserrat-Black.ttf b/web/assets/fonts/Montserrat-Black.ttf new file mode 100644 index 0000000..b4359a0 Binary files /dev/null and b/web/assets/fonts/Montserrat-Black.ttf differ diff --git a/web/assets/fonts/Montserrat-Bold.ttf b/web/assets/fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000..a3dbde5 Binary files /dev/null and b/web/assets/fonts/Montserrat-Bold.ttf differ diff --git a/web/assets/fonts/Montserrat-Regular.ttf b/web/assets/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..3ef210a Binary files /dev/null and b/web/assets/fonts/Montserrat-Regular.ttf differ diff --git a/web/assets/icons/icon-180.png b/web/assets/icons/icon-180.png new file mode 100644 index 0000000..054bc14 Binary files /dev/null and b/web/assets/icons/icon-180.png differ diff --git a/web/assets/icons/icon-192.png b/web/assets/icons/icon-192.png new file mode 100644 index 0000000..a21d16b Binary files /dev/null and b/web/assets/icons/icon-192.png differ diff --git a/web/assets/icons/icon-32.png b/web/assets/icons/icon-32.png new file mode 100644 index 0000000..57061d4 Binary files /dev/null and b/web/assets/icons/icon-32.png differ diff --git a/web/assets/icons/icon-512.png b/web/assets/icons/icon-512.png new file mode 100644 index 0000000..61356ad Binary files /dev/null and b/web/assets/icons/icon-512.png differ diff --git a/web/assets/img/og.png b/web/assets/img/og.png new file mode 100644 index 0000000..8b0b2ae Binary files /dev/null and b/web/assets/img/og.png differ diff --git a/web/assets/img/poster.png b/web/assets/img/poster.png new file mode 100644 index 0000000..531336a Binary files /dev/null and b/web/assets/img/poster.png differ diff --git a/web/css/style.css b/web/css/style.css new file mode 100644 index 0000000..fa3ebf0 --- /dev/null +++ b/web/css/style.css @@ -0,0 +1,818 @@ +/* An omakase counter. The plate (the live cover) holds the stage on deep sumi + ink; the content sits on a washi menu card; a vermilion hanko signs it. The + whole sheet tints itself from the current cover's theme. */ + +@font-face { + font-family: "Montserrat"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/Montserrat-Regular.ttf") format("truetype"); +} + +@font-face { + font-family: "Montserrat"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../assets/fonts/Montserrat-Bold.ttf") format("truetype"); +} + +@font-face { + font-family: "Montserrat"; + font-style: normal; + font-weight: 900; + font-display: swap; + src: url("../assets/fonts/Montserrat-Black.ttf") format("truetype"); +} + +/* Registered so the theme tint can interpolate on every omakase. */ +@property --tint { + syntax: ""; + inherits: true; + initial-value: #d89868; +} + +@property --bg-tint { + syntax: ""; + inherits: true; + initial-value: #2d231b; +} + +:root { + --bg: #161310; /* deep warm sumi ink */ + --bg-tint: #2d231b; /* live: 12% of the theme's background over the ink */ + --text: #f2ecdd; /* cream, ≈ 15:1 on the ink (AAA) */ + --dim: #b5a996; /* ≈ 8:1 (AAA) */ + --faint: #8d8271; /* ≈ 4.6:1, decorative */ + --tint: #d89868; /* live: the current theme's background hue */ + --vermilion: #c73e2d; /* the hanko — fixed, never themed */ + --paper: #f2ecdd; /* the washi counter card */ + --paper-ink: #241a12; /* ≈ 13:1 on paper (AAA) */ + --sans: "Montserrat", ui-sans-serif, system-ui, "Segoe UI", sans-serif; + --display: var(--sans); + --kanji: "Noto Serif CJK JP", "Noto Serif JP", "Hiragino Mincho ProN", "Yu Mincho", serif; + --margin: clamp(1.1rem, 3.4vw, 2.75rem); + --counter-w: min(23rem, 30vw); +} + +* { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100svh; + background: var(--bg-tint); + color: var(--text); + font-family: var(--sans); + line-height: 1.55; + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--tint); + text-decoration: none; +} + +:focus-visible { + outline: 2px solid var(--tint); + outline-offset: 4px; +} + +.counter :focus-visible, +.downloads :focus-visible { + outline-color: var(--paper-ink); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + overflow: hidden; + white-space: nowrap; +} + +.skip-link { + position: fixed; + top: 0.5rem; + left: 0.5rem; + z-index: 100; + padding: 0.55rem 0.9rem; + background: var(--text); + color: var(--bg); + font-weight: 700; + transform: translateY(-160%); + transition: transform 0.18s ease; +} +.skip-link:focus { + transform: translateY(0); +} + +/* ---- The sheet: wagara ground + film grain, both theme-tinted ---- */ +.ground, +.grain { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; +} + +/* Seigaiha fans as an alpha mask, painted with the live tint. Same geometry as + the engine's pattern: overlapping upper semicircles in three concentric radii, + rows offset by half a period. */ +.ground { + background: var(--tint); + opacity: 0.05; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cpath fill='none' stroke='white' stroke-width='2' d='M-120,0%20A60,60%200%200%200%200,0%20M-100,0%20A40,40%200%200%200%20-20,0%20M-80,0%20A20,20%200%200%200%20-40,0%20M0,0%20A60,60%200%200%200%20120,0%20M20,0%20A40,40%200%200%200%20100,0%20M40,0%20A20,20%200%200%200%2080,0%20M120,0%20A60,60%200%200%200%20240,0%20M140,0%20A40,40%200%200%200%20220,0%20M160,0%20A20,20%200%200%200%20200,0%20M-60,60%20A60,60%200%200%200%2060,60%20M-40,60%20A40,40%200%200%200%2040,60%20M-20,60%20A20,20%200%200%200%2020,60%20M60,60%20A60,60%200%200%200%20180,60%20M80,60%20A40,40%200%200%200%20160,60%20M100,60%20A20,20%200%200%200%20140,60%20M-120,120%20A60,60%200%200%200%200,120%20M-100,120%20A40,40%200%200%200%20-20,120%20M-80,120%20A20,20%200%200%200%20-40,120%20M0,120%20A60,60%200%200%200%20120,120%20M20,120%20A40,40%200%200%200%20100,120%20M40,120%20A20,20%200%200%200%2080,120%20M120,120%20A60,60%200%200%200%20240,120%20M140,120%20A40,40%200%200%200%20220,120%20M160,120%20A20,20%200%200%200%20200,120'/%3E%3C/svg%3E"); + mask-repeat: repeat; + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cpath fill='none' stroke='white' stroke-width='2' d='M-120,0%20A60,60%200%200%200%200,0%20M-100,0%20A40,40%200%200%200%20-20,0%20M-80,0%20A20,20%200%200%200%20-40,0%20M0,0%20A60,60%200%200%200%20120,0%20M20,0%20A40,40%200%200%200%20100,0%20M40,0%20A20,20%200%200%200%2080,0%20M120,0%20A60,60%200%200%200%20240,0%20M140,0%20A40,40%200%200%200%20220,0%20M160,0%20A20,20%200%200%200%20200,0%20M-60,60%20A60,60%200%200%200%2060,60%20M-40,60%20A40,40%200%200%200%2040,60%20M-20,60%20A20,20%200%200%200%2020,60%20M60,60%20A60,60%200%200%200%20180,60%20M80,60%20A40,40%200%200%200%20160,60%20M100,60%20A20,20%200%200%200%20140,60%20M-120,120%20A60,60%200%200%200%200,120%20M-100,120%20A40,40%200%200%200%20-20,120%20M-80,120%20A20,20%200%200%200%20-40,120%20M0,120%20A60,60%200%200%200%20120,120%20M20,120%20A40,40%200%200%200%20100,120%20M40,120%20A20,20%200%200%200%2080,120%20M120,120%20A60,60%200%200%200%20240,120%20M140,120%20A40,40%200%200%200%20220,120%20M160,120%20A20,20%200%200%200%20200,120'/%3E%3C/svg%3E"); + -webkit-mask-repeat: repeat; +} + +.grain { + z-index: 1; + opacity: 0.09; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + mix-blend-mode: soft-light; +} + +/* ---- Crop marks (the sheet is a print proof) ---- */ +.marks { + position: fixed; + inset: 0; + z-index: 2; + pointer-events: none; +} +.mark { + position: absolute; + width: 13px; + height: 13px; + opacity: 0.5; +} +.mark::before, +.mark::after { + content: ""; + position: absolute; + background: var(--dim); +} +.mark::before { + inset: 6px 0 auto 0; + height: 1px; +} +.mark::after { + inset: 0 auto 0 6px; + width: 1px; +} +.mark--tl { + top: var(--margin); + left: var(--margin); +} +.mark--tr { + top: var(--margin); + right: var(--margin); +} +.mark--bl { + bottom: var(--margin); + left: var(--margin); +} +.mark--br { + bottom: var(--margin); + right: var(--margin); +} + +/* ---- The stage: the plate and its proof slug ---- */ +.stage { + position: fixed; + z-index: 2; + top: calc(var(--margin) + 2.4rem); + bottom: calc(var(--margin) + 2rem); + left: var(--margin); + right: calc(var(--counter-w) + var(--margin) * 2); + display: flex; + align-items: center; + justify-content: center; +} + +.plate { + margin: 0; + max-width: 100%; + max-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.95rem; +} + +/* The cover itself. JS drives the exact width/height per aspect ratio; + without JS the square poster stands in. */ +.plate__card { + position: relative; + display: block; + width: min(100%, 60vh, 34rem); + aspect-ratio: 1; + padding: 0; + border: 0; + background: var(--tint); + cursor: pointer; + box-shadow: + 0 26px 70px rgba(8, 6, 4, 0.55), + 0 4px 18px rgba(8, 6, 4, 0.35); +} + +.plate__poster, +.plate__cover { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.plate__poster { + display: block; + object-fit: cover; +} +.plate__poster[hidden] { + display: none; +} +.plate__cover svg { + display: block; + width: 100%; + height: 100%; +} + +/* The proof slug: theme 赤土 · pattern · layout · ratio. */ +.plate__slug { + margin: 0; + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--dim); + white-space: nowrap; +} +.plate__slug > span:not(:first-child)::before { + content: "·"; + margin: 0 0.6em; + color: var(--faint); +} +.plate__slug [lang="ja"] { + font-family: var(--kanji); + letter-spacing: 0.1em; +} + +/* ---- The counter card: a washi menu card, signed with a hanko ---- */ +.counter { + position: fixed; + z-index: 3; + top: 50%; + right: var(--margin); + width: var(--counter-w); + transform: translateY(-50%) rotate(-0.4deg); + padding: 2rem 1.7rem 2.2rem; + background: var(--paper); + color: var(--paper-ink); + box-shadow: + 0 22px 55px rgba(8, 6, 4, 0.5), + 0 3px 12px rgba(8, 6, 4, 0.3); +} +/* The kicker tick, echoing the one the engine draws on every cover. */ +.counter::before { + content: ""; + position: absolute; + top: 1.1rem; + left: 1.7rem; + width: 56px; + height: 7px; + background: var(--vermilion); +} +/* The house seal, stamped slightly askew. */ +.counter::after { + content: "a"; + position: absolute; + right: 0.95rem; + bottom: 0.9rem; + width: 1.9rem; + height: 1.9rem; + display: grid; + place-items: center; + background: var(--vermilion); + color: var(--paper); + font-family: var(--display); + font-weight: 900; + font-size: 1.05rem; + line-height: 1; + border-radius: 3px; + transform: rotate(6deg); +} + +.counter__statement { + margin: 1.1rem 0 0; + font-family: var(--display); + font-weight: 900; + font-size: clamp(0.95rem, 1.3vw, 1.15rem); + line-height: 1.22; + text-transform: uppercase; + letter-spacing: 0.02em; +} +.counter__statement > span { + white-space: nowrap; +} + +.counter__detail { + margin: 0.55rem 0 0; + font-size: 0.72rem; + line-height: 1.6; + opacity: 0.78; + text-wrap: pretty; +} + +.field { + display: block; + margin-top: 1.15rem; +} +.field__label { + display: block; + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.16em; + opacity: 0.65; +} +.field__input { + width: 100%; + padding: 0.35rem 0; + border: 0; + border-bottom: 2px solid rgba(36, 26, 18, 0.35); + background: transparent; + color: var(--paper-ink); + font-family: var(--display); + font-weight: 700; + font-size: 0.95rem; + caret-color: var(--vermilion); +} +.field__input::placeholder { + color: rgba(36, 26, 18, 0.42); +} +.field__input:focus { + outline: none; + border-bottom-color: var(--vermilion); +} + +/* Theme stamps: one mini palette per theme, built from the engine's catalog. */ +.themes { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.15rem; +} +.theme { + width: 2.4rem; + height: 2.4rem; + display: grid; + place-items: center; + padding: 0; + border: 2px solid rgba(36, 26, 18, 0.18); + border-radius: 50%; + font-family: var(--kanji); + font-size: 0.92rem; + line-height: 1; + cursor: pointer; + transition: transform 0.15s ease; +} +.theme:hover, +.theme:focus-visible { + transform: scale(1.1); +} +.theme[aria-pressed="true"] { + border-color: var(--vermilion); + box-shadow: 0 0 0 2px var(--paper), 0 0 0 3.5px var(--vermilion); +} + +/* Primary interaction: the hanko. Press it, the house decides. */ +.omakase { + display: flex; + align-items: center; + gap: 0.85rem; + margin-top: 1.35rem; + padding: 0; + border: 0; + background: none; + color: var(--paper-ink); + font: inherit; + cursor: pointer; +} +.omakase__seal { + position: relative; + width: 3.2rem; + height: 3.2rem; + display: grid; + place-items: center; + background: var(--vermilion); + color: var(--paper); + border-radius: 50%; + font-family: var(--kanji); + font-size: 1.5rem; + line-height: 1; + box-shadow: + inset 0 0 0 2px rgba(242, 236, 221, 0.22), + 0 3px 10px rgba(8, 6, 4, 0.3); + transition: transform 0.12s ease; +} +.omakase:hover .omakase__seal, +.omakase:focus-visible .omakase__seal { + transform: scale(1.06); +} +.omakase:active .omakase__seal { + transform: scale(0.92); +} +.omakase__seal::after { + content: ""; + position: absolute; + inset: 0; + border: 1px solid var(--vermilion); + border-radius: 50%; + opacity: 0; + pointer-events: none; +} +.omakase:hover .omakase__seal::after, +.omakase:active .omakase__seal::after { + animation: seal-radiate 1.1s ease-out infinite; +} +@keyframes seal-radiate { + from { + transform: scale(1); + opacity: 0.7; + } + to { + transform: scale(1.9); + opacity: 0; + } +} +.omakase__label { + font-family: var(--display); + font-weight: 900; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + border-bottom: 2px solid transparent; +} +.omakase:hover .omakase__label { + border-bottom-color: var(--vermilion); +} + +.counter__hint { + margin: 1.05rem 0 0; + padding-right: 2.4rem; /* clears the house seal in the corner */ + font-size: 0.62rem; + line-height: 1.5; + opacity: 0.68; + text-wrap: pretty; +} +.counter__hint [lang="ja"] { + font-family: var(--kanji); +} + +/* ---- The stamp drawer: downloads ---- */ +.downloads-wrap { + position: fixed; + z-index: 4; + right: var(--margin); + bottom: calc(var(--margin) + 1.6rem); +} + +.download-toggle { + padding: 0.6rem 1rem; + border: 1px solid rgba(242, 236, 221, 0.3); + background: rgba(22, 19, 16, 0.55); + color: var(--text); + font: inherit; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; +} +.download-toggle:hover { + border-color: var(--tint); + color: var(--tint); +} + +.downloads { + position: absolute; + bottom: calc(100% + 0.6rem); + right: 0; + width: 19.5rem; + display: grid; + gap: 0.15rem; + padding: 1.35rem 1.15rem 1.15rem; + background: var(--paper); + color: var(--paper-ink); + box-shadow: 0 22px 55px rgba(8, 6, 4, 0.5); +} +.downloads[hidden] { + display: none; +} +.downloads::before { + content: ""; + position: absolute; + top: 0.65rem; + left: 1.15rem; + width: 40px; + height: 6px; + background: var(--vermilion); +} + +/* While the drawer is open, one scrim veils everything behind it uniformly. */ +.downloads-open::after { + content: ""; + position: fixed; + inset: 0; + z-index: 5; + background: rgba(12, 10, 8, 0.64); + animation: scrim-in 0.25s ease; +} +@keyframes scrim-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.downloads-open .downloads-wrap { + z-index: 6; +} +.download-toggle { + transition: opacity 0.25s ease, border-color 0.15s ease, color 0.15s ease; +} +.downloads-open .download-toggle { + opacity: 0.25; +} + +.downloads__close { + justify-self: end; + padding: 0.15rem 0.35rem; + border: 0; + background: none; + color: var(--paper-ink); + font: inherit; + font-size: 0.72rem; + cursor: pointer; + opacity: 0.75; +} +.download__note { + display: none; /* desktop-only builds — only worth saying on touch devices */ + margin: 0 0 0.4rem; + padding: 0.4rem 0.5rem; + font-size: 0.74rem; + line-height: 1.5; + border-bottom: 1px dotted currentColor; +} +@media (pointer: coarse) { + .download__note { + display: block; + } +} +.download { + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.45rem 0.8rem; + border-radius: 999px; + color: var(--paper-ink); + transition: background 0.12s ease; +} +.download:hover, +.download:focus-visible { + background: rgba(36, 26, 18, 0.1); +} +.download__icon { + width: 1.5rem; + height: 1.5rem; + flex: none; + color: var(--paper-ink); +} +.download__os { + font-weight: 700; +} +.download__format { + margin-left: auto; + font-size: 0.72rem; + opacity: 0.7; +} +.download--minor { + font-size: 0.82rem; +} +.download--minor .download__os { + font-weight: 400; +} +.download--minor .download__icon { + width: 1.15rem; + height: 1.15rem; + opacity: 0.7; +} +.download--all { + justify-content: center; + padding-left: 0; + font-weight: 700; + opacity: 1; +} + +/* ---- Loose margin annotations ---- */ +.source { + position: fixed; + z-index: 3; + left: var(--margin); + top: var(--margin); + color: var(--text); + font-size: 0.78rem; + border-bottom: 1px solid var(--tint); + padding-bottom: 0.15rem; + transition: color 0.15s ease; +} +.source:hover { + color: var(--tint); +} +.source::after { + content: "→"; + display: inline-block; + margin-left: 0.35rem; + transition: transform 0.15s ease; +} +.source:hover::after { + transform: translateX(4px); +} + +.edge { + position: fixed; + z-index: 3; + left: 50%; + transform: translateX(-50%); + margin: 0; + padding: 0.2rem 0.7rem; + background: var(--bg-tint); /* opaque backing over the ground so the label stays legible */ + color: var(--faint); + font-size: 0.68rem; + letter-spacing: 0.16em; + text-transform: uppercase; + white-space: nowrap; +} +.edge--bottom { + bottom: calc(var(--margin) - 0.15rem); +} + +/* ---- Cursor follower: a hanko dot trailing the pointer ---- */ +.follower { + position: fixed; + z-index: 90; + top: 0; + left: 0; + width: 26px; + height: 26px; + margin: -13px 0 0 -13px; + border: 1.5px solid var(--vermilion); + border-radius: 50%; + opacity: 0; + pointer-events: none; + will-change: transform; +} +.follower::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 4px; + margin: -2px 0 0 -2px; + border-radius: 50%; + background: var(--vermilion); +} +@media not (pointer: fine) { + .follower { + display: none; + } +} + +/* ---- The live engine is unavailable: the poster stands in, controls bow out ---- */ +.no-live .field, +.no-live .themes, +.no-live .omakase { + display: none; +} +.no-live .plate__card { + cursor: default; +} + +/* ---- Small viewports: plate on top, counter card below ---- */ +@media (max-width: 52rem) { + .stage { + top: calc(var(--margin) + 2.2rem); + bottom: auto; + left: var(--margin); + right: var(--margin); + height: min(46vh, 24rem); + } + .plate__card { + width: min(100%, 40vh, 34rem); + } + .counter { + top: auto; + bottom: calc(var(--margin) + 3.6rem); + left: var(--margin); + right: var(--margin); + width: auto; + transform: rotate(-0.4deg); + padding: 1.2rem 1.15rem 1.5rem; + } + .counter::before { + top: 0.7rem; + left: 1.15rem; + } + .counter__statement { + margin-top: 0.7rem; + } + .counter__detail { + display: none; + } + .field { + margin-top: 0.75rem; + } + .themes, + .omakase { + margin-top: 0.85rem; + } + .counter__hint { + margin-top: 0.7rem; + } + .plate__slug { + font-size: 0.58rem; + letter-spacing: 0.1em; + white-space: normal; + text-align: center; + max-width: 100%; + } + .downloads-wrap { + right: var(--margin); + bottom: var(--margin); + } + /* The drawer becomes a bottom sheet. */ + .downloads { + position: fixed; + top: auto; + left: calc(var(--margin) * 0.5); + right: calc(var(--margin) * 0.5); + bottom: calc(var(--margin) * 0.5); + width: auto; + max-height: 48vh; + overflow-y: auto; + z-index: 6; + } + .source { + top: auto; + bottom: calc(var(--margin) + 0.05rem); + left: var(--margin); + max-width: calc(100vw - 2 * var(--margin) - 9rem); + font-size: 0.7rem; + text-wrap: balance; + background: var(--bg-tint); + padding: 0.2rem 0.4rem 0.3rem; + } + .edge--bottom { + display: none; + } +} + +/* Reveal baseline: JS fades the counter in. Without JS it stays visible. */ +.js [data-reveal] { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .js [data-reveal] { + opacity: 1; + } + .skip-link { + transition: none; + } + .theme, + .omakase__seal, + .download-toggle { + transition: none; + } + .omakase:hover .omakase__seal::after, + .omakase:active .omakase__seal::after { + animation: none; + } + .follower { + display: none; + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e6fd6f2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,204 @@ + + + + + + + + + acag · article cover art generator — omakase covers, plated live + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ acag — article cover art generator: omakase covers in six sumi-ê themes, five wagara + patterns, three layouts, five formats +

+ + + + + +
+
+ +
+ terracotta 赤土 + seigaiha + editorial + 1:1 +
+
+
+ + + + + +
+ + +
+ + + Read the source, build it yourself + + + + + + + + + + diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..4138354 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,531 @@ +// The omakase counter. The real acag engine (Rust → WebAssembly) plates every +// cover on the page: the same render_cover_svg the desktop app rasterizes, +// injected as inline SVG. Each round the house re-rolls the style, the plate +// morphs to the new aspect ratio, and the engine typesets the title live, +// character by character — auto-wrap, auto-size, WCAG AAA, all of it running in +// the browser. Reduced motion / no WASM / no GSAP fall back to the pre-rendered +// poster (the same config, rendered by the same code at build time). + +const BASE_INK = { r: 0x16, g: 0x13, b: 0x10 }; +const BG_MIX = 0.12; // how much of the theme's background tints the page ink +const TINT_MIN_LIGHTNESS = 0.58; // dark theme hues are lifted to stay legible +const DRIFT_SECONDS = 11; // idle time before the house plates the next cover +const FIRST_DRIFT_SECONDS = 6.5; +const TYPE_MIN_MS = 30; +const TYPE_JITTER_MS = 40; +const PARALLAX_PX = 7; +const PARALLAX_DEG = 1.3; + +// Mirrors `poster_config()` in examples/site.rs, so swapping the poster for the +// first live render is invisible. +const FIRST_PLATE = { title: 'Design systems that scale', category: 'engineering', number: '014' }; +const FIRST_STYLE = { theme: 0, pattern: 0, layout: 0, format: 0, strength: 1, grain: 0.25 }; +const BRAND = 'skvggor.dev'; + +// The house menu: titles the engine typesets while the visitor watches. +const PLATES = [ + FIRST_PLATE, + { title: 'Performance without the magic', category: 'engineering', number: '021' }, + { title: 'The quiet art of refactoring legacy code', category: 'essays', number: '007' }, + { title: 'Ship less, design more', category: 'product', number: '032' }, + { title: 'Naming things is design work', category: 'design', number: '009' }, + { title: 'A field guide to code review', category: 'process', number: '017' }, + { title: 'Typography for terminal people', category: 'design', number: '026' }, + { title: 'What the compiler taught me about patience', category: 'essays', number: '041' }, +]; + +// Page-chrome kanji only — the engine never draws Japanese glyphs on artwork. +const THEME_KANJI = { + terracotta: '赤土', + sumi: '墨', + matcha: '抹茶', + washi: '和紙', + ai: '藍', + sakura: '桜', +}; + +const root = document.documentElement; +const stage = document.getElementById('stage'); +const card = document.getElementById('card'); +const cover = document.getElementById('cover'); +const poster = document.getElementById('poster'); +const slug = document.getElementById('slug'); +const slugTheme = document.getElementById('slug-theme'); +const slugPattern = document.getElementById('slug-pattern'); +const slugLayout = document.getElementById('slug-layout'); +const slugFormat = document.getElementById('slug-format'); +const themesGroup = document.getElementById('themes'); +const omakaseButton = document.getElementById('omakase'); +const titleInput = document.getElementById('title-input'); +const hint = document.getElementById('hint'); +const downloadToggle = document.getElementById('download-toggle'); +const downloadsPanel = document.getElementById('downloads'); +const downloadsClose = document.getElementById('downloads-close'); +const followerElement = document.getElementById('follower'); + +const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; +const hasGsap = typeof window.gsap !== 'undefined'; +const animated = hasGsap && !reducedMotion; + +root.classList.add('js'); + +let renderCover = null; +let catalog = null; +let live = false; +let style = { ...FIRST_STYLE }; +let plateIndex = 0; +let plate = PLATES[plateIndex]; +let customTitle = null; +let typeCall = null; +let driftCall = null; +let renderQueued = false; + +function clamp(value, low, high) { + return value < low ? low : value > high ? high : value; +} + +/* ---- Color math: tint the chrome from the engine's own palette ---- */ + +function hexToRgb(hex) { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + }; +} + +function rgbToHex({ r, g, b }) { + const part = (v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0'); + return `#${part(r)}${part(g)}${part(b)}`; +} + +function mix(a, b, amount) { + return { + r: a.r + (b.r - a.r) * amount, + g: a.g + (b.g - a.g) * amount, + b: a.b + (b.b - a.b) * amount, + }; +} + +function rgbToHsl({ r, g, b }) { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) return { h: 0, s: 0, l }; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / d + 2) / 6; + else h = ((rn - gn) / d + 4) / 6; + return { h, s, l }; +} + +function hslToRgb({ h, s, l }) { + if (s === 0) return { r: l * 255, g: l * 255, b: l * 255 }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const channel = (t) => { + let tc = t; + if (tc < 0) tc += 1; + if (tc > 1) tc -= 1; + if (tc < 1 / 6) return p + (q - p) * 6 * tc; + if (tc < 1 / 2) return q; + if (tc < 2 / 3) return p + (q - p) * (2 / 3 - tc) * 6; + return p; + }; + return { r: channel(h + 1 / 3) * 255, g: channel(h) * 255, b: channel(h - 1 / 3) * 255 }; +} + +/** The theme's background hue, lifted enough to read on the dark chrome. */ +function chromeTint(themeBackground) { + const hsl = rgbToHsl(hexToRgb(themeBackground)); + if (hsl.l < TINT_MIN_LIGHTNESS) hsl.l = TINT_MIN_LIGHTNESS; + return rgbToHex(hslToRgb(hsl)); +} + +function pageInk(themeBackground) { + return rgbToHex(mix(BASE_INK, hexToRgb(themeBackground), BG_MIX)); +} + +/* ---- The engine ---- */ + +function currentTitle() { + return customTitle !== null ? customTitle : plate.title; +} + +function renderSvg(title, grain) { + return renderCover( + title, + plate.category, + '', + plate.number, + BRAND, + style.theme, + style.pattern, + style.layout, + style.format, + style.strength, + grain, + ); +} + +function inject(title, grain = style.grain) { + cover.innerHTML = renderSvg(title, grain); +} + +/* ---- Plate geometry ---- */ + +function cardSize() { + const box = stage.getBoundingClientRect(); + const slugSpace = slug.getBoundingClientRect().height + 18; + const availableWidth = box.width * 0.97; + const availableHeight = Math.max(120, box.height - slugSpace); + const { width, height } = catalog.formats[style.format]; + const scale = Math.min(availableWidth / width, availableHeight / height); + return { width: width * scale, height: height * scale }; +} + +function applySize(immediate) { + const size = cardSize(); + if (animated && !immediate) { + return window.gsap.to(card, { + width: size.width, + height: size.height, + duration: 0.75, + ease: 'power3.inOut', + }); + } + card.style.width = `${size.width}px`; + card.style.height = `${size.height}px`; + card.style.aspectRatio = 'auto'; + return null; +} + +/* ---- Chrome tinting ---- */ + +function applyTint(immediate) { + const theme = catalog.themes[style.theme]; + const values = { '--tint': chromeTint(theme.bg), '--bg-tint': pageInk(theme.bg) }; + if (animated && !immediate) { + window.gsap.to(root, { ...values, duration: 0.75, ease: 'power2.inOut' }); + window.gsap.to(card, { backgroundColor: theme.bg, duration: 0.75, ease: 'power2.inOut' }); + return; + } + root.style.setProperty('--tint', values['--tint']); + root.style.setProperty('--bg-tint', values['--bg-tint']); + card.style.backgroundColor = theme.bg; +} + +function updateSlug() { + const theme = catalog.themes[style.theme]; + const kanji = THEME_KANJI[theme.label]; + slugTheme.innerHTML = kanji ? `${theme.label} ${kanji}` : theme.label; + slugPattern.textContent = catalog.patterns[style.pattern]; + slugLayout.textContent = catalog.layouts[style.layout]; + slugFormat.textContent = catalog.formats[style.format].label.replace(' · ', ' '); + for (const button of themesGroup.children) { + button.setAttribute('aria-pressed', String(Number(button.dataset.theme) === style.theme)); + } +} + +/* ---- The typewriter: the engine typesets while you watch ---- */ + +function cancelType() { + if (typeCall) { + typeCall.kill(); + typeCall = null; + } +} + +function typeTitle(title, onDone) { + cancelType(); + let position = 0; + const step = () => { + position += 1; + const done = position >= title.length; + // Grain only lands with the final stroke; feTurbulence per keystroke is waste. + inject(title.slice(0, position), done ? style.grain : 0); + if (done) { + typeCall = null; + onDone(); + return; + } + typeCall = window.gsap.delayedCall((TYPE_MIN_MS + Math.random() * TYPE_JITTER_MS) / 1000, step); + }; + step(); +} + +/* ---- Drift: left idle, the house keeps plating ---- */ + +function killDrift() { + if (driftCall) { + driftCall.kill(); + driftCall = null; + } +} + +function scheduleDrift(seconds = DRIFT_SECONDS) { + killDrift(); + if (!animated || customTitle !== null) return; + driftCall = window.gsap.delayedCall(seconds, () => omakase()); +} + +/* ---- Omakase: re-roll the style, morph the plate, retype the title ---- */ + +function nextStyle() { + const random = (length) => Math.floor(Math.random() * length); + let theme = random(catalog.themes.length); + while (theme === style.theme) theme = random(catalog.themes.length); + return { + theme, + pattern: random(catalog.patterns.length), + layout: random(catalog.layouts.length), + format: random(catalog.formats.length), + strength: 0.7 + Math.random() * 0.3, + grain: Math.random() < 0.5 ? 0.18 + Math.random() * 0.22 : 0, + }; +} + +function omakase() { + if (!live) return; + cancelType(); + killDrift(); + style = nextStyle(); + if (customTitle === null) { + plateIndex = (plateIndex + 1) % PLATES.length; + plate = PLATES[plateIndex]; + } + + if (!animated) { + applyTint(true); + applySize(true); + inject(currentTitle()); + updateSlug(); + return; + } + + const retype = customTitle === null; + const timeline = window.gsap.timeline(); + timeline.to(cover, { opacity: 0, y: 12, duration: 0.38, ease: 'power2.in' }, 0); + timeline.to(slug, { opacity: 0, duration: 0.3, ease: 'power2.in' }, 0); + timeline.add(() => { + applyTint(); + applySize(); + }, 0.18); + timeline.add(() => { + window.gsap.set(cover, { y: 0 }); + updateSlug(); + window.gsap.to(slug, { opacity: 1, duration: 0.5, ease: 'power2.out' }); + window.gsap.to(cover, { opacity: 1, duration: 0.4, ease: 'power2.out' }); + if (retype) { + typeTitle(currentTitle(), () => scheduleDrift()); + } else { + inject(currentTitle()); + scheduleDrift(); + } + }, 1.0); +} + +/** A lighter round for direct theme choices: same plate, new ink. */ +function restyleTheme(themeIndex) { + if (!live || themeIndex === style.theme) return; + cancelType(); + style.theme = themeIndex; + if (!animated) { + applyTint(true); + inject(currentTitle()); + updateSlug(); + return; + } + applyTint(); + window.gsap.to(cover, { + opacity: 0, + duration: 0.2, + ease: 'power2.in', + onComplete: () => { + inject(currentTitle()); + updateSlug(); + window.gsap.to(cover, { opacity: 1, duration: 0.3, ease: 'power2.out' }); + }, + }); + scheduleDrift(); +} + +/* ---- Controls ---- */ + +function buildThemeStamps() { + catalog.themes.forEach((theme, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'theme'; + button.dataset.theme = String(index); + button.style.backgroundColor = theme.bg; + button.style.color = theme.text; + button.setAttribute('aria-pressed', String(index === style.theme)); + button.setAttribute('aria-label', `${theme.label} theme`); + button.title = theme.label; + const kanji = document.createElement('span'); + kanji.lang = 'ja'; + kanji.textContent = THEME_KANJI[theme.label] ?? theme.label.slice(0, 1); + button.append(kanji); + button.addEventListener('click', () => restyleTheme(index)); + themesGroup.append(button); + }); +} + +function queueCustomRender() { + if (renderQueued) return; + renderQueued = true; + requestAnimationFrame(() => { + renderQueued = false; + inject(currentTitle()); + }); +} + +function wireControls() { + card.addEventListener('click', () => omakase()); + omakaseButton.addEventListener('click', () => omakase()); + + titleInput.addEventListener('input', () => { + const value = titleInput.value.trim(); + if (value) { + customTitle = value; + killDrift(); + cancelType(); + queueCustomRender(); + } else { + customTitle = null; + inject(currentTitle()); + scheduleDrift(); + } + }); +} + +/* ---- Downloads drawer ---- */ + +function wireDownloads() { + const close = () => { + downloadsPanel.hidden = true; + downloadToggle.setAttribute('aria-expanded', 'false'); + document.body.classList.remove('downloads-open'); + }; + downloadToggle.addEventListener('click', () => { + const open = downloadsPanel.hidden; + downloadsPanel.hidden = !open; + downloadToggle.setAttribute('aria-expanded', String(open)); + document.body.classList.toggle('downloads-open', open); + if (open) downloadsClose.focus(); + }); + downloadsClose.addEventListener('click', () => { + close(); + downloadToggle.focus(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !downloadsPanel.hidden) { + close(); + downloadToggle.focus(); + } + }); +} + +/* ---- Pointer life: parallax plate, hanko follower ---- */ + +function wirePointer() { + if (!animated || !window.matchMedia('(pointer: fine)').matches) return; + + const moveX = window.gsap.quickTo(card, 'x', { duration: 0.7, ease: 'power2.out' }); + const moveY = window.gsap.quickTo(card, 'y', { duration: 0.7, ease: 'power2.out' }); + const tiltX = window.gsap.quickTo(card, 'rotationX', { duration: 0.7, ease: 'power2.out' }); + const tiltY = window.gsap.quickTo(card, 'rotationY', { duration: 0.7, ease: 'power2.out' }); + window.gsap.set(card, { transformPerspective: 900 }); + + const followX = window.gsap.quickTo(followerElement, 'x', { duration: 0.35, ease: 'power3.out' }); + const followY = window.gsap.quickTo(followerElement, 'y', { duration: 0.35, ease: 'power3.out' }); + let followerShown = false; + + window.addEventListener('pointermove', (event) => { + const nx = (event.clientX / window.innerWidth) * 2 - 1; + const ny = (event.clientY / window.innerHeight) * 2 - 1; + moveX(nx * PARALLAX_PX); + moveY(ny * PARALLAX_PX * 0.7); + tiltY(nx * PARALLAX_DEG); + tiltX(-ny * PARALLAX_DEG); + followX(event.clientX); + followY(event.clientY); + if (!followerShown) { + followerShown = true; + window.gsap.set(followerElement, { x: event.clientX, y: event.clientY }); + window.gsap.to(followerElement, { opacity: 0.85, duration: 0.4 }); + } + }); + document.addEventListener('pointerdown', () => { + window.gsap.fromTo( + followerElement, + { scale: 1 }, + { scale: 1.55, duration: 0.35, ease: 'power2.out', yoyo: true, repeat: 1 }, + ); + }); + document.addEventListener('mouseleave', () => { + followerShown = false; + window.gsap.to(followerElement, { opacity: 0, duration: 0.3 }); + }); +} + +/* ---- Boot ---- */ + +function reveal() { + const items = document.querySelectorAll('[data-reveal]'); + if (!animated) { + for (const item of items) item.style.opacity = 1; + return; + } + window.gsap.set(items, { y: 10, opacity: 0 }); + window.gsap.to(items, { y: 0, opacity: 1, duration: 0.8, ease: 'power2.out', stagger: 0.12, delay: 0.15 }); +} + +function withoutLiveEngine() { + root.classList.add('no-live'); + hint.textContent = 'The live engine needs WebAssembly — this plate was pre-rendered by the same code.'; + reveal(); +} + +async function boot() { + if (hasGsap) window.gsap.ticker.lagSmoothing(0); + + let module; + try { + module = await import('../wasm/acag.js'); + await module.default(); + } catch (error) { + console.warn('acag wasm unavailable:', error); + withoutLiveEngine(); + return; + } + + renderCover = module.render_cover; + catalog = JSON.parse(module.catalog()); + live = true; + + buildThemeStamps(); + applySize(true); + applyTint(true); + inject(currentTitle()); + updateSlug(); + poster.hidden = true; + + wireControls(); + wirePointer(); + reveal(); + scheduleDrift(FIRST_DRIFT_SECONDS); + + window.addEventListener('resize', () => { + if (live) applySize(true); + }); +} + +wireDownloads(); +boot(); diff --git a/web/js/vendor/gsap.min.js b/web/js/vendor/gsap.min.js new file mode 100644 index 0000000..3bb5803 --- /dev/null +++ b/web/js/vendor/gsap.min.js @@ -0,0 +1,11 @@ +/*! + * GSAP 3.13.0 + * https://gsap.com + * + * @license Copyright 2025, GreenSock. All rights reserved. + * Subject to the terms at https://gsap.com/standard-license. + * @author: Jack Doyle, jack@greensock.com + */ + +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function P(t){return(i=yt(t,ot))&&ze}function Q(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function R(t,e){return!e&&console.warn(t)}function S(t,e){return t&&(ot[t]=e)&&i&&(i[t]=e)||ot}function T(){return 0}function ea(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=gt.length;r--&&!gt[r].targetTest(i););e=gt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Zt(t[r],e)))||t.splice(r,1);return t}function fa(t){return t._gsap||ea(Ot(t))[0]._gsap}function ga(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ha(t,e){return(t=t.split(",")).forEach(e)||t}function ia(t){return Math.round(1e5*t)/1e5||0}function ja(t){return Math.round(1e7*t)/1e7||0}function ka(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function la(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++ia;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function za(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function Aa(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Ba(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Da(t,e,r,i){return t._startAt&&(L?t._startAt.revert(ht):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Fa(t){return t._repeat?Tt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ha(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ia(t){return t._end=ja(t._start+(t._tDur/Math.abs(t._ts||t._rts||U)||0))}function Ja(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=ja(r._time-(0U)&&e.render(r,!0)),Ba(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur(n=Math.abs(n))&&(a=i,o=n);return a}function ub(t){return Aa(t),t.scrollTrigger&&t.scrollTrigger.kill(!!L),t.progress()<1&&Pt(t,"onInterrupt"),t}function xb(t){if(t)if(t=!t.name&&t.default||t,x()||t.headless){var e=t.name,r=s(t),i=e&&!r&&t.init?function(){this._props=[]}:t,n={init:T,render:ue,add:Vt,kill:de,modifier:he,rawVars:0},a={targetTest:0,get:0,getSetter:ie,aliases:{},register:0};if(Ft(),t!==i){if(pt[e])return;ra(i,ra(va(t,n),a)),yt(i.prototype,yt(n,va(t,a))),pt[i.prop=e]=i,t.targetTest&&(gt.push(i),ft[e]=1),e=("css"===e?"CSS":e.charAt(0).toUpperCase()+e.substr(1))+"Plugin"}S(e,i),t.register&&t.register(ze,i,ge)}else Ct.push(t)}function Ab(t,e,r){return(6*(t+=t<0?1:1>16,e>>8&St,e&St]:0:Dt.black;if(!p){if(","===e.substr(-1)&&(e=e.substr(0,e.length-1)),Dt[e])p=Dt[e];else if("#"===e.charAt(0)){if(e.length<6&&(e="#"+(n=e.charAt(1))+n+(a=e.charAt(2))+a+(s=e.charAt(3))+s+(5===e.length?e.charAt(4)+e.charAt(4):"")),9===e.length)return[(p=parseInt(e.substr(1,6),16))>>16,p>>8&St,p&St,parseInt(e.substr(7),16)/255];p=[(e=parseInt(e.substr(1),16))>>16,e>>8&St,e&St]}else if("hsl"===e.substr(0,3))if(p=c=e.match(tt),r){if(~e.indexOf("="))return p=e.match(et),i&&p.length<4&&(p[3]=1),p}else o=+p[0]%360/360,u=p[1]/100,n=2*(h=p[2]/100)-(a=h<=.5?h*(u+1):h+u-h*u),3=N?u.endTime(!1):t._dur;return r(e)&&(isNaN(e)||e in o)?(a=e.charAt(0),s="%"===e.substr(-1),n=e.indexOf("="),"<"===a||">"===a?(0<=n&&(e=e.replace(/=/,"")),("<"===a?u._start:u.endTime(0<=u._repeat))+(parseFloat(e.substr(1))||0)*(s?(n<0?u:i).totalDuration()/100:1)):n<0?(e in o||(o[e]=h),o[e]):(a=parseFloat(e.charAt(n-1)+e.substr(n+1)),s&&i&&(a=a/100*($(i)?i[0]:i).totalDuration()),1=r&&te)return i;i=i._next}else for(i=t._last;i&&i._start>=r;){if("isPause"===i.data&&i._start=n._start)&&n._ts&&h!==n){if(n.parent!==this)return this.render(t,e,r);if(n.render(0=this.totalDuration()||!v&&_)&&(f!==this._start&&Math.abs(l)===Math.abs(this._ts)||this._lock||(!t&&g||!(v===m&&0=i&&(a instanceof Jt?e&&n.push(a):(r&&n.push(a),t&&n.push.apply(n,a.getChildren(!0,e,r)))),a=a._next;return n},e.getById=function getById(t){for(var e=this.getChildren(1,1,1),r=e.length;r--;)if(e[r].vars.id===t)return e[r]},e.remove=function remove(t){return r(t)?this.removeLabel(t):s(t)?this.killTweensOf(t):(t.parent===this&&za(this,t),t===this._recent&&(this._recent=this._last),Ba(this))},e.totalTime=function totalTime(t,e){return arguments.length?(this._forcing=1,!this._dp&&this._ts&&(this._start=ja(Rt.time-(0r:!r||s.isActive())&&n.push(s):(i=s.getTweensOf(a,r)).length&&n.push.apply(n,i),s=s._next;return n},e.tweenTo=function tweenTo(t,e){e=e||{};var r,i=this,n=xt(i,t),a=e.startAt,s=e.onStart,o=e.onStartParams,u=e.immediateRender,h=Jt.to(i,ra({ease:e.ease||"none",lazy:!1,immediateRender:!1,time:n,overwrite:"auto",duration:e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale())||U,onStart:function onStart(){if(i.pause(),!r){var t=e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale());h._dur!==t&&Sa(h,t,0,1).render(h._time,!0,!0),r=1}s&&s.apply(h,o||[])}},e));return u?h.render(0):h},e.tweenFromTo=function tweenFromTo(t,e,r){return this.tweenTo(e,ra({startAt:{time:xt(this,t)}},r))},e.recent=function recent(){return this._recent},e.nextLabel=function nextLabel(t){return void 0===t&&(t=this._time),sb(this,xt(this,t))},e.previousLabel=function previousLabel(t){return void 0===t&&(t=this._time),sb(this,xt(this,t),1)},e.currentLabel=function currentLabel(t){return arguments.length?this.seek(t,!0):this.previousLabel(this._time+U)},e.shiftChildren=function shiftChildren(t,e,r){void 0===r&&(r=0);for(var i,n=this._first,a=this.labels;n;)n._start>=r&&(n._start+=t,n._end+=t),n=n._next;if(e)for(i in a)a[i]>=r&&(a[i]+=t);return Ba(this)},e.invalidate=function invalidate(t){var e=this._first;for(this._lock=0;e;)e.invalidate(t),e=e._next;return i.prototype.invalidate.call(this,t)},e.clear=function clear(t){void 0===t&&(t=!0);for(var e,r=this._first;r;)e=r._next,this.remove(r),r=e;return this._dp&&(this._time=this._tTime=this._pTime=0),t&&(this.labels={}),Ba(this)},e.totalDuration=function totalDuration(t){var e,r,i,n=0,a=this,s=a._last,o=N;if(arguments.length)return a.timeScale((a._repeat<0?a.duration():a.totalDuration())/(a.reversed()?-t:t));if(a._dirty){for(i=a.parent;s;)e=s._prev,s._dirty&&s.totalDuration(),o<(r=s._start)&&a._sort&&s._ts&&!a._lock?(a._lock=1,La(a,s,r-s._delay,1)._lock=0):o=r,r<0&&s._ts&&(n-=r,(!i&&!a._dp||i&&i.smoothChildTiming)&&(a._start+=r/a._ts,a._time-=r,a._tTime-=r),a.shiftChildren(-r,!1,-Infinity),o=0),s._end>n&&s._ts&&(n=s._end),s=e;Sa(a,a===I&&a._time>n?a._time:n,1,1),a._dirty=0}return a._tDur},Timeline.updateRoot=function updateRoot(t){if(I._ts&&(oa(I,Ha(t,I)),f=Rt.frame),Rt.frame>=mt){mt+=X.autoSleep||120;var e=I._first;if((!e||!e._ts)&&X.autoSleep&&Rt._listeners.length<2){for(;e&&!e._ts;)e=e._next;e||Rt.sleep()}}},Timeline}(Nt);ra(Qt.prototype,{_lock:0,_hasPause:0,_forcing:0});function bc(t,e,i,n,a,o){var u,h,l,f;if(pt[t]&&!1!==(u=new pt[t]).init(a,u.rawVars?e[t]:function _processVars(t,e,i,n,a){if(s(t)&&(t=Gt(t,a,e,i,n)),!v(t)||t.style&&t.nodeType||$(t)||J(t))return r(t)?Gt(t,a,e,i,n):t;var o,u={};for(o in t)u[o]=Gt(t[o],a,e,i,n);return u}(e[t],n,a,o,i),i,n,o)&&(i._pt=h=new ge(i._pt,a,t,0,1,u.render,u,0,u.priority),i!==d))for(l=i._ptLookup[i._targets.indexOf(a)],f=u._props.length;f--;)l[u._props[f]]=h;return u}function hc(t,r,e,i){var n,a,s=r.ease||i||"power1.inOut";if($(r))a=e[t]||(e[t]=[]),r.forEach(function(t,e){return a.push({t:e/(r.length-1)*100,v:t,e:s})});else for(n in r)a=e[n]||(e[n]=[]),"ease"===n||a.push({t:parseFloat(t),v:r[n],e:s})}var Ut,qt,Vt=function _addPropTween(t,e,i,n,a,o,u,h,l,f){s(n)&&(n=n(a||0,t,o));var d,c=t[e],p="get"!==i?i:s(c)?l?t[e.indexOf("set")||!s(t["get"+e.substr(3)])?e:"get"+e.substr(3)](l):t[e]():c,_=s(c)?l?re:te:$t;if(r(n)&&(~n.indexOf("random(")&&(n=pb(n)),"="===n.charAt(1)&&(!(d=ka(p,n)+(Za(p)||0))&&0!==d||(n=d))),!f||p!==n||qt)return isNaN(p*n)||""===n?(c||e in t||Q(e,n),function _addComplexStringPropTween(t,e,r,i,n,a,s){var o,u,h,l,f,d,c,p,_=new ge(this._pt,t,e,0,1,oe,null,n),m=0,g=0;for(_.b=r,_.e=i,r+="",(c=~(i+="").indexOf("random("))&&(i=pb(i)),a&&(a(p=[r,i],t,e),r=p[0],i=p[1]),u=r.match(it)||[];o=it.exec(i);)l=o[0],f=i.substring(m,o.index),h?h=(h+1)%5:"rgba("===f.substr(-5)&&(h=1),l!==u[g++]&&(d=parseFloat(u[g-1])||0,_._pt={_next:_._pt,p:f||1===g?f:",",s:d,c:"="===l.charAt(1)?ka(d,l)-d:parseFloat(l)-d,m:h&&h<4?Math.round:0},m=it.lastIndex);return _.c=m")}),s.duration();else{for(l in u={},x)"ease"===l||"easeEach"===l||hc(l,x[l],u,x.easeEach);for(l in u)for(C=u[l].sort(function(t,e){return t.t-e.t}),o=z=0;o=t._tDur||e<0)&&t.ratio===u&&(u&&Aa(t,1),r||L||(Pt(t,u?"onComplete":"onReverseComplete",!0),t._prom&&t._prom()))}else t._zTime||(t._zTime=e)}(this,t,e,r);return this},e.targets=function targets(){return this._targets},e.invalidate=function invalidate(t){return t&&this.vars.runBackwards||(this._startAt=0),this._pt=this._op=this._onUpdate=this._lazy=this.ratio=0,this._ptLookup=[],this.timeline&&this.timeline.invalidate(t),E.prototype.invalidate.call(this,t)},e.resetTo=function resetTo(t,e,r,i,n){c||Rt.wake(),this._ts||this.play();var a,s=Math.min(this._dur,(this._dp._time-this._start)*this._ts);return this._initted||Wt(this,s),a=this._ease(s/this._dur),function _updatePropTweens(t,e,r,i,n,a,s,o){var u,h,l,f,d=(t._pt&&t._ptCache||(t._ptCache={}))[e];if(!d)for(d=t._ptCache[e]=[],l=t._ptLookup,f=t._targets.length;f--;){if((u=l[f][e])&&u.d&&u.d._pt)for(u=u.d._pt;u&&u.p!==e&&u.fp!==e;)u=u._next;if(!u)return qt=1,t.vars[e]="+=0",Wt(t,s),qt=0,o?R(e+" not eligible for reset"):1;d.push(u)}for(f=d.length;f--;)(u=(h=d[f])._pt||h).s=!i&&0!==i||n?u.s+(i||0)+a*u.c:i,u.c=r-u.s,h.e&&(h.e=ia(r)+Za(h.e)),h.b&&(h.b=u.s+Za(h.b))}(this,t,e,r,i,a,s,n)?this.resetTo(t,e,r,i,1):(Ja(this,0),this.parent||ya(this._dp,this,"_first","_last",this._dp._sort?"_start":0),this.render(0))},e.kill=function kill(t,e){if(void 0===e&&(e="all"),!(t||e&&"all"!==e))return this._lazy=this._pt=0,this.parent?ub(this):this.scrollTrigger&&this.scrollTrigger.kill(!!L),this;if(this.timeline){var i=this.timeline.totalDuration();return this.timeline.killTweensOf(t,e,Ut&&!0!==Ut.vars.overwrite)._first||ub(this),this.parent&&i!==this.timeline.totalDuration()&&Sa(this,this._dur*this.timeline._tDur/i,0,1),this}var n,a,s,o,u,h,l,f=this._targets,d=t?Ot(t):f,c=this._ptLookup,p=this._pt;if((!e||"all"===e)&&function _arraysMatch(t,e){for(var r=t.length,i=r===e.length;i&&r--&&t[r]===e[r];);return r<0}(f,d))return"all"===e&&(this._pt=0),ub(this);for(n=this._op=this._op||[],"all"!==e&&(r(e)&&(u={},ha(e,function(t){return u[t]=1}),e=u),e=function _addAliasesToVars(t,e){var r,i,n,a,s=t[0]?fa(t[0]).harness:0,o=s&&s.aliases;if(!o)return e;for(i in r=yt({},e),o)if(i in r)for(n=(a=o[i].split(",")).length;n--;)r[a[n]]=r[i];return r}(f,e)),l=f.length;l--;)if(~d.indexOf(f[l]))for(u in a=c[l],"all"===e?(n[l]=e,o=a,s={}):(s=n[l]=n[l]||{},o=e),o)(h=a&&a[u])&&("kill"in h.d&&!0!==h.d.kill(u)||za(this,h,"_pt"),delete a[u]),"all"!==s&&(s[u]=1);return this._initted&&!this._pt&&p&&ub(this),this},Tween.to=function to(t,e,r){return new Tween(t,e,r)},Tween.from=function from(t,e){return Wa(1,arguments)},Tween.delayedCall=function delayedCall(t,e,r,i){return new Tween(e,0,{immediateRender:!1,lazy:!1,overwrite:!1,delay:t,onComplete:e,onReverseComplete:e,onCompleteParams:r,onReverseCompleteParams:r,callbackScope:i})},Tween.fromTo=function fromTo(t,e,r){return Wa(2,arguments)},Tween.set=function set(t,e){return e.duration=0,e.repeatDelay||(e.repeat=0),new Tween(t,e)},Tween.killTweensOf=function killTweensOf(t,e,r){return I.killTweensOf(t,e,r)},Tween}(Nt);ra(Jt.prototype,{_targets:[],_lazy:0,_startAt:0,_op:0,_onInit:0}),ha("staggerTo,staggerFrom,staggerFromTo",function(r){Jt[r]=function(){var t=new Qt,e=kt.call(arguments,0);return e.splice("staggerFromTo"===r?5:4,0,0),t[r].apply(t,e)}});function pc(t,e,r){return t.setAttribute(e,r)}function xc(t,e,r,i){i.mSet(t,e,i.m.call(i.tween,r,i.mt),i)}var $t=function _setterPlain(t,e,r){return t[e]=r},te=function _setterFunc(t,e,r){return t[e](r)},re=function _setterFuncWithParam(t,e,r,i){return t[e](i.fp,r)},ie=function _getSetter(t,e){return s(t[e])?te:u(t[e])&&t.setAttribute?pc:$t},ne=function _renderPlain(t,e){return e.set(e.t,e.p,Math.round(1e6*(e.s+e.c*t))/1e6,e)},se=function _renderBoolean(t,e){return e.set(e.t,e.p,!!(e.s+e.c*t),e)},oe=function _renderComplexString(t,e){var r=e._pt,i="";if(!t&&e.b)i=e.b;else if(1===t&&e.e)i=e.e;else{for(;r;)i=r.p+(r.m?r.m(r.s+r.c*t):Math.round(1e4*(r.s+r.c*t))/1e4)+i,r=r._next;i+=e.c}e.set(e.t,e.p,i,e)},ue=function _renderPropTweens(t,e){for(var r=e._pt;r;)r.r(t,r.d),r=r._next},he=function _addPluginModifier(t,e,r,i){for(var n,a=this._pt;a;)n=a._next,a.p===i&&a.modifier(t,e,r),a=n},de=function _killPropTweensOf(t){for(var e,r,i=this._pt;i;)r=i._next,i.p===t&&!i.op||i.op===t?za(this,i,"_pt"):i.dep||(e=1),i=r;return!e},_e=function _sortPropTweensByPriority(t){for(var e,r,i,n,a=t._pt;a;){for(e=a._next,r=i;r&&r.pr>a.pr;)r=r._next;(a._prev=r?r._prev:n)?a._prev._next=a:i=a,(a._next=r)?r._prev=a:n=a,a=e}t._pt=i},ge=(PropTween.prototype.modifier=function modifier(t,e,r){this.mSet=this.mSet||this.set,this.set=xc,this.m=t,this.mt=r,this.tween=e},PropTween);function PropTween(t,e,r,i,n,a,s,o,u){this.t=e,this.s=i,this.c=n,this.p=r,this.r=a||ne,this.d=s||this,this.set=o||$t,this.pr=u||0,(this._next=t)&&(t._prev=this)}ha(vt+"parent,duration,ease,delay,overwrite,runBackwards,startAt,yoyo,immediateRender,repeat,repeatDelay,data,paused,reversed,lazy,callbackScope,stringFilter,id,yoyoEase,stagger,inherit,repeatRefresh,keyframes,autoRevert,scrollTrigger",function(t){return ft[t]=1}),ot.TweenMax=ot.TweenLite=Jt,ot.TimelineLite=ot.TimelineMax=Qt,I=new Qt({sortChildren:!1,defaults:Z,autoRemoveChildren:!0,id:"root",smoothChildTiming:!0}),X.stringFilter=Gb;function Fc(t){return(be[t]||Me).map(function(t){return t()})}function Gc(){var t=Date.now(),o=[];2 + + + https://skvggor.github.io/acag/ + 2026-07-15 + monthly + 1.0 + +