diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99f09b2..a3bbf5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,5 @@ jobs: - run: npm ci - run: npm run typecheck - run: npm run build + - run: node scripts/size.mjs + - run: node scripts/brand-export.mjs --check diff --git a/.gitignore b/.gitignore index b93b0f4..5a26b64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules dist .vercel + +# Brand docs: kept locally, not part of the repo +site/brand/ +docs/brand-*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 702a98b..2a5335c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to PixelSpec are listed here. The site's changelog page (`site/changelog.html`) shows the same entries. +## Unreleased (0.3.0) + +New look. + +### Changed + +- New PixelSpec mark and an original outline icon set, all drawn on the same grid so they line up. +- Keyboard shortcuts show as keycaps. Glows removed from the toolbar and popover. +- The toolbar stays pinned to its corner and can no longer be dragged. Positions saved by older versions are ignored. + ## 0.2.1 (2026-10-20) Bug fixes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6103d97..d482cbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ In a second terminal, run `npm run dev` to rebuild on every change, then refresh | `src/panel.ts` | The spec card and the "All properties" list | | `src/inspect.ts` | Reading the element: selectors, components, accessibility, AI and CSS export | | `src/styles.ts` | All UI styles (the overlay lives in a shadow root) | -| `src/icons.ts` | Hugeicons used in the UI, bundled at build time | +| `src/icons.ts` | The UI icons. They are generated from `scripts/icons.mjs` into `src/brand.gen.ts` by `npm run brand` | +| `site/` | The pixelspec.dev landing page | | `src/react.ts` | The `` React component | | `src/auto.ts` | The drop-in ` + + + diff --git a/package-lock.json b/package-lock.json index d0bde56..591bb01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixelspec", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixelspec", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "devDependencies": { "@hugeicons/core-free-icons": "^4.3.5", diff --git a/package.json b/package.json index bffb730..69ba17b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pixelspec", - "version": "0.2.1", - "description": "Design inspector and spacing overlay for any website: measure padding, margin, gaps, distances, typography, colors and contrast, then copy AI-ready feedback.", + "version": "0.3.0", + "description": "Visual feedback and CSS inspector for AI coding agents. Measure spacing, typography and color contrast on your live site, pin notes to any element, and copy pixel-perfect feedback for Claude Code, Cursor, Codex or Copilot.", "license": "MIT", "author": "Yesith (https://github.com/yesith0106)", "homepage": "https://www.pixelspec.dev", @@ -40,6 +40,8 @@ "dev": "tsup --watch", "typecheck": "tsc --noEmit", "demo": "node scripts/serve.mjs", + "brand": "node scripts/brand-export.mjs", + "size": "node scripts/size.mjs", "prepare": "npm run build" }, "peerDependencies": { @@ -59,18 +61,30 @@ "keywords": [ "design inspector", "css inspector", - "spacing", - "measure", - "redline", - "pixel perfect", + "spacing inspector", "design qa", + "visual feedback", + "ui feedback", + "pixel perfect", + "redline", + "measure", + "padding", + "margin", + "typography", + "color contrast", + "wcag", "devtools", "overlay", - "typography", - "letter spacing", - "contrast", "ai agent", - "visual feedback", - "agentation alternative" + "ai coding", + "vibe coding", + "claude code", + "cursor", + "copilot", + "codex", + "design handoff", + "react", + "nextjs", + "vite" ] } diff --git a/scripts/brand-export.mjs b/scripts/brand-export.mjs new file mode 100644 index 0000000..c9b9d9d --- /dev/null +++ b/scripts/brand-export.mjs @@ -0,0 +1,151 @@ +// Brand export: `npm run brand` +// 1. Writes src/brand.gen.ts for the product (tokens, the mark and its icons). `--product` stops here; `--check` fails on drift. +// 2. Writes standalone logo SVGs to site/assets/logo/ from site/assets/brand.js. +// 3. Writes the icon sprite site/assets/icons.svg from scripts/icons.mjs (our own filled set). +// 4. Screenshots the social cards in site/brand/posters/ to PNG with headless Chrome. +// Set CHROME=/path/to/chrome if it isn't found. Pass --svg to skip the PNGs. +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import vm from 'node:vm'; +import { ICONS, PRODUCT_ICONS } from './icons.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const at = (...p) => join(root, ...p); + +/* ---------- load the logo engine in a sandbox */ +const sandbox = { window: {}, document: undefined }; +sandbox.window.window = sandbox.window; +vm.runInNewContext(readFileSync(at('site/assets/brand.js'), 'utf8'), sandbox); +const PS = sandbox.window.PS; + +/* ---------- tokens from ps.css :root */ +const css = readFileSync(at('site/assets/ps.css'), 'utf8'); +const tok = {}; +for (const [, k, v] of css.match(/:root\s*\{([\s\S]*?)\n\}/)[1].matchAll(/--([\w-]+):\s*(#[0-9A-Fa-f]{6})\b/g)) tok[k] = v.toUpperCase(); + +/* ---------- 1. product module: src/brand.gen.ts */ +const PRODUCT_TOKENS = ['void', 'night', 'deep', 'line', 'line-2', 'fog', 'mute', 'snow', 'ink', 'signal', 'signal-2', 'redline', 'pad', 'margin', 'gap', 'marker']; + +function productModule() { + const missing = PRODUCT_TOKENS.filter((k) => !tok[k]); + if (missing.length) throw new Error(`ps.css is missing tokens: ${missing.join(', ')}`); + const key = (k) => (/^[a-z]\w*$/i.test(k) ? k : `'${k}'`); + return [ + '// Generated by scripts/brand-export.mjs from site/assets/ps.css and site/assets/brand.js.', + '// Do not edit by hand: change the brand files and run `npm run brand -- --product`.', + '', + 'export const T = {', + ...PRODUCT_TOKENS.map((k) => ` ${key(k)}: '${tok[k]}',`), + '} as const;', + '', + '/** The mark: one solid path on a 7×7 grid (viewBox 0 0 7 7). Fill it with one color. */', + 'export const MARK = {', + ` d: '${PS.MARK}',`, + ' size: 7,', + '} as const;', + '', + '/** PixelSpec icons used by the overlay: inner SVG markup on an 18-unit grid (viewBox 0 0 18 18), 1.5 stroke in currentColor. Source: scripts/icons.mjs. */', + 'export const ICONS = {', + ...PRODUCT_ICONS.map((k) => { + if (!ICONS[k]) throw new Error(`scripts/icons.mjs has no icon "${k}"`); + if (ICONS[k].includes("'")) throw new Error(`icon "${k}" contains a single quote`); + return ` ${key(k)}: '${ICONS[k]}',`; + }), + '} as const;', + '', + 'export type IconName = keyof typeof ICONS;', + '', + ].join('\n'); +} + +{ + const file = at('src/brand.gen.ts'); + const next = productModule(); + if (process.argv.includes('--check')) { + const cur = existsSync(file) ? readFileSync(file, 'utf8').replace(/\r\n/g, '\n') : ''; + if (cur !== next) { console.error('src/brand.gen.ts is out of date. Run `npm run brand -- --product`.'); process.exit(1); } + console.log('product: src/brand.gen.ts is up to date'); + process.exit(0); + } + writeFileSync(file, next); + console.log('product: wrote src/brand.gen.ts'); + if (process.argv.includes('--product')) process.exit(0); +} + +/* ---------- 2. logo files */ +const C = { void: tok.void, night: tok.night, snow: tok.snow, ink: tok.ink, signal: tok.signal, line: tok.line }; + +// Give an engine SVG a pixel size and drop page-only attributes. +function standalone(svg, height) { + const [, , w, h] = svg.match(/viewBox="([-\d.\s]+)"/)[1].split(/\s+/).map(Number); + return svg.replace(/ class="[^"]*"/, '').replace('PixelSpec` + + `` + + `${PS.mark({ mark: '#FFFFFF' }).replace(/^]*>|<\/svg>$/g, '')}\n`; +} + +const logos = { + 'mark-dark.svg': standalone(PS.mark(onDark), 96), + 'mark-light.svg': standalone(PS.mark(onLight), 96), + 'wordmark-dark.svg': standalone(PS.wordmark(onDark), 64), + 'wordmark-light.svg': standalone(PS.wordmark(onLight), 64), + 'lockup-dark.svg': standalone(PS.lockup(onDark), 64), + 'lockup-light.svg': standalone(PS.lockup(onLight), 64), + 'favicon.svg': tile(32, 0.66, 5.5), + 'app-icon.svg': tile(512, 0.56, 5.4), +}; +mkdirSync(at('site/assets/logo'), { recursive: true }); +for (const [name, body] of Object.entries(logos)) writeFileSync(at('site/assets/logo', name), body); +console.log(`logo: wrote ${Object.keys(logos).length} SVGs to site/assets/logo/`); + +/* ---------- 3. icon sprite from scripts/icons.mjs */ +{ + const symbols = Object.entries(ICONS).map(([id, body]) => `${body}`); + writeFileSync(at('site/assets/icons.svg'), + `\n${symbols.join('\n')}\n\n`); + console.log(`icons: wrote ${symbols.length} symbols to site/assets/icons.svg`); +} + +if (process.argv.includes('--svg')) process.exit(0); + +/* ---------- 4. PNGs via headless Chrome */ +const chrome = [ + process.env.CHROME, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', + '/usr/bin/chromium', +].find((p) => p && existsSync(p)); +if (!chrome) { console.error('No Chrome found. Set CHROME=/path/to/chrome.'); process.exit(1); } + +const shots = [ + ['site/brand/posters/og.html', 'site/assets/og.png', 1200, 630], + ['site/brand/posters/social-github.html', 'site/assets/social-github.png', 1280, 640], + ['site/assets/logo/app-icon.svg', 'site/assets/logo/app-icon-180.png', 180, 180], +].filter(([page]) => existsSync(at(page))); +for (const [page, out, w, h] of shots) { + let src = at(page); + if (page.endsWith('.svg')) { + // Resize a copy so the SVG fills the window exactly. + src = join(tmpdir(), `pixelspec-${w}x${h}.svg`); + writeFileSync(src, readFileSync(at(page), 'utf8').replace(/width="\d+" height="\d+"/, `width="${w}" height="${h}"`)); + } + const url = pathToFileURL(src).href + (page.endsWith('.html') ? '?export' : ''); + const r = spawnSync(chrome, [ + '--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1', + '--virtual-time-budget=6000', `--window-size=${w},${h}`, `--screenshot=${at(out)}`, url, + ], { stdio: 'ignore' }); + console.log(`${r.status === 0 && existsSync(at(out)) ? 'png ' : 'FAIL'}: ${out} (${w}x${h})`); +} diff --git a/scripts/icons.mjs b/scripts/icons.mjs new file mode 100644 index 0000000..ed1acd0 --- /dev/null +++ b/scripts/icons.mjs @@ -0,0 +1,114 @@ +// PixelSpec icons: an original outline set on an 18×18 grid. +// Single source of truth. `npm run brand` builds site/assets/icons.svg and the product icons in src/brand.gen.ts. +// +// Rules +// - 18-unit grid, viewBox "0 0 18 18". Outline strokes of 1.5 with round caps and joins. Single color. +// - One live area: every glyph's rendered bounds fit a 14×14 box centred in the grid (2u padding), +// so path extents stay within 2.75…15.25. Square-ish glyphs fill 13–14u, round glyphs use a +// 12.5u circle (14u rendered) so they look the same size as squares. +// - No fills except tiny accents (dots and squares ≤ 2.5u): bullets, pupils, the pixel. +// - Overlaps are drawn with gaps, not masks. +// - Logo tie-in: the small solid square is "the selected pixel" (focus, spacing, padding, guides, gaps). + +export const GRID = 18; +const W = 1.5; +const n = (v) => +(+v).toFixed(2); + +const stroke = (w = W) => `fill="none" stroke="currentColor" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`; +/** Line through points. */ +const L = (pts) => ``; +/** Outline path. */ +const O = (d, extra = '') => ``; +/** Outline rounded rect. */ +const OR = (x, y, w, h, r) => ``; +/** Outline circle / ellipse. */ +const OC = (cx, cy, r) => ``; +const OE = (cx, cy, rx, ry, extra = '') => ``; +/** Solid accents (≤ 2.5u). */ +const D = (cx, cy, r = 0.9) => ``; +const SQ = (cx, cy, s = 2.5, r = 0.6) => ``; +const F = (d) => ``; + +/** Four-point sparkle centred on (cx, cy), radius r. */ +function star(cx, cy, r, k = 0.22) { + const a = r * k; + return `M${n(cx)} ${n(cy - r)}Q${n(cx + a)} ${n(cy - a)} ${n(cx + r)} ${n(cy)}Q${n(cx + a)} ${n(cy + a)} ${n(cx)} ${n(cy + r)}Q${n(cx - a)} ${n(cy + a)} ${n(cx - r)} ${n(cy)}Q${n(cx - a)} ${n(cy - a)} ${n(cx)} ${n(cy - r)}Z`; +} + +// The logo's corner brackets on the live area. +const BRACKETS = 'M2.75 6.5V4.75a2 2 0 0 1 2-2h1.75M11.5 2.75h1.75a2 2 0 0 1 2 2V6.5M15.25 11.5v1.75a2 2 0 0 1-2 2H11.5M6.5 15.25H4.75a2 2 0 0 1-2-2V11.5'; +const EYE = 'M9 3.75c3.5 0 5.6 3 6.1 4.35.2.55.2 1.25 0 1.8-.5 1.35-2.6 4.35-6.1 4.35s-5.6-3-6.1-4.35a2.6 2.6 0 0 1 0-1.8C3.4 6.75 5.5 3.75 9 3.75z'; +const GITHUB = 'M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.7 1.7.2 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3'; + +export const ICONS = { + /* ---------- measuring */ + spacing: L([[2.75, 2.75], [2.75, 15.25]]) + L([[15.25, 2.75], [15.25, 15.25]]) + OR(6, 6, 6, 6, 1.75) + SQ(9, 9, 2), + redline: L([[2.75, 2.75], [2.75, 15.25]]) + L([[15.25, 2.75], [15.25, 15.25]]) + L([[5.75, 9], [12.25, 9]]) + L([[7.5, 7.25], [5.75, 9], [7.5, 10.75]]) + L([[10.5, 7.25], [12.25, 9], [10.5, 10.75]]), + ruler: `${OR(3.1, 6.4, 11.8, 5.2, 1.5)}${L([[6, 6.4], [6, 8.4]])}${L([[9, 6.4], [9, 9.2]])}${L([[12, 6.4], [12, 8.4]])}`, + guides: L([[9, 2.75], [9, 5.75]]) + L([[9, 12.25], [9, 15.25]]) + L([[2.75, 9], [5.75, 9]]) + L([[12.25, 9], [15.25, 9]]) + SQ(9, 9), + focus: O(BRACKETS) + SQ(9, 9), + padding: OR(2.75, 2.75, 12.5, 12.5, 3) + OR(6.25, 6.25, 5.5, 5.5, 1.25) + SQ(9, 9, 1.6, 0.4), + margin: O(BRACKETS) + OR(6.25, 6.25, 5.5, 5.5, 1.25), + 'gap-h': OR(2.75, 2.75, 3.75, 12.5, 1.5) + OR(11.5, 2.75, 3.75, 12.5, 1.5) + SQ(9, 9, 2), + 'gap-v': OR(2.75, 2.75, 12.5, 3.75, 1.5) + OR(2.75, 11.5, 12.5, 3.75, 1.5) + SQ(9, 9, 2), + + /* ---------- inspecting */ + type: L([[3.25, 4.25], [3.25, 3], [14.75, 3], [14.75, 4.25]]) + L([[9, 3], [9, 15]]) + L([[7, 15], [11, 15]]), + color: O('M9 2.75a6.25 6.25 0 0 0 0 12.5c.85 0 1.35-.6 1.35-1.3 0-.4-.15-.7-.4-1-.25-.3-.4-.6-.4-1 0-.7.6-1.3 1.3-1.3h1.65a2.75 2.75 0 0 0 2.75-2.75C15.25 5.15 12.45 2.75 9 2.75z') + D(5.9, 8.4) + D(7.9, 5.6) + D(11.2, 5.9), + contrast: OC(9, 9, 6.25) + F('M9 5.5a3.5 3.5 0 0 1 0 7z'), + component: O('M9 2.75l2.3 2.3L9 7.35 6.7 5.05z') + O('M4.95 6.7l2.3 2.3-2.3 2.3L2.65 9z') + O('M13.05 6.7l2.3 2.3-2.3 2.3L10.75 9z') + O('M9 10.65l2.3 2.3L9 15.25l-2.3-2.3z'), + code: L([[5.5, 4.75], [2.75, 9], [5.5, 13.25]]) + L([[12.5, 4.75], [15.25, 9], [12.5, 13.25]]) + L([[10.25, 3.5], [7.75, 14.5]]), + 'file-code': O('M10.25 2.75h-5a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h7.5a2 2 0 0 0 2-2v-6.5zM10.1 2.9v2.75a1.6 1.6 0 0 0 1.6 1.6h2.9') + L([[7.5, 9.5], [6.1, 11], [7.5, 12.5]]) + L([[10.5, 9.5], [11.9, 11], [10.5, 12.5]]), + eye: O(EYE) + OC(9, 9, 2.25), + 'eye-off': O('M5.25 5.1C3.95 6.05 3.25 7.3 2.9 8.1a2.6 2.6 0 0 0 0 1.8C3.4 11.25 5.5 14.25 9 14.25c1.4 0 2.6-.45 3.6-1.1M7.35 3.95c.52-.13 1.07-.2 1.65-.2 3.6 0 5.75 3 6.1 4.35.2.55.2 1.25 0 1.8-.23.6-.7 1.45-1.4 2.25') + L([[2.75, 2.75], [15.25, 15.25]]) + O('M7.4 7.45a2.25 2.25 0 0 0 3.15 3.15'), + layers: O('M9 2.75l6.25 3.25L9 9.25 2.75 6z') + L([[2.75, 9.25], [9, 12.5], [15.25, 9.25]]) + L([[2.75, 12], [9, 15.25], [15.25, 12]]), + grid: OR(2.75, 2.75, 5, 5, 1.5) + OR(10.25, 2.75, 5, 5, 1.5) + OR(2.75, 10.25, 5, 5, 1.5) + OR(10.25, 10.25, 5, 5, 1.5), + + /* ---------- feedback and AI */ + feedback: O('M5 2.75h8a2.25 2.25 0 0 1 2.25 2.25v5.5A2.25 2.25 0 0 1 13 12.75H9.75l-3.25 2.5v-2.5H5a2.25 2.25 0 0 1-2.25-2.25V5A2.25 2.25 0 0 1 5 2.75z') + D(6.25, 7.75) + D(9, 7.75) + D(11.75, 7.75), + ai: OR(2.75, 2.75, 12.5, 12.5, 3) + O(star(9, 9, 3.6)), + sparkles: O(star(8, 10.1, 5)) + O(star(13.4, 4.6, 1.85)) + SQ(14.1, 13.4, 2), + copy: OR(2.75, 6.25, 9, 9, 2) + O('M6.25 4.6v-.1a1.75 1.75 0 0 1 1.75-1.75h5.5a1.75 1.75 0 0 1 1.75 1.75V10a1.75 1.75 0 0 1-1.75 1.75h-.1'), + check: L([[2.75, 9.75], [6.6, 13.75], [15.25, 4.25]]), + 'check-circle': OC(9, 9, 6.25) + L([[6.1, 9.25], [8.1, 11.2], [11.9, 7]]), + cursor: O('M3.25 3.25l11.5 4.6-4.85 1.7-1.7 4.85z'), + click: O('M7.25 7.25l8 3.15-3.35 1.15-1.15 3.35z') + L([[2.95, 2.95], [4.4, 4.4]]) + L([[7.25, 2.75], [7.25, 4.25]]) + L([[2.75, 7.25], [4.25, 7.25]]), + 'text-select': O('M5.5 2.75c1.9 0 3.5.75 3.5 2.5v7.5c0 1.75 1.6 2.5 3.5 2.5M12.5 2.75c-1.9 0-3.5.75-3.5 2.5M5.5 15.25c1.9 0 3.5-.75 3.5-2.5M7.25 9h3.5'), + + /* ---------- controls */ + pause: OR(3.75, 3, 3.5, 12, 1.25) + OR(10.75, 3, 3.5, 12, 1.25), + play: O('M5.25 3.9c0-.85.95-1.4 1.7-.95l7.7 5.1c.65.45.65 1.45 0 1.9l-7.7 5.1c-.75.45-1.7-.1-1.7-.95z'), + trash: L([[2.75, 4.75], [15.25, 4.75]]) + O('M6.75 4.5V3.6a.85.85 0 0 1 .85-.85h2.8a.85.85 0 0 1 .85.85v.9') + O('M4.25 7.25l.7 6.2a2 2 0 0 0 2 1.8h4.1a2 2 0 0 0 2-1.8l.7-6.2'), + settings: L([[2.75, 5.5], [4.25, 5.5]]) + L([[8.75, 5.5], [15.25, 5.5]]) + OC(6.5, 5.5, 2.25) + L([[2.75, 12.5], [9.25, 12.5]]) + L([[13.75, 12.5], [15.25, 12.5]]) + OC(11.5, 12.5, 2.25), + plus: L([[9, 2.75], [9, 15.25]]) + L([[2.75, 9], [15.25, 9]]), + minus: L([[2.75, 9], [15.25, 9]]), + close: L([[3.5, 3.5], [14.5, 14.5]]) + L([[14.5, 3.5], [3.5, 14.5]]), + menu: L([[2.75, 4.25], [15.25, 4.25]]) + L([[2.75, 9], [15.25, 9]]) + L([[2.75, 13.75], [10.75, 13.75]]), + arrow: L([[2.75, 9], [15.25, 9]]) + L([[10.5, 4.25], [15.25, 9], [10.5, 13.75]]), + 'arrow-up': L([[9, 15.25], [9, 2.75]]) + L([[4.25, 7.5], [9, 2.75], [13.75, 7.5]]), + 'arrow-down': L([[9, 2.75], [9, 15.25]]) + L([[4.25, 10.5], [9, 15.25], [13.75, 10.5]]), + 'arrow-up-right': L([[3.25, 14.75], [14.75, 3.25]]) + L([[6.25, 3.25], [14.75, 3.25], [14.75, 11.75]]), + 'chevron-right': L([[6, 2.75], [12.25, 9], [6, 15.25]]), + download: L([[9, 2.75], [9, 10.75]]) + L([[5.75, 7.5], [9, 10.75], [12.25, 7.5]]) + O('M2.75 11.25v1.5a2.5 2.5 0 0 0 2.5 2.5h7.5a2.5 2.5 0 0 0 2.5-2.5v-1.5'), + clock: OC(9, 9, 6.25) + L([[9, 5.5], [9, 9], [11.5, 10.5]]), + + /* ---------- objects */ + terminal: OR(2.75, 3, 12.5, 12, 2.5) + L([[5.75, 7], [7.75, 9], [5.75, 11]]) + L([[9.5, 11], [12.25, 11]]), + keyboard: OR(2.75, 3.75, 12.5, 10.5, 2.5) + D(5.75, 7) + D(8, 7) + D(10.25, 7) + D(12.5, 7) + L([[6.25, 11], [11.75, 11]]), + browser: OR(2.75, 2.75, 12.5, 12.5, 2.5) + L([[2.75, 6.5], [15.25, 6.5]]) + D(5, 4.65, .7) + D(7, 4.65, .7), + globe: OC(9, 9, 6.25) + OE(9, 9, 2.6, 6.25) + L([[2.75, 9], [15.25, 9]]), + package: O('M9 2.75l6.25 3.4v5.7L9 15.25l-6.25-3.4v-5.7z') + L([[2.95, 6.25], [9, 9.6], [15.05, 6.25]]) + L([[9, 9.6], [9, 15.05]]), + zap: O('M10.25 2.75 3.75 10.25H9l-1.25 5 6.5-7.5H9z'), + shield: O('M9 2.75 3.5 4.75v4c0 3.35 2.3 5.6 5.5 6.5 3.2-.9 5.5-3.15 5.5-6.5v-4z') + L([[6.6, 9], [8.3, 10.6], [11.4, 7.4]]), + lock: OR(3.25, 8, 11.5, 7.25, 2.25) + O('M5.75 8V6a3.25 3.25 0 0 1 6.5 0v2') + D(9, 11.6, 1), + feather: O('M15.25 2.75c-6 0-9.25 3.25-9.25 8.75v1.75h1.75c5.5 0 7.5-4.5 7.5-10.5z') + L([[2.75, 15.25], [10.5, 7.5]]), + npm: OR(2.75, 2.75, 12.5, 12.5, 3) + O('M6.5 12.25V6.25h5v6'), + react: D(9, 9, 1.3) + OE(9, 9, 5.9, 2.3) + OE(9, 9, 5.9, 2.3, ' transform="rotate(60 9 9)"') + OE(9, 9, 5.9, 2.3, ' transform="rotate(120 9 9)"'), + github: ``, +}; + +/** Icons the product overlay uses (src/brand.gen.ts). */ +export const PRODUCT_ICONS = [ + 'spacing', 'guides', 'pause', 'play', 'eye', 'eye-off', 'copy', 'check', 'trash', 'settings', 'close', + 'arrow-up', 'arrow-down', 'chevron-right', 'code', 'ruler', 'component', 'padding', 'margin', 'gap-h', 'gap-v', +]; diff --git a/scripts/serve.mjs b/scripts/serve.mjs index 4e2d8e5..4b02b57 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -6,7 +6,9 @@ import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('..', import.meta.url)); const port = Number(process.env.PORT) || 5178; -const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json', '.svg': 'image/svg+xml' }; +const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json', '.svg': 'image/svg+xml', + '.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', '.ico': 'image/x-icon', + '.woff2': 'font/woff2', '.json': 'application/json', '.webmanifest': 'application/manifest+json' }; createServer(async (req, res) => { let path = decodeURIComponent(new URL(req.url, 'http://x').pathname); diff --git a/scripts/size.mjs b/scripts/size.mjs new file mode 100644 index 0000000..5c25a5d --- /dev/null +++ b/scripts/size.mjs @@ -0,0 +1,14 @@ +// Size budget for the drop-in script. Run after `npm run build`: `node scripts/size.mjs`. +import { readFileSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; + +const BUDGET = { min: 104 * 1024, gzip: 33 * 1024 }; +const file = new URL('../dist/pixelspec.global.js', import.meta.url); +const buf = readFileSync(file); +const gz = gzipSync(buf, { level: 9 }).length; +const kb = (n) => `${(n / 1024).toFixed(1)} KB`; +console.log(`pixelspec.global.js: ${kb(buf.length)} min (budget ${kb(BUDGET.min)}), ${kb(gz)} gzip (budget ${kb(BUDGET.gzip)})`); +if (buf.length > BUDGET.min || gz > BUDGET.gzip) { + console.error('Over the size budget.'); + process.exit(1); +} diff --git a/site/assets/brand.js b/site/assets/brand.js new file mode 100644 index 0000000..579863c --- /dev/null +++ b/site/assets/brand.js @@ -0,0 +1,49 @@ +/* PixelSpec brand: the logo as clean vector SVG. Exposes window.PS and hydrates [data-logo]. + * Mark: one solid shape on a 7×7 cell grid. A thick frame with a stepped notch at the top right, + * and a cut-out that reads as a "P" and a speech bubble: the page, and the note you leave on it. + * Outside corners are rounded (0.48 cell), inside corners filleted (0.34 cell). + * Wordmark: "PixelSpec" in Geist SemiBold, -0.025em, outlined (no font needed). + * Lockup: mark box 82 wordmark units, centred on the cap height, 26-unit gap. */ +(function () { + 'use strict'; + var ROWS = ["#####..","######.","##...##","##...##","##...##","##.####","#######"]; + var MARK = 'M0.48 0L1 0L1 1L0 1L0 0.48C0 0.215 0.215 0 0.48 0ZM1 0L2 0L2 1L1 1L1 0ZM2 0L3 0L3 1L2 1L2 0ZM3 0L4 0L4 1L3 1L3 0ZM4 0L4.52 0C4.785 0 5 0.215 5 0.48L5 1L4 1L4 0ZM5 1L5 0.66C5 0.848 5.152 1 5.34 1ZM0 1L1 1L1 2L0 2L0 1ZM1 1L2 1L2 2L1 2L1 1ZM2 1L3 1L3 2L2 2L2 1ZM3 1L4 1L4 2L3 2L3 1ZM4 1L5 1L5 2L4 2L4 1ZM5 1L5.52 1C5.785 1 6 1.215 6 1.48L6 2L5 2L5 1ZM6 2L6 1.66C6 1.848 6.152 2 6.34 2ZM0 2L1 2L1 3L0 3L0 2ZM1 2L2 2L2 3L1 3L1 2ZM2 2L2.34 2C2.152 2 2 2.152 2 2.34ZM5 2L5 2.34C5 2.152 4.848 2 4.66 2ZM5 2L6 2L6 3L5 3L5 2ZM6 2L6.52 2C6.785 2 7 2.215 7 2.48L7 3L6 3L6 2ZM0 3L1 3L1 4L0 4L0 3ZM1 3L2 3L2 4L1 4L1 3ZM5 3L6 3L6 4L5 4L5 3ZM6 3L7 3L7 4L6 4L6 3ZM0 4L1 4L1 5L0 5L0 4ZM1 4L2 4L2 5L1 5L1 4ZM5 5L4.66 5C4.848 5 5 4.848 5 4.66ZM5 4L6 4L6 5L5 5L5 4ZM6 4L7 4L7 5L6 5L6 4ZM0 5L1 5L1 6L0 6L0 5ZM1 5L2 5L2 6L1 6L1 5ZM3 6L2.66 6C2.848 6 3 5.848 3 5.66ZM2 6L2 5.66C2 5.848 2.152 6 2.34 6ZM3.48 5L4 5L4 6L3 6L3 5.48C3 5.215 3.215 5 3.48 5ZM4 5L5 5L5 6L4 6L4 5ZM5 5L6 5L6 6L5 6L5 5ZM6 5L7 5L7 6L6 6L6 5ZM0 6L1 6L1 7L0.48 7C0.215 7 0 6.785 0 6.52L0 6ZM1 6L2 6L2 7L1 7L1 6ZM2 6L3 6L3 7L2 7L2 6ZM3 6L4 6L4 7L3 7L3 6ZM4 6L5 6L5 7L4 7L4 6ZM5 6L6 6L6 7L5 7L5 6ZM6 6L7 6L7 6.52C7 6.785 6.785 7 6.52 7L6 7L6 6Z'; + var WORD = { d: 'M13 0 L0 0 L0 -71 L27.5 -71 Q39.9 -71 46.95 -65 Q54 -59 54 -48.4 L54 -48.4 Q54 -37.7 46.95 -31.65 Q39.9 -25.6 27.5 -25.6 L27.5 -25.6 L13 -25.6 L13 0 M13 -59.7 L13 -36.9 L26.9 -36.9 Q33.5 -36.9 37.05 -39.8 Q40.6 -42.7 40.6 -48.4 L40.6 -48.4 Q40.6 -54 37.05 -56.85 Q33.5 -59.7 26.9 -59.7 L26.9 -59.7 L13 -59.7 M73.7 0 L60.9 0 L60.9 -53.4 L73.7 -53.4 L73.7 0 M74 -60.5 L60.7 -60.5 L60.7 -71.9 L74 -71.9 L74 -60.5 M92.1 0 L77.9 0 L97.1 -27.2 L78.6 -53.4 L92.5 -53.4 L104.7 -35.2 L116.7 -53.4 L130.8 -53.4 L112.4 -27.1 L131.5 0 L117.6 0 L104.9 -19.2 L92.1 0 M157.8 1.2 L157.8 1.2 Q149.8 1.2 143.9 -2.25 Q138 -5.7 134.8 -12 Q131.6 -18.3 131.6 -26.7 L131.6 -26.7 Q131.6 -35.1 134.8 -41.4 Q138 -47.7 143.85 -51.15 Q149.7 -54.6 157.5 -54.6 L157.5 -54.6 Q165.1 -54.6 170.8 -51.2 Q176.5 -47.8 179.65 -41.4 Q182.8 -35 182.8 -26 L182.8 -26 L182.8 -23.1 L144.9 -23.1 Q145.2 -16.1 148.7 -12.55 Q152.2 -9 157.9 -9 L157.9 -9 Q166.6 -9 169 -16.4 L169 -16.4 L182.1 -15.6 Q179.9 -7.8 173.45 -3.3 Q167 1.2 157.8 1.2 M144.9 -31.7 L144.9 -31.7 L169.6 -31.7 Q169.1 -38.2 165.85 -41.35 Q162.6 -44.5 157.5 -44.5 L157.5 -44.5 Q152.3 -44.5 149 -41.2 Q145.7 -37.9 144.9 -31.7 M211.5 0 L203.6 0 Q197.7 0 194.25 -3 Q190.8 -6 190.8 -12.6 L190.8 -12.6 L190.8 -71 L203.6 -71 L203.6 -13.9 Q203.6 -9.9 207.6 -9.9 L207.6 -9.9 L211.5 -9.9 L211.5 0 M245.8 1.6 L245.8 1.6 Q237 1.6 230.6 -1.5 Q224.2 -4.6 220.55 -10.15 Q216.9 -15.7 216.3 -23.1 L216.3 -23.1 L229.5 -23.9 Q230.4 -17 234.5 -13.3 Q238.6 -9.6 246 -9.6 L246 -9.6 Q252.3 -9.6 255.75 -11.95 Q259.2 -14.3 259.2 -18.8 L259.2 -18.8 Q259.2 -21.5 257.9 -23.6 Q256.6 -25.7 252.9 -27.5 Q249.2 -29.3 241.9 -31 L241.9 -31 Q233.1 -33 227.8 -35.55 Q222.5 -38.1 220.1 -41.95 Q217.7 -45.8 217.7 -51.6 L217.7 -51.6 Q217.7 -57.8 220.8 -62.55 Q223.9 -67.3 229.6 -69.95 Q235.3 -72.6 243.3 -72.6 L243.3 -72.6 Q251.8 -72.6 257.75 -69.55 Q263.7 -66.5 267.05 -61.2 Q270.4 -55.9 271.2 -49 L271.2 -49 L258.1 -48.4 Q257.4 -54.2 253.6 -57.8 Q249.8 -61.4 243.1 -61.4 L243.1 -61.4 Q237.5 -61.4 234.25 -58.8 Q231 -56.2 231 -52.1 L231 -52.1 Q231 -49.2 232.35 -47.3 Q233.7 -45.4 237.2 -44 Q240.7 -42.6 247 -41.2 L247 -41.2 Q256.5 -39.1 262.1 -36.05 Q267.7 -33 270.1 -28.9 Q272.5 -24.8 272.5 -19.5 L272.5 -19.5 Q272.5 -13 269.15 -8.25 Q265.8 -3.5 259.85 -0.95 Q253.9 1.6 245.8 1.6 M295.1 15 L282.3 15 L282.3 -53.4 L294.6 -53.4 L294.8 -45.4 Q297.1 -49.9 301.3 -52.25 Q305.5 -54.6 310.8 -54.6 L310.8 -54.6 Q318.4 -54.6 323.45 -50.85 Q328.5 -47.1 331 -40.75 Q333.5 -34.4 333.5 -26.7 L333.5 -26.7 Q333.5 -19 330.95 -12.7 Q328.4 -6.4 323.35 -2.6 Q318.3 1.2 310.7 1.2 L310.7 1.2 Q305.6 1.2 301.45 -1.05 Q297.3 -3.3 295.1 -7.3 L295.1 -7.3 L295.1 15 M307.7 -9.2 L307.7 -9.2 Q313.5 -9.2 316.85 -13.85 Q320.2 -18.5 320.2 -26.7 L320.2 -26.7 Q320.2 -34.9 316.85 -39.55 Q313.5 -44.2 307.7 -44.2 L307.7 -44.2 Q301.9 -44.2 298.5 -39.8 Q295.1 -35.4 295.1 -26.7 L295.1 -26.7 Q295.1 -18 298.45 -13.6 Q301.8 -9.2 307.7 -9.2 M364 1.2 L364 1.2 Q356 1.2 350.1 -2.25 Q344.2 -5.7 341 -12 Q337.8 -18.3 337.8 -26.7 L337.8 -26.7 Q337.8 -35.1 341 -41.4 Q344.2 -47.7 350.05 -51.15 Q355.9 -54.6 363.7 -54.6 L363.7 -54.6 Q371.3 -54.6 377 -51.2 Q382.7 -47.8 385.85 -41.4 Q389 -35 389 -26 L389 -26 L389 -23.1 L351.1 -23.1 Q351.4 -16.1 354.9 -12.55 Q358.4 -9 364.1 -9 L364.1 -9 Q372.8 -9 375.2 -16.4 L375.2 -16.4 L388.3 -15.6 Q386.1 -7.8 379.65 -3.3 Q373.2 1.2 364 1.2 M351.1 -31.7 L351.1 -31.7 L375.8 -31.7 Q375.3 -38.2 372.05 -41.35 Q368.8 -44.5 363.7 -44.5 L363.7 -44.5 Q358.5 -44.5 355.2 -41.2 Q351.9 -37.9 351.1 -31.7 M419.7 1.2 L419.7 1.2 Q411.7 1.2 405.8 -2.25 Q399.9 -5.7 396.65 -12 Q393.4 -18.3 393.4 -26.7 L393.4 -26.7 Q393.4 -35.1 396.65 -41.4 Q399.9 -47.7 405.8 -51.15 Q411.7 -54.6 419.7 -54.6 L419.7 -54.6 Q429.9 -54.6 436.65 -49.3 Q443.4 -44 444.6 -34.5 L444.6 -34.5 L431.4 -33.8 Q430.6 -38.9 427.45 -41.55 Q424.3 -44.2 419.7 -44.2 L419.7 -44.2 Q413.5 -44.2 410.1 -39.6 Q406.7 -35 406.7 -26.7 L406.7 -26.7 Q406.7 -18.4 410.1 -13.8 Q413.5 -9.2 419.7 -9.2 L419.7 -9.2 Q424.4 -9.2 427.55 -11.9 Q430.7 -14.6 431.4 -20.3 L431.4 -20.3 L444.6 -19.7 Q443.5 -10.1 436.75 -4.45 Q430 1.2 419.7 1.2', w: 444.6, cap: 71 }; + + function attrs(o) { + return o.title ? 'role="img" aria-label="' + o.title + '"' : 'aria-hidden="true"'; + } + // The mark takes --ps-mark (brand blue by default on pages), the wordmark currentColor. Pass hex for files. + function markFill(o) { return o.mark ? 'fill="' + o.mark + '"' : 'style="fill:var(--ps-mark, currentColor)"'; } + function mark(o) { + o = o || {}; + return ''; + } + function wordmark(o) { + o = o || {}; + return ''; + } + var MS = 82, MY = -35.5 - MS / 2, LX = MS + 26; + var VB = '0 ' + Math.floor(MY) + ' ' + Math.ceil(LX + WORD.w) + ' ' + Math.ceil(16 - MY); + function lockup(o) { + o = o || {}; + return ''; + } + var PS = { ROWS: ROWS, MARK: MARK, WORD: WORD, mark: mark, wordmark: wordmark, lockup: lockup }; + PS.hydrate = function (root) { + (root || document).querySelectorAll('[data-logo]').forEach(function (el) { + var k = el.getAttribute('data-logo'); + var fn = PS[k] || PS.lockup; + el.innerHTML = fn({ title: el.hasAttribute('data-decorative') ? '' : 'PixelSpec' }); + }); + }; + if (typeof window !== 'undefined') { + window.PS = PS; + if (typeof document !== 'undefined' && document.querySelectorAll) { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { PS.hydrate(); }); + else PS.hydrate(); + } + } +})(); diff --git a/site/assets/demo.mp4 b/site/assets/demo.mp4 index 8c805ce..094d0c6 100644 Binary files a/site/assets/demo.mp4 and b/site/assets/demo.mp4 differ diff --git a/site/assets/demo.webm b/site/assets/demo.webm new file mode 100644 index 0000000..6978bef Binary files /dev/null and b/site/assets/demo.webm differ diff --git a/site/assets/favicon.svg b/site/assets/favicon.svg index 6a9a780..1ec4a4f 100644 --- a/site/assets/favicon.svg +++ b/site/assets/favicon.svg @@ -1 +1 @@ - +PixelSpec diff --git a/site/assets/icons.svg b/site/assets/icons.svg new file mode 100644 index 0000000..d703527 --- /dev/null +++ b/site/assets/icons.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/assets/logo/app-icon-180.png b/site/assets/logo/app-icon-180.png new file mode 100644 index 0000000..1f29c90 Binary files /dev/null and b/site/assets/logo/app-icon-180.png differ diff --git a/site/assets/logo/app-icon.svg b/site/assets/logo/app-icon.svg new file mode 100644 index 0000000..5d7bc00 --- /dev/null +++ b/site/assets/logo/app-icon.svg @@ -0,0 +1 @@ +PixelSpec diff --git a/site/assets/logo/favicon.svg b/site/assets/logo/favicon.svg new file mode 100644 index 0000000..1ec4a4f --- /dev/null +++ b/site/assets/logo/favicon.svg @@ -0,0 +1 @@ +PixelSpec diff --git a/site/assets/logo/lockup-dark.svg b/site/assets/logo/lockup-dark.svg new file mode 100644 index 0000000..2ea95a9 --- /dev/null +++ b/site/assets/logo/lockup-dark.svg @@ -0,0 +1 @@ + diff --git a/site/assets/logo/lockup-light.svg b/site/assets/logo/lockup-light.svg new file mode 100644 index 0000000..241ca85 --- /dev/null +++ b/site/assets/logo/lockup-light.svg @@ -0,0 +1 @@ + diff --git a/site/assets/logo/mark-dark.svg b/site/assets/logo/mark-dark.svg new file mode 100644 index 0000000..81625ed --- /dev/null +++ b/site/assets/logo/mark-dark.svg @@ -0,0 +1 @@ + diff --git a/site/assets/logo/mark-light.svg b/site/assets/logo/mark-light.svg new file mode 100644 index 0000000..81625ed --- /dev/null +++ b/site/assets/logo/mark-light.svg @@ -0,0 +1 @@ + diff --git a/site/assets/logo/wordmark-dark.svg b/site/assets/logo/wordmark-dark.svg new file mode 100644 index 0000000..8474e6c --- /dev/null +++ b/site/assets/logo/wordmark-dark.svg @@ -0,0 +1 @@ + diff --git a/site/assets/logo/wordmark-light.svg b/site/assets/logo/wordmark-light.svg new file mode 100644 index 0000000..5891465 --- /dev/null +++ b/site/assets/logo/wordmark-light.svg @@ -0,0 +1 @@ + diff --git a/site/assets/mock.css b/site/assets/mock.css new file mode 100644 index 0000000..35b32bb --- /dev/null +++ b/site/assets/mock.css @@ -0,0 +1,133 @@ +/* Live hero mockup (assets/mock.js): a dark skeleton analytics app with the PixelSpec overlay, in real HTML. */ + +/* window */ +.wrap.wide { max-width: 980px; } +.window { padding: 0; border-radius: 16px; background: var(--night); overflow: hidden; + box-shadow: 0 0 0 1px var(--line-2), 0 24px 60px -24px rgb(0 0 0 / .7); } +.window .win-bar { height: 44px; padding: 0 12px 0 16px; gap: 7px; background: var(--night); border-bottom: 1px solid var(--line); } +.window .win-bar > span:not(.spec-tag) { width: 10px; height: 10px; border-radius: 50%; background: var(--line-2); } +.window .win-url { display: flex; align-items: center; justify-content: center; gap: 6px; flex: 1; max-width: 340px; height: 26px; margin: 0 auto; padding: 0 12px; border-radius: 8px; background: var(--deep); color: var(--mute); font: 400 12px/26px var(--f-mono); } +.window .win-url .i { width: 12px; height: 12px; } + +.mock { position: relative; overflow: hidden; aspect-ratio: 720 / 400; background: #0B0C0D; } +.mock .stage { position: absolute; left: 0; top: 0; width: 720px; height: 400px; transform-origin: 0 0; font-family: var(--f-sans); color: var(--fog); text-align: left; transition: opacity .5s var(--ease); } +.mock.fade .stage { opacity: 0; } + +/* the app being reviewed: skeleton only */ +.m-app { position: absolute; inset: 0; display: flex; } +.m-side { width: 50px; flex: none; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 14px 0; border-right: 1px solid var(--line); background: #0D0E0F; } +.m-side i { width: 18px; height: 18px; border-radius: 5px; background: #1D1F22; } +.m-side i.act { background: #2A2C30; box-shadow: 0 0 0 4px #17181B; } +.m-side .m-logo { width: 22px; height: 22px; border-radius: 7px; margin-bottom: 8px; background: linear-gradient(135deg, #2B2D31, #1D1F22); } +.m-side .m-av { margin-top: auto; border-radius: 50%; } +.m-main { position: relative; flex: 1; padding: 16px 20px 0; } +.m-head { display: flex; align-items: center; gap: 12px; height: 32px; margin-bottom: 16px; } +.m-head b { font-size: 15px; font-weight: 600; color: #C9CCD1; letter-spacing: -0.015em; } +.m-search { margin-left: auto; width: 150px; height: 28px; border-radius: 8px; background: #141517; box-shadow: inset 0 0 0 1px var(--line); } +.m-btn { width: 84px; height: 28px; border-radius: 8px; background: var(--signal); } +.sk { display: block; height: 7px; border-radius: 4px; background: #23252A; } +.sk.big { height: 18px; border-radius: 5px; background: #2B2D31; } +.w40 { width: 40px; } .w50 { width: 50px; } .w60 { width: 60px; } .w70 { width: 70px; } .w80 { width: 80px; } .w90 { width: 90px; } + +.m-grid { display: grid; grid-template-columns: 2fr 1fr; grid-template-rows: auto auto; gap: 12px; margin-bottom: 12px; } +.m-chart { grid-row: span 2; position: relative; display: flex; flex-direction: column; padding: 16px; border-radius: 12px; background: #131416; box-shadow: inset 0 0 0 1px var(--line); } +.m-chart-h { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; } +.m-chart-h .big { width: 96px; } +.m-bars { flex: 1; display: flex; align-items: flex-end; gap: 8px; height: 120px; padding-top: 6px; border-bottom: 1px solid var(--line); } +.m-bars i { flex: 1; height: var(--h); border-radius: 4px 4px 2px 2px; background: #22242A; } +.m-bars i.hi { background: var(--signal); } +.m-stat { position: relative; display: flex; flex-direction: column; gap: 10px; height: 92px; padding: 16px; border-radius: 12px; background: #131416; box-shadow: inset 0 0 0 1px var(--line); } +.m-stat .big { width: 64px; } +.m-delta { width: 36px; height: 14px; border-radius: 99px; background: rgb(134 239 172 / .22); } +.m-delta.dn { background: rgb(255 59 48 / .22); } +.m-list { border-radius: 12px; background: #131416; box-shadow: inset 0 0 0 1px var(--line); } +.m-li { display: flex; align-items: center; gap: 18px; height: 44px; padding: 0 16px; } +.m-li + .m-li { border-top: 1px solid var(--line); } +.m-li i { width: 20px; height: 20px; border-radius: 50%; background: #23252A; } +.m-chip { margin-left: auto; width: 56px; height: 18px; border-radius: 99px; background: rgb(13 153 255 / .18); } +.m-chip.b { background: #1D1F22; } + +/* the overlay: the product's own look on a dark page */ +.ov { position: absolute; inset: 0; pointer-events: none; font-family: var(--f-mono); } +.ov > * { position: absolute; opacity: 0; transition: opacity .16s var(--ease); } +.ov > .on { opacity: 1; } +.o-hov { border: 1px solid var(--signal); background: rgb(13 153 255 / .08); } +.o-sel { border: 1.5px solid var(--signal); } +.o-sel i { position: absolute; width: 7px; height: 7px; border-radius: 2px; background: var(--snow); border: 1px solid var(--signal); } +.o-sel i:nth-child(1) { left: -4px; top: -4px; } .o-sel i:nth-child(2) { right: -4px; top: -4px; } +.o-sel i:nth-child(3) { left: -4px; bottom: -4px; } .o-sel i:nth-child(4) { right: -4px; bottom: -4px; } +.o-tag { height: 20px; padding: 0 7px; border-radius: 5px; background: var(--snow); color: var(--ink); font-size: 10.5px; line-height: 20px; white-space: nowrap; box-shadow: 0 4px 12px rgb(0 0 0 / .4); } +.o-tag span { margin-left: 7px; color: #5E6168; } +.o-size { transform: translateX(-50%); height: 18px; padding: 0 6px; border-radius: 4px; background: var(--signal); color: var(--ink); font-size: 10.5px; line-height: 18px; white-space: nowrap; } +.o-rl { height: 1px; background: var(--redline); } +.o-rl::before, .o-rl::after { content: ''; position: absolute; top: -4px; width: 1px; height: 9px; background: var(--redline); } +.o-rl::before { left: 0; } .o-rl::after { right: 0; } +.o-rl span { position: absolute; left: 50%; top: -24px; transform: translateX(-50%); height: 18px; padding: 0 5px; border-radius: 4px; background: var(--redline); color: var(--ink); font-size: 10.5px; font-weight: 500; line-height: 18px; } +.ov > .o-marks { inset: 0; opacity: 1; } +.o-mark { position: absolute; width: 20px; height: 20px; border-radius: 50%; background: var(--signal); color: var(--ink); font: 600 10.5px/20px var(--f-mono); text-align: center; box-shadow: 0 0 0 2px var(--snow), 0 4px 12px rgb(0 0 0 / .5); animation: mk-pop .32s cubic-bezier(.3, 1.4, .5, 1); } +@keyframes mk-pop { from { transform: scale(0); } } + +/* note popover */ +/* inspector popover: the product's real layout (header, specs, all properties, composer) */ +.o-note { width: 240px; border-radius: 14px; overflow: hidden; background: var(--night); color: var(--snow); font-family: var(--f-sans); box-shadow: 0 0 0 1px var(--line-2), 0 20px 44px -10px rgb(0 0 0 / .7); transform: translateY(4px); transition: opacity .18s var(--ease), transform .18s var(--ease); } +.o-note.on { transform: none; } +.n-head { display: flex; align-items: center; gap: 6px; height: 34px; padding: 0 6px 0 10px; border-bottom: 1px solid var(--line); } +.n-tag { flex: none; height: 17px; padding: 0 5px; border-radius: 4px; background: rgb(13 153 255 / .16); color: var(--signal-2); font: 600 9.5px/17px var(--f-mono); } +.n-sel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 9.5px var(--f-mono); color: var(--fog); } +.n-acts { display: flex; gap: 7px; margin-left: auto; padding-right: 3px; color: var(--fog); } +.n-acts .i { width: 11px; height: 11px; } +.n-specs { display: grid; gap: 1px; padding: 8px 10px 4px; } +.n-sr { display: grid; grid-template-columns: 46px 1fr; align-items: center; height: 19px; font-size: 9px; } +.n-sr > span { color: var(--mute); } +.n-sr b { display: flex; align-items: center; gap: 6px; font: 500 9.5px var(--f-mono); color: var(--snow); white-space: nowrap; } +.n-sr em { font: 500 8.5px var(--f-sans); font-style: normal; color: var(--mute); margin-right: -2px; } +.n-sr i { width: 9px; height: 9px; border-radius: 2px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / .18); } +.n-more { display: flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; font-size: 9px; color: var(--fog); } +.n-more .i { width: 10px; height: 10px; color: var(--mute); } +.n-pn { padding: 8px; border-top: 1px solid var(--line); } +.n-field { border-radius: 10px; background: var(--void); box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow .15s; } +.o-note.has .n-field { box-shadow: inset 0 0 0 1px rgb(13 153 255 / .6); } +.n-text { display: flex; align-items: center; height: 26px; padding: 0 9px; font-size: 10px; white-space: nowrap; } +.n-ph { color: var(--mute); } +.o-note.has .n-ph { display: none; } +.n-caret { display: none; width: 1.2px; height: 12px; margin-left: 1px; background: var(--signal); animation: blink 1s steps(1) infinite; } +.o-note.has .n-caret { display: inline-block; } +@keyframes blink { 50% { opacity: 0; } } +.n-bar { display: flex; align-items: center; height: 26px; padding: 0 4px 0 9px; } +.n-hint { flex: 1; font-size: 8.5px; color: var(--mute); } +.n-hint kbd { display: inline-block; height: 13px; padding: 0 4px; margin-right: 3px; border-radius: 3px; background: var(--deep); box-shadow: inset 0 0 0 1px var(--line-2); color: var(--fog); font: 500 7.5px/13px var(--f-sans); } +.n-send { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--line); color: var(--mute); transition: background .15s, color .15s; } +.n-send .i { width: 11px; height: 11px; } +.o-note.has .n-send { background: var(--signal); color: var(--ink); } + +/* what lands in the clipboard */ +.o-clip { right: 14px; bottom: 62px; width: 300px; border-radius: 12px; overflow: hidden; background: var(--night); box-shadow: 0 0 0 1px var(--line-2), 0 20px 44px -10px rgb(0 0 0 / .7); transform: translateY(6px); transition: opacity .2s var(--ease), transform .2s var(--ease); } +.o-clip.on { transform: none; } +.c-head { display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 10px; border-bottom: 1px solid var(--line); font: 500 10px var(--f-sans); color: var(--snow); } +.c-head [data-logo] { height: 12px; } +.c-head em { margin-left: auto; padding: 0 5px; border-radius: 4px; background: var(--deep); color: var(--mute); font: 500 8.5px/15px var(--f-mono); font-style: normal; } +.c-body { margin: 0; padding: 9px 10px 10px; font: 400 9px/1.6 var(--f-mono); color: #C9CCD1; white-space: pre; overflow: hidden; } +.c-body .h { color: var(--mute); } .c-body .t { color: var(--snow); } .c-body .s { color: var(--signal-2); } .c-body .n { color: var(--pad); } + +/* toolbar */ +.ov > .o-tb { right: 14px; bottom: 14px; display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: 14px; background: var(--night); color: var(--fog); opacity: 1; box-shadow: 0 0 0 1px var(--line-2), 0 12px 30px rgb(0 0 0 / .5); } +.o-tb span { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; } +.o-tb span.on { background: var(--deep); color: var(--signal-2); } +.o-tb .i { width: 17px; height: 17px; } +.o-tb b { width: 1px; height: 16px; margin: 0 3px; background: var(--line-2); } +.o-copy .ic-ok { display: none; color: var(--pad); } +.o-copy.ok { background: var(--deep); } +.o-copy.ok .ic-copy { display: none; } .o-copy.ok .ic-ok { display: block; } +.o-badge { position: absolute; top: 1px; right: 1px; min-width: 15px; height: 15px; padding: 0 4px; border-radius: 99px; background: var(--signal); color: var(--ink); font: 600 9.5px/15px var(--f-mono); font-style: normal; text-align: center; box-shadow: 0 0 0 2px var(--night); opacity: 0; transition: opacity .15s; } +.o-badge.on { opacity: 1; } +.o-toast { left: 50%; top: 12px; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px 0 10px; border-radius: 12px; background: var(--night); color: var(--snow); font: 500 12.5px/1 var(--f-sans); white-space: nowrap; box-shadow: 0 0 0 1px var(--line-2), 0 12px 28px rgb(0 0 0 / .5); transform: translate(-50%, -6px); transition: opacity .2s var(--ease), transform .2s var(--ease); } +.o-toast.on { transform: translate(-50%, 0); } +.o-toast [data-logo] { height: 15px; color: var(--snow); } + +/* cursor and click ripple */ +.m-cur { position: absolute; left: 0; top: 0; z-index: 5; width: 16px; height: 22px; transition-property: transform; transition-timing-function: cubic-bezier(.45, 0, .2, 1); filter: drop-shadow(0 1px 2px rgb(0 0 0 / .5)); } +.m-click { position: absolute; z-index: 4; width: 24px; height: 24px; margin: -10px 0 0 -10px; border-radius: 50%; border: 2px solid var(--signal); opacity: 0; pointer-events: none; } +.m-click.go { animation: tap .45s var(--ease); } +@keyframes tap { from { opacity: .9; transform: scale(.3); } to { opacity: 0; transform: scale(1.4); } } +.o-marks .o-mark { transition: opacity .35s var(--ease), transform .35s var(--ease); } +.o-marks.clearing .o-mark { opacity: 0; transform: scale(.6); } diff --git a/site/assets/mock.js b/site/assets/mock.js new file mode 100644 index 0000000..d99aa82 --- /dev/null +++ b/site/assets/mock.js @@ -0,0 +1,177 @@ +/* Live hero mockup: a scripted PixelSpec session on a skeleton analytics app, in real HTML. + * Hover (name + size) → measure → click (specs + note) → marker, twice → copy, showing the compact text the agent gets. + * Pauses off screen. With reduced motion it shows one finished frame instead. */ +(function () { + 'use strict'; + var root = document.getElementById('mock'); + if (!root) return; + var stage = root.querySelector('.stage'); + var W = 720, H = 400; + var q = function (s) { return root.querySelector(s); }; + + /* scale the fixed-size stage to the window width */ + function fit() { stage.style.transform = 'scale(' + root.clientWidth / W + ')'; } + new ResizeObserver(fit).observe(root); + fit(); + + /* geometry in stage units */ + function box(el) { + var x = 0, y = 0, e = el; + while (e && e !== stage) { x += e.offsetLeft; y += e.offsetTop; e = e.offsetParent; } + return { x: x, y: y, w: el.offsetWidth, h: el.offsetHeight }; + } + function put(el, b, r) { + el.style.left = b.x + 'px'; el.style.top = b.y + 'px'; el.style.width = b.w + 'px'; el.style.height = b.h + 'px'; + el.style.borderRadius = (r || 12) + 'px'; + } + var on = function (el, v) { el.classList.toggle('on', v !== false); }; + + var cur = q('.m-cur'), ripple = q('.m-click'), hov = q('.o-hov'), tag = q('.o-tag'), sel = q('.o-sel'), size = q('.o-size'), + rl = q('.o-rl'), note = q('.o-note'), typed = q('.n-typed'), marks = q('.o-marks'), badge = q('.o-badge'), + copyBtn = q('.o-copy'), toast = q('.o-toast'), toastMsg = q('.t-msg'), clip = q('.o-clip'); + + var T = { + chart: { el: q('#m-chart'), name: 'div.chart', tag: 'div', cls: '.chart', path: 'main>div.grid>div.chart', pad: '16', fill: '#131416', rad: 12, note: 'Give the bars more room' }, + s1: { el: q('#m-s1'), name: 'div.stat' }, + btn: { el: q('#m-btn'), name: 'button.primary', tag: 'button', cls: '.primary', path: 'main>header>button.primary', fill: '#0D99FF', rad: 8, note: 'Label this “Export report”', r: 8 }, + }; + + /* ---------- timeline plumbing */ + var run = 0, visible = true; + new IntersectionObserver(function (es) { visible = es[0].isIntersecting; }, { threshold: 0.15 }).observe(root); + function wait(ms, id) { + return new Promise(function (res, rej) { + var left = ms, last = performance.now(); + (function tick(now) { + if (id !== run) return rej('stop'); + now = now || performance.now(); + if (visible && !document.hidden) left -= now - last; + last = now; + if (left <= 0) res(); else requestAnimationFrame(tick); + })(last); + }); + } + var cx = 0, cy = 0; + function move(x, y, ms, id) { + cur.style.transitionDuration = ms + 'ms'; + cur.style.transform = 'translate(' + x + 'px,' + y + 'px)'; + cx = x; cy = y; + return wait(ms + 40, id); + } + function moveTo(el, fx, fy, ms, id) { var b = box(el); return move(b.x + b.w * fx, b.y + b.h * fy, ms, id); } + function tap() { + ripple.style.left = cx + 'px'; ripple.style.top = cy + 'px'; + ripple.classList.remove('go'); void ripple.offsetWidth; ripple.classList.add('go'); + } + + /* ---------- overlay pieces */ + function hover(t, withTag) { + if (!t) { on(hov, false); on(tag, false); return; } + var b = box(t.el); + put(hov, b, t.r); on(hov); + if (withTag === false) { on(tag, false); return; } + tag.innerHTML = t.name + '' + b.w + ' × ' + b.h + ''; + tag.style.left = b.x + 'px'; tag.style.top = (b.y - 26 < 4 ? b.y + b.h + 6 : b.y - 26) + 'px'; on(tag); + } + function select(t) { + if (!t) { on(sel, false); on(size, false); on(note, false); return; } + var b = box(t.el); + put(sel, b, t.r); on(sel); + size.textContent = b.w + ' × ' + b.h; + size.style.left = (b.x + b.w / 2) + 'px'; size.style.top = (b.y + b.h + 7) + 'px'; on(size); + q('.n-tag').textContent = t.tag; q('.n-sel').textContent = t.cls; + q('.n-specs').innerHTML = + '
Size' + b.w + ' × ' + b.h + '
' + + (t.pad ? '
SpacingPadding' + t.pad + '
' : '') + + '
ColorFill' + t.fill + '
' + + '
StyleRadius' + t.rad + '
'; + typed.textContent = ''; note.classList.remove('has'); + // beside the element if it fits, otherwise below it, kept inside the stage + var NW = 240, x = b.x + b.w + 12, y = b.y; + if (x + NW > W - 8) { x = W - NW - 10; y = b.y + b.h + 30 + 200 < H - 60 ? b.y + b.h + 30 : b.y + 14; } + note.style.left = x + 'px'; note.style.top = y + 'px'; + on(note); + } + // horizontal redline from the right edge of a to the left edge of c + function redline(a, c) { + if (!a) { on(rl, false); return; } + var A = box(a.el), C = box(c.el), x1 = A.x + A.w, x2 = C.x, y = C.y + C.h / 2; + rl.style.left = x1 + 'px'; rl.style.top = y + 'px'; rl.style.width = (x2 - x1) + 'px'; + rl.querySelector('span').textContent = Math.round(x2 - x1); + on(rl); + } + async function type(text, id) { + note.classList.add('has'); + for (var i = 1; i <= text.length; i++) { typed.textContent = text.slice(0, i); await wait(40 + (text[i - 1] === ' ' ? 40 : 0), id); } + } + function mark(t, n) { + var b = box(t.el), m = document.createElement('span'); + m.className = 'o-mark'; m.textContent = n; + m.style.left = (b.x + b.w - 12) + 'px'; m.style.top = (b.y - 8) + 'px'; + marks.appendChild(m); + badge.textContent = n; on(badge); + } + // the compact text PixelSpec copies for the agent, built from the live geometry + function compact() { + var line = function (n, t) { + var b = box(t.el); + return '' + n + '. ' + t.note + '\n ' + t.path + '\n ' + b.w + '×' + b.h + '' + + (t.pad ? ' p:' + t.pad : '') + ' r:' + t.rad + ' | bg:' + t.fill; + }; + return 'UI feedback for /analytics (1440×900):\n' + line(1, T.chart) + '\n' + line(2, T.btn); + } + function say(msg) { toastMsg.textContent = msg; on(toast); } + function reset() { + hover(); select(); redline(); marks.innerHTML = ''; on(badge, false); on(toast, false); on(clip, false); copyBtn.classList.remove('ok'); + root.classList.remove('fade'); + } + function park() { cur.style.transitionDuration = '0ms'; cx = W * 0.45; cy = H + 24; cur.style.transform = 'translate(' + cx + 'px,' + cy + 'px)'; } + + // End of a round: the toast and markers fade out while the cursor keeps moving, so the loop never blanks. + async function clear(id) { + on(toast, false); on(clip, false); on(badge, false); copyBtn.classList.remove('ok'); + marks.classList.add('clearing'); await wait(400, id); + marks.innerHTML = ''; marks.classList.remove('clearing'); + } + + /* ---------- the script (loops seamlessly: each round starts from wherever the cursor is) */ + async function play(id, first) { + if (first) { reset(); park(); await wait(400, id); } + // 1. hover the chart: name and size + await moveTo(T.chart.el, 0.45, 0.4, 900, id); hover(T.chart); await wait(1000, id); + // 2. hover a stat card: the gap to the chart (the redline is the label while measuring) + await moveTo(T.s1.el, 0.5, 0.55, 700, id); hover(T.s1, false); redline(T.chart, T.s1); await wait(1400, id); + redline(); + // 3. click the chart and leave a note + await moveTo(T.chart.el, 0.4, 0.55, 700, id); hover(T.chart); await wait(200, id); + tap(); hover(); select(T.chart); await wait(450, id); + await type(T.chart.note, id); await wait(250, id); + await moveTo(q('.n-send'), 0.5, 0.5, 450, id); tap(); select(); mark(T.chart, 1); say('Feedback 1 added'); await wait(900, id); on(toast, false); + // 4. second note on the primary button + await moveTo(T.btn.el, 0.5, 0.55, 800, id); hover(T.btn); await wait(500, id); + tap(); hover(); select(T.btn); await wait(400, id); + await type(T.btn.note, id); await wait(250, id); + await moveTo(q('.n-send'), 0.5, 0.5, 450, id); tap(); select(); mark(T.btn, 2); say('Feedback 2 added'); await wait(900, id); on(toast, false); + // 5. copy everything for the agent + await moveTo(copyBtn, 0.5, 0.5, 900, id); await wait(200, id); + tap(); copyBtn.classList.add('ok'); say('Copied 2 feedback (compact). Paste it into your agent.'); + q('.c-body').innerHTML = compact(); await wait(250, id); on(clip); await wait(3400, id); + // 6. glide back into the page while the round clears + move(W * 0.42, H * 0.62, 1100, id); await clear(id); await wait(700, id); + } + async function loop() { + var id = ++run; + try { for (var first = true; ; first = false) await play(id, first); } catch (e) { /* stopped */ } + } + + /* reduced motion: one finished frame */ + function still() { + run++; reset(); + mark(T.btn, 1); select(T.chart); note.classList.add('has'); typed.textContent = T.chart.note; + var b = box(T.chart.el); cur.style.transitionDuration = '0ms'; cur.style.transform = 'translate(' + (b.x + b.w * 0.5) + 'px,' + (b.y + b.h * 0.5) + 'px)'; + } + var rm = matchMedia('(prefers-reduced-motion: reduce)'); + function start() { if (rm.matches) still(); else loop(); } + rm.addEventListener('change', start); + (document.fonts ? document.fonts.ready : Promise.resolve()).then(start); +})(); diff --git a/site/assets/og.png b/site/assets/og.png index c5d980d..1cd9652 100644 Binary files a/site/assets/og.png and b/site/assets/og.png differ diff --git a/site/assets/pixel.js b/site/assets/pixel.js new file mode 100644 index 0000000..4a35f8a --- /dev/null +++ b/site/assets/pixel.js @@ -0,0 +1,774 @@ +/* + * PixelSpec brand engine. + * Bitmap type, logo, pixel icons and the generative backgrounds (blueprint grid, + * pixel dissolve, halftone field). No dependencies. Exposes window.PX. + * + * Markup hooks (rendered by PX.hydrate()): + * bitmap text (5x7 caps) + * + * + * + */ +(function () { + 'use strict'; + + /* ------------------------------------------------------------------ noise */ + + function hash2(x, y, seed) { + let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + Math.imul(seed | 0, 982451653)) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; + } + function vnoise(x, y, seed) { + const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi; + const s = (t) => t * t * (3 - 2 * t); + const a = hash2(xi, yi, seed), b = hash2(xi + 1, yi, seed), c = hash2(xi, yi + 1, seed), d = hash2(xi + 1, yi + 1, seed); + const u = s(xf), v = s(yf); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; + } + function fbm(x, y, seed, oct = 4) { + let t = 0, amp = 0.5, f = 1, norm = 0; + for (let i = 0; i < oct; i++) { t += amp * vnoise(x * f, y * f, seed + i * 17); norm += amp; f *= 2; amp *= 0.5; } + return t / norm; + } + const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v)); + const reduced = () => window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; + const cssVar = (name, el = document.documentElement) => getComputedStyle(el).getPropertyValue(name).trim(); + + /* ------------------------------------------------------------- bitmap type */ + // '#' = ink, '*' = accent (signal blue), '.' = off. Rows top to bottom. + + const CAPS = { + A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'], + B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'], + C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'], + D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'], + E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'], + F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'], + G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.####'], + H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'], + I: ['###', '.#.', '.#.', '.#.', '.#.', '.#.', '###'], + J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'], + K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'], + L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'], + M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'], + N: ['#...#', '#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#'], + O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'], + P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'], + Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'], + R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'], + S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'], + T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'], + U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'], + V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'], + W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '#.#.#', '.#.#.'], + X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'], + Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'], + Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'], + 0: ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'], + 1: ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'], + 2: ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'], + 3: ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'], + 4: ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'], + 5: ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'], + 6: ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'], + 7: ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'], + 8: ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'], + 9: ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'], + ' ': ['...', '...', '...', '...', '...', '...', '...'], + '.': ['.', '.', '.', '.', '.', '.', '#'], + '*': ['.', '.', '.', '.', '.', '.', '*'], + ',': ['..', '..', '..', '..', '..', '.#', '#.'], + ':': ['.', '.', '#', '.', '.', '#', '.'], + '!': ['#', '#', '#', '#', '#', '.', '#'], + '?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'], + "'": ['#', '#', '.', '.', '.', '.', '.'], + '-': ['....', '....', '....', '####', '....', '....', '....'], + '_': ['.....', '.....', '.....', '.....', '.....', '.....', '#####'], + '+': ['.....', '..#..', '..#..', '#####', '..#..', '..#..', '.....'], + '=': ['.....', '.....', '#####', '.....', '#####', '.....', '.....'], + '/': ['....#', '....#', '...#.', '..#..', '.#...', '#....', '#....'], + '<': ['...#', '..#.', '.#..', '#...', '.#..', '..#.', '...#'], + '>': ['#...', '.#..', '..#.', '...#', '..#.', '.#..', '#...'], + '%': ['##..#', '##..#', '...#.', '..#..', '.#...', '#..##', '#..##'], + '×': ['.....', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '.....'], + '#': ['.#.#.', '.#.#.', '#####', '.#.#.', '#####', '.#.#.', '.#.#.'], + '@': ['.###.', '#...#', '#.###', '#.#.#', '#.###', '#....', '.####'], + '&': ['.##..', '#..#.', '#.#..', '.#...', '#.#.#', '#..#.', '.##.#'], + '(': ['..#', '.#.', '#..', '#..', '#..', '.#.', '..#'], + ')': ['#..', '.#.', '..#', '..#', '..#', '.#.', '#..'], + '[': ['###', '#..', '#..', '#..', '#..', '#..', '###'], + ']': ['###', '..#', '..#', '..#', '..#', '..#', '###'], + }; + + // Lowercase set for the wordmark. 9 rows: 2 ascender, 5 x-height, 2 descender. + // The dot of the i is the signal pixel. + const LOWER = { + p: ['....', '....', '###.', '#..#', '#..#', '#..#', '###.', '#...', '#...'], + i: ['*', '.', '#', '#', '#', '#', '#', '.', '.'], + x: ['....', '....', '#..#', '#..#', '.##.', '#..#', '#..#', '....', '....'], + e: ['....', '....', '.##.', '#..#', '####', '#...', '.###', '....', '....'], + l: ['#', '#', '#', '#', '#', '#', '#', '.', '.'], + s: ['....', '....', '.###', '#...', '.##.', '...#', '###.', '....', '....'], + c: ['....', '....', '.###', '#...', '#...', '#...', '.###', '....', '....'], + }; + + // The mark: a focus frame. Four signal-blue corner brackets select one pixel. 8x8. + // '*' cells (the frame) take the accent color, '#' (the pixel) takes the fill. + const MARK = ['***..***', '*......*', '*......*', '...##...', '...##...', '*......*', '*......*', '***..***']; + + // Bold caps for the wordmark: each glyph smeared one pixel to the right (classic bitmap bold). + const BOLD = {}; + for (const k in CAPS) { + BOLD[k] = CAPS[k].map((row) => { + let out = ''; + for (let i = 0; i <= row.length; i++) { + const on = (c) => c === '#' || c === '*'; + out += on(row[i]) || on(row[i - 1]) ? (row[i] === '*' || row[i - 1] === '*' ? '*' : '#') : '.'; + } + return out; + }); + } + BOLD[' '] = CAPS[' ']; + + // 11x11 pixel icons. + const ICONS = { + spacing: ['#.#.#.#.#.#', '...........', '#.#######.#', '..#.....#..', '#.#.***.#.#', '..#.*.*.#..', '#.#.***.#.#', '..#.....#..', '#.#######.#', '...........', '#.#.#.#.#.#'], + redline: ['...........', '#.........#', '#.........#', '#.*.....*.#', '#.**...**.#', '#*********#', '#.**...**.#', '#.*.....*.#', '#.........#', '#.........#', '...........'], + type: ['###########', '#....#....#', '.....#.....', '.....#.....', '.....#.....', '.....#.....', '.....#.....', '.....#.....', '.....#.....', '....###....', '...........'], + color: ['.....#.....', '....###....', '...#####...', '..#######..', '.##*######.', '##*########', '##*########', '###*#######', '.#########.', '..#######..', '....###....'], + contrast: ['...#####...', '..#..****..', '.#...*****.', '#....******', '#....******', '#....******', '#....******', '#....******', '.#...*****.', '..#..****..', '...#####...'], + component: ['...........', '...........', '...#..**...', '..#...*.#..', '.#...*...#.', '#....*....#', '.#...*...#.', '..#.*...#..', '...#*..#...', '...........', '...........'], + feedback: ['.#########.', '#.........#', '#.*******.#', '#.........#', '#.*****...#', '#.........#', '.###.#####.', '...#.#.....', '...##......', '...#.......', '...........'], + ai: ['.....*.....', '.....*.....', '.....*.....', '....***....', '..*******..', '***********', '..*******..', '....***....', '.....*.....', '.....*.....', '.....*.....'], + cursor: ['#..........', '##.........', '#*#........', '#**#.......', '#***#......', '#****#.....', '#*****#....', '#**####....', '#*#.#*#....', '##..##*#...', '.....##....'], + guides: ['.....#.....', '.....#.....', '.....#.....', '...........', '...........', '###..*..###', '...........', '...........', '.....#.....', '.....#.....', '.....#.....'], + shadow: ['....#######', '....#.....#', '....#.....#', '....#.....#', '*******...#', '*******...#', '###########', '*******....', '*******....', '*******....', '*******....'], + copy: ['...#######.', '...#.....#.', '...#.....#.', '#######..#.', '#.....#..#.', '#.***.#..#.', '#.....####.', '#.***.#....', '#.....#....', '#######....', '...........'], + pause: ['...........', '.###...###.', '.###...###.', '.###...###.', '.###...###.', '.###...###.', '.###...###.', '.###...###.', '.###...###.', '.###...###.', '...........'], + zero: ['...#####...', '..#.....#..', '.#......*#.', '#......*..#', '#.....*...#', '#....*....#', '#...*.....#', '#..*......#', '.#*......#.', '..#.....#..', '...#####...'], + bolt: ['......####.', '.....####..', '....####...', '...####....', '..#######..', '.......##..', '......##...', '.....##....', '....##.....', '...##......', '..#........'], + check: ['...........', '...........', '.........##', '........##.', '.......##..', '##....##...', '.##..##....', '..####.....', '...##......', '...........', '...........'], + arrow: ['...........', '......#....', '......##...', '......###..', '#########..', '##########.', '#########..', '......###..', '......##...', '......#....', '...........'], + github: ['...#####...', '.#########.', '.##.###.##.', '###########', '###.....###', '##.......##', '##.......##', '###.....###', '.#.##.####.', '..##...###.', '...#...##..'], + ruler: ['...........', '...........', '###########', '#.*.*.*.*.#', '#.*...*...#', '#.*...*...#', '#.........#', '#.........#', '###########', '...........', '...........'], + plus: ['...........', '.....#.....', '.....#.....', '.....#.....', '.....#.....', '.#########.', '.....#.....', '.....#.....', '.....#.....', '.....#.....', '...........'], + // Toolbar set (product). Drawn for 22px, exactly 2x. + play: ['...........', '...#.......', '...##......', '...###.....', '...####....', '...#####...', '...####....', '...###.....', '...##......', '...#.......', '...........'], + eye: ['...........', '...........', '...#####...', '.##.....##.', '#...***...#', '#...***...#', '#...***...#', '.##.....##.', '...#####...', '...........', '...........'], + eyeOff: ['...........', '...........', '...........', '#.........#', '.##.....##.', '...#####...', '..#..#..#..', '.#...#...#.', '...........', '...........', '...........'], + trash: ['....###....', '###########', '...........', '.#########.', '.#..#.#..#.', '.#..#.#..#.', '.#..#.#..#.', '.#..#.#..#.', '.#..#.#..#.', '.#########.', '...........'], + settings: ['.***.......', '#***#######', '.***.......', '...........', '.......***.', '#######***#', '.......***.', '...........', '....***....', '####***####', '....***....'], + close: ['...........', '.#.......#.', '..#.....#..', '...#...#...', '....#.#....', '.....#.....', '....#.#....', '...#...#...', '..#.....#..', '.#.......#.', '...........'], + }; + + // Giant cursor for illustrations ('#' outline, '*' fill). + const BIG_CURSOR = [ + '#...........', '##..........', '#*#.........', '#**#........', '#***#.......', '#****#......', '#*****#.....', + '#******#....', '#*******#...', '#********#..', '#*****#####.', '#**#**#.....', '#*#.#**#....', '##..#**#....', + '#....#**#...', '.....#**#...', '......#**#..', '......#**#..', '.......##...', + ]; + + function layout(text, font, tracking = 1) { + const rowsN = font === LOWER ? 9 : 7; + const caps = font === CAPS || font === BOLD; + const cells = []; + let x = 0; + const chars = [...String(text)]; + chars.forEach((ch, i) => { + const key = caps ? ch.toUpperCase() : ch; + const g = font[key] || font[ch] || (caps ? font['?'] : null); + if (!g) return; + g.forEach((row, y) => { + [...row].forEach((c, gx) => { if (c !== '.') cells.push([x + gx, y, c === '*' ? 1 : 0]); }); + }); + x += g[0].length + (i < chars.length - 1 ? tracking : 0); + }); + return { w: x, h: rowsN, cells }; + } + + function grid(rows) { + const cells = []; + rows.forEach((row, y) => [...row].forEach((c, x) => { if (c !== '.') cells.push([x, y, c === '*' ? 1 : 0]); })); + return { w: rows[0].length, h: rows.length, cells }; + } + + /** + * Render a cell set as SVG. 1 viewBox unit = 1 pixel cell, so size it with CSS. + * shape: square | dot | ring | plus | cross + * gap: 0..0.6 fraction of a cell left empty between pixels. + */ + function svg(bm, o = {}) { + const shape = o.shape || 'square'; + const gap = o.gap != null ? +o.gap : shape === 'square' ? 0 : 0.14; + const fill = o.fill || 'currentColor'; + const accent = o.accent || 'var(--signal, #0D99FF)'; + const pad = o.pad || 0; + const seam = shape === 'square' && !gap ? 0.04 : 0; + const s = 1 - gap + seam, off = gap / 2 - seam / 2; + const r = (n) => +n.toFixed(3); + let a = '', b = ''; + for (const [x, y, k] of bm.cells) { + let el; + const X = x + pad, Y = y + pad; + if (shape === 'dot') el = ``; + else if (shape === 'ring') el = ``; + else if (shape === 'plus') el = ``; + else if (shape === 'cross') el = ``; + else el = ``; + if (k && !o.mono) b += el; else a += el; + } + const W = bm.w + pad * 2, H = bm.h + pad * 2; + const stroked = shape === 'ring' || shape === 'plus' || shape === 'cross'; + const paint = (c) => (stroked ? `fill="none" stroke="${c}" stroke-width="${shape === 'ring' ? 0.18 : 0.22}"` : `fill="${c}"`); + const label = o.title ? `${String(o.title).replace(/[<&]/g, '')}` : ''; + const attrs = o.title ? `role="img" aria-label="${String(o.title).replace(/"/g, '"')}"` : 'aria-hidden="true"'; + return `${label}${a}${b ? `${b}` : ''}`; + } + + const text = (str, o = {}) => svg(layout(str, CAPS, o.tracking ?? 1), { title: o.title ?? str, ...o }); + // Logo geometry, in units (1 unit = 1 logo pixel). + const WORD = () => layout('PIXELSPEC', BOLD, 1); // 60 x 7 + function WORD_STACK() { // 32 x 16: two lines, 2u leading + const a = layout('PIXEL', BOLD, 1), b = layout('SPEC', BOLD, 1); + return { w: Math.max(a.w, b.w), h: 16, cells: a.cells.concat(b.cells.map(([x, y, k]) => [x, y + 9, k])) }; + } + const scaled = (bm, n) => ({ w: bm.w * n, h: bm.h * n, cells: bm.cells.flatMap(([x, y, k]) => { const o = []; for (let a = 0; a < n; a++) for (let b = 0; b < n; b++) o.push([x * n + a, y * n + b, k]); return o; }) }); + const beside = (a, b, gap, dy = 0) => ({ w: a.w + gap + b.w, h: Math.max(a.h, b.h + dy), cells: a.cells.concat(b.cells.map(([x, y, k]) => [x + a.w + gap, y + dy, k])) }); + // Stacked lockup: the mark at 2x is exactly as tall as the two lines of type (16u). + const LOCKUP = () => beside(scaled(grid(MARK), 2), WORD_STACK(), 4); // 52 x 16 + // Inline lockup for nav bars: mark at 1x, one line of type centred on it. + const LOCKUP_INLINE = () => beside(grid(MARK), WORD(), 3, 0.5); // 71 x 8 + + const T = { title: 'PixelSpec' }; + const wordmark = (o = {}) => svg(o.stack ? WORD_STACK() : WORD(), { ...T, ...o }); + const mark = (o = {}) => svg(grid(MARK), { ...T, ...o }); + const lockup = (o = {}) => svg(o.inline ? LOCKUP_INLINE() : LOCKUP(), { ...T, ...o }); + const icon = (name, o = {}) => svg(grid(ICONS[name] || ICONS.plus), o); + + /* ----------------------------------------------------------- backgrounds */ + + function fit(canvas) { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const rect = canvas.getBoundingClientRect(); + const w = Math.max(1, Math.round(rect.width)), h = Math.max(1, Math.round(rect.height)); + if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + } + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { ctx, w, h, dpr }; + } + + // Tracks the pointer relative to an element, even when the canvas sits under content. + function pointer(el, onMove) { + const state = { x: -1e4, y: -1e4, inside: false }; + const move = (e) => { + const r = el.getBoundingClientRect(); + state.x = e.clientX - r.left; state.y = e.clientY - r.top; + state.inside = state.x >= 0 && state.y >= 0 && state.x <= r.width && state.y <= r.height; + // Content marked data-px-shield keeps the backgrounds from drawing over it. + state.shielded = !!(e.target && e.target.closest && e.target.closest('[data-px-shield]')); + onMove && onMove(state); + }; + window.addEventListener('pointermove', move, { passive: true }); + window.addEventListener('pointerleave', () => { state.inside = false; onMove && onMove(state); }); + return state; + } + + function loop(canvas, frame) { + let visible = true, raf = 0, running = false; + const tick = (t) => { raf = 0; if (!visible) { running = false; return; } frame(t); raf = requestAnimationFrame(tick); }; + const start = () => { if (!running) { running = true; raf = requestAnimationFrame(tick); } }; + new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) start(); }, { rootMargin: '80px' }).observe(canvas); + start(); + return { kick: start }; + } + + /** + * Blueprint grid: crosshair registration marks, minor ticks, diagonal hatch, + * cells that light up, little nodes that travel along the grid, and a + * measured cell under the cursor. + */ + function blueprint(canvas, opts = {}) { + const o = Object.assign({ major: 192, sub: 4, seed: 11, theme: canvas.dataset.theme || 'dark', hover: true, glows: 1, nodes: 4, label: true }, opts); + if (canvas.dataset.major) o.major = +canvas.dataset.major; + if (canvas.dataset.hover === 'false') o.hover = false; + const light = o.theme === 'light'; + const C = light + ? { bg: '#EEF1F4', hatch: 'rgba(16,40,64,.045)', line: 'rgba(16,40,64,.07)', tick: 'rgba(16,40,64,.28)', cross: 'rgba(4,8,13,.75)', glow: 'rgba(13,153,255,.07)', glowHatch: 'rgba(13,153,255,.16)', node: 'rgba(4,8,13,.55)', sig: '#0D99FF', label: 'rgba(4,8,13,.6)' } + : { bg: '#04080D', hatch: 'rgba(40,86,130,.075)', line: 'rgba(110,150,190,.075)', tick: 'rgba(150,180,210,.32)', cross: 'rgba(225,235,245,.82)', glow: 'rgba(13,153,255,.055)', glowHatch: 'rgba(60,150,220,.13)', node: 'rgba(205,215,225,.7)', sig: '#0D99FF', label: 'rgba(170,200,230,.75)' }; + if (canvas.dataset.bg) C.bg = canvas.dataset.bg; + let W, H, M, ox, oy, cols, rows, layer, dprv; + let glows = [], nodes = [], shieldFn = () => false; + const ptr = o.hover ? pointer(canvas) : { inside: false }; + let hov = { cx: -1, cy: -1, a: 0 }; + + function build() { + const { w, h, dpr } = fit(canvas); + W = w; H = h; dprv = dpr; + M = W < 640 ? Math.round(o.major * 0.62) : o.major; + cols = Math.ceil(W / M) + 1; rows = Math.ceil(H / M) + 1; + ox = Math.round(((W % M) / 2) - M); ox = ((ox % M) + M) % M; oy = Math.round(M * 0.45) % M; + layer = document.createElement('canvas'); + layer.width = Math.round(W * dpr); layer.height = Math.round(H * dpr); + const g = layer.getContext('2d'); + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.fillStyle = C.bg; g.fillRect(0, 0, W, H); + // diagonal hatch + g.strokeStyle = C.hatch; g.lineWidth = 6; + g.beginPath(); + for (let c = -H; c < W + H; c += 22) { g.moveTo(c, H); g.lineTo(c + H, 0); } + g.stroke(); + // grid lines + g.strokeStyle = C.line; g.lineWidth = 1; + g.beginPath(); + for (let x = ox; x <= W; x += M) { g.moveTo(Math.round(x) + 0.5, 0); g.lineTo(Math.round(x) + 0.5, H); } + for (let y = oy; y <= H; y += M) { g.moveTo(0, Math.round(y) + 0.5); g.lineTo(W, Math.round(y) + 0.5); } + g.stroke(); + // Text marked data-px-shield (inside this canvas's section) stays free of crosshairs and ticks. + const cr = canvas.getBoundingClientRect(); + const shields = [...(canvas.parentElement || document).querySelectorAll('[data-px-shield]')].map((el) => { + const r = el.getBoundingClientRect(); + return [r.left - cr.left - 12, r.top - cr.top - 12, r.right - cr.left + 12, r.bottom - cr.top + 12]; + }); + const shielded = (X, Y) => shields.some(([a, b, c, d2]) => X >= a && X <= c && Y >= b && Y <= d2); + shieldFn = shielded; + // minor ticks + const m = M / o.sub; + g.strokeStyle = C.tick; g.lineWidth = 1; + g.beginPath(); + for (let x = ox - M; x <= W + m; x += m) for (let y = oy - M; y <= H + m; y += m) { + const onMajor = Math.abs(((x - ox) / M) - Math.round((x - ox) / M)) < 0.01 && Math.abs(((y - oy) / M) - Math.round((y - oy) / M)) < 0.01; + if (onMajor) continue; + const X = Math.round(x) + 0.5, Y = Math.round(y) + 0.5; + if (shielded(X, Y)) continue; + g.moveTo(X - 2.5, Y); g.lineTo(X + 2.5, Y); g.moveTo(X, Y - 2.5); g.lineTo(X, Y + 2.5); + } + g.stroke(); + // major crosshairs + g.strokeStyle = C.cross; g.lineWidth = 1.25; + g.beginPath(); + const arm = W < 640 ? 7 : 10; + for (let x = ox; x <= W + 1; x += M) for (let y = oy; y <= H + 1; y += M) { + const X = Math.round(x) + 0.5, Y = Math.round(y) + 0.5; + if (shielded(X, Y)) continue; + g.moveTo(X - arm, Y); g.lineTo(X + arm, Y); g.moveTo(X, Y - arm); g.lineTo(X, Y + arm); + } + g.stroke(); + // seed glowing cells and nodes + let s = o.seed; + const rnd = () => hash2(s++, 7, o.seed); + glows = Array.from({ length: o.glows }, () => ({ cx: Math.floor(rnd() * cols) - 1, cy: Math.floor(rnd() * rows) - 1, t0: rnd() * 6000, dur: 5000 + rnd() * 5000 })); + nodes = Array.from({ length: o.nodes }, () => { + const ix = Math.floor(rnd() * cols * o.sub), iy = Math.floor(rnd() * rows * o.sub); + return { ix, iy, tx: ix, ty: iy, t0: 0, dur: 900, wait: rnd() * 3000 }; + }); + } + + function cellRect(cx, cy) { return [ox + cx * M, oy + cy * M, M, M]; } + + function drawHatchCell(ctx, x, y, w, h, alpha, fill, hatch) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); + ctx.fillStyle = fill; ctx.fillRect(x, y, w, h); + ctx.strokeStyle = hatch; ctx.lineWidth = 6; + ctx.beginPath(); + // Global stripes are the lines x + y = 22k; draw the same ones inside the cell. + for (let K = Math.floor((x + y) / 22) * 22; K <= x + w + y + h + 22; K += 22) { + ctx.moveTo(K - (y + h), y + h); ctx.lineTo(K - y, y); + } + ctx.stroke(); + ctx.restore(); + } + + function frame(t) { + const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.drawImage(layer, 0, 0); + ctx.setTransform(dprv, 0, 0, dprv, 0, 0); + // glowing cells + for (const gl of glows) { + let p = ((t + gl.t0) % gl.dur) / gl.dur; + if (p < 0.02 && !gl.moved) { + gl.cx = Math.floor(hash2(gl.cx * 31 + 1, gl.cy + (t | 0), o.seed) * cols) - 1; + gl.cy = Math.floor(hash2(gl.cy * 17 + 3, gl.cx + (t | 0), o.seed) * rows) - 1; + gl.moved = true; + } else if (p > 0.5) gl.moved = false; + const a = Math.sin(Math.PI * p); + if (a <= 0.01) continue; + const [x, y, w, h] = cellRect(gl.cx, gl.cy); + drawHatchCell(ctx, x, y, w, h, a, C.glow, C.glowHatch); + } + // traveling nodes + const m = M / o.sub; + ctx.fillStyle = C.node; + for (const n of nodes) { + if (t > n.t0 + n.dur + n.wait) { + n.ix = n.tx; n.iy = n.ty; + const dir = Math.floor(hash2(n.ix, n.iy, (t | 0)) * 4); + const steps = 1 + Math.floor(hash2(n.iy, n.ix, (t | 0) + 1) * 3); + n.tx = n.ix + (dir === 0 ? steps : dir === 1 ? -steps : 0); + n.ty = n.iy + (dir === 2 ? steps : dir === 3 ? -steps : 0); + n.t0 = t; n.dur = 500 + steps * 260; n.wait = 800 + hash2(n.tx, n.ty, 5) * 3200; + } + const p = clamp((t - n.t0) / n.dur); + const e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; + const x = ox - M + (n.ix + (n.tx - n.ix) * e) * m, y = oy - M + (n.iy + (n.ty - n.iy) * e) * m; + if (shieldFn(x, y)) continue; + ctx.fillRect(Math.round(x) - 2, Math.round(y) - 2, 5, 5); + } + // measured cell under the cursor + if (o.hover) { + const cx = Math.floor((ptr.x - ox) / M), cy = Math.floor((ptr.y - oy) / M); + const live = ptr.inside && !ptr.shielded; + if (live && (cx !== hov.cx || cy !== hov.cy)) { hov.cx = cx; hov.cy = cy; hov.born = t; } + hov.a += ((live ? 1 : 0) - hov.a) * 0.12; + if (hov.a > 0.01 && hov.born != null) { + const [x, y, w, h] = cellRect(hov.cx, hov.cy); + const pop = clamp((t - hov.born) / 220); + ctx.save(); + ctx.globalAlpha = hov.a; + ctx.strokeStyle = C.sig; ctx.lineWidth = 1; + ctx.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, w, h); + // handles + ctx.fillStyle = C.sig; + const hs = 6 * pop; + for (const [hx, hy] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) ctx.fillRect(Math.round(hx) - hs / 2 + 0.5, Math.round(hy) - hs / 2 + 0.5, hs, hs); + if (o.label) { + ctx.font = '500 11px "Geist Mono", ui-monospace, monospace'; + const lab = `${Math.round(w)} × ${Math.round(h)}`; + const tw = ctx.measureText(lab).width + 12; + ctx.fillStyle = C.sig; + ctx.fillRect(Math.round(x + w / 2 - tw / 2), Math.round(y + h + 8), Math.round(tw), 18); + ctx.fillStyle = '#04080D'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(lab, Math.round(x + w / 2), Math.round(y + h + 17.5)); + } + ctx.restore(); + } + } + } + + build(); + let rt; + new ResizeObserver(() => { clearTimeout(rt); rt = setTimeout(() => { build(); if (reduced()) frame(0); }, 60); }).observe(canvas); + if (reduced()) { frame(0); window.addEventListener('pointermove', () => requestAnimationFrame(frame), { passive: true }); } + else loop(canvas, frame); + } + + /** + * Pixel dissolve edge between two colors. Cells near the edge carry + * minesweeper-style counts: how many pixels of the other color touch them. + * data-top / data-bottom: colors. data-cell: cell size in px. data-flip: dissolve upward. + */ + function dissolve(canvas, opts = {}) { + const d = canvas.dataset; + const o = Object.assign({ + top: d.top || cssVar('--void') || '#04080D', bottom: d.bottom || cssVar('--paper') || '#EEF1F4', + cell: +(d.cell || 24), seed: +(d.seed || 3), numbers: d.numbers !== 'false', accent: d.accent || '#0D99FF', + numTop: d.numTop || 'rgba(238,241,244,.55)', numBottom: d.numBottom || 'rgba(4,8,13,.45)', hover: d.hover === 'true', + spread: +(d.spread || 0.55), scatter: +(d.scatter || 1), + }, opts); + let W, H, cols, rows, field, cs; + const ptr = o.hover ? pointer(canvas) : null; + const trail = new Map(); // "c,r" -> time + + function build() { + const f = fit(canvas); W = f.w; H = f.h; + cs = W < 640 ? Math.max(12, Math.round(o.cell * 0.7)) : o.cell; + cols = Math.ceil(W / cs); rows = Math.ceil(H / cs); + field = new Uint8Array(cols * rows); + for (let c = 0; c < cols; c++) { + const n = fbm(c * 0.07, 0.5, o.seed, 4); + const n2 = fbm(c * 0.33, 4.2, o.seed + 9, 2); + const front = rows * (0.5 + (n - 0.5) * 2 * o.spread) + Math.round((n2 - 0.5) * 2); + for (let r = 0; r < rows; r++) { + const dist = r - front; + let v = dist < 0 ? 0 : 1; // 0 = top color, 1 = bottom color + const p = 0.24 * o.scatter * Math.exp(-Math.abs(dist) / 1.3); + if (hash2(c, r, o.seed) < p) v = 1 - v; + // a few stray islands further out + if (Math.abs(dist) > 3 && hash2(c * 3, r * 7, o.seed + 4) < 0.012 * o.scatter) v = 1 - v; + // A partial last row would show thin slivers, so it always takes the bottom color. + if ((r + 1) * cs > H) v = 1; + field[r * cols + c] = v; + } + } + draw(performance.now()); + } + + function at(c, r) { if (c < 0 || r < 0 || c >= cols || r >= rows) return -1; return field[r * cols + c]; } + + function draw(t) { + const ctx = canvas.getContext('2d'); + ctx.fillStyle = o.top; ctx.fillRect(0, 0, W, H); + ctx.fillStyle = o.bottom; + for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) if (field[r * cols + c]) ctx.fillRect(c * cs, r * cs, cs + 0.5, cs + 0.5); + if (o.numbers && cs >= 14) { + ctx.font = `500 ${Math.round(cs * 0.36)}px "Geist Mono", ui-monospace, monospace`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) { + const v = at(c, r); + let n = 0; + for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if ((dx || dy) && at(c + dx, r + dy) === 1 - v) n++; + if (!n || n > 4 || hash2(c, r, o.seed + 2) > 0.42) continue; + ctx.fillStyle = v ? o.numBottom : o.numTop; + ctx.fillText(String(n), c * cs + cs / 2, r * cs + cs / 2 + 0.5); + } + } + // cursor trail + for (const [k, t0] of trail) { + const age = (t - t0) / 900; + if (age >= 1) { trail.delete(k); continue; } + const [c, r] = k.split(',').map(Number); + ctx.globalAlpha = (1 - age) * 0.9; + ctx.fillStyle = o.accent; + ctx.fillRect(c * cs, r * cs, cs, cs); + ctx.globalAlpha = 1; + } + } + + build(); + let rt; + new ResizeObserver(() => { clearTimeout(rt); rt = setTimeout(build, 60); }).observe(canvas); + if (o.hover) { + let raf = 0; + const step = (t) => { raf = 0; draw(t); if (trail.size) raf = requestAnimationFrame(step); }; + window.addEventListener('pointermove', () => { + if (!ptr.inside) return; + const c = Math.floor(ptr.x / cs), r = Math.floor(ptr.y / cs); + trail.set(`${c},${r}`, performance.now()); + if (!raf) raf = requestAnimationFrame(step); + }, { passive: true }); + } + } + + /** + * Halftone dot field: dot sizes follow a slow noise field inside a horizontal band. + * data-color, data-step, data-hole ("w,h" in px, clears a box in the middle). + */ + function halftone(canvas, opts = {}) { + const d = canvas.dataset; + const o = Object.assign({ color: d.color || '#0D99FF', bg: d.bg || 'transparent', step: +(d.step || 14), seed: +(d.seed || 5), hole: d.hole, holeEl: d.holeEl, speed: +(d.speed || 1), band: d.band !== 'false', hover: d.hover !== 'false' }, opts); + const holeEls = o.holeEl ? [...(canvas.parentElement || document).querySelectorAll(o.holeEl)] : []; + let W, H, step; + const ptr = pointer(canvas); + function frame(t) { + const f = fit(canvas); W = f.w; H = f.h; + const ctx = f.ctx; + step = W < 640 ? Math.round(o.step * 0.8) : o.step; + ctx.clearRect(0, 0, W, H); + if (o.bg !== 'transparent') { ctx.fillStyle = o.bg; ctx.fillRect(0, 0, W, H); } + ctx.fillStyle = o.color; + const time = (t || 0) * 0.00004 * o.speed; + let [hw, hh] = o.hole ? o.hole.split(',').map(Number) : [0, 0]; + // Holes: soft boxes cleared around elements (data-hole-el), or one centred box (data-hole). + let holes = []; + if (holeEls.length) { + const cr = canvas.getBoundingClientRect(); + holes = holeEls.map((el) => { + const er = el.getBoundingClientRect(); + const pad = Math.min(80, W * 0.06, Math.max(20, Math.min(er.width, er.height) * 0.6)); + return [er.left - cr.left + er.width / 2, er.top - cr.top + er.height / 2, er.width + pad * 2, er.height + pad * 2]; + }); + } else if (hw) { + // Keep the cleared box inside the canvas so small screens still get dots around it. + holes = [[W / 2, H / 2, Math.min(hw, W * 0.86), Math.min(hh, H * 0.8)]]; + } + const cols = Math.ceil(W / step), rows = Math.ceil(H / step); + const offX = (W - (cols - 1) * step) / 2, offY = (H - (rows - 1) * step) / 2; + for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) { + const x = offX + c * step, y = offY + r * step; + let v = fbm(c * 0.05 + time, r * 0.09 - time * 0.6, o.seed, 4); + v = (v - 0.3) * 1.9 * (0.35 + 0.95 * hash2(c + 91, r + 37, o.seed)); + if (o.band) { const by = Math.abs(y / H - 0.5) * 2; v *= 1 - Math.pow(by, 2.2); } + const jitter = hash2(c, r, o.seed); + if (jitter < 0.22) v *= 0.3; + if (holes.length) { + let dd = Infinity; + for (const [hx, hy, hw2, hh2] of holes) dd = Math.min(dd, Math.max(Math.abs(x - hx) - hw2 / 2, Math.abs(y - hy) - hh2 / 2)); + if (dd < 0) continue; + v *= clamp(dd / (step * 3)); + } + if (o.hover && ptr.inside) { + const pd = Math.hypot(x - ptr.x, y - ptr.y); + if (pd < 120) v = Math.max(v, (1 - pd / 120) * 0.9); + } + v = clamp(v); + if (v < 0.1) continue; + const rad = (step / 2) * (0.22 + 0.7 * v); + ctx.beginPath(); ctx.arc(x, y, rad, 0, Math.PI * 2); ctx.fill(); + } + } + if (reduced()) { frame(0); new ResizeObserver(() => frame(0)).observe(canvas); } + else loop(canvas, frame); + } + + /* ----------------------------------------------------------------- rulers */ + + /** + * Rulers on the top and left edge of the page. The top ruler sits + * in the flow right under the page header and sticks with it; the left ruler is + * fixed. Both show page coordinates, the cursor position, and the extent of the + * element under the cursor. Enable with . + */ + function rulers(opts = {}) { + const header = opts.header || document.querySelector('body > header'); + const S = 20; // ruler thickness + const root = document.documentElement; + root.classList.add('px-rulers-on'); + + const top = document.createElement('div'); + top.className = 'px-ruler px-ruler-x'; + top.setAttribute('aria-hidden', 'true'); + top.innerHTML = 'PX'; + const left = document.createElement('div'); + left.className = 'px-ruler px-ruler-y'; + left.setAttribute('aria-hidden', 'true'); + left.innerHTML = ''; + if (header && header.parentNode) header.after(top); else document.body.prepend(top); + document.body.append(left); + const cx = top.querySelector('canvas'), cy = left.querySelector('canvas'); + + const C = { bg: '#08090A', tick: 'rgba(161,166,174,.26)', label: '#7D828B', sig: '#0D99FF', ink: '#0A0A0A' }; + let mx = -1, my = -1, raf = 0, inside = false, hb = 0, on = true; + + const stickTop = () => { + hb = header ? header.getBoundingClientRect().height : 0; + top.style.top = hb + 'px'; + left.style.top = hb + S + 'px'; // the left ruler starts under the corner square + if (on) root.style.scrollPaddingTop = hb + S + 8 + 'px'; // anchors land below header + ruler + }; + + function ticks(ctx, len, offset, vertical) { + // offset: page coordinate at the ruler's start + ctx.fillStyle = C.bg; ctx.fillRect(0, 0, vertical ? S : len, vertical ? len : S); + ctx.strokeStyle = C.tick; ctx.lineWidth = 1; + ctx.font = '500 9px "Geist Mono", ui-monospace, monospace'; + ctx.fillStyle = C.label; + ctx.beginPath(); + const first = Math.floor(offset / 10) * 10; + for (let v = first; v <= offset + len; v += 10) { + const p = Math.round(v - offset) + 0.5; + const h = v % 100 === 0 ? S : v % 50 === 0 ? 7 : 4; + if (vertical) { ctx.moveTo(S - h, p); ctx.lineTo(S, p); } else { ctx.moveTo(p, S - h); ctx.lineTo(p, S); } + } + ctx.stroke(); + for (let v = Math.floor(offset / 100) * 100; v <= offset + len; v += 100) { + const p = Math.round(v - offset); + const tw = ctx.measureText(v).width; + if (vertical ? p + 3 - tw < 0 : p + 3 + tw > len) continue; // never draw a clipped label + if (vertical) { ctx.save(); ctx.translate(9, p + 3); ctx.rotate(-Math.PI / 2); ctx.textAlign = 'right'; ctx.fillText(v, 0, 0); ctx.restore(); } + else { ctx.textAlign = 'left'; ctx.fillText(v, p + 3, 9); } + } + } + + function marker(ctx, pos, val, vertical, len) { + ctx.fillStyle = C.sig; + if (vertical) ctx.fillRect(0, Math.round(pos), S, 1); else ctx.fillRect(Math.round(pos), 0, 1, S); + const text = String(Math.round(val)); + ctx.font = '600 9px "Geist Mono", ui-monospace, monospace'; + const w = ctx.measureText(text).width + 8; + if (vertical) { + const y = Math.max(0, Math.min(len - w, pos - w / 2)); + ctx.fillRect(2, y, S - 4, w); + ctx.save(); ctx.translate(S / 2 + 3, y + w / 2); ctx.rotate(-Math.PI / 2); ctx.fillStyle = C.ink; ctx.textAlign = 'center'; ctx.fillText(text, 0, 0); ctx.restore(); + } else { + const x = Math.max(0, Math.min(len - w, pos - w / 2)); + ctx.fillRect(x, 2, w, S - 4); + ctx.fillStyle = C.ink; ctx.textAlign = 'center'; ctx.fillText(text, x + w / 2, S / 2 + 3); + } + } + + function draw() { + raf = 0; + // top ruler: x measured from the content edge (right of the left ruler) + const tw = Math.max(1, cx.getBoundingClientRect().width); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const y0 = hb + S, th = Math.max(1, window.innerHeight - y0); // left ruler's screen top and length + for (const [c, w, h] of [[cx, tw, S], [cy, S, th]]) { + if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); } + c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); + } + const gx = cx.getContext('2d'), gy = cy.getContext('2d'); + const ox = cx.getBoundingClientRect().left; // screen x of page x = 0 ... content starts at S + const xStart = ox - S; // page x at the canvas's left edge + ticks(gx, tw, xStart + window.scrollX, false); + ticks(gy, th, window.scrollY + y0, true); + // Cursor markers only over the page content, never over the rulers or header. + if (inside && mx >= S && my >= y0) { + marker(gx, mx - ox, mx - S + window.scrollX, false, tw); + marker(gy, my - y0, my + window.scrollY, true, th); + } + } + const kick = () => { if (!raf) raf = requestAnimationFrame(draw); }; + + window.addEventListener('pointermove', (e) => { + mx = e.clientX; my = e.clientY; inside = true; + kick(); + }, { passive: true }); + document.addEventListener('pointerleave', () => { inside = false; kick(); }); + window.addEventListener('scroll', kick, { passive: true }); + window.addEventListener('resize', () => { stickTop(); kick(); }); + if (header) new ResizeObserver(stickTop).observe(header); + // Rulers need room: switch them off below 861px and back on when the window grows. + const wide = matchMedia('(min-width: 861px)'); + const apply = () => { + on = wide.matches; + root.classList.toggle('px-rulers-on', on); + top.hidden = left.hidden = !on; + if (!on) root.style.scrollPaddingTop = ''; + stickTop(); kick(); + }; + wide.addEventListener('change', apply); + apply(); + return { redraw: kick }; + } + + /* ---------------------------------------------------------------- hydrate */ + + function opts(el) { + const d = el.dataset, o = {}; + if (d.shape) o.shape = d.shape; + if (d.gap) o.gap = +d.gap; + if (d.fill) o.fill = d.fill; + if (d.accent) o.accent = d.accent; + if (d.mono != null) o.mono = true; + if (d.tracking) o.tracking = +d.tracking; + return o; + } + + function hydrate(root = document) { + root.querySelectorAll('[data-px-text]').forEach((el) => { el.innerHTML = text(el.dataset.pxText, { title: el.getAttribute('aria-label') || el.dataset.pxText, ...opts(el) }); }); + root.querySelectorAll('[data-px-logo]').forEach((el) => { + const kind = el.dataset.pxLogo, o = opts(el); + el.innerHTML = kind === 'mark' ? mark(o) + : kind === 'lockup' ? lockup(o) + : kind === 'lockup-inline' ? lockup({ ...o, inline: true }) + : kind === 'wordmark-stack' ? wordmark({ ...o, stack: true }) + : wordmark(o); + }); + root.querySelectorAll('[data-px-icon]').forEach((el) => { el.innerHTML = icon(el.dataset.pxIcon, opts(el)); }); + root.querySelectorAll('[data-px-cursor]').forEach((el) => { el.innerHTML = svg(grid(BIG_CURSOR), opts(el)); }); + root.querySelectorAll('canvas[data-px-bg]').forEach((c) => { + if (c.__px) return; c.__px = 1; + const kind = c.dataset.pxBg; + if (kind === 'blueprint') blueprint(c); + else if (kind === 'dissolve') dissolve(c); + else if (kind === 'halftone') halftone(c); + }); + } + + window.PX = { CAPS, BOLD, LOWER, MARK, WORD, WORD_STACK, LOCKUP, LOCKUP_INLINE, scaled, beside, ICONS, BIG_CURSOR, layout, grid, svg, text, wordmark, mark, lockup, icon, blueprint, dissolve, halftone, rulers, hydrate, noise: { hash2, vnoise, fbm } }; + + const ready = () => (document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => { + hydrate(); + // Rulers only where there is room and a mouse to point with. + if (document.body && document.body.hasAttribute && document.body.hasAttribute('data-px-rulers') && window.matchMedia && matchMedia('(pointer: fine)').matches) rulers(); + }); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready, { once: true }); + else ready(); +})(); diff --git a/site/assets/ps.css b/site/assets/ps.css new file mode 100644 index 0000000..858383a --- /dev/null +++ b/site/assets/ps.css @@ -0,0 +1,170 @@ +/* PixelSpec brand tokens and shared components. Spacing on a 4px base, layout on an 8px grid. */ + +:root { + /* Surfaces, dark (default) */ + --void: #08090A; /* page */ + --night: #0F1011; /* surface: cards, panels */ + --deep: #16171A; /* raised: hover, inputs, nested cards */ + --line: #1F2023; /* hairlines */ + --line-2: #2B2D31; /* borders on controls */ + --fog: #A1A6AE; /* secondary text */ + --mute: #7D828B; /* tertiary text and labels, AA on void and night */ + --snow: #F7F8F8; /* primary text */ + + /* Surfaces, light */ + --paper: #F7F6F3; /* warm light page and cards */ + --paper-2: #EEEDE9; + --rule: #E3E1DC; /* hairlines on light */ + --ink: #0A0A0A; /* text on light */ + --ink-2: #5E6168; /* secondary text on light, AA */ + + /* Brand + spec palette. Signal is the brand blue; the rest only appear in product visuals. */ + --signal: #0D99FF; + --signal-2: #6CC2FF; + --redline: #FF3B30; /* distances */ + --pad: #86EFAC; /* padding */ + --margin: #F6B26B; /* margin */ + --gap: #E83E8C; /* gap */ + --marker: #FFE14D; /* highlight */ + + /* Type: Geist for everything, Geist Pixel as an accent, Geist Mono for numbers and labels */ + --f-sans: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --f-pixel: 'Geist Pixel', 'Geist Mono', ui-monospace, monospace; + --f-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + + /* Shape */ + --r-sm: 8px; + --r: 12px; + --r-lg: 20px; + --r-pill: 999px; + + /* Layout */ + --gutter: clamp(20px, 4vw, 40px); + --max: 1200px; + --ease: cubic-bezier(.2, 0, 0, 1); + + color-scheme: dark; +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } +body { + margin: 0; + background: var(--void); + color: var(--snow); + font: 400 16px/1.6 var(--f-sans); + letter-spacing: -0.006em; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +img, svg, canvas, video { display: block; max-width: 100%; } +a { color: inherit; } +::selection { background: var(--signal); color: var(--ink); } +:focus-visible { outline: 2px solid var(--signal); outline-offset: 3px; border-radius: 4px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } + +/* ---------- logo (site/assets/brand.js fills [data-logo]) */ +[data-logo] { display: inline-block; line-height: 0; color: inherit; --ps-mark: var(--signal); } +[data-logo] svg { height: 100%; width: auto; overflow: visible; } + +/* ---------- icons: (Hugeicons, stroke) */ +.i { width: 20px; height: 20px; flex: none; color: inherit; } +.i.sm { width: 16px; height: 16px; } +.i.lg { width: 24px; height: 24px; } + +/* ---------- type */ +.t-pixel { font-family: var(--f-pixel); font-weight: 400; letter-spacing: 0; font-variation-settings: 'ELSH' 0; } +.t-mono { font-family: var(--f-mono); } +.h-display { margin: 0; font: 500 clamp(44px, 7vw, 80px)/1.02 var(--f-sans); letter-spacing: -0.045em; text-wrap: balance; } +.h-1 { margin: 0; font: 500 clamp(32px, 4.4vw, 52px)/1.06 var(--f-sans); letter-spacing: -0.035em; text-wrap: balance; } +.h-2 { margin: 0; font: 500 clamp(24px, 2.6vw, 32px)/1.15 var(--f-sans); letter-spacing: -0.025em; text-wrap: balance; } +.h-3 { margin: 0; font: 500 17px/1.35 var(--f-sans); letter-spacing: -0.015em; } +.lede { margin: 0; font-size: clamp(17px, 1.6vw, 20px); line-height: 1.5; color: var(--fog); text-wrap: pretty; } +.muted { color: var(--fog); } + +/* Small mono label, Linear-style: "FIG 0.1", section numbers, metadata */ +.label { + font: 500 12px/1 var(--f-mono); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--mute); +} + +/* ---------- buttons: pills */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + height: 40px; padding: 0 18px; + border: 0; border-radius: var(--r-pill); + font: 500 14px/1 var(--f-sans); letter-spacing: -0.01em; + text-decoration: none; cursor: pointer; white-space: nowrap; + transition: background-color .15s var(--ease), color .15s var(--ease), box-shadow .15s var(--ease); +} +.btn.lg { height: 48px; padding: 0 24px; font-size: 15px; } +.btn.sm { height: 32px; padding: 0 14px; font-size: 13px; } +.btn-primary { background: var(--snow); color: var(--ink); } +.btn-primary:hover { background: #fff; } +.btn-secondary { background: var(--deep); color: var(--snow); box-shadow: inset 0 0 0 1px var(--line-2); } +.btn-secondary:hover { background: #1D1E21; } +.btn-ghost { background: transparent; color: var(--fog); } +.btn-ghost:hover { color: var(--snow); } +.btn-signal { background: var(--signal); color: var(--ink); } +.btn-signal:hover { background: #2AA6FF; } +.on-paper .btn-primary { background: var(--ink); color: #fff; } +.on-paper .btn-primary:hover { background: #222; } +.on-paper .btn-secondary { background: var(--paper-2); color: var(--ink); box-shadow: none; } +.on-paper .btn-secondary:hover { background: #E6E4DF; } + +/* Text link with arrow */ +.more-link { display: inline-flex; align-items: center; gap: 6px; color: var(--fog); text-decoration: none; font-size: 15px; transition: color .15s; } +.more-link:hover { color: var(--snow); } +.more-link .i { width: 16px; height: 16px; transition: transform .15s var(--ease); } +.more-link:hover .i { transform: translateX(2px); } + +/* Copyable command */ +.cmd { + display: inline-flex; align-items: center; gap: 12px; + height: 48px; padding: 0 6px 0 18px; + background: var(--night); border: 1px solid var(--line-2); border-radius: var(--r-pill); + font: 500 14px/1 var(--f-mono); color: var(--snow); + cursor: pointer; text-align: left; +} +.cmd .prompt { color: var(--mute); } +.cmd .copy-ic { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; color: var(--mute); transition: color .15s, background-color .15s; } +.cmd:hover .copy-ic { color: var(--snow); background: var(--deep); } +.cmd[data-copied] .copy-ic { color: var(--pad); } + +/* ---------- cards */ +.card { background: var(--night); border: 1px solid var(--line); border-radius: var(--r-lg); } +.on-paper .card, .card.paper { background: var(--paper); border-color: transparent; color: var(--ink); } + +/* Spec tag: the blue size label from the product */ +.spec-tag { + display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: 4px; + background: var(--signal); color: var(--ink); + font: 500 11px/1 var(--f-mono); white-space: nowrap; +} + +/* ---------- layout */ +.wrap { width: 100%; max-width: var(--max); margin: 0 auto; padding: 0 var(--gutter); } + +/* ---------- rulers (PX.rulers in pixel.js, enabled with ) */ +.px-rulers-on body { padding-left: 20px; } +.px-ruler { background: var(--void); } +.px-ruler canvas { display: block; } +.px-ruler-x { + position: sticky; z-index: 49; height: 20px; margin-left: -20px; + display: flex; box-shadow: inset 0 -1px 0 var(--line); +} +.px-ruler-x canvas { flex: 1; min-width: 0; height: 20px; } +.px-ruler-corner { + flex: none; width: 20px; height: 20px; display: grid; place-items: center; + font: 500 7px/1 var(--f-mono); letter-spacing: .04em; color: var(--mute); + box-shadow: inset -1px -1px 0 var(--line); +} +.px-ruler-y { + position: fixed; z-index: 48; left: 0; top: 84px; bottom: 0; width: 20px; + box-shadow: inset -1px 0 0 var(--line); +} +.px-ruler-y canvas { width: 20px; height: 100%; } +.px-ruler[hidden] { display: none; } diff --git a/site/assets/site.css b/site/assets/site.css new file mode 100644 index 0000000..0322ad7 --- /dev/null +++ b/site/assets/site.css @@ -0,0 +1,291 @@ +/* Landing page. Tokens and shared components live in ps.css. */ + +/* ---------- nav */ +.nav { position: sticky; top: 0; z-index: 50; background: rgb(8 9 10 / .82); backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); border-bottom: 1px solid var(--line); } +.nav .wrap { display: flex; align-items: center; gap: 40px; height: 64px; } +.nav .brand { display: flex; align-items: center; height: 22px; color: var(--snow); text-decoration: none; flex: none; } +.nav .brand [data-logo] { height: 22px; } +.nav .links { display: flex; gap: 28px; margin-left: auto; } +.nav .links a { font-size: 14px; color: var(--fog); text-decoration: none; white-space: nowrap; transition: color .15s; } +.nav .links a:hover { color: var(--snow); } +.nav .actions { display: flex; align-items: center; gap: 8px; padding-left: 20px; border-left: 1px solid var(--line); } +@media (max-width: 900px) { .nav .links { display: none; } .nav .actions { margin-left: auto; border: 0; padding: 0; } } +@media (max-width: 420px) { .nav .gh span { display: none; } } + +/* ---------- hero */ +.hero { position: relative; padding: clamp(64px, 10vw, 120px) 0 64px; text-align: center; overflow: hidden; } +/* A faint grid that fades out, like a sheet of graph paper under the page. */ +.hero::before { + content: ''; position: absolute; inset: 0 0 auto; height: 980px; pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='112' height='112'%3E%3Cg fill='none'%3E%3Cpath d='M0 56.5H112M56.5 0V112' stroke='rgba(255,255,255,0.045)'/%3E%3Cpath d='M50 56.5H63M56.5 50V63' stroke='rgba(255,255,255,0.28)' stroke-width='1.25'/%3E%3C/g%3E%3C/svg%3E"); + background-size: 112px 112px; background-position: calc(50% + 0px) -56px; + -webkit-mask-image: radial-gradient(ellipse 72% 78% at 50% 18%, #000 20%, transparent 75%); mask-image: radial-gradient(ellipse 72% 78% at 50% 18%, #000 20%, transparent 75%); +} +.hero > .wrap { position: relative; } +.pill-link { display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 12px 0 10px; margin-bottom: 32px; border: 1px solid var(--line-2); border-radius: var(--r-pill); + background: var(--night); color: var(--fog); font-size: 13px; text-decoration: none; transition: border-color .15s, color .15s; } +.pill-link:hover { border-color: #3A3C41; color: var(--snow); } +.pill-link b { color: var(--snow); font-weight: 500; } +.pill-link .dot { width: 6px; height: 6px; border-radius: 2px; background: var(--signal); } +.pill-link .sep { width: 1px; height: 12px; background: var(--line-2); } +.pill-link .i { color: var(--mute); } +.hero .h-display { max-width: 19ch; margin: 0 auto; } +.accent { color: var(--snow); font-size: .96em; letter-spacing: -0.04em; word-spacing: -0.12em; } +.measure { position: relative; display: flex; align-items: center; height: 20px; margin: 18px auto 28px; max-width: 100%; color: var(--signal); } +.measure i { flex: 1; height: 1px; background: currentColor; opacity: .7; } +.measure::before, .measure::after { content: ''; width: 1px; height: 9px; background: currentColor; opacity: .7; } +.measure .spec-tag { margin: 0 8px; } +.hero .lede { max-width: 600px; margin: 0 auto; } +.hero-cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin-top: 36px; } +.hero-cta .btn .kbd { display: inline-flex; gap: 4px; margin-left: 6px; } +/* light keycaps on the white button */ +.hero-cta .btn kbd { height: 22px; color: #3A3C41; background: linear-gradient(180deg, #FFFFFF 0%, #ECECEE 100%); + box-shadow: inset 0 0 0 1px rgb(10 10 10 / .12), inset 0 -1.5px 0 rgb(10 10 10 / .1), 0 1px 1px rgb(10 10 10 / .06); } + +.wrap.wide { max-width: 1280px; margin-top: clamp(56px, 8vw, 88px); } +.window { position: relative; margin: 0; border-radius: 14px; background: var(--night); padding: 6px; box-shadow: 0 0 0 1px var(--line-2), 0 24px 60px -24px rgb(0 0 0 / .7); } +.win-bar { display: flex; align-items: center; gap: 6px; height: 34px; padding: 0 8px 0 10px; } +.win-bar > span:not(.spec-tag) { width: 10px; height: 10px; border-radius: 50%; background: var(--line-2); } +.win-url { flex: 1; margin: 0 8px; height: 22px; max-width: 360px; margin-inline: auto; border-radius: 6px; background: var(--deep); color: var(--mute); font: 400 12px/22px var(--f-mono); text-align: center; } + +/* ---------- stats */ +.stats { padding: clamp(0px, 2.5vw, 32px) 0 0; } +.stats .wrap { display: grid; grid-template-columns: repeat(4, 1fr); } +.stat { padding: 4px 16px; text-align: center; } +.stat + .stat { border-left: 1px solid var(--line); } +.stat .num { display: block; font-size: clamp(36px, 4vw, 52px); line-height: 1; color: var(--snow); } +.stat .num small { font-size: .42em; color: var(--mute); margin-left: 4px; } +.stat p { margin: 14px 0 0; font-size: 14px; color: var(--fog); } +@media (max-width: 760px) { + .stats .wrap { grid-template-columns: 1fr 1fr; row-gap: 32px; } + .stat:nth-child(3) { border-left: 0; } +} + +/* ---------- sections */ +.sec { padding: clamp(96px, 13vw, 168px) 0 0; scroll-margin-top: 64px; } +.statement { margin: 0; max-width: 1040px; font: 500 clamp(28px, 3.6vw, 44px)/1.14 var(--f-sans); letter-spacing: -0.03em; color: var(--snow); text-wrap: pretty; } +.statement span { color: #6F747C; } +.intro { display: grid; grid-template-columns: 1fr 1fr; gap: 40px 64px; align-items: start; margin-bottom: clamp(48px, 6vw, 72px); } +.intro .lede { color: var(--fog); } +.intro .more-link { margin-top: 24px; } +@media (max-width: 860px) { .intro { grid-template-columns: 1fr; gap: 20px; } .intro br { display: none; } } + +/* figs */ +.figs { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: clamp(64px, 8vw, 104px); border-top: 1px solid var(--line); } +.fig { padding: 24px 32px 0 0; } +.fig + .fig { padding-left: 32px; border-left: 1px solid var(--line); } +.fig-viz { position: relative; display: grid; place-items: center; height: 168px; margin: 8px 0 28px; border-radius: var(--r); background: var(--night); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; + background-image: radial-gradient(circle, #1F2124 1px, transparent 1.4px); background-size: 16px 16px; background-position: 8px 8px; } +/* 0.1 exact */ +.fv-row { display: flex; align-items: center; margin-top: -18px; } +.fv-el { position: relative; display: inline-flex; align-items: center; height: 36px; padding: 0 16px; border-radius: 9px; background: var(--deep); box-shadow: inset 0 0 0 1px var(--line-2); font-size: 13px; font-weight: 500; color: var(--fog); } +.fv-el.sel { background: var(--snow); color: var(--ink); box-shadow: none; outline: 1.5px solid var(--signal); outline-offset: 3px; } +.fv-el.sel i { position: absolute; width: 6px; height: 6px; border-radius: 2px; background: var(--snow); border: 1px solid var(--signal); } +.fv-el.sel i:nth-child(1) { left: -7px; top: -7px; } .fv-el.sel i:nth-child(2) { right: -7px; top: -7px; } +.fv-el.sel i:nth-child(3) { left: -7px; bottom: -7px; } .fv-el.sel i:nth-child(4) { right: -7px; bottom: -7px; } +.fv-rl { position: relative; width: 44px; height: 1px; margin: 0 4px 0 8px; background: var(--redline); } +.fv-rl::before, .fv-rl::after { content: ''; position: absolute; top: -4px; width: 1px; height: 9px; background: var(--redline); } +.fv-rl::before { left: 0; } .fv-rl::after { right: 0; } +.fv-rl b { position: absolute; left: 50%; top: -28px; transform: translateX(-50%); height: 18px; padding: 0 5px; border-radius: 4px; background: var(--redline); color: var(--ink); font: 500 11px/18px var(--f-mono); } +.fv-size { position: absolute; left: 50%; top: calc(100% + 12px); transform: translateX(-50%); height: 18px; padding: 0 6px; border-radius: 4px; background: var(--signal); color: var(--ink); font: 500 11px/18px var(--f-mono); white-space: nowrap; } +/* 0.2 agent */ +.fv-note { display: flex; gap: 10px; max-width: calc(100% - 32px); padding: 12px 14px 12px 12px; border-radius: 12px; background: var(--deep); box-shadow: inset 0 0 0 1px var(--line-2), 0 10px 24px rgb(0 0 0 / .35); } +.fv-pin { flex: none; width: 20px; height: 20px; border-radius: 50%; background: var(--signal); color: var(--ink); font: 600 10.5px/20px var(--f-mono); text-align: center; } +.fv-note b { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; color: var(--snow); } +.fv-note code { display: block; font: 400 11px/1.7 var(--f-mono); color: #C9CCD1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fv-note .s { color: var(--signal-2); } .fv-note .k { color: #C9B8FF; } .fv-note .n { color: var(--snow); } .fv-note .g { color: var(--pad); } .fv-note .d { color: var(--mute); } +/* 0.3 invisible */ +.fv-root { position: relative; display: grid; place-items: center; width: 70%; height: 92px; border-radius: 12px; border: 1px dashed #3A3C41; } +.fv-label { position: absolute; left: 10px; top: -9px; padding: 0 6px; background: var(--night); font: 500 11px/18px var(--f-mono); color: var(--mute); } +.fv-tb { display: flex; align-items: center; gap: 6px; padding: 6px 10px 6px 8px; border-radius: 12px; background: var(--deep); box-shadow: inset 0 0 0 1px var(--line-2); } +.fv-tb [data-logo] { height: 18px; } +.fv-tb i { width: 16px; height: 16px; border-radius: 5px; background: #2B2D31; } +.fv-pill { position: absolute; right: 14px; bottom: 12px; display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 9px 0 7px; border-radius: 99px; background: var(--deep); box-shadow: inset 0 0 0 1px var(--line-2); font: 500 11px/1 var(--f-mono); color: var(--fog); } +.fv-pill .i { width: 13px; height: 13px; color: var(--pad); } +.fig p { margin: 8px 0 0; font-size: 15px; color: var(--fog); max-width: 34ch; } +@media (max-width: 860px) { + .figs { grid-template-columns: 1fr; border-top: 0; } + .fig, .fig + .fig { padding: 28px 0 0; border-left: 0; border-top: 1px solid var(--line); } + .fig-viz { margin: 20px 0; } + .fig + .fig { margin-top: 28px; } +} + +/* ---------- feature cards */ +.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.feat { display: flex; flex-direction: column; overflow: hidden; } +.feat-copy { padding: 28px 28px 0; } +.feat h3 { display: flex; align-items: center; gap: 10px; } +.feat h3 .i { color: var(--fog); } +.feat p { margin: 10px 0 0; font-size: 15px; color: var(--fog); text-wrap: pretty; } +@media (max-width: 1000px) { .cards { grid-template-columns: 1fr 1fr; } } +@media (max-width: 640px) { .cards { grid-template-columns: 1fr; } } + +/* mini mockups: drawn in the product's own colors */ +.viz { position: relative; flex: 1; min-height: 210px; margin: 24px 12px 12px; border-radius: var(--r); background: var(--deep); display: grid; place-items: center; overflow: hidden; + background-image: radial-gradient(circle, #24262A 1px, transparent 1.4px); background-size: 16px 16px; background-position: 8px 8px; } +.viz .spec-tag { position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%); } +.viz-spacing .m { padding: 16px; margin-bottom: 18px; background: repeating-linear-gradient(-45deg, rgb(246 178 107 / .38) 0 2px, rgb(246 178 107 / .14) 2px 7px); border-radius: 4px; } +.viz-spacing .p { padding: 12px 20px; background: rgb(134 239 172 / .3); border-radius: 10px; outline: 1px solid var(--signal); } +.viz-spacing .c { padding: 2px 6px; background: rgb(13 153 255 / .18); color: var(--snow); font: 500 14px/1.4 var(--f-sans); border-radius: 3px; white-space: nowrap; } + +.viz-redline .a, .viz-redline .b { position: absolute; border-radius: 8px; } +.viz-redline .a { left: 18%; top: 22%; width: 22%; height: 40%; background: #2B2D31; } +.viz-redline .b { right: 14%; top: 34%; width: 30%; height: 26%; background: var(--signal); } +.viz-redline .rl { position: absolute; left: 40%; right: 44%; top: 47%; height: 1px; background: var(--redline); } +.viz-redline .rl::before, .viz-redline .rl::after { content: ''; position: absolute; top: -4px; width: 1px; height: 9px; background: var(--redline); } +.viz-redline .rl::before { left: 0; } .viz-redline .rl::after { right: 0; } +.viz-redline .rv { position: absolute; left: 29%; top: 62%; bottom: 14%; width: 1px; background: var(--redline); } +.tag-red { position: absolute; left: 50%; top: 50%; transform: translate(-50%, 10px); padding: 0 5px; height: 18px; border-radius: 4px; background: var(--redline); color: var(--ink); font: 500 11px/18px var(--f-mono); } +.viz-redline .rv .tag-red { transform: translate(8px, -50%); } + +.viz-type .ag { position: relative; margin-bottom: 28px; font: 500 88px/1 var(--f-sans); letter-spacing: -0.04em; color: var(--snow); padding: 0 12px; } +.viz-type .cap, .viz-type .base { position: absolute; left: -24px; right: -24px; height: 1px; } +.viz-type .cap { top: 14px; background: rgb(13 153 255 / .7); } +.viz-type .base { bottom: 19px; background: rgb(255 59 48 / .7); } + +.viz-contrast .sw { display: flex; gap: 0; margin-bottom: 30px; border-radius: 10px; overflow: hidden; } +.viz-contrast .sw div { display: grid; place-items: center; width: 92px; height: 92px; font: 600 30px/1 var(--f-sans); } +.viz-contrast .s1 { background: var(--signal); color: #fff; } +.viz-contrast .s2 { background: #050506; color: var(--marker); box-shadow: inset 0 0 0 1px var(--line-2); } +.viz-contrast .ratio { position: absolute; left: 0; right: 0; bottom: 22px; display: flex; justify-content: center; gap: 8px; } +.pill-tag { height: 20px; padding: 0 7px; border-radius: 4px; font: 500 11px/20px var(--f-mono); color: var(--ink); } +.pill-tag.bad { background: var(--redline); } +.pill-tag.ok { background: var(--pad); } + +.viz-component .tree { font: 400 13px/2.1 var(--f-mono); color: var(--mute); } +.viz-component .tree div { padding-left: calc(var(--d, 0) * 18px); white-space: nowrap; } +.viz-component .tree .sel { margin-left: calc(var(--d) * 18px - 8px); padding: 0 8px; border-radius: 6px; background: rgb(13 153 255 / .14); box-shadow: inset 0 0 0 1px rgb(13 153 255 / .5); color: var(--snow); } +.viz-component .tree em { font-style: normal; color: var(--signal-2); } +.viz-component .tree .sel span { margin-left: 10px; color: var(--fog); } + +.viz-feedback { gap: 14px; align-content: center; } +.viz-feedback .target { position: relative; padding: 12px 22px; border-radius: 10px; background: var(--snow); color: var(--ink); font: 600 14px/1 var(--f-sans); outline: 1.5px solid var(--signal); outline-offset: 4px; } +.pin { position: absolute; right: -16px; top: -16px; width: 22px; height: 22px; border-radius: 50%; background: var(--signal); color: var(--ink); font: 600 11px/22px var(--f-mono); text-align: center; box-shadow: 0 0 0 2px var(--snow); } +.viz-feedback .note { display: flex; align-items: center; gap: 10px; padding: 9px 14px 9px 10px; border-radius: 10px; background: var(--night); box-shadow: 0 0 0 1px var(--line-2), 0 10px 24px rgb(0 0 0 / .4); font-size: 13px; color: var(--snow); } +.pin.sm { position: static; width: 18px; height: 18px; font-size: 10px; line-height: 18px; box-shadow: none; flex: none; } + +.extras { display: flex; flex-wrap: wrap; gap: 10px 28px; list-style: none; margin: 40px 0 0; padding: 0; } +.extras li { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--fog); } +.extras .i { color: var(--mute); } + +/* ---------- agents */ +.ai { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 16px; align-items: stretch; } +.term { display: flex; flex-direction: column; overflow: hidden; } +.term-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 12px 12px 20px; border-bottom: 1px solid var(--line); } +.term-title { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--fog); } +.seg { display: inline-flex; padding: 3px; border-radius: var(--r-pill); background: var(--deep); box-shadow: inset 0 0 0 1px var(--line); } +.seg button { all: unset; cursor: pointer; padding: 6px 14px; border-radius: var(--r-pill); font-size: 13px; color: var(--fog); } +.seg button[aria-pressed="true"] { background: var(--line-2); color: var(--snow); } +.seg button:focus-visible { outline: 2px solid var(--signal); } +.term pre { flex: 1; margin: 0; padding: 22px 24px; white-space: pre-wrap; overflow-wrap: anywhere; font: 400 12.5px/1.8 var(--f-mono); color: #D4D7DC; } +.term .dim { color: var(--mute); } .term .n { color: var(--snow); font-weight: 500; } .term .s { color: var(--signal-2); } +.term .k { color: #C9B8FF; } .term .g { color: var(--pad); } .term .o { color: var(--margin); } .term .r { color: #FF6B61; } +.term-foot { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 14px 20px; border-top: 1px solid var(--line); font-size: 13px; color: var(--mute); } +.term-foot b { color: var(--fog); font-weight: 500; } +/* keycaps */ +kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 6px; vertical-align: middle; + font: 500 12px/1 var(--f-sans); letter-spacing: 0; color: #E6E8EB; white-space: nowrap; + background: linear-gradient(180deg, #2A2C30 0%, #1C1D20 100%); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / .09), inset 0 1px 0 rgb(255 255 255 / .06), inset 0 -1.5px 0 rgb(0 0 0 / .55), 0 1px 2px rgb(0 0 0 / .45); } +.kbd { display: inline-flex; gap: 4px; } +.ai-steps { list-style: none; margin: 0; padding: 0; display: grid; align-content: start; } +.ai-steps li { display: grid; grid-template-columns: 44px 1fr; gap: 8px; padding: 26px 0; border-top: 1px solid var(--line); } +.ai-steps li:first-child { border-top: 0; padding-top: 8px; } +.ai-steps .t-pixel { font-size: 20px; line-height: 1.2; color: var(--signal); } +.ai-steps p { margin: 6px 0 0; font-size: 15px; color: var(--fog); } +@media (max-width: 960px) { .ai { grid-template-columns: 1fr; } .ai-steps { padding: 0 4px; } } + +/* ---------- how */ +.steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, 1fr); border-top: 1px solid var(--line); } +.steps li { display: flex; flex-direction: column; padding: 24px 24px 0 0; } +.steps li + li { padding-left: 24px; border-left: 1px solid var(--line); } +.steps h3 { margin-top: 28px; } +.steps p { margin: 8px 0 20px; font-size: 15px; color: var(--fog); text-wrap: pretty; } +.steps .kbd { display: flex; gap: 4px; margin-top: auto; } +@media (max-width: 960px) { + .steps { grid-template-columns: 1fr 1fr; } + .steps li, .steps li + li { padding: 24px 20px 28px 0; border-left: 0; border-bottom: 1px solid var(--line); } + .steps h3 { margin-top: 16px; } +} +@media (max-width: 520px) { .steps { grid-template-columns: 1fr; } .steps p { margin-bottom: 12px; } } + +/* ---------- install */ +.install { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 16px; align-items: stretch; } +.tabs { overflow: hidden; } +.tabs [role="tablist"] { display: flex; gap: 4px; padding: 10px; border-bottom: 1px solid var(--line); overflow-x: auto; } +.tabs [role="tab"] { all: unset; cursor: pointer; padding: 8px 14px; border-radius: var(--r-pill); font-size: 14px; color: var(--fog); white-space: nowrap; } +.tabs [role="tab"]:hover { color: var(--snow); } +.tabs [role="tab"][aria-selected="true"] { background: var(--deep); color: var(--snow); box-shadow: inset 0 0 0 1px var(--line-2); } +.tabs [role="tab"]:focus-visible { outline: 2px solid var(--signal); } +.code + .code { border-top: 1px solid var(--line); } +.code .cap { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px 0; font: 400 12px/1 var(--f-mono); color: var(--mute); } +.code .cp { all: unset; cursor: pointer; padding: 6px 10px; border-radius: var(--r-pill); font: 500 12px/1 var(--f-sans); color: var(--fog); box-shadow: inset 0 0 0 1px var(--line-2); } +.code .cp:hover, .code .cp[data-copied] { color: var(--snow); background: var(--deep); } +.code pre { margin: 0; padding: 12px 20px 20px; overflow-x: auto; font: 400 13px/1.75 var(--f-mono); color: #D4D7DC; } +.code .k { color: #C9B8FF; } .code .s { color: var(--pad); } .code .t { color: var(--signal-2); } .code .a { color: var(--margin); } .code .c { color: var(--mute); } +.keys { padding: 24px; } +.keys ul { list-style: none; margin: 16px 0 0; padding: 0; } +.keys li { display: flex; align-items: center; gap: 12px; padding: 11px 0; border-top: 1px solid var(--line); font-size: 14px; color: var(--fog); } +.keys li:first-child { border-top: 0; } +.keys li .i { color: var(--mute); } +.keys .kbd { display: flex; gap: 4px; margin-left: auto; } +@media (max-width: 960px) { .install { grid-template-columns: 1fr; } } +@media (max-width: 560px) { .tabs .xl { display: none; } } + +/* ---------- CTA */ +.cta { position: relative; padding: clamp(120px, 16vw, 200px) 0 clamp(260px, 30vw, 380px); text-align: center; overflow: hidden; } +.cta .wrap { position: relative; } +.cta .h-display { font-size: clamp(40px, 6vw, 72px); } +.cta .lede { max-width: 520px; margin: 20px auto 0; } +.cta-px { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: clamp(220px, 26vw, 330px); } + +/* ---------- footer: continues the CTA's blue pixel edge */ +.foot { padding: 32px 0 32px; background: var(--signal); color: var(--ink); } +.foot .cols { display: grid; grid-template-columns: 2fr repeat(2, 1fr); gap: 40px; } +.foot-logo { height: 20px; color: var(--ink); } +.foot-logo { --ps-mark: #fff; } +.foot-brand p { margin: 16px 0 0; font-size: 14px; color: rgb(10 10 10 / .72); } +.foot h4 { margin: 0 0 14px; font-size: 13px; font-weight: 600; color: var(--ink); } +.foot nav a { display: block; padding: 4px 0; font-size: 14px; color: rgb(10 10 10 / .78); text-decoration: none; } +.foot nav a:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; } +.foot .base { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-top: 64px; padding-top: 24px; border-top: 1px solid rgb(10 10 10 / .18); font-size: 13px; color: rgb(10 10 10 / .72); } +.foot :focus-visible { outline-color: var(--ink); } +@media (max-width: 760px) { .foot .cols { grid-template-columns: 1fr 1fr; } .foot-brand { grid-column: 1 / -1; } } + +/* ---------- toast */ +.toast { position: fixed; left: 50%; bottom: 24px; z-index: 60; transform: translate(-50%, 12px); padding: 10px 16px; border-radius: var(--r-pill); background: var(--snow); color: var(--ink); font-size: 14px; font-weight: 500; opacity: 0; pointer-events: none; transition: opacity .2s var(--ease), transform .2s var(--ease); } +.toast.show { opacity: 1; transform: translate(-50%, 0); } + +/* touch screens: no keyboard hints */ +@media (pointer: coarse) { .hero-cta .btn .kbd { display: none; } } +@media (max-width: 520px) { .hero-cta > * { width: 100%; justify-content: space-between; } .hero-cta .btn { justify-content: center; } } + +html { scroll-padding-top: 88px; } + +/* ---------- FAQ */ +.faq { border-top: 1px solid var(--line); } +.faq details { border-bottom: 1px solid var(--line); } +.faq summary { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 22px 0; cursor: pointer; list-style: none; } +.faq summary::-webkit-details-marker { display: none; } +.faq summary .h-3 { font-size: 18px; } +.faq summary .i { flex: none; color: var(--mute); transition: transform .2s var(--ease); } +.faq details[open] summary .i { transform: rotate(45deg); } +.faq summary:hover .i { color: var(--snow); } +.faq p { margin: -6px 0 24px; max-width: 720px; font-size: 16px; color: var(--fog); } + +/* ---------- changelog page */ +.nav .links a[aria-current="page"] { color: var(--snow); } +.cl { padding: clamp(120px, 14vw, 168px) 0 clamp(96px, 12vw, 144px); } +.cl-lead { margin: 16px 0 56px; font-size: 17px; color: var(--fog); } +.releases { border-top: 1px solid var(--line); } +.release { display: grid; grid-template-columns: 200px 1fr; gap: 32px; padding: 40px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 80px; } +.release .ver { display: flex; align-items: center; gap: 10px; margin: 0 0 6px; font: 500 22px/1.2 var(--f-sans); letter-spacing: -0.02em; color: var(--snow); } +.release .latest { height: 22px; padding: 0 8px; border-radius: 99px; background: rgb(13 153 255 / .16); color: var(--signal-2); font: 500 12px/22px var(--f-mono); letter-spacing: 0; } +.release time { font: 400 13px var(--f-mono); color: var(--mute); } +.release .summary { margin: 0 0 20px; font-size: 17px; color: var(--snow); } +.release h3 { margin: 24px 0 10px; font: 500 12px var(--f-mono); letter-spacing: .04em; text-transform: uppercase; color: var(--mute); } +.release ul { margin: 0; padding-left: 18px; color: var(--fog); font-size: 15.5px; line-height: 1.6; } +.release li + li { margin-top: 6px; } +.release li::marker { color: var(--line-2); } +.release code { padding: 1px 5px; border-radius: 5px; background: var(--deep); font: 400 .9em var(--f-mono); color: var(--snow); } +@media (max-width: 720px) { .release { grid-template-columns: 1fr; gap: 16px; padding: 32px 0; } } diff --git a/site/assets/social-github.png b/site/assets/social-github.png new file mode 100644 index 0000000..640cd19 Binary files /dev/null and b/site/assets/social-github.png differ diff --git a/site/assets/style.css b/site/assets/style.css deleted file mode 100644 index 216a4fc..0000000 --- a/site/assets/style.css +++ /dev/null @@ -1,120 +0,0 @@ -:root { - --ink: rgb(50 64 79); - --ink-80: rgb(50 64 79 / .8); - --ink-55: rgb(50 64 79 / .55); - --ink-40: rgb(50 64 79 / .42); - --line: rgb(50 64 79 / .1); - --tint: #f4f4f5; - --primary: #2f6bff; - --sans: Inter, "Helvetica Neue", Arial, sans-serif; - --body: 17px; /* paragraph text, everywhere */ - --side: 232px; /* where the main column starts */ -} -* { box-sizing: border-box; } -html { scroll-behavior: smooth; } -body { - margin: 0; background: #fff; color: var(--ink); - font: 400 15px/1.5 var(--sans); letter-spacing: -0.005em; - -webkit-font-smoothing: antialiased; -} -a { color: inherit; text-decoration: none; } -p { margin: 0; } -button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; } -kbd { font: 500 12px/1 var(--sans); padding: 2px 5px; border-radius: 4px; border: 1px solid var(--line); background: var(--tint); } - -/* Sidebar */ -.brand { position: fixed; top: 28px; left: 24px; z-index: 5; display: flex; align-items: center; gap: 10px; color: var(--ink); } -.brand::before { content: ""; width: 10px; height: 10px; background: var(--primary); transition: transform .2s; } -.brand:hover::before { transform: rotate(90deg) scale(1.15); } -.side-nav { position: fixed; top: 240px; left: 24px; z-index: 5; display: grid; gap: 12px; } -.side-nav a { position: relative; line-height: 15px; color: var(--ink-40); transition: color .15s; } -.side-nav a:hover { color: var(--ink); } -.side-nav a.active { color: var(--primary); } -.side-nav a.active::before { content: ""; position: absolute; left: -12px; top: 5px; width: 4px; height: 4px; background: var(--primary); } -.side-nav .gap { height: 12px; } -.side-nav .icon-link { display: flex; align-items: center; gap: 4px; } -.side-nav .icon-link svg { flex: none; } - -/* Main column */ -main { margin-left: var(--side); max-width: 816px; padding: 240px 0 0; } -h1 { margin: 0; font: 600 64px/72px var(--sans); letter-spacing: -0.045em; } -h2 { margin: 8px 0 0; font: 600 32px/40px var(--sans); letter-spacing: -0.035em; } -.label { color: var(--ink-55); } -.body { color: var(--ink-80); } -.body a, .link { color: var(--ink); background: linear-gradient(currentColor, currentColor) 0 100% / 100% 1px no-repeat; transition: color .15s; } -.body a:hover, .link:hover { color: var(--primary); } - -/* Hero */ -.lead, .intro, .release .summary, .release ul { font-size: var(--body); line-height: 1.6; } -/* Text blocks read best a little narrower than the column */ -.lead, .intro, h2 { max-width: 80%; } -.lead { margin-top: 24px; color: var(--ink-80); } -.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 32px; } -.btn { display: inline-flex; align-items: center; height: 42px; padding: 0 20px; border-radius: 999px; background: var(--ink); color: #fff; transition: background .15s; } -.btn:hover { background: var(--primary); } -.install { display: inline-flex; align-items: center; gap: 14px; height: 42px; padding: 0 6px 0 18px; border-radius: 999px; border: 1px solid var(--line); font: 14px/1 var(--sans); } -.install .dollar { color: var(--ink-40); } -.install button { height: 30px; padding: 0 12px; border-radius: 999px; background: var(--tint); font: 13px/1 var(--sans); color: var(--ink-55); transition: color .15s; } -.install button:hover { color: var(--primary); } -.hint { margin-top: 16px; color: var(--ink-55); } -/* Same color as the video's own background, so the two blend */ -.hero-video { margin-top: 56px; background: #fbfbfb; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } -.hero-video video { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; } - -/* Text sections */ -section { padding-top: 160px; scroll-margin-top: 80px; } -.intro { margin-top: 16px; } - -.tabs { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 32px; } -.tabs button { color: var(--ink-40); transition: color .15s; } -.tabs button:hover { color: var(--ink); } -.tabs button[aria-selected="true"] { color: var(--primary); } -.code { position: relative; margin-top: 14px; background: var(--tint); } -.code pre { margin: 0; padding: 22px 24px; overflow-x: auto; font: 14px/1.75 var(--sans); color: var(--ink); tab-size: 2; } -.code .c { color: var(--ink-40); } -.code .k { color: #b0306f; } -.code .s { color: #1d7d57; } -.code .copy { position: absolute; top: 16px; right: 18px; color: var(--ink-40); font-size: 13px; } -.code .copy:hover { color: var(--primary); } -[role="tabpanel"][hidden] { display: none; } - -footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 120px; padding: 24px; color: var(--ink-40); } -footer .status { display: flex; align-items: center; gap: 8px; padding-left: 12px; } -footer .status::before { content: ""; width: 4px; height: 4px; background: var(--primary); } -footer nav { display: flex; gap: 32px; } -footer a { transition: color .15s; } -footer a:hover { color: var(--primary); } - -@media (max-width: 1100px) { - :root { --side: 200px; } - main { margin-right: 24px; } -} -@media (max-width: 860px) { - .lead, .intro, h2 { max-width: none; } - .brand { position: absolute; } - .side-nav { display: none; } - main { margin: 0 20px; padding-top: 120px; } - h1 { font-size: 40px; line-height: 46px; } - h2 { font-size: 26px; line-height: 32px; } - .hero-video { margin-top: 40px; } - section { padding-top: 104px; } - footer { flex-direction: column; align-items: flex-start; padding: 24px 20px; } - footer .status { padding-left: 0; } - footer nav { gap: 20px; flex-wrap: wrap; } -} -@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } - -/* Changelog */ -.release { display: grid; grid-template-columns: 160px 1fr; gap: 32px; padding: 48px 0; border-top: 1px solid var(--line); } -.releases { margin-top: 64px; } -.release .ver { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--ink); } -.release .latest { font-size: 12px; font-weight: 500; line-height: 20px; padding: 0 8px; border-radius: 999px; color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); } -.release time { display: block; margin-top: 2px; color: var(--ink-55); } -.release .summary { color: var(--ink); font-weight: 500; } -.release h3 { margin: 24px 0 8px; font: 500 13px/1 var(--sans); letter-spacing: .02em; text-transform: uppercase; color: var(--ink-55); } -.release ul { margin: 0; padding-left: 18px; color: var(--ink-80); } -.release li + li { margin-top: 6px; } -.release code { font: 13px/1 var(--sans); padding: 2px 6px; border-radius: 4px; background: var(--tint); color: var(--ink); } -@media (max-width: 860px) { - .release { grid-template-columns: 1fr; gap: 12px; padding: 36px 0; } -} diff --git a/site/changelog.html b/site/changelog.html index 0b731d0..f087a19 100644 --- a/site/changelog.html +++ b/site/changelog.html @@ -4,7 +4,7 @@ PixelSpec changelog: release notes and new features - + @@ -15,31 +15,41 @@ - + - - + + + - - PixelSpec + - - -
-

