diff --git a/.gitignore b/.gitignore index 3bdd52e..5587ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .DS_Store +.scroll-debug/ diff --git a/README.md b/README.md index 1fe564f..58b72d4 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,13 @@ Design concepts for the Levkin software development company homepage. -### Brand directions - | Option | Path | Vibe | |--------|------|------| | **Spec** | `/spec/` | RFC documentation, endpoints, iliadobkin.com | -| **Slab** | `/slab/` | Brutalist poster | -| **Relay** | `/relay/` | Telegraph, decoded messages | -| **Vault** | `/vault/` | Institutional trust | +| **Cards** | `/stack/` | Dark overlapping sticky cards — click a layer to bring it forward | +| **Folder** | `/stack-folder/` | Manila folders, staggered tabs (L0–L7), site previews | -### Stack variants (L0–L6, scroll stops at interface) - -| Variant | Path | Metaphor | -|---------|------|----------| -| **Cards** | `/stack/` | Dark overlapping sticky cards | -| **Folder** | `/stack-folder/` | Manila folders, top tabs stay visible | -| **Trace** | `/stack-trace/` | Call-stack / devtools frames | -| **Rack** | `/stack-rack/` | Server rack 1U units with status LEDs | - -Open `/` to compare all. +Open `/` to compare all three. ## Develop @@ -30,6 +18,15 @@ npm install npm run dev ``` +Vite serves the multi-page app (default `http://localhost:5173`). If that port is busy, pass another: `npx vite --port 5175`. + +| Page | URL | +|------|-----| +| Compare | `/` | +| Spec | `/spec/` | +| Cards | `/stack/` | +| Folders | `/stack-folder/` | + ## Build ```bash @@ -37,6 +34,42 @@ npm run build # output in dist/ ``` +## Folder site (`/stack-folder/`) + +Eight manila folders (L0–L7) with labeled tabs. Scroll stacks earlier tabs on a shared rail; L7 rises into the row last. Scroll depth is capped when all tabs align (`is-folded`). + +- **L0** — Company + Cal embed +- **L1** — Scope + spec preview +- **L2** — Services +- **L3–L5** — Automation, CaseWare, QA previews +- **L6** — Git repos preview +- **L7** — Terms + Cal embed + +Modules: `shared/stack-scroll.js` (scroll/fold), `folder-rail.js` (side rail), `folder-cal.js` (Cal.com embeds). + +### Preview screenshots + +Each linked folder shows a screenshot; refresh captures with dev server running: + +```bash +npm run dev # separate terminal +npm run capture-previews +``` + +Writes PNGs to `stack-folder/previews/`. + +### Tests (Playwright) + +Requires dev server on the URL you pass: + +```bash +npm run dev +STACK_URL=http://localhost:5173/stack-folder/ npm run test:folder +STACK_URL=http://localhost:5173/stack-folder/ npm run test:stack-scroll +``` + +`test:folder` checks tab alignment at max scroll and L7 jump. `test:stack-scroll` covers scroll/blur behavior on the card stack. + ## Related sites - [auto.levkin.ca](https://auto.levkin.ca) — automation diff --git a/index.html b/index.html index 79c67a4..0ccfaec 100644 --- a/index.html +++ b/index.html @@ -24,7 +24,7 @@ min-height: 100vh; line-height: 1.5; } - .wrap { max-width: 1100px; margin: 0 auto; padding: 4rem 1.5rem 6rem; } + .wrap { max-width: 900px; margin: 0 auto; padding: 4rem 1.5rem 6rem; } header { margin-bottom: 3rem; } .eyebrow { font-family: 'DM Mono', monospace; @@ -44,15 +44,7 @@ .grid { display: grid; gap: 1.25rem; - } - @media (min-width: 640px) { - .grid { grid-template-columns: repeat(2, 1fr); } - } - @media (min-width: 900px) { - .grid { grid-template-columns: repeat(3, 1fr); } - } - @media (min-width: 1200px) { - .grid { grid-template-columns: repeat(5, 1fr); } + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } a.card { display: block; @@ -68,12 +60,10 @@ transform: translateY(-3px); } .preview { - height: 130px; + height: 110px; display: flex; align-items: center; justify-content: center; - position: relative; - overflow: hidden; font-size: 0.7rem; } .preview--spec { @@ -82,35 +72,6 @@ font-family: 'DM Mono', monospace; letter-spacing: 0.06em; } - .preview--slab { - background: #f5f5f0; - color: #0a0a0a; - font-weight: 800; - font-size: 2rem; - letter-spacing: -0.06em; - font-family: system-ui, sans-serif; - } - .preview--relay { - background: #1a1814; - color: #d4a574; - font-family: 'DM Mono', monospace; - letter-spacing: 0.2em; - } - .preview--relay::after { - content: '· · · ─ ─ ·'; - position: absolute; - bottom: 1rem; - font-size: 0.55rem; - opacity: 0.5; - } - .preview--vault { - background: linear-gradient(160deg, #0c1410 0%, #1a2820 100%); - color: #c9b896; - font-family: 'DM Sans', sans-serif; - font-size: 0.65rem; - letter-spacing: 0.25em; - text-transform: uppercase; - } .preview--stack { background: #0e0e10; flex-direction: column; @@ -119,17 +80,21 @@ } .preview--stack .card-layer { width: 70%; - height: 14px; + height: 12px; border-radius: 3px; border: 1px solid rgba(255,255,255,0.1); background: linear-gradient(90deg, #2a2a32, #3a3a46); - box-shadow: 0 2px 8px rgba(0,0,0,0.4); } .preview--stack .card-layer:nth-child(1) { width: 55%; opacity: 0.5; } - .preview--stack .card-layer:nth-child(2) { width: 62%; opacity: 0.65; } - .preview--stack .card-layer:nth-child(3) { width: 68%; opacity: 0.8; } - .preview--stack .card-layer:nth-child(4) { width: 75%; } - .preview--stack .card-layer:nth-child(5) { width: 82%; background: #4a4a58; } + .preview--stack .card-layer:nth-child(2) { width: 62%; opacity: 0.7; } + .preview--stack .card-layer:nth-child(3) { width: 75%; background: #4a4a58; } + .preview--folder { + background: #e8e2d4; + color: #2a2824; + font-family: 'DM Mono', monospace; + font-size: 0.65rem; + border-left: 12px solid #c9a86c; + } .card-body { padding: 1.15rem 1.25rem 1.35rem; } .card-body h2 { font-size: 1.05rem; font-weight: 600; margin-bottom: 0.3rem; } .card-body p { font-size: 0.82rem; color: var(--muted); line-height: 1.4; } @@ -141,13 +106,6 @@ color: var(--accent); letter-spacing: 0.06em; } - .kept { - display: inline-block; - font-family: 'DM Mono', monospace; - font-size: 0.6rem; - color: var(--muted); - margin-left: 0.35rem; - } footer { margin-top: 3.5rem; padding-top: 2rem; @@ -157,120 +115,43 @@ } footer a { color: var(--accent); text-decoration: none; } footer code { font-family: 'DM Mono', monospace; font-size: 0.75rem; } - .section-label { - font-family: 'DM Mono', monospace; - font-size: 0.68rem; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--muted); - margin: 2rem 0 1rem; - } - .grid-stacks { margin-bottom: 0; } - .preview--folder { - background: #e8e2d4; - color: #2a2824; - font-family: 'DM Mono', monospace; - font-size: 0.65rem; - border-left: 12px solid #c9a86c; - } - .preview--rack { - background: #12141a; - color: #4ade80; - font-family: 'DM Mono', monospace; - font-size: 0.55rem; - letter-spacing: 0.15em; - border: 2px solid #2a3040; - } - .preview--trace { - background: #0d0d0f; - color: #6b9b6b; - font-family: 'DM Mono', monospace; - font-size: 0.6rem; - }
-

levkin.ca · round 3

-

Eight directions.

-

Five brand concepts + four stack variants (L0–L6, stops on time). Spec updated with iliadobkin.com.

+

levkin.ca

+

Three directions.

+

Spec for the company story. Cards and Folder for the L0–L6 scroll stack — click a layer to bring it to the front.

-

Brand

GET /company → 200
-

Spec kept

-

Levkin as an RFC. Endpoints, schemas, required properties. Precise and documentation-native.

- paper · RFC · precise +

Spec

+

Levkin as an RFC. Endpoints, schemas, required properties.

+ paper · RFC
- -
LEV
-
-

Slab

-

Brutalist poster. Massive type, hard edges, zero decoration. Confidence without explaining itself.

- brutalist · bold · minimal -
-
- - -
RELAY
-
-

Relay

-

