<div class="fx-screen" id="fl-screen" aria-hidden="true"> <div class="fx-menubar"></div> <div class="fx-chrome"></div> <div class="fx-page" id="fl-page"></div> <div class="fx-dock"></div> </div> <p class="fx-enter" id="fl-enter">Click any button to enter</p> <div class="fx-err"><p>this scene needs webgl</p></div> </div> <!-- Loading screen — released when the page AND the diamond widget are ready --> <div id="site-loader" role="status" aria-label="Loading"> <div class="loader-inner"> <div class="loader-word">FloriumStudio</div> <div class="loader-bar"><span id="loaderFill"></span></div> <div class="loader-pct"><span id="loaderPct">0</span>%</div> </div> </div> <!-- Header — fixed, lives outside the scroll container so it stays sticky --> <header> <a class="nav-wordmark" href="index.html">FloriumStudio</a> <img class="nav-logo" src="favicon.png" alt="Florium Studio — pixel flower logo" width="48" height="48"> <div class="nav-links"> <a href="work.html">Work</a> <a href="origin.html">Studio</a> </div> <a class="nav-reach" href="reachout.html">Reach out</a> <button class="nav-menu-toggle" type="button" aria-expanded="false" aria-controls="nav-menu">Menu</button> </header> <!-- Mobile full-screen menu overlay (red). Sits below the fixed header (which recolours to white + swaps "Menu" → "Close" while open). Hidden on desktop via CSS. --> <nav class="nav-menu-overlay" id="nav-menu" aria-hidden="true"> <ul class="nav-menu-links"> <li><a href="work.html">Work</a></li> <li><a href="origin.html">Studio</a></li> <li><a href="reachout.html">Reach Out</a></li> </ul> <div class="nav-menu-footer">
<span class="phys-letter" aria-hidden="true">L</span> <span class="phys-letter" aria-hidden="true">O</span> <span class="phys-letter" aria-hidden="true">R</span> <span class="phys-letter" aria-hidden="true">I</span> <span class="phys-letter" aria-hidden="true">U</span> <span class="phys-letter" aria-hidden="true">M</span> </h1> <div class="hero-frame"> <p class="hero-tagline">Design that makes you impossible to ignore</p> <div class="hero-meta hero-meta--base"> <span>Independent design studio</span> <span>Est. 2026</span> <span>©2026</span> </div> </div> </section> </div> <!-- Stats Section --> <section class="stats-section"> <div class="stat-block"> <h2>Who we build for</h2> <div class="divider"></div> <div class="stat-list"> <div class="stat-item">Robotics and hardware</div> <div class="stat-item">AI and deep tech</div> <div class="stat-item">Products that need explaining</div> <div class="stat-item">Brands with something new</div> </div> </div> <div class="team-block"> <h2>Two people, no handoffs</h2> <div class="divider"></div> <div class="team-list"> <div class="team-member">Nika Quridze</div> <div class="team-role">Founder / Developer</div> <div class="team-member">Nikoloz Gochitashvili</div> <div class="team-role">Co-Founder / Developer</div> </div>
<div class="work-item" data-img="img/lemoniadis/photo-1.jpg" data-img2="img/lemoniadis/parallax.jpg"><span class="work-item__label">Lemoniadis</span></div> <div class="work-item" style="border-bottom: none;" data-img="img/athoneli/bull.jpg" data-img2="img/lemoniadis/photo-3.jpg"><span class="work-item__label">All Work</span></div> </div> <div class="divider"></div> <div class="work-description"> Your website is the clearest thing your brand gets to say about itself. Most of the people who matter will see it once, decide quickly, and never come back. We build for that: for companies with something genuinely new to explain, and for the kind of business that would rather be remembered than be acceptable. </div> </section> <!-- Extra Content & FAQ — native <details> accordions: answers are real page content (indexed + AI-extractable, mirrored in the FAQPage schema above), collapsed by default so the editorial rhythm of the list stays intact. --> <section class="faq-section"> <h2 class="faq-header">Good to know</h2> <div class="faq-container"> <div class="faq-label">FAQ</div> <div class="faq-list"> <details class="faq-item"> <summary class="faq-item__q">Why work with Florium?</summary> <div class="faq-item__body"><p class="faq-item__a">Because we design and build the whole thing ourselves — brand, interface, motion, code — so what you approve is what ships and nothing gets diluted being passed between people. You talk to the two of us from the first call to the day it goes live, and we are still here after it does.</p></div> </details>
</details> <details class="faq-item"> <summary class="faq-item__q">How long does a project take?</summary> <div class="faq-item__body"><p class="faq-item__a">Six to ten weeks from the first conversation to live, for a full site. If you have a launch date we work backwards from it and tell you at the start whether it's realistic — rather than in week seven, when it has become your problem instead of ours.</p></div> </details> <details class="faq-item"> <summary class="faq-item__q">What do you build with?</summary> <div class="faq-item__body"><p class="faq-item__a">Real-time 3D and custom shaders where they earn their place, built on Three.js over hand-written HTML, CSS and JavaScript. No page builders and no themes, which means nothing is on the page because a tool made it easy, and the site stays fast enough that the work actually gets seen.</p></div> </details> <details class="faq-item"> <summary class="faq-item__q">How do I get in touch?</summary> <div class="faq-item__body"><p class="faq-item__a">Write to floriumstudio9@gmail.com, message us on WhatsApp at +995 555 62 00 94, or use the <a href="reachout.html">reach out page</a> — it's a working 3D typewriter, and yes, it sends. We read everything ourselves and reply within a day.</p></div> </details> <details class="faq-item"> <summary class="faq-item__q">What can good design actually do for my business?</summary>
Origin
Driven
By
Design
Florium is an independent studio built for companies with
something new to show and one moment in which to show it.
Two founders, and nobody in between. We handle the whole impression — the brand, the design, the motion, the code — so nothing gets diluted being passed along. What you approve is what ships, and the people who made it are still there after it goes live.
which every other edge (container, case wrapper, full-bleed break-outs, the menu overlay via --edge) is aligned to. Halved from the original 5rem, so change it HERE and the whole site follows instead of drifting apart. */ --gutter: 2.5rem; } * { margin: 0; padding: 0; box-sizing: border-box; } /* Background on <html> too, so the page is never white — including the instant the body is hidden during a scramble transition. Without this, hiding <body> exposes the browser's default white canvas, which reads as a visible page reload. */ html { background-color: var(--color-bg); /* Fluid scale: every size on the site is rem, so the root maps the 1920px design onto the viewport — full size at ≥1920, shrinking proportionally down to the 820px breakpoint (where the mobile root below takes over). 16/1920 = 0.83333vw. */ font-size: min(0.83333vw, 16px); } body { background-color: var(--color-bg); color: var(--color-text); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; letter-spacing: -0.02em; /* matches Figma tracking on all text */ } /* Hidden before paint when arriving via a scramble transition, so there's no flash of unscrambled text; transition.js reveals it instantly once the scramble is in place. */ .fl-enter body { opacity: 0; } .container { width: 100%; max-width: 120rem; margin: 0 auto; padding: 2.5rem var(--gutter); display: flex; flex-direction: column; overflow-x: clip; /* contains the hero word's horizontal scroll-drift */ } .viewport-section { min-height: calc(100vh - 5rem); /* 100vh minus container vertical padding */ display: flex; flex-direction: column; justify-content: space-between; margin-bottom: 12.5rem; } /* Header — fixed, floats over the content and inverts via difference blend so it stays
gap: 0.375rem; } /* Stats Section */ .stats-section { display: flex; flex-direction: column; gap: 7.5rem; /* the two blocks hug their content; the 12.5rem margins buffer neighbours */ margin-bottom: 12.5rem; } .stat-block, .team-block { width: 50%; min-width: 45rem; } .stat-block h2, .team-block h2 { font-family: var(--font-sans); font-size: 3.75rem; font-weight: 400; margin-bottom: 1rem; } .divider { border-bottom: var(--border-style); margin-bottom: 1.5rem; } .stat-list { display: flex; flex-direction: column; gap: 0.75rem; } .stat-item { font-family: var(--font-sans); font-size: 1.875rem; } .team-list { display: grid; grid-template-columns: 1fr auto; /* role column sizes to its content so it never wraps */ row-gap: 0.75rem; column-gap: 2.5rem; } .team-member { font-family: var(--font-sans); font-size: 1.875rem; white-space: nowrap; } .team-role { font-family: var(--font-sans); font-size: 1.875rem; text-align: right; white-space: nowrap; } /* Featured Work */ .work-section { margin-bottom: 12.5rem; } .work-label { font-family: var(--font-sans); font-size: 2rem; font-weight: 400; /* it's an <h2> for structure; keep the regular look */ margin-bottom: 0.75rem; } .work-list { border-top: var(--border-style); display: flex; flex-direction: column; } .work-item { font-family: var(--font-serif); font-size: 10.5rem; text-align: center; padding: 1.5rem 0; border-bottom: var(--border-style); } .work-description { font-family: var(--font-serif); font-size: 4.5rem; line-height: 1.1; margin-top: 2.5rem; } /* Featured Work — hover interaction: scramble text + cursor-tracking photo */ .work-item { position: relative; } .work-item__label { position: relative; z-index: 2; }
appearance: none; background: none; border: 0; padding: 0; margin: 0; font: inherit; letter-spacing: inherit; color: inherit; cursor: pointer; } .footer-big { width: 100%; margin-top: -1.25rem; /* Pull it slightly up */ } .footer-big svg { display: block; width: 100%; height: auto; } /* SVG font-family attribute can't read a CSS var(); set it here so the wordmark renders in the pixel face (Figma: 400 / 273px). */ .footer-big text { font-family: var(--font-pixel); } /* ============================================================ Mobile menu — shared base (all viewports) The "Menu" toggle and the red full-screen overlay are hidden on desktop and only surface inside the mobile breakpoint below. Kept here (not per-page) so any page carrying the markup gets the same behaviour. ============================================================ */ .nav-menu-toggle { display: none; /* revealed in the mobile query */ pointer-events: auto; /* header itself is pointer-events:none */ background: none; border: 0; padding: 0; margin: 0; color: inherit; font: inherit; letter-spacing: inherit; cursor: pointer; } .nav-menu-overlay { display: none; /* becomes a flex column in the mobile query */ } /* ============================================================ Mobile layout — ALL PAGES. Everything below is authored in rem; the fluid root maps the 1400px-wide Figma mobile frames 1:1 onto the phone's width, so a value is simply (figma-px / 16)rem. ============================================================ */ @media (max-width: 819px) { /* Fluid mobile root: 1rem = one 16px design unit of the 1400px mobile artboard (16/1400 = 1.143vw), clamped so small phones stay legible and wide phones/tablets don't oversize. */ html { font-size: clamp(3.4px, 1.143vw, 5.7px);
font-family: var(--font-pixel); font-size: 3.25rem; /* 52 design px */ line-height: 1.3; color: #fff; } .nav-menu-note { max-width: 30rem; } .nav-menu-copy { white-space: nowrap; } /* Keep the page scrollable while the menu is open: locking it drops the scrollbar, which changes the viewport width and jolts the fixed nav sideways. The opaque overlay covers the page, so whatever scrolls underneath stays invisible. */ /* ---- Container gutters match the Figma 71px side margin ---- */ .container { padding: 0 var(--gutter); /* top handled per-section/page */ } /* ---- Footer: two rows (contact / socials) + big pixel wordmark ---- */ footer { border-top: var(--border-style); padding-top: 1.5rem; padding-bottom: 2.5rem; } .footer-top { display: grid; grid-template-columns: auto 1fr auto; /* phone/email hug content so they never wrap */ grid-template-areas: "phone copy email" "social social social"; row-gap: 1.5rem; font-family: var(--font-serif); font-size: 3.25rem; /* 52 design px */ margin-bottom: 3rem; } .footer-phone { grid-area: phone; justify-self: start; white-space: nowrap; } .footer-copyright { grid-area: copy; justify-self: center; white-space: nowrap; } .footer-email { grid-area: email; justify-self: end; white-space: nowrap; } .footer-socials { grid-area: social; /* full-width second row */ display: flex; justify-content: space-between; gap: 2rem; } } /* ============================================================ Mobile — homepage sections (hero / stats / featured work / FAQ). Same 1400px-artboard scale as the shell above. ============================================================ */ @media (max-width: 819px) {
Mindset
Think
&
Execute
Our work is to make a brand impossible to confuse with
anyone else in its category, using design, code and
motion. The question we start from is simple: what can this brand claim that no competitor can?
Every brand worth remembering carries something the others cannot copy — a way of moving and speaking you recognise before you can explain it. That is what most companies lack, and it is not a budget problem. We read where you actually stand, decide what you are willing to be worse at, and build the surface so the decision is impossible to miss.
function upperFirst(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; } // Frames for a single letter-run. function wordFrames(word, frames, capFirst, resolveLast) { var N = word.length, out = []; var finalWord = capFirst ? upperFirst(word) : word; if (N <= 1) { for (var f0 = 0; f0 < frames; f0++) out.push(finalWord); return out; } // In label mode (capFirst) every letter can shuffle — including the first — and whatever // lands in position 0 is capitalised; we work in lower-case so no stray capital shows up // mid-word. In flow mode (page transition) we pin the real first letter and keep casing. var base = capFirst ? word.toLowerCase().split('') : word.split(''); var startIdx = capFirst ? 0 : 1; var movable = []; for (var i = startIdx; i < N; i++) movable.push(i); var correct = movable.map(function (p) { return base[p]; }); // the real letters, in order function same(a, b) { for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } // A full shuffle of ALL the word's movable letters — an anagram that isn't the real word // and differs from the previous frame, so the WHOLE word scrambles each frame (not just a // few letters). Same letters only — "random but not fully random". function anagram(prev) { var c; for (var t = 0; t < 24; t++) { c = shuffle(correct.slice()); if (!same(c, correct) && (!prev || !same(c, prev))) return c; } return c; // fallback for very short / repeated-letter words } var prev = null; for (var f = 0; f < frames; f++) { var settled = resolveLast && f === frames - 1; // last frame snaps back to the real word if (settled) { out.push(finalWord); continue; } var arr = anagram(prev); prev = arr; var slot = base.slice();
var S = window.FloriumScramble; var KEY = 'florium:transition'; var FRAMES = 3, STEP_MS = 95; // Every leaf element that holds visible text — excluding the physics wordmark and any // SVG (the footer wordmark), which aren't plain text runs. function collect() { var out = [], all = document.querySelectorAll('body *'); for (var i = 0; i < all.length; i++) { var el = all[i]; if (el.children.length) continue; // leaf elements only var txt = el.textContent; if (!txt || !txt.trim()) continue; if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE') continue; if (el.closest('#heroPhysics') || el.closest('svg')) continue; out.push({ el: el, text: txt }); } return out; } // Step every target through the given list of its frame indices; resolves when the last // index has painted. The first index is painted immediately (no initial dwell), so motion // starts at once — that's what kills the "frozen unscrambled" beat before navigation. function play(targets, indices, step) { return new Promise(function (resolve) { function setAll(idx) { for (var i = 0; i < targets.length; i++) targets[i].el.textContent = targets[i].frames[idx]; } var k = 0; setAll(indices[0]); if (indices.length <= 1) { resolve(); return; } var timer = setInterval(function () { k++; setAll(indices[k]); if (k >= indices.length - 1) { clearInterval(timer); resolve(); } }, step); }); } function range(n) { var a = []; for (var i = 0; i < n; i++) a.push(i); return a; } // --- Intro: only when we arrived through the transition --- var arriving = false; try { arriving = sessionStorage.getItem(KEY) === '1'; sessionStorage.removeItem(KEY); } catch (e) {} if (reduce || !arriving || !S) { html.classList.remove('fl-enter'); // show immediately } else { var inT = collect();
var FINE = matchMedia('(hover: hover) and (pointer: fine)').matches; var FRAMES = 4, STEP_MS = 65; // hoverEl gets the listeners; textEl is the element whose text scrambles (defaults to // hoverEl). They differ for the project links (hover the <a>, scramble its inner <span>) // and the work tiles (hover the whole tile, scramble its label). function attach(hoverEl, textEl) { textEl = textEl || hoverEl; var original = textEl.textContent; var timer = null; function stop() { if (timer) { clearInterval(timer); timer = null; } } // Anagram frames render at different widths (the site faces aren't // equal-width), which used to shove sibling nav links around mid-scramble. // Freeze the text box at its resting width for the duration. function lock() { textEl.style.width = ''; textEl.style.whiteSpace = ''; var w = textEl.getBoundingClientRect().width; textEl.style.display = 'inline-block'; textEl.style.width = w + 'px'; // nowrap: a wider anagram must never wrap to a second line (that doubled // the height of "Visit Website"); the fixed width keeps siblings put. textEl.style.whiteSpace = 'nowrap'; } function unlock() { textEl.style.display = ''; textEl.style.width = ''; textEl.style.whiteSpace = ''; } hoverEl.addEventListener('pointerenter', function () { stop(); lock(); var frames = FloriumScramble.textFrames(original, FRAMES, true, true); var i = 0; textEl.textContent = frames[0]; // gentle onset (<= 3 letters change) timer = setInterval(function () { i++; if (i >= frames.length) { stop(); textEl.textContent = original; unlock(); return; } textEl.textContent = frames[i]; }, STEP_MS); }); hoverEl.addEventListener('pointerleave', function () { stop(); textEl.textContent = original; unlock(); }); }
var SETTLE_MS = 100; // cycle time per letter after the frontier passes var OPEN_MS = 560; // keep in sync with the CSS height transition var glyph = function () { return POOL[(Math.random() * POOL.length) | 0]; }; // locomotive's page limit must follow the accordion's height changes; the // index page exposes its instance as a top-level `scroll` const. function updateScroll() { try { if (typeof scroll !== 'undefined' && scroll && typeof scroll.update === 'function') scroll.update(); } catch (e) {} } // Wrap every character of the answer once: words become inline-blocks (so // line wrapping still happens at word boundaries), each letter a ghost+live // pair. Recurses so links inside answers keep working. function wrapChars(root, store) { Array.prototype.slice.call(root.childNodes).forEach(function (node) { if (node.nodeType === 3) { var frag = document.createDocumentFragment(); node.nodeValue.split(/(\s+)/).forEach(function (part) { if (!part) return; if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); return; } var word = document.createElement('span'); word.className = 'faq-word'; for (var i = 0; i < part.length; i++) { var s = document.createElement('span'); s.className = 'faq-scr'; var ghost = document.createElement('span'); ghost.className = 'faq-scr__ghost'; ghost.textContent = part[i]; var live = document.createElement('span'); live.className = 'faq-scr__live'; live.setAttribute('aria-hidden', 'true'); s.appendChild(ghost); s.appendChild(live); word.appendChild(s); store.push({ span: s, ghost: ghost, live: live, ch: part[i], t0: 0, done: false }); } frag.appendChild(word);
Manifesto
Impossible
To
Ignore
Most websites blend in. They load, they say what everyone expects,
and they leave nothing behind. We build the opposite.
Anything worth making takes longer than it should. We're fine with that. The alternative is another site nobody remembers, and there are enough of those already. If it could have been made by anyone, it shouldn't have been made by us.
g.applyMatrix4(o.matrixWorld); g = g.toNonIndexed(); const clean = new THREE.BufferGeometry(); clean.setAttribute('position', g.attributes.position); parts.push(clean); }); if (!parts.length) { console.error('[diamond-widget] no mesh in', CONFIG.modelPath); return; } let geo = parts.length === 1 ? parts[0] : mergeGeometries(parts, false); geo.computeBoundingBox(); const size = geo.boundingBox.getSize(new THREE.Vector3()); const center = geo.boundingBox.getCenter(new THREE.Vector3()); geo.translate(-center.x, -center.y, -center.z); const s = CONFIG.fitSize / Math.max(size.x, size.y, size.z); geo.scale(s, s, s); if (CONFIG.targetEdge > 0) geo = refineToEdgeLength(geo, CONFIG.targetEdge); if (CONFIG.microFacet > 0) displaceMicroFacets(geo, CONFIG.microFacet, CONFIG.microFreq); geo.computeVertexNormals(); // non-indexed → flat per-facet normals (what a cut gem needs) const bvh = new MeshBVH(geo, { strategy: SAH, maxLeafTris: 1 }); diamondMat = makeDiamondMaterial(); diamondMat.uniforms.bvh.value.updateFrom(bvh); diamondMat.uniforms.resolution.value.copy(drawSize); pivot.add(new THREE.Mesh(geo, diamondMat)); // look-dev handle: tweak uniforms/bloom/exposure live from the console, // then bake the numbers back into CONFIG. e.g. __diamond.u.envGamma.value = 2.6 // Perf debugging: __diamond.applyLevel(n) forces a ladder level; __diamond.status() reports. window.__diamond = { u: diamondMat.uniforms, bloom, renderer, composer, CONFIG, applyLevel, status: () => ({ tier: COARSE ? 'mobile' : 'desktop', level, ema: Math.round(ema * 10) / 10, lockUp, dpr: renderer.getPixelRatio(), reduced: REDUCED }) }; // Warm-up: render a few real frames NOW (behind the loading screen) so shader // compilation + GPU allocations are fully finished before the user can scroll. let warm = 0; (function warmup() {
function sizeRenderer() { renderer.setSize(stage.clientWidth || 1, stage.clientHeight || 1, false); } function onResize() { sizeRenderer(); composer.setPixelRatio(renderer.getPixelRatio()); composer.setSize(stage.clientWidth || 1, stage.clientHeight || 1); camera.aspect = aspect(); camera.updateProjectionMatrix(); renderer.getDrawingBufferSize(drawSize); if (diamondMat) diamondMat.uniforms.resolution.value.copy(drawSize); } new ResizeObserver(onResize).observe(stage); window.addEventListener('resize', onResize); /* ======================================================================== ENVIRONMENT — a jewelry light rig, not a room. Near-black sphere with a few THIN hard light panels + an overhead ring. A gem only reads as brilliant when neighbouring facets alternate BLACK ↔ BLAZING; soft wide lights (or a photo HDR's ambient) average everything to grey. The strips carry slight warm/cool tints so the gem's body has tonal life without leaning on chromatic aberration. 8-bit canvas is fine: the shader re-expands it to HDR via envGamma/envIntensity. ====================================================================== */ function makeEnvTexture() { // Layout tuned at 2048; K rescales the px-sized features when the mobile // tier draws at half size (indistinguishable through refraction at 360px). const W = TIER.envSize, H = W / 2, K = W / 2048; const c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d'); // the room: black, with the faintest top sheen so dark facets aren't dead voids const g = x.createLinearGradient(0, 0, 0, H); g.addColorStop(0.00, '#141414'); g.addColorStop(0.45, '#060606'); g.addColorStop(1.00, '#020202'); x.fillStyle = g; x.fillRect(0, 0, W, H);
the shell above it tapers along the length. Scaling the WHOLE section by hmax (the obvious way) shrank the parting line to 1.6 mm at the ends and pinched it out of sight, which is why it read as "nothing on the mouse". */ const YP = 0.231, partY = YP * H; /* No seam, no scroll well, no wheel. The whole point of this mouse is that the top is ONE unbroken surface — the click is the shell flexing and the scroll is a capacitive touch panel, so there is nothing to model. Cutting a seam and a wheel into it (an earlier pass here) turns it into a generic two-button mouse wearing the right silhouette. The only break in the surface is the parting line at the base, handled by the section itself. */ const pos = []; for (let i = 0; i <= NU; i++) { const u = i / NU, a = Math.abs(2 * u - 1); const hw = halfW(a), hm = hmax(a), z = (u - 0.5) * L; const shell = Math.max(hm - partY, 0.0006); for (let j = 0; j <= NS; j++) { const sy = sec[j][1]; const x = sec[j][0] * hw; const y = sy <= YP ? sy * H : partY + ((sy - YP) / (1 - YP)) * shell; pos.push(x, y, z); } } /* index band by band along the section, so the low strip near the desk can be its own group — that is the parting line between the shell and the base on the real thing */ const idx = []; const groups = []; for (let j = 0; j < NS; j++) { const start = idx.length; for (let i = 0; i < NU; i++) { const p = i * (NS + 1) + j, q = p + NS + 1; idx.push(p, q, p + 1, q, q + 1, p + 1); } const isBase = sec[j][1] < 0.231 && sec[j + 1][1] <= 0.231; const last = groups[groups.length - 1]; if (last && last.mat === (isBase ? 1 : 0)) last.count += idx.length - start; else groups.push({ start, count: idx.length - start, mat: isBase ? 1 : 0 }); }
function placeEnter() { if (!enterEl) return; projCam.copy(camera); projCam.position.copy(basePos); projCam.lookAt(target); projCam.updateMatrixWorld(true); let low = innerHeight * 0.72; // fallback when there is no laptop if (lapBox) { low = -Infinity; for (let i = 0; i < 8; i++) { projV.set(i & 1 ? lapBox.max.x : lapBox.min.x, i & 2 ? lapBox.max.y : lapBox.min.y, i & 4 ? lapBox.max.z : lapBox.min.z).project(projCam); low = Math.max(low, (-projV.y * 0.5 + 0.5) * innerHeight); } low = Math.min(low, innerHeight); } /* Put the anchor where the prompt used to be drawn — halfway between the laptop's lowest point and the bottom of the window — by casting that screen point back onto the desk's front lip. */ const cy = (low + innerHeight) / 2; const ndc = new THREE.Vector3(0, -(cy / innerHeight) * 2 + 1, 0.5).unproject(projCam); const dir = ndc.sub(projCam.position).normalize(); const planeY = D.topY + 0.02; const tHit = (planeY - projCam.position.y) / (dir.y || -1e-6); enterAnchor.copy(projCam.position).addScaledVector(dir, Math.max(0.05, tHit)); enterRef = 0; trackEnter(); } /* project the anchor, and a point 5 cm to its right, so the label takes the same perspective scale the room does */ function trackEnter() { if (!enterEl || !enterAnchor.lengthSq()) return; const a = enterAnchor.clone().project(camera); const b = enterAnchor.clone().addScaledVector( new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0), 0.05).project(camera); const px = Math.abs(b.x - a.x) * 0.5 * innerWidth; if (!enterRef) enterRef = px || 1; const s = px / enterRef; enterEl.style.left = ((a.x * 0.5 + 0.5) * innerWidth) + 'px'; enterEl.style.top = ((-a.y * 0.5 + 0.5) * innerHeight) + 'px';
Capabilities
Look
Move
Read
We work out what a business is really claiming, then build the language, marks and rules that make the claim recognisable anywhere.
We model, light and render what does not exist yet, so a product or a space can be seen long before it is built.
We design the whole impression: the first second, the scroll, and the details you only catch on the second visit.
We build what we design, by hand, so it arrives quickly, holds up on any screen, and stays easy to change.
We draw the one mark that has to carry everything, and the small system around it that keeps it working at any size.
We give the work timing and weight, so it moves the way the brand would move if it could move at all.
We make the pieces that travel — decks, covers, campaigns — so nothing you send out arrives looking like someone else.
""" import os import sys from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler class NoCacheHandler(SimpleHTTPRequestHandler): protocol_version = "HTTP/1.1" def end_headers(self): self.send_header("Cache-Control", "no-store") super().end_headers() # precedence: explicit argv > PORT env (set by tooling that auto-assigns ports) > 8000 port = int(sys.argv[1]) if len(sys.argv) > 1 else int(os.environ.get("PORT", 8000)) print(f"Serving on http://localhost:{port} (no-store cache headers)") # ThreadingHTTPServer, like `python -m http.server` — a single-threaded server # deadlocks against the browser's six parallel keep-alive connections. ThreadingHTTPServer(("", port), NoCacheHandler).serve_forever()
node_type = node.get('type') name = node.get('name') box = node.get('absoluteBoundingBox', {}) x = box.get('x', 0) - base_x if box else 0 y = box.get('y', 0) - base_y if box else 0 w = box.get('width', 0) if box else 0 h = box.get('height', 0) if box else 0 if w == 0 or h == 0: return "" style_css = f"position: absolute; left: {x}px; top: {y}px; width: {w}px; height: {h}px;" fills = node.get('fills', []) bg = "transparent" if fills and len(fills) > 0 and fills[0].get('type') == 'SOLID': if fills[0].get('visible', True): bg = rgba_to_hex(fills[0].get('color')) if bg != "transparent" and node_type != "TEXT": style_css += f" background-color: {bg};" strokes = node.get('strokes', []) if strokes and len(strokes) > 0 and strokes[0].get('type') == 'SOLID': stroke_color = rgba_to_hex(strokes[0].get('color')) stroke_weight = node.get('strokeWeight', 1) style_css += f" border: {stroke_weight}px solid {stroke_color};" content = "" if node_type == 'TEXT': text_chars = node.get('characters', '') style = node.get('style', {}) font = style.get('fontFamily', 'sans-serif') size = style.get('fontSize', 16) weight = style.get('fontWeight', 400) line_height = style.get('lineHeightPx', size * 1.2) align_h = style.get('textAlignHorizontal', 'LEFT').lower() align_v = style.get('textAlignVertical', 'TOP').lower() # Figma aligns: center -> center, right -> right if align_h == 'center': align_h = 'center' if align_h == 'right': align_h = 'right' # Color for text text_color = bg if bg != "transparent" else "#000" # fallback if fills and len(fills) > 0 and fills[0].get('type') == 'SOLID': text_color = rgba_to_hex(fills[0].get('color'))
def wverts(o): return [o.matrix_world @ v.co for v in o.data.vertices] def bbc(o): vs = wverts(o); xs=[p.x for p in vs]; ys=[p.y for p in vs]; zs=[p.z for p in vs] return Vector((min(xs),min(ys),min(zs))), Vector((max(xs),max(ys),max(zs))) def dims(o): mn,mx=bbc(o); return mx-mn def cen(o): mn,mx=bbc(o); return (mn+mx)/2 def deselect(): bpy.ops.object.select_all(action='DESELECT') # The 46 keys are bent LEVERS (stems). Their caps are separate meshes: the # flat cap-ROW objects (main keys) AND, for the 4 corner keys, a metallic # RING (~53v) + FACE disc (<=20v). The corner ring/face sit on an OUTERMOST # lever — they are that lever's cap, NOT standalone keys. So: weld ring+face, # then treat them as CAPS to merge onto their nearest lever (not new keys). stems = [o for o in meshes if 700<=len(o.data.vertices)<=1100 and max(dims(o))<2.2 and cen(o).z<0.2] stemset = set(stems) corner_rings = [o for o in meshes if o not in stemset and 40<=len(o.data.vertices)<=70 and abs(cen(o).y)>1.5 and max(dims(o))<0.6] corner_faces = [o for o in meshes if o not in stemset and len(o.data.vertices)<=20 and abs(cen(o).y)>1.5] for face in corner_faces: ring = min(corner_rings, key=lambda r: (cen(r) - cen(face)).length) deselect(); ring.select_set(True); face.select_set(True) bpy.context.view_layer.objects.active = ring bpy.ops.object.join() meshes = [o for o in bpy.data.objects if o.type == 'MESH'] # refresh after joins corner_rings = [o for o in meshes if o not in stemset and 40<=len(o.data.vertices)<=90 and abs(cen(o).y)>1.5] # Cap sources: flat cap-rows (main keys) + corner ring/face caps. cap_rows = [o for o in meshes if o not in stemset and dims(o).z < 0.15 and dims(o).y > 1.5 and cen(o).z < 0.25 and len(o.data.vertices) < 2000] cap_rows = cap_rows + corner_rings print("stems (levers):", len(stems), " cap sources incl corners:", len(cap_rows)) # 1) join all cap rows into one, then separate into individual caps
"cut out the unimportant stuff" part: it stops them competing with the subject. """ import os import numpy as np from PIL import Image, ImageFilter Image.MAX_IMAGE_PIXELS = None SRC = r'C:\Users\whydu\florium\img\relief\CHSDM-3F756338E8FC2_01.jpg' OUT = r'C:\Users\whydu\florium\img\relief' CROP = (248, 0, 6856, 18544) # museum backdrop removed; found by edge scan W = 1024 # depth resolution; the bake target is built to match ALBEDO_W = 1536 # colour can afford more than the bake does LO_W = 192 # geometry only needs the broad forms — see below HP_RADIUS = 40 # high-pass radius at W; larger keeps broader forms def smooth(x, a, b): t = np.clip((x - a) / (b - a), 0, 1) return t * t * (3 - 2 * t) def blur(arr, radius): img = Image.fromarray((np.clip(arr, 0, 1) * 255).astype(np.uint8)) return np.asarray(img.filter(ImageFilter.GaussianBlur(radius)), float) / 255 def mirror_blur(img, radius, pad): """Blur without letting the edges pull in tone from outside the crop — otherwise the panel comes back with a dark band down every side.""" w, h = img.size big = Image.new('L', (w + pad * 2, h + pad * 2)) big.paste(img, (pad, pad)) big.paste(img.crop((0, 0, pad, h)).transpose(Image.FLIP_LEFT_RIGHT), (0, pad)) big.paste(img.crop((w - pad, 0, w, h)).transpose(Image.FLIP_LEFT_RIGHT), (w + pad, pad)) strip = big.crop((0, pad, w + pad * 2, h + pad)).copy() big.paste(strip.crop((0, 0, w + pad * 2, pad)).transpose(Image.FLIP_TOP_BOTTOM), (0, 0)) big.paste(strip.crop((0, h - pad, w + pad * 2, h)).transpose(Image.FLIP_TOP_BOTTOM), (0, h + pad)) return big.filter(ImageFilter.GaussianBlur(radius)).crop((pad, pad, pad + w, pad + h)) im = Image.open(SRC).crop(CROP) H = int(round(W * im.height / im.width)) work = im.resize((W, H), Image.LANCZOS).convert('RGB')
Processes
Ask
Make
Stay
We ask what the business actually does for the person paying it, and what that person already believes before arriving.
We read what already exists — the language in use, the habits an audience has formed — before anything is discarded.
We gather the photography, words, marks and material the work gets built from, and name what is still missing.
We decide what it should feel like before it looks like anything, so every later choice has something to answer to.
We draw it properly: type, spacing, timing, and the states nobody photographs but everybody ends up meeting.
We turn the drawing into a build that is quick, written by hand, and legible to whoever inherits it next.
We ship it, watch how it behaves with real visitors, and stay with it afterwards. A site is kept, not received.
/* h.js — 3d type sketch. three r160. no build step, no deps. drop it on a page with the site's importmap and it runs. */ import*as T from'three'; import{FontLoader as FL}from'three/addons/loaders/FontLoader.js'; import{TextGeometry as TG}from'three/addons/geometries/TextGeometry.js'; const W=()=>innerWidth,H=()=>innerHeight,PI=Math.PI,τ=PI*2; const clamp=(v,a,b)=>v<a?a:v>b?b:v,mix=(a,b,t)=>a+(b-a)*t; const smooth=t=>t*t*(3-2*t),fract=x=>x-Math.floor(x); const hash=(x,y)=>fract(Math.sin(x*127.1+y*311.7)*43758.5453123); const noise=(x,y)=>{const i=Math.floor(x),j=Math.floor(y),u=smooth(x-i),v=smooth(y-j); return mix(mix(hash(i,j),hash(i+1,j),u),mix(hash(i,j+1),hash(i+1,j+1),u),v)*2-1;}; const S=new T.Scene();S.fog=new T.FogExp2(0x000000,.055); const C=new T.PerspectiveCamera(42,W()/H(),.1,120);C.position.set(0,0,15.5); const R=new T.WebGLRenderer({antialias:!0,alpha:!0,powerPreference:'high-performance'}); R.setPixelRatio(Math.min(devicePixelRatio,2));R.setSize(W(),H()); R.toneMapping=T.ACESFilmicToneMapping;R.toneMappingExposure=1.15; R.outputColorSpace=T.SRGBColorSpace;document.body.appendChild(R.domElement); /* the material. vertex noise on the extrusion normal, fresnel rim on the way out, both driven off one uniform so the whole thing is a single number in time. */ const U={t:{value:0},a:{value:0},c1:{value:new T.Color('#ffffff')},c2:{value:new T.Color('#6f6f6f')}}; const M=new T.ShaderMaterial({uniforms:U,transparent:!0,side:T.DoubleSide, vertexShader:`uniform float t;uniform float a;varying vec3 vN;varying vec3 vV;varying float vD; vec3 hs(vec3 p){return fract(sin(vec3(dot(p,vec3(127.1,311.7,74.7)),dot(p,vec3(269.5,183.3,246.1)),dot(p,vec3(113.5,271.9,124.6))))*43758.5453);}
float sn(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.-2.*f); float n=mix(mix(mix(hs(i).x,hs(i+vec3(1,0,0)).x,f.x),mix(hs(i+vec3(0,1,0)).x,hs(i+vec3(1,1,0)).x,f.x),f.y), mix(mix(hs(i+vec3(0,0,1)).x,hs(i+vec3(1,0,1)).x,f.x),mix(hs(i+vec3(0,1,1)).x,hs(i+vec3(1,1,1)).x,f.x),f.y),f.z); return n*2.-1.;} void main(){vN=normalize(normalMatrix*normal); float d=sn(position*.55+vec3(0.,0.,t*.35));vD=d; vec3 p=position+normal*d*a*.42; vec4 mv=modelViewMatrix*vec4(p,1.);vV=-normalize(mv.xyz); gl_Position=projectionMatrix*mv;}`, fragmentShader:`uniform vec3 c1;uniform vec3 c2;uniform float a;varying vec3 vN;varying vec3 vV;varying float vD; void main(){float f=pow(1.-clamp(dot(normalize(vN),normalize(vV)),0.,1.),2.6); vec3 c=mix(c2,c1,clamp(f+vD*.25+.35,0.,1.)); float g=smoothstep(.0,.9,a); gl_FragColor=vec4(c,g*(.35+f*.65));}`}); const G=new T.Group();S.add(G); const L=new T.Group();G.add(L); /* one glyph per mesh so each can carry its own delay — the word assembles left to right rather than appearing whole. */ const WORD='Hello world'; const parts=[]; new FL().load('https://threejs.org/examples/fonts/helvetiker_bold.typeface.json',fnt=>{ let x=0;const gap=.12; [...WORD].forEach((ch,i)=>{
if(ch===' '){x+=.55;return;} const g=new TG(ch,{font:fnt,size:1.9,depth:.42,curveSegments:14, bevelEnabled:!0,bevelThickness:.05,bevelSize:.035,bevelSegments:5}); g.computeBoundingBox();g.computeVertexNormals(); const b=g.boundingBox,w=b.max.x-b.min.x; g.translate(-b.min.x,-(b.max.y+b.min.y)/2,-(b.max.z+b.min.z)/2); const m=new T.Mesh(g,M.clone());m.material.uniforms=Object.assign({},U,{a:{value:0}}); m.position.x=x;x+=w+gap; m.userData={i,d:i*.055,seed:hash(i*7.3,i*1.9)*τ}; L.add(m);parts.push(m);}); L.position.x=-x/2; parts.forEach(p=>{p.position.y=-6;p.rotation.x=-PI/2;}); }); /* a ring of ghosts behind the word: same geometry, drawn back-to-front and faded, so the type reads as one object with depth rather than a flat plate. */ const GH=8,ghosts=[]; for(let i=0;i<GH;i++){const g=new T.Group();g.position.z=-(i+1)*.55; g.userData={k:(i+1)/GH};G.add(g);ghosts.push(g);} const key=new T.DirectionalLight(0xffffff,2.1);key.position.set(3,5,6);S.add(key); const rim=new T.DirectionalLight(0x8899ff,1.3);rim.position.set(-5,-2,-4);S.add(rim); S.add(new T.AmbientLight(0xffffff,.28)); const P={x:0,y:0,tx:0,ty:0}; addEventListener('pointermove',e=>{P.tx=(e.clientX/W())*2-1;P.ty=-(e.clientY/H())*2+1;});
addEventListener('resize',()=>{C.aspect=W()/H();C.updateProjectionMatrix();R.setSize(W(),H());}); const t0=performance.now();let raf=0; (function loop(now){raf=requestAnimationFrame(loop); const t=(now-t0)/1000;U.t.value=t; P.x=mix(P.x,P.tx,.045);P.y=mix(P.y,P.ty,.045); G.rotation.y=P.x*.34+Math.sin(t*.21)*.06; G.rotation.x=-P.y*.22+Math.cos(t*.17)*.04; parts.forEach(p=>{const {d,seed}=p.userData; const k=clamp((t-.35-d)/1.15,0,1),e=1-Math.pow(1-k,3.2); p.position.y=mix(-6,0,e)+Math.sin(t*.9+seed)*.045*e; p.rotation.x=mix(-PI/2,0,e); p.rotation.z=Math.sin(t*.55+seed)*.018*e; p.material.uniforms.a.value=e*(.55+.45*(.5+.5*Math.sin(t*.7+seed))); p.material.uniforms.t.value=t;}); ghosts.forEach(g=>{g.rotation.copy(G.rotation);g.rotation.y*=1-g.userData.k*.35;}); C.position.x=P.x*.9;C.position.y=P.y*.55;C.lookAt(0,0,0); R.render(S,C);})(t0); addEventListener('beforeunload',()=>{cancelAnimationFrame(raf); parts.forEach(p=>{p.geometry.dispose();p.material.dispose();}); R.dispose();R.forceContextLoss();});
People