Changelog

-

New features, changes and fixes in each PixelSpec release.

+ -
+
+
+
+

Changelog

+

New features, changes and fixes in each PixelSpec release.

+

0.2.1 Latest

@@ -97,17 +107,24 @@

Added

-
+
+ +
- + - + diff --git a/site/index.html b/site/index.html index 0524352..51be9aa 100644 --- a/site/index.html +++ b/site/index.html @@ -3,172 +3,473 @@ - PixelSpec: design inspector and visual feedback for AI coding agents - - + PixelSpec: Visual feedback and CSS inspector for AI coding agents + + + - - - + + + - - - - + + + + - + + - - + + + + - - PixelSpec + + + + +
- + +
+
+ v0.3Open source, MIT +

Pixel-perfect UI feedback for AI agents.

+ +

PixelSpec is a free, open-source CSS inspector for your live website. Measure spacing, typography and color, pin visual feedback to any element, and copy a precise spec your AI coding agent can act on.

+
+ Install PixelSpec + +
+
-
-

Click any element.
Tell your AI what to fix.

+
+
+
localhost:3000/analytics
720 × 400
+ + +
+
+
-

PixelSpec is a free, open-source design inspector for any live website. Measure padding, margins, gaps and distances, check type, color and contrast, then click any element to send precise feedback to Claude Code, Cursor or your AI agent. A sharper alternative to Agentation.

+ +
+
+
0

Runtime dependencies

+
1

Line to install

+
28kb

Gzipped, dev builds only

+
MIT

Open source, forever

+
+
-
- -
$ npm i -D pixelspec
+ +
+
+

Design QA on the real page. PixelSpec measures what actually shipped, lets you annotate it where it lives, and turns every note into a precise instruction your AI coding agent can act on.

+
+
+ +

Exact, not approximate

+

Every number comes from the rendered page. Sizes, spacing, line heights and contrast, measured to the pixel.

+
+
+ +

Made for your agent

+

Notes carry the selector, the component and the styles that matter. Paste once and your agent knows what to change.

+
+
+ +

Invisible to your app

+

It lives in its own shadow root and only loads in development. Your styles, bundle and users never see it.

+
+
-

Or press Alt Shift I anywhere on this page.

+
+ + +
+
+
+

Measure spacing, type and color on any website

+
+

A CSS inspector built for UI review. Hover for names and sizes, click for the full spec, and hover a second element to measure the distance between them. Every value is one click from your clipboard.

+
+
+ +
+
+

Spacing inspector

See padding, margin and the gap between children, drawn right on the element.

+ +
+
+

Distance measurement

Measure the space between any two elements in pixels. Hold Alt to measure to the parent.

+ +
+
+

Typography inspector

The font that actually rendered, with font size, weight, line height and letter spacing.

+ +
+
+

Color contrast checker

Text and background colors with the WCAG contrast ratio, checked against what's really behind them.

+ +
+
+

Component detection

React, Vue and Svelte components named automatically, with source files in dev builds.

+ +
+
+

Pinned visual feedback

Click anything and type. A numbered marker keeps the note on its element, even after a reload.

+ +
+
-
- +
    +
  • Crosshair guides
  • +
  • Pause animations
  • +
  • Quote selected text
  • +
  • No style leaks
  • +
  • Undo for deletes
  • +
  • Keyboard first
  • +
+
-
-

Design feedback your AI agent can act on.

-

Designers now build straight in code with Claude Code, Cursor or v0, but the browser doesn't show design specs the way design tools do. Feedback turns vague ("make it a bit more spacious") and the agent guesses. PixelSpec shows the exact spacing, type and color values on the real page and attaches them, with the element's selector, to every note. Design QA without the back-and-forth.

-
- -
-

Install

-

Install PixelSpec in your dev build.

-

Works with React, Next.js, Vite, Vue, Svelte and plain HTML. No dependencies, and it only loads in development.

-
- - - + +
+
+
+

Visual feedback your
AI coding agent can act on

+
+

