diff --git a/README.md b/README.md index ddfdae7..713a576 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Homepage design concepts for Levkin Inc. | **Spec** | `/spec/` | Company story as RFC-style documentation | | **Folder** | `/stack-folder/` | Manila folders L0–L7, sticky tab stack, site previews | -Open `/` to pick a direction. +**Production** (`levkin.ca`): `/` → spec, `/folders/` → stack. Dev picker at `/` only when running `npm run dev` (not deployed). ## Develop @@ -16,7 +16,7 @@ npm install npm run dev ``` -Vite default: `http://localhost:5173` +Dev server: `http://localhost:5175` (5173 is often taken by other apps) | Page | URL | |------|-----| @@ -28,9 +28,22 @@ Vite default: `http://localhost:5173` ```bash npm run build -# dist/ +# dist/ — Vite multi-page output + +npm run build:www +# www/ — production layout for nginx (commit www/ before deploy) ``` +### Deploy (pve10 LXC 220) + +```bash +npm run build:www +git add www/ && git commit -m "Rebuild www" && git push origin main +ssh root@10.0.10.10 'pct exec 220 -- bash -c "cd /var/www/levkin && git pull origin main"' +``` + +Requires DNS: `levkin.ca` and `www.levkin.ca` A → your home IP (same as other `*.levkin.ca` sites). Caddy on VM 106 proxies to `10.0.10.60:80`. + ## Folder stack (`/stack-folder/`) Eight layers with labeled tabs. Scroll pins earlier tabs on a shared rail; L7 joins last. Scroll stops when all tabs align. @@ -52,9 +65,22 @@ Writes PNGs used in `stack-folder/previews/` (spec, auto, caseware, iliadobkin, ```bash npm run dev -STACK_URL=http://localhost:5173/stack-folder/ npm run test:folder +STACK_URL=http://localhost:5175/stack-folder/ npm run test:folder ``` +Playwright runs **desktop** (tab alignment at max scroll) and **mobile** (bottom tab rail, no horizontal overflow, L3 jump). + +### Test on a phone + +Dev server listens on your LAN (`host: true` in Vite config): + +```bash +npm run dev +# open http://:5175/stack-folder/ on the phone (same Wi‑Fi) +``` + +Production: [levkin.ca/folders/](https://levkin.ca/folders/) — on viewports ≤720px the side rail becomes a **bottom tab bar** (L0–L7) with safe-area padding for iOS. + ## Related sites - [auto.levkin.ca](https://auto.levkin.ca) diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..245fb1b Binary files /dev/null and b/public/og-image.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..ecab2aa --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://levkin.ca/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..fbf5821 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,13 @@ + + + + https://levkin.ca/ + monthly + 1.0 + + + https://levkin.ca/folders/ + monthly + 0.8 + + diff --git a/scripts/capture-previews.mjs b/scripts/capture-previews.mjs index ceeefda..c4dcc05 100644 --- a/scripts/capture-previews.mjs +++ b/scripts/capture-previews.mjs @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const out = join(root, 'stack-folder/previews'); -const base = process.env.PREVIEW_BASE || 'http://localhost:5177'; +const base = process.env.PREVIEW_BASE || 'http://localhost:5175'; mkdirSync(out, { recursive: true }); diff --git a/scripts/prepare-www.mjs b/scripts/prepare-www.mjs index 8a2796b..4d17c58 100644 --- a/scripts/prepare-www.mjs +++ b/scripts/prepare-www.mjs @@ -37,6 +37,9 @@ await mkdir(join(www, 'folders'), { recursive: true }); await cp(join(dist, 'assets'), join(www, 'assets'), { recursive: true }); await cp(join(root, 'public', 'favicon.svg'), join(www, 'favicon.svg')); +await cp(join(root, 'public', 'robots.txt'), join(www, 'robots.txt')); +await cp(join(root, 'public', 'sitemap.xml'), join(www, 'sitemap.xml')); +await cp(join(root, 'public', 'og-image.png'), join(www, 'og-image.png')); const specHtml = await readFile(join(dist, 'spec', 'index.html'), 'utf8'); await writeFile( diff --git a/scripts/test-stack-folder.mjs b/scripts/test-stack-folder.mjs index 180dc83..e0437ee 100644 --- a/scripts/test-stack-folder.mjs +++ b/scripts/test-stack-folder.mjs @@ -1,64 +1,124 @@ /** - * Tab rail alignment tests for /stack-folder/. - * Run: STACK_URL=http://localhost:5173/stack-folder/ npm run test:folder + * Tab rail alignment tests for /stack-folder/ (desktop + mobile). + * Run: STACK_URL=http://localhost:5175/stack-folder/ npm run test:folder */ -import { chromium } from 'playwright'; +import { chromium, devices } from 'playwright'; -const URL = process.env.STACK_URL || 'http://localhost:5173/stack-folder/'; +const URL = process.env.STACK_URL || 'http://localhost:5175/stack-folder/'; const VERBOSE = process.env.VERBOSE === '1'; -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); -await page.goto(URL, { waitUntil: 'networkidle' }); +async function runViewport(name, contextOptions) { + const browser = await chromium.launch(); + const page = await browser.newPage(contextOptions); + await page.goto(URL, { waitUntil: 'networkidle' }); -async function tabTops() { - return page.evaluate(() => { - const tabs = [...document.querySelectorAll('.mount .tab')]; - const stick = - parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--stack-stick')) * 16; - return { - scrollY: window.scrollY, - isFolded: document.querySelector('.mount')?.classList.contains('is-folded'), - stick, - tabs: tabs.map((t) => ({ - code: t.querySelector('.tab-code')?.textContent, - top: Math.round(t.getBoundingClientRect().top), - left: Math.round(t.getBoundingClientRect().left), - })), - }; + async function tabTops() { + return page.evaluate(() => { + const tabs = [...document.querySelectorAll('.mount .tab')]; + const stick = + parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--stack-stick')) * 16; + return { + scrollY: window.scrollY, + isFolded: document.querySelector('.mount')?.classList.contains('is-folded'), + stick, + tabs: tabs.map((t) => ({ + code: t.querySelector('.tab-code')?.textContent, + top: Math.round(t.getBoundingClientRect().top), + left: Math.round(t.getBoundingClientRect().left), + })), + }; + }); + } + + if (VERBOSE) console.log(`${name} initial`, await tabTops()); + + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await page.waitForTimeout(600); + if (VERBOSE) console.log(`${name} max scroll`, await tabTops()); + + const railVisible = await page.evaluate(() => { + const rail = document.querySelector('.tab-rail'); + if (!rail) return false; + return getComputedStyle(rail).display !== 'none'; }); + + if (name === 'mobile' && !railVisible) { + await browser.close(); + return [`${name}: mobile tab rail not visible`]; + } + + const foldIssues = await page.evaluate(({ isMobile }) => { + const tabs = [...document.querySelectorAll('.mount .tab')]; + const tops = tabs.map((t) => t.getBoundingClientRect().top); + const min = Math.min(...tops); + const max = Math.max(...tops); + const issues = []; + const spreadLimit = isMobile ? 40 : 30; + + if (document.documentElement.scrollWidth > window.innerWidth + 1) { + issues.push('horizontal overflow'); + } + if (max - min > spreadLimit) { + issues.push(`tab row spread ${Math.round(max - min)}px (want ≤${spreadLimit})`); + } + if (!document.querySelector('.mount')?.classList.contains('is-folded')) { + issues.push('mount not folded at max scroll'); + } + return issues; + }, { isMobile: name === 'mobile' }); + + if (foldIssues.length) { + await browser.close(); + return foldIssues.map((issue) => `${name}: ${issue}`); + } + + if (name === 'mobile') { + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(200); + await page.locator('.tab-rail .rail-tab[data-goto="3"]').tap(); + await page.waitForTimeout(800); + if (VERBOSE) console.log(`${name} rail goto L3`, await tabTops()); + } else { + await page.click('[data-goto="7"]'); + await page.waitForTimeout(800); + if (VERBOSE) console.log(`${name} goto L7`, await tabTops()); + } + + const fail = await page.evaluate(({ isMobile }) => { + const issues = []; + + if (isMobile) { + const rail = document.querySelector('.tab-rail .rail-tab[data-goto="3"]'); + const railBox = rail?.getBoundingClientRect(); + if (!railBox || railBox.height < 40) issues.push('rail tap target too small'); + if (window.scrollY < 100) issues.push('rail L3 tap did not scroll'); + } else { + const tabs = [...document.querySelectorAll('.mount .tab')]; + const tops = tabs.map((t) => t.getBoundingClientRect().top); + const l7 = tabs.find((t) => t.querySelector('.tab-code')?.textContent === 'L7'); + const l0 = tabs.find((t) => t.querySelector('.tab-code')?.textContent === 'L0'); + if (l7 && l0 && Math.abs(l7.getBoundingClientRect().top - l0.getBoundingClientRect().top) > 30) { + issues.push('L7 not aligned with L0'); + } + } + + return issues; + }, { isMobile: name === 'mobile' }); + + await browser.close(); + return fail.map((issue) => `${name}: ${issue}`); } -if (VERBOSE) console.log('initial', await tabTops()); +const desktop = { viewport: { width: 1400, height: 900 } }; +const mobile = { ...devices['iPhone 13'], hasTouch: true }; -await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); -await page.waitForTimeout(600); -if (VERBOSE) console.log('max scroll', await tabTops()); +const results = [ + ...(await runViewport('desktop', desktop)), + ...(await runViewport('mobile', mobile)), +]; -await page.click('[data-goto="7"]'); -await page.waitForTimeout(800); -if (VERBOSE) console.log('goto L7', await tabTops()); - -const fail = await page.evaluate(() => { - const tabs = [...document.querySelectorAll('.mount .tab')]; - const tops = tabs.map((t) => t.getBoundingClientRect().top); - const min = Math.min(...tops); - const max = Math.max(...tops); - const l7 = tabs.find((t) => t.querySelector('.tab-code')?.textContent === 'L7'); - const l0 = tabs.find((t) => t.querySelector('.tab-code')?.textContent === 'L0'); - const issues = []; - if (max - min > 30) issues.push(`tab row spread ${Math.round(max - min)}px (want ≤30)`); - if (l7 && l0 && Math.abs(l7.getBoundingClientRect().top - l0.getBoundingClientRect().top) > 30) - issues.push('L7 not aligned with L0'); - if (!document.querySelector('.mount')?.classList.contains('is-folded')) - issues.push('mount not folded at max scroll'); - return issues; -}); - -await browser.close(); - -if (fail.length) { - console.error('FAIL:', fail.join('; ')); +if (results.length) { + console.error('FAIL:', results.join('; ')); process.exit(1); } -console.log('PASS: stack-folder tab rail'); +console.log('PASS: stack-folder (desktop + mobile)'); diff --git a/spec/index.html b/spec/index.html index abe16eb..237be36 100644 --- a/spec/index.html +++ b/spec/index.html @@ -2,10 +2,37 @@ - + Levkin — Company Specification + + + + + + + + + + + + @@ -24,21 +51,24 @@