Rebuild stack-folder with sticky tab rail and site previews.
L0–L7 folders stack on scroll with aligned max depth, labeled tabs that stay consistent when L7 joins the rail, Cal embeds, preview screenshots, Playwright tests, and updated README. Co-authored-by: Cursor <cursoragent@cursor.com>
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
.scroll-debug/
|
||||
|
||||
63
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
|
||||
|
||||
175
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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<p class="eyebrow">levkin.ca · round 3</p>
|
||||
<h1>Eight directions.</h1>
|
||||
<p class="lead">Five brand concepts + four stack variants (L0–L6, stops on time). Spec updated with iliadobkin.com.</p>
|
||||
<p class="eyebrow">levkin.ca</p>
|
||||
<h1>Three directions.</h1>
|
||||
<p class="lead">Spec for the company story. Cards and Folder for the L0–L6 scroll stack — click a layer to bring it to the front.</p>
|
||||
</header>
|
||||
|
||||
<p class="section-label">Brand</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="/spec/">
|
||||
<div class="preview preview--spec">GET /company → 200</div>
|
||||
<div class="card-body">
|
||||
<h2>Spec <span class="kept">kept</span></h2>
|
||||
<p>Levkin as an RFC. Endpoints, schemas, required properties. Precise and documentation-native.</p>
|
||||
<span class="tag">paper · RFC · precise</span>
|
||||
<h2>Spec</h2>
|
||||
<p>Levkin as an RFC. Endpoints, schemas, required properties.</p>
|
||||
<span class="tag">paper · RFC</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="/slab/">
|
||||
<div class="preview preview--slab">LEV</div>
|
||||
<div class="card-body">
|
||||
<h2>Slab</h2>
|
||||
<p>Brutalist poster. Massive type, hard edges, zero decoration. Confidence without explaining itself.</p>
|
||||
<span class="tag">brutalist · bold · minimal</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="/relay/">
|
||||
<div class="preview preview--relay">RELAY</div>
|
||||
<div class="card-body">
|
||||
<h2>Relay</h2>
|
||||
<p>Telegraph and signal chain. Messages arrive, get decoded. Communication as craft.</p>
|
||||
<span class="tag">vintage · interactive · warm</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="/vault/">
|
||||
<div class="preview preview--vault">Secured</div>
|
||||
<div class="card-body">
|
||||
<h2>Vault</h2>
|
||||
<p>Institutional trust. Deep green, brass accents. For enterprise clients who need gravitas.</p>
|
||||
<span class="tag">trust · enterprise · calm</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="section-label">Stack variants (scroll test)</p>
|
||||
<div class="grid grid-stacks">
|
||||
<a class="card" href="/stack/">
|
||||
<div class="preview preview--stack">
|
||||
<span class="card-layer"></span><span class="card-layer"></span><span class="card-layer"></span><span class="card-layer"></span><span class="card-layer"></span>
|
||||
<span class="card-layer"></span><span class="card-layer"></span><span class="card-layer"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h2>Stack · Cards</h2>
|
||||
<p>L0–L6 overlapping sticky cards. Scroll stops at L6.</p>
|
||||
<span class="tag">default · dark · technical</span>
|
||||
<h2>Cards</h2>
|
||||
<p>Dark sticky stack. Scroll or click L0–L6 to focus a layer.</p>
|
||||
<span class="tag">stack · scroll</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="/stack-folder/">
|
||||
<div class="preview preview--folder">L0│tab</div>
|
||||
<div class="preview preview--folder">L0│ L1│ L2│</div>
|
||||
<div class="card-body">
|
||||
<h2>Stack · Folder</h2>
|
||||
<p>Tabs on top, staggered left — all readable. Click tab or L0–L6 rail to jump.</p>
|
||||
<span class="tag">folder · tabs · office</span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="/stack-rack/">
|
||||
<div class="preview preview--rack">U0▮U1▮</div>
|
||||
<div class="card-body">
|
||||
<h2>Stack · Rack</h2>
|
||||
<p>Server rack 1U units — LEDs, handles, infra stack.</p>
|
||||
<span class="tag">rack · infra · LEDs</span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="/stack-trace/">
|
||||
<div class="preview preview--trace">at Levkin.*</div>
|
||||
<div class="card-body">
|
||||
<h2>Stack · Trace</h2>
|
||||
<p>Call-stack frames — iliadobkin.com as quality frame.</p>
|
||||
<span class="tag">trace · devtools · mono</span>
|
||||
<h2>Folder</h2>
|
||||
<p>Spec as a filing cabinet — manila tabs, clause sections, pull a file forward.</p>
|
||||
<span class="tag">folder · spec</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -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",
|
||||
|
||||
82
scripts/capture-previews.mjs
Normal file
@ -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();
|
||||
64
scripts/test-stack-folder.mjs
Normal file
@ -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');
|
||||
118
scripts/test-stack-scroll.mjs
Normal file
@ -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);
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
184
stack-folder/folder-cal.js
Normal file
@ -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 + ↗ */
|
||||
});
|
||||
}
|
||||
32
stack-folder/folder-rail.js
Normal file
@ -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);
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Levkin — Stack Folder</title>
|
||||
<title>Levkin — Company Files</title>
|
||||
<meta name="description" content="Levkin Inc. — company specification, filed by section." />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&family=Literata:opsz,wght@7..72,400;7..72,600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="./folder.css" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a href="/">← options</a>
|
||||
<a href="/stack/">cards</a>
|
||||
<a href="/stack-trace/">trace</a>
|
||||
<a href="/stack-rack/">rack</a>
|
||||
<span class="depth" id="depth">L0</span>
|
||||
</nav>
|
||||
<header class="site-header desk-footer">
|
||||
Levkin Inc. · production software, automation, and handoff-ready systems ·
|
||||
<a href="https://cal.levkin.ca/ilia/consult" target="_blank" rel="noopener noreferrer">book discovery call</a>
|
||||
</header>
|
||||
|
||||
<div class="tab-rail" aria-label="Jump to layer">
|
||||
<div class="tab-rail" id="tab-rail" aria-label="Layers L0–L7">
|
||||
<button type="button" class="rail-tab active" data-goto="0" data-layer="0">L0</button>
|
||||
<button type="button" class="rail-tab" data-goto="1" data-layer="1">L1</button>
|
||||
<button type="button" class="rail-tab" data-goto="2" data-layer="2">L2</button>
|
||||
@ -25,74 +25,161 @@
|
||||
<button type="button" class="rail-tab" data-goto="4" data-layer="4">L4</button>
|
||||
<button type="button" class="rail-tab" data-goto="5" data-layer="5">L5</button>
|
||||
<button type="button" class="rail-tab" data-goto="6" data-layer="6">L6</button>
|
||||
<button type="button" class="rail-tab" data-goto="7" data-layer="7">L7</button>
|
||||
</div>
|
||||
|
||||
<main class="mount">
|
||||
<article class="folder f0" data-layer="0" id="layer-0">
|
||||
<button type="button" class="tab" data-goto="0">L0 · foundation</button>
|
||||
<div class="body">
|
||||
<h1>Levkin</h1>
|
||||
<p>Software development · Canada · remote</p>
|
||||
<p class="avail">Taking new engagements · 15+ yrs · 8h→2m</p>
|
||||
<button type="button" class="tab" data-goto="0"><span class="tab-code">L0</span><span class="tab-label"> · Company</span></button>
|
||||
<div class="body body--has-preview">
|
||||
<div class="body-copy">
|
||||
<p class="file-id">LK-SPEC-1.0 · Cover sheet</p>
|
||||
<h1>Levkin Software Development Company</h1>
|
||||
<p class="lead">Custom applications, automation, and practice systems — <span class="hi">scoped</span>, <span class="hi">documented</span>, and built to run in <span class="hi">production</span>.</p>
|
||||
<p class="avail"><span class="badge badge--avail">AVAILABLE</span> <span class="hi">Taking on new engagements</span> · remote NA & EU</p>
|
||||
<div class="cta-block">
|
||||
<a class="btn btn--primary" href="https://cal.levkin.ca/ilia/consult" target="_blank" rel="noopener noreferrer">Book 15-minute discovery call</a>
|
||||
<a class="btn btn--ghost" href="mailto:ilia@levkine.ca?subject=Project%20enquiry">ilia@levkine.ca</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-slot is-blocked" data-cal-embed data-cal-theme="dark" data-cal-target="cal-inline-l0">
|
||||
<div class="site-preview-bar"><span class="site-preview-url">cal.levkin.ca · book a call</span><a class="site-preview-go" href="https://cal.levkin.ca/ilia/consult" target="_blank" rel="noopener noreferrer" aria-label="Open calendar in new tab">↗</a></div>
|
||||
<div class="cal-embed-frame">
|
||||
<div class="cal-inline" id="cal-inline-l0"></div>
|
||||
<a class="cal-embed-fallback" href="https://cal.levkin.ca/ilia/consult" target="_blank" rel="noopener noreferrer">
|
||||
<img src="./previews/cal-dark.png" alt="" decoding="async" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="folder f1" data-layer="1" id="layer-1">
|
||||
<button type="button" class="tab" data-goto="1">L1 · application</button>
|
||||
<div class="body">
|
||||
<h2>Custom software</h2>
|
||||
<p>Web apps, APIs, tools — TypeScript · Python · .NET · PostgreSQL</p>
|
||||
<button type="button" class="tab" data-goto="1"><span class="tab-code">L1</span><span class="tab-label"> · Scope</span></button>
|
||||
<div class="body body--has-preview">
|
||||
<div class="body-copy">
|
||||
<h2>Scope</h2>
|
||||
<p><span class="hi">Boutique practice</span> — not a staffing agency. Clear deliverable and handoff so your team can own what ships.</p>
|
||||
</div>
|
||||
<a class="site-preview" href="/spec/" target="_blank" rel="noopener noreferrer">
|
||||
<span class="site-preview-bar"><span class="site-preview-url">levkin.ca / spec</span><span class="site-preview-go" aria-hidden="true">↗</span></span>
|
||||
<span class="site-preview-frame"><img class="site-preview-img" src="./previews/spec.png" alt="" loading="lazy" decoding="async" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="folder f2" data-layer="2" id="layer-2">
|
||||
<button type="button" class="tab" data-goto="2">L2 · automation</button>
|
||||
<article class="folder f2 folder--services" data-layer="2" id="layer-2">
|
||||
<button type="button" class="tab" data-goto="2"><span class="tab-code">L2</span><span class="tab-label"> · Services</span></button>
|
||||
<div class="body">
|
||||
<h2>Automation</h2>
|
||||
<p>n8n · Zapier · CI/CD · LLMs · <a href="https://auto.levkin.ca">auto.levkin.ca</a></p>
|
||||
<div class="body-copy">
|
||||
<h2>Services</h2>
|
||||
<p class="section-lead">Primary lines — each engagement gets a <span class="hi">statement of work</span> after discovery.</p>
|
||||
<ul class="service-deep-list">
|
||||
<li><strong>Custom software</strong> — Web apps, APIs, and internal tools; fit the problem, not a preset stack.</li>
|
||||
<li><strong>Automation</strong> — Reporting, notifications, data sync, ops in the background. <span class="xref"><a href="https://auto.levkin.ca" target="_blank" rel="noopener noreferrer">auto.levkin.ca</a></span></li>
|
||||
<li><strong>CaseWare</strong> — Templates, releases, practice customization, pipeline work. <span class="xref"><a href="https://caseware.levkin.ca" target="_blank" rel="noopener noreferrer">caseware.levkin.ca</a></span></li>
|
||||
<li><strong>Quality & testing</strong> — Test strategy, automation, release confidence. <span class="xref"><a href="https://iliadobkin.com" target="_blank" rel="noopener noreferrer">iliadobkin.com</a></span></li>
|
||||
</ul>
|
||||
<p class="service-note"><strong>How we work:</strong> fixed scope · production-ready · handoff-friendly · right-sized solutions.</p>
|
||||
<p class="service-note">Discovery call → written proposal → delivery with docs → optional support.</p>
|
||||
</div>
|
||||
<div class="body-fill" aria-hidden="true"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="folder f3" data-layer="3" id="layer-3">
|
||||
<button type="button" class="tab" data-goto="3">L3 · enterprise</button>
|
||||
<div class="body">
|
||||
<h2>CaseWare & CaseView</h2>
|
||||
<p>15+ years · CaseWare Intl, MNP, JazzIt · <a href="https://caseware.levkin.ca">caseware.levkin.ca</a></p>
|
||||
<button type="button" class="tab" data-goto="3"><span class="tab-code">L3</span><span class="tab-label"> · Automation</span></button>
|
||||
<div class="body body--has-preview">
|
||||
<div class="body-copy">
|
||||
<h2>Automation</h2>
|
||||
<p>Workflows that run <span class="hi">reliably in the background</span> — reporting, sync, ops off your plate.</p>
|
||||
</div>
|
||||
<a class="site-preview" href="https://auto.levkin.ca" target="_blank" rel="noopener noreferrer">
|
||||
<span class="site-preview-bar"><span class="site-preview-url">auto.levkin.ca</span><span class="site-preview-go" aria-hidden="true">↗</span></span>
|
||||
<span class="site-preview-frame"><img class="site-preview-img" src="./previews/auto.png" alt="" loading="lazy" decoding="async" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="folder f4" data-layer="4" id="layer-4">
|
||||
<button type="button" class="tab" data-goto="4">L4 · quality</button>
|
||||
<div class="body">
|
||||
<h2>Quality engineering</h2>
|
||||
<p>Senior SDET · <a href="https://iliadobkin.com">iliadobkin.com</a></p>
|
||||
<button type="button" class="tab" data-goto="4"><span class="tab-code">L4</span><span class="tab-label"> · Enterprise</span></button>
|
||||
<div class="body body--has-preview">
|
||||
<div class="body-copy">
|
||||
<h2>CaseWare</h2>
|
||||
<p>Templates, releases, and practice customization — from small fixes to full pipeline overhauls.</p>
|
||||
</div>
|
||||
<a class="site-preview" href="https://caseware.levkin.ca" target="_blank" rel="noopener noreferrer">
|
||||
<span class="site-preview-bar"><span class="site-preview-url">caseware.levkin.ca</span><span class="site-preview-go" aria-hidden="true">↗</span></span>
|
||||
<span class="site-preview-frame"><img class="site-preview-img" src="./previews/caseware.png" alt="" loading="lazy" decoding="async" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="folder f5" data-layer="5" id="layer-5">
|
||||
<button type="button" class="tab" data-goto="5">L5 · operations</button>
|
||||
<div class="body">
|
||||
<h2>Job Ops</h2>
|
||||
<p>Internal hiring orchestrator · <a href="https://jobs.levkin.ca">jobs.levkin.ca</a></p>
|
||||
<button type="button" class="tab" data-goto="5"><span class="tab-code">L5</span><span class="tab-label"> · Portfolio</span></button>
|
||||
<div class="body body--has-preview">
|
||||
<div class="body-copy">
|
||||
<h2>Quality & testing</h2>
|
||||
<p>Test strategy, automation, and release confidence without hiring full-time.</p>
|
||||
</div>
|
||||
<a class="site-preview" href="https://iliadobkin.com" target="_blank" rel="noopener noreferrer">
|
||||
<span class="site-preview-bar"><span class="site-preview-url">iliadobkin.com</span><span class="site-preview-go" aria-hidden="true">↗</span></span>
|
||||
<span class="site-preview-frame"><img class="site-preview-img" src="./previews/iliadobkin.png" alt="" loading="lazy" decoding="async" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="folder f6" data-layer="6" id="layer-6">
|
||||
<button type="button" class="tab" data-goto="6">L6 · interface</button>
|
||||
<div class="body">
|
||||
<h2>Engage</h2>
|
||||
<p>Discover → Proposal → Ship → Maintain</p>
|
||||
<p><a class="btn" href="https://cal.levkin.ca/ilia/consult">Book 15 min</a> <a class="btn ghost" href="mailto:hello@levkine.ca">hello@levkine.ca</a></p>
|
||||
<button type="button" class="tab" data-goto="6"><span class="tab-code">L6</span><span class="tab-label"> · Source</span></button>
|
||||
<div class="body body--has-preview">
|
||||
<div class="body-copy">
|
||||
<h2>Source & repos</h2>
|
||||
<p>Project code and templates on our Gitea — private by default, yours on handoff.</p>
|
||||
</div>
|
||||
<a class="site-preview" href="https://git.levkin.ca/explore/repos" target="_blank" rel="noopener noreferrer">
|
||||
<span class="site-preview-bar"><span class="site-preview-url">git.levkin.ca / explore</span><span class="site-preview-go" aria-hidden="true">↗</span></span>
|
||||
<span class="site-preview-frame"><img class="site-preview-img" src="./previews/git-repos.png" alt="" loading="lazy" decoding="async" /></span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="stack-stop"></div>
|
||||
</main>
|
||||
<article class="folder f7 folder--last" data-layer="7" id="layer-7">
|
||||
<button type="button" class="tab" data-goto="7"><span class="tab-code">L7</span><span class="tab-label"> · Engage</span></button>
|
||||
<div class="body body--has-preview">
|
||||
<div class="body-copy">
|
||||
<h2>Terms & contact</h2>
|
||||
<ul class="terms-list">
|
||||
<li><strong>Fixed scope</strong> — quoted after discovery; no open-ended hourly surprises.</li>
|
||||
<li><strong>Production-ready</strong> — monitoring, failure handling, documentation included.</li>
|
||||
<li><strong>Handoff-friendly</strong> — your team or the next vendor can maintain what we ship.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cal-slot is-blocked" data-cal-embed data-cal-theme="light" data-cal-target="cal-inline-l7">
|
||||
<div class="site-preview-bar"><span class="site-preview-url">cal.levkin.ca · book a call</span><a class="site-preview-go" href="https://cal.levkin.ca/ilia/consult" target="_blank" rel="noopener noreferrer" aria-label="Open calendar in new tab">↗</a></div>
|
||||
<div class="cal-embed-frame">
|
||||
<div class="cal-inline" id="cal-inline-l7"></div>
|
||||
<a class="cal-embed-fallback" href="https://cal.levkin.ca/ilia/consult" target="_blank" rel="noopener noreferrer">
|
||||
<img src="./previews/cal-light.png" alt="" decoding="async" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<footer class="foot"><a href="https://git.levkin.ca">git.levkin.ca</a><span>levkin.ca</span></footer>
|
||||
<div class="stack-stop" aria-hidden="true"></div>
|
||||
</main>
|
||||
<script type="module">
|
||||
import { initStackScroll } from '../shared/stack-scroll.js';
|
||||
initStackScroll();
|
||||
import { initRailRoller } from './folder-rail.js';
|
||||
import { initCalEmbeds } from './folder-cal.js';
|
||||
initStackScroll({
|
||||
sectionSelector: '.folder[data-layer]',
|
||||
mountSelector: '.mount',
|
||||
foldTabs: true,
|
||||
interactionMode: 'navigate',
|
||||
});
|
||||
initRailRoller();
|
||||
initCalEmbeds();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
stack-folder/previews/auto.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
stack-folder/previews/cal-dark.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
stack-folder/previews/cal-light.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
stack-folder/previews/cal.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
stack-folder/previews/caseware.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
stack-folder/previews/git-repos.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
stack-folder/previews/git.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
stack-folder/previews/iliadobkin.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
stack-folder/previews/jobs.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
stack-folder/previews/spec.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
stack-folder/previews/stack.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Levkin — Stack</title>
|
||||
<meta name="description" content="Levkin — software development, layer by layer." />
|
||||
<meta name="description" content="Levkin — company specification by layer." />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
@ -14,72 +14,70 @@
|
||||
<body>
|
||||
<nav class="nav">
|
||||
<a href="/">← options</a>
|
||||
<span class="variants"><a href="/stack-folder/">folder</a> · <a href="/stack-trace/">trace</a> · <a href="/stack-rack/">rack</a></span>
|
||||
<span class="variants"><a href="/spec/">spec</a> · <a href="/stack-folder/">folder</a></span>
|
||||
<span class="depth" id="depth">L0</span>
|
||||
</nav>
|
||||
|
||||
<main class="stack-mount">
|
||||
<section class="layer layer-0" data-layer="0">
|
||||
<div class="layer-inner">
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="0">L0</button><span class="layer-name">foundation</span></header>
|
||||
<h1>Levkin</h1>
|
||||
<p class="tagline">Software · Canada · remote</p>
|
||||
<p class="layer-copy">Boutique engineering — production systems, automation, enterprise.</p>
|
||||
<div class="chips"><span>15+ yrs</span><span>8h→2m</span><span>24/7</span></div>
|
||||
<p class="avail">Taking new engagements</p>
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="0">L0</button><span class="layer-name">company</span></header>
|
||||
<h1>Levkin Inc.</h1>
|
||||
<p class="tagline">LK-SPEC-1.0 · Software development · Canada</p>
|
||||
<p class="layer-copy">Builds and maintains production software — custom apps, automation, practice systems. Scoped, documented, handoff-ready.</p>
|
||||
<p class="avail">ACTIVE · Taking new engagements · remote NA & EU</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layer layer-1" data-layer="1">
|
||||
<div class="layer-inner">
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="1">L1</button><span class="layer-name">application</span></header>
|
||||
<h2>Custom software</h2>
|
||||
<p class="layer-copy">Web apps, APIs, internal tools — TS · Python · .NET</p>
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="1">L1</button><span class="layer-name">scope</span></header>
|
||||
<h2>1. Scope</h2>
|
||||
<p class="layer-copy">Boutique practice — clear start, deliverable, and handoff. Discovery → fixed proposal → delivery → optional support.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layer layer-2" data-layer="2">
|
||||
<div class="layer-inner">
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="2">L2</button><span class="layer-name">automation</span><a href="https://auto.levkin.ca" class="layer-link">auto.levkin.ca ↗</a></header>
|
||||
<h2>Automation</h2>
|
||||
<p class="layer-copy">n8n · Zapier · CI/CD · webhooks · LLMs</p>
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="2">L2</button><span class="layer-name">application</span></header>
|
||||
<h2>2.1 Custom software</h2>
|
||||
<p class="layer-copy">Web apps, APIs, internal tools — chosen to fit the problem, not a preset stack.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layer layer-3" data-layer="3">
|
||||
<div class="layer-inner">
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="3">L3</button><span class="layer-name">enterprise</span><a href="https://caseware.levkin.ca" class="layer-link">caseware.levkin.ca ↗</a></header>
|
||||
<h2>CaseWare & CaseView</h2>
|
||||
<p class="layer-copy">15+ years · CaseWare Intl, MNP, JazzIt</p>
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="3">L3</button><span class="layer-name">automation</span><a href="https://auto.levkin.ca" class="layer-link">auto.levkin.ca ↗</a></header>
|
||||
<h2>2.2 Automation</h2>
|
||||
<p class="layer-copy">Background workflows — reporting, notifications, data sync, ops off your plate.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layer layer-4" data-layer="4">
|
||||
<div class="layer-inner">
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="4">L4</button><span class="layer-name">quality</span><a href="https://iliadobkin.com" class="layer-link">iliadobkin.com ↗</a></header>
|
||||
<h2>Quality engineering</h2>
|
||||
<p class="layer-copy">Senior SDET · test automation · CI/CD · trace-driven QA</p>
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="4">L4</button><span class="layer-name">enterprise</span><a href="https://caseware.levkin.ca" class="layer-link">caseware.levkin.ca ↗</a></header>
|
||||
<h2>2.3 CaseWare</h2>
|
||||
<p class="layer-copy">Templates, releases, practice customization — small fixes to full pipeline overhauls.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layer layer-5" data-layer="5">
|
||||
<div class="layer-inner">
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="5">L5</button><span class="layer-name">operations</span><a href="https://jobs.levkin.ca" class="layer-link">jobs.levkin.ca ↗</a></header>
|
||||
<h2>Job Ops</h2>
|
||||
<p class="layer-copy">Internal hiring orchestrator (auth required)</p>
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="5">L5</button><span class="layer-name">quality</span><a href="https://iliadobkin.com" class="layer-link">iliadobkin.com ↗</a></header>
|
||||
<h2>2.4 Quality & testing</h2>
|
||||
<p class="layer-copy">Test strategy, automation, release confidence — experienced QA lead without full-time hire.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layer layer-6" data-layer="6">
|
||||
<div class="layer-inner">
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="6">L6</button><span class="layer-name">interface</span></header>
|
||||
<h2>Engage</h2>
|
||||
<p class="layer-copy">Discover → Proposal → Ship → Maintain</p>
|
||||
<header class="layer-head"><button type="button" class="layer-id" data-goto="6">L6</button><span class="layer-name">contact</span></header>
|
||||
<h2>Terms & contact</h2>
|
||||
<p class="layer-copy">Fixed scope · production-ready · handoff-friendly · right-sized.</p>
|
||||
<div class="contact-row">
|
||||
<a class="btn" href="https://cal.levkin.ca/ilia/consult">Book 15 min</a>
|
||||
<a class="btn btn-ghost" href="mailto:hello@levkine.ca">hello@levkine.ca</a>
|
||||
<a class="btn btn-ghost" href="mailto:ilia@levkine.ca?subject=Project%20enquiry">ilia@levkine.ca</a>
|
||||
</div>
|
||||
<p class="guarantees">Retries · docs · tests first</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||