Every note ships with its CSS selector, component, size and the styles that matter. Your agent knows which element you mean, what it looks like now, and what you want instead. No screenshots, no guessing.

+ Set it up in a minute +
-
- -
// npm i -D pixelspec
-import { PixelSpec } from 'pixelspec/react';
 
-export default function App() {
+      
+
+
+ Clipboard +
+ + +
+
+
UI feedback for /pricing (1440×900):
+1. Make this button bigger
+   main>section.hero>button.cta "Start free trial"
+   126×34 p:10 18 | Inter 14/14 w600 | contrast:3.7 fails AA
+2. Cards should line up with the nav
+   section.plans>div.grid <PlanGrid> src/plans.tsx
+   1104×520 p:0 24 | grid gap:24 cols:3
+3. Too tight. Use the 8px grid
+   div.card>ul.features "Unlimited projects"
+   320×96 m:0 0 12 | col gap:6 | Inter 15/22 w400
+ +
3 notes · compactPress C to copy · Shift for full
+
+ +
    +
  1. 01

    Mark it up

    Click an element and type what should change. Select text to quote it.

  2. +
  3. 02

    Copy once

    Press C to copy every note. Compact by default, so it costs few tokens.

  4. +
  5. 03

    Paste into your agent

    Any coding agent reads it. It gets the element, the numbers and your intent.

  6. +
+
+
+
+ + +
+
+
+

How it works:
five moves, no setup

+

Open PixelSpec from the button in the corner or with Alt + Shift + I. Then review your UI on the real page with the same five moves every time.

+
+
    +
  1. 01

    Hover

    Name and size of anything under the cursor.

  2. +
  3. 02

    Click

    The full spec. Click any value to copy it.

    ↑↓
  4. +
  5. 03

    Measure

    Hover a second element for the distance.

    Alt
  6. +
  7. 04

    Comment

    Type a note, press Enter. A marker pins it.

    Enter
  8. +
  9. 05

    Copy

    Every note, ready for your agent.

    C
  10. +
+
+
+ + +
+
+
+

Install PixelSpec
in under a minute

+

Works with React, Next.js, Vite, Vue, Svelte and plain HTML. PixelSpec loads only in development, has zero dependencies and never touches your styles.

+
+
+
+
+ + + +
+
+
Terminal
+
npm install -D pixelspec
+
App.tsx
+
import { PixelSpec } from 'pixelspec/react';
+
+export default function App() {
   return (
     <>
-      <YourApp />
-      {process.env.NODE_ENV === 'development' && <PixelSpec />}
+      <YourApp />
+      {process.env.NODE_ENV === 'development' && <PixelSpec />}
     </>
   );
-}
+}
+
+ + +
+ +
+

