Compare commits
6 commits
9f742928d5
...
0c4b3a438e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c4b3a438e | |||
| 8b277193d1 | |||
| bd4d2f5f33 | |||
| fb815768e2 | |||
| e3439d6c8f | |||
| 547515061c |
27 changed files with 5328 additions and 789 deletions
210
BUSINESS.md
Normal file
210
BUSINESS.md
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
# Fenja AI — Business Description
|
||||||
|
|
||||||
|
*A synthesis of the company and products described on the project-bifrost.fenja.ai web experience. Drawn entirely from on-site copy: the entrance, timeline, overview, and product deepdive pages.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. At a glance
|
||||||
|
|
||||||
|
**Fenja AI** is a Danish company **and** an AI platform — one organisation, one product, one name. The platform is **client-managed**: it installs into the customer's own environment (on-prem or in their controlled cloud), runs on their hardware, and never sends data to a foreign provider. It is positioned as **"trusted, sovereign AI built in Denmark, for Europe"** and is backed by **Innovationsfonden** (the Danish national Innovation Fund).
|
||||||
|
|
||||||
|
The platform brings **language models, organisational knowledge, tools, and agents** together in a single architecture so that a customer can not just run an LLM, but actually get work done with AI under their own governance.
|
||||||
|
|
||||||
|
A parallel initiative, **Project Bifrost**, exists alongside the platform. It is the engagement programme through which a curated group of Danish and European organisations help shape Fenja's product roadmap and gain early access to the platform at a subsidised price.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The strategic thesis
|
||||||
|
|
||||||
|
Fenja AI's pitch is built on a sustained argument that **European digital sovereignty is no longer a procurement preference — it is a security imperative**. The site presents this argument as a 13-event timeline running from September 2024 through Q1 2026, drawn from public reporting on Danish and European institutions, the U.S. CLOUD Act, the Trump tariffs, and the migration of the International Criminal Court off Microsoft.
|
||||||
|
|
||||||
|
The thesis can be summarised in five claims:
|
||||||
|
|
||||||
|
1. **Concentration risk is now critical.** Three American firms run roughly 70% of Europe's cloud and almost all of its AI capacity (per the September 2024 Draghi Report).
|
||||||
|
2. **The "kill switch" is real, not theoretical.** In May 2025, Microsoft disabled the email of the ICC chief prosecutor in compliance with a U.S. executive order. In June 2025, Microsoft testified under oath to the French Senate that it could not guarantee European sovereignty even for data stored on European soil with European staff and European keys, because of the U.S. CLOUD Act.
|
||||||
|
3. **European governments now treat dependence as a vulnerability.** Copenhagen's Microsoft bill rose 72% in five years (313→538M DKK). Copenhagen and Aarhus announced full Microsoft exits. The Danish Minister of Emergency Preparedness has urged every Danish company to "create exit plans for cloud services". The official Danish threat assessment in January 2026 listed the U.S. alongside Russia and China for the first time in history.
|
||||||
|
4. **Tariffs and trade weaponisation make U.S. dependency a foreign-policy risk.** The January 2026 tariffs on Denmark and seven European nations were tied to demands over Greenland. The DSA, DMA, and AI Act have been reframed by the U.S. administration as discriminatory trade barriers.
|
||||||
|
5. **Regulation alone will not close the gap.** Europe is "writing rules for infrastructure it does not own" — U.S. hyperscalers add €10B/quarter of European capacity, more than Gaia-X spent in a decade. **Sovereign capacity has to be built, not legislated.**
|
||||||
|
|
||||||
|
Fenja AI positions itself as the practical answer to that gap for regulated and security-sensitive Danish and European organisations: not a research project, not an EU policy lever, but a working AI platform a customer can install and run today.
|
||||||
|
|
||||||
|
The site's framing question — *"When AI runs Europe, who runs the AI?"* — captures the pitch in one line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The company
|
||||||
|
|
||||||
|
**Fenja AI** is the company name and the product name. The site is explicit that this is intentional: *"Fenja AI is both our company and our platform — one mission, one name."*
|
||||||
|
|
||||||
|
Stated identity:
|
||||||
|
|
||||||
|
- **Origin:** Built in Denmark.
|
||||||
|
- **Audience:** Danish and European organisations that need full control over their own AI.
|
||||||
|
- **Backer:** Innovationsfonden (the Danish Innovation Fund). The phrase "Backed by Innovationsfonden" appears on the welcome screen, the hero, and the join footer; the pilot subsidy explicitly references the same fund.
|
||||||
|
- **Stance:** Trusted, sovereign, client-managed. Throughout the site, "client-managed" is the operative phrase — Fenja is not a SaaS, not a hyperscaler resale, not a hosted product. It runs in the customer's environment.
|
||||||
|
|
||||||
|
The site does not name founders, headcount, or revenue figures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The platform — architecture in four layers
|
||||||
|
|
||||||
|
The site introduces the platform as **"The Fenja AI platform in four steps"** and reinforces three parallel framings:
|
||||||
|
|
||||||
|
- **One complete platform:** *"Fenja AI brings models, knowledge, tools, and agents together in one platform for using and scaling AI across your organisation."*
|
||||||
|
- **Full control:** *"Fenja AI is installed in your own client-managed environment, giving you full control over data, security, and governance."*
|
||||||
|
- **Sovereignty:** *"Fenja AI is built in Denmark for European organisations that want trusted, sovereign AI on their own terms."*
|
||||||
|
|
||||||
|
The architecture itself is then described both at the timeline-overview level (four layers) and in the deepdive (a more detailed five-beat build with specific cards inside each layer). The deepdive version is the canonical, more-precise statement.
|
||||||
|
|
||||||
|
### Layer 1 — Foundation: the language model
|
||||||
|
|
||||||
|
A **state-of-the-art, open-source language model**, deployed **on-prem** in the customer's environment. No data leaves the customer's perimeter. The site frames this as the necessary starting point but explicitly *not yet Fenja* on its own — *"Installing an open-source language model isn't enough."*
|
||||||
|
|
||||||
|
### Layer 2 — Foundation: knowledge
|
||||||
|
|
||||||
|
What turns a generic open-source model into *Fenja*. Two sub-components are called out as cards:
|
||||||
|
|
||||||
|
- **Wiki** — Company and domain knowledge captured in a wiki the customer's team can read and edit. Three scopes: **organisational, departmental, personal**.
|
||||||
|
- **Routines & memory** — How Fenja works inside the organisation: **stand-ups, recurring tasks, working memory**. The framing positions Fenja as "a coworker who knows how things get done", not a chatbot.
|
||||||
|
|
||||||
|
### Layer 3 — Tools: how Fenja acts
|
||||||
|
|
||||||
|
Four tool families are listed:
|
||||||
|
|
||||||
|
- **Document retrieval** — Find and cite. (RAG.)
|
||||||
|
- **Structured data (e.g. SQL)** — Query and extract. Natural language → SQL.
|
||||||
|
- **System actions** — Read and write across the customer's systems. APIs and integrations.
|
||||||
|
- **Custom tools** — Defined by the customer for their specific work.
|
||||||
|
|
||||||
|
The framing: *"How knowledge becomes work. Fenja uses tools to find documents, query data, take action across your systems. Some are obvious; others depend on what your work needs."*
|
||||||
|
|
||||||
|
### Layer 4 — Agents: how Fenja scales
|
||||||
|
|
||||||
|
Four agent constructs:
|
||||||
|
|
||||||
|
- **Supervisor** — Plan and dispatch. Orchestration.
|
||||||
|
- **Specialists** — Focused expertise. Subagents.
|
||||||
|
- **Skills** — Reusable capability, portable across specialists.
|
||||||
|
- **Workflows** — Composed by the customer. Governed end-to-end.
|
||||||
|
|
||||||
|
The framing: *"Real work isn't one task. Fenja becomes a team — a supervisor and specialists, each focused, each governed, all dispatched by workflows you've designed."*
|
||||||
|
|
||||||
|
### The summary claim
|
||||||
|
|
||||||
|
Once the four layers are assembled, the deepdive closes with: *"Everything you need and with full control. Fenja brings together all the pieces to solve simple and complex AI use cases across your organisation."*
|
||||||
|
|
||||||
|
This is the core differentiation argument: not "we sell you an LLM" and not "we sell you a chatbot", but a complete on-prem stack from foundation model up through governed multi-agent workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The products — four capabilities
|
||||||
|
|
||||||
|
Fenja is sold as four named capability tiers that build on each other. The deployment story: a customer starts with **Core** and adds **Dev**, **Analyze**, and/or **Agentic** as their needs grow. The dark-tile **Agentic** tier is the full bundle.
|
||||||
|
|
||||||
|
### Fenja Core
|
||||||
|
**Tier:** Foundational.
|
||||||
|
**Includes:** Essential LLM capabilities with Fenja Semantic — a safe and custom chatbot that understands the customer's organisation.
|
||||||
|
**Audience:** Any customer starting their sovereign-AI journey. The baseline.
|
||||||
|
|
||||||
|
### Fenja Dev
|
||||||
|
**Tier:** Developer toolset.
|
||||||
|
**Includes:** Fenja Core, plus an AI-supported development platform.
|
||||||
|
**Promise:** *"Code faster and better with your own secure AI-supported development platform."*
|
||||||
|
**Audience:** Engineering teams who want a coding-assistant capability without sending source code to an external provider.
|
||||||
|
|
||||||
|
### Fenja Analyze
|
||||||
|
**Tier:** Strategic intel.
|
||||||
|
**Includes:** Fenja Core, plus an analytics and insights capability.
|
||||||
|
**Promise:** *"Bring real insights to your people. You ask for an insight, and your agents will find, analyze, and present the relevant data."*
|
||||||
|
**Audience:** Analysts, decision-makers, knowledge workers — anyone whose value comes from synthesising data across the organisation.
|
||||||
|
|
||||||
|
### Fenja Agentic
|
||||||
|
**Tier:** Automation. The complete framework.
|
||||||
|
**Includes:** Core + Dev + Analyze.
|
||||||
|
**Promise:** *"The complete framework. Fully governed and controlled agents collaborate to solve your most important processes."*
|
||||||
|
**Audience:** Customers ready to operationalise AI as governed multi-agent workflows across critical processes — not just assist humans, but run end-to-end work under human oversight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Project Bifrost — the engagement programme
|
||||||
|
|
||||||
|
**Project Bifrost** is positioned as a parallel initiative, not a product:
|
||||||
|
|
||||||
|
> *"Project Bifrost is the bridge between an industrial-grade AI platform and the realities of regulated organisations — built with them, not just for them."*
|
||||||
|
|
||||||
|
The Norse-mythology naming is deliberate: Bifrost is the bridge between worlds. In Fenja's framing, the two worlds are (a) a fast-moving, industrial-grade AI platform and (b) the slower-moving operational, legal, and security realities of regulated European organisations. Project Bifrost is the structured way the two are reconciled in product design.
|
||||||
|
|
||||||
|
Members of Project Bifrost get **three things** — and the site is explicit that these are inseparable, not a menu:
|
||||||
|
|
||||||
|
### 6.1 Community — shape the future together
|
||||||
|
|
||||||
|
> *"Join a select community of organisations helping define the future of trusted sovereign AI in Denmark and Europe. At a time when Europe needs greater technological independence, this is an opportunity to contribute to an AI platform built on trust, shared ambition, and a common mission."*
|
||||||
|
|
||||||
|
A peer network of like-minded Danish and European organisations engaged in the same sovereignty problem. The framing emphasises **trust, shared ambition, and a common mission** rather than transactional vendor/customer dynamics.
|
||||||
|
|
||||||
|
### 6.2 Advisory Council — turn insight into influence
|
||||||
|
|
||||||
|
> *"Take part in regular advisory council sessions where your input directly shapes the product and platform roadmap. Gain first-hand insight into cutting-edge AI developments and help influence what is built, which capabilities are prioritised, and how the platform evolves to meet real organisational needs."*
|
||||||
|
|
||||||
|
A formal feedback channel into the product. Members influence which capabilities are prioritised, how governance is built in, how the platform fits into regulated environments. This is the mechanism that makes the "*built with you, not just for you*" claim concrete.
|
||||||
|
|
||||||
|
### 6.3 Pilot Projects — access the platform before others
|
||||||
|
|
||||||
|
> *"A select number of Project Bifrost participants will have the opportunity to join pilot projects and gain early access to the platform at a significantly reduced price, subsidised by the Innovation Fund. This gives your organisation the chance to explore cutting-edge sovereign AI early, realise value at low cost, and help shape the platform through real-world use."*
|
||||||
|
|
||||||
|
Early commercial access. Crucially, the **pricing is subsidised by Innovationsfonden**, which materially lowers the cost of the pilot for the participant and is part of why participation is positioned as "selectivity" rather than a sales motion.
|
||||||
|
|
||||||
|
### 6.4 What happens after a customer joins
|
||||||
|
|
||||||
|
The post-join confirmation panel commits to a specific sequence:
|
||||||
|
|
||||||
|
1. The Fenja AI team reaches out shortly.
|
||||||
|
2. The participant receives an invitation to the **project portal**, where all communication, materials, and updates live.
|
||||||
|
3. A date is being set for the **first advisory council meeting**; the participant will be invited.
|
||||||
|
4. Pilot-project participation is discussed individually shortly after.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. The invitation framing
|
||||||
|
|
||||||
|
The web experience itself is invite-gated — visitors land on an email entry, the email is checked against an invite list, and only invited contacts get past the welcome step. The welcome copy makes this explicit:
|
||||||
|
|
||||||
|
> *"This is a personal invitation because we believe your perspective can make a meaningful contribution to an important mission: building trusted, sovereign AI for Denmark and Europe. In this short web experience, we will explain why this matters, what Fenja AI is, and how you, through Project Bifrost, can help shape its future."*
|
||||||
|
|
||||||
|
Two terms are then defined for the visitor:
|
||||||
|
|
||||||
|
- **Fenja AI** — *"The company and platform for sovereign and safe AI."*
|
||||||
|
- **Project Bifrost** — *"The initiative created to ensure that Fenja AI is built not just for organisations like yours, but with you."*
|
||||||
|
|
||||||
|
This invite-only positioning reinforces the selectivity narrative around Project Bifrost: it is not a sign-up form, it is a curated cohort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Differentiation — what Fenja is not
|
||||||
|
|
||||||
|
The deepdive's framing question makes the negative space explicit:
|
||||||
|
|
||||||
|
> *"Renting a few AI capabilities from American companies isn't enough. Installing an open-source language model isn't enough. You need a platform you control — with the tools, the knowledge, and the framework to make AI actually do the work your organization needs done."*
|
||||||
|
|
||||||
|
This positions Fenja against three implicit alternatives:
|
||||||
|
|
||||||
|
1. **U.S. AI APIs (OpenAI, Anthropic, etc.)** — rejected on sovereignty grounds. The CLOUD Act narrative on the timeline is the supporting argument: even European-soil U.S.-vendor data is reachable by U.S. authorities.
|
||||||
|
2. **DIY open-source LLM deployment** — acknowledged as necessary but insufficient. A model alone is "the starting point — but not yet Fenja". Without the knowledge layer, tool integrations, and agent framework, the customer has running infrastructure but no actual capability to do work.
|
||||||
|
3. **EU-policy-led sovereignty (Gaia-X, regulation)** — implicitly framed as too slow on the timeline ("Europe writes rules for infrastructure it does not own"). Fenja's argument is that sovereign capacity has to be **built and shipped**, not merely legislated.
|
||||||
|
|
||||||
|
Fenja's positive claim is the combination: **on-prem foundation model + organisational knowledge + tool integrations + governed agent framework**, all from one vendor, all in one client-managed deployment, all under the customer's control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Backing and provenance
|
||||||
|
|
||||||
|
- **Innovationsfonden** (Innovation Fund Denmark) — the consistent backer reference across every page of the site. It appears in the entrance footer, the platform hero, the join footer, and is named explicitly in the Pilot Projects pricing description.
|
||||||
|
- **Built in Denmark** — surfaced in the hero, the architecture rotating taglines, and the join footer ("Built in Denmark. Supported by the Innovation Fund.").
|
||||||
|
- **For Europe** — the consistent geographic scope. Customer language is always "Danish and European organisations", and the strategic thesis is European, not just Danish.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Summary in one paragraph
|
||||||
|
|
||||||
|
**Fenja AI is a Danish-built, client-managed AI platform for Danish and European organisations that need full control over their own AI. The platform combines an on-prem open-source language model, an organisational knowledge layer (wiki, routines, working memory), a tools layer (document retrieval, structured-data query, system actions, custom tools), and an agent framework (supervisor, specialists, skills, workflows) into a single architecture deployed inside the customer's environment. It is sold as four progressively-bundled capabilities — Fenja Core, Fenja Dev, Fenja Analyze, and Fenja Agentic. A parallel initiative, Project Bifrost, brings a curated group of regulated organisations into the product's design through a community, an advisory council, and subsidised pilot projects funded by Innovationsfonden. The pitch is grounded in a concrete strategic argument: U.S. cloud and AI dependency is now a security and trade-policy risk for Europe, regulation alone will not close the gap, and sovereign AI capacity has to be built in-region — which is what Fenja AI is.**
|
||||||
268
architecture boxes/design_handoff_architecture_diagram/README.md
Normal file
268
architecture boxes/design_handoff_architecture_diagram/README.md
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
# Handoff: Fenja architecture diagram
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A **right-side architecture diagram** for a section of the Fenja AI website. The left side of the section will hold explanatory prose (the developer adds this in their codebase). The right side is this stacked, three-layer diagram showing the Fenja system at a glance: **Foundation → Tools → Agents**.
|
||||||
|
|
||||||
|
The diagram is the visual anchor of the section. As the user reads down the left column, each paragraph cues the next layer of the diagram to enter — creating a guided "build" of the architecture in concert with the prose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## About the design files
|
||||||
|
|
||||||
|
The files in `reference/` are a **design reference** — a working HTML/React prototype that shows the intended look, layout, and entry animation. They are **not production code to copy verbatim**.
|
||||||
|
|
||||||
|
- `reference/preview.html` — open this in a browser to see the diagram rendered.
|
||||||
|
- `reference/approach-fenja.jsx` — the React component (inline JSX, transpiled by Babel in the prototype). Use it as a structural and styling reference.
|
||||||
|
- `reference/colors_and_type.css` — Fenja design tokens (CSS variables) and base type styles. Use these tokens — do not invent new ones.
|
||||||
|
- `reference/fonts/` — Manrope (sans) and Newsreader (serif) self-hosted TTFs. The diagram **requires both families** — Newsreader for italic descriptors, Manrope for everything else.
|
||||||
|
|
||||||
|
Recreate the design in your existing environment (Next.js, React, Vue, Astro, whatever the Fenja site uses), wired up to the site's real design tokens and motion primitives.
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High-fidelity.** Colors, typography, spacing, and corner radii are final. Recreate pixel-perfectly using the established Fenja design tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this diagram is
|
||||||
|
|
||||||
|
A vertically stacked diagram with three layers, top to bottom:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ FOUNDATION Sovereign by design │
|
||||||
|
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ │ Language model │ │ Knowledge (taller) │ │
|
||||||
|
│ │ italic descriptor │ │ italic descriptor │ │
|
||||||
|
│ │ mono · tag │ │ mono · tag │ │
|
||||||
|
│ │ │ │ italic descriptor │ │
|
||||||
|
│ │ │ │ mono · tag │ │
|
||||||
|
│ └──────────────────────┘ └──────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ TOOLS How Fenja acts │
|
||||||
|
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
||||||
|
│ │ 4 boxes, equal width and height │
|
||||||
|
│ └────┘ └────┘ └────┘ └────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ AGENTS How Fenja scales │
|
||||||
|
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
||||||
|
│ │ 4 boxes, equal width and height │
|
||||||
|
│ └────┘ └────┘ └────┘ └────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical asymmetries
|
||||||
|
|
||||||
|
- **Foundation has 2 boxes** (narrower row) — the irreducible base.
|
||||||
|
- **Tools and Agents each have 4 boxes** (full row) — they extend the base.
|
||||||
|
- Within Foundation, the **Knowledge box is intentionally taller** than Language model (5 content rows vs 2). Do not equalize them. The asymmetry is the point.
|
||||||
|
- Within Tools and Agents, all 4 boxes are **equal height** (stretch). Do not let content drift the heights.
|
||||||
|
|
||||||
|
### No connecting lines
|
||||||
|
|
||||||
|
The first pass has **no lines between layers**. Stacking + vertical order communicates "lower layers depend on what's above." If that's unclear after live testing with copy, a second pass can add subtle connectors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer content (exact)
|
||||||
|
|
||||||
|
Use this content verbatim. Do not paraphrase.
|
||||||
|
|
||||||
|
### Layer 1 — Foundation
|
||||||
|
|
||||||
|
- Eyebrow (top-left of layer): `Foundation` (rendered uppercase via `text-transform: uppercase`)
|
||||||
|
- Descriptor (top-right of layer, serif italic): `Sovereign by design`
|
||||||
|
|
||||||
|
**Box 1 — Language model**
|
||||||
|
- Bold name: `Language model`
|
||||||
|
- Italic role: `State-of-the-art, open-source`
|
||||||
|
- Mono tag: `On-prem`
|
||||||
|
|
||||||
|
**Box 2 — Knowledge** (taller — 5 content rows, no min-height equalization)
|
||||||
|
- Bold name: `Knowledge`
|
||||||
|
- Italic: `Organizational memory at every level`
|
||||||
|
- Mono: `Organizational · Departmental · Personal`
|
||||||
|
- Italic: `Facts, context, and how things get done`
|
||||||
|
- Mono: `Wiki · Routines · Working memory`
|
||||||
|
|
||||||
|
### Layer 2 — Tools
|
||||||
|
|
||||||
|
- Eyebrow: `Tools`
|
||||||
|
- Descriptor: `How Fenja acts`
|
||||||
|
|
||||||
|
| Bold name | Italic role | Mono tag |
|
||||||
|
|---|---|---|
|
||||||
|
| Document retrieval | Find and cite | RAG |
|
||||||
|
| Structured data | Query and extract | NL → SQL |
|
||||||
|
| System actions | Read and write | APIs · integrations |
|
||||||
|
| Custom tools | Your specific work | Defined by you |
|
||||||
|
|
||||||
|
### Layer 3 — Agents
|
||||||
|
|
||||||
|
- Eyebrow: `Agents`
|
||||||
|
- Descriptor: `How Fenja scales`
|
||||||
|
|
||||||
|
| Bold name | Italic role | Mono tag |
|
||||||
|
|---|---|---|
|
||||||
|
| Supervisor | Plan and dispatch | Orchestration |
|
||||||
|
| Specialists | Focused expertise | Subagents |
|
||||||
|
| Skills | Reusable capability | Portable across specialists |
|
||||||
|
| Workflows | Composed by you | Governed end-to-end |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout & sizing
|
||||||
|
|
||||||
|
The diagram is meant to occupy the right column of a two-column section (roughly 50% of the viewport width on desktop, ~600–760px wide). It is fully fluid — it scales to its container.
|
||||||
|
|
||||||
|
| Element | Spec |
|
||||||
|
|---|---|
|
||||||
|
| Outer container | `padding: 56px 56px 64px` on the standalone prototype. In the site, drop the outer padding and let the section's own grid drive margins. |
|
||||||
|
| Layer (group) | `background: var(--surface-container)` · `border-radius: 20px` · `padding: 24px 24px 28px` |
|
||||||
|
| Layer-to-layer gap | `20px` (vertical flex gap) |
|
||||||
|
| Layer header | flex row, baseline-aligned, eyebrow left + italic descriptor right, `margin-bottom: 18px` |
|
||||||
|
| Eyebrow label | Manrope · 11px · weight 600 · `letter-spacing: 0.14em` · uppercase · `color: var(--on-surface-variant)` |
|
||||||
|
| Italic descriptor | Newsreader italic · 14px · `color: var(--on-surface-muted)` |
|
||||||
|
| Card grid | `display: grid` · `gap: 14px` · 2 cols (Foundation) or 4 cols (Tools, Agents) |
|
||||||
|
| Card grid alignment | **Foundation: `align-items: start`** (preserves Knowledge's taller height) · **Tools & Agents: `align-items: stretch`** (equal heights within row) |
|
||||||
|
| Card | `background: var(--surface-container-lowest)` · `border-radius: 12px` · `padding: 18px 20px` · column flex · `gap: 6px` · `min-height: 76px` · `box-shadow: 0 1px 0 rgba(56,56,49,0.04), 0 8px 20px -16px rgba(56,56,49,0.18)` |
|
||||||
|
| Card name | Manrope · 15px · weight 600 · `letter-spacing: -0.005em` · `color: var(--on-surface)` |
|
||||||
|
| Italic row | Newsreader italic · 13px · `line-height: 1.35` · `color: var(--on-surface-variant)` |
|
||||||
|
| Mono row | Mono stack · 10.5px · `letter-spacing: 0.04em` · `line-height: 1.4` · `color: var(--on-surface-muted)` |
|
||||||
|
|
||||||
|
**No borders anywhere.** Containment is expressed entirely through the tonal step from `--surface-container` (group) to `--surface-container-lowest` (card). The card's tiny ambient shadow is the only depth cue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design tokens (use existing Fenja tokens; do not invent)
|
||||||
|
|
||||||
|
These come from `colors_and_type.css`. Map to whatever variable system the codebase uses.
|
||||||
|
|
||||||
|
```
|
||||||
|
--surface #faf6ee /* page canvas */
|
||||||
|
--surface-container #efeadc /* group / layer background */
|
||||||
|
--surface-container-lowest #fffcf7 /* card background */
|
||||||
|
--on-surface #383831 /* card name, primary text */
|
||||||
|
--on-surface-variant #5f5e5e /* italic role, eyebrow */
|
||||||
|
--on-surface-muted #8a887f /* mono tag, descriptor */
|
||||||
|
|
||||||
|
--font-sans Manrope, Inter, system-ui (weights 400, 500, 600 used)
|
||||||
|
--font-serif Newsreader, Source Serif Pro, Georgia (italic, 400)
|
||||||
|
--font-mono JetBrains Mono, IBM Plex Mono, ui-monospace
|
||||||
|
|
||||||
|
--radius-md 12px (cards)
|
||||||
|
--radius-lg 20px (groups)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entry animation
|
||||||
|
|
||||||
|
The user requested: **"each element jumps in together with text I'll add in Claude Code."**
|
||||||
|
|
||||||
|
### Intent
|
||||||
|
|
||||||
|
As the section scrolls into view, the diagram builds layer-by-layer in step with the prose on the left. Each paragraph the reader passes "summons" the corresponding layer.
|
||||||
|
|
||||||
|
### Mechanism (recommended)
|
||||||
|
|
||||||
|
Use whatever scroll-trigger primitive the site already uses (Framer Motion `whileInView`, GSAP ScrollTrigger, IntersectionObserver, etc.). Do not introduce a new motion library just for this.
|
||||||
|
|
||||||
|
### Animation values — match Fenja system motion
|
||||||
|
|
||||||
|
The Fenja design system has **one easing** and **page entrances are fade + 4px translate-up, never scale**. Use those.
|
||||||
|
|
||||||
|
```
|
||||||
|
duration: 240ms (use --duration-med)
|
||||||
|
easing: cubic-bezier(0.2, 0.0, 0, 1) (use --ease-standard)
|
||||||
|
from: opacity: 0; transform: translateY(8px)
|
||||||
|
to: opacity: 1; transform: translateY(0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the design system says 4px translate-up, but for cards inside a build sequence 8px reads better. Stay within 4–10px.
|
||||||
|
|
||||||
|
### Choreography
|
||||||
|
|
||||||
|
Three trigger points, each tied to a paragraph on the left:
|
||||||
|
|
||||||
|
| Trigger | Reveals | Stagger |
|
||||||
|
|---|---|---|
|
||||||
|
| Paragraph 1 in view | Foundation layer (group + 2 cards) | Layer fades, then cards stagger 60ms each |
|
||||||
|
| Paragraph 2 in view | Tools layer (group + 4 cards) | Same — group, then cards 60ms apart |
|
||||||
|
| Paragraph 3 in view | Agents layer (group + 4 cards) | Same |
|
||||||
|
|
||||||
|
Within a single layer, the **group container** (with eyebrow + descriptor) appears first, then its cards stagger in left-to-right.
|
||||||
|
|
||||||
|
### Reduced motion
|
||||||
|
|
||||||
|
Honor `prefers-reduced-motion: reduce` — fade in over 120ms, no translate.
|
||||||
|
|
||||||
|
### What to wire on the developer side
|
||||||
|
|
||||||
|
The component should expose a controllable visibility state per layer, e.g.:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FenjaArchitecture
|
||||||
|
visibleLayers={{ foundation: true, tools: paragraph2InView, agents: paragraph3InView }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
…or accept three IntersectionObserver refs (one per paragraph) and drive its own visibility internally. Either is fine — pick whichever suits the codebase's conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior when the section is statically rendered (no JS)
|
||||||
|
|
||||||
|
If the page renders without JS (SSR, RSS preview, screenshot tool, print), all three layers must be visible. Animation is a progressive enhancement — the static state is the final state, and the animation should ramp from that final state's opposite, not block on JS to render content. (Translation: render at full opacity by default and let the animation library set initial opacity:0 only when it's ready.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
No app state. Pure presentational component. All content is static; nothing is fetched.
|
||||||
|
|
||||||
|
If the prose paragraphs and the diagram need to coordinate, expose a single `currentLayer: 'foundation' | 'tools' | 'agents' | null` prop or use shared IntersectionObserver hooks — implementer's choice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
None. The diagram is text-on-paper — no icons, no images, no SVG illustrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files in this bundle
|
||||||
|
|
||||||
|
```
|
||||||
|
design_handoff_architecture_diagram/
|
||||||
|
├── README.md ← you are here
|
||||||
|
└── reference/
|
||||||
|
├── preview.html ← open this to see the diagram render
|
||||||
|
├── approach-fenja.jsx ← the React reference component
|
||||||
|
├── colors_and_type.css ← Fenja design tokens (variables + base styles)
|
||||||
|
└── fonts/ ← Manrope + Newsreader TTFs (self-hosted)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
- [ ] Component scaffolded in the codebase's preferred framework (React, Vue, etc.)
|
||||||
|
- [ ] All three layers render with the exact copy above
|
||||||
|
- [ ] Foundation: Knowledge box visibly taller than Language model (do not equalize)
|
||||||
|
- [ ] Tools: 4 cards flush-equal in height (`align-items: stretch`)
|
||||||
|
- [ ] Agents: 4 cards flush-equal in height (`align-items: stretch`)
|
||||||
|
- [ ] Eyebrow + italic descriptor in the header of every layer
|
||||||
|
- [ ] Mono tags use the site's monospace stack (do not substitute regular text)
|
||||||
|
- [ ] Bullet separator `·` (interpunct U+00B7) used in mono tags — not `•` or `-`
|
||||||
|
- [ ] No borders anywhere (containment = tonal step only)
|
||||||
|
- [ ] No connecting lines between layers
|
||||||
|
- [ ] No icons inside boxes
|
||||||
|
- [ ] Entry animation: fade + 8px translate-up, 240ms, `cubic-bezier(0.2, 0, 0, 1)`
|
||||||
|
- [ ] Per-layer trigger tied to corresponding left-column paragraph entering viewport
|
||||||
|
- [ ] Cards within a layer stagger 60ms left-to-right
|
||||||
|
- [ ] `prefers-reduced-motion: reduce` honored
|
||||||
|
- [ ] Static render is fully visible without JS
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
// Fenja architecture — right-side diagram for the section page.
|
||||||
|
// Sibling of Approach 1 (Editorial Stack). Same paper-on-paper
|
||||||
|
// language: tonal nested cards, no borders, eyebrow + italic
|
||||||
|
// descriptor in the group header. No connectors between layers.
|
||||||
|
//
|
||||||
|
// Three layers, top → bottom: FOUNDATION → TOOLS → AGENTS.
|
||||||
|
// Foundation has 2 boxes (Knowledge intentionally taller).
|
||||||
|
// Tools and Agents each have 4 boxes.
|
||||||
|
|
||||||
|
const fenjaArchData = {
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "foundation",
|
||||||
|
label: "Foundation",
|
||||||
|
caption: "Sovereign by design",
|
||||||
|
columns: 2,
|
||||||
|
align: "start", // intentional asymmetry — Knowledge is taller
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: "model",
|
||||||
|
name: "Language model",
|
||||||
|
rows: [
|
||||||
|
{ kind: "italic", text: "State-of-the-art, open-source" },
|
||||||
|
{ kind: "mono", text: "On-prem" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "knowledge",
|
||||||
|
name: "Knowledge",
|
||||||
|
rows: [
|
||||||
|
{ kind: "italic", text: "Organizational memory at every level" },
|
||||||
|
{ kind: "mono", text: "Organizational · Departmental · Personal" },
|
||||||
|
{ kind: "italic", text: "Facts, context, and how things get done" },
|
||||||
|
{ kind: "mono", text: "Wiki · Routines · Working memory" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tools",
|
||||||
|
label: "Tools",
|
||||||
|
caption: "How Fenja acts",
|
||||||
|
columns: 4,
|
||||||
|
cards: [
|
||||||
|
{ id: "retrieval", name: "Document retrieval",
|
||||||
|
rows: [{ kind: "italic", text: "Find and cite" }, { kind: "mono", text: "RAG" }] },
|
||||||
|
{ id: "structured", name: "Structured data",
|
||||||
|
rows: [{ kind: "italic", text: "Query and extract" }, { kind: "mono", text: "NL → SQL" }] },
|
||||||
|
{ id: "actions", name: "System actions",
|
||||||
|
rows: [{ kind: "italic", text: "Read and write" }, { kind: "mono", text: "APIs · integrations" }] },
|
||||||
|
{ id: "custom", name: "Custom tools",
|
||||||
|
rows: [{ kind: "italic", text: "Your specific work" }, { kind: "mono", text: "Defined by you" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agents",
|
||||||
|
label: "Agents",
|
||||||
|
caption: "How Fenja scales",
|
||||||
|
columns: 4,
|
||||||
|
cards: [
|
||||||
|
{ id: "supervisor", name: "Supervisor",
|
||||||
|
rows: [{ kind: "italic", text: "Plan and dispatch" }, { kind: "mono", text: "Orchestration" }] },
|
||||||
|
{ id: "specialists", name: "Specialists",
|
||||||
|
rows: [{ kind: "italic", text: "Focused expertise" }, { kind: "mono", text: "Subagents" }] },
|
||||||
|
{ id: "skills", name: "Skills",
|
||||||
|
rows: [{ kind: "italic", text: "Reusable capability" }, { kind: "mono", text: "Portable across specialists" }] },
|
||||||
|
{ id: "workflows", name: "Workflows",
|
||||||
|
rows: [{ kind: "italic", text: "Composed by you" }, { kind: "mono", text: "Governed end-to-end" }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fenjaStyles = {
|
||||||
|
artboard: {
|
||||||
|
width: "100%",
|
||||||
|
minHeight: "100%",
|
||||||
|
background: "var(--surface)",
|
||||||
|
padding: "56px 56px 64px",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
color: "var(--on-surface)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
},
|
||||||
|
stage: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "20px",
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
background: "var(--surface-container)",
|
||||||
|
borderRadius: "20px",
|
||||||
|
padding: "24px 24px 28px",
|
||||||
|
},
|
||||||
|
groupHeader: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "18px",
|
||||||
|
paddingLeft: "4px",
|
||||||
|
},
|
||||||
|
groupLabel: {
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.14em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--on-surface-variant)",
|
||||||
|
},
|
||||||
|
groupCaption: {
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontStyle: "italic",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "var(--on-surface-muted)",
|
||||||
|
},
|
||||||
|
cards: {
|
||||||
|
display: "grid",
|
||||||
|
gap: "14px",
|
||||||
|
},
|
||||||
|
cardsStretch: { alignItems: "stretch" },
|
||||||
|
cardsStart: { alignItems: "start" },
|
||||||
|
card: {
|
||||||
|
background: "var(--surface-container-lowest)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "18px 20px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "6px",
|
||||||
|
minHeight: "76px",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
boxShadow: "0 1px 0 rgba(56,56,49,0.04), 0 8px 20px -16px rgba(56,56,49,0.18)",
|
||||||
|
},
|
||||||
|
cardName: {
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: "15px",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "-0.005em",
|
||||||
|
color: "var(--on-surface)",
|
||||||
|
marginBottom: "2px",
|
||||||
|
},
|
||||||
|
rowItalic: {
|
||||||
|
fontFamily: "var(--font-serif)",
|
||||||
|
fontStyle: "italic",
|
||||||
|
fontSize: "13px",
|
||||||
|
color: "var(--on-surface-variant)",
|
||||||
|
lineHeight: 1.35,
|
||||||
|
},
|
||||||
|
rowMono: {
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: "10.5px",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--on-surface-muted)",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function FenjaArchitecture({ data = fenjaArchData }) {
|
||||||
|
return (
|
||||||
|
<div style={fenjaStyles.artboard}>
|
||||||
|
<div style={fenjaStyles.stage}>
|
||||||
|
{data.layers.map((layer) => (
|
||||||
|
<section key={layer.id} style={fenjaStyles.group}>
|
||||||
|
<div style={fenjaStyles.groupHeader}>
|
||||||
|
<div style={fenjaStyles.groupLabel}>{layer.label}</div>
|
||||||
|
<div style={fenjaStyles.groupCaption}>{layer.caption}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...fenjaStyles.cards,
|
||||||
|
...(layer.align === "start" ? fenjaStyles.cardsStart : fenjaStyles.cardsStretch),
|
||||||
|
gridTemplateColumns: `repeat(${layer.columns}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer.cards.map((c) => (
|
||||||
|
<div key={c.id} style={fenjaStyles.card}>
|
||||||
|
<div style={fenjaStyles.cardName}>{c.name}</div>
|
||||||
|
{c.rows.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={r.kind === "mono" ? fenjaStyles.rowMono : fenjaStyles.rowItalic}
|
||||||
|
>
|
||||||
|
{r.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.FenjaArchitecture = FenjaArchitecture;
|
||||||
|
window.FENJA_ARCH_DATA = fenjaArchData;
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
/* =============================================================
|
||||||
|
Fenja AI — Nordic Editorial Design System
|
||||||
|
"The Digital Archivist"
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* ---------- Fonts ------------------------------------------ */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Manrope-ExtraLight.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Manrope-Light.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Manrope-Regular.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Manrope-Medium.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Manrope-SemiBold.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Manrope-Bold.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Manrope-ExtraBold.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Newsreader-Regular.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Newsreader-Italic.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Newsreader-Bold.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Newsreader-BoldItalic.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Newsreader";
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("./fonts/Newsreader-ExtraBold.ttf") format("truetype");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tokens ----------------------------------------- */
|
||||||
|
:root {
|
||||||
|
/* --- Core neutrals (unbleached paper, clay, slate) --- */
|
||||||
|
--background: #faf6ee; /* base canvas — warm paper */
|
||||||
|
--surface: #faf6ee;
|
||||||
|
--surface-container-lowest: #fffcf7; /* most-elevated — unbleached paper, never pure white */
|
||||||
|
--surface-container-low: #f6f2e8;
|
||||||
|
--surface-container: #efeadc;
|
||||||
|
--surface-container-high: #e7e1d0;
|
||||||
|
--surface-container-highest: #ddd6c3;
|
||||||
|
--surface-variant: #ddd6c3;
|
||||||
|
|
||||||
|
--on-surface: #383831; /* charcoal slate */
|
||||||
|
--on-surface-variant: #5f5e5e;
|
||||||
|
--on-surface-muted: #8a887f;
|
||||||
|
|
||||||
|
--primary: #5f5e5e;
|
||||||
|
--on-primary: #fffcf7;
|
||||||
|
|
||||||
|
--secondary: #785f53; /* hand-rubbed wood */
|
||||||
|
--secondary-dim: #6b5348;
|
||||||
|
--on-secondary: #ffffff;
|
||||||
|
--secondary-fixed-dim: #9a8679;
|
||||||
|
|
||||||
|
--outline: #babab0;
|
||||||
|
--outline-variant: #babab0; /* used at 15% for ghost borders */
|
||||||
|
|
||||||
|
/* --- Archival Pigment accent palette (flat, matte inks) --- */
|
||||||
|
--pigment-terracotta: #b96b58; /* warnings, critical */
|
||||||
|
--pigment-copper: #6d8c7c; /* success, growth */
|
||||||
|
--pigment-ochre: #c29d59; /* cautions, tertiary */
|
||||||
|
--pigment-indigo: #5a6d83; /* info, neutral data */
|
||||||
|
--pigment-heather: #8d7a85; /* categorical, supportive */
|
||||||
|
|
||||||
|
/* --- Semantic state mappings --- */
|
||||||
|
--color-success: var(--pigment-copper);
|
||||||
|
--color-warning: var(--pigment-ochre);
|
||||||
|
--color-danger: var(--pigment-terracotta);
|
||||||
|
--color-info: var(--pigment-indigo);
|
||||||
|
|
||||||
|
/* --- Type families --- */
|
||||||
|
--font-serif: "Newsreader", "Source Serif Pro", Georgia, "Times New Roman", serif;
|
||||||
|
--font-sans: "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
|
||||||
|
/* --- Type scale (clamped for responsive) --- */
|
||||||
|
--text-display-xl: clamp(3.5rem, 6vw, 5.5rem); /* 56–88 */
|
||||||
|
--text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 48–72 */
|
||||||
|
--text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 40–56 */
|
||||||
|
--text-headline-lg: 2.25rem; /* 36 */
|
||||||
|
--text-headline-md: 1.75rem; /* 28 */
|
||||||
|
--text-headline-sm: 1.375rem; /* 22 */
|
||||||
|
--text-title-lg: 1.125rem; /* 18 */
|
||||||
|
--text-title-md: 1rem; /* 16 */
|
||||||
|
--text-body-lg: 1.0625rem; /* 17 */
|
||||||
|
--text-body-md: 1rem; /* 16 */
|
||||||
|
--text-body-sm: 0.875rem; /* 14 */
|
||||||
|
--text-label-md: 0.8125rem; /* 13 */
|
||||||
|
--text-label-sm: 0.75rem; /* 12 */
|
||||||
|
|
||||||
|
/* Letter-spacing */
|
||||||
|
--tracking-tight: -0.02em;
|
||||||
|
--tracking-snug: -0.01em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.04em;
|
||||||
|
--tracking-wider: 0.08em;
|
||||||
|
|
||||||
|
/* Line-heights */
|
||||||
|
--leading-tight: 1.1;
|
||||||
|
--leading-snug: 1.25;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.6;
|
||||||
|
--leading-loose: 1.75;
|
||||||
|
|
||||||
|
/* --- Spacing scale (editorial, generous) --- */
|
||||||
|
--space-1: 0.25rem; /* 4 */
|
||||||
|
--space-2: 0.5rem; /* 8 */
|
||||||
|
--space-3: 0.75rem; /* 12 */
|
||||||
|
--space-4: 1rem; /* 16 */
|
||||||
|
--space-5: 1.5rem; /* 24 */
|
||||||
|
--space-6: 2rem; /* 32 — list separator default */
|
||||||
|
--space-7: 2.5rem; /* 40 */
|
||||||
|
--space-8: 2.75rem; /* 44 — hero-card padding */
|
||||||
|
--space-10: 4rem; /* 64 */
|
||||||
|
--space-12: 5rem; /* 80 */
|
||||||
|
--space-16: 6rem; /* 96 */
|
||||||
|
--space-20: 7rem; /* 112 — desktop lateral margin */
|
||||||
|
--space-24: 8rem; /* 128 */
|
||||||
|
|
||||||
|
/* --- Radii --- */
|
||||||
|
--radius-none: 0;
|
||||||
|
--radius-sm: 0.375rem; /* 6 */
|
||||||
|
--radius-md: 0.75rem; /* 12 — primary */
|
||||||
|
--radius-lg: 1.25rem; /* 20 */
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* --- Elevation (atmospheric, warm) --- */
|
||||||
|
--shadow-none: none;
|
||||||
|
--shadow-ambient: 0 12px 32px -12px rgba(56, 56, 49, 0.06);
|
||||||
|
--shadow-float: 0 24px 48px -16px rgba(56, 56, 49, 0.05), 0 4px 12px -4px rgba(56, 56, 49, 0.04);
|
||||||
|
--shadow-modal: 0 40px 64px -24px rgba(56, 56, 49, 0.08), 0 8px 16px -6px rgba(56, 56, 49, 0.04);
|
||||||
|
|
||||||
|
/* --- Ghost border (WCAG fallback only) --- */
|
||||||
|
--ghost-border-color: rgba(186, 186, 176, 0.15);
|
||||||
|
--ghost-border: 1px solid var(--ghost-border-color);
|
||||||
|
|
||||||
|
/* --- Glass --- */
|
||||||
|
--glass-blur: blur(16px);
|
||||||
|
--glass-surface: rgba(255, 252, 247, 0.8);
|
||||||
|
|
||||||
|
/* --- Motion --- */
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0.0, 0, 1);
|
||||||
|
--ease-entrance: cubic-bezier(0, 0, 0, 1);
|
||||||
|
--ease-exit: cubic-bezier(0.3, 0, 1, 1);
|
||||||
|
--duration-fast: 140ms;
|
||||||
|
--duration-med: 240ms;
|
||||||
|
--duration-slow: 420ms;
|
||||||
|
|
||||||
|
/* --- Layout --- */
|
||||||
|
--content-max: 72rem; /* 1152 */
|
||||||
|
--reading-max: 42rem; /* 672 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Base semantic styles --------------------------- */
|
||||||
|
html {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--on-surface);
|
||||||
|
background: var(--background);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--on-surface);
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display — serif, tight, left-aligned editorial intent */
|
||||||
|
.display-xl,
|
||||||
|
.display-lg,
|
||||||
|
.display-md {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
color: var(--on-surface);
|
||||||
|
margin: 0 0 var(--space-5) 0;
|
||||||
|
}
|
||||||
|
.display-xl { font-size: var(--text-display-xl); }
|
||||||
|
.display-lg { font-size: var(--text-display-lg); }
|
||||||
|
.display-md { font-size: var(--text-display-md); }
|
||||||
|
|
||||||
|
/* Headlines — serif, authoritative */
|
||||||
|
h1, .headline-lg,
|
||||||
|
h2, .headline-md,
|
||||||
|
h3, .headline-sm {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--on-surface);
|
||||||
|
letter-spacing: var(--tracking-snug);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
}
|
||||||
|
h1, .headline-lg { font-size: var(--text-headline-lg); }
|
||||||
|
h2, .headline-md { font-size: var(--text-headline-md); }
|
||||||
|
h3, .headline-sm { font-size: var(--text-headline-sm); }
|
||||||
|
|
||||||
|
/* Titles — sans, precise structural labels */
|
||||||
|
h4, .title-lg,
|
||||||
|
h5, .title-md {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface);
|
||||||
|
letter-spacing: var(--tracking-normal);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
margin: 0 0 var(--space-3) 0;
|
||||||
|
}
|
||||||
|
h4, .title-lg { font-size: var(--text-title-lg); }
|
||||||
|
h5, .title-md { font-size: var(--text-title-md); }
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
p, .body-md {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--on-surface);
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.body-lg {
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
.body-sm {
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels — muted, small caps optional */
|
||||||
|
.label-md,
|
||||||
|
.label-sm {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.label-md { font-size: var(--text-label-md); }
|
||||||
|
.label-sm { font-size: var(--text-label-sm); }
|
||||||
|
|
||||||
|
/* Editorial lead — serif italic, subtle */
|
||||||
|
.lead {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code / mono */
|
||||||
|
code, kbd, samp, pre, .mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links — editorial, no underline until hover */
|
||||||
|
a {
|
||||||
|
color: var(--secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(120, 95, 83, 0.3);
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-standard),
|
||||||
|
color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--secondary-dim);
|
||||||
|
border-bottom-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection — warm, not blue */
|
||||||
|
::selection {
|
||||||
|
background: rgba(120, 95, 83, 0.18);
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility: ghost border fallback */
|
||||||
|
.ghost-border { border: var(--ghost-border); }
|
||||||
|
.ghost-border-bottom { border-bottom: var(--ghost-border); }
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Fenja architecture diagram — reference</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css" />
|
||||||
|
<style>
|
||||||
|
html, body { height: 100%; margin: 0; background: var(--surface); }
|
||||||
|
body { font-family: var(--font-sans); }
|
||||||
|
.wrap {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--on-surface-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="note">Reference rendering — implement in your codebase, do not ship this HTML.</div>
|
||||||
|
<div id="root"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/babel" src="approach-fenja.jsx"></script>
|
||||||
|
<script type="text/babel" data-presets="react">
|
||||||
|
const Fenja = window.FenjaArchitecture;
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<Fenja />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
examples/capabilities.png
Normal file
BIN
examples/capabilities.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
480
protected/_archive/stack-scene.html
Normal file
480
protected/_archive/stack-scene.html
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Archive — stack-scene + words-scene (removed 2026-05-19)</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-monospace, Menlo, monospace; max-width: 1100px;
|
||||||
|
margin: 40px auto; padding: 0 24px; line-height: 1.45; color: #383831; }
|
||||||
|
h1 { font-size: 20px; }
|
||||||
|
h2 { font-size: 16px; margin-top: 32px; }
|
||||||
|
pre { background: #f4efe2; padding: 16px; border-radius: 6px;
|
||||||
|
overflow: auto; font-size: 12px; line-height: 1.5; }
|
||||||
|
code { font-family: inherit; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Archive — 4-capabilities (#stack-scene) + "This is why we've invited you" (#words-scene)</h1>
|
||||||
|
|
||||||
|
<p>These two sections were removed from <code>protected/index.html</code> on 2026-05-19
|
||||||
|
when the experience was reframed from a Project Bifrost personal invitation into a
|
||||||
|
customer presentation. Kept here so they can be restored.</p>
|
||||||
|
|
||||||
|
<h2>To restore</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Copy the <code><template id="stack-scene-html"></code> and
|
||||||
|
<code><template id="words-scene-html"></code> contents back into
|
||||||
|
<code>#overview-scroll</code> in <code>protected/index.html</code>, between
|
||||||
|
<code>#hero</code> and <code>#bifrost</code> (in that order).</li>
|
||||||
|
<li>Copy the JS in <code><script type="text/x-archived-js" id="stack-words-js"></code>
|
||||||
|
back into <code>protected/bifrost.js</code>, right after the HERO fade-in (the
|
||||||
|
<code>gsap.to('.hero-wrap', { opacity: 1, ... })</code> block).</li>
|
||||||
|
<li>In <code>bifrost.js</code>, add <code>'stack-scene'</code> and <code>'words-scene'</code>
|
||||||
|
back into <code>sceneOrder</code> (between <code>'hero'</code> and <code>'bifrost'</code>),
|
||||||
|
add their entries to <code>sceneToDot</code>, and add <code>'words-scene'</code> back to
|
||||||
|
the <code>sceneIds</code> array inside <code>collectStickyTargets()</code>.</li>
|
||||||
|
<li>Restore the "Capabilities" dot-nav button in <code>protected/index.html</code>:
|
||||||
|
<pre><button class="dot-btn" data-target="page-overview" data-scroll-to="stack-scene">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="label">Capabilities</span>
|
||||||
|
</button></pre></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>HTML — #stack-scene (Scene 2)</h2>
|
||||||
|
<template id="stack-scene-html">
|
||||||
|
<!-- ============================================================
|
||||||
|
SCENE 2 — ARCHITECTURE (pinned, scrubbed)
|
||||||
|
============================================================ -->
|
||||||
|
<section id="stack-scene" aria-label="The Fenja AI architecture">
|
||||||
|
<div class="stack-pin">
|
||||||
|
<!-- Title bar: rides along with the pin so it stays visible while
|
||||||
|
the reader scrolls through all 4 capability cards. The per-card
|
||||||
|
counter now lives inside each .layer-card (see .card-counter
|
||||||
|
below), so the title here is a standalone centered lockup. -->
|
||||||
|
<div class="stack-title-bar" aria-hidden="true">
|
||||||
|
<h2 class="stack-title">The Fenja AI platform in four steps</h2>
|
||||||
|
</div>
|
||||||
|
<div class="layer-theatre">
|
||||||
|
|
||||||
|
<!-- LEFT SIDE — explanatory copy, visible only during the grid phase. -->
|
||||||
|
<div class="copy-stage" aria-live="polite">
|
||||||
|
<div class="copy-layer" data-copy="0">
|
||||||
|
<span class="tag">One complete platform</span>
|
||||||
|
<h2>Everything you need <em>in one place.</em></h2>
|
||||||
|
<p>Fenja AI brings models, knowledge, tools, and agents together in one platform for using and scaling AI across your organisation.</p>
|
||||||
|
</div>
|
||||||
|
<div class="copy-layer" data-copy="1">
|
||||||
|
<span class="tag">Full control</span>
|
||||||
|
<h2>Your <strong>infrastructure.</strong><br/>Your <em>rules.</em></h2>
|
||||||
|
<p>Fenja AI is installed in your own client-managed environment, giving you full control over data, security, and governance.</p>
|
||||||
|
</div>
|
||||||
|
<div class="copy-layer" data-copy="2">
|
||||||
|
<span class="tag">Sovereignty</span>
|
||||||
|
<h2>Built in <strong>Denmark.</strong><br/>Ready for <em>Europe.</em></h2>
|
||||||
|
<p>Fenja AI is built in Denmark for European organisations that want trusted, sovereign AI on their own terms.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LAYER CARDS — drop in, stack, then rearrange to grid -->
|
||||||
|
<article class="layer-card" data-layer="0" aria-label="Layer 1: the AI">
|
||||||
|
<span class="card-eyebrow">The AI</span>
|
||||||
|
<div class="card-box">
|
||||||
|
<span class="card-counter" aria-hidden="true">1 / 4</span>
|
||||||
|
<span class="card-grid-label" aria-hidden="true">The AI</span>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-title">An <b>open-source</b> model, running on your <em>own hardware.</em></h3>
|
||||||
|
<p class="card-body">A state-of-the-art open-source language model deployed directly in your environment. It gives you powerful AI capabilities with full control over data, performance, and security.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-brain" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="layer-card" data-layer="1" aria-label="Layer 2: Knowledge">
|
||||||
|
<span class="card-eyebrow">The Knowledge</span>
|
||||||
|
<div class="card-box">
|
||||||
|
<span class="card-counter" aria-hidden="true">2 / 4</span>
|
||||||
|
<span class="card-grid-label" aria-hidden="true">The Knowledge</span>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-title">The business context that makes <em>AI understand your world.</em></h3>
|
||||||
|
<p class="card-body">A built-in knowledge layer that helps the platform understand your terminology, processes, and data. It retains what matters, improves over time, and gives the AI the context needed to deliver relevant and accurate results.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-brain" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="layer-card" data-layer="2" aria-label="Layer 3: Tools">
|
||||||
|
<span class="card-eyebrow">The Tools</span>
|
||||||
|
<div class="card-box">
|
||||||
|
<span class="card-counter" aria-hidden="true">3 / 4</span>
|
||||||
|
<span class="card-grid-label" aria-hidden="true">The Tools</span>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-title">How AI <b>acts</b> — not just what it <em>knows.</em></h3>
|
||||||
|
<p class="card-body">The capabilities that let the platform do real work across your environment. From search and retrieval to data access, automation, and analysis, these are the tools the AI uses to solve tasks in practice.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-brain" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="layer-card" data-layer="3" aria-label="Layer 4: Agents">
|
||||||
|
<span class="card-eyebrow">The Agents</span>
|
||||||
|
<div class="card-box">
|
||||||
|
<span class="card-counter" aria-hidden="true">4 / 4</span>
|
||||||
|
<span class="card-grid-label" aria-hidden="true">The Agents</span>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-title">Specialized AI agents <b>working together</b> around <em>real tasks.</em></h3>
|
||||||
|
<p class="card-body">Purpose-built agents designed to handle distinct roles and workflows. Fenja AI includes both ready-made agents and the framework to build new ones, so you can orchestrate AI the same way your organisation already works — through specialisation and coordination.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-brain" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h2>HTML — #words-scene (Scene 3)</h2>
|
||||||
|
<template id="words-scene-html">
|
||||||
|
<!-- ============================================================
|
||||||
|
SCENE 3 — SLIDE 11 — WORDS FLY IN ONE AT A TIME
|
||||||
|
============================================================ -->
|
||||||
|
<section id="words-scene" aria-labelledby="words-head">
|
||||||
|
<h3 id="words-head" class="sr-only" style="position:absolute;left:-9999px;">
|
||||||
|
This is why we've invited you. To ensure Fenja AI is not just built for you, but with you.
|
||||||
|
</h3>
|
||||||
|
<div class="words-pin">
|
||||||
|
<p class="words" aria-hidden="true" id="words-sentence">
|
||||||
|
<span class="w">This</span>
|
||||||
|
<span class="w">is</span>
|
||||||
|
<span class="w">why</span>
|
||||||
|
<span class="w">we’ve</span>
|
||||||
|
<span class="w">invited</span>
|
||||||
|
<span class="w hi">you.</span>
|
||||||
|
<span class="w">To</span>
|
||||||
|
<span class="w">ensure</span>
|
||||||
|
<span class="w">Fenja</span>
|
||||||
|
<span class="w">AI</span>
|
||||||
|
<span class="w">is</span>
|
||||||
|
<span class="w">not</span>
|
||||||
|
<span class="w">just</span>
|
||||||
|
<span class="w">built</span>
|
||||||
|
<span class="w">for</span>
|
||||||
|
<span class="w">you</span>
|
||||||
|
<span class="w">—</span>
|
||||||
|
<span class="w">but</span>
|
||||||
|
<span class="w hi">with</span>
|
||||||
|
<span class="w hi">you.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h2>JS — bifrost.js timelines (stack-scene + words-scene)</h2>
|
||||||
|
<script type="text/x-archived-js" id="stack-words-js">
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
ARCHITECTURE — two-phase scrubbed sequence
|
||||||
|
Phase A (0.00 – 0.45): each of 4 layer-cards falls from above
|
||||||
|
and lands at a progressively higher Y offset so the previous
|
||||||
|
card's bottom strip peeks out below. Only the topmost card's
|
||||||
|
eyebrow is visible at any time.
|
||||||
|
Phase B (0.50 – 1.00): the stack rearranges into a 2x2 grid on
|
||||||
|
the right side. Body text in each card fades out; eyebrow
|
||||||
|
stays. Explanatory copy crossfades on the LEFT, three panels:
|
||||||
|
~0.55 "All the capabilities to solve business use cases"
|
||||||
|
~0.70 "Full client control / Complete sovereignty"
|
||||||
|
~0.85 "Built in Denmark / For Europe"
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
const theatre = document.querySelector('.layer-theatre');
|
||||||
|
const cards = gsap.utils.toArray('.layer-card');
|
||||||
|
const copyLayers = gsap.utils.toArray('.copy-layer');
|
||||||
|
|
||||||
|
// Each card lands N pixels higher than the previous — previous's
|
||||||
|
// bottom strip is visible below.
|
||||||
|
const STACK_OFFSET_PER_CARD = 22; // px, upward
|
||||||
|
|
||||||
|
// Compute grid target positions. In .in-grid mode, each card-box is
|
||||||
|
// 20vw square and centered (via margin:auto) inside its full-width
|
||||||
|
// parent .layer-card. We translate the parent card so the box lands
|
||||||
|
// at the correct grid-cell position.
|
||||||
|
function computeGridPlan() {
|
||||||
|
const W = theatre.offsetWidth;
|
||||||
|
const H = theatre.offsetHeight;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
|
||||||
|
const cellSize = vw * 0.17; // matches .in-grid .card-box width (17vw)
|
||||||
|
const gap = Math.max(14, vw * 0.014);
|
||||||
|
|
||||||
|
const totalW = 2 * cellSize + gap;
|
||||||
|
const totalH = 2 * cellSize + gap;
|
||||||
|
|
||||||
|
// Right-anchor grid so it sits flush with the right side of the theatre
|
||||||
|
const gridRight = W * 0.99;
|
||||||
|
const gridStartX = gridRight - totalW;
|
||||||
|
const gridStartY = (H - totalH) / 2;
|
||||||
|
|
||||||
|
// Grid cell centers (in theatre coordinates), reading order: TL, TR, BL, BR
|
||||||
|
const centers = [
|
||||||
|
{ cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 },
|
||||||
|
{ cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 },
|
||||||
|
{ cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 + cellSize + gap },
|
||||||
|
{ cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 + cellSize + gap },
|
||||||
|
];
|
||||||
|
|
||||||
|
// In grid mode the card-box's horizontal center is the theatre horizontal
|
||||||
|
// center (via margin:auto). That's our anchor for dx computations.
|
||||||
|
const theatreCx = W / 2;
|
||||||
|
const theatreCy = H / 2;
|
||||||
|
|
||||||
|
return { cellSize, theatreCx, theatreCy, centers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state — hide everything, set card translations.
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
gsap.set(card, { xPercent: 0, yPercent: -50, opacity: 0, x: 0, y: 0, rotation: 0, scale: 1 });
|
||||||
|
gsap.set(card.querySelector('.card-eyebrow'), { opacity: 0 });
|
||||||
|
});
|
||||||
|
copyLayers.forEach(el => gsap.set(el, { yPercent: -50, opacity: 0, y: 20 }));
|
||||||
|
|
||||||
|
const stackTl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#stack-scene',
|
||||||
|
start: 'top top',
|
||||||
|
end: '+=5000',
|
||||||
|
scrub: 0.6,
|
||||||
|
pin: '.stack-pin',
|
||||||
|
pinSpacing: true,
|
||||||
|
anticipatePin: 1,
|
||||||
|
invalidateOnRefresh: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------- Phase A: card landings --------
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
const landingY = -i * STACK_OFFSET_PER_CARD;
|
||||||
|
const t = i * 0.105;
|
||||||
|
|
||||||
|
stackTl
|
||||||
|
.fromTo(card,
|
||||||
|
{ y: -900, rotation: (i % 2 === 0 ? -4 : 4), scale: 0.97 },
|
||||||
|
{ y: landingY, rotation: 0, scale: 1, duration: 0.09, ease: 'power3.out' },
|
||||||
|
t);
|
||||||
|
|
||||||
|
stackTl.fromTo(card,
|
||||||
|
{ opacity: 0 },
|
||||||
|
{ opacity: 1, duration: 0.065, ease: 'power2.out' },
|
||||||
|
t + 0.015);
|
||||||
|
|
||||||
|
stackTl
|
||||||
|
.to(card, { y: landingY + 4, duration: 0.012, ease: 'power1.out' }, t + 0.092)
|
||||||
|
.to(card, { y: landingY, duration: 0.02, ease: 'power2.inOut' }, t + 0.105);
|
||||||
|
|
||||||
|
stackTl.to(card.querySelector('.card-eyebrow'),
|
||||||
|
{ opacity: 1, duration: 0.025, ease: 'power2.out' },
|
||||||
|
t + 0.06);
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
stackTl.to(cards[i - 1].querySelector('.card-eyebrow'),
|
||||||
|
{ opacity: 0, duration: 0.02, ease: 'power2.in' },
|
||||||
|
t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------- Phase B: rearrange to grid + fade copy --------
|
||||||
|
const PHASE_B_START = 0.58;
|
||||||
|
|
||||||
|
function scheduleGridTransition() {
|
||||||
|
const plan = computeGridPlan();
|
||||||
|
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const cardRect = cards[0].getBoundingClientRect();
|
||||||
|
const cardW = cardRect.width || vw;
|
||||||
|
const cardH = cardRect.height || 600;
|
||||||
|
const targetW = vw * 0.17;
|
||||||
|
const targetH = targetW;
|
||||||
|
const targetScaleX = targetW / cardW;
|
||||||
|
const targetScaleY = targetH / cardH;
|
||||||
|
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
const target = plan.centers[i];
|
||||||
|
const dx = target.cx - plan.theatreCx;
|
||||||
|
const dy = target.cy - plan.theatreCy;
|
||||||
|
const content = card.querySelector('.card-content');
|
||||||
|
const gridLabel = card.querySelector('.card-grid-label');
|
||||||
|
const brain = card.querySelector('.card-brain');
|
||||||
|
|
||||||
|
stackTl.to(card,
|
||||||
|
{ x: dx, y: dy,
|
||||||
|
scaleX: targetScaleX, scaleY: targetScaleY,
|
||||||
|
rotation: 0,
|
||||||
|
duration: 0.14, ease: 'power2.inOut',
|
||||||
|
transformOrigin: 'center center' },
|
||||||
|
PHASE_B_START);
|
||||||
|
|
||||||
|
const counterScaleX = targetScaleY / targetScaleX;
|
||||||
|
stackTl.to(brain,
|
||||||
|
{ scaleX: counterScaleX,
|
||||||
|
duration: 0.14, ease: 'power2.inOut',
|
||||||
|
transformOrigin: 'right center',
|
||||||
|
immediateRender: false },
|
||||||
|
PHASE_B_START);
|
||||||
|
|
||||||
|
const resetVars = {
|
||||||
|
scaleX: 1, scaleY: 1,
|
||||||
|
duration: 0.00001,
|
||||||
|
immediateRender: false,
|
||||||
|
};
|
||||||
|
if (i === 0) {
|
||||||
|
resetVars.onStart = function() {
|
||||||
|
theatre.classList.add('in-grid');
|
||||||
|
};
|
||||||
|
resetVars.onReverseComplete = function() {
|
||||||
|
theatre.classList.remove('in-grid');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
stackTl.to(card, resetVars, PHASE_B_START + 0.14);
|
||||||
|
|
||||||
|
stackTl.to(brain,
|
||||||
|
{ scaleX: 1, duration: 0.00001, immediateRender: false },
|
||||||
|
PHASE_B_START + 0.14);
|
||||||
|
|
||||||
|
stackTl.to(content,
|
||||||
|
{ opacity: 0, duration: 0.08, ease: 'power2.in' },
|
||||||
|
PHASE_B_START);
|
||||||
|
stackTl.to(gridLabel,
|
||||||
|
{ opacity: 0.88, duration: 0.08, ease: 'power2.out' },
|
||||||
|
PHASE_B_START + 0.06);
|
||||||
|
|
||||||
|
stackTl.to(card.querySelector('.card-eyebrow'),
|
||||||
|
{ opacity: 0, duration: 0.06, ease: 'power2.in' },
|
||||||
|
PHASE_B_START);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scheduleGridTransition();
|
||||||
|
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => ScrollTrigger.refresh(), 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------- Copy layer crossfade on the LEFT (during grid phase) --------
|
||||||
|
const FADE = 0.025;
|
||||||
|
const swap = (fromIdx, toIdx, pos) => {
|
||||||
|
if (fromIdx !== null) {
|
||||||
|
stackTl.to(copyLayers[fromIdx], { opacity: 0, y: -14, duration: FADE, ease: 'power2.in' }, pos);
|
||||||
|
}
|
||||||
|
stackTl.fromTo(copyLayers[toIdx],
|
||||||
|
{ opacity: 0, y: 16 },
|
||||||
|
{ opacity: 1, y: 0, duration: FADE, ease: 'power2.out' },
|
||||||
|
pos + FADE + 0.002);
|
||||||
|
};
|
||||||
|
|
||||||
|
stackTl.fromTo(copyLayers[0],
|
||||||
|
{ opacity: 0, y: 16 },
|
||||||
|
{ opacity: 1, y: 0, duration: FADE * 1.5, ease: 'power2.out' },
|
||||||
|
PHASE_B_START + 0.08);
|
||||||
|
|
||||||
|
swap(0, 1, 0.77);
|
||||||
|
swap(1, 2, 0.90);
|
||||||
|
|
||||||
|
// Clean exit
|
||||||
|
stackTl.to('.layer-theatre', { opacity: 0, duration: 0.03, ease: 'power2.in' }, 0.97);
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------
|
||||||
|
SCENE 3 — WORDS fly in one at a time, driven by scroll
|
||||||
|
------------------------------------------------------------- */
|
||||||
|
|
||||||
|
(function rebuildWordsSentence() {
|
||||||
|
const wordsP = document.getElementById('words-sentence');
|
||||||
|
if (!wordsP) return;
|
||||||
|
|
||||||
|
const firstName = (typeof window.__fenjaFirstName === 'string')
|
||||||
|
? window.__fenjaFirstName.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let tokens;
|
||||||
|
if (firstName) {
|
||||||
|
tokens = [
|
||||||
|
{ text: 'This' }, { text: 'is' }, { text: 'why' },
|
||||||
|
{ text: 'we’ve' }, { text: 'invited' }, { text: 'you,' },
|
||||||
|
{ text: firstName + '.', hi: true },
|
||||||
|
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
|
||||||
|
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
|
||||||
|
{ text: 'just' }, { text: 'built' }, { text: 'for' },
|
||||||
|
{ text: 'you' }, { text: '—' }, { text: 'but' },
|
||||||
|
{ text: 'with', hi: true }, { text: 'you.', hi: true },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
tokens = [
|
||||||
|
{ text: 'This' }, { text: 'is' }, { text: 'why' },
|
||||||
|
{ text: 'we’ve' }, { text: 'invited' },
|
||||||
|
{ text: 'you.', hi: true },
|
||||||
|
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
|
||||||
|
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
|
||||||
|
{ text: 'just' }, { text: 'built' }, { text: 'for' },
|
||||||
|
{ text: 'you' }, { text: '—' }, { text: 'but' },
|
||||||
|
{ text: 'with', hi: true }, { text: 'you.', hi: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
wordsP.textContent = '';
|
||||||
|
tokens.forEach((t, i) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = t.hi ? 'w hi' : 'w';
|
||||||
|
span.textContent = t.text;
|
||||||
|
wordsP.appendChild(span);
|
||||||
|
if (i < tokens.length - 1) wordsP.appendChild(document.createTextNode(' '));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
const wordEls = gsap.utils.toArray('.words .w');
|
||||||
|
|
||||||
|
const rnd = (i, seed) => {
|
||||||
|
const s = Math.sin((i + 1) * seed) * 10000;
|
||||||
|
return s - Math.floor(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
wordEls.forEach((w, i) => {
|
||||||
|
const hi = w.classList.contains('hi');
|
||||||
|
const fromX = hi ? 0 : (rnd(i, 12.9898) - 0.5) * 220;
|
||||||
|
const fromY = hi ? 80 : (rnd(i, 78.233) - 0.5) * 160;
|
||||||
|
const rot = hi ? 0 : (rnd(i, 37.719) - 0.5) * 16;
|
||||||
|
gsap.set(w, {
|
||||||
|
opacity: 0,
|
||||||
|
x: fromX,
|
||||||
|
y: fromY,
|
||||||
|
rotate: rot,
|
||||||
|
scale: hi ? 1.05 : 0.9,
|
||||||
|
filter: 'blur(6px)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const wordsTl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#words-scene',
|
||||||
|
start: 'top top',
|
||||||
|
end: 'bottom bottom',
|
||||||
|
scrub: 0.4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wordEls.forEach((w, i) => {
|
||||||
|
const hi = w.classList.contains('hi');
|
||||||
|
const dur = hi ? 0.14 : 0.1;
|
||||||
|
wordsTl.to(w, {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0, y: 0, rotate: 0,
|
||||||
|
scale: 1,
|
||||||
|
filter: 'blur(0px)',
|
||||||
|
duration: dur,
|
||||||
|
ease: 'power3.out',
|
||||||
|
}, i * 0.055);
|
||||||
|
if (hi) {
|
||||||
|
wordsTl.to(w, { scale: 1.0, duration: 0.05 }, '>-0.02');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -3,9 +3,12 @@
|
||||||
// Overview page of the timeline.
|
// Overview page of the timeline.
|
||||||
//
|
//
|
||||||
// This file:
|
// This file:
|
||||||
// 1. Wraps site-2's six scroll-bound scenes (hero → architecture
|
// 1. Wraps the Overview's scroll-bound scenes (hero → aurora arc
|
||||||
// stack → words → aurora arc → treasure-map → join CTA) so
|
// → treasure-map). The 4-card architecture stack, the "This is
|
||||||
// they run inside the Overview page, not as a standalone site.
|
// why we've invited you" word fly-in, and the Project Bifrost
|
||||||
|
// Join CTA were removed in the 2026-05-19 customer-presentation
|
||||||
|
// conversion; the architecture explainer (formerly /deepdive)
|
||||||
|
// now follows the treasure-map inline — see protected/platform.js.
|
||||||
// 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
|
// 2. Rewires Lenis smooth scroll + GSAP ScrollTrigger so the
|
||||||
// scroller is the Overview's internal scrolling container —
|
// scroller is the Overview's internal scrolling container —
|
||||||
// never the window — so the three-page Timeline/Overview/
|
// never the window — so the three-page Timeline/Overview/
|
||||||
|
|
@ -188,21 +191,36 @@
|
||||||
// midpoint AND its bottom is below it. For stacked pinned scenes
|
// midpoint AND its bottom is below it. For stacked pinned scenes
|
||||||
// (S2) the pin duration makes "bottom" go well past the viewport,
|
// (S2) the pin duration makes "bottom" go well past the viewport,
|
||||||
// so the first-match wins — scenes are checked top-to-bottom.
|
// so the first-match wins — scenes are checked top-to-bottom.
|
||||||
|
// Every scrollable scene in the Overview, top-to-bottom. The
|
||||||
|
// scroll-spy walks this list and decides which one is "in
|
||||||
|
// view" by the viewport midline rule below. Intermediate
|
||||||
|
// scenes (bifrost-meaning, platform-question) map to a
|
||||||
|
// neighbouring dot via sceneToDot so the nav stays highlighted
|
||||||
|
// through them.
|
||||||
const sceneOrder = [
|
const sceneOrder = [
|
||||||
'hero', 'stack-scene', 'words-scene',
|
'hero',
|
||||||
'bifrost', 'bifrost-meaning', 'bifrost-join',
|
'bifrost', 'bifrost-meaning',
|
||||||
|
'platform-question', 'platform-layers',
|
||||||
|
'wiki-deepdive',
|
||||||
|
'platform-cards',
|
||||||
|
'platform-roadmap',
|
||||||
];
|
];
|
||||||
// Not every scene has a dot in the nav — words-scene and bifrost-meaning
|
// Maps a scene's id to the data-scroll-to of the dot that
|
||||||
// are intermediate sections with no standalone dot. Map them to the
|
// should highlight when that scene is in view.
|
||||||
// nearest surviving upstream dot so the nav stays highlighted through
|
// bifrost-meaning → bifrost (treasure-map is a
|
||||||
// those sections instead of going blank.
|
// continuation of the
|
||||||
|
// Project Bifrost reveal)
|
||||||
|
// platform-question → platform-layers (framing lead-in to
|
||||||
|
// the architecture)
|
||||||
const sceneToDot = {
|
const sceneToDot = {
|
||||||
'hero': 'hero',
|
'hero': 'hero',
|
||||||
'stack-scene': 'stack-scene',
|
'bifrost': 'bifrost',
|
||||||
'words-scene': 'stack-scene',
|
'bifrost-meaning': 'bifrost',
|
||||||
'bifrost': 'bifrost',
|
'platform-question': 'platform-layers',
|
||||||
'bifrost-meaning': 'bifrost',
|
'platform-layers': 'platform-layers',
|
||||||
'bifrost-join': 'bifrost-join',
|
'wiki-deepdive': 'wiki-deepdive',
|
||||||
|
'platform-cards': 'platform-cards',
|
||||||
|
'platform-roadmap': 'platform-roadmap',
|
||||||
};
|
};
|
||||||
let lastActiveScene = null;
|
let lastActiveScene = null;
|
||||||
function updateActiveSceneDot() {
|
function updateActiveSceneDot() {
|
||||||
|
|
@ -316,13 +334,13 @@
|
||||||
// the multiplier is restored.
|
// the multiplier is restored.
|
||||||
//
|
//
|
||||||
// Targets are:
|
// Targets are:
|
||||||
// - Non-pinned scenes (hero, words-scene, bifrost, bifrost-join)
|
// - Non-pinned scenes (hero, bifrost)
|
||||||
// - The treasure map (bifrost-meaning) AND each of its three
|
// - The treasure map (bifrost-meaning) AND each of its three
|
||||||
// stops individually — previously the whole 300vh section was
|
// stops individually — previously the whole 300vh section was
|
||||||
// one target, so users flew through the individual stops.
|
// one target, so users flew through the individual stops.
|
||||||
//
|
//
|
||||||
// stack-scene (S2) is deliberately excluded — it's GSAP-pinned and
|
// platform-layers is GSAP-pinned and scrubbed; damping on top would
|
||||||
// scrubbed; damping on top makes its card-fall feel like a drag.
|
// make its beat-by-beat build feel like a drag, so we exclude it.
|
||||||
const BASE_WHEEL_MULT = 1.0;
|
const BASE_WHEEL_MULT = 1.0;
|
||||||
const BASE_TOUCH_MULT = 1.5;
|
const BASE_TOUCH_MULT = 1.5;
|
||||||
const STICKY_WHEEL_MULT = 0.35; // 65% reduction while in a sticky zone
|
const STICKY_WHEEL_MULT = 0.35; // 65% reduction while in a sticky zone
|
||||||
|
|
@ -334,7 +352,12 @@
|
||||||
// change after init.
|
// change after init.
|
||||||
function collectStickyTargets() {
|
function collectStickyTargets() {
|
||||||
const targets = [];
|
const targets = [];
|
||||||
const sceneIds = ['hero', 'words-scene', 'bifrost', 'bifrost-join'];
|
// platform-cards is NOT a sticky-damping target — it has its
|
||||||
|
// own GSAP pin (see initCards in platform.js) which provides
|
||||||
|
// the "stop" feel. Layering both would slow wheel input to
|
||||||
|
// 0.35× during the pin and turn the 100vh budget into a
|
||||||
|
// ~285vh slog. platform-roadmap, same.
|
||||||
|
const sceneIds = ['hero', 'bifrost'];
|
||||||
sceneIds.forEach(id => {
|
sceneIds.forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) targets.push(el);
|
if (el) targets.push(el);
|
||||||
|
|
@ -395,427 +418,18 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
/* -------------------------------------------------------------
|
/* -------------------------------------------------------------
|
||||||
ARCHITECTURE — two-phase scrubbed sequence
|
ARCHITECTURE STACK + "This is why we've invited you" words —
|
||||||
Phase A (0.00 – 0.45): each of 4 layer-cards falls from above
|
REMOVED 2026-05-19 in the customer-presentation conversion.
|
||||||
and lands at a progressively higher Y offset so the previous
|
The full HTML + JS is archived at
|
||||||
card's bottom strip peeks out below. Only the topmost card's
|
protected/_archive/stack-scene.html
|
||||||
eyebrow is visible at any time.
|
so the 4-capabilities pinned-scrub sequence can be restored.
|
||||||
Phase B (0.50 – 1.00): the stack rearranges into a 2x2 grid on
|
|
||||||
the right side. Body text in each card fades out; eyebrow
|
|
||||||
stays. Explanatory copy crossfades on the LEFT, three panels:
|
|
||||||
~0.55 "All the capabilities to solve business use cases"
|
|
||||||
~0.70 "Full client control / Complete sovereignty"
|
|
||||||
~0.85 "Built in Denmark / For Europe"
|
|
||||||
------------------------------------------------------------- */
|
------------------------------------------------------------- */
|
||||||
const theatre = document.querySelector('.layer-theatre');
|
/* Removed: stack-scene timeline (computeGridPlan + 4-card scrubbed
|
||||||
const cards = gsap.utils.toArray('.layer-card');
|
build + grid morph + copy-stage crossfade). Archived in
|
||||||
const copyLayers = gsap.utils.toArray('.copy-layer');
|
protected/_archive/stack-scene.html. */
|
||||||
|
|
||||||
// Each card lands N pixels higher than the previous — previous's
|
/* Removed: words-scene "This is why we've invited you" timeline.
|
||||||
// bottom strip is visible below.
|
Archived in protected/_archive/stack-scene.html. */
|
||||||
const STACK_OFFSET_PER_CARD = 22; // px, upward
|
|
||||||
|
|
||||||
// Compute grid target positions. In .in-grid mode, each card-box is
|
|
||||||
// 20vw square and centered (via margin:auto) inside its full-width
|
|
||||||
// parent .layer-card. We translate the parent card so the box lands
|
|
||||||
// at the correct grid-cell position.
|
|
||||||
function computeGridPlan() {
|
|
||||||
const W = theatre.offsetWidth;
|
|
||||||
const H = theatre.offsetHeight;
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
|
|
||||||
const cellSize = vw * 0.17; // matches .in-grid .card-box width (17vw)
|
|
||||||
const gap = Math.max(14, vw * 0.014);
|
|
||||||
|
|
||||||
const totalW = 2 * cellSize + gap;
|
|
||||||
const totalH = 2 * cellSize + gap;
|
|
||||||
|
|
||||||
// Right-anchor grid so it sits flush with the right side of the theatre
|
|
||||||
const gridRight = W * 0.99;
|
|
||||||
const gridStartX = gridRight - totalW;
|
|
||||||
const gridStartY = (H - totalH) / 2;
|
|
||||||
|
|
||||||
// Grid cell centers (in theatre coordinates), reading order: TL, TR, BL, BR
|
|
||||||
const centers = [
|
|
||||||
{ cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 },
|
|
||||||
{ cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 },
|
|
||||||
{ cx: gridStartX + cellSize / 2, cy: gridStartY + cellSize / 2 + cellSize + gap },
|
|
||||||
{ cx: gridStartX + cellSize / 2 + cellSize + gap, cy: gridStartY + cellSize / 2 + cellSize + gap },
|
|
||||||
];
|
|
||||||
|
|
||||||
// In grid mode the card-box's horizontal center is the theatre horizontal
|
|
||||||
// center (via margin:auto). That's our anchor for dx computations.
|
|
||||||
const theatreCx = W / 2;
|
|
||||||
const theatreCy = H / 2;
|
|
||||||
|
|
||||||
return { cellSize, theatreCx, theatreCy, centers };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial state — hide everything, set card translations.
|
|
||||||
// Cards are positioned via left:0/right:0 + top:50% in CSS; we use
|
|
||||||
// yPercent:-50 to center vertically (so `y` animations remain additive).
|
|
||||||
cards.forEach((card, i) => {
|
|
||||||
gsap.set(card, { xPercent: 0, yPercent: -50, opacity: 0, x: 0, y: 0, rotation: 0, scale: 1 });
|
|
||||||
gsap.set(card.querySelector('.card-eyebrow'), { opacity: 0 });
|
|
||||||
});
|
|
||||||
// Copy layers vertically centered in copy-stage via yPercent: -50.
|
|
||||||
// The animation uses `y` for the little drop-in offset (which is additive
|
|
||||||
// to yPercent, so centering is preserved).
|
|
||||||
copyLayers.forEach(el => gsap.set(el, { yPercent: -50, opacity: 0, y: 20 }));
|
|
||||||
|
|
||||||
const stackTl = gsap.timeline({
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: '#stack-scene',
|
|
||||||
start: 'top top',
|
|
||||||
end: '+=5000', // 5.5 viewports — more scroll for the new sequence
|
|
||||||
scrub: 0.6,
|
|
||||||
pin: '.stack-pin',
|
|
||||||
pinSpacing: true,
|
|
||||||
anticipatePin: 1,
|
|
||||||
invalidateOnRefresh: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------- Phase A: card landings --------
|
|
||||||
// Card i lands at y = -i * STACK_OFFSET_PER_CARD (above baseline).
|
|
||||||
// Its eyebrow fades IN on landing; the previous card's eyebrow fades OUT.
|
|
||||||
cards.forEach((card, i) => {
|
|
||||||
const landingY = -i * STACK_OFFSET_PER_CARD;
|
|
||||||
const t = i * 0.105; // each card gets ~10.5% of timeline
|
|
||||||
|
|
||||||
// Y motion — starts above viewport. Distance reduced to -900 so the
|
|
||||||
// visible portion of the fall (from viewport top down to landing) is
|
|
||||||
// a meaningful share of the animation rather than being swallowed by
|
|
||||||
// off-screen travel that the user never sees.
|
|
||||||
stackTl
|
|
||||||
.fromTo(card,
|
|
||||||
{ y: -900, rotation: (i % 2 === 0 ? -4 : 4), scale: 0.97 },
|
|
||||||
{ y: landingY, rotation: 0, scale: 1, duration: 0.09, ease: 'power3.out' },
|
|
||||||
t);
|
|
||||||
|
|
||||||
// Opacity ramps up across most of the fall so the user sees the card
|
|
||||||
// traveling rather than just popping in at the end.
|
|
||||||
stackTl.fromTo(card,
|
|
||||||
{ opacity: 0 },
|
|
||||||
{ opacity: 1, duration: 0.065, ease: 'power2.out' },
|
|
||||||
t + 0.015);
|
|
||||||
|
|
||||||
// Settle bounce
|
|
||||||
stackTl
|
|
||||||
.to(card, { y: landingY + 4, duration: 0.012, ease: 'power1.out' }, t + 0.092)
|
|
||||||
.to(card, { y: landingY, duration: 0.02, ease: 'power2.inOut' }, t + 0.105);
|
|
||||||
|
|
||||||
// This card's eyebrow fades in
|
|
||||||
stackTl.to(card.querySelector('.card-eyebrow'),
|
|
||||||
{ opacity: 1, duration: 0.025, ease: 'power2.out' },
|
|
||||||
t + 0.06);
|
|
||||||
|
|
||||||
// Previous card's eyebrow fades out (it's now covered)
|
|
||||||
if (i > 0) {
|
|
||||||
stackTl.to(cards[i - 1].querySelector('.card-eyebrow'),
|
|
||||||
{ opacity: 0, duration: 0.02, ease: 'power2.in' },
|
|
||||||
t);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Short hold after all 4 have landed (0.42 to 0.50)
|
|
||||||
|
|
||||||
// -------- Phase B: rearrange to grid + fade copy --------
|
|
||||||
// Phase A's 4th card (Agents) finishes its fade-in around timeline 0.42,
|
|
||||||
// but Lenis + scrub:0.6 adds smoothing so visually cards settle around
|
|
||||||
// 0.55 of scroll progress. Starting Phase B at 0.58 ensures the user
|
|
||||||
// sees the complete stack briefly before the grid morph begins.
|
|
||||||
const PHASE_B_START = 0.58;
|
|
||||||
|
|
||||||
// Transition each card to its grid cell. The .in-grid class
|
|
||||||
// (applied via a separate ScrollTrigger at Phase B start) restructures
|
|
||||||
// each card-box into a 30vw square centered within its full-width card.
|
|
||||||
// GSAP only needs to translate — scale stays 1.
|
|
||||||
//
|
|
||||||
// The card's effective visual center in grid phase is the card-box's
|
|
||||||
// center, which is the theatre horizontal center (margin:auto). So
|
|
||||||
// dx = targetCellCenterX − theatreCenterX, dy = same for Y.
|
|
||||||
function scheduleGridTransition() {
|
|
||||||
const plan = computeGridPlan();
|
|
||||||
|
|
||||||
// Target scales for the morph. Cards start as wide rectangles
|
|
||||||
// (~1324×526 at 1440vw) and need to morph to squares (~288×288).
|
|
||||||
// Using independent scaleX/scaleY lets the rectangle SHAPE-CHANGE
|
|
||||||
// into a square as it shrinks — so at morph-end the pre-snap and
|
|
||||||
// post-snap aspect ratios match and the .in-grid CSS handoff is
|
|
||||||
// imperceptible. Without this, ending at uniform scale would leave
|
|
||||||
// a flat 2.5:1 rectangle that pops to a 1:1 square on snap.
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const cardRect = cards[0].getBoundingClientRect();
|
|
||||||
const cardW = cardRect.width || vw;
|
|
||||||
const cardH = cardRect.height || 600;
|
|
||||||
const targetW = vw * 0.17; // matches .in-grid .card-box width (17vw)
|
|
||||||
const targetH = targetW; // square
|
|
||||||
const targetScaleX = targetW / cardW;
|
|
||||||
const targetScaleY = targetH / cardH;
|
|
||||||
|
|
||||||
cards.forEach((card, i) => {
|
|
||||||
const target = plan.centers[i];
|
|
||||||
const dx = target.cx - plan.theatreCx;
|
|
||||||
const dy = target.cy - plan.theatreCy;
|
|
||||||
const content = card.querySelector('.card-content');
|
|
||||||
const gridLabel = card.querySelector('.card-grid-label');
|
|
||||||
const brain = card.querySelector('.card-brain');
|
|
||||||
|
|
||||||
// Translate card to grid-cell position AND morph its SHAPE from
|
|
||||||
// wide rectangle to square via independent scaleX/scaleY. Ending
|
|
||||||
// at the exact target aspect ratio means the CSS .in-grid snap
|
|
||||||
// (where card-box becomes aspect-ratio 1:1) produces no visual
|
|
||||||
// change — the user sees a continuous morph.
|
|
||||||
stackTl.to(card,
|
|
||||||
{ x: dx, y: dy,
|
|
||||||
scaleX: targetScaleX, scaleY: targetScaleY,
|
|
||||||
rotation: 0,
|
|
||||||
duration: 0.14, ease: 'power2.inOut',
|
|
||||||
transformOrigin: 'center center' },
|
|
||||||
PHASE_B_START);
|
|
||||||
|
|
||||||
// COUNTER-SCALE the brain to prevent it being visually squeezed
|
|
||||||
// by the card's non-uniform scale. Without this, the brain would
|
|
||||||
// appear horizontally compressed (stretched tall/narrow) during
|
|
||||||
// the morph because scaleX (0.22) is 2.5× more compressed than
|
|
||||||
// scaleY (0.55).
|
|
||||||
//
|
|
||||||
// Applying additional scaleX = targetScaleY / targetScaleX (~2.5)
|
|
||||||
// to the brain combines with the card's scale multiplicatively:
|
|
||||||
// brain.visual.scaleX = card.scaleX × brain.scaleX
|
|
||||||
// = 0.22 × 2.5 = 0.55 = card.scaleY
|
|
||||||
// giving the brain UNIFORM visual scaling (both axes reduced by
|
|
||||||
// card.scaleY factor), preserving its natural aspect ratio.
|
|
||||||
//
|
|
||||||
// Using transformOrigin: 'right center' on the brain keeps its
|
|
||||||
// right edge anchored and expands the scale LEFTWARD into the
|
|
||||||
// card's interior — not rightward into blank space or adjacent
|
|
||||||
// cards. The brain already sits on the right side of the card
|
|
||||||
// (grid column), so this keeps it where the user expects it.
|
|
||||||
//
|
|
||||||
// Content (title+body) and grid-label are NOT counter-scaled —
|
|
||||||
// content fades to 0 opacity early in the morph, masking any
|
|
||||||
// distortion; grid-label is tiny text, distortion barely visible.
|
|
||||||
const counterScaleX = targetScaleY / targetScaleX;
|
|
||||||
stackTl.to(brain,
|
|
||||||
{ scaleX: counterScaleX,
|
|
||||||
duration: 0.14, ease: 'power2.inOut',
|
|
||||||
transformOrigin: 'right center',
|
|
||||||
immediateRender: false },
|
|
||||||
PHASE_B_START);
|
|
||||||
|
|
||||||
// INSTANT scale reset at the end of the morph window. Using a
|
|
||||||
// tiny duration (0.00001) with immediateRender:false means scale
|
|
||||||
// jumps from targetScale to 1 essentially in a single scrub frame
|
|
||||||
// — no visible ramp (0.00001 of a 1-second timeline is far below
|
|
||||||
// one render frame). Piggy-back the .in-grid CSS class toggle on
|
|
||||||
// the FIRST card's scale-reset tween via onStart (forward) and
|
|
||||||
// onReverseComplete (backward), so the scale snap and the class
|
|
||||||
// apply happen in the same GSAP render pass. Previously the class
|
|
||||||
// toggle was a separate tween or a separate ScrollTrigger; either
|
|
||||||
// way GSAP and ScrollTrigger didn't guarantee same-frame
|
|
||||||
// execution, producing a visible moment where scale=1 but
|
|
||||||
// box=1324 (the "becomes large briefly" glitch the user saw).
|
|
||||||
const resetVars = {
|
|
||||||
scaleX: 1, scaleY: 1,
|
|
||||||
duration: 0.00001,
|
|
||||||
immediateRender: false,
|
|
||||||
};
|
|
||||||
if (i === 0) {
|
|
||||||
resetVars.onStart = function() {
|
|
||||||
theatre.classList.add('in-grid');
|
|
||||||
};
|
|
||||||
resetVars.onReverseComplete = function() {
|
|
||||||
theatre.classList.remove('in-grid');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
stackTl.to(card, resetVars, PHASE_B_START + 0.14);
|
|
||||||
|
|
||||||
// Reset brain counter-scale atomically with the card's scale
|
|
||||||
// snap. After this, CSS .in-grid takes over layout (brain fills
|
|
||||||
// the square flex-column centered, with no inline scaleX).
|
|
||||||
stackTl.to(brain,
|
|
||||||
{ scaleX: 1, duration: 0.00001, immediateRender: false },
|
|
||||||
PHASE_B_START + 0.14);
|
|
||||||
|
|
||||||
// Crossfade: the old text content fades out while the grid label
|
|
||||||
// fades in. Both run alongside the scale/translate so all changes
|
|
||||||
// happen simultaneously as a single coherent morph.
|
|
||||||
stackTl.to(content,
|
|
||||||
{ opacity: 0, duration: 0.08, ease: 'power2.in' },
|
|
||||||
PHASE_B_START);
|
|
||||||
stackTl.to(gridLabel,
|
|
||||||
{ opacity: 0.88, duration: 0.08, ease: 'power2.out' },
|
|
||||||
PHASE_B_START + 0.06);
|
|
||||||
|
|
||||||
// Fade the outside-box eyebrow out as we transition to grid.
|
|
||||||
stackTl.to(card.querySelector('.card-eyebrow'),
|
|
||||||
{ opacity: 0, duration: 0.06, ease: 'power2.in' },
|
|
||||||
PHASE_B_START);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
scheduleGridTransition();
|
|
||||||
|
|
||||||
// (Class-toggle is now piggy-backed on card[0]'s scale-reset tween
|
|
||||||
// above — see the i === 0 branch. Keeping them on the same tween
|
|
||||||
// guarantees they fire in the same GSAP render pass.)
|
|
||||||
|
|
||||||
// On resize we need to recompute. ScrollTrigger.invalidateOnRefresh
|
|
||||||
// only rebuilds positions if our tweens use function-based values or
|
|
||||||
// we kill/rebuild. Simplest: rebuild timeline entirely on resize.
|
|
||||||
let resizeTimer;
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
clearTimeout(resizeTimer);
|
|
||||||
resizeTimer = setTimeout(() => ScrollTrigger.refresh(), 250);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------- Copy layer crossfade on the LEFT (during grid phase) --------
|
|
||||||
const FADE = 0.025;
|
|
||||||
const swap = (fromIdx, toIdx, pos) => {
|
|
||||||
if (fromIdx !== null) {
|
|
||||||
stackTl.to(copyLayers[fromIdx], { opacity: 0, y: -14, duration: FADE, ease: 'power2.in' }, pos);
|
|
||||||
}
|
|
||||||
stackTl.fromTo(copyLayers[toIdx],
|
|
||||||
{ opacity: 0, y: 16 },
|
|
||||||
{ opacity: 1, y: 0, duration: FADE, ease: 'power2.out' },
|
|
||||||
pos + FADE + 0.002);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3 panels: capabilities → sovereignty → Denmark
|
|
||||||
stackTl.fromTo(copyLayers[0],
|
|
||||||
{ opacity: 0, y: 16 },
|
|
||||||
{ opacity: 1, y: 0, duration: FADE * 1.5, ease: 'power2.out' },
|
|
||||||
PHASE_B_START + 0.08);
|
|
||||||
|
|
||||||
swap(0, 1, 0.77); // sovereignty
|
|
||||||
swap(1, 2, 0.90); // Denmark
|
|
||||||
|
|
||||||
// Clean exit: fade the whole stack-pin contents just before the pin
|
|
||||||
// releases, so the scroll gap before #words-scene shows clean paper
|
|
||||||
// rather than stack content receding away.
|
|
||||||
stackTl.to('.layer-theatre', { opacity: 0, duration: 0.03, ease: 'power2.in' }, 0.97);
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------
|
|
||||||
SCENE 3 — WORDS fly in one at a time, driven by scroll
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
|
|
||||||
// Before capturing the .words spans, rebuild the sentence with the
|
|
||||||
// user's first name if we have one. window.__fenjaFirstName is set
|
|
||||||
// by timeline.js's /auth/me fetch. Falls back to the no-name variant
|
|
||||||
// already in the DOM (see public/entrance.html's static fallback).
|
|
||||||
//
|
|
||||||
// Sentence shape:
|
|
||||||
// With name: "This is why we've invited you, <hi>Erik.</hi>
|
|
||||||
// To ensure Fenja AI is not just built for you — but
|
|
||||||
// <hi>with</hi> <hi>you.</hi>"
|
|
||||||
// No name: "This is why we've invited <hi>you.</hi> To ensure
|
|
||||||
// Fenja AI is not just built for you — but
|
|
||||||
// <hi>with</hi> <hi>you.</hi>"
|
|
||||||
//
|
|
||||||
// We rebuild the .words paragraph in place. The hi-classed spans are
|
|
||||||
// the ones that fly in from center with extra weight (see below).
|
|
||||||
(function rebuildWordsSentence() {
|
|
||||||
const wordsP = document.getElementById('words-sentence');
|
|
||||||
if (!wordsP) return;
|
|
||||||
|
|
||||||
const firstName = (typeof window.__fenjaFirstName === 'string')
|
|
||||||
? window.__fenjaFirstName.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Build the token list. Each token is { text, hi }. Whitespace
|
|
||||||
// between tokens is handled by natural text-wrap — each .w has
|
|
||||||
// `display: inline-block` plus normal spacing between siblings.
|
|
||||||
let tokens;
|
|
||||||
if (firstName) {
|
|
||||||
tokens = [
|
|
||||||
{ text: 'This' }, { text: 'is' }, { text: 'why' },
|
|
||||||
{ text: 'we\u2019ve' }, { text: 'invited' }, { text: 'you,' },
|
|
||||||
{ text: firstName + '.', hi: true },
|
|
||||||
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
|
|
||||||
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
|
|
||||||
{ text: 'just' }, { text: 'built' }, { text: 'for' },
|
|
||||||
{ text: 'you' }, { text: '\u2014' }, { text: 'but' },
|
|
||||||
{ text: 'with', hi: true }, { text: 'you.', hi: true },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// No name — structurally identical layout so the same fly-in
|
|
||||||
// curves work without retuning. "you." after "invited" gets .hi
|
|
||||||
// to carry the weight the name would've carried.
|
|
||||||
tokens = [
|
|
||||||
{ text: 'This' }, { text: 'is' }, { text: 'why' },
|
|
||||||
{ text: 'we\u2019ve' }, { text: 'invited' },
|
|
||||||
{ text: 'you.', hi: true },
|
|
||||||
{ text: 'To' }, { text: 'ensure' }, { text: 'Fenja' },
|
|
||||||
{ text: 'AI' }, { text: 'is' }, { text: 'not' },
|
|
||||||
{ text: 'just' }, { text: 'built' }, { text: 'for' },
|
|
||||||
{ text: 'you' }, { text: '\u2014' }, { text: 'but' },
|
|
||||||
{ text: 'with', hi: true }, { text: 'you.', hi: true },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush the fallback content, rebuild. Using explicit createElement
|
|
||||||
// rather than innerHTML so firstName is never HTML-interpolated.
|
|
||||||
wordsP.textContent = '';
|
|
||||||
tokens.forEach((t, i) => {
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = t.hi ? 'w hi' : 'w';
|
|
||||||
span.textContent = t.text;
|
|
||||||
wordsP.appendChild(span);
|
|
||||||
// Preserve natural whitespace between tokens (critical for text-wrap).
|
|
||||||
if (i < tokens.length - 1) wordsP.appendChild(document.createTextNode(' '));
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
const wordEls = gsap.utils.toArray('.words .w');
|
|
||||||
|
|
||||||
// Give each word a random fly-in vector (stable per word), and a scale pop.
|
|
||||||
// The "with them" words (marked .hi) come in from center with more weight.
|
|
||||||
const rnd = (i, seed) => {
|
|
||||||
// simple deterministic pseudo-random so layout is stable per word
|
|
||||||
const s = Math.sin((i + 1) * seed) * 10000;
|
|
||||||
return s - Math.floor(s);
|
|
||||||
};
|
|
||||||
|
|
||||||
wordEls.forEach((w, i) => {
|
|
||||||
const hi = w.classList.contains('hi');
|
|
||||||
const fromX = hi ? 0 : (rnd(i, 12.9898) - 0.5) * 220;
|
|
||||||
const fromY = hi ? 80 : (rnd(i, 78.233) - 0.5) * 160;
|
|
||||||
const rot = hi ? 0 : (rnd(i, 37.719) - 0.5) * 16;
|
|
||||||
gsap.set(w, {
|
|
||||||
opacity: 0,
|
|
||||||
x: fromX,
|
|
||||||
y: fromY,
|
|
||||||
rotate: rot,
|
|
||||||
scale: hi ? 1.05 : 0.9,
|
|
||||||
filter: 'blur(6px)',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const wordsTl = gsap.timeline({
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: '#words-scene',
|
|
||||||
start: 'top top',
|
|
||||||
end: 'bottom bottom',
|
|
||||||
scrub: 0.4,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wordEls.forEach((w, i) => {
|
|
||||||
const hi = w.classList.contains('hi');
|
|
||||||
const dur = hi ? 0.14 : 0.1;
|
|
||||||
wordsTl.to(w, {
|
|
||||||
opacity: 1,
|
|
||||||
x: 0, y: 0, rotate: 0,
|
|
||||||
scale: 1,
|
|
||||||
filter: 'blur(0px)',
|
|
||||||
duration: dur,
|
|
||||||
ease: 'power3.out',
|
|
||||||
}, i * 0.055);
|
|
||||||
if (hi) {
|
|
||||||
wordsTl.to(w, { scale: 1.0, duration: 0.05 }, '>-0.02');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------
|
/* -------------------------------------------------------------
|
||||||
SCENE 4 — PROJECT BIFROST REVEAL
|
SCENE 4 — PROJECT BIFROST REVEAL
|
||||||
|
|
@ -1052,103 +666,10 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* -------------------------------------------------------------
|
/* SCENE 6 — Join CTA + Innovationsfonden footer: REMOVED 2026-05-19
|
||||||
SCENE 6 — Join section: scroll-triggered reveals + CTA click
|
in the customer-presentation conversion. The CTA, confirmation
|
||||||
------------------------------------------------------------- */
|
panel, click handler, and three-mark footer all went away with
|
||||||
|
the #bifrost-join section in protected/index.html. */
|
||||||
// Reveal the CTA panel when the section scrolls into view.
|
|
||||||
// Captured to a variable so the click handler can kill this
|
|
||||||
// ScrollTrigger once the user has joined — otherwise scrolling up
|
|
||||||
// and back down would re-play the reveal and the CTA would fade
|
|
||||||
// back in over the confirmation.
|
|
||||||
const ctaRevealTween = gsap.to('.join-cta', {
|
|
||||||
opacity: 1, y: 0,
|
|
||||||
duration: 0.9, ease: 'power3.out',
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: '#bifrost-join',
|
|
||||||
start: 'top 70%',
|
|
||||||
toggleActions: 'play none none reverse',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reveal the three footer marks in sequence
|
|
||||||
gsap.to('.join-footer > *', {
|
|
||||||
opacity: 1, y: 0,
|
|
||||||
duration: 0.8, stagger: 0.14,
|
|
||||||
ease: 'power3.out',
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: '.join-footer',
|
|
||||||
start: 'top 88%',
|
|
||||||
toggleActions: 'play none none reverse',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// CTA click handler — crossfade CTA out, confirmation in, then stagger
|
|
||||||
// the checkmarks on each list item so the list feels like it's
|
|
||||||
// filling in as the user reads it.
|
|
||||||
const joinBtn = document.getElementById('joinBtn');
|
|
||||||
const joinCTA = document.getElementById('joinCTA');
|
|
||||||
const joinConfirm = document.getElementById('joinConfirm');
|
|
||||||
|
|
||||||
if (joinBtn && joinCTA && joinConfirm) {
|
|
||||||
joinBtn.addEventListener('click', () => {
|
|
||||||
if (joinBtn.disabled) return;
|
|
||||||
joinBtn.disabled = true;
|
|
||||||
|
|
||||||
// Record the click on the server. Fire-and-forget — the UI
|
|
||||||
// transitions below run regardless of network outcome so a
|
|
||||||
// temporary failure doesn't trap the user in a broken state.
|
|
||||||
// The server uses INSERT OR IGNORE keyed on email, so repeat
|
|
||||||
// clicks from the same user are safely deduplicated.
|
|
||||||
fetch('/api/bifrost-join', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
}).catch(() => {
|
|
||||||
// Network/server error — intentionally swallowed. An admin
|
|
||||||
// listing missing entries can follow up out-of-band.
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kill the CTA's scroll-reveal trigger so scrolling up + back
|
|
||||||
// down can't replay the reveal and bring the CTA back over the
|
|
||||||
// confirmation. After click, the CTA stays in whatever state
|
|
||||||
// the click-timeline puts it in (fading out, then hidden).
|
|
||||||
if (ctaRevealTween && ctaRevealTween.scrollTrigger) {
|
|
||||||
ctaRevealTween.scrollTrigger.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = joinConfirm.querySelectorAll('.confirm-list li');
|
|
||||||
|
|
||||||
const tl = gsap.timeline();
|
|
||||||
// Fade the CTA out
|
|
||||||
tl.to(joinCTA, {
|
|
||||||
opacity: 0, y: -16,
|
|
||||||
duration: 0.5, ease: 'power2.in',
|
|
||||||
onComplete: () => {
|
|
||||||
joinCTA.setAttribute('aria-hidden', 'true');
|
|
||||||
joinCTA.style.pointerEvents = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Fade the confirmation in
|
|
||||||
tl.fromTo(joinConfirm,
|
|
||||||
{ opacity: 0, y: 16 },
|
|
||||||
{
|
|
||||||
opacity: 1, y: 0,
|
|
||||||
duration: 0.7, ease: 'power3.out',
|
|
||||||
onStart: () => {
|
|
||||||
joinConfirm.setAttribute('aria-hidden', 'false');
|
|
||||||
joinConfirm.style.pointerEvents = 'auto';
|
|
||||||
},
|
|
||||||
}, '-=0.1');
|
|
||||||
|
|
||||||
// Stagger the circle+check markers by toggling `.is-checked`
|
|
||||||
// on each list item — CSS handles the pop-in transition.
|
|
||||||
items.forEach((li, i) => {
|
|
||||||
gsap.delayedCall(0.45 + i * 0.16, () => {
|
|
||||||
li.classList.add('is-checked');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------
|
/* -------------------------------------------------------------
|
||||||
Refresh ScrollTrigger after fonts and images load so positions
|
Refresh ScrollTrigger after fonts and images load so positions
|
||||||
|
|
@ -1207,7 +728,7 @@
|
||||||
* Smooth-scroll the Overview's internal scroller to a scene.
|
* Smooth-scroll the Overview's internal scroller to a scene.
|
||||||
* Called by the dot-nav click handler in timeline.js.
|
* Called by the dot-nav click handler in timeline.js.
|
||||||
*
|
*
|
||||||
* @param {string} sceneId id of the scene section (e.g. "stack-scene")
|
* @param {string} sceneId id of the scene section (e.g. "bifrost")
|
||||||
* — see sceneOrder[] inside init().
|
* — see sceneOrder[] inside init().
|
||||||
* Special value "hero" scrolls to top (0).
|
* Special value "hero" scrolls to top (0).
|
||||||
*/
|
*/
|
||||||
|
|
@ -1215,26 +736,57 @@
|
||||||
// dot-nav button anchors to it, so the reader lands AFTER the scene's
|
// dot-nav button anchors to it, so the reader lands AFTER the scene's
|
||||||
// initial reveal rather than at an empty pre-scrub frame.
|
// initial reveal rather than at an empty pre-scrub frame.
|
||||||
//
|
//
|
||||||
// stack-scene — offset 0 (top of the pin) so the reader lands right
|
|
||||||
// when the title appears and the first card starts its fall, and
|
|
||||||
// sees the full progression through all 4 landings.
|
|
||||||
//
|
|
||||||
// bifrost — section is 200vh with a scrubbed reveal that runs from
|
// bifrost — section is 200vh with a scrubbed reveal that runs from
|
||||||
// top-top to bottom-bottom (100vh scroll range). The sub-headline
|
// top-top to bottom-bottom (100vh scroll range). The sub-headline
|
||||||
// fades in at ~0.83 of that. Offset is computed per viewport as
|
// fades in at ~0.83 of that. Offset is computed per viewport as
|
||||||
// 85% of vh so the reader arrives on the fully-drawn arc +
|
// 85% of vh so the reader arrives on the fully-drawn arc +
|
||||||
// wordmark, regardless of display size.
|
// wordmark, regardless of display size.
|
||||||
//
|
//
|
||||||
// hero, bifrost-join — short reveal tweens; offsetTop is already
|
// platform-cards / platform-roadmap — pinned at 'center center', so
|
||||||
// the correct landing spot so offset is 0.
|
// the section's "intended landing" is the scroll position where
|
||||||
|
// section.centre aligns with viewport.centre. That position is
|
||||||
|
// section.offsetTop + (section.height - vh) / 2. For sections
|
||||||
|
// shorter than the viewport this offset is negative — the user
|
||||||
|
// lands just before the section's natural top, but the pin is
|
||||||
|
// engaged so visually they see the section centred at full
|
||||||
|
// opacity with the entire pin budget still ahead of them.
|
||||||
|
//
|
||||||
|
// hero — short reveal tween; offsetTop is already the correct
|
||||||
|
// landing spot so offset is 0.
|
||||||
function getSceneAnchorOffset(sceneId) {
|
function getSceneAnchorOffset(sceneId) {
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
switch (sceneId) {
|
switch (sceneId) {
|
||||||
case 'bifrost': return Math.round(vh * 0.85);
|
case 'bifrost':
|
||||||
default: return 0;
|
return Math.round(vh * 0.85);
|
||||||
|
case 'platform-cards':
|
||||||
|
case 'platform-roadmap': {
|
||||||
|
const el = document.getElementById(sceneId);
|
||||||
|
if (!el) return 0;
|
||||||
|
return Math.round((el.offsetHeight - vh) / 2);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section's offset within the scroller's content. Walks the
|
||||||
|
// offsetParent chain summing offsetTop. Necessary because
|
||||||
|
// ScrollTrigger's pinSpacing wraps pinned sections in a pin-spacer
|
||||||
|
// div (position: relative), which becomes the section's offsetParent
|
||||||
|
// — so `target.offsetTop` alone returns ~0 and scrollTo lands the
|
||||||
|
// user at the top of the page instead of the requested section.
|
||||||
|
function offsetTopWithin(el, scroller) {
|
||||||
|
let offset = 0;
|
||||||
|
let current = el;
|
||||||
|
while (current && current !== scroller) {
|
||||||
|
offset += current.offsetTop;
|
||||||
|
const parent = current.offsetParent;
|
||||||
|
if (!parent) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
function scrollTo(sceneId) {
|
function scrollTo(sceneId) {
|
||||||
if (!scrollerEl) return; // init() hasn't run yet — ignore
|
if (!scrollerEl) return; // init() hasn't run yet — ignore
|
||||||
const target = document.getElementById(sceneId);
|
const target = document.getElementById(sceneId);
|
||||||
|
|
@ -1243,7 +795,7 @@
|
||||||
// "hero" is the first scene and sits at scrollTop 0. Scrolling to
|
// "hero" is the first scene and sits at scrollTop 0. Scrolling to
|
||||||
// the scene element directly works in most cases but produces a tiny
|
// the scene element directly works in most cases but produces a tiny
|
||||||
// non-zero offset (padding / border) — hard-code 0 for hero.
|
// non-zero offset (padding / border) — hard-code 0 for hero.
|
||||||
const base = sceneId === 'hero' ? 0 : target.offsetTop;
|
const base = sceneId === 'hero' ? 0 : offsetTopWithin(target, scrollerEl);
|
||||||
const scrollY = base + getSceneAnchorOffset(sceneId);
|
const scrollY = base + getSceneAnchorOffset(sceneId);
|
||||||
|
|
||||||
if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {
|
if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {
|
||||||
|
|
|
||||||
396
protected/deepdive.html
Normal file
396
protected/deepdive.html
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Fenja AI Platform — Product Deepdive</title>
|
||||||
|
<link rel="stylesheet" href="/fenja/colors_and_type.css" />
|
||||||
|
<link rel="stylesheet" href="/platform.css" />
|
||||||
|
<style>
|
||||||
|
/* ───── Page scaffolding ─────
|
||||||
|
Standalone subpage — no SPA page-switching. The deepdive section
|
||||||
|
is the only "page" here; its internal scroller
|
||||||
|
(#product-deepdive-scroll) handles all scrolling, so the body
|
||||||
|
itself stays clipped (matches the timeline page's pattern). */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--on-surface);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* Same warm radial wash as the timeline page so the two surfaces
|
||||||
|
feel like the same paper. */
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 800px at 18% 45%,
|
||||||
|
var(--surface-container-lowest) 0%,
|
||||||
|
var(--background) 55%,
|
||||||
|
var(--surface-container-low) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reproduce the .page / .is-active contract platform.js expects.
|
||||||
|
There's only one page here, so it's active from frame 0. */
|
||||||
|
.page {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 380ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
.page.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Site wordmark — top-left, links back to the entrance. Mirrors the
|
||||||
|
masthead pattern from /timeline so the two pages feel paired. */
|
||||||
|
.site-mark,
|
||||||
|
.site-mark:link,
|
||||||
|
.site-mark:visited,
|
||||||
|
.site-mark:hover,
|
||||||
|
.site-mark:active,
|
||||||
|
.site-mark:focus,
|
||||||
|
.site-mark:focus-visible {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.site-mark {
|
||||||
|
position: fixed;
|
||||||
|
top: 28px;
|
||||||
|
left: 36px;
|
||||||
|
width: 118px;
|
||||||
|
height: auto;
|
||||||
|
z-index: 50;
|
||||||
|
opacity: 0.85;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
.site-mark img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.site-mark:hover,
|
||||||
|
.site-mark:focus-visible { opacity: 1; }
|
||||||
|
.site-mark:focus-visible {
|
||||||
|
outline: 2px solid #785f53;
|
||||||
|
outline-offset: 6px;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.site-mark { width: 90px; top: 20px; left: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-screen-label="03 Product Deepdive">
|
||||||
|
|
||||||
|
<a class="site-mark" href="/timeline" aria-label="Back to the timeline">
|
||||||
|
<img src="/fenja/fenja-wordmark-black.svg" alt="Fenja" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- ───── PRODUCT DEEPDIVE — standalone page ─────
|
||||||
|
Self-contained page reached at /deepdive. Uses the same internal
|
||||||
|
scroller (#product-deepdive-scroll) the timeline's deepdive used,
|
||||||
|
so Lenis + ScrollTrigger.scrollerProxy can be wired without
|
||||||
|
touching the window scroll. All behaviour lives in /platform.js,
|
||||||
|
all styles in /platform.css. Pre-set `is-active` so platform.js's
|
||||||
|
init observer fires immediately on load. -->
|
||||||
|
<section class="page page-product-deepdive is-active" id="page-product-deepdive" data-screen-label="03 Product Deepdive">
|
||||||
|
<div id="product-deepdive-scroll">
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
"The Question" intro — first section of the Deepdive page.
|
||||||
|
Full-viewport framing statement, fades in on scroll like
|
||||||
|
the cards stagger.
|
||||||
|
============================================================ -->
|
||||||
|
<section id="platform-question" aria-labelledby="platform-question-head">
|
||||||
|
<div class="pq-wrap">
|
||||||
|
<!-- Two-part typographic rhythm: italic serif for the
|
||||||
|
parallel "isn't enough" rejection (problem), upright
|
||||||
|
serif for the affirmative resolution. Reads as one
|
||||||
|
flowing statement, not headline + sub. -->
|
||||||
|
<h2 id="platform-question-head" class="pq-title">
|
||||||
|
Renting a few AI capabilities from American companies isn't enough.<br>
|
||||||
|
Installing an open-source language model isn't enough.
|
||||||
|
</h2>
|
||||||
|
<p class="pq-body">
|
||||||
|
You need a <em>platform you control</em> — with the
|
||||||
|
tools, the knowledge, and the framework to make AI
|
||||||
|
actually do the work your organization needs done.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
"The Layers" entry section — pinned scrubbed five-beat
|
||||||
|
build. Two columns inside the pin: a copy stage on the left
|
||||||
|
whose five text panels swap per beat, and an architecture
|
||||||
|
canvas on the right that assembles progressively (Foundation
|
||||||
|
in Beat 1+2, Tools in Beat 3, Agents in Beat 4; Beat 5 is
|
||||||
|
the closing summary against the fully assembled diagram).
|
||||||
|
A static title sits above the body from the moment the pin
|
||||||
|
engages.
|
||||||
|
|
||||||
|
Layout invariant: the canvas reserves its full final
|
||||||
|
assembled height from frame 0. Each .pl-group occupies its
|
||||||
|
final vertical slot from the start (opacity 0 until its
|
||||||
|
beat). Cards inside reveal in place — settled elements
|
||||||
|
never move.
|
||||||
|
|
||||||
|
All behaviour in /platform.js (initLayers), all styles in
|
||||||
|
/platform.css (.pl-* selectors).
|
||||||
|
============================================================ -->
|
||||||
|
<section id="platform-layers" aria-labelledby="platform-layers-head">
|
||||||
|
<h2 id="platform-layers-head" class="sr-only" style="position:absolute;left:-9999px;">The Fenja architecture, layer by layer</h2>
|
||||||
|
<div class="pl-pin">
|
||||||
|
|
||||||
|
<!-- HEADER — section title + subtitle. Statically rendered
|
||||||
|
from the moment the pin engages; visible before any
|
||||||
|
beat fires. Stays in place while the beats play below. -->
|
||||||
|
<header class="pl-pin-header">
|
||||||
|
<p class="pl-pin-title">Fenja AI Platform Architecture</p>
|
||||||
|
<p class="pl-pin-subtitle">Simply Explained</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- BODY — two-column build. Copy stage (left) and the
|
||||||
|
diagram canvas (right) live here. -->
|
||||||
|
<div class="pl-pin-body">
|
||||||
|
|
||||||
|
<!-- LEFT — text panel stage. Five beats; each panel is
|
||||||
|
absolutely positioned, vertically centred within the
|
||||||
|
stage so shorter panels (Beats 1, 3) read as deliberate
|
||||||
|
rather than top-anchored fragments. -->
|
||||||
|
<div class="pl-copy-stage" aria-live="polite">
|
||||||
|
|
||||||
|
<!-- Beat 1 — Foundation begins -->
|
||||||
|
<div class="pl-copy-step" data-beat="1">
|
||||||
|
<p class="pl-eyebrow">The foundation</p>
|
||||||
|
<h3 class="pl-headline"><em>A model in your environment.</em></h3>
|
||||||
|
<p class="pl-body">
|
||||||
|
A state-of-the-art open-source language model, running
|
||||||
|
entirely on your hardware. No data leaves your
|
||||||
|
perimeter. The starting point — but not yet
|
||||||
|
Fenja.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beat 2 — Knowledge added -->
|
||||||
|
<div class="pl-copy-step" data-beat="2">
|
||||||
|
<p class="pl-eyebrow">The foundation</p>
|
||||||
|
<h3 class="pl-headline"><em>Knowledge.</em></h3>
|
||||||
|
<p class="pl-body">
|
||||||
|
What makes the model <em>Fenja</em> — an
|
||||||
|
understanding of your organization, captured in a wiki
|
||||||
|
your team can read and edit. Plus the routines and
|
||||||
|
working memory that turn Fenja into a coworker who
|
||||||
|
knows how things get done.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beat 3 — Tools layer arrives -->
|
||||||
|
<div class="pl-copy-step" data-beat="3">
|
||||||
|
<p class="pl-eyebrow">What Fenja can do</p>
|
||||||
|
<h3 class="pl-headline"><em>Tools.</em></h3>
|
||||||
|
<p class="pl-body">
|
||||||
|
How knowledge becomes work. Fenja uses tools to find
|
||||||
|
documents, query data, take action across your
|
||||||
|
systems. Some are obvious; others depend on what your
|
||||||
|
work needs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beat 4 — Agents layer arrives -->
|
||||||
|
<div class="pl-copy-step" data-beat="4">
|
||||||
|
<p class="pl-eyebrow">When one becomes a team</p>
|
||||||
|
<h3 class="pl-headline"><em>Agents.</em></h3>
|
||||||
|
<p class="pl-body">
|
||||||
|
Real work isn't one task. Fenja becomes a team
|
||||||
|
— a supervisor and specialists, each focused,
|
||||||
|
each governed, all dispatched by workflows you've
|
||||||
|
designed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beat 5 — final summary. Diagram is fully assembled
|
||||||
|
by now; this panel speaks for the whole stack. -->
|
||||||
|
<div class="pl-copy-step" data-beat="5">
|
||||||
|
<p class="pl-eyebrow">The full picture</p>
|
||||||
|
<h3 class="pl-headline"><em>Everything you need and with full control.</em></h3>
|
||||||
|
<p class="pl-body">
|
||||||
|
Fenja brings together all the pieces to solve simple
|
||||||
|
and complex AI use cases across your organisation.
|
||||||
|
Every component hosted in your infrastructure with
|
||||||
|
full traceability and governance. Secure and sovereign
|
||||||
|
by design.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT — diagram canvas. Three layer slots, each at its
|
||||||
|
final vertical position from the start. .pl-canvas-wrap
|
||||||
|
holds the column; .pl-canvas is the flex container that
|
||||||
|
stacks the three layers with a 20px gap (per spec). -->
|
||||||
|
<div class="pl-canvas-wrap">
|
||||||
|
<div class="pl-canvas">
|
||||||
|
|
||||||
|
<!-- Beat-5 frame: thin border around all three layers
|
||||||
|
with a top-right label. Hidden until Beat 5 fires;
|
||||||
|
platform.js fades opacity to 1. -->
|
||||||
|
<div class="pl-canvas-frame" aria-hidden="true">
|
||||||
|
<span class="pl-canvas-frame-label">Everything Client-Managed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Foundation — 3 equal cards (corrects the original
|
||||||
|
handoff's tall-Knowledge asymmetry: that asymmetry
|
||||||
|
was for a single Knowledge concept; we've split
|
||||||
|
Knowledge into Wiki + Routines & memory, so all
|
||||||
|
three Foundation cards balance). -->
|
||||||
|
<section class="pl-group" data-layer="foundation" aria-hidden="true">
|
||||||
|
<header class="pl-group-head">
|
||||||
|
<span class="pl-group-label">Foundation</span>
|
||||||
|
<span class="pl-group-caption">Sovereign by design</span>
|
||||||
|
</header>
|
||||||
|
<div class="pl-cards pl-cards--3 pl-cards--stretch">
|
||||||
|
<article class="pl-card" data-card="lm">
|
||||||
|
<h4 class="pl-card-name">Language model</h4>
|
||||||
|
<p class="pl-card-italic">State-of-the-art, open-source</p>
|
||||||
|
<p class="pl-card-mono">On-prem</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card" data-card="wiki">
|
||||||
|
<h4 class="pl-card-name">Wiki</h4>
|
||||||
|
<p class="pl-card-italic">Company and domain knowledge</p>
|
||||||
|
<p class="pl-card-mono">Organizational · Departmental · Personal</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card" data-card="routines">
|
||||||
|
<h4 class="pl-card-name">Routines & memory</h4>
|
||||||
|
<p class="pl-card-italic">How Fenja works inside it</p>
|
||||||
|
<p class="pl-card-mono">Stand-ups · Recurring tasks · Working memory</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tools -->
|
||||||
|
<section class="pl-group" data-layer="tools" aria-hidden="true">
|
||||||
|
<header class="pl-group-head">
|
||||||
|
<span class="pl-group-label">Tools</span>
|
||||||
|
<span class="pl-group-caption">How Fenja acts</span>
|
||||||
|
</header>
|
||||||
|
<div class="pl-cards pl-cards--4 pl-cards--stretch">
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">Document retrieval</h4>
|
||||||
|
<p class="pl-card-italic">Find and cite</p>
|
||||||
|
<p class="pl-card-mono">RAG</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">Structured data (ie SQL)</h4>
|
||||||
|
<p class="pl-card-italic">Query and extract</p>
|
||||||
|
<p class="pl-card-mono">NL → SQL</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">System actions</h4>
|
||||||
|
<p class="pl-card-italic">Read and write</p>
|
||||||
|
<p class="pl-card-mono">APIs · integrations</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">Custom tools</h4>
|
||||||
|
<p class="pl-card-italic">Your specific work</p>
|
||||||
|
<p class="pl-card-mono">Defined by you</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Agents -->
|
||||||
|
<section class="pl-group" data-layer="agents" aria-hidden="true">
|
||||||
|
<header class="pl-group-head">
|
||||||
|
<span class="pl-group-label">Agents</span>
|
||||||
|
<span class="pl-group-caption">How Fenja scales</span>
|
||||||
|
</header>
|
||||||
|
<div class="pl-cards pl-cards--4 pl-cards--stretch">
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">Supervisor</h4>
|
||||||
|
<p class="pl-card-italic">Plan and dispatch</p>
|
||||||
|
<p class="pl-card-mono">Orchestration</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">Specialists</h4>
|
||||||
|
<p class="pl-card-italic">Focused expertise</p>
|
||||||
|
<p class="pl-card-mono">Subagents</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">Skills</h4>
|
||||||
|
<p class="pl-card-italic">Reusable capability</p>
|
||||||
|
<p class="pl-card-mono">Portable across specialists</p>
|
||||||
|
</article>
|
||||||
|
<article class="pl-card">
|
||||||
|
<h4 class="pl-card-name">Workflows</h4>
|
||||||
|
<p class="pl-card-italic">Composed by you</p>
|
||||||
|
<p class="pl-card-mono">Governed end-to-end</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.pl-pin-body -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- "Choose your Capability" — 4 product cards. Final section. -->
|
||||||
|
<section id="platform-cards" aria-labelledby="platform-cards-head">
|
||||||
|
<header class="platform-cards-head">
|
||||||
|
<p class="platform-eyebrow">Deployment options</p>
|
||||||
|
<h2 id="platform-cards-head" class="platform-title">Choose your <em>Capability.</em></h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="platform-card-grid" role="list">
|
||||||
|
<article class="platform-card" role="listitem">
|
||||||
|
<h3 class="platform-card-name">Fenja <em>Core.</em></h3>
|
||||||
|
<p class="platform-card-tier">Foundational</p>
|
||||||
|
<p class="platform-card-body">Essential LLM capabilities with Fenja Semantic. Your safe and custom chatbot that understands your organization.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="platform-card" role="listitem">
|
||||||
|
<h3 class="platform-card-name">Fenja <em>Dev.</em></h3>
|
||||||
|
<p class="platform-card-tier">Developer toolset</p>
|
||||||
|
<p class="platform-card-body">Code faster and better with your own secure AI-supported development platform.</p>
|
||||||
|
<p class="platform-card-includes">+ Core</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="platform-card" role="listitem">
|
||||||
|
<h3 class="platform-card-name">Fenja <em>Analyze.</em></h3>
|
||||||
|
<p class="platform-card-tier">Strategic intel</p>
|
||||||
|
<p class="platform-card-body">Bring real insights to your people. You ask for an insight, and your agents will find, analyze, and present the relevant data.</p>
|
||||||
|
<p class="platform-card-includes">+ Core</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="platform-card is-dark" role="listitem">
|
||||||
|
<h3 class="platform-card-name">Fenja <em>Agentic.</em></h3>
|
||||||
|
<p class="platform-card-tier">Automation</p>
|
||||||
|
<p class="platform-card-body">The complete framework. Fully governed and controlled agents collaborate to solve your most important processes.</p>
|
||||||
|
<p class="platform-card-includes">+ Core. Dev. Analyze.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
</div><!-- /#product-deepdive-scroll -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="/vendor/lenis.min.js" defer></script>
|
||||||
|
<script src="/vendor/gsap.min.js" defer></script>
|
||||||
|
<script src="/vendor/scrolltrigger.min.js" defer></script>
|
||||||
|
<script src="/platform.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1053
protected/index.html
1053
protected/index.html
File diff suppressed because it is too large
Load diff
1474
protected/platform.css
Normal file
1474
protected/platform.css
Normal file
File diff suppressed because it is too large
Load diff
938
protected/platform.js
Normal file
938
protected/platform.js
Normal file
|
|
@ -0,0 +1,938 @@
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// protected/platform.js — Fenja AI Platform Architecture explainer
|
||||||
|
//
|
||||||
|
// Sections (in order):
|
||||||
|
// #platform-question — full-viewport framing statement (fade-in)
|
||||||
|
// #platform-layers — pinned scrubbed five-beat architecture build
|
||||||
|
// #platform-cards — "Choose your Capability" deployment options
|
||||||
|
// (final section; centred when at scroll end)
|
||||||
|
//
|
||||||
|
// Runs in two host pages:
|
||||||
|
// A. Inlined into protected/index.html's Overview page, after the
|
||||||
|
// Project Bifrost treasure-map — the customer-presentation flow.
|
||||||
|
// Scroller: #overview-scroll. Lenis + ScrollTrigger.scrollerProxy
|
||||||
|
// are set up by bifrost.js, so we just attach our triggers.
|
||||||
|
// B. Standalone protected/deepdive.html (the original /deepdive
|
||||||
|
// page). Scroller: #product-deepdive-scroll. We own Lenis +
|
||||||
|
// scrollerProxy here.
|
||||||
|
//
|
||||||
|
// Detection: at boot we look for #overview-scroll first; if present
|
||||||
|
// we wait for #page-overview to gain `is-active` (i.e. bifrost.js
|
||||||
|
// has run its init) and attach scene triggers without creating a
|
||||||
|
// second Lenis instance. Otherwise we fall back to the standalone
|
||||||
|
// deepdive path.
|
||||||
|
//
|
||||||
|
// CSP: 'script-src self'. No inline scripts anywhere.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
let scrollerEl = null;
|
||||||
|
let lenisInstance = null;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (initialized) return;
|
||||||
|
if (typeof window.gsap === 'undefined' ||
|
||||||
|
typeof window.ScrollTrigger === 'undefined' ||
|
||||||
|
typeof window.Lenis === 'undefined') {
|
||||||
|
console.warn('[platform] gsap/ScrollTrigger/Lenis missing; skipping init.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prefer the Overview's existing scroller when present — that's
|
||||||
|
// the inlined customer-presentation path, where bifrost.js owns
|
||||||
|
// Lenis + scrollerProxy and we must not create a second pair.
|
||||||
|
const overviewScroller = document.getElementById('overview-scroll');
|
||||||
|
const deepdiveScroller = document.getElementById('product-deepdive-scroll');
|
||||||
|
const integrated = !!overviewScroller && !!document.getElementById('platform-layers');
|
||||||
|
const scroller = integrated ? overviewScroller : deepdiveScroller;
|
||||||
|
if (!scroller) {
|
||||||
|
console.warn('[platform] no scroller (#overview-scroll or #product-deepdive-scroll) found; skipping init.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
scrollerEl = scroller;
|
||||||
|
|
||||||
|
const gsap = window.gsap;
|
||||||
|
const ScrollTrigger = window.ScrollTrigger;
|
||||||
|
const Lenis = window.Lenis;
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
// Lenis + scrollerProxy: only when standalone. In the integrated
|
||||||
|
// path, bifrost.js already wired both onto #overview-scroll; we'd
|
||||||
|
// create a duplicate Lenis fighting the existing one if we ran
|
||||||
|
// this block.
|
||||||
|
if (!integrated && !reduceMotion) {
|
||||||
|
const lenis = new Lenis({
|
||||||
|
wrapper: scroller,
|
||||||
|
content: scroller.firstElementChild,
|
||||||
|
duration: 1.15,
|
||||||
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
|
smoothWheel: true,
|
||||||
|
wheelMultiplier: 1,
|
||||||
|
touchMultiplier: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tell ScrollTrigger how to read scroll on this scroller. pinType
|
||||||
|
// 'transform' is required because the scroller is itself an
|
||||||
|
// overflow-scroll element rather than the window.
|
||||||
|
ScrollTrigger.scrollerProxy(scroller, {
|
||||||
|
scrollTop(value) {
|
||||||
|
if (arguments.length) {
|
||||||
|
scroller.scrollTop = value;
|
||||||
|
}
|
||||||
|
return scroller.scrollTop;
|
||||||
|
},
|
||||||
|
getBoundingClientRect() {
|
||||||
|
return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight };
|
||||||
|
},
|
||||||
|
pinType: 'transform',
|
||||||
|
});
|
||||||
|
|
||||||
|
lenis.on('scroll', ScrollTrigger.update);
|
||||||
|
gsap.ticker.add((time) => lenis.raf(time * 1000));
|
||||||
|
gsap.ticker.lagSmoothing(0);
|
||||||
|
|
||||||
|
lenisInstance = lenis;
|
||||||
|
}
|
||||||
|
|
||||||
|
initQuestion(gsap, ScrollTrigger, scroller, reduceMotion);
|
||||||
|
initLayers(gsap, ScrollTrigger, scroller, reduceMotion);
|
||||||
|
initWiki(gsap, ScrollTrigger, scroller, reduceMotion);
|
||||||
|
initCards(gsap, ScrollTrigger, scroller, reduceMotion);
|
||||||
|
initRoadmap(gsap, ScrollTrigger, scroller, reduceMotion);
|
||||||
|
|
||||||
|
// Refresh now that the page is laid out and triggers exist.
|
||||||
|
if (!reduceMotion) ScrollTrigger.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── "The Layers" entry section ─────────────────────────────
|
||||||
|
// Pinned scrubbed five-beat build.
|
||||||
|
//
|
||||||
|
// Beat 1 — Foundation wrapper + Language model card.
|
||||||
|
// Beat 2 — Wiki + Routines & memory cards (stagger).
|
||||||
|
// Beat 3 — Tools wrapper + 4 cards (stagger).
|
||||||
|
// Beat 4 — Agents wrapper + 4 cards (stagger).
|
||||||
|
// Beat 5 — Closing summary copy panel; diagram is fully
|
||||||
|
// assembled and unchanged.
|
||||||
|
//
|
||||||
|
// Layout invariant: the canvas reserves its full assembled
|
||||||
|
// height from the start, with each .pl-group at its final
|
||||||
|
// vertical slot. Reveals are pure opacity/translate — no card
|
||||||
|
// ever moves once it has settled, because the Foundation grid
|
||||||
|
// is 3-col throughout (slots for Wiki and Routines exist from
|
||||||
|
// frame 0, just invisible) and Tools/Agents wrappers occupy
|
||||||
|
// their layout space (opacity 0) from frame 0. The .pl-pin
|
||||||
|
// header (title + subtitle) is statically rendered — visible
|
||||||
|
// before any beat fires, untouched by the timeline.
|
||||||
|
function initLayers(gsap, ScrollTrigger, scroller, reduceMotion) {
|
||||||
|
const section = document.getElementById('platform-layers');
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const copies = Array.from(section.querySelectorAll('.pl-copy-step'));
|
||||||
|
const groupF = section.querySelector('[data-layer="foundation"]');
|
||||||
|
const groupT = section.querySelector('[data-layer="tools"]');
|
||||||
|
const groupA = section.querySelector('[data-layer="agents"]');
|
||||||
|
const cardsF = groupF ? Array.from(groupF.querySelectorAll('.pl-card')) : [];
|
||||||
|
const cardsT = groupT ? Array.from(groupT.querySelectorAll('.pl-card')) : [];
|
||||||
|
const cardsA = groupA ? Array.from(groupA.querySelectorAll('.pl-card')) : [];
|
||||||
|
const frame = section.querySelector('.pl-canvas-frame');
|
||||||
|
|
||||||
|
if (!groupF || !groupT || !groupA ||
|
||||||
|
copies.length !== 5 ||
|
||||||
|
cardsF.length !== 3 || cardsT.length !== 4 || cardsA.length !== 4) {
|
||||||
|
console.warn('[deepdive] platform-layers DOM mismatch — expected 5 copy steps, 3 layer groups, 3+4+4 cards.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
// CSS @media handles the unfold; nothing for JS to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial states. All three layer wrappers occupy their final
|
||||||
|
// grid slots from frame 0; only opacity is animated for the
|
||||||
|
// wrappers themselves. Cards animate y+opacity within their
|
||||||
|
// pre-allocated grid cells. Text panels fade + 14px translate.
|
||||||
|
gsap.set([groupF, groupT, groupA], { opacity: 0 });
|
||||||
|
gsap.set([...cardsF, ...cardsT, ...cardsA], { opacity: 0, y: 24 });
|
||||||
|
gsap.set(copies, { opacity: 0, y: 14 });
|
||||||
|
if (frame) gsap.set(frame, { opacity: 0 });
|
||||||
|
|
||||||
|
const BEAT = 1.0;
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#platform-layers',
|
||||||
|
scroller,
|
||||||
|
start: 'top top',
|
||||||
|
end: '+=500%',
|
||||||
|
pin: '.pl-pin',
|
||||||
|
pinType: 'transform',
|
||||||
|
scrub: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper — previous-copy fade-out. Skipped on Beat 1.
|
||||||
|
function fadeOutPrev(i, t) {
|
||||||
|
if (i === 0) return;
|
||||||
|
tl.to(copies[i - 1], { opacity: 0, y: -12, duration: 0.06, ease: 'power2.in' }, t);
|
||||||
|
}
|
||||||
|
// Helper — new-copy fade-in.
|
||||||
|
function fadeInCopy(i, t) {
|
||||||
|
tl.to(copies[i], { opacity: 1, y: 0, duration: 0.10, ease: 'power2.out' }, t + 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beat 1 — Foundation wrapper appears, Language model card lands.
|
||||||
|
const t1 = 0 * BEAT;
|
||||||
|
fadeOutPrev(0, t1);
|
||||||
|
tl.to(groupF, { opacity: 1, duration: 0.10, ease: 'power3.out' }, t1 + 0.06);
|
||||||
|
tl.to(cardsF[0], { opacity: 1, y: 0, duration: 0.20, ease: 'power3.out' }, t1 + 0.10);
|
||||||
|
fadeInCopy(0, t1);
|
||||||
|
|
||||||
|
// Beat 2 — Wiki + Routines cards stagger in alongside Language model.
|
||||||
|
const t2 = 1 * BEAT;
|
||||||
|
fadeOutPrev(1, t2);
|
||||||
|
tl.to([cardsF[1], cardsF[2]], {
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.20,
|
||||||
|
ease: 'power3.out',
|
||||||
|
stagger: 0.04,
|
||||||
|
}, t2 + 0.06);
|
||||||
|
fadeInCopy(1, t2);
|
||||||
|
|
||||||
|
// Beat 3 — Tools wrapper appears, 4 cards stagger left-to-right.
|
||||||
|
const t3 = 2 * BEAT;
|
||||||
|
fadeOutPrev(2, t3);
|
||||||
|
tl.to(groupT, { opacity: 1, duration: 0.10, ease: 'power3.out' }, t3 + 0.06);
|
||||||
|
tl.to(cardsT, {
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.20,
|
||||||
|
ease: 'power3.out',
|
||||||
|
stagger: 0.04,
|
||||||
|
}, t3 + 0.10);
|
||||||
|
fadeInCopy(2, t3);
|
||||||
|
|
||||||
|
// Beat 4 — Agents wrapper appears, 4 cards stagger left-to-right.
|
||||||
|
const t4 = 3 * BEAT;
|
||||||
|
fadeOutPrev(3, t4);
|
||||||
|
tl.to(groupA, { opacity: 1, duration: 0.10, ease: 'power3.out' }, t4 + 0.06);
|
||||||
|
tl.to(cardsA, {
|
||||||
|
opacity: 1, y: 0,
|
||||||
|
duration: 0.20,
|
||||||
|
ease: 'power3.out',
|
||||||
|
stagger: 0.04,
|
||||||
|
}, t4 + 0.10);
|
||||||
|
fadeInCopy(3, t4);
|
||||||
|
|
||||||
|
// Beat 5 — closing summary panel. Diagram is fully assembled
|
||||||
|
// by now; the copy stage swaps to the summary text and the
|
||||||
|
// "Everything Client-Managed" frame fades in around the stack.
|
||||||
|
const t5 = 4 * BEAT;
|
||||||
|
fadeOutPrev(4, t5);
|
||||||
|
fadeInCopy(4, t5);
|
||||||
|
if (frame) tl.to(frame, { opacity: 1, duration: 0.20, ease: 'power2.out' }, t5 + 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── "The Platform" Part A: The Question ────────────────────
|
||||||
|
// Full-viewport question moment; just the title + subtitle, with
|
||||||
|
// a simple stagger fade-in. Same gate the cards use.
|
||||||
|
function initQuestion(gsap, ScrollTrigger, scroller, reduceMotion) {
|
||||||
|
const els = document.querySelectorAll(
|
||||||
|
'#platform-question .pq-title, #platform-question .pq-body'
|
||||||
|
);
|
||||||
|
if (!els.length) return;
|
||||||
|
if (reduceMotion) {
|
||||||
|
els.forEach(e => { e.style.opacity = '1'; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gsap.set(els, { opacity: 0, y: 18 });
|
||||||
|
gsap.to(els, {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.7,
|
||||||
|
ease: 'power3.out',
|
||||||
|
stagger: 0.15,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#platform-question',
|
||||||
|
scroller,
|
||||||
|
start: 'top 70%',
|
||||||
|
once: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Section A: Deployment options (cards) ──────────────────
|
||||||
|
// Pin-with-scrub-and-release. Two ScrollTriggers share the
|
||||||
|
// section as trigger element:
|
||||||
|
//
|
||||||
|
// Phase A — fade IN (no pin), ~50vh of scroll input.
|
||||||
|
// Section moves up naturally; opacity 0→1 as
|
||||||
|
// its centre approaches viewport centre. Phase
|
||||||
|
// A's end is the exact scroll position at which
|
||||||
|
// the pin engages, so the perceived arrival is
|
||||||
|
// one continuous motion (no snap from "fading"
|
||||||
|
// to "pinned").
|
||||||
|
//
|
||||||
|
// Phase B+C — PIN with scrubbed timeline, ~150vh of scroll
|
||||||
|
// input total.
|
||||||
|
// • 0 → 100vh of pin = HOLD at full opacity.
|
||||||
|
// The "deliberate pause"; scroll itself is
|
||||||
|
// never blocked, the pin just holds the
|
||||||
|
// section visually fixed at viewport
|
||||||
|
// centre while wheel/keyboard/touch input
|
||||||
|
// is spent against the budget.
|
||||||
|
// • 100 → 150vh of pin = fade OUT, still
|
||||||
|
// pinned. Section dissolves in place;
|
||||||
|
// pin releases at opacity 0, so the next
|
||||||
|
// content immediately scrolls into view.
|
||||||
|
//
|
||||||
|
// Bundling pin + fade-out into one trigger eliminates the
|
||||||
|
// cross-trigger dependency that previously had Phase C's
|
||||||
|
// range collapsing to a single frame (Lenis can use CSS
|
||||||
|
// transform on the scroller content, in which case
|
||||||
|
// scroller.scrollTop reads 0 and BCR-based scroll math
|
||||||
|
// gives a moving target instead of a fixed pin-release
|
||||||
|
// position). It also avoids a render-order race where
|
||||||
|
// Phase A would keep writing opacity=1 every scroll event
|
||||||
|
// past its range, fighting Phase C's writes.
|
||||||
|
//
|
||||||
|
// Trade-off vs the literal spec ("fade-out AFTER pin
|
||||||
|
// releases"): the fade happens while the section is still
|
||||||
|
// pinned at viewport centre rather than while scrolling
|
||||||
|
// upward after release. Visually the difference is subtle —
|
||||||
|
// the section dwells, dissolves, then the next content
|
||||||
|
// arrives. Implementation is rock solid.
|
||||||
|
//
|
||||||
|
// NEVER use CSS scroll-snap for this — snap is non-breakable
|
||||||
|
// and gives no scroll-budget mechanic. Users must always be
|
||||||
|
// able to break through by continuing to scroll.
|
||||||
|
//
|
||||||
|
// Reduced-motion: skip the fade and the pin entirely. The
|
||||||
|
// section appears at full opacity and scrolls past normally.
|
||||||
|
function initCards(gsap, ScrollTrigger, scroller, reduceMotion) {
|
||||||
|
const section = document.getElementById('platform-cards');
|
||||||
|
if (!section) return;
|
||||||
|
const head = section.querySelector('.platform-cards-head');
|
||||||
|
const cards = Array.from(section.querySelectorAll('.platform-card'));
|
||||||
|
const targets = [head, ...cards].filter(Boolean);
|
||||||
|
if (!targets.length) return;
|
||||||
|
if (reduceMotion) {
|
||||||
|
targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.set(targets, { opacity: 0, y: 18 });
|
||||||
|
|
||||||
|
// Phase A — fade IN, ~50vh (center bottom → center center).
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: section,
|
||||||
|
scroller,
|
||||||
|
start: 'center bottom',
|
||||||
|
end: 'center center',
|
||||||
|
scrub: 0.5,
|
||||||
|
animation: gsap.fromTo(
|
||||||
|
targets,
|
||||||
|
{ opacity: 0, y: 18 },
|
||||||
|
{ opacity: 1, y: 0, ease: 'none' }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase B+C — pin for 150vh with scrubbed timeline.
|
||||||
|
// Timeline duration = 1.5 units; ScrollTrigger maps scroll
|
||||||
|
// progress 0→1 across the 150vh pin onto timeline 0→1.5.
|
||||||
|
// t = 0 → 1.0 : HOLD. A dummy tween on an empty
|
||||||
|
// object reserves the time slot so the
|
||||||
|
// timeline's totalDuration is 1.5
|
||||||
|
// rather than 0.5; nothing is written
|
||||||
|
// to the targets, so opacity stays at
|
||||||
|
// Phase A's settled =1.
|
||||||
|
// t = 1.0 → 1.5 : fade OUT. fromTo with explicit from-
|
||||||
|
// state {opacity:1, y:0} guarantees the
|
||||||
|
// fade actually starts at full opacity
|
||||||
|
// regardless of what GSAP captures or
|
||||||
|
// when — the previous gsap.to here was
|
||||||
|
// silently recording opacity=0 (the
|
||||||
|
// initial gsap.set value) as its from,
|
||||||
|
// so it animated 0→0 during the visible
|
||||||
|
// range and only snapped to 0 once past
|
||||||
|
// the end. immediateRender:false keeps
|
||||||
|
// the from-state off the targets until
|
||||||
|
// the playhead actually reaches t=1.
|
||||||
|
const pinTl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: section,
|
||||||
|
scroller,
|
||||||
|
start: 'center center',
|
||||||
|
end: '+=150%',
|
||||||
|
pin: true,
|
||||||
|
pinSpacing: true,
|
||||||
|
pinType: 'transform',
|
||||||
|
scrub: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pinTl
|
||||||
|
.to({}, { duration: 1 }, 0)
|
||||||
|
.fromTo(
|
||||||
|
targets,
|
||||||
|
{ opacity: 1, y: 0 },
|
||||||
|
{
|
||||||
|
opacity: 0,
|
||||||
|
y: -18,
|
||||||
|
ease: 'none',
|
||||||
|
duration: 0.5,
|
||||||
|
immediateRender: false,
|
||||||
|
},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wiki deep-dive — pinned scrubbed five-beat ─────────────
|
||||||
|
//
|
||||||
|
// Beat 0 Anchor (Wiki pl-card) scales up & fades in centered.
|
||||||
|
// Beat 1 Left "Scattered knowledge" zone reveals; document
|
||||||
|
// icons stagger in; anchor fades to a quiet echo.
|
||||||
|
// Beat 2 Middle "Fenja AI Compiler" reveals; two scatter →
|
||||||
|
// compiler flow lines draw via strokeDashoffset.
|
||||||
|
// Beat 3 Right page stack composes — back, then middle, then
|
||||||
|
// front. Each card landing applies a blur to the
|
||||||
|
// card(s) beneath it ("each layer in front fogs the
|
||||||
|
// layers below"). Two compiler → stack flow lines
|
||||||
|
// draw alongside. Front-card <sup> citation markers
|
||||||
|
// fade in last.
|
||||||
|
// Beat 4 Trust beat — citation [1] lights up walnut, the
|
||||||
|
// source PDF icon on the left tints subtly, and a
|
||||||
|
// faint arc traces from citation back to PDF.
|
||||||
|
//
|
||||||
|
// Mirrors initLayers's timeline structure (BEAT = 1.0 second
|
||||||
|
// intervals, +=500% scroll range, pinType: 'transform').
|
||||||
|
function initWiki(gsap, ScrollTrigger, scroller, reduceMotion) {
|
||||||
|
const section = document.getElementById('wiki-deepdive');
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const anchor = section.querySelector('.wd-anchor');
|
||||||
|
const zoneScatter = section.querySelector('.wd-zone--scatter');
|
||||||
|
const zoneCompiler = section.querySelector('.wd-zone--compiler');
|
||||||
|
const zoneWiki = section.querySelector('.wd-zone--wiki');
|
||||||
|
const compiler = section.querySelector('.wd-compiler');
|
||||||
|
const docs = Array.from(section.querySelectorAll('.wd-doc'));
|
||||||
|
const chevrons = Array.from(section.querySelectorAll('.wd-chevron'));
|
||||||
|
const stackBack = section.querySelector('.wd-stack-card[data-depth="back"]');
|
||||||
|
const stackMid = section.querySelector('.wd-stack-card[data-depth="mid"]');
|
||||||
|
const stackFront = section.querySelector('.wd-stack-card[data-depth="front"]');
|
||||||
|
const cites = Array.from(section.querySelectorAll('.wd-cite'));
|
||||||
|
const firstCite = section.querySelector('.wd-cite[data-cite="1"]');
|
||||||
|
const pairedSource = stackFront && stackFront.querySelector('.wd-stack-source[data-source="1"]');
|
||||||
|
/* Trust-beat source-tint target in the cluster. The arc that
|
||||||
|
used to connect them visually was removed; the citation
|
||||||
|
pulse + source PDF tint + paired source-row highlight
|
||||||
|
remain as the trust cues. */
|
||||||
|
const sourceDoc = section.querySelector('.wd-doc[data-doc="pdf"]');
|
||||||
|
|
||||||
|
/* Per-icon target opacity. --o is read from inline style so
|
||||||
|
foreground items fade to 1.0 and background-pile items fade
|
||||||
|
to their reduced opacity (≈0.45) — the layered cluster look
|
||||||
|
isn't flattened by the reveal animation. */
|
||||||
|
const docTargets = docs.map((d) => {
|
||||||
|
const raw = getComputedStyle(d).getPropertyValue('--o').trim();
|
||||||
|
const op = raw ? parseFloat(raw) : 1;
|
||||||
|
return { el: d, opacity: Number.isFinite(op) ? op : 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!zoneScatter || !zoneCompiler || !zoneWiki) {
|
||||||
|
console.warn('[platform] wiki-deepdive DOM missing zones; skipping init.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
// CSS @media handles the unfold; nothing for JS to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blur targets per-stack-position. Each card lands sharp;
|
||||||
|
// when the next layer arrives, it gains the blur listed here.
|
||||||
|
// Tuned to the deck's depth language — soft enough not to
|
||||||
|
// dominate, strong enough to read as "behind glass".
|
||||||
|
const BLUR_MID = 3; // back card receives this once mid lands
|
||||||
|
const BLUR_BACK = 7; // back card's blur deepens once front lands
|
||||||
|
const BLUR_MID_FINAL = 2; // mid card's blur once front lands
|
||||||
|
|
||||||
|
// Initial states.
|
||||||
|
if (anchor) gsap.set(anchor, { opacity: 0, scale: 0.85 });
|
||||||
|
gsap.set([zoneScatter, zoneCompiler, zoneWiki], { opacity: 0, y: 14 });
|
||||||
|
if (compiler) gsap.set(compiler, { scale: 0.94, opacity: 0 });
|
||||||
|
gsap.set(docs, { opacity: 0, y: 10 });
|
||||||
|
gsap.set(cites, { opacity: 0 });
|
||||||
|
if (chevrons.length) gsap.set(chevrons, { opacity: 0 });
|
||||||
|
|
||||||
|
// Stack: each card starts off-frame (translated right + down)
|
||||||
|
// and lands into its CSS-defined position via xPercent/yPercent
|
||||||
|
// delta. CSS owns absolute position; GSAP only moves the
|
||||||
|
// transform offset so we don't fight the layout.
|
||||||
|
[stackBack, stackMid, stackFront].forEach((c) => {
|
||||||
|
if (!c) return;
|
||||||
|
gsap.set(c, { opacity: 0, xPercent: 40, yPercent: 24, filter: 'blur(0px)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const BEAT = 1.0;
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: '#wiki-deepdive',
|
||||||
|
scroller,
|
||||||
|
start: 'top top',
|
||||||
|
end: '+=500%',
|
||||||
|
pin: '.wd-pin',
|
||||||
|
pinType: 'transform',
|
||||||
|
scrub: 0.5,
|
||||||
|
invalidateOnRefresh: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beat 0 — anchor enters scaled up to the centre.
|
||||||
|
const t0 = 0 * BEAT;
|
||||||
|
if (anchor) {
|
||||||
|
tl.to(anchor, {
|
||||||
|
opacity: 1, scale: 1.6,
|
||||||
|
duration: 0.22, ease: 'power3.out',
|
||||||
|
}, t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beat 1 — scatter zone reveals; anchor fades.
|
||||||
|
const t1 = 1 * BEAT;
|
||||||
|
if (anchor) {
|
||||||
|
tl.to(anchor, { opacity: 0.18, scale: 1.4, duration: 0.16, ease: 'power2.in' }, t1);
|
||||||
|
}
|
||||||
|
tl.to(zoneScatter, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t1 + 0.04);
|
||||||
|
// Each doc fades to its OWN target opacity (foreground icons
|
||||||
|
// to 1.0, background-pile icons to their --o value, ≈0.45).
|
||||||
|
docTargets.forEach((d, i) => {
|
||||||
|
tl.to(d.el, {
|
||||||
|
opacity: d.opacity, y: 0,
|
||||||
|
duration: 0.18, ease: 'power3.out',
|
||||||
|
}, t1 + 0.08 + i * 0.02);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beat 2 — compiler reveals; chevrons fade in (one between
|
||||||
|
// cluster ↔ compiler, one between compiler ↔ stack — they
|
||||||
|
// act as the directional cue the removed flow curves used to
|
||||||
|
// carry).
|
||||||
|
const t2 = 2 * BEAT;
|
||||||
|
tl.to(zoneCompiler, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t2);
|
||||||
|
if (compiler) {
|
||||||
|
tl.to(compiler, {
|
||||||
|
opacity: 1, scale: 1,
|
||||||
|
duration: 0.20, ease: 'power3.out',
|
||||||
|
}, t2 + 0.04);
|
||||||
|
}
|
||||||
|
if (chevrons.length) {
|
||||||
|
tl.to(chevrons, {
|
||||||
|
opacity: 0.55,
|
||||||
|
duration: 0.18, ease: 'power2.out',
|
||||||
|
stagger: 0.06,
|
||||||
|
}, t2 + 0.10);
|
||||||
|
}
|
||||||
|
if (anchor) {
|
||||||
|
tl.to(anchor, { opacity: 0, duration: 0.10 }, t2 + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beat 3 — page stack composes back → middle → front. As
|
||||||
|
// each card lands, the card(s) behind it pick up blur ("each
|
||||||
|
// layer in front fogs the layers below"). Two compiler →
|
||||||
|
// stack flow lines draw alongside; citation markers on the
|
||||||
|
// front card fade in after the front card has settled.
|
||||||
|
const t3 = 3 * BEAT;
|
||||||
|
tl.to(zoneWiki, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, t3);
|
||||||
|
|
||||||
|
// Back card lands first.
|
||||||
|
if (stackBack) {
|
||||||
|
tl.to(stackBack, {
|
||||||
|
opacity: 1, xPercent: 0, yPercent: 0,
|
||||||
|
duration: 0.22, ease: 'power3.out',
|
||||||
|
}, t3 + 0.04);
|
||||||
|
}
|
||||||
|
// Mid card lands; back card receives its first blur layer.
|
||||||
|
if (stackMid) {
|
||||||
|
tl.to(stackMid, {
|
||||||
|
opacity: 1, xPercent: 0, yPercent: 0,
|
||||||
|
duration: 0.22, ease: 'power3.out',
|
||||||
|
}, t3 + 0.24);
|
||||||
|
}
|
||||||
|
if (stackBack) {
|
||||||
|
tl.to(stackBack, {
|
||||||
|
filter: `blur(${BLUR_MID}px)`,
|
||||||
|
duration: 0.18, ease: 'power2.out',
|
||||||
|
}, t3 + 0.26);
|
||||||
|
}
|
||||||
|
// Front card lands; mid picks up its blur, back's deepens.
|
||||||
|
if (stackFront) {
|
||||||
|
tl.to(stackFront, {
|
||||||
|
opacity: 1, xPercent: 0, yPercent: 0,
|
||||||
|
duration: 0.24, ease: 'power3.out',
|
||||||
|
}, t3 + 0.44);
|
||||||
|
}
|
||||||
|
if (stackMid) {
|
||||||
|
tl.to(stackMid, {
|
||||||
|
filter: `blur(${BLUR_MID_FINAL}px)`,
|
||||||
|
duration: 0.18, ease: 'power2.out',
|
||||||
|
}, t3 + 0.46);
|
||||||
|
}
|
||||||
|
if (stackBack) {
|
||||||
|
tl.to(stackBack, {
|
||||||
|
filter: `blur(${BLUR_BACK}px)`,
|
||||||
|
duration: 0.20, ease: 'power2.out',
|
||||||
|
}, t3 + 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Citations on the front card fade in last (the curving
|
||||||
|
// flow lines that previously drew alongside this beat were
|
||||||
|
// removed in the geometric-language pass — the chevrons in
|
||||||
|
// the gaps already carry direction).
|
||||||
|
tl.to(cites, {
|
||||||
|
opacity: 1,
|
||||||
|
duration: 0.18, ease: 'power2.out',
|
||||||
|
stagger: 0.05,
|
||||||
|
}, t3 + 0.62);
|
||||||
|
|
||||||
|
// Beat 4 — trust beat. Citation [1] lights in walnut, the
|
||||||
|
// source PDF gains its .is-source tint, and a thin arc draws
|
||||||
|
// back from the citation to the source doc. Subtle by design.
|
||||||
|
// Class toggles use paired onStart/onReverseComplete so the
|
||||||
|
// tint retreats cleanly when the user scrolls back up.
|
||||||
|
const t4 = 4 * BEAT;
|
||||||
|
if (firstCite) {
|
||||||
|
tl.to({}, {
|
||||||
|
duration: 0.001,
|
||||||
|
onStart: () => firstCite.classList.add('is-lit'),
|
||||||
|
onReverseComplete: () => firstCite.classList.remove('is-lit'),
|
||||||
|
}, t4);
|
||||||
|
tl.fromTo(firstCite,
|
||||||
|
{ scale: 1 },
|
||||||
|
{ scale: 1.4, duration: 0.12, ease: 'power2.out', transformOrigin: 'center bottom' },
|
||||||
|
t4);
|
||||||
|
tl.to(firstCite, { scale: 1, duration: 0.24, ease: 'power2.inOut' }, t4 + 0.14);
|
||||||
|
}
|
||||||
|
if (sourceDoc) {
|
||||||
|
tl.to({}, {
|
||||||
|
duration: 0.001,
|
||||||
|
onStart: () => sourceDoc.classList.add('is-source'),
|
||||||
|
onReverseComplete: () => sourceDoc.classList.remove('is-source'),
|
||||||
|
}, t4 + 0.04);
|
||||||
|
}
|
||||||
|
// Pair the front card's matching bottom source entry with
|
||||||
|
// the lit citation — readers see the in-text marker tied to
|
||||||
|
// its source row at the foot of the page, AND the arc back
|
||||||
|
// to the document in the cluster.
|
||||||
|
if (pairedSource) {
|
||||||
|
tl.to({}, {
|
||||||
|
duration: 0.001,
|
||||||
|
onStart: () => pairedSource.classList.add('is-paired'),
|
||||||
|
onReverseComplete: () => pairedSource.classList.remove('is-paired'),
|
||||||
|
}, t4 + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Implementation roadmap — pin-with-scrub-release + card morph
|
||||||
|
// Same pin pattern as initCards (Phase A + Phase B), but no
|
||||||
|
// Phase C fade-out: this is the page's final section and stays
|
||||||
|
// visible as the page ends. Header (eyebrow + title), stages,
|
||||||
|
// band, and foot all fade in together so the section arrives
|
||||||
|
// as one unit — anything inside the section that wasn't part
|
||||||
|
// of the reveal would look disjointed once the pin engages and
|
||||||
|
// freezes the section at viewport centre.
|
||||||
|
//
|
||||||
|
// Click-to-expand (setupRoadmapMorph) is wired regardless of
|
||||||
|
// reduced-motion; it's a discrete interaction, not ambient.
|
||||||
|
function initRoadmap(gsap, ScrollTrigger, scroller, reduceMotion) {
|
||||||
|
const section = document.getElementById('platform-roadmap');
|
||||||
|
|
||||||
|
setupRoadmapMorph(reduceMotion);
|
||||||
|
|
||||||
|
if (!section) return;
|
||||||
|
const header = section.querySelector('.platform-cards-head');
|
||||||
|
const stages = Array.from(section.querySelectorAll('.rm-card'));
|
||||||
|
const band = section.querySelector('.rm-band');
|
||||||
|
const foot = section.querySelector('.rm-foot');
|
||||||
|
const targets = [header, ...stages, band, foot].filter(Boolean);
|
||||||
|
if (!targets.length) return;
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
targets.forEach(c => { c.style.opacity = '1'; c.style.transform = 'none'; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.set(targets, { opacity: 0, y: 18 });
|
||||||
|
|
||||||
|
// Phase A — fade IN, ~50vh (center bottom → center center).
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: section,
|
||||||
|
scroller,
|
||||||
|
start: 'center bottom',
|
||||||
|
end: 'center center',
|
||||||
|
scrub: 0.5,
|
||||||
|
animation: gsap.fromTo(
|
||||||
|
targets,
|
||||||
|
{ opacity: 0, y: 18 },
|
||||||
|
{ opacity: 1, y: 0, ease: 'none' }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase B — pin for 100vh of scroll input. No fade-out.
|
||||||
|
ScrollTrigger.create({
|
||||||
|
trigger: section,
|
||||||
|
scroller,
|
||||||
|
start: 'center center',
|
||||||
|
end: '+=100%',
|
||||||
|
pin: true,
|
||||||
|
pinSpacing: true,
|
||||||
|
pinType: 'transform',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Roadmap card morph (FLIP) ───────────────────────────────
|
||||||
|
// Replaces the previous backdropped modal. Clicking a card
|
||||||
|
// toggles .is-expanded on that card and .has-expanded on the
|
||||||
|
// row; CSS reconfigures the grid (4×1 → 6×2). We capture
|
||||||
|
// first/last rects of ALL four cards before and after the
|
||||||
|
// class flip and apply inverse transforms so the layout shift
|
||||||
|
// animates as a single continuous morph — the same DOM
|
||||||
|
// element grows into the featured panel while the others
|
||||||
|
// slide into row 2.
|
||||||
|
//
|
||||||
|
// - Esc, the in-card ×, and clicks outside the expanded
|
||||||
|
// card all collapse it.
|
||||||
|
// - Clicking a different card while one is open does a
|
||||||
|
// single sequenced collapse → expand morph (no abrupt
|
||||||
|
// swap).
|
||||||
|
// - prefers-reduced-motion: classes flip with no FLIP
|
||||||
|
// animation; the CSS @media block handles the cross-fade.
|
||||||
|
let _roadmapMorphWired = false;
|
||||||
|
|
||||||
|
function setupRoadmapMorph(reduceMotion) {
|
||||||
|
if (_roadmapMorphWired) return;
|
||||||
|
const row = document.querySelector('#platform-roadmap .rm-row');
|
||||||
|
if (!row) return;
|
||||||
|
const cards = Array.from(row.querySelectorAll('.rm-card'));
|
||||||
|
if (!cards.length) return;
|
||||||
|
|
||||||
|
const DURATION = 360; // ms — within the 300–400 target
|
||||||
|
const EASE = 'cubic-bezier(0.2, 0, 0, 1)';
|
||||||
|
let activeCard = null; // currently expanded card, if any
|
||||||
|
let animating = false; // ignore re-entry during transition
|
||||||
|
|
||||||
|
// FLIP helper. Captures first rects, runs `mutate`, captures
|
||||||
|
// last rects, then transitions inverse transforms back to
|
||||||
|
// identity. `done` fires after the visual settles.
|
||||||
|
function flip(mutate, done) {
|
||||||
|
if (reduceMotion) {
|
||||||
|
mutate();
|
||||||
|
if (done) done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = cards.map((c) => c.getBoundingClientRect());
|
||||||
|
mutate();
|
||||||
|
const last = cards.map((c) => c.getBoundingClientRect());
|
||||||
|
|
||||||
|
cards.forEach((c, i) => {
|
||||||
|
const f = first[i];
|
||||||
|
const l = last[i];
|
||||||
|
const dx = f.left - l.left;
|
||||||
|
const dy = f.top - l.top;
|
||||||
|
const sx = l.width > 0 ? f.width / l.width : 1;
|
||||||
|
const sy = l.height > 0 ? f.height / l.height : 1;
|
||||||
|
c.style.transformOrigin = 'top left';
|
||||||
|
c.style.transition = 'none';
|
||||||
|
c.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force a synchronous reflow so the inverse transforms
|
||||||
|
// commit before the transition starts.
|
||||||
|
void row.offsetHeight;
|
||||||
|
|
||||||
|
cards.forEach((c) => {
|
||||||
|
c.style.transition = `transform ${DURATION}ms ${EASE}`;
|
||||||
|
c.style.transform = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up at the end; fallback timeout in case transitionend
|
||||||
|
// gets dropped (browser quirk on hidden tabs etc.).
|
||||||
|
let cleanedUp = false;
|
||||||
|
function cleanup() {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
cards.forEach((c) => {
|
||||||
|
c.style.transition = '';
|
||||||
|
c.style.transform = '';
|
||||||
|
c.style.transformOrigin = '';
|
||||||
|
});
|
||||||
|
if (done) done();
|
||||||
|
}
|
||||||
|
const fallback = setTimeout(cleanup, DURATION + 80);
|
||||||
|
cards[0].addEventListener('transitionend', function once(e) {
|
||||||
|
if (e.propertyName !== 'transform') return;
|
||||||
|
cards[0].removeEventListener('transitionend', once);
|
||||||
|
clearTimeout(fallback);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand(card) {
|
||||||
|
if (animating || card === activeCard) return;
|
||||||
|
animating = true;
|
||||||
|
flip(() => {
|
||||||
|
row.classList.add('has-expanded');
|
||||||
|
card.classList.add('is-expanded');
|
||||||
|
card.setAttribute('aria-expanded', 'true');
|
||||||
|
const body = card.querySelector('.rm-card-body');
|
||||||
|
if (body) body.setAttribute('aria-hidden', 'false');
|
||||||
|
activeCard = card;
|
||||||
|
}, () => {
|
||||||
|
animating = false;
|
||||||
|
// Focus the close button so keyboard users can dismiss
|
||||||
|
// immediately with Enter.
|
||||||
|
const closeBtn = card.querySelector('.rm-card-close');
|
||||||
|
if (closeBtn) closeBtn.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse(thenExpand) {
|
||||||
|
if (animating || !activeCard) {
|
||||||
|
if (thenExpand) thenExpand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animating = true;
|
||||||
|
const card = activeCard;
|
||||||
|
flip(() => {
|
||||||
|
card.classList.remove('is-expanded');
|
||||||
|
card.setAttribute('aria-expanded', 'false');
|
||||||
|
const body = card.querySelector('.rm-card-body');
|
||||||
|
if (body) body.setAttribute('aria-hidden', 'true');
|
||||||
|
row.classList.remove('has-expanded');
|
||||||
|
activeCard = null;
|
||||||
|
}, () => {
|
||||||
|
animating = false;
|
||||||
|
// Return focus to the card so keyboard nav doesn't lose
|
||||||
|
// its place.
|
||||||
|
if (card && typeof card.focus === 'function') card.focus();
|
||||||
|
if (thenExpand) thenExpand();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardActivate(card) {
|
||||||
|
if (animating) return;
|
||||||
|
if (activeCard === card) {
|
||||||
|
// Re-clicking the expanded card collapses it.
|
||||||
|
collapse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeCard) {
|
||||||
|
// Sequenced collapse → expand for a smooth swap.
|
||||||
|
const next = card;
|
||||||
|
collapse(() => requestAnimationFrame(() => expand(next)));
|
||||||
|
} else {
|
||||||
|
expand(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
// Close button inside the card has its own handler below;
|
||||||
|
// ignore here so the card click doesn't re-expand.
|
||||||
|
if (e.target.closest('.rm-card-close')) return;
|
||||||
|
onCardActivate(card);
|
||||||
|
});
|
||||||
|
card.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('.rm-card-close')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onCardActivate(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const closeBtn = card.querySelector('.rm-card-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
collapse();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esc closes from anywhere.
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && activeCard) {
|
||||||
|
e.stopPropagation();
|
||||||
|
collapse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Outside click closes — anywhere that's not inside the
|
||||||
|
// currently expanded card.
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!activeCard) return;
|
||||||
|
if (e.target.closest('.rm-card.is-expanded')) return;
|
||||||
|
// Re-activations of OTHER cards are handled in their own
|
||||||
|
// click listeners above; this catches clicks elsewhere.
|
||||||
|
if (e.target.closest('.rm-card')) return;
|
||||||
|
collapse();
|
||||||
|
});
|
||||||
|
|
||||||
|
_roadmapMorphWired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public scrollTo (used when the dot is re-clicked while
|
||||||
|
// already on the deepdive page) ──────────────────────────────
|
||||||
|
function scrollTo(top) {
|
||||||
|
if (!initialized || !scrollerEl) return;
|
||||||
|
const y = typeof top === 'number' ? top : 0;
|
||||||
|
if (lenisInstance && typeof lenisInstance.scrollTo === 'function') {
|
||||||
|
lenisInstance.scrollTo(y, { immediate: false });
|
||||||
|
} else {
|
||||||
|
scrollerEl.scrollTo({ top: y, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lazy auto-init on page activation ───────────────────────
|
||||||
|
// Two cases:
|
||||||
|
// A. Integrated into the Overview: wait for #page-overview to be
|
||||||
|
// active (bifrost.js's init has run), then attach scene
|
||||||
|
// triggers without re-creating Lenis.
|
||||||
|
// B. Standalone deepdive: wait for #page-product-deepdive to be
|
||||||
|
// active and own the full setup.
|
||||||
|
function tryInit() {
|
||||||
|
if (initialized) return;
|
||||||
|
const overviewActive = !!document.querySelector('#page-overview.is-active');
|
||||||
|
const deepdiveActive = !!document.querySelector('#page-product-deepdive.is-active');
|
||||||
|
if (!overviewActive && !deepdiveActive) return;
|
||||||
|
if (typeof window.gsap === 'undefined' ||
|
||||||
|
typeof window.ScrollTrigger === 'undefined' ||
|
||||||
|
typeof window.Lenis === 'undefined') return;
|
||||||
|
// Small delay: in the integrated path this lets bifrost.js finish
|
||||||
|
// wiring scrollerProxy + Lenis before we register triggers. In the
|
||||||
|
// standalone path it just lets layout settle, same as before.
|
||||||
|
setTimeout(init, overviewActive ? 140 : 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachObserver() {
|
||||||
|
const pages = [
|
||||||
|
document.getElementById('page-overview'),
|
||||||
|
document.getElementById('page-product-deepdive'),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (!pages.length) return;
|
||||||
|
const observer = new MutationObserver(tryInit);
|
||||||
|
pages.forEach((p) => observer.observe(p, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class'],
|
||||||
|
}));
|
||||||
|
tryInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__platform = { init, scrollTo };
|
||||||
|
// Backwards-compat: older code referred to `window.__deepdive`.
|
||||||
|
window.__deepdive = window.__platform;
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', attachObserver, { once: true });
|
||||||
|
} else {
|
||||||
|
attachObserver();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -379,8 +379,7 @@ function buildGlobe(wrap, opts) {
|
||||||
*
|
*
|
||||||
* @param {string} targetId e.g. "page-timeline" or "page-overview"
|
* @param {string} targetId e.g. "page-timeline" or "page-overview"
|
||||||
* @param {string?} scrollToId (Overview only) id of a scene to land on:
|
* @param {string?} scrollToId (Overview only) id of a scene to land on:
|
||||||
* "hero", "stack-scene", "words-scene",
|
* "hero", "bifrost", "bifrost-meaning"
|
||||||
* "bifrost", "bifrost-meaning", "bifrost-join"
|
|
||||||
*/
|
*/
|
||||||
function activatePage(targetId, scrollToId) {
|
function activatePage(targetId, scrollToId) {
|
||||||
document.querySelectorAll('.page').forEach(p => {
|
document.querySelectorAll('.page').forEach(p => {
|
||||||
|
|
@ -395,6 +394,18 @@ function activatePage(targetId, scrollToId) {
|
||||||
// the user can't scroll before ScrollTriggers are wired up.
|
// the user can't scroll before ScrollTriggers are wired up.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.__bifrost.init();
|
window.__bifrost.init();
|
||||||
|
// Run platform.js's init in the SAME tick. Without this,
|
||||||
|
// platform's own MutationObserver fires it ~140ms after
|
||||||
|
// page activation — too late for the scrollTo below, which
|
||||||
|
// reads target.offsetTop. Platform installs pin spacers for
|
||||||
|
// #platform-cards and #platform-roadmap; those spacers shift
|
||||||
|
// sections downstream of cards by 150vh, so scrolling to
|
||||||
|
// pre-spacer offsetTop lands the user in empty space.
|
||||||
|
// __platform.init is idempotent (guarded by `initialized`),
|
||||||
|
// so calling it here is a no-op if it already ran.
|
||||||
|
if (window.__platform && typeof window.__platform.init === 'function') {
|
||||||
|
window.__platform.init();
|
||||||
|
}
|
||||||
// After init resolves, scroll to the requested scene (or top
|
// After init resolves, scroll to the requested scene (or top
|
||||||
// if none specified). Bifrost exposes scrollTo() which drives
|
// if none specified). Bifrost exposes scrollTo() which drives
|
||||||
// Lenis on the overview's internal scroller.
|
// Lenis on the overview's internal scroller.
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,30 @@
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Part of BioInnovation Institute AI Lab" — sits directly below
|
||||||
|
the welcome-backer line. Same horizontal anchor (left:75%) and
|
||||||
|
transform offset, just nudged down so the two lines stack with
|
||||||
|
consistent breathing room. Fades in with the welcome step. */
|
||||||
|
.welcome-bii {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(50% + 112px);
|
||||||
|
left: 75%;
|
||||||
|
transform: translate(calc(-38% - 5px), 0);
|
||||||
|
font-family: "Manrope", system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
transition: opacity 640ms var(--ease) 200ms;
|
||||||
|
}
|
||||||
|
body:has(#step-welcome.is-active) .welcome-bii {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
/* ───── Welcome ───── */
|
/* ───── Welcome ───── */
|
||||||
.welcome-title {
|
.welcome-title {
|
||||||
font-family: "Newsreader", Georgia, "Times New Roman", serif;
|
font-family: "Newsreader", Georgia, "Times New Roman", serif;
|
||||||
|
|
@ -322,6 +346,7 @@
|
||||||
.welcome-body { font-size: 18px; }
|
.welcome-body { font-size: 18px; }
|
||||||
.welcome-logo { display: none; }
|
.welcome-logo { display: none; }
|
||||||
.welcome-backer { display: none; }
|
.welcome-backer { display: none; }
|
||||||
|
.welcome-bii { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -342,6 +367,9 @@
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="welcome-bii" aria-hidden="true">
|
||||||
|
Part of BioInnovation Institute AI Lab
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="entrance">
|
<main class="entrance">
|
||||||
<div class="entrance-inner">
|
<div class="entrance-inner">
|
||||||
|
|
@ -349,7 +377,7 @@
|
||||||
<!-- STEP 1 — EMAIL -->
|
<!-- STEP 1 — EMAIL -->
|
||||||
<section class="step is-active" id="step-email">
|
<section class="step is-active" id="step-email">
|
||||||
<p class="tagline">
|
<p class="tagline">
|
||||||
Thank you for your commitment and willingness to contribute.
|
An introduction to Fenja AI.
|
||||||
</p>
|
</p>
|
||||||
<form class="field" id="email-form" novalidate>
|
<form class="field" id="email-form" novalidate>
|
||||||
<input
|
<input
|
||||||
|
|
@ -369,19 +397,19 @@
|
||||||
|
|
||||||
<!-- STEP 2 — WELCOME -->
|
<!-- STEP 2 — WELCOME -->
|
||||||
<!-- The title is set dynamically by entrance.js:
|
<!-- The title is set dynamically by entrance.js:
|
||||||
with first name: "Thanks for your interest, <em>Erik.</em>"
|
with first name: "Welcome, <em>Erik.</em>"
|
||||||
without first name: "Thank you for your <em>interest.</em>"
|
without first name: "<em>Welcome.</em>"
|
||||||
Static fallback text (for no-JS) renders the anonymous variant. -->
|
Static fallback text (for no-JS) renders the anonymous variant. -->
|
||||||
<section class="step" id="step-welcome">
|
<section class="step" id="step-welcome">
|
||||||
<h1 class="welcome-title" id="welcome-title">
|
<h1 class="welcome-title" id="welcome-title">
|
||||||
Thank you for your <em>interest.</em>
|
<em>Welcome.</em>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="welcome-body">
|
<p class="welcome-body">
|
||||||
This is a personal invitation because we believe your perspective
|
In this short walkthrough, we want to introduce Fenja AI — the
|
||||||
can make a meaningful contribution to an important mission: building
|
company and the platform — and the initiative around it,
|
||||||
trusted, sovereign AI for Denmark and Europe. In this short web
|
Project Bifrost. The aim is straightforward: to show why trusted,
|
||||||
experience, we will explain why this matters, what Fenja AI is, and
|
sovereign AI matters now, what Fenja is, and how it is being built
|
||||||
how you, through Project Bifrost, can help shape its future.
|
in Denmark and Europe.
|
||||||
</p>
|
</p>
|
||||||
<div class="welcome-define">
|
<div class="welcome-define">
|
||||||
<h3 class="welcome-term"><em>Fenja AI</em></h3>
|
<h3 class="welcome-term"><em>Fenja AI</em></h3>
|
||||||
|
|
@ -389,7 +417,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="welcome-define">
|
<div class="welcome-define">
|
||||||
<h3 class="welcome-term"><em>Project Bifrost</em></h3>
|
<h3 class="welcome-term"><em>Project Bifrost</em></h3>
|
||||||
<p class="welcome-def">The initiative created to ensure that Fenja AI is built not just for organisations like yours, but <em>with</em> you.</p>
|
<p class="welcome-def">The initiative shaping Fenja AI together with a select group of Danish and European organisations.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="welcome-cta" id="welcome-continue">
|
<button type="button" class="welcome-cta" id="welcome-continue">
|
||||||
<svg class="c-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
<svg class="c-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
|
|
|
||||||
|
|
@ -71,18 +71,16 @@ function showStep(name) {
|
||||||
function setWelcomeTitle(firstName) {
|
function setWelcomeTitle(firstName) {
|
||||||
const el = document.getElementById('welcome-title');
|
const el = document.getElementById('welcome-title');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
// Reset before rebuilding so re-renders don't append stale nodes.
|
||||||
|
el.textContent = '';
|
||||||
if (firstName) {
|
if (firstName) {
|
||||||
// Keep DOM construction to textContent + appended <em> — no innerHTML
|
el.textContent = 'Welcome, ';
|
||||||
// of unsanitised input. firstName came from the server but we still
|
|
||||||
// construct the node tree explicitly for clarity.
|
|
||||||
el.textContent = 'Thank you for your interest, ';
|
|
||||||
const em = document.createElement('em');
|
const em = document.createElement('em');
|
||||||
em.textContent = firstName + '.';
|
em.textContent = firstName + '.';
|
||||||
el.appendChild(em);
|
el.appendChild(em);
|
||||||
} else {
|
} else {
|
||||||
el.textContent = 'Thank you for your ';
|
|
||||||
const em = document.createElement('em');
|
const em = document.createElement('em');
|
||||||
em.textContent = 'interest.';
|
em.textContent = 'Welcome.';
|
||||||
el.appendChild(em);
|
el.appendChild(em);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue