From 5fa93f4e75f3613c6f7d6785a57fd9604ce91402 Mon Sep 17 00:00:00 2001 From: jamey Date: Mon, 16 Feb 2026 23:37:29 +0000 Subject: [PATCH] add CSS migration foundation and screenshot tooling (Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSS file structure with @layer declaration (reset, layout, components, utilities, overrides) - Layout primitives: .stack, .cluster, .row, .auto-grid, .container-page, .with-sidebar, .center - mix screenshots task using Playwright for visual regression testing - Golden baseline captured (10 pages x 4 breakpoints = 40 screenshots) - No visual changes — new CSS not wired into any layout yet Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + assets/css/shop.css | 17 ++ assets/css/shop/components.css | 11 ++ assets/css/shop/layout.css | 69 +++++++ assets/css/shop/overrides.css | 7 + assets/css/shop/reset.css | 80 ++++++++ assets/css/shop/utilities.css | 39 ++++ lib/mix/tasks/screenshots.ex | 333 +++++++++++++++++++++++++++++++++ 8 files changed, 559 insertions(+) create mode 100644 assets/css/shop.css create mode 100644 assets/css/shop/components.css create mode 100644 assets/css/shop/layout.css create mode 100644 assets/css/shop/overrides.css create mode 100644 assets/css/shop/reset.css create mode 100644 assets/css/shop/utilities.css create mode 100644 lib/mix/tasks/screenshots.ex diff --git a/.gitignore b/.gitignore index e7c75bf..62de3dd 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ npm-debug.log # API reference specs (development only) /docs/api-specs/ +# Visual regression screenshots (generated by mix screenshots) +/screenshots/ + # Database files *.db *.db-* diff --git a/assets/css/shop.css b/assets/css/shop.css new file mode 100644 index 0000000..3ae264e --- /dev/null +++ b/assets/css/shop.css @@ -0,0 +1,17 @@ +/* Shop CSS — hand-written, zero-framework stylesheet. + Layered cascade: later layers beat earlier ones, no !important needed. + + This file runs alongside app-shop.css during migration (Phases 1-4). + After Phase 5 it replaces app-shop.css entirely. */ + +@layer reset, primitives, tokens, components, layout, utilities, overrides; + +@import "./shop/reset.css"; + +/* Theme primitives and tokens stay in their existing files. + They'll be wrapped in @layer during Phase 1. */ + +@import "./shop/components.css"; +@import "./shop/layout.css"; +@import "./shop/utilities.css"; +@import "./shop/overrides.css"; diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css new file mode 100644 index 0000000..57a94d0 --- /dev/null +++ b/assets/css/shop/components.css @@ -0,0 +1,11 @@ +/* Component styles — extracted from inline styles in later phases. + Each component gets its own section. */ + +@layer components { + /* Phase 2: product cards, grid, badges, hero, categories */ + /* Phase 2: PDP, variant selector, gallery, accordion */ + /* Phase 3: layout components (header, footer, nav, search) */ + /* Phase 3: cart components (drawer, items, summary) */ + /* Phase 4: content components (contact, reviews, newsletter) */ + /* Phase 4: page templates (checkout success, etc.) */ +} diff --git a/assets/css/shop/layout.css b/assets/css/shop/layout.css new file mode 100644 index 0000000..c8ecc2a --- /dev/null +++ b/assets/css/shop/layout.css @@ -0,0 +1,69 @@ +/* Layout primitives — composable building blocks for page structure. + Each primitive does one layout job well. Combine them freely. */ + +@layer layout { + /* Vertical stack with consistent gap */ + .stack { + display: flex; + flex-direction: column; + gap: var(--stack-gap, var(--space-md)); + } + + /* Horizontal flex-wrap cluster for tags, pills, badges */ + .cluster { + display: flex; + flex-wrap: wrap; + gap: var(--cluster-gap, var(--space-sm)); + align-items: center; + } + + /* Horizontal flex row, no wrap — navbars, toolbars, inline groups */ + .row { + display: flex; + align-items: center; + gap: var(--row-gap, var(--space-sm)); + } + + /* Intrinsic responsive grid — items wrap naturally without breakpoints */ + .auto-grid { + display: grid; + grid-template-columns: repeat( + auto-fill, + minmax(min(var(--auto-grid-min, 16rem), 100%), 1fr) + ); + gap: var(--auto-grid-gap, var(--space-md)); + } + + /* Centered max-width container */ + .container-page { + width: 100%; + max-width: var(--t-layout-max-width, 1400px); + margin-inline: auto; + padding-inline: var(--space-md); + } + + /* Main content + sidebar layout */ + .with-sidebar { + display: flex; + flex-wrap: wrap; + gap: var(--sidebar-gap, var(--space-xl)); + + & > :first-child { + flex-grow: 999; + flex-basis: 0; + min-width: 60%; + } + + & > :last-child { + flex-grow: 1; + flex-basis: var(--sidebar-width, 20rem); + } + } + + /* Flex center — for centering content both axes */ + .center { + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/assets/css/shop/overrides.css b/assets/css/shop/overrides.css new file mode 100644 index 0000000..6a60299 --- /dev/null +++ b/assets/css/shop/overrides.css @@ -0,0 +1,7 @@ +/* Overrides — highest-priority layer for edge cases. + Theme editor preview frame rules go here (migrated from app-shop.css in Phase 5). */ + +@layer overrides { + /* Cart drawer transitions (currently in app-shop.css, will move here) */ + /* Preview-frame rules (currently in app.css, will move here) */ +} diff --git a/assets/css/shop/reset.css b/assets/css/shop/reset.css new file mode 100644 index 0000000..4fe057d --- /dev/null +++ b/assets/css/shop/reset.css @@ -0,0 +1,80 @@ +/* Reset — minimal, modern defaults + Normalises browser inconsistencies without being opinionated. */ + +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + } + + * { + margin: 0; + } + + html { + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100%; + } + + body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; + } + + img, + picture, + video, + canvas, + svg { + display: block; + max-width: 100%; + } + + input, + button, + textarea, + select { + font: inherit; + color: inherit; + } + + p, + h1, + h2, + h3, + h4, + h5, + h6 { + overflow-wrap: break-word; + } + + a { + color: inherit; + text-decoration-skip-ink: auto; + } + + ul, + ol { + list-style: none; + padding: 0; + } + + fieldset { + border: none; + padding: 0; + } + + table { + border-collapse: collapse; + } + + /* Remove default button styling */ + button { + background: none; + border: none; + padding: 0; + cursor: pointer; + } +} diff --git a/assets/css/shop/utilities.css b/assets/css/shop/utilities.css new file mode 100644 index 0000000..99abe18 --- /dev/null +++ b/assets/css/shop/utilities.css @@ -0,0 +1,39 @@ +/* Utility classes — a small, curated set for common patterns. + Not a framework. Just the handful that earn their keep. */ + +@layer utilities { + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .text-balance { + text-wrap: balance; + } + + /* Hide visually but keep in DOM (for phx-update="stream" empty states etc.) */ + .visually-hidden:not(:focus):not(:active) { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } +} diff --git a/lib/mix/tasks/screenshots.ex b/lib/mix/tasks/screenshots.ex new file mode 100644 index 0000000..a618c24 --- /dev/null +++ b/lib/mix/tasks/screenshots.ex @@ -0,0 +1,333 @@ +defmodule Mix.Tasks.Screenshots do + @shortdoc "Capture visual regression screenshots of all shop pages" + @moduledoc """ + Takes screenshots of shop pages at multiple breakpoints for visual + regression testing during the CSS migration. + + Uses Playwright for reliable viewport sizing and page load handling. + + ## Prerequisites + + - Node.js in PATH + - Playwright installed (`npx playwright install chromium`) + + ## Usage + + # Capture screenshots (saved to screenshots/) + mix screenshots + + # Capture only specific pages + mix screenshots /about /cart + + # Save to a named directory (e.g. "before" and "after") + mix screenshots --name before + mix screenshots --name after + + # Override port + mix screenshots --port 4003 + + ## Output + + Screenshots are saved to `screenshots//` with filenames + like `home-375.png`, `collection-768.png`, etc. + + Default name is a timestamp (YYYYMMDD-HHMMSS). + """ + + use Mix.Task + + import Ecto.Query + + @default_port 4003 + + @breakpoints [ + {"375", 375}, + {"768", 768}, + {"1024", 1024}, + {"1440", 1440} + ] + + @pages [ + {"home", "/", nil}, + {"collection", "/collections/all", nil}, + {"about", "/about", nil}, + {"contact", "/contact", nil}, + {"delivery", "/delivery", nil}, + {"privacy", "/privacy", nil}, + {"terms", "/terms", nil}, + {"cart-empty", "/cart", nil}, + {"error-404", "/nonexistent-page-404", nil} + ] + + @impl Mix.Task + def run(args) do + {opts, filter_paths, _} = + OptionParser.parse(args, + strict: [name: :string, port: :integer] + ) + + port = opts[:port] || @default_port + name = opts[:name] || timestamp() + output_dir = Path.join("screenshots", name) + + pw_path = find_playwright!() + build_prod_assets!() + base_url = start_server!(port) + wait_for_images() + + File.mkdir_p!(output_dir) + + pages = resolve_pages(base_url, filter_paths) + total = length(pages) * length(@breakpoints) + + Mix.shell().info( + "Capturing #{total} screenshots (#{length(pages)} pages x #{length(@breakpoints)} breakpoints)..." + ) + + Mix.shell().info("Output: #{output_dir}/\n") + + script = build_playwright_script(pages, base_url, output_dir) + + script_path = + Path.join(System.tmp_dir!(), "screenshots-#{System.unique_integer([:positive])}.cjs") + + try do + File.write!(script_path, script) + + # Set NODE_PATH so require("playwright") resolves from the npx cache + case System.cmd("node", [script_path], + stderr_to_stdout: true, + env: [{"NODE_PATH", pw_path}] + ) do + {output, 0} -> + Mix.shell().info(output) + count = count_screenshots(output_dir) + + Mix.shell().info( + "\n#{IO.ANSI.green()}Done. #{count} screenshots saved to #{output_dir}/#{IO.ANSI.reset()}" + ) + + {output, code} -> + Mix.shell().error("Screenshot capture failed (exit #{code}):\n#{output}") + exit({:shutdown, 1}) + end + after + File.rm(script_path) + end + end + + # Locate the playwright package directory from the npx cache + defp find_playwright! do + {output, 0} = + System.cmd("npx", ["playwright", "--version"], stderr_to_stdout: true) + + unless String.contains?(output, ".") do + Mix.raise(""" + Playwright not found. Install it with: + + npx playwright install chromium + """) + end + + # Find the actual node_modules path containing playwright + {cache_output, _} = + System.cmd("find", [ + Path.expand("~/.npm/_npx"), + "-name", + "playwright", + "-type", + "d", + "-path", + "*/node_modules/playwright" + ]) + + case cache_output |> String.trim() |> String.split("\n") |> List.first() do + nil -> + Mix.raise("Found playwright via npx but can't locate the package directory") + + "" -> + Mix.raise("Found playwright via npx but can't locate the package directory") + + path -> + Mix.shell().info(" Using playwright from #{path}") + # Return the node_modules directory (parent of playwright/) + Path.dirname(path) + end + end + + defp build_prod_assets! do + Mix.shell().info("Building production assets...") + + Mix.Task.run("tailwind", ["simpleshop_theme", "--minify"]) + Mix.Task.run("tailwind", ["simpleshop_theme_shop", "--minify"]) + Mix.Task.run("esbuild", ["simpleshop_theme", "--minify"]) + Mix.Task.run("phx.digest") + + Mix.shell().info(" Assets built and digested.") + end + + defp start_server!(port) do + config = Application.get_env(:simpleshop_theme, SimpleshopThemeWeb.Endpoint, []) + + config = + Keyword.merge(config, + http: [ip: {127, 0, 0, 1}, port: port], + server: true, + watchers: [], + cache_static_manifest: "priv/static/cache_manifest.json" + ) + + Application.put_env(:simpleshop_theme, SimpleshopThemeWeb.Endpoint, config) + + Mix.Task.run("app.start") + + base_url = "http://localhost:#{port}" + wait_for_server(base_url, 20) + base_url + end + + defp wait_for_server(_url, 0) do + Mix.raise("Server failed to start") + end + + defp wait_for_server(url, attempts) do + Application.ensure_all_started(:inets) + Application.ensure_all_started(:ssl) + + case :httpc.request(:get, {~c"#{url}/health", []}, [timeout: 2_000], []) do + {:ok, {{_, status, _}, _, _}} when status in 200..399 -> + :ok + + _ -> + Process.sleep(250) + wait_for_server(url, attempts - 1) + end + end + + defp wait_for_images do + pending = count_pending_images() + + if pending > 0 do + Mix.shell().info("Waiting for #{pending} image variant(s) to finish processing...") + do_wait_for_images(120) + end + end + + defp do_wait_for_images(0), do: Mix.shell().info(" Timed out — running screenshots anyway") + + defp do_wait_for_images(seconds_left) do + case count_pending_images() do + 0 -> + Mix.shell().info(" Image variants ready.") + + n -> + if rem(seconds_left, 10) == 0, + do: Mix.shell().info(" #{n} image(s) still processing...") + + Process.sleep(1_000) + do_wait_for_images(seconds_left - 1) + end + end + + defp count_pending_images do + SimpleshopTheme.Media.Image + |> where([i], i.is_svg == false) + |> where([i], i.variants_status != "complete" or is_nil(i.variants_status)) + |> SimpleshopTheme.Repo.aggregate(:count) + end + + defp resolve_pages(base_url, []) do + dynamic = dynamic_pages(base_url) + @pages ++ dynamic + end + + defp resolve_pages(_base_url, filter_paths) do + Enum.filter(@pages, fn {_slug, path, _} -> path in filter_paths end) + end + + defp dynamic_pages(_base_url) do + case SimpleshopTheme.Products.list_visible_products(limit: 1) do + [product] -> + [{"pdp", "/products/#{product.id}", nil}] + + _ -> + Mix.shell().info(" No products found — skipping PDP screenshot") + [] + end + end + + defp build_playwright_script(pages, base_url, output_dir) do + breakpoint_entries = + Enum.map_join(@breakpoints, ",\n ", fn {label, width} -> + ~s({ label: "#{label}", width: #{width} }) + end) + + page_entries = + Enum.map_join(pages, ",\n ", fn {slug, path, _setup} -> + ~s({ slug: "#{slug}", path: "#{path}" }) + end) + + ~s""" + const { chromium } = require("playwright"); + const path = require("path"); + + const baseUrl = "#{base_url}"; + const outputDir = "#{output_dir}"; + + const breakpoints = [ + #{breakpoint_entries} + ]; + + const pages = [ + #{page_entries} + ]; + + (async () => { + const browser = await chromium.launch(); + + for (const pg of pages) { + for (const bp of breakpoints) { + const context = await browser.newContext({ + viewport: { width: bp.width, height: 900 }, + deviceScaleFactor: 1 + }); + const tab = await context.newPage(); + + try { + await tab.goto(baseUrl + pg.path, { waitUntil: "networkidle", timeout: 15000 }); + await tab.waitForSelector("[data-phx-main]", { timeout: 5000 }).catch(() => {}); + await tab.waitForTimeout(500); + + const filename = pg.slug + "-" + bp.label + ".png"; + await tab.screenshot({ + path: path.join(outputDir, filename), + fullPage: true + }); + console.log(" " + filename); + } catch (err) { + console.error(" FAILED: " + pg.slug + "-" + bp.label + ": " + err.message); + } + + await context.close(); + } + } + + await browser.close(); + console.log("\\nScreenshots complete."); + })(); + """ + end + + defp count_screenshots(dir) do + dir + |> File.ls!() + |> Enum.count(&String.ends_with?(&1, ".png")) + end + + defp timestamp do + {{y, m, d}, {h, min, s}} = :calendar.local_time() + + :io_lib.format("~4..0B~2..0B~2..0B-~2..0B~2..0B~2..0B", [y, m, d, h, min, s]) + |> IO.iodata_to_binary() + end +end