Keyboard

+
    +
  • Toggle PixelSpecAltShiftI
  • +
  • Spacing overlayS
  • +
  • GuidesG
  • +
  • Pause animationsP
  • +
  • Hide markersH
  • +
  • Copy feedbackC
  • +
  • Clear all, with undoX
  • +
+
- +
+ + +
+
+
+

Frequently asked
questions

+

Everything you need to know about inspecting, measuring and annotating your UI with PixelSpec.

-
+
+
-
+ +
+ +
+

Stop eyeballing it.

+

Add PixelSpec to your dev build and ship every pixel the way you meant it.

+
+ + Star on GitHub +
+
+
+ - - - - - + + +
+ + + + + + + diff --git a/site/sitemap.xml b/site/sitemap.xml index 5889600..f84a8f2 100644 --- a/site/sitemap.xml +++ b/site/sitemap.xml @@ -1,5 +1,5 @@ - https://www.pixelspec.dev/2026-10-20 - https://www.pixelspec.dev/changelog2026-10-20 + https://www.pixelspec.dev/2026-11-10 + https://www.pixelspec.dev/changelog2026-11-10 diff --git a/src/brand.gen.ts b/src/brand.gen.ts new file mode 100644 index 0000000..9f9daa0 --- /dev/null +++ b/src/brand.gen.ts @@ -0,0 +1,54 @@ +// Generated by scripts/brand-export.mjs from site/assets/ps.css and site/assets/brand.js. +// Do not edit by hand: change the brand files and run `npm run brand -- --product`. + +export const T = { + void: '#08090A', + night: '#0F1011', + deep: '#16171A', + line: '#1F2023', + 'line-2': '#2B2D31', + fog: '#A1A6AE', + mute: '#7D828B', + snow: '#F7F8F8', + ink: '#0A0A0A', + signal: '#0D99FF', + 'signal-2': '#6CC2FF', + redline: '#FF3B30', + pad: '#86EFAC', + margin: '#F6B26B', + gap: '#E83E8C', + marker: '#FFE14D', +} as const; + +/** The mark: one solid path on a 7×7 grid (viewBox 0 0 7 7). Fill it with one color. */ +export const MARK = { + d: 'M0.48 0L1 0L1 1L0 1L0 0.48C0 0.215 0.215 0 0.48 0ZM1 0L2 0L2 1L1 1L1 0ZM2 0L3 0L3 1L2 1L2 0ZM3 0L4 0L4 1L3 1L3 0ZM4 0L4.52 0C4.785 0 5 0.215 5 0.48L5 1L4 1L4 0ZM5 1L5 0.66C5 0.848 5.152 1 5.34 1ZM0 1L1 1L1 2L0 2L0 1ZM1 1L2 1L2 2L1 2L1 1ZM2 1L3 1L3 2L2 2L2 1ZM3 1L4 1L4 2L3 2L3 1ZM4 1L5 1L5 2L4 2L4 1ZM5 1L5.52 1C5.785 1 6 1.215 6 1.48L6 2L5 2L5 1ZM6 2L6 1.66C6 1.848 6.152 2 6.34 2ZM0 2L1 2L1 3L0 3L0 2ZM1 2L2 2L2 3L1 3L1 2ZM2 2L2.34 2C2.152 2 2 2.152 2 2.34ZM5 2L5 2.34C5 2.152 4.848 2 4.66 2ZM5 2L6 2L6 3L5 3L5 2ZM6 2L6.52 2C6.785 2 7 2.215 7 2.48L7 3L6 3L6 2ZM0 3L1 3L1 4L0 4L0 3ZM1 3L2 3L2 4L1 4L1 3ZM5 3L6 3L6 4L5 4L5 3ZM6 3L7 3L7 4L6 4L6 3ZM0 4L1 4L1 5L0 5L0 4ZM1 4L2 4L2 5L1 5L1 4ZM5 5L4.66 5C4.848 5 5 4.848 5 4.66ZM5 4L6 4L6 5L5 5L5 4ZM6 4L7 4L7 5L6 5L6 4ZM0 5L1 5L1 6L0 6L0 5ZM1 5L2 5L2 6L1 6L1 5ZM3 6L2.66 6C2.848 6 3 5.848 3 5.66ZM2 6L2 5.66C2 5.848 2.152 6 2.34 6ZM3.48 5L4 5L4 6L3 6L3 5.48C3 5.215 3.215 5 3.48 5ZM4 5L5 5L5 6L4 6L4 5ZM5 5L6 5L6 6L5 6L5 5ZM6 5L7 5L7 6L6 6L6 5ZM0 6L1 6L1 7L0.48 7C0.215 7 0 6.785 0 6.52L0 6ZM1 6L2 6L2 7L1 7L1 6ZM2 6L3 6L3 7L2 7L2 6ZM3 6L4 6L4 7L3 7L3 6ZM4 6L5 6L5 7L4 7L4 6ZM5 6L6 6L6 7L5 7L5 6ZM6 6L7 6L7 6.52C7 6.785 6.785 7 6.52 7L6 7L6 6Z', + size: 7, +} as const; + +/** PixelSpec icons used by the overlay: inner SVG markup on an 18-unit grid (viewBox 0 0 18 18), 1.5 stroke in currentColor. Source: scripts/icons.mjs. */ +export const ICONS = { + spacing: '', + guides: '', + pause: '', + play: '', + eye: '', + 'eye-off': '', + copy: '', + check: '', + trash: '', + settings: '', + close: '', + 'arrow-up': '', + 'arrow-down': '', + 'chevron-right': '', + code: '', + ruler: '', + component: '', + padding: '', + margin: '', + 'gap-h': '', + 'gap-v': '', +} as const; + +export type IconName = keyof typeof ICONS; diff --git a/src/core.ts b/src/core.ts index 9733c4b..6d16123 100644 --- a/src/core.ts +++ b/src/core.ts @@ -16,7 +16,7 @@ export interface PixelSpecOptions { startActive?: boolean; /** Show the floating toolbar. Default `true`. */ showToolbar?: boolean; - /** Initial toolbar corner (the user can drag it anywhere). Default `bottom-right`. */ + /** Toolbar corner. The toolbar stays pinned there. Default `bottom-right`. */ position?: 'bottom-right' | 'bottom-left'; /** Initial "Copy for AI" detail. Users can change it in settings; Shift while copying uses the other one. */ aiDetail?: AIDetail; @@ -43,12 +43,11 @@ interface Settings { guides: boolean; markers: boolean; clearOnCopy: boolean; - dock: { x: number; y: number } | null; tourDone: boolean; measured: boolean; } -const VERSION = '0.2.1'; +const VERSION = '0.3.0'; const INTERACTIVE = 'a,button,input,textarea,select,option,label,summary,[contenteditable=""],[contenteditable="true"],[draggable="true"],[role="button"],[role="link"]'; const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); @@ -76,6 +75,15 @@ function hotkeyLabel(spec: string): string { .join(''); } +/** The hotkey as separate keycaps, e.g. AltShiftI. */ +function hotkeyKeys(spec: string): string { + const map: Record = isMac + ? { alt: '⌥', option: '⌥', shift: '⇧', ctrl: '⌃', control: '⌃', meta: '⌘', cmd: '⌘', mod: '⌘' } + : { alt: 'Alt', option: 'Alt', shift: 'Shift', ctrl: 'Ctrl', control: 'Ctrl', meta: 'Win', cmd: 'Win', mod: 'Ctrl' }; + const parts = spec.toLowerCase().split('+').map((p) => p.trim()); + return `${parts.map((p, i) => `${esc(i === parts.length - 1 ? p.toUpperCase() : map[p] ?? p)}`).join('')}`; +} + function isEditable(el: Element | null): boolean { return !!el && ((el as HTMLElement).isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)); } @@ -118,8 +126,6 @@ export class PixelSpec { private pausedVideos: HTMLMediaElement[] = []; private closed = new Set(['attrs']); private moreOpen = false; - private dockDrag: { sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null; - private suppressClick = false; private toastTimer = 0; private popPos = ''; /** Where the popover is anchored, relative to the selected element's top-left (follows it on scroll). */ @@ -138,12 +144,13 @@ export class PixelSpec { guides: false, markers: true, clearOnCopy: false, - dock: null, tourDone: false, measured: false, ...this.load>('settings', {}), }; delete (this.s as Partial & { color?: string }).color; + // Older versions let you drag the toolbar and saved its position. It's pinned now. + delete (this.s as Partial & { dock?: unknown }).dock; this.host = document.createElement('pixelspec-overlay'); this.host.setAttribute('data-pixelspec', ''); @@ -181,7 +188,6 @@ export class PixelSpec { this.root.addEventListener('keydown', this.onUiKey as EventListener); this.root.addEventListener('input', this.onUiInput); this.root.addEventListener('toggle', this.onToggle, true); - this.tb.addEventListener('pointerdown', this.onDockDown); this.host.addEventListener('keydown', (e) => e.stopPropagation()); // Smooth-scroll libraries (Lenis, Locomotive, ScrollSmoother) cancel wheel and touch events page-wide, // which stops the popover from scrolling. Keep those events inside our UI so the browser scrolls it natively. @@ -189,7 +195,6 @@ export class PixelSpec { for (const t of ['wheel', 'touchmove']) this.root.addEventListener(t, (e) => e.stopPropagation(), { passive: true }); window.addEventListener('pointermove', this.onMove, true); - window.addEventListener('pointerup', this.onDockUp, true); for (const t of ['pointerdown', 'mousedown']) window.addEventListener(t, this.onDown, true); for (const t of ['pointerup', 'mouseup', 'dblclick', 'auxclick']) window.addEventListener(t, this.onSwallow, true); window.addEventListener('click', this.onClick, true); @@ -307,7 +312,6 @@ export class PixelSpec { this.deactivate(); cancelAnimationFrame(this.raf); window.removeEventListener('pointermove', this.onMove, true); - window.removeEventListener('pointerup', this.onDockUp, true); for (const t of ['pointerdown', 'mousedown']) window.removeEventListener(t, this.onDown, true); for (const t of ['pointerup', 'mouseup', 'dblclick', 'auxclick']) window.removeEventListener(t, this.onSwallow, true); window.removeEventListener('click', this.onClick, true); @@ -517,7 +521,7 @@ export class PixelSpec { const id = `${esc(tag)}${el.id ? `#${esc(el.id)}` : ''}${cls.length ? `.${esc(cls.join('.'))}` : ''}`; this.pop.innerHTML = `
-
${comp ? `${esc(comp.names[0])}` : ''}
+
${comp ? `${I.component}${esc(comp.names[0])}` : ''}
${ib('copy-css', I.codeMd, 'Copy CSS')} @@ -539,7 +543,7 @@ export class PixelSpec {
- ${this.s.measured ? '' : `
${I.ruler}Hover another element to measure · Alt for parent
`}`; + ${this.s.measured ? '' : `
${I.ruler}Tip: hover another element to measure, Alt for parent
`}`; const wasHidden = this.pop.hidden; this.pop.hidden = false; const body = this.pop.querySelector('.pbody'); @@ -704,7 +708,7 @@ export class PixelSpec { const sw = (key: keyof Settings, label: string, kbd = '') => ``; this.menu.innerHTML = ` -
PixelSpec v${VERSION}
+
${I.markSm}PixelSpecv${VERSION}
Copy detail
${s.detail === 'compact' ? 'Two lines per element. Saves tokens.' : 'Markdown with layout, position and shadows.'} Hold Shift when copying for the other.
@@ -714,18 +718,18 @@ export class PixelSpec { ${sw('hoverBox', 'Box model on hover')} ${sw('guides', 'Guides', 'G')} ${sw('clearOnCopy', 'Clear after copy')} -
Toggle ${esc(hotkeyLabel(this.opts.hotkey))}
`; +
Toggle ${hotkeyKeys(this.opts.hotkey)}
`; } private renderHint(): void { const show = this.active && !this.s.tourDone && !this.selected; this.hint.hidden = !show; if (!show) return; - this.hint.innerHTML = `Hover to inspectClick for specs and feedbackSelect text to quote itEsc to exit`; + this.hint.innerHTML = `${I.markSm}Hover to inspectClick for specs and feedbackSelect text to quote itEsc to exit`; } private toast(msg: string, action?: { label: string; fn: () => void }): void { - this.toastEl.innerHTML = `${esc(msg)}${action ? `` : ''}`; + this.toastEl.innerHTML = `${I.markSm}${esc(msg)}${action ? `` : ''}`; this.toastAction = action?.fn ?? null; this.toastEl.classList.toggle('act', !!action); this.toastEl.classList.add('show'); @@ -737,28 +741,17 @@ export class PixelSpec { } private toastAction: (() => void) | null = null; + /** The toolbar is pinned to its corner and can't be moved. */ private placeDock = () => { - const d = this.s.dock; + const left = this.opts.position === 'bottom-left'; const st = this.dock.style; - const vw = document.documentElement.clientWidth; - if (d) { - const r = this.dock.getBoundingClientRect(); - const x = clamp(d.x, 8, Math.max(8, vw - (this.tb.offsetWidth || 46) - 8)); - const y = clamp(d.y, 8, Math.max(8, innerHeight - (this.tb.offsetHeight || 46) - 8)); - st.left = `${x}px`; - st.top = 'auto'; - st.right = 'auto'; - st.bottom = `${innerHeight - y - (this.tb.offsetHeight || 46)}px`; - this.dock.classList.toggle('top', y < innerHeight / 2); - this.dock.style.alignItems = x + (r.width || 46) / 2 < vw / 2 ? 'flex-start' : 'flex-end'; - } else { - st.left = this.opts.position === 'bottom-left' ? '16px' : 'auto'; - st.right = this.opts.position === 'bottom-left' ? 'auto' : '16px'; - st.top = 'auto'; - st.bottom = '16px'; - this.dock.style.alignItems = this.opts.position === 'bottom-left' ? 'flex-start' : 'flex-end'; - this.dock.classList.remove('top'); - } + st.left = left ? '16px' : 'auto'; + st.right = left ? 'auto' : '16px'; + st.top = 'auto'; + st.bottom = '16px'; + st.alignItems = left ? 'flex-start' : 'flex-end'; + this.dock.classList.toggle('left', left); + this.dock.classList.remove('top'); }; private async copy(text: string, kind: CopyKind, msg = 'Copied'): Promise { @@ -861,7 +854,7 @@ export class PixelSpec { return t instanceof Element ? t : null; } - /** Empty page background (html/body) isn't highlighted on hover, like in design tools. It stays reachable via the parent arrow. */ + /** Empty page background (html/body) isn't highlighted on hover. It stays reachable via the parent arrow. */ private hoverable(el: Element | null): Element | null { return el && el !== document.documentElement && el !== document.body ? el : null; } @@ -869,17 +862,6 @@ export class PixelSpec { private onMove = (e: PointerEvent) => { this.mouse.x = e.clientX; this.mouse.y = e.clientY; - const dd = this.dockDrag; - if (dd) { - const dx = e.clientX - dd.sx; - const dy = e.clientY - dd.sy; - if (!dd.moved && Math.hypot(dx, dy) < 4) return; - dd.moved = true; - this.tb.classList.add('dragging'); - this.s.dock = { x: dd.ox + dx, y: dd.oy + dy }; - this.placeDock(); - return; - } if (!this.active || this.isOurs(e)) return; this.hovered = this.hoverable(this.targetOf(e)); }; @@ -1062,25 +1044,7 @@ export class PixelSpec { else this.closed.add(id); }; - private onDockDown = (e: PointerEvent) => { - if (e.button !== 0) return; - const r = this.tb.getBoundingClientRect(); - this.dockDrag = { sx: e.clientX, sy: e.clientY, ox: r.left, oy: r.top, moved: false }; - }; - - private onDockUp = () => { - const dd = this.dockDrag; - this.dockDrag = null; - if (dd?.moved) { - this.suppressClick = true; - this.tb.classList.remove('dragging'); - this.save(); - setTimeout(() => (this.suppressClick = false), 0); - } - }; - private onUiClick = (e: Event) => { - if (this.suppressClick) return; const t = e.target as Element; const mk = t.closest('.mk'); if (mk) { diff --git a/src/icons.ts b/src/icons.ts index 5559cc5..01e77f0 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,54 +1,42 @@ -// Icons from Hugeicons (https://hugeicons.com), free set, MIT licensed. -import { - AlignHorizontalSpaceAroundIcon, AlignHorizontalSpaceBetweenIcon, AlignVerticalSpaceBetweenIcon, FullScreenIcon, - ArrowDown02Icon, ArrowRight01Icon, ArrowUp02Icon, Cancel01Icon, CenterFocusIcon, Copy01Icon, CrosshairIcon, Delete02Icon, - PauseIcon, RulerIcon, PlayIcon, RulerDimensionLineIcon, SlidersHorizontalIcon, SourceCodeIcon, Tick02Icon, - ViewIcon, ViewOffSlashIcon, -} from '@hugeicons/core-free-icons'; +// PixelSpec icons (18-unit grid, 1.5 outline) and the mark, both from the brand (src/brand.gen.ts, +// generated from scripts/icons.mjs and site/assets/brand.js). +import { ICONS, MARK, type IconName } from './brand.gen'; -type IconData = readonly (readonly [string, Readonly>])[]; - -const kebab = (k: string) => k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); - -/** Render Hugeicons shape data as an inline SVG string. */ -function svg(icon: IconData, size = 18): string { - const body = icon - .map(([tag, attrs]) => { - const a = Object.entries(attrs) - .filter(([k]) => k !== 'key') - .map(([k, v]) => `${kebab(k)}="${String(v)}"`) - .join(' '); - return `<${tag} ${a}/>`; - }) - .join(''); - return ``; +/** Render a brand icon as an inline SVG string. Strokes and accents use currentColor. */ +function icon(name: IconName, size = 18): string { + return ``; } -const h = (icon: unknown, size?: number) => svg(icon as IconData, size); +/** The PixelSpec mark: one solid shape in the brand blue (.mark in styles.ts). */ +export function mark(size: number): string { + return ``; +} export const I = { - logo: h(CenterFocusIcon, 22), - spacing: h(RulerDimensionLineIcon), - guides: h(CrosshairIcon), - pause: h(PauseIcon), - play: h(PlayIcon), - eye: h(ViewIcon), - eyeOff: h(ViewOffSlashIcon), - copy: h(Copy01Icon), - check: h(Tick02Icon), - trash: h(Delete02Icon), - settings: h(SlidersHorizontalIcon), - x: h(Cancel01Icon), - up: h(ArrowUp02Icon, 16), - send: h(ArrowUp02Icon, 16), - down: h(ArrowDown02Icon, 16), - close: h(Cancel01Icon, 16), - chevron: h(ArrowRight01Icon, 14), - codeMd: h(SourceCodeIcon, 16), - trashSm: h(Delete02Icon, 14), - ruler: h(RulerIcon, 14), - padding: h(AlignHorizontalSpaceAroundIcon, 14), - margin: h(FullScreenIcon, 14), - gapH: h(AlignHorizontalSpaceBetweenIcon, 14), - gapV: h(AlignVerticalSpaceBetweenIcon, 14), + logo: mark(24), + markSm: mark(16), + spacing: icon('spacing'), + guides: icon('guides'), + pause: icon('pause'), + play: icon('play'), + eye: icon('eye'), + eyeOff: icon('eye-off'), + copy: icon('copy'), + check: icon('check'), + trash: icon('trash'), + settings: icon('settings'), + x: icon('close'), + up: icon('arrow-up', 16), + send: icon('arrow-up', 16), + down: icon('arrow-down', 16), + close: icon('close', 16), + chevron: icon('chevron-right', 14), + codeMd: icon('code', 16), + trashSm: icon('trash', 14), + ruler: icon('ruler', 14), + component: icon('component', 12), + padding: icon('padding', 14), + margin: icon('margin', 14), + gapH: icon('gap-h', 14), + gapV: icon('gap-v', 14), }; diff --git a/src/overlay.ts b/src/overlay.ts index f8e2188..6773d55 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -60,10 +60,10 @@ function sideLabels(outer: Rect, inner: Rect, v: number[], cls: string): string let o = ''; const lab = (val: number, x: number, y: number, room: number, need: number) => val && room >= need ? `
${fmt(val)}
` : ''; - o += lab(v[0], inner.left + inner.width / 2, outer.top + (inner.top - outer.top) / 2, inner.top - outer.top, 12); - o += lab(v[2], inner.left + inner.width / 2, inner.bottom + (outer.bottom - inner.bottom) / 2, outer.bottom - inner.bottom, 12); - o += lab(v[3], outer.left + (inner.left - outer.left) / 2, inner.top + inner.height / 2, inner.left - outer.left, 18); - o += lab(v[1], inner.right + (outer.right - inner.right) / 2, inner.top + inner.height / 2, outer.right - inner.right, 18); + o += lab(v[0], inner.left + inner.width / 2, outer.top + (inner.top - outer.top) / 2, inner.top - outer.top, 14); + o += lab(v[2], inner.left + inner.width / 2, inner.bottom + (outer.bottom - inner.bottom) / 2, outer.bottom - inner.bottom, 14); + o += lab(v[3], outer.left + (inner.left - outer.left) / 2, inner.top + inner.height / 2, inner.left - outer.left, 20); + o += lab(v[1], inner.right + (outer.right - inner.right) / 2, inner.top + inner.height / 2, outer.right - inner.right, 20); return o; } @@ -81,10 +81,14 @@ export function drawBoxModel(bx: Boxes, labels: boolean): string { } export function drawOutline(r: Rect, cls: string): string { - return div(`ol ${cls}`, r.left, r.top, r.width, r.height); + let o = div(`ol ${cls}`, r.left, r.top, r.width, r.height); + // Square corner handles on the selection, left off small elements so they don't cover them. + if (cls === 'ol-sel' && r.width >= 24 && r.height >= 24) { + for (const [x, y] of [[r.left, r.top], [r.right, r.top], [r.left, r.bottom], [r.right, r.bottom]]) o += div('hd', x - 3.5, y - 3.5, 7, 7); + } + return o; } -/** Tag name above, dimensions below. */ /** * One quiet label per element. * 'name': hovered element, shows "tag.class W × H" above it. @@ -121,7 +125,7 @@ const hDash = (x1: number, x2: number, y: number) => div('dd-h', Math.min(x1, x2 const vDash = (y1: number, y2: number, x: number) => div('dd-v', x - 0.5, Math.min(y1, y2), 1, Math.abs(y2 - y1)); /** - * Design-tool-style distance measurement from `a` (anchor) to `b` (target). + * Distance measurement from `a` (anchor) to `b` (target). * Handles separated, intersecting and nested rects. */ export function drawMeasure(a: Rect, b: Rect): string { @@ -213,9 +217,3 @@ export function drawCrosshair(x: number, y: number): string { pillL('coord', x + 10, y + 18, `${Math.round(x)}, ${Math.round(y)} · page ${Math.round(x + scrollX)}, ${Math.round(y + scrollY)}`) ); } - -export function drawMarker(r: Rect, n: number): string { - const x = clamp(r.left, 2, vw() - 22); - const y = clamp(r.top, 2, vh() - 22); - return `
${n}
`; -} diff --git a/src/styles.ts b/src/styles.ts index 3b58f7e..a235cfd 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,273 +1,298 @@ +import { T } from './brand.gen'; + +// Every color is a --ps-* token defined on .ui, so host custom properties can't leak in. +const tokens = Object.entries(T) + .map(([k, v]) => `--ps-${k}: ${v};`) + .join(' '); + export const STYLES = /* css */ ` :host { all: initial; } * { box-sizing: border-box; } .ui { - --bg: #18181b; --bg2: #232327; --line: rgba(255,255,255,.09); --fg: #f4f4f5; --fg2: #a1a1aa; --fg3: #71717a; - --red: #FF3B30; --accent: #0d99ff; --accent-soft: rgba(13,153,255,.16); --accent-fg: #6cc2ff; --mk: #0d99ff; - --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; - --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; - font: 500 13px/1.4 var(--sans); color: var(--fg); -webkit-font-smoothing: antialiased; + ${tokens} + --ps-r-xs: 4px; --ps-r-sm: 6px; --ps-r: 8px; --ps-r-md: 10px; --ps-r-lg: 14px; + --ps-shadow: 0 12px 32px rgb(0 0 0 / .35), 0 2px 6px rgb(0 0 0 / .2); + --ps-ease: cubic-bezier(.2, 0, 0, 1); --ps-fast: 120ms; --ps-base: 200ms; + --ps-sans: Geist, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --ps-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font: 500 13px/1.4 var(--ps-sans); color: var(--ps-snow); -webkit-font-smoothing: antialiased; } -button { all: unset; box-sizing: border-box; cursor: pointer; font-family: var(--sans); } -button:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -kbd { display: inline-block; min-width: 18px; padding: 1px 5px; border-radius: 5px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.08); - font: 600 10.5px/1.4 var(--mono); color: var(--fg); text-align: center; } +.ui ::selection { background: var(--ps-signal); color: var(--ps-ink); } +button { all: unset; box-sizing: border-box; cursor: pointer; font-family: var(--ps-sans); } +button:focus-visible, textarea:focus-visible { outline: 2px solid var(--ps-signal); outline-offset: 2px; } +/* keycaps: soft gradient, hairline edge, darker bottom lip */ +kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 5px; border-radius: 5px; vertical-align: middle; + font: 500 11px/1 var(--ps-sans); color: #E6E8EB; white-space: nowrap; background: linear-gradient(180deg, #2A2C30 0%, #1C1D20 100%); + box-shadow: inset 0 0 0 1px rgb(255 255 255 / .09), inset 0 1px 0 rgb(255 255 255 / .06), inset 0 -1.5px 0 rgb(0 0 0 / .55), 0 1px 2px rgb(0 0 0 / .45); } +.keys { display: inline-flex; gap: 3px; vertical-align: middle; } +.mf .keys { margin-left: 6px; } + +/* the mark (icons.ts mark()) */ +.mark { display: block; flex: none; } +.mark path { fill: var(--ps-signal); } /* ---------- tooltips ---------- */ [data-tip] { position: relative; } [data-tip]::after { content: attr(data-tip) " " attr(data-kbd); position: absolute; left: 50%; bottom: calc(100% + 10px); transform: translate(-50%, 4px); - padding: 5px 8px; border-radius: 7px; background: #09090b; color: #fff; font: 500 11.5px/1.2 var(--sans); white-space: pre; pointer-events: none; - opacity: 0; transition: opacity .12s, transform .12s; box-shadow: 0 4px 14px rgba(0,0,0,.3); z-index: 5; + padding: 6px 9px; border-radius: var(--ps-r-sm); background: var(--ps-void); border: 1px solid var(--ps-line-2); color: var(--ps-snow); + font: 500 12px/1.2 var(--ps-sans); white-space: pre; pointer-events: none; + opacity: 0; transition: opacity var(--ps-fast) var(--ps-ease), transform var(--ps-fast) var(--ps-ease); box-shadow: var(--ps-shadow); z-index: 5; } [data-tip]:hover::after { opacity: 1; transform: translate(-50%, 0); transition-delay: .3s; } .dock.top [data-tip]::after { bottom: auto; top: calc(100% + 10px); transform: translate(-50%, -4px); } .dock.top [data-tip]:hover::after { transform: translate(-50%, 0); } +/* Tooltips on the outer buttons line up with the dock's screen edge instead of running off it. */ +.dock .fab[data-tip]::after, .dock .tb > :last-child[data-tip]::after { left: auto; right: -4px; transform: translate(0, 4px); } +.dock.left .fab[data-tip]::after, .dock.left .tb > :first-child[data-tip]::after { left: -4px; right: auto; } +.dock.left .tb > :last-child[data-tip]::after { left: 50%; right: auto; transform: translate(-50%, 4px); } +.dock .fab[data-tip]:hover::after, .dock .tb > :last-child[data-tip]:hover::after, .dock.left .tb > :first-child[data-tip]:hover::after { transform: translate(0, 0); } +.dock.left .tb > :last-child[data-tip]:hover::after { transform: translate(-50%, 0); } +.dock.left .tb > :first-child[data-tip]::after { transform: translate(0, 4px); } /* ---------- overlay layer ---------- */ -.layer { position: fixed; inset: 0; pointer-events: none; overflow: hidden; font: 600 10.5px/1.2 var(--sans); font-variant-numeric: tabular-nums; } +.layer { position: fixed; inset: 0; pointer-events: none; overflow: hidden; font: 500 11px/1 var(--ps-mono); font-variant-numeric: tabular-nums; } .layer > div { position: absolute; } -.bx-m { background: rgba(246, 178, 107, .22); } -.bx-b { background: rgba(255, 229, 153, .35); } -.bx-p { background: rgba(147, 196, 125, .28); } +.bx-m { background: color-mix(in srgb, var(--ps-margin) 24%, transparent); } +.bx-b { background: color-mix(in srgb, var(--ps-marker) 30%, transparent); } +.bx-p { background: color-mix(in srgb, var(--ps-pad) 30%, transparent); } .bx-c { background: transparent; } -.ol-sel { border: 1.5px solid var(--accent); } -.ol-hov { border: 1px solid var(--accent); background: rgba(13,153,255,.05); } +.ol-sel { border: 1.5px solid var(--ps-signal); border-radius: 1px; } +.ol-hov { border: 1px solid var(--ps-signal); background: color-mix(in srgb, var(--ps-signal) 5%, transparent); } .ol-hov.big { background: none; } -.ol-par { border: 1px dashed var(--red); } -.sv { transform: translate(-50%, -50%); font-size: 9.5px; font-weight: 700; } -.sv-p { color: #2c6e1a; } -.sv-m { color: #9a5212; } -.pill { transform: translate(-50%, -50%); padding: 3px 6px; border-radius: 5px; color: #fff; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,.2); } +.ol-par { border: 1px dashed var(--ps-redline); } +.hd { width: 7px; height: 7px; border-radius: 2px; background: #fff; border: 1px solid var(--ps-signal); } +.sv { transform: translate(-50%, -50%); padding: 2px 4px; border-radius: var(--ps-r-xs); font: 600 9.5px/1 var(--ps-mono); color: var(--ps-ink); } +.sv-p { background: var(--ps-pad); } +.sv-m { background: var(--ps-margin); } +.pill { transform: translate(-50%, -50%); height: 18px; padding: 0 6px; border-radius: var(--ps-r-xs); color: var(--ps-ink); line-height: 18px; white-space: nowrap; + box-shadow: 0 1px 3px rgb(0 0 0 / .25); } .pill-l { transform: translate(0, -50%); } -.dim-sel { background: var(--accent); } -.tag-hov { background: #18181b; box-shadow: 0 0 0 1px rgba(255,255,255,.08), 0 2px 6px rgba(0,0,0,.25); } -.pd { margin-left: 8px; color: #a1a1aa; font-weight: 500; } -.dl, .dcap, .dp { background: var(--red); } -.dd-h { background: repeating-linear-gradient(90deg, var(--red) 0 3px, transparent 3px 6px); } -.dd-v { background: repeating-linear-gradient(180deg, var(--red) 0 3px, transparent 3px 6px); } -.gap { background: repeating-linear-gradient(-45deg, rgba(232, 62, 140, .42) 0 1.5px, rgba(232, 62, 140, .1) 1.5px 6px); box-shadow: inset 0 0 0 1px rgba(232, 62, 140, .7); } -.gp { background: #e83e8c; font-size: 9.5px; padding: 2px 4px; } -.tl { box-shadow: inset 0 0 0 1px rgba(124, 58, 237, .8); background: rgba(124, 58, 237, .06); } -.gd-v, .gd-h { background: rgba(13, 153, 255, .5); } -.ch { background: rgba(255, 59, 48, .5); } -.coord { background: #09090b; } +.dim-sel { background: var(--ps-signal); } +.tag-hov, .coord { background: var(--ps-night); color: var(--ps-snow); box-shadow: inset 0 0 0 1px var(--ps-line-2), 0 2px 6px rgb(0 0 0 / .25); } +.pd { margin-left: 8px; color: var(--ps-fog); } +.dl, .dcap, .dp { background: var(--ps-redline); } +.dcap, .dl { border-radius: 0; } +.dd-h { background: repeating-linear-gradient(90deg, var(--ps-redline) 0 3px, transparent 3px 6px); } +.dd-v { background: repeating-linear-gradient(180deg, var(--ps-redline) 0 3px, transparent 3px 6px); } +.gap { background: repeating-linear-gradient(-45deg, color-mix(in srgb, var(--ps-gap) 42%, transparent) 0 1.5px, color-mix(in srgb, var(--ps-gap) 10%, transparent) 1.5px 6px); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ps-gap) 70%, transparent); } +.gp { background: var(--ps-gap); height: 16px; line-height: 16px; padding: 0 4px; font-size: 10px; } +.tl { outline: 1px dashed var(--ps-signal-2); outline-offset: -1px; } +.gd-v, .gd-h { background: color-mix(in srgb, var(--ps-signal) 50%, transparent); } +.ch { background: color-mix(in srgb, var(--ps-redline) 50%, transparent); } /* ---------- markers ---------- */ .marks { position: fixed; inset: 0; pointer-events: none; } -.mk { position: absolute; width: 22px; height: 22px; border-radius: 11px; background: var(--mk); color: #fff; font: 700 11px/22px var(--sans); text-align: center; - pointer-events: auto; box-shadow: 0 0 0 2px #fff, 0 3px 10px rgba(0,0,0,.25); animation: mk-in .28s cubic-bezier(.3,1.6,.5,1); transition: transform .12s; } -.mk:hover, .mk.on { transform: scale(1.15); } -.mk[data-tip]::after { white-space: normal; width: max-content; max-width: 240px; text-align: left; line-height: 1.35; } +.mk { position: absolute; width: 22px; height: 22px; border-radius: 50%; background: var(--ps-signal); color: var(--ps-ink); + font: 600 11px/22px var(--ps-mono); text-align: center; pointer-events: auto; + box-shadow: 0 0 0 2px #fff, 0 3px 10px rgb(0 0 0 / .25); animation: mk-in .22s var(--ps-ease); transition: transform var(--ps-fast) var(--ps-ease); } +.mk:hover, .mk.on { transform: scale(1.12); } +.mk[data-tip]::after { white-space: normal; width: max-content; max-width: 240px; text-align: left; font: 400 12px/1.4 var(--ps-sans); } /* The element this note was on has been removed from the page. */ -.mk.gone { background: #71717a; box-shadow: 0 0 0 2px #fff, 0 3px 10px rgba(0,0,0,.25); outline: 1.5px dashed #71717a; outline-offset: 3px; opacity: .85; } -@keyframes mk-in { from { transform: scale(0); } } +.mk.gone { background: var(--ps-mute); outline: 1.5px dashed var(--ps-mute); outline-offset: 3px; opacity: .85; } +@keyframes mk-in { from { transform: scale(.4); opacity: 0; } } /* ---------- dock & toolbar ---------- */ .dock { position: fixed; right: 16px; bottom: 16px; pointer-events: auto; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; } .dock.top { flex-direction: column-reverse; } -.tb { display: flex; align-items: center; gap: 2px; padding: 4px; background: var(--bg); border: 1px solid var(--line); border-radius: 24px; - box-shadow: 0 10px 30px rgba(0,0,0,.28), 0 2px 6px rgba(0,0,0,.15); user-select: none; touch-action: none; } -.tb.collapsed { padding: 0; border-radius: 50%; } -.tb.dragging { cursor: grabbing; } -.tb.opening > * { animation: fade-in .18s ease-out both; } -.fab { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; color: #fff; transition: transform .15s, background .15s; } -.fab:hover { transform: scale(1.06); background: var(--bg2); } -.ib { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; color: var(--fg2); transition: background .12s, color .12s; } -.ib:hover { background: rgba(255,255,255,.08); color: #fff; } -.ib.on { color: var(--accent-fg); background: var(--accent-soft); } -.ib.ok { color: #4ade80; } -.ib.close:hover { color: var(--red); } -.sep { width: 1px; height: 20px; background: var(--line); margin: 0 4px; } -.bdg { position: absolute; top: 1px; right: 0; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--mk); color: #fff; - font: 700 10px/16px var(--sans); text-align: center; box-shadow: 0 0 0 2px var(--bg); } -.bdg.bump { animation: bump .35s cubic-bezier(.3,1.6,.5,1); } -@keyframes bump { 40% { transform: scale(1.4); } } -@keyframes fade-in { from { opacity: 0; transform: scale(.9); } } +.tb { display: flex; align-items: center; gap: 2px; padding: 4px; background: var(--ps-night); border: 1px solid var(--ps-line-2); border-radius: var(--ps-r-lg); + box-shadow: var(--ps-shadow); user-select: none; } +.tb.collapsed { padding: 0; } +.tb.opening > * { animation: fade-in var(--ps-base) var(--ps-ease) both; } +.fab { width: 44px; height: 44px; border-radius: 13px; display: grid; place-items: center; color: var(--ps-snow); transition: background var(--ps-fast) var(--ps-ease); } +.fab:hover { background: var(--ps-deep); } +.ib { width: 36px; height: 36px; border-radius: var(--ps-r-md); display: grid; place-items: center; color: var(--ps-fog); + transition: background var(--ps-fast) var(--ps-ease), color var(--ps-fast) var(--ps-ease); } +.ib:hover { background: var(--ps-deep); color: var(--ps-snow); } +.ib.on { background: var(--ps-deep); color: var(--ps-signal-2); box-shadow: inset 0 0 0 1px var(--ps-line-2); } +.ib.ok { color: var(--ps-pad); } +.ib.close:hover { color: var(--ps-redline); } +.sep { width: 1px; height: 20px; background: var(--ps-line-2); margin: 0 4px; } +.bdg { position: absolute; top: 2px; right: 1px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--ps-signal); color: var(--ps-ink); + font: 600 10px/16px var(--ps-mono); text-align: center; box-shadow: 0 0 0 2px var(--ps-night); } +.bdg.bump { animation: bump .35s var(--ps-ease); } +@keyframes bump { 40% { transform: scale(1.3); } } +@keyframes fade-in { from { opacity: 0; transform: scale(.94); } } /* settings menu */ -.menu { width: 268px; padding: 6px; background: var(--bg); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 16px 40px rgba(0,0,0,.35); animation: pop-in .14s ease-out; } +.menu { width: 272px; padding: 6px; background: var(--ps-night); border: 1px solid var(--ps-line-2); border-radius: var(--ps-r-lg); box-shadow: var(--ps-shadow); + animation: pop-in var(--ps-fast) var(--ps-ease); } .menu[hidden] { display: none; } -.mh { display: flex; justify-content: space-between; align-items: baseline; padding: 6px 8px 8px; font-weight: 650; } -.mh span { color: var(--fg3); font: 500 11px var(--mono); } -.mi { width: 100%; text-align: left; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 8px; border-radius: 8px; color: #e4e4e7; } -.mi:hover { background: rgba(255,255,255,.04); } +.mh { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px 10px; } +.mh b { display: flex; align-items: center; gap: 8px; font: 600 13.5px/1 var(--ps-sans); letter-spacing: -.01em; color: var(--ps-snow); } +.mh span { color: var(--ps-mute); font: 500 11px var(--ps-mono); } +.mi { width: 100%; text-align: left; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 8px; border-radius: var(--ps-r); color: var(--ps-snow); } +.mi:hover { background: var(--ps-deep); } .mi kbd { margin-left: 6px; } .mi.col { flex-direction: column; align-items: stretch; } -.seg { display: grid; grid-template-columns: 1fr 1fr; background: rgba(255,255,255,.06); border-radius: 8px; padding: 2px; } -.seg button { text-align: center; padding: 5px 8px; border-radius: 6px; color: var(--fg2); font-size: 12px; } -.seg button.on { background: #3f3f46; color: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.3); } -.sub { color: var(--fg3); font-size: 11.5px; margin-top: 6px; } -.switch { width: 30px; height: 18px; border-radius: 9px; background: #3f3f46; position: relative; flex: none; transition: background .15s; } -.switch::before { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s; } -.switch.on { background: var(--accent); } -.switch.on::before { transform: translateX(12px); } -.mf { display: flex; justify-content: space-between; align-items: center; padding: 8px 8px 4px; margin-top: 4px; border-top: 1px solid var(--line); color: var(--fg3); font-size: 11.5px; } +.mi.col:hover { background: none; } +.mi.col > span:first-child { font-size: 12px; color: var(--ps-mute); } +.seg { display: grid; grid-template-columns: 1fr 1fr; background: var(--ps-void); border: 1px solid var(--ps-line); border-radius: var(--ps-r); padding: 2px; } +.seg button { text-align: center; padding: 5px 8px; border-radius: var(--ps-r-sm); color: var(--ps-fog); font-size: 12px; } +.seg button:hover { color: var(--ps-snow); } +.seg button.on { background: var(--ps-deep); color: var(--ps-snow); box-shadow: inset 0 0 0 1px var(--ps-line-2); } +.sub { color: var(--ps-mute); font-size: 11.5px; margin-top: 8px; } +.switch { width: 30px; height: 18px; border-radius: 9px; background: var(--ps-line-2); position: relative; flex: none; transition: background var(--ps-fast) var(--ps-ease); } +.switch::before { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--ps-fog); transition: transform var(--ps-fast) var(--ps-ease), background var(--ps-fast); } +.switch.on { background: var(--ps-signal); } +.switch.on::before { transform: translateX(12px); background: #fff; } +.mf { display: flex; justify-content: space-between; align-items: center; padding: 8px 8px 4px; margin-top: 4px; border-top: 1px solid var(--ps-line); color: var(--ps-mute); font-size: 11.5px; } /* ---------- buttons ---------- */ -.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: 8px; - font: 600 12.5px/1 var(--sans); letter-spacing: -.005em; color: var(--fg); white-space: nowrap; - background: rgba(255,255,255,.07); box-shadow: inset 0 0 0 1px rgba(255,255,255,.08), inset 0 1px 0 rgba(255,255,255,.04); - transition: background .12s, box-shadow .12s, color .12s, transform .06s; } -.btn:hover { background: rgba(255,255,255,.11); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12), inset 0 1px 0 rgba(255,255,255,.05); } -.btn:active { transform: translateY(.5px); background: rgba(255,255,255,.06); } -.btn svg { flex: none; color: var(--fg2); transition: color .12s; } -.btn:hover svg { color: var(--fg); } -.btn.sm { height: 30px; padding: 0 10px; font-size: 12px; } -.btn.pri { color: #fff; background: linear-gradient(180deg, #2aa6ff, #0d8fef); box-shadow: inset 0 0 0 1px rgba(255,255,255,.12), inset 0 1px 0 rgba(255,255,255,.22), 0 1px 2px rgba(0,0,0,.35); } -.btn.pri:hover { background: linear-gradient(180deg, #45b2ff, #1f9bf5); } -.btn.pri:disabled { opacity: .4; cursor: default; transform: none; } -.btn.pri kbd { background: rgba(255,255,255,.18); border-color: transparent; color: #fff; min-width: 0; padding: 1px 4px; font-size: 10px; } -.btn.tint { color: #bfe3ff; background: rgba(13,153,255,.14); box-shadow: inset 0 0 0 1px rgba(13,153,255,.32); } -.btn.tint svg { color: #6cc2ff; } -.btn.tint:hover { background: rgba(13,153,255,.22); box-shadow: inset 0 0 0 1px rgba(13,153,255,.45); color: #fff; } -.btn.ghost { background: transparent; box-shadow: none; color: var(--fg2); } -.btn.ghost:hover { background: rgba(255,255,255,.07); color: var(--fg); } -.btn.danger { color: var(--red); } +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: var(--ps-r); + font: 500 12.5px/1 var(--ps-sans); color: var(--ps-snow); white-space: nowrap; background: var(--ps-deep); box-shadow: inset 0 0 0 1px var(--ps-line-2); + transition: background var(--ps-fast) var(--ps-ease), color var(--ps-fast) var(--ps-ease); } +.btn:hover { background: #1D1E21; } +.btn svg { flex: none; color: var(--ps-fog); } +.btn:hover svg { color: var(--ps-snow); } +.btn.sm { height: 28px; padding: 0 10px; font-size: 12px; } +.btn.pri { color: var(--ps-ink); background: var(--ps-snow); box-shadow: none; border-radius: 999px; } +.btn.pri:hover { background: #fff; } +.btn.pri:disabled { opacity: .4; cursor: default; } +.btn.pri kbd { color: #3A3C41; background: linear-gradient(180deg, #FFFFFF 0%, #ECECEE 100%); box-shadow: inset 0 0 0 1px rgb(10 10 10 / .12), inset 0 -1.5px 0 rgb(10 10 10 / .1); } +.btn.tint { color: var(--ps-signal-2); background: color-mix(in srgb, var(--ps-signal) 12%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ps-signal) 35%, transparent); } +.btn.tint svg { color: var(--ps-signal-2); } +.btn.tint:hover { background: color-mix(in srgb, var(--ps-signal) 20%, transparent); color: var(--ps-snow); } +.btn.ghost { background: transparent; box-shadow: none; color: var(--ps-fog); } +.btn.ghost:hover { background: var(--ps-deep); color: var(--ps-snow); } +.btn.danger { color: var(--ps-redline); } .btn.danger svg { color: currentColor; } -.btn.danger:hover { background: rgba(255, 59, 48, .14); color: var(--red); } -.btn.accent { color: var(--accent); } -.btn.accent:hover { background: rgba(13,153,255,.1); color: var(--accent); } +.btn.danger:hover { background: color-mix(in srgb, var(--ps-redline) 14%, transparent); color: var(--ps-redline); } +.btn.accent { color: var(--ps-signal-2); } +.btn.accent:hover { background: color-mix(in srgb, var(--ps-signal) 12%, transparent); color: var(--ps-signal-2); } /* ---------- popover ---------- */ .pop { position: fixed; left: 0; top: 0; width: 360px; max-height: min(600px, calc(100vh - 24px)); display: flex; flex-direction: column; pointer-events: auto; - background: var(--bg); border-radius: 14px; overflow: hidden; - box-shadow: 0 0 0 1px rgba(255,255,255,.09), 0 24px 60px -12px rgba(0,0,0,.5), 0 8px 20px -8px rgba(0,0,0,.3); - animation: pop-in .16s cubic-bezier(.2,.9,.3,1); } + background: var(--ps-night); border-radius: var(--ps-r-lg); overflow: hidden; box-shadow: 0 0 0 1px var(--ps-line-2), var(--ps-shadow); + animation: pop-in var(--ps-fast) var(--ps-ease); } .pop[hidden] { display: none; } -.pop.shake { animation: shake .35s; } -@keyframes pop-in { from { opacity: 0; transform: translateY(4px) scale(.985); } } +.pop.shake { animation: shake .35s var(--ps-ease); } +@keyframes pop-in { from { opacity: 0; transform: translateY(4px) scale(.99); } } @keyframes shake { 20%, 60% { transform: translateX(-5px); } 40%, 80% { transform: translateX(5px); } } -.ph { display: flex; align-items: center; gap: 10px; height: 48px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); flex: none; } +.ph { display: flex; align-items: center; gap: 10px; height: 48px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--ps-line); flex: none; } .pid { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; white-space: nowrap; } -.pid .tag { flex: none; height: 22px; padding: 0 7px; display: inline-flex; align-items: center; border-radius: 6px; background: var(--accent-soft); - color: var(--accent-fg); font: 600 12px var(--mono); } -.pid code { min-width: 0; font: 500 12.5px var(--mono); color: var(--fg2); overflow: hidden; text-overflow: ellipsis; } -.pid .comp { flex: none; height: 22px; padding: 0 7px; display: inline-flex; align-items: center; border-radius: 6px; background: rgba(167,139,250,.12); - color: #c9b5ff; font: 600 11.5px var(--sans); } +.pid .tag { flex: none; height: 22px; padding: 0 7px; display: inline-flex; align-items: center; border-radius: var(--ps-r-sm); + background: color-mix(in srgb, var(--ps-signal) 16%, transparent); color: var(--ps-signal-2); font: 600 12px var(--ps-mono); } +.pid code { min-width: 0; font: 500 12px var(--ps-mono); color: var(--ps-fog); overflow: hidden; text-overflow: ellipsis; } +.pid .comp { flex: none; height: 22px; padding: 0 7px; display: inline-flex; align-items: center; gap: 5px; border-radius: var(--ps-r-sm); + background: var(--ps-deep); box-shadow: inset 0 0 0 1px var(--ps-line-2); color: var(--ps-fog); font: 500 11.5px var(--ps-sans); } +.pid .comp svg { color: var(--ps-signal-2); } .pa { display: flex; align-items: center; gap: 2px; flex: none; } .pa .nav { display: contents; } -.pa .ib { width: 30px; height: 30px; border-radius: 8px; } +.pa .ib { width: 30px; height: 30px; border-radius: var(--ps-r); } .pa .ib svg { width: 16px; height: 16px; } -.pbody { overflow: auto; overscroll-behavior: contain; padding: 12px 14px 0; scrollbar-width: thin; scrollbar-color: #3f3f46 transparent; } -.quote { margin: 0 0 12px; padding: 8px 12px; border-left: 2px solid var(--mk); border-radius: 0 8px 8px 0; background: rgba(13,153,255,.06); - color: #d4d4d8; font: italic 400 12.5px/1.5 var(--sans); } -.specs { display: grid; gap: 2px; } +.pbody { overflow: auto; overscroll-behavior: contain; padding: 12px 14px 0; scrollbar-width: thin; scrollbar-color: var(--ps-line-2) transparent; } +.quote { margin: 0 0 12px; padding: 8px 12px; border-left: 2px solid var(--ps-signal); border-radius: 0 var(--ps-r) var(--ps-r) 0; background: var(--ps-deep); + color: var(--ps-snow); font: 400 12.5px/1.5 var(--ps-sans); } +.specs { display: grid; gap: 2px; margin-bottom: 6px; } .sr { display: grid; grid-template-columns: 64px 1fr; gap: 10px; align-items: start; min-height: 28px; } -.sk { color: var(--fg3); font-size: 12px; line-height: 26px; } +.sk { color: var(--ps-mute); font-size: 12px; line-height: 26px; } .sc { display: flex; flex-wrap: wrap; gap: 0 6px; padding-top: 1px; } -.chip { display: inline-flex; align-items: center; height: 26px; padding: 0 6px; margin: 0 -2px; border-radius: 6px; background: transparent; - font: 500 12px/1 var(--mono); color: var(--fg); cursor: copy; transition: background .1s, box-shadow .1s; max-width: 100%; overflow: hidden; +.chip { display: inline-flex; align-items: center; height: 26px; padding: 0 6px; margin: 0 -2px; border-radius: var(--ps-r-sm); background: transparent; + font: 500 12px/1 var(--ps-mono); color: var(--ps-snow); cursor: copy; transition: background var(--ps-fast) var(--ps-ease); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chip:hover { background: rgba(255,255,255,.07); } -.chip:active { background: rgba(13,153,255,.18); } -.cd { color: var(--fg3); margin-right: 5px; font: 500 11.5px var(--sans); } -.ci { display: inline-flex; margin-right: 6px; color: var(--fg3); } -.c-pad .ci { color: #8fd17a; } -.c-mar .ci { color: #f6b26b; } -.c-gap .ci { color: #f472b6; } -.cr { display: inline-flex; align-items: center; height: 26px; padding: 0 4px; font: 600 11.5px/1 var(--sans); } +.chip:hover { background: var(--ps-deep); } +.chip:active { background: color-mix(in srgb, var(--ps-signal) 18%, transparent); } +.cd { color: var(--ps-mute); margin-right: 5px; font: 500 11.5px var(--ps-sans); } +.ci { display: inline-flex; margin-right: 6px; color: var(--ps-mute); } +.c-pad .ci { color: var(--ps-pad); } +.c-mar .ci { color: var(--ps-margin); } +.c-gap .ci { color: var(--ps-gap); } +.cr { display: inline-flex; align-items: center; height: 26px; padding: 0 4px; font: 500 11.5px/1 var(--ps-sans); } .cr::before { content: ""; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; } -.cr.ok { color: #86efac; } -.cr.no { color: var(--red); } +.cr.ok { color: var(--ps-pad); } +.cr.no { color: var(--ps-redline); } -.specs { margin-bottom: 6px; } -.pid .ident { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; cursor: copy; border-radius: 7px; } -.pid .ident:hover .tag { background: rgba(13,153,255,.26); } -.pid .ident:hover code { color: var(--fg); } -.pid .ident:active .tag { transform: scale(.96); } -/* Tooltips inside the popover open downward so the popover edge never clips them. */ +.pid .ident { display: flex; align-items: center; gap: 8px; min-width: 0; overflow: hidden; cursor: copy; border-radius: var(--ps-r-sm); } +.pid .ident:hover .tag { background: color-mix(in srgb, var(--ps-signal) 26%, transparent); } +.pid .ident:hover code { color: var(--ps-snow); } +/* Tooltips inside the popover open downward so they never cover the header. */ .pop [data-tip]::after { bottom: auto; top: calc(100% + 8px); transform: translate(-50%, -4px); } .pop [data-tip]:hover::after { transform: translate(-50%, 0); } .pa .close[data-tip]::after { left: auto; right: 0; transform: translate(0, -4px); } .pa .close[data-tip]:hover::after { transform: translate(0, 0); } .more { margin: 0 -14px; } -.more > summary { list-style: none; cursor: pointer; height: 38px; padding: 0 14px; color: var(--fg2); font-size: 12px; font-weight: 500; - display: flex; align-items: center; gap: 8px; user-select: none; transition: background .12s; } -.more > summary:hover { background: rgba(255,255,255,.035); color: var(--fg); } +.more > summary { list-style: none; cursor: pointer; height: 38px; padding: 0 14px; color: var(--ps-fog); font-size: 12px; + display: flex; align-items: center; gap: 8px; user-select: none; transition: background var(--ps-fast) var(--ps-ease); } +.more > summary:hover { background: var(--ps-deep); color: var(--ps-snow); } .more > summary::-webkit-details-marker { display: none; } -.more > summary svg { color: var(--fg3); transition: transform .15s; } +.more > summary svg { color: var(--ps-mute); transition: transform var(--ps-fast) var(--ps-ease); } .more[open] > summary svg { transform: rotate(90deg); } -.more > summary em { margin-left: auto; font: 400 11.5px var(--sans); color: var(--fg3); font-style: normal; } +.more > summary em { margin-left: auto; font: 400 11.5px var(--ps-sans); color: var(--ps-mute); font-style: normal; } .mb { padding: 2px 14px 10px; } -.pn { padding: 12px; border-top: 1px solid var(--line); flex: none; } -.composer { position: relative; border-radius: 12px; background: rgba(0,0,0,.22); - box-shadow: inset 0 0 0 1px rgba(255,255,255,.07), inset 0 1px 2px rgba(0,0,0,.25); - transition: box-shadow .2s ease, background .2s ease; } -.composer:hover { box-shadow: inset 0 0 0 1px rgba(255,255,255,.11), inset 0 1px 2px rgba(0,0,0,.25); } -.composer:focus-within { background: rgba(0,0,0,.28); - box-shadow: inset 0 0 0 1px rgba(13,153,255,.5), 0 0 0 4px rgba(13,153,255,.07), inset 0 1px 2px rgba(0,0,0,.25); } +.pn { padding: 12px; border-top: 1px solid var(--ps-line); flex: none; } +.composer { position: relative; border-radius: var(--ps-r-md); background: var(--ps-void); box-shadow: inset 0 0 0 1px var(--ps-line); transition: box-shadow var(--ps-fast) var(--ps-ease); } +.composer:hover { box-shadow: inset 0 0 0 1px var(--ps-line-2); } +.composer:focus-within { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ps-signal) 70%, transparent); } textarea { all: unset; display: block; box-sizing: border-box; width: 100%; min-height: 44px; max-height: 168px; overflow-y: auto; resize: none; - padding: 11px 12px 2px; color: var(--fg); font: 400 13px/1.5 var(--sans); white-space: pre-wrap; word-break: break-word; - scrollbar-width: thin; scrollbar-color: #3f3f46 transparent; } -textarea::placeholder { color: var(--fg3); } + padding: 11px 12px 2px; color: var(--ps-snow); font: 400 13px/1.5 var(--ps-sans); white-space: pre-wrap; word-break: break-word; + scrollbar-width: thin; scrollbar-color: var(--ps-line-2) transparent; } +textarea::placeholder { color: var(--ps-mute); } textarea:focus, textarea:focus-visible { outline: none; } .cbar { display: flex; align-items: center; gap: 4px; padding: 4px 6px 6px 12px; min-height: 40px; } -.chint { flex: 1; min-width: 0; color: var(--fg3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - opacity: 0; transform: translateY(2px); transition: opacity .2s ease, transform .2s ease; } -.composer:focus-within .chint { opacity: 1; transform: none; } -.chint kbd { min-width: 0; padding: 0 4px; font-size: 10px; line-height: 16px; background: rgba(255,255,255,.07); border-color: rgba(255,255,255,.06); color: var(--fg2); } -.cbar .del { width: 28px; height: 28px; border-radius: 8px; color: var(--fg3); } -.cbar .del:hover { color: var(--red); background: rgba(255, 59, 48, .14); } -.send { flex: none; width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; color: #fff; - background: linear-gradient(180deg, #2aa6ff, #0d8fef); - box-shadow: inset 0 0 0 1px rgba(255,255,255,.14), inset 0 1px 0 rgba(255,255,255,.25), 0 2px 6px rgba(13,143,239,.35); - transition: background .2s ease, box-shadow .2s ease, color .2s ease, transform .12s ease; } -.send:hover { transform: translateY(-1px); background: linear-gradient(180deg, #45b2ff, #1f9bf5); } -.send:active { transform: translateY(0) scale(.94); } -.send:disabled { cursor: default; transform: none; color: var(--fg3); background: rgba(255,255,255,.06); - box-shadow: inset 0 0 0 1px rgba(255,255,255,.06); } -.send svg { transition: transform .2s ease; } -.send:not(:disabled):hover svg { transform: translateY(-1px); } -.ptip { display: flex; align-items: center; gap: 8px; padding: 9px 14px; font-weight: 500; line-height: 1.45; border-top: 1px solid rgba(13,153,255,.18); background: rgba(13,153,255,.07); - color: #a9d8ff; font-size: 12px; flex: none; } -.ptip svg { flex: none; color: #6cc2ff; } +.chint { flex: 1; min-width: 0; color: var(--ps-mute); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + opacity: 0; transition: opacity var(--ps-base) var(--ps-ease); } +.composer:focus-within .chint { opacity: 1; } +.chint kbd { min-width: 18px; height: 18px; padding: 0 5px; font-size: 10.5px; } +.cbar .del { width: 28px; height: 28px; border-radius: var(--ps-r); color: var(--ps-mute); } +.cbar .del:hover { color: var(--ps-redline); background: color-mix(in srgb, var(--ps-redline) 14%, transparent); } +.send { flex: none; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; color: var(--ps-ink); background: var(--ps-signal); + transition: background var(--ps-fast) var(--ps-ease), color var(--ps-fast) var(--ps-ease), transform var(--ps-fast) var(--ps-ease); } +.send:hover { background: #2AA6FF; } +.send:active { transform: scale(.94); } +.send:disabled { cursor: default; color: var(--ps-mute); background: var(--ps-deep); transform: none; } +.ptip { display: flex; align-items: center; gap: 8px; padding: 10px 14px; line-height: 1.45; border-top: 1px solid var(--ps-line); background: var(--ps-deep); + color: var(--ps-fog); font-size: 12px; flex: none; } +.ptip b { color: var(--ps-snow); font-weight: 600; } +.ptip svg { flex: none; color: var(--ps-signal-2); } /* full property list (inside "All properties") */ -.mb details { border-top: 1px solid rgba(255,255,255,.05); } -.mb summary { list-style: none; cursor: pointer; padding: 7px 0; font-weight: 600; font-size: 12px; color: #e4e4e7; display: flex; align-items: center; gap: 6px; user-select: none; } +.mb details { border-top: 1px solid var(--ps-line); } +.mb summary { list-style: none; cursor: pointer; padding: 8px 0; font-weight: 600; font-size: 12px; color: var(--ps-snow); + display: flex; align-items: center; gap: 8px; user-select: none; } .mb summary::-webkit-details-marker { display: none; } -.mb summary::before { content: ''; border-left: 4px solid var(--fg3); border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform .12s; } +.mb summary::before { content: ''; border-left: 4px solid var(--ps-mute); border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform var(--ps-fast) var(--ps-ease); } .mb details[open] > summary::before { transform: rotate(90deg); } -.badge { margin-left: auto; font-weight: 500; color: var(--fg3); font-size: 11px; } +.badge { margin-left: auto; font-weight: 500; color: var(--ps-mute); font-size: 11px; } .sb { padding: 0 0 8px; } .row { display: grid; grid-template-columns: 98px 1fr; gap: 8px; padding: 2px 0; align-items: start; font-size: 12px; } -.k { color: var(--fg3); } -.v { font: 11.5px/1.45 var(--mono); color: #e4e4e7; word-break: break-word; cursor: copy; border-radius: 4px; padding: 0 3px; margin: 0 -3px; } -.v:hover { background: rgba(255,255,255,.08); } -.v.mu { color: var(--fg3); } +.k { color: var(--ps-mute); } +.v { font: 11.5px/1.45 var(--ps-mono); color: var(--ps-snow); word-break: break-word; cursor: copy; border-radius: var(--ps-r-xs); padding: 0 3px; margin: 0 -3px; } +.v:hover { background: var(--ps-deep); } +.v.mu { color: var(--ps-mute); } .sw { display: inline-block; flex: none; width: 12px; height: 12px; border-radius: 3px; margin-right: 6px; vertical-align: -2px; overflow: hidden; - box-shadow: inset 0 0 0 1px rgba(255,255,255,.2); background: repeating-conic-gradient(#888 0 25%, #ccc 0 50%) 0 0 / 6px 6px; } + box-shadow: inset 0 0 0 1px rgb(255 255 255 / .2); background: repeating-conic-gradient(#888 0 25%, #ccc 0 50%) 0 0 / 6px 6px; } .sw > i { display: block; width: 100%; height: 100%; } -.wc { display: inline-block; font: 600 10px/1.5 var(--sans); padding: 0 5px; border-radius: 4px; margin-left: 4px; } -.wc.ok { background: #14532d; color: #86efac; } -.wc.no { background: rgba(255, 59, 48, .16); color: var(--red); } -.spec { font-size: 26px; line-height: 1.2; color: #fff; padding: 2px 0 8px; display: flex; align-items: baseline; gap: 10px; overflow: hidden; white-space: nowrap; } -.spec span { font-size: 12px; color: var(--fg2); overflow: hidden; text-overflow: ellipsis; } +.wc { display: inline-block; font: 600 10px/1.5 var(--ps-sans); padding: 0 5px; border-radius: var(--ps-r-xs); margin-left: 4px; } +.wc.ok { background: color-mix(in srgb, var(--ps-pad) 16%, transparent); color: var(--ps-pad); } +.wc.no { background: color-mix(in srgb, var(--ps-redline) 16%, transparent); color: var(--ps-redline); } +.spec { font-size: 26px; line-height: 1.2; color: var(--ps-snow); padding: 2px 0 8px; display: flex; align-items: baseline; gap: 10px; overflow: hidden; white-space: nowrap; } +.spec span { font-size: 12px; color: var(--ps-fog); overflow: hidden; text-overflow: ellipsis; } .bm { position: relative; display: grid; grid-template-columns: 28px 1fr 28px; grid-template-rows: 18px auto 18px; grid-template-areas: ". t ." "l in r" ". b ."; - text-align: center; align-items: center; border-radius: 4px; padding: 2px; font: 10.5px/1 var(--mono); color: #1b1b1b; margin-bottom: 8px; } + text-align: center; align-items: center; border-radius: var(--ps-r-xs); padding: 2px; font: 500 10.5px/1 var(--ps-mono); color: var(--ps-ink); margin-bottom: 8px; } .bm .bm { margin: 0; } .bm > .t { grid-area: t; } .bm > .b { grid-area: b; } .bm > .l { grid-area: l; } .bm > .r { grid-area: r; } .bm > .in { grid-area: in; } -.bl { position: absolute; left: 5px; top: 4px; font: 600 9px/1 var(--sans); text-transform: uppercase; letter-spacing: .04em; opacity: .6; } -.bm-m { background: #f6c38f; border: 1px dashed #a96a2c; } -.bm-b { background: #fde8a8; border: 1px solid #1b1b1b; } -.bm-p { background: #b9d9a8; border: 1px dashed #4b7a37; } -.bm-c { background: #a5c8ec; border: 1px solid #4b7fb5; padding: 8px 4px; border-radius: 2px; } +.bl { position: absolute; left: 5px; top: 4px; font: 600 9px/1 var(--ps-sans); text-transform: uppercase; letter-spacing: .04em; opacity: .6; } +.bm-m { background: var(--ps-margin); border: 1px dashed rgb(0 0 0 / .4); } +.bm-b { background: var(--ps-marker); border: 1px solid var(--ps-ink); } +.bm-p { background: var(--ps-pad); border: 1px dashed rgb(0 0 0 / .4); } +.bm-c { background: var(--ps-signal-2); border: 1px solid rgb(0 0 0 / .4); padding: 8px 4px; border-radius: 2px; } /* ---------- onboarding hint & toast ---------- */ -.hint { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; align-items: center; gap: 14px; padding: 8px 8px 8px 16px; pointer-events: auto; - background: var(--bg); border: 1px solid var(--line); border-radius: 24px; box-shadow: 0 10px 30px rgba(0,0,0,.28); color: var(--fg2); font-size: 12.5px; white-space: nowrap; - animation: rise .25s ease-out; } +.hint { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); display: flex; align-items: center; gap: 14px; padding: 7px 7px 7px 14px; pointer-events: auto; + background: var(--ps-night); border: 1px solid var(--ps-line-2); border-radius: var(--ps-r-lg); box-shadow: var(--ps-shadow); color: var(--ps-fog); font-size: 12.5px; white-space: nowrap; + animation: rise var(--ps-base) var(--ps-ease); } .hint[hidden] { display: none; } -.hint b { color: #fff; font-weight: 600; } +.hint b { color: var(--ps-snow); font-weight: 600; } @keyframes rise { from { opacity: 0; transform: translate(-50%, 8px); } } -.toast { position: fixed; left: 50%; top: 16px; transform: translate(-50%, -8px); display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-radius: 12px; - background: #27272a; color: var(--fg); font-weight: 600; font-size: 12.5px; box-shadow: 0 0 0 1px rgba(255,255,255,.1), 0 12px 32px rgba(0,0,0,.35); opacity: 0; pointer-events: none; - transition: opacity .16s, transform .16s; white-space: nowrap; } +.toast { position: fixed; left: 50%; top: 16px; transform: translate(-50%, -8px); display: flex; align-items: center; gap: 10px; padding: 7px 8px 7px 12px; border-radius: var(--ps-r-lg); + background: var(--ps-night); border: 1px solid var(--ps-line-2); color: var(--ps-snow); font-weight: 500; font-size: 12.5px; box-shadow: var(--ps-shadow); opacity: 0; pointer-events: none; + transition: opacity var(--ps-fast) var(--ps-ease), transform var(--ps-fast) var(--ps-ease); white-space: nowrap; } .toast.show { opacity: 1; transform: translate(-50%, 0); } .toast.act { pointer-events: auto; } .toast:not(.act) { padding-right: 14px; }