Telegraph and signal chain. Messages arrive, get decoded. Communication as craft.

- vintage · interactive · warm -
-
- - -
Secured
-
-

Vault

-

Institutional trust. Deep green, brass accents. For enterprise clients who need gravitas.

- trust · enterprise · calm -
-
- -
- -

Stack variants (scroll test)

-
- +
-

Stack · Cards

-

L0–L6 overlapping sticky cards. Scroll stops at L6.

- default · dark · technical +

Cards

+

Dark sticky stack. Scroll or click L0–L6 to focus a layer.

+ stack · scroll
+ -
L0│tab
+
L0│ L1│ L2│
-

Stack · Folder

-

Tabs on top, staggered left — all readable. Click tab or L0–L6 rail to jump.

- folder · tabs · office -
-
- -
U0▮U1▮
-
-

Stack · Rack

-

Server rack 1U units — LEDs, handles, infra stack.

- rack · infra · LEDs -
-
- -
at Levkin.*
-
-

Stack · Trace

-

Call-stack frames — iliadobkin.com as quality frame.

- trace · devtools · mono +

Folder

+

Spec as a filing cabinet — manila tabs, clause sections, pull a file forward.

+ folder · spec
diff --git a/package.json b/package.json index 08c58e8..7947630 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "capture-previews": "node scripts/capture-previews.mjs", + "test:folder": "node scripts/test-stack-folder.mjs", + "test:stack-scroll": "node scripts/test-stack-scroll.mjs" }, "devDependencies": { "playwright": "^1.60.0", diff --git a/scripts/capture-previews.mjs b/scripts/capture-previews.mjs new file mode 100644 index 0000000..c50cbe5 --- /dev/null +++ b/scripts/capture-previews.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** Refresh stack-folder/previews/*.png — run dev server first for local shots */ +import { chromium } from 'playwright'; +import { mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +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'; + +mkdirSync(out, { recursive: true }); + +const shots = [ + ['spec', `${base}/spec/`], + ['stack', `${base}/stack/`], + ['auto', 'https://auto.levkin.ca'], + ['caseware', 'https://caseware.levkin.ca'], + ['iliadobkin', 'https://iliadobkin.com'], + ['git-repos', 'https://git.levkin.ca/explore/repos'], + ['cal', 'https://cal.levkin.ca/ilia/consult'], +]; + +async function setCalTheme(page, mode) { + await page.evaluate((want) => { + const root = document.documentElement; + const btn = [...document.querySelectorAll('button, [role="button"], label, a')].find( + (el) => new RegExp(want, 'i').test(el.textContent || el.getAttribute('aria-label') || ''), + ); + if (btn) btn.click(); + if (want === 'dark') { + root.dataset.theme = 'dark'; + root.classList.add('dark'); + } else { + root.dataset.theme = 'light'; + root.classList.remove('dark'); + } + }, mode); +} + +async function captureCal(page, outDir, theme) { + const url = 'https://cal.levkin.ca/ilia/consult'; + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25000 }); + await page.waitForTimeout(1200); + await setCalTheme(page, theme); + await page.waitForTimeout(1500); + + await page.evaluate(() => { + window.scrollTo(0, 120); + }); + await page.waitForTimeout(600); + + const file = theme === 'light' ? 'cal-light.png' : 'cal-dark.png'; + await page.screenshot({ + path: join(outDir, file), + clip: { x: 0, y: 0, width: 1280, height: 680 }, + }); + console.log(`✓ ${file}`); +} + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + +for (const [name, url] of shots) { + try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); + await page.waitForTimeout(1500); + await page.screenshot({ path: join(out, `${name}.png`) }); + console.log(`✓ ${name}.png`); + } catch (err) { + console.warn(`✗ ${name}: ${err.message}`); + } +} + +try { + await captureCal(page, out, 'dark'); + await captureCal(page, out, 'light'); +} catch (err) { + console.warn(`✗ cal captures: ${err.message}`); +} + +await browser.close(); diff --git a/scripts/test-stack-folder.mjs b/scripts/test-stack-folder.mjs new file mode 100644 index 0000000..180dc83 --- /dev/null +++ b/scripts/test-stack-folder.mjs @@ -0,0 +1,64 @@ +/** + * Tab rail alignment tests for /stack-folder/. + * Run: STACK_URL=http://localhost:5173/stack-folder/ npm run test:folder + */ +import { chromium } from 'playwright'; + +const URL = process.env.STACK_URL || 'http://localhost:5173/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 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('initial', await tabTops()); + +await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); +await page.waitForTimeout(600); +if (VERBOSE) console.log('max scroll', await tabTops()); + +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('; ')); + process.exit(1); +} +console.log('PASS: stack-folder tab rail'); diff --git a/scripts/test-stack-scroll.mjs b/scripts/test-stack-scroll.mjs new file mode 100644 index 0000000..0753692 --- /dev/null +++ b/scripts/test-stack-scroll.mjs @@ -0,0 +1,118 @@ +/** + * Automated scroll/blur tests for stack-folder. + * Run: node scripts/test-stack-scroll.mjs + */ +import { chromium } from 'playwright'; + +const URL = process.env.STACK_URL || 'http://localhost:5173/stack-folder/'; +const VIEWPORT = { width: 1280, height: 800 }; + +function fail(msg) { + console.error('FAIL:', msg); + process.exitCode = 1; +} + +function pass(msg) { + console.log('PASS:', msg); +} + +async function readState(page) { + return page.evaluate(() => { + const stick = + parseFloat(getComputedStyle(document.documentElement).fontSize) * 3; + const l7Tab = document.querySelector('.f7 .tab'); + const l0 = document.querySelector('.f0'); + const l0Body = document.querySelector('.f0 .body'); + const folderBlur = l0?.style.getPropertyValue('--stack-blur') || ''; + const filter = l0Body ? getComputedStyle(l0Body).filter : ''; + const blurMatch = filter.match(/blur\(([\d.]+)px\)/); + const blurPx = blurMatch ? parseFloat(blurMatch[1]) : 0; + return { + scrollY: window.scrollY, + docMax: document.documentElement.scrollHeight - innerHeight, + l7TabTop: l7Tab?.getBoundingClientRect().top ?? null, + stick, + l0Covered: l0?.classList.contains('is-covered'), + l0BlurPx: blurPx, + folderBlur, + l0Filter: filter, + runway: getComputedStyle(document.querySelector('.mount')).getPropertyValue( + '--stack-runway', + ), + }; + }); +} + +async function main() { + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: VIEWPORT }); + await page.goto(URL, { waitUntil: 'networkidle' }); + await page.waitForTimeout(400); + + const top = await readState(page); + if (top.l0Covered || top.l0BlurPx > 0.1) { + fail(`L0 blurred at top (covered=${top.l0Covered}, blur=${top.l0BlurPx})`); + } else { + pass('L0 clear at scroll top'); + } + + await page.evaluate(() => window.scrollTo(0, 999999)); + await page.waitForTimeout(350); + const end = await readState(page); + + if (end.l7TabTop === null) fail('L7 tab missing'); + else if (Math.abs(end.l7TabTop - end.stick) > 8) { + fail(`L7 tab not on stick: top=${end.l7TabTop} stick=${end.stick} scrollY=${end.scrollY}`); + } else { + pass(`L7 on stick at max scroll (y=${end.scrollY}, tabTop=${end.l7TabTop.toFixed(1)})`); + } + + if (end.l0BlurPx < 2) { + fail(`L0 not blurred when stacked (blur=${end.l0BlurPx})`); + } else { + pass(`L0 blurred when stacked (blur=${end.l0BlurPx}px)`); + } + + const midY = Math.floor(end.scrollY * 0.45); + await page.evaluate((y) => window.scrollTo(0, y), midY); + await page.waitForTimeout(200); + const mid = await readState(page); + if (mid.l0BlurPx > 2) { + fail(`L0 still heavily blurred mid-scroll out (blur=${mid.l0BlurPx} at y=${mid.scrollY})`); + } else { + pass(`L0 unfades when scrolling out (blur=${mid.l0BlurPx}px at y=${mid.scrollY})`); + } + + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(350); + const back = await readState(page); + if (back.l0Covered || back.l0BlurPx > 0.1) { + fail(`L0 still blurred after scroll to top (covered=${back.l0Covered}, blur=${back.l0BlurPx})`); + } else { + pass('L0 unfades after scroll back to top'); + } + + const maxY = end.scrollY; + await page.evaluate((y) => window.scrollTo(0, y), maxY); + await page.waitForTimeout(200); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(350); + const back2 = await readState(page); + if (back2.l0Covered || back2.l0BlurPx > 0.1) { + fail(`L0 stuck after down-up cycle (blur=${back2.l0BlurPx})`); + } else { + pass('L0 unfades after full down-up cycle'); + } + + await browser.close(); + if (process.exitCode) { + console.log('\nTests failed.'); + process.exit(1); + } + console.log('\nAll tests passed.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/shared/stack-layout.css b/shared/stack-layout.css index e67d41f..4704e04 100644 --- a/shared/stack-layout.css +++ b/shared/stack-layout.css @@ -1,6 +1,6 @@ -/* Spacer after L6 so scroll can stop */ .stack-stop, .stop { - height: 1px; - margin-bottom: 4rem; + height: 0; + margin: 0; + pointer-events: none; } diff --git a/shared/stack-scroll.js b/shared/stack-scroll.js index 85dba7d..047a4f8 100644 --- a/shared/stack-scroll.js +++ b/shared/stack-scroll.js @@ -1,43 +1,329 @@ -/** Scroll depth + jump — panels are [data-layer] sticky cards */ +/** Scroll depth, jump, folder fold state */ export function initStackScroll(options = {}) { const { - sectionSelector = '.layer[data-layer], .folder[data-layer], .frame[data-layer], .unit[data-layer]', + sectionSelector = '.layer[data-layer], .folder[data-layer]', depthEl = document.getElementById('depth'), depthPrefix = 'L', - tabSelector = '[data-goto], .jump', + tabSelector = '[data-goto], .jump, .layer-id', + mountSelector = '.mount', + foldTabs = false, + interactionMode = 'pin', } = options; const sections = document.querySelectorAll(sectionSelector); if (!sections.length) return; - const mid = () => window.innerHeight * 0.45; + const mount = document.querySelector(mountSelector); + const lastLayer = sections.length - 1; + const scrollPad = 56; + let frontLayer = null; + let layerScrollTops = []; + let maxScrollY = Infinity; + let measuringScroll = false; + const deckTabs = () => + mount ? [...mount.querySelectorAll('.folder .tab')] : []; - function updateDepth() { - let active = 0; - sections.forEach((el) => { - const r = el.getBoundingClientRect(); - if (r.top <= mid() && r.bottom > mid()) active = Number(el.dataset.layer); - }); - if (depthEl) depthEl.textContent = `${depthPrefix}${active}`; + function layerAnchor(el) { + return el.querySelector('.tab') || el.querySelector('.body') || el; + } - document.querySelectorAll('.stack-ruler button, .stack-ruler [data-goto], .tab-rail button, .tab[data-goto]').forEach((tab) => { - const n = tab.dataset.layer ?? tab.dataset.goto; + function tabRowMetrics() { + const tabs = deckTabs(); + const stick = stackStickPx(); + const tops = tabs.map((t) => t.getBoundingClientRect().top); + const spread = tops.length ? Math.max(...tops) - Math.min(...tops) : 0; + const onStick = tops.length + ? tops.every((top) => Math.abs(top - stick) <= 6) + : false; + return { tabs, stick, spread, onStick }; + } + + function stackStickPx() { + const root = document.documentElement; + const raw = getComputedStyle(root).getPropertyValue('--stack-stick').trim(); + const n = parseFloat(raw); + if (!Number.isFinite(n)) return scrollPad; + if (raw.endsWith('rem')) { + return n * parseFloat(getComputedStyle(root).fontSize); + } + if (raw.endsWith('px')) return n; + return n; + } + + function captureLayerAnchors(force = false) { + if (!force && window.scrollY > 8 && layerScrollTops.length) return; + const stick = stackStickPx(); + layerScrollTops = [...sections].map((el) => + Math.max(0, layerAnchor(el).offsetTop - stick), + ); + } + + function lastTabEl() { + return sections[lastLayer]?.querySelector('.tab') ?? null; + } + + function measureDeskEnd() { + const tab = lastTabEl(); + if (!tab) return 0; + return Math.max(0, tab.offsetTop - stackStickPx()); + } + + function ensureStackRunway() { + if (!mount) return; + measuringScroll = true; + try { + captureLayerAnchors(true); + mount.style.setProperty('--stack-runway', '0px'); + void mount.offsetHeight; + + const stick = stackStickPx(); + const tab = lastTabEl(); + if (!tab) return; + + for (let i = 0; i < 12; i++) { + const docMax = Math.max( + 0, + document.documentElement.scrollHeight - window.innerHeight, + ); + window.scrollTo({ top: docMax, behavior: 'auto' }); + void mount.offsetHeight; + const shortfall = Math.ceil(tab.getBoundingClientRect().top - stick); + if (shortfall <= 1) break; + + const cur = + parseFloat(getComputedStyle(mount).getPropertyValue('--stack-runway')) || 0; + mount.style.setProperty('--stack-runway', `${cur + shortfall + 2}px`); + void mount.offsetHeight; + captureLayerAnchors(true); + } + } finally { + measuringScroll = false; + window.scrollTo({ top: 0, behavior: 'auto' }); + } + } + + function tabsAlignedAtScroll() { + const { tabs, stick, spread } = tabRowMetrics(); + if (!tabs.length) return false; + const tops = tabs.map((t) => t.getBoundingClientRect().top); + return ( + spread <= 1 && + tops.every((top) => Math.abs(top - stick) <= 2) + ); + } + + /** Furthest scroll Y where every tab is flush on the stick line */ + function computeMaxScrollAligned() { + const docMax = Math.max( + 0, + document.documentElement.scrollHeight - window.innerHeight, + ); + if (!deckTabs().length) return 0; + + measuringScroll = true; + try { + for (let y = docMax; y >= 0; y -= 1) { + window.scrollTo({ top: y, behavior: 'auto' }); + void mount?.offsetHeight; + if (tabsAlignedAtScroll()) return y; + } + return 0; + } finally { + measuringScroll = false; + window.scrollTo({ top: 0, behavior: 'auto' }); + } + } + + function captureMaxScroll() { + if (window.scrollY <= 8) { + captureLayerAnchors(true); + ensureStackRunway(); + captureLayerAnchors(true); + } + maxScrollY = computeMaxScrollAligned(); + } + + function isFolded() { + if (!foldTabs || !mount) return false; + if (deckTabs().length < sections.length) return false; + return window.scrollY > 120 && tabsAlignedAtScroll(); + } + + function updateFolded() { + if (!mount) return; + mount.classList.toggle('is-folded', isFolded()); + } + + function clampScroll() { + if (measuringScroll) return; + if (window.scrollY > maxScrollY) { + window.scrollTo({ top: maxScrollY, behavior: 'auto' }); + } + } + + function layerTabTitle(layer) { + const section = sections[layer]; + const tab = section?.querySelector('.tab'); + if (!tab) return `${depthPrefix}${layer}`; + const code = tab.querySelector('.tab-code')?.textContent?.trim() ?? `${depthPrefix}${layer}`; + const label = tab.querySelector('.tab-label')?.textContent?.trim() ?? ''; + return `${code}${label}`; + } + + function setIndicators(active) { + if (depthEl) { + depthEl.textContent = + active === lastLayer && isFolded() + ? layerTabTitle(active) + : `${depthPrefix}${active}`; + } + document.querySelectorAll('.stack-ruler button, .stack-ruler [data-goto], .tab-rail button, .tab[data-goto], .layer-id').forEach((el) => { + const n = el.dataset.layer ?? el.dataset.goto; if (n === undefined) return; - tab.classList.toggle('active', Number(n) === active); + el.classList.toggle('active', Number(n) === active); }); } + function setFront(layer) { + frontLayer = layer; + sections.forEach((el) => { + el.classList.toggle('is-front', layer !== null && Number(el.dataset.layer) === layer); + }); + if (layer !== null) setIndicators(layer); + } + + function clearFront() { + setFront(null); + } + + function scrollToLayer(layer) { + if (isFolded() && layerScrollTops[layer] !== undefined) { + window.scrollTo({ top: layerScrollTops[layer], behavior: 'smooth' }); + return; + } + + const target = + document.getElementById(`layer-${layer}`) || + document.querySelector(`${sectionSelector}[data-layer="${layer}"]`); + if (!target) return; + let y; + if (layer === lastLayer) { + captureMaxScroll(); + y = maxScrollY; + } else { + const anchor = layerAnchor(target); + y = Math.min( + anchor.getBoundingClientRect().top + window.scrollY - scrollPad, + maxScrollY, + ); + } + const behavior = layer === lastLayer ? 'auto' : 'smooth'; + window.scrollTo({ top: Math.max(0, y), behavior }); + if (layer === lastLayer) { + requestAnimationFrame(updateDepth); + } + } + + function navigateToLayer(layer) { + clearFront(); + scrollToLayer(layer); + requestAnimationFrame(() => { + updateFolded(); + updateDepth(); + }); + } + + function openLayer(layer) { + if (interactionMode === 'navigate' || isFolded()) { + navigateToLayer(layer); + return; + } + + if (frontLayer === layer) { + clearFront(); + updateDepth(); + scrollToLayer(layer); + return; + } + + if (frontLayer !== null) { + clearFront(); + scrollToLayer(layer); + requestAnimationFrame(updateDepth); + return; + } + + setFront(layer); + scrollToLayer(layer); + } + + function currentActiveLayer() { + if (isFolded()) return lastLayer; + const y = window.scrollY; + let active = 0; + for (let i = sections.length - 1; i >= 0; i--) { + const top = layerScrollTops[i]; + if (top !== undefined && y >= top - 4) { + active = i; + break; + } + } + return active; + } + + function updateCovered() { + sections.forEach((el) => { + el.classList.remove('is-covered'); + el.style.removeProperty('--stack-blur'); + }); + } + + function updateDepth() { + if (measuringScroll) return; + clampScroll(); + updateFolded(); + if (frontLayer !== null) return; + const active = currentActiveLayer(); + updateCovered(); + setIndicators(active); + window.dispatchEvent( + new CustomEvent('stack-depth', { detail: { active } }), + ); + } + + window.addEventListener('stack-goto-layer', (e) => { + const layer = Number(e.detail?.layer); + if (Number.isFinite(layer)) openLayer(layer); + }); + + sections.forEach((panel) => { + panel.addEventListener('click', (e) => { + if (e.target.closest('a[href], button[data-goto], .layer-id, .rail-tab, .tab[data-goto], .site-preview, .cta-block, .cal-slot, [data-cal-embed]')) return; + openLayer(Number(panel.dataset.layer)); + }); + }); + document.querySelectorAll(tabSelector).forEach((tab) => { tab.addEventListener('click', (e) => { e.preventDefault(); - const layer = tab.dataset.goto ?? tab.dataset.layer; - const target = document.querySelector(`${sectionSelector}[data-layer="${layer}"]`); - if (!target) return; - const y = target.getBoundingClientRect().top + window.scrollY - 56; - window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' }); + e.stopPropagation(); + openLayer(Number(tab.dataset.goto ?? tab.dataset.layer)); }); }); - window.addEventListener('scroll', updateDepth, { passive: true }); + captureMaxScroll(); updateDepth(); + + window.addEventListener('load', () => { + if (window.scrollY > 8) window.scrollTo(0, 0); + requestAnimationFrame(() => { + captureMaxScroll(); + updateDepth(); + }); + }); + window.addEventListener('resize', () => { + captureMaxScroll(); + updateDepth(); + }, { passive: true }); + window.addEventListener('scroll', updateDepth, { passive: true }); } diff --git a/shared/stack-vars.css b/shared/stack-vars.css index ea6bb6e..017048b 100644 --- a/shared/stack-vars.css +++ b/shared/stack-vars.css @@ -1,7 +1,10 @@ -/* Original working sticky stack (cfca7aa) */ +/* Sticky stack — one viewport of scroll per layer until L6 is on top */ :root { --stack-nav: 2.5rem; - --stack-stick: 3.5rem; - --stack-step: 2.75rem; - --stack-card-min: 52vh; + --stack-stick: 3rem; + --stack-tab-h: 1.72rem; + --stack-step: 1.75rem; + --stack-gap: 0.35rem; + /* ~one folder body of scroll between layers (not a full viewport each) */ + --stack-scroll-slot: 1.5rem; } diff --git a/stack-folder/folder-cal.js b/stack-folder/folder-cal.js new file mode 100644 index 0000000..4b4389d --- /dev/null +++ b/stack-folder/folder-cal.js @@ -0,0 +1,184 @@ +/** + * Cal.com inline embed — paste from Event type → consult → Embed → Inline. + * Uses embed.js (not a raw iframe). No “allowed domains” setting needed for this path. + * L0 = dark snippet, L7 = light snippet. Screenshot fallback if embed does not mount. + */ +const CAL_ORIGIN = 'https://cal.levkin.ca'; +const CAL_LINK = 'ilia/consult'; +const EMBED_SCRIPT = `${CAL_ORIGIN}/embed/embed.js`; + +const SLOTS = { + dark: { + ns: 'consult-l0', + inlineConfig: { + layout: 'week_view', + useSlotsViewOnSmallScreen: 'true', + theme: 'dark', + }, + ui: { theme: 'dark', hideEventTypeDetails: true, layout: 'week_view' }, + }, + light: { + ns: 'consult-l7', + inlineConfig: { + layout: 'week_view', + useSlotsViewOnSmallScreen: 'true', + theme: 'light', + }, + ui: { theme: 'light', hideEventTypeDetails: true, layout: 'week_view' }, + }, +}; + +let calApiReady; + +/** Loader from Cal embed UI */ +function bootCalLoader() { + if (window.Cal?.loaded) return; + (function (C, A, L) { + const p = (a, ar) => { + a.q.push(ar); + }; + const d = C.document; + C.Cal = + C.Cal || + function () { + const cal = C.Cal; + const ar = arguments; + if (!cal.loaded) { + cal.ns = {}; + cal.q = cal.q || []; + d.head.appendChild(d.createElement('script')).src = A; + cal.loaded = true; + } + if (ar[0] === L) { + const api = function () { + p(api, arguments); + }; + const namespace = ar[1]; + api.q = api.q || []; + if (typeof namespace === 'string') { + cal.ns[namespace] = cal.ns[namespace] || api; + p(cal.ns[namespace], ar); + p(cal, ['initNamespace', namespace]); + } else p(cal, ar); + return; + } + p(cal, ar); + }; + })(window, EMBED_SCRIPT, 'init'); +} + +function waitForEmbedScript() { + return new Promise((resolve) => { + const finish = () => resolve(window.Cal); + const attach = (s) => { + if (s.dataset.calReady) { + finish(); + return; + } + s.addEventListener( + 'load', + () => { + s.dataset.calReady = '1'; + finish(); + }, + { once: true }, + ); + s.addEventListener('error', () => resolve(null), { once: true }); + }; + + const existing = document.querySelector(`script[src="${EMBED_SCRIPT}"]`); + if (existing) { + attach(existing); + return; + } + + const mo = new MutationObserver(() => { + const s = document.querySelector(`script[src="${EMBED_SCRIPT}"]`); + if (!s) return; + mo.disconnect(); + attach(s); + }); + mo.observe(document.head, { childList: true }); + window.setTimeout(() => { + mo.disconnect(); + finish(); + }, 12000); + }); +} + +function loadCalApi() { + if (!calApiReady) { + bootCalLoader(); + /* First init pulls in embed.js (same as Cal’s generated snippet) */ + window.Cal('init', 'consult', { origin: CAL_ORIGIN }); + calApiReady = waitForEmbedScript(); + } + return calApiReady; +} + +function mountInline(slot) { + const targetId = slot.dataset.calTarget; + const themeKey = slot.dataset.calTheme === 'light' ? 'light' : 'dark'; + const spec = SLOTS[themeKey]; + const el = document.getElementById(targetId); + if (!el || !spec || !window.Cal?.ns) return; + + window.Cal('init', spec.ns, { origin: CAL_ORIGIN }); + + window.Cal.ns[spec.ns]('inline', { + elementOrSelector: `#${targetId}`, + config: spec.inlineConfig, + calLink: CAL_LINK, + }); + + window.Cal.ns[spec.ns]('ui', spec.ui); +} + +function watchSlot(slot) { + const targetId = slot.dataset.calTarget; + const el = document.getElementById(targetId); + if (!el) return; + + const showFallback = () => { + slot.classList.add('is-blocked'); + slot.classList.remove('is-embedded'); + }; + const showEmbed = () => { + slot.classList.remove('is-blocked'); + slot.classList.add('is-embedded'); + }; + + showFallback(); + + const hasLiveEmbed = () => { + const iframe = el.querySelector('iframe'); + return Boolean(iframe && iframe.offsetHeight > 60); + }; + + const obs = new MutationObserver(() => { + if (hasLiveEmbed()) showEmbed(); + }); + obs.observe(el, { childList: true, subtree: true }); + + window.setTimeout(() => { + obs.disconnect(); + if (!hasLiveEmbed()) showFallback(); + }, 8000); +} + +export function initCalEmbeds() { + const slots = [...document.querySelectorAll('[data-cal-embed]')]; + if (!slots.length) return; + + loadCalApi() + .then((Cal) => { + if (!Cal) return; + slots.forEach((slot) => { + mountInline(slot); + watchSlot(slot); + }); + }) + .catch(() => { + /* screenshots + ↗ */ + }); +} diff --git a/stack-folder/folder-rail.js b/stack-folder/folder-rail.js new file mode 100644 index 0000000..4f882e4 --- /dev/null +++ b/stack-folder/folder-rail.js @@ -0,0 +1,32 @@ +/** Fixed rail — all L0–L7 visible; click to jump (syncs with scroll) */ +export function initRailRoller(rail = document.querySelector('.tab-rail')) { + const tabs = rail ? [...rail.querySelectorAll('.rail-tab')] : []; + if (!rail || !tabs.length) return; + + function setActive(layer) { + const n = Math.max(0, Math.min(tabs.length - 1, layer)); + tabs.forEach((btn, i) => btn.classList.toggle('active', i === n)); + } + + function gotoLayer(layer) { + setActive(layer); + window.dispatchEvent( + new CustomEvent('stack-goto-layer', { detail: { layer } }), + ); + } + + tabs.forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + gotoLayer(Number(btn.dataset.layer ?? btn.dataset.goto)); + }); + }); + + window.addEventListener('stack-depth', (e) => { + const layer = Number(e.detail?.active); + if (Number.isFinite(layer)) setActive(layer); + }); + + setActive(0); +} diff --git a/stack-folder/folder.css b/stack-folder/folder.css index 39d3ff6..0cdd0d4 100644 --- a/stack-folder/folder.css +++ b/stack-folder/folder.css @@ -2,142 +2,687 @@ @import '../shared/stack-layout.css'; :root { - --tab-offset: 2.35rem; + --tab-offset: 2.1rem; --mono: 'IBM Plex Mono', monospace; --sans: 'Instrument Sans', system-ui, sans-serif; + --text: 'Instrument Sans', system-ui, sans-serif; + --display: 'Literata', Georgia, serif; + --hi: #fff59d; + --hi-edge: #f5e06a; + --desk: #3d3830; + --manila: #ebe4d4; + --manila-edge: #c9bea8; + --manila-dark: #d8cfbc; + --ink: #1c1a16; + --ink-muted: #4a4640; + --l0-tab: #c9a86c; + --l0-tab-fg: #2a2824; + --l1-tab: #a8c4d4; + --l1-tab-fg: #1a2830; + --l2-tab: #b8d4a8; + --l2-tab-fg: #1a2818; + --l3-tab: #d4b8c4; + --l3-tab-fg: #2a1820; + --l4-tab: #d4c8a8; + --l4-tab-fg: #2a2418; + --l5-tab: #c4c4c4; + --l5-tab-fg: #2a2a2a; + --l6-tab: #6a7a8a; + --l6-tab-fg: #f0ece4; + --l7-tab: #2a4a6b; + --l7-tab-fg: #ebe4d4; } * { box-sizing: border-box; margin: 0; padding: 0; } -body { font-family: var(--sans); background: #2a2824; color: #1a1814; } - -.nav { - position: fixed; top: 0; left: 0; right: 0; z-index: 400; - display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; padding: 0.5rem 1rem; - font-family: var(--mono); font-size: 0.62rem; - background: rgba(42,40,36,0.97); color: #c4b8a8; +body { + font-family: var(--sans); + background: var(--desk); + color: var(--ink); + background-image: + linear-gradient(90deg, rgba(0,0,0,0.04) 1px, transparent 1px), + linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px); + background-size: 24px 24px; } -.nav a { color: #8a8278; text-decoration: none; } -.nav a:hover { color: #d4a574; } -.depth { margin-left: auto; color: #d4a574; font-weight: 600; } +.site-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 400; + padding: 0.5rem 1rem; + font-family: var(--mono); + font-size: 0.62rem; + letter-spacing: 0.02em; + text-align: center; + color: #b8ad9d; + background: rgba(42, 40, 36, 0.97); + border-bottom: 1px solid #59534a; +} + +.site-header a { + color: #d4a574; + text-decoration: none; +} + +.site-header a:hover { + color: #ebc39a; +} .tab-rail { position: fixed; - top: 2.75rem; - right: max(0.5rem, calc(50% - 340px)); + top: calc(var(--stack-nav) + 0.35rem); + left: calc(50% + 20rem + 1.35rem); + right: auto; z-index: 500; display: flex; flex-direction: column; - gap: 0.2rem; - padding: 0.35rem; - background: rgba(42,40,36,0.92); + gap: 0.15rem; + padding: 0.3rem; + background: rgba(42,40,36,0.94); border: 1px solid #4a4844; - border-radius: 6px; + border-radius: 0; } .rail-tab { font-family: var(--mono); font-size: 0.55rem; - padding: 0.2rem 0.45rem; - border: none; - border-radius: 3px; + padding: 0.22rem 0.5rem; + border: 1px solid transparent; + border-radius: 0; background: transparent; color: #8a8278; cursor: pointer; text-align: left; + min-width: 2.1rem; } -.rail-tab:hover, -.rail-tab.active { background: #c9a86c; color: #2a2824; } +.rail-tab[data-layer="0"]:hover, +.rail-tab[data-layer="0"].active { background: var(--l0-tab); color: var(--l0-tab-fg); border-color: rgba(0,0,0,0.12); } +.rail-tab[data-layer="1"]:hover, +.rail-tab[data-layer="1"].active { background: var(--l1-tab); color: var(--l1-tab-fg); border-color: rgba(0,0,0,0.12); } +.rail-tab[data-layer="2"]:hover, +.rail-tab[data-layer="2"].active { background: var(--l2-tab); color: var(--l2-tab-fg); border-color: rgba(0,0,0,0.12); } +.rail-tab[data-layer="3"]:hover, +.rail-tab[data-layer="3"].active { background: var(--l3-tab); color: var(--l3-tab-fg); border-color: rgba(0,0,0,0.12); } +.rail-tab[data-layer="4"]:hover, +.rail-tab[data-layer="4"].active { background: var(--l4-tab); color: var(--l4-tab-fg); border-color: rgba(0,0,0,0.12); } +.rail-tab[data-layer="5"]:hover, +.rail-tab[data-layer="5"].active { background: var(--l5-tab); color: var(--l5-tab-fg); border-color: rgba(0,0,0,0.12); } +.rail-tab[data-layer="6"]:hover, +.rail-tab[data-layer="6"].active { background: var(--l6-tab); color: var(--l6-tab-fg); border-color: rgba(0,0,0,0.12); } +.rail-tab[data-layer="7"]:hover, +.rail-tab[data-layer="7"].active { background: var(--l7-tab); color: var(--l7-tab-fg); border-color: rgba(0,0,0,0.08); } .mount { + --folder-body-h: 30rem; width: min(640px, 100%); margin: 0 auto; - padding: var(--stack-nav) 1rem 0; + padding: calc(var(--stack-nav) + 0.75rem) 1rem 2rem; +} + +/* Scroll step between layers (folder uses display:contents) */ +.folder:not(.folder--last) .body { + margin-bottom: calc(var(--stack-scroll-slot) + 5.5rem); +} + +.folder--last .body { + margin-bottom: var(--stack-runway, 0px); } -/* Same pattern as /stack/ — one sticky card, next covers previous */ .folder { + display: contents; +} + +.folder .body { position: sticky; - min-height: var(--stack-card-min); - margin-bottom: 1.25rem; - width: 100%; + top: calc(var(--stack-stick) + var(--stack-tab-h) - 1px); + cursor: pointer; } +.tab { + opacity: 1; +} + +/* Each tab sticks above its folder body as you scroll the stack */ .tab { position: sticky; display: block; - width: fit-content; - max-width: calc(100% - 1rem); + top: var(--stack-stick); + height: var(--stack-tab-h); + box-sizing: border-box; + width: max-content; + max-width: none; + white-space: nowrap; font-family: var(--mono); - font-size: 0.62rem; + font-size: 0.58rem; font-weight: 600; letter-spacing: 0.04em; - padding: 0.4rem 1rem 0.35rem; - border: 1px solid rgba(0,0,0,0.12); + text-transform: uppercase; + line-height: 1.2; + padding: 0.28rem 0.5rem 0; + border: 1px solid rgba(0,0,0,0.18); border-bottom: none; - border-radius: 8px 8px 0 0; + border-radius: 0; cursor: pointer; text-align: left; - z-index: 80; - box-shadow: 0 -2px 8px rgba(0,0,0,0.12); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.35); } -.tab:hover { filter: brightness(1.06); } +.tab-label { font-weight: 500; opacity: 0.92; } +.tab:hover { filter: brightness(1.04); } + +/* Every folder same height — previews fill remaining space */ .body { - background: #e8e2d4; - border: 1px solid #c4b8a8; - border-top: none; - border-radius: 0 10px 10px 10px; - padding: 1.25rem 1.4rem 1.5rem; - box-shadow: 0 10px 32px rgba(0,0,0,0.25); + min-height: var(--folder-body-h); + height: var(--folder-body-h); + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--sans); + font-size: 0.88rem; + font-weight: 400; + line-height: 1.55; + background: linear-gradient(180deg, var(--manila) 0%, #e3dbc8 100%); + border: 1px solid var(--manila-edge); + border-top: 2px solid var(--manila-dark); + border-radius: 0; + padding: 0.85rem 1rem 0.95rem 1.15rem; + box-shadow: + inset 5px 0 0 var(--manila-dark), + inset 0 -1px 0 rgba(0,0,0,0.06); } -.f0 { top: var(--stack-stick); z-index: 1; } -.f0 .tab { top: var(--stack-stick); margin-left: calc(var(--tab-offset) * 0); background: #c9a86c; color: #2a2824; } +.body-copy { + flex-shrink: 0; + overflow: hidden; +} -.f1 { top: calc(var(--stack-stick) + var(--stack-step)); z-index: 2; } -.f1 .tab { top: calc(var(--stack-stick) + var(--stack-step)); margin-left: calc(var(--tab-offset) * 1); background: #a8c4d4; color: #1a2830; } +.body-copy h2 { + margin-bottom: 0.25rem; +} -.f2 { top: calc(var(--stack-stick) + var(--stack-step) * 2); z-index: 3; } -.f2 .tab { top: calc(var(--stack-stick) + var(--stack-step) * 2); margin-left: calc(var(--tab-offset) * 2); background: #b8d4a8; color: #1a2818; } +.body-copy p:last-child { + margin-bottom: 0.35rem; +} -.f3 { top: calc(var(--stack-stick) + var(--stack-step) * 3); z-index: 4; } -.f3 .tab { top: calc(var(--stack-stick) + var(--stack-step) * 3); margin-left: calc(var(--tab-offset) * 3); background: #d4b8c4; color: #2a1820; } +.body-fill { + flex: 1 1 auto; + min-height: 0; + pointer-events: none; +} -.f4 { top: calc(var(--stack-stick) + var(--stack-step) * 4); z-index: 5; } -.f4 .tab { top: calc(var(--stack-stick) + var(--stack-step) * 4); margin-left: calc(var(--tab-offset) * 4); background: #d4c8a8; color: #2a2418; } +/* Preview folders: text on top, screenshot fills the rest */ +.body--has-preview { + padding-bottom: 0.7rem; + overflow: hidden; +} -.f5 { top: calc(var(--stack-stick) + var(--stack-step) * 5); z-index: 6; } -.f5 .tab { top: calc(var(--stack-stick) + var(--stack-step) * 5); margin-left: calc(var(--tab-offset) * 5); background: #c4c4c4; color: #2a2a2a; } +.body--has-preview .body-copy { + max-height: 38%; + overflow: hidden; + overflow-y: hidden; +} -.f6 { top: calc(var(--stack-stick) + var(--stack-step) * 6); z-index: 7; margin-bottom: 4rem; } -.f6 .tab { top: calc(var(--stack-stick) + var(--stack-step) * 6); margin-left: calc(var(--tab-offset) * 6); background: #2a4a6b; color: #e8e2d4; } +.body--has-preview .body-copy p { + margin-bottom: 0.3rem; + font-size: 0.86rem; + line-height: 1.5; +} + +.body--has-preview .body-copy h1 { + font-size: 1.2rem; + margin-bottom: 0.2rem; +} + +.body--has-preview .body-copy h2 { + font-size: 1.05rem; + margin-bottom: 0.2rem; +} + +.body--has-preview .body-copy .cta-block { + margin-top: 0.35rem; +} + +.body--has-preview .terms-list { + margin-bottom: 0.25rem; + font-size: 0.95rem; +} + +.body--has-preview .terms-list li { + margin-bottom: 0.2rem; +} + +/* Screenshot preview — click opens site in new tab */ +a.site-preview { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + margin: 0.4rem 0 0; + border: 1px solid var(--manila-edge); + background: #f6f2e8; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.5); + text-decoration: none; + color: inherit; + cursor: pointer; + overflow: hidden; +} + +a.site-preview:hover { + border-color: #a89878; + filter: brightness(1.02); +} + +.site-preview-bar { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.45rem; + background: #ddd6c8; + border-bottom: 1px solid var(--manila-edge); + font-family: var(--mono); + font-size: 0.52rem; + color: var(--ink-muted); +} + +.site-preview-url { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.site-preview-go { + color: #2a4a6b; + font-weight: 600; +} + +.site-preview-frame { + display: flex; + flex: 1 1 0; + min-height: 11rem; + overflow: hidden; + background: #1a1814; +} + +.site-preview-img { + display: block; + width: 100%; + height: 100%; + min-height: 100%; + object-fit: cover; + object-position: top center; + background: #1a1814; +} + +.cta-block { + position: relative; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin: 0.55rem 0 0.35rem; +} + +.body .btn { + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 600; + line-height: 1.2; + letter-spacing: 0.01em; +} + +.body .btn--primary { + background: #2a4a6b; + color: #fff !important; + border: 1px solid #1e3854; +} + +.body .btn--primary:hover { + background: #1e3854; + color: #fff !important; +} + +.body .btn--ghost { + background: #f6f2e8; + color: #1c3a5c !important; + border: 1px solid #8a9ab0; +} + +.body .btn--ghost:hover { + background: #fff; + color: #2a4a6b !important; +} + +.service-deep-list { + margin: 0 0 0.5rem 0; + padding: 0; + list-style: none; + font-size: 1.05rem; + line-height: 1.4; + color: var(--ink); +} + +.service-deep-list > li { + margin-bottom: 0.55rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); +} + +.service-deep-list > li:last-child { + border-bottom: none; + margin-bottom: 0.35rem; +} + +.service-deep-list strong { + display: inline; + font-family: var(--sans); + font-size: inherit; + font-weight: 600; + color: var(--ink); +} + +.hi { + font-weight: 600; + color: var(--ink); +} + +.service-deep-list .xref { + display: block; + margin-top: 0.2rem; +} + +.section-lead { + font-size: 0.88rem; + color: var(--ink-muted); + margin-bottom: 0.55rem; +} + +/* L2 — dense copy, no internal scroll */ +.folder--services .body-copy { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + overflow-y: hidden; +} + +.folder--services .section-lead { + font-size: 0.86rem; + margin-bottom: 0.4rem; + line-height: 1.4; +} + +.folder--services .service-deep-list { + font-size: 0.8rem; + line-height: 1.35; +} + +.folder--services .service-deep-list > li { + margin-bottom: 0.35rem; + padding-bottom: 0.3rem; +} + +.folder--services .service-deep-list strong { + font-size: 0.88rem; + display: inline; + margin-right: 0.2rem; +} + +.folder--services .service-deep-list .xref { + display: inline; + margin-top: 0; + margin-left: 0.15rem; +} + +.folder--services .body-copy > p:last-child { + font-size: 0.78rem; + margin-bottom: 0; +} + +.body::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 28%; + height: 100%; + pointer-events: none; + background: linear-gradient(90deg, transparent, rgba(0,0,0,0.03)); +} + +/* Bodies pile by z-index; each tab sticks above its folder */ +.f0 .body { z-index: 1; } +.f1 .body { z-index: 2; } +.f2 .body { z-index: 3; } +.f3 .body { z-index: 4; } +.f4 .body { z-index: 5; } +.f5 .body { z-index: 6; } +.f6 .body { z-index: 7; } +.f7 .body { z-index: 8; } + +.f0 .tab { margin-left: calc(var(--tab-offset) * 0); z-index: 110; background: var(--l0-tab); color: var(--l0-tab-fg); } +.f1 .tab { margin-left: calc(var(--tab-offset) * 1); z-index: 111; background: var(--l1-tab); color: var(--l1-tab-fg); } +.f2 .tab { margin-left: calc(var(--tab-offset) * 2); z-index: 112; background: var(--l2-tab); color: var(--l2-tab-fg); } +.f3 .tab { margin-left: calc(var(--tab-offset) * 3); z-index: 113; background: var(--l3-tab); color: var(--l3-tab-fg); } +.f4 .tab { margin-left: calc(var(--tab-offset) * 4); z-index: 114; background: var(--l4-tab); color: var(--l4-tab-fg); } +.f5 .tab { margin-left: calc(var(--tab-offset) * 5); z-index: 115; background: var(--l5-tab); color: var(--l5-tab-fg); } +.f6 .tab { margin-left: calc(var(--tab-offset) * 6); z-index: 116; background: var(--l6-tab); color: var(--l6-tab-fg); } +.f7 .tab { margin-left: calc(var(--tab-offset) * 7); z-index: 117; background: var(--l7-tab); color: var(--l7-tab-fg); } + +.file-id { + font-family: var(--mono); + font-size: 0.58rem; + letter-spacing: 0.06em; + color: var(--ink-muted); + margin-bottom: 0.35rem; +} + +.body h1 { + font-family: var(--display); + font-size: 1.35rem; + font-weight: 600; + line-height: 1.25; + margin-bottom: 0.35rem; + color: var(--ink); +} + +.body h2 { + font-family: var(--display); + font-size: 1.05rem; + font-weight: 600; + margin-bottom: 0.4rem; + color: var(--ink); +} + +.lead, .body p, .clause { + font-size: 0.88rem; + color: var(--ink-muted); + line-height: 1.55; + margin-bottom: 0.45rem; +} + +.stack-note, .xref { + font-family: var(--mono); + font-size: 0.68rem; + color: var(--ink-muted); +} + +.body a:not(.btn) { color: #2a4a6b; font-weight: 600; } + +.meta-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + margin: 0.5rem 0 0.65rem; +} + +.meta-table th { + font-family: var(--mono); + font-size: 0.62rem; + font-weight: 500; + text-align: left; + color: var(--ink-muted); + padding: 0.2rem 0.65rem 0.2rem 0; + vertical-align: top; + width: 5.5rem; +} + +.meta-table td { padding: 0.2rem 0; color: var(--ink); } + +.badge { + display: inline-block; + font-family: var(--mono); + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.08em; + padding: 0.12rem 0.4rem; + border: 1px solid var(--ink); + color: var(--ink); +} + +.badge--avail { border-color: #3d6b3d; color: #2d5a2d; } + +.avail { margin-top: 0.35rem; font-size: 0.82rem; } + +.terms-list { + margin: 0 0 0.65rem 1rem; + font-size: 1.05rem; + color: var(--ink); + line-height: 1.45; +} + +.terms-list strong { + font-weight: 600; + color: var(--ink); +} + +.terms-list li { margin-bottom: 0.35rem; } + +.meta-table--contact { margin-top: 0.5rem; } -.body h1 { font-size: 1.65rem; margin-bottom: 0.35rem; } -.body h2 { font-size: 1.25rem; margin-bottom: 0.4rem; } -.body p { font-size: 0.92rem; color: #4a4844; line-height: 1.5; } -.body a { color: #2a4a6b; } -.avail { font-family: var(--mono); font-size: 0.68rem; color: #3d6b3d; margin-top: 0.5rem; } .btn { - font-family: var(--mono); font-size: 0.7rem; padding: 0.45rem 0.75rem; - background: #2a4a6b; color: #fff; text-decoration: none; border-radius: 4px; - margin-right: 0.4rem; display: inline-block; margin-top: 0.35rem; + padding: 0.5rem 0.85rem; + text-decoration: none; + border-radius: 0; + margin-right: 0.35rem; + display: inline-block; + margin-top: 0.3rem; } -.btn.ghost { background: transparent; color: #2a4a6b; border: 1px solid #2a4a6b; } -.foot { - display: flex; justify-content: space-between; - width: min(640px, 100%); margin: 0 auto; - padding: 0 1rem 2rem; font-family: var(--mono); font-size: 0.62rem; color: #6a6458; +/* Cal.com embed (needs embed allowlist on cal.levkin.ca for levkin.ca) */ +.cal-slot { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + margin: 0.4rem 0 0; + border: 1px solid var(--manila-edge); + background: #1a1814; + overflow: hidden; +} + +.cal-slot .site-preview-bar { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.45rem; + background: #ddd6c8; + border-bottom: 1px solid var(--manila-edge); + font-family: var(--mono); + font-size: 0.52rem; + color: var(--ink-muted); +} + +.cal-slot .site-preview-go { + color: #2a4a6b; + font-weight: 600; + text-decoration: none; +} + +.cal-embed-frame { + position: relative; + flex: 1 1 auto; + min-height: 11rem; + background: #0a0a0a; +} + +.cal-slot[data-cal-theme='light'] .cal-embed-frame { + background: #f5f3ee; +} + +.cal-inline { + width: 100%; + height: 100%; + min-height: 14rem; + overflow: auto; +} + +.cal-slot.is-blocked .cal-inline { + display: none; +} + +.cal-slot.is-embedded .cal-inline iframe { + display: block; + width: 100%; + min-height: 14rem; + border: 0; +} + +.cal-embed-fallback { + display: none; + position: absolute; + inset: 0; + text-decoration: none; + color: inherit; +} + +.cal-slot.is-blocked .cal-embed-fallback { + display: flex; + flex-direction: column; +} + +.cal-embed-fallback img { + flex: 1; + width: 100%; + object-fit: cover; + object-position: top center; + /* Crop Cal.com branding bar at bottom of screenshot */ + max-height: calc(100% + 2rem); + margin-bottom: -2rem; +} + +.cal-embed-fallback-label { + font-family: var(--mono); + font-size: 0.55rem; + text-align: center; + padding: 0.35rem; + background: rgba(0, 0, 0, 0.75); + color: #c4b8a8; +} + +.service-note { + font-size: 0.78rem; + line-height: 1.4; + color: var(--ink-muted); + margin-bottom: 0.28rem; +} + +.folder--services .service-deep-list > li { + margin-bottom: 0.28rem; + padding-bottom: 0.22rem; +} + +.folder--services .body-copy > p:last-child { + margin-bottom: 0; } -.foot a { color: #6a6458; text-decoration: none; } @media (max-width: 720px) { .tab-rail { display: none; } - :root { --stack-step: 1.75rem; --tab-offset: 1.4rem; --stack-card-min: 48vh; } - .f5 .tab { margin-left: calc(var(--tab-offset) * 4); } - .f6 .tab { margin-left: calc(var(--tab-offset) * 3); } + .mount { padding-right: 1rem; } + :root { --tab-offset: 1.75rem; } } + diff --git a/stack-folder/index.html b/stack-folder/index.html index 3fab136..892bb42 100644 --- a/stack-folder/index.html +++ b/stack-folder/index.html @@ -3,21 +3,21 @@ - Levkin — Stack Folder + Levkin — Company Files + - + + + - + -
+
@@ -25,74 +25,161 @@ +
- -
-

Levkin

-

Software development · Canada · remote

-

Taking new engagements · 15+ yrs · 8h→2m

+ +
+
+

LK-SPEC-1.0 · Cover sheet

+

Levkin Software Development Company

+

Custom applications, automation, and practice systems — scoped, documented, and built to run in production.

+

AVAILABLE Taking on new engagements · remote NA & EU

+ +
+
+
cal.levkin.ca · book a call
+
+
+ + + +
+
- -
-

Custom software

-

Web apps, APIs, tools — TypeScript · Python · .NET · PostgreSQL

+ +
+
+

Scope

+

Boutique practice — not a staffing agency. Clear deliverable and handoff so your team can own what ships.

+
+ + levkin.ca / spec + +
-
- +
+
-

Automation

-

n8n · Zapier · CI/CD · LLMs · auto.levkin.ca

+
+

Services

+

Primary lines — each engagement gets a statement of work after discovery.

+
    +
  • Custom software — Web apps, APIs, and internal tools; fit the problem, not a preset stack.
  • +
  • Automation — Reporting, notifications, data sync, ops in the background. auto.levkin.ca
  • +
  • CaseWare — Templates, releases, practice customization, pipeline work. caseware.levkin.ca
  • +
  • Quality & testing — Test strategy, automation, release confidence. iliadobkin.com
  • +
+

How we work: fixed scope · production-ready · handoff-friendly · right-sized solutions.

+

Discovery call → written proposal → delivery with docs → optional support.

+
+
- -
-

CaseWare & CaseView

-

15+ years · CaseWare Intl, MNP, JazzIt · caseware.levkin.ca

+ +
+
+

Automation

+

Workflows that run reliably in the background — reporting, sync, ops off your plate.

+
+ + auto.levkin.ca + +
- -
-

Quality engineering

-

Senior SDET · iliadobkin.com

+ +
+
+

CaseWare

+

Templates, releases, and practice customization — from small fixes to full pipeline overhauls.

+
+ + caseware.levkin.ca + +
- -
-

Job Ops

-

Internal hiring orchestrator · jobs.levkin.ca

+ +
+
+

Quality & testing

+

Test strategy, automation, and release confidence without hiring full-time.

+
+ + iliadobkin.com + +
-
-
+
+ +
+
+

Terms & contact

+
    +
  • Fixed scope — quoted after discovery; no open-ended hourly surprises.
  • +
  • Production-ready — monitoring, failure handling, documentation included.
  • +
  • Handoff-friendly — your team or the next vendor can maintain what we ship.
  • +
+
+
+
cal.levkin.ca · book a call
+
+
+ + + +
+
+
+
- + + diff --git a/stack-folder/previews/auto.png b/stack-folder/previews/auto.png new file mode 100644 index 0000000..e7145f3 Binary files /dev/null and b/stack-folder/previews/auto.png differ diff --git a/stack-folder/previews/cal-dark.png b/stack-folder/previews/cal-dark.png new file mode 100644 index 0000000..a6a5253 Binary files /dev/null and b/stack-folder/previews/cal-dark.png differ diff --git a/stack-folder/previews/cal-light.png b/stack-folder/previews/cal-light.png new file mode 100644 index 0000000..02c5dc3 Binary files /dev/null and b/stack-folder/previews/cal-light.png differ diff --git a/stack-folder/previews/cal.png b/stack-folder/previews/cal.png new file mode 100644 index 0000000..7d4de20 Binary files /dev/null and b/stack-folder/previews/cal.png differ diff --git a/stack-folder/previews/caseware.png b/stack-folder/previews/caseware.png new file mode 100644 index 0000000..d1c18f3 Binary files /dev/null and b/stack-folder/previews/caseware.png differ diff --git a/stack-folder/previews/git-repos.png b/stack-folder/previews/git-repos.png new file mode 100644 index 0000000..f070c78 Binary files /dev/null and b/stack-folder/previews/git-repos.png differ diff --git a/stack-folder/previews/git.png b/stack-folder/previews/git.png new file mode 100644 index 0000000..663e6f3 Binary files /dev/null and b/stack-folder/previews/git.png differ diff --git a/stack-folder/previews/iliadobkin.png b/stack-folder/previews/iliadobkin.png new file mode 100644 index 0000000..3a06934 Binary files /dev/null and b/stack-folder/previews/iliadobkin.png differ diff --git a/stack-folder/previews/jobs.png b/stack-folder/previews/jobs.png new file mode 100644 index 0000000..abed536 Binary files /dev/null and b/stack-folder/previews/jobs.png differ diff --git a/stack-folder/previews/spec.png b/stack-folder/previews/spec.png new file mode 100644 index 0000000..245fb1b Binary files /dev/null and b/stack-folder/previews/spec.png differ diff --git a/stack-folder/previews/stack.png b/stack-folder/previews/stack.png new file mode 100644 index 0000000..c8246df Binary files /dev/null and b/stack-folder/previews/stack.png differ diff --git a/stack/index.html b/stack/index.html index 8df7548..0dea7ac 100644 --- a/stack/index.html +++ b/stack/index.html @@ -4,7 +4,7 @@ Levkin — Stack - + @@ -14,72 +14,70 @@
-
foundation
-

Levkin

-

Software · Canada · remote

-

Boutique engineering — production systems, automation, enterprise.

-
15+ yrs8h→2m24/7
-

Taking new engagements

+
company
+

Levkin Inc.

+

LK-SPEC-1.0 · Software development · Canada

+

Builds and maintains production software — custom apps, automation, practice systems. Scoped, documented, handoff-ready.

+

ACTIVE · Taking new engagements · remote NA & EU

-
application
-

Custom software

-

Web apps, APIs, internal tools — TS · Python · .NET

+
scope
+

1. Scope

+

Boutique practice — clear start, deliverable, and handoff. Discovery → fixed proposal → delivery → optional support.

-
automationauto.levkin.ca ↗
-

Automation

-

n8n · Zapier · CI/CD · webhooks · LLMs

+
application
+

2.1 Custom software

+

Web apps, APIs, internal tools — chosen to fit the problem, not a preset stack.

-
enterprisecaseware.levkin.ca ↗
-

CaseWare & CaseView

-

15+ years · CaseWare Intl, MNP, JazzIt

+
automationauto.levkin.ca ↗
+

2.2 Automation

+

Background workflows — reporting, notifications, data sync, ops off your plate.

-
qualityiliadobkin.com ↗
-

Quality engineering

-

Senior SDET · test automation · CI/CD · trace-driven QA

+
enterprisecaseware.levkin.ca ↗
+

2.3 CaseWare

+

Templates, releases, practice customization — small fixes to full pipeline overhauls.

-
operationsjobs.levkin.ca ↗
-

Job Ops

-

Internal hiring orchestrator (auth required)

+
qualityiliadobkin.com ↗
+

2.4 Quality & testing

+

Test strategy, automation, release confidence — experienced QA lead without full-time hire.

-
interface
-

Engage

-

Discover → Proposal → Ship → Maintain

+
contact
+

Terms & contact

+

Fixed scope · production-ready · handoff-friendly · right-sized.

-

Retries · docs · tests first

diff --git a/stack/stack.css b/stack/stack.css index 9eb614e..df68aaf 100644 --- a/stack/stack.css +++ b/stack/stack.css @@ -36,14 +36,23 @@ body { margin: 0 auto; } -/* Each card sticks; next scrolls over previous */ +/* Compact sticky card — click brings to front */ +.layer:not(.layer-6) { + margin-bottom: var(--stack-scroll-slot); +} + .layer { position: sticky; - min-height: var(--stack-card-min); - margin-bottom: 1.25rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 12px 36px rgba(0,0,0,0.5); + cursor: pointer; + transition: box-shadow 0.2s; +} + +.layer.is-front { + z-index: 100 !important; + box-shadow: 0 16px 48px rgba(0,0,0,0.65); } .layer-0 { top: var(--stack-stick); z-index: 1; background: #1c1c20; } @@ -52,9 +61,9 @@ body { .layer-3 { top: calc(var(--stack-stick) + var(--stack-step) * 3); z-index: 4; background: #343440; } .layer-4 { top: calc(var(--stack-stick) + var(--stack-step) * 4); z-index: 5; background: #3c3c4a; } .layer-5 { top: calc(var(--stack-stick) + var(--stack-step) * 5); z-index: 6; background: #444454; } -.layer-6 { top: calc(var(--stack-stick) + var(--stack-step) * 6); z-index: 7; background: #4c4c5e; margin-bottom: 4rem; } +.layer-6 { top: calc(var(--stack-stick) + var(--stack-step) * 6); z-index: 7; background: #4c4c5e; margin-bottom: 0; } -.layer-inner { padding: 1.2rem 1.35rem 1.5rem; } +.layer-inner { padding: 0.95rem 1.15rem 1.05rem; } .layer-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.65rem; @@ -70,8 +79,8 @@ body { .layer-id:hover { text-decoration: underline; } .layer-name { color: #6b6966; text-transform: uppercase; letter-spacing: 0.1em; } .layer-link { margin-left: auto; color: #8b9cb3; text-decoration: none; font-size: 0.58rem; } -.layer h1 { font-size: 2rem; font-weight: 600; letter-spacing: -0.03em; } -.layer h2 { font-size: 1.2rem; font-weight: 600; margin-bottom: 0.35rem; } +.layer h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.03em; } +.layer h2 { font-size: 1.05rem; font-weight: 600; margin-bottom: 0.3rem; } .tagline { font-family: var(--mono); font-size: 0.65rem; color: #6b6966; margin-bottom: 0.4rem; } .layer-copy { font-size: 0.9rem; color: #a8a6a1; } .chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.5rem 0; } @@ -105,6 +114,6 @@ body { .stack-ruler button.active { color: #c4a574; } @media (max-width: 700px) { - :root { --stack-step: 1.75rem; --stack-card-min: 48vh; } + :root { --stack-step: 1.5rem; } .variants { display: none; } } diff --git a/vite.config.js b/vite.config.js index 16969da..0d0a149 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,13 +7,8 @@ export default defineConfig({ input: { main: resolve(__dirname, 'index.html'), spec: resolve(__dirname, 'spec/index.html'), - slab: resolve(__dirname, 'slab/index.html'), - relay: resolve(__dirname, 'relay/index.html'), - vault: resolve(__dirname, 'vault/index.html'), stack: resolve(__dirname, 'stack/index.html'), stackFolder: resolve(__dirname, 'stack-folder/index.html'), - stackTrace: resolve(__dirname, 'stack-trace/index.html'), - stackRack: resolve(__dirname, 'stack-rack/index.html'), }, }, },