diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/hero-open-source-chip.tsx b/apps/web/src/app/[lang]/components/home-page-section/hero/hero-open-source-chip.tsx
new file mode 100644
index 0000000..40990bc
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/hero/hero-open-source-chip.tsx
@@ -0,0 +1,39 @@
+import { chipClass } from "@scibly/ui/design-language";
+import { cn } from "@scibly/ui/utils";
+import { ArrowDown, Github } from "lucide-react";
+
+import { PILLARS, tint } from "@/app/[lang]/components/marketing-tokens";
+
+import { type HeroDictionary } from "./i18n/hero.types";
+
+export function HeroOpenSourceChip({ t }: { t: HeroDictionary["openSource"] }) {
+ return (
+
+
+ {t.label}
+
+
+ {t.license}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx b/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx
index bbc6f0f..d3e65c3 100644
--- a/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx
+++ b/apps/web/src/app/[lang]/components/home-page-section/hero/hero.tsx
@@ -1,6 +1,7 @@
import { type ReactNode } from "react";
import { HeroGlassFrame } from "./hero-glass-frame";
+import { HeroOpenSourceChip } from "./hero-open-source-chip";
import { type HeroDictionary } from "./i18n/hero.types";
interface HeroProps {
@@ -34,6 +35,10 @@ export function Hero({ t, children }: HeroProps) {
+
+
+
+
{/* Measured in characters so the line length holds at every size. German
compounds need the wider measure to keep the headline off a fourth
line; phones stay tighter so it never runs edge to edge. */}
diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json
index ced21b4..dce223c 100644
--- a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json
+++ b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.de.json
@@ -5,6 +5,10 @@
"titleHighlight": "moderne",
"titleSuffix": " Teams",
"subtitle": "Wir verwandeln vorhandenes Unternehmenswissen in interaktive Kurse.",
+ "openSource": {
+ "label": "Scibly ist Open Source",
+ "license": "AGPLv3"
+ },
"complianceBadges": [
{
"kind": "euServer",
diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json
index d305ab4..d2bf19a 100644
--- a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json
+++ b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.i18n.en.json
@@ -5,6 +5,10 @@
"titleHighlight": "modern",
"titleSuffix": " teams",
"subtitle": "We turn your existing company knowledge into interactive courses.",
+ "openSource": {
+ "label": "Scibly is open source",
+ "license": "AGPLv3"
+ },
"complianceBadges": [
{
"kind": "euServer",
diff --git a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts
index 3b2179b..8e87279 100644
--- a/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts
+++ b/apps/web/src/app/[lang]/components/home-page-section/hero/i18n/hero.types.ts
@@ -32,6 +32,10 @@ export type HeroDictionary = {
titleHighlight: string;
titleSuffix: string;
subtitle: string;
+ openSource: {
+ label: string;
+ license: string;
+ };
complianceBadges: Array<{
kind: string;
label: string;
diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.de.json b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.de.json
new file mode 100644
index 0000000..7fc90a3
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.de.json
@@ -0,0 +1,36 @@
+{
+ "openSource": {
+ "eyebrow": "Open Source",
+ "title": "Offener Code. Deine Wahl. Deine Kontrolle.",
+ "subtitle": "Der Code von Scibly ist Open Source auf GitHub. Klone das Repository, starte den Stack und sieh selbst, wie Scibly funktioniert.",
+ "actions": {
+ "repo": "Code auf GitHub ansehen",
+ "selfHost": "Selbst hosten"
+ },
+ "licenseLabel": "AGPLv3",
+ "terminal": {
+ "transcriptLabel": "Terminal-Ausgabe",
+ "stepsLabel": "Terminal-Schritte"
+ },
+ "steps": [
+ {
+ "kind": "clone",
+ "label": "Repository klonen",
+ "caption": "Der komplette Code. Kein abgespecktes Template.",
+ "note": "AGPLv3. Forke es und liefere deinen eigenen Build aus."
+ },
+ {
+ "kind": "boot",
+ "label": "Stack starten",
+ "caption": "Ein Befehl. Datenbank und Apps laufen.",
+ "note": "Ein Befehl, und der komplette Stack läuft auf deiner eigenen Infrastruktur."
+ },
+ {
+ "kind": "issue",
+ "label": "Bringe deine Ideen ein",
+ "caption": "Bugs melden, Ideen teilen und neue Features vorschlagen.",
+ "note": "Landet im selben Tracker, aus dem wir planen."
+ }
+ ]
+ }
+}
diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.en.json b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.en.json
new file mode 100644
index 0000000..cf66bd6
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.i18n.en.json
@@ -0,0 +1,36 @@
+{
+ "openSource": {
+ "eyebrow": "Open source",
+ "title": "Open code. Your choice. Your control.",
+ "subtitle": "Scibly's code is open source on GitHub. Clone the repository, start the stack, and see for yourself how Scibly works.",
+ "actions": {
+ "repo": "View the code on GitHub",
+ "selfHost": "Host it yourself"
+ },
+ "licenseLabel": "AGPLv3",
+ "terminal": {
+ "transcriptLabel": "Terminal transcript",
+ "stepsLabel": "Terminal steps"
+ },
+ "steps": [
+ {
+ "kind": "clone",
+ "label": "Clone the repository",
+ "caption": "The complete code. Not a stripped-down template.",
+ "note": "AGPLv3. Fork it and ship your own build."
+ },
+ {
+ "kind": "boot",
+ "label": "Start the stack",
+ "caption": "One command. Database and apps running.",
+ "note": "One command, and the whole stack runs on your own infrastructure."
+ },
+ {
+ "kind": "issue",
+ "label": "Contribute your ideas",
+ "caption": "Report bugs, share ideas, suggest new features.",
+ "note": "Lands in the same tracker we plan from."
+ }
+ ]
+ }
+}
diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.types.ts b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.types.ts
new file mode 100644
index 0000000..a28f430
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/i18n/open-source.types.ts
@@ -0,0 +1,22 @@
+type OpenSourceStep = {
+ kind: string;
+ label: string;
+ caption: string;
+ note: string;
+};
+
+export type OpenSourceDictionary = {
+ eyebrow: string;
+ title: string;
+ subtitle: string;
+ actions: {
+ repo: string;
+ selfHost: string;
+ };
+ licenseLabel: string;
+ terminal: {
+ transcriptLabel: string;
+ stepsLabel: string;
+ };
+ steps: OpenSourceStep[];
+};
diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-atmosphere.tsx b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-atmosphere.tsx
new file mode 100644
index 0000000..b43ec14
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-atmosphere.tsx
@@ -0,0 +1,43 @@
+import { Braces, Container, GitFork, Terminal } from "lucide-react";
+
+import {
+ type AtmosphereTile,
+ MarketingAtmosphere,
+} from "@/app/[lang]/components/marketing-grid-field";
+import { PILLARS } from "@/app/[lang]/components/marketing-tokens";
+
+const tiles: AtmosphereTile[] = [
+ {
+ id: "terminal",
+ className: "top-[16%] left-[3.5%] hidden xl:flex",
+ icon:
,
+ tone: PILLARS.import,
+ },
+ {
+ id: "fork",
+ className: "top-[24%] right-[4%] hidden xl:flex",
+ icon:
,
+ tone: PILLARS.byoai,
+ },
+ {
+ id: "container",
+ className: "bottom-[18%] left-[5%] hidden 2xl:flex",
+ icon:
,
+ tone: PILLARS.channels,
+ },
+ {
+ id: "braces",
+ className: "right-[3.5%] bottom-[15%] hidden 2xl:flex",
+ icon:
,
+ tone: PILLARS.learner,
+ },
+];
+
+const indents = [
+ "top-[54%] left-[4%] hidden xl:block",
+ "top-[62%] right-[5%] hidden xl:block",
+];
+
+export function OpenSourceAtmosphere() {
+ return
;
+}
diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-facts.ts b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-facts.ts
new file mode 100644
index 0000000..d762b27
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source-facts.ts
@@ -0,0 +1,72 @@
+import { routes } from "@scibly/routes";
+import { CircleDot, Container, type LucideIcon, Terminal } from "lucide-react";
+
+import { type Pillar, PILLARS } from "@/app/[lang]/components/marketing-tokens";
+
+/* The transcript stays untranslated: `docker compose up` prints English in every locale. */
+export const MONO_FONT = 'ui-monospace, "SFMono-Regular", Menlo, monospace';
+
+const SELF_HOST_STEP_IDS = ["clone", "boot", "issue"] as const;
+
+type SelfHostStepId = (typeof SELF_HOST_STEP_IDS)[number];
+
+export const toSelfHostStepId = (id: string): SelfHostStepId | undefined =>
+ SELF_HOST_STEP_IDS.find((known) => known === id);
+
+type TerminalLineTone = "log" | "ok" | "port";
+
+export type TerminalLine = {
+ tone: TerminalLineTone;
+ text: string;
+ value?: string;
+};
+
+type SelfHostStep = {
+ pillar: Pillar;
+ icon: LucideIcon;
+ command: string;
+ lines: readonly TerminalLine[];
+};
+
+export const SELF_HOST_STEPS = {
+ clone: {
+ pillar: PILLARS.import,
+ icon: Terminal,
+ command: "git clone https://github.com/scibly-dev/scibly",
+ lines: [
+ { tone: "log", text: "Cloning into 'scibly'..." },
+ { tone: "log", text: "remote: Counting objects: 100%, done." },
+ { tone: "ok", text: "apps/ packages/ ee/ docs/" },
+ ],
+ },
+ boot: {
+ pillar: PILLARS.channels,
+ icon: Container,
+ command: "docker compose up -d --build",
+ lines: [
+ { tone: "ok", text: "Container scibly-postgres-1 Started" },
+ { tone: "port", text: "web", value: "http://localhost:3000" },
+ { tone: "port", text: "app", value: "http://localhost:3001" },
+ { tone: "port", text: "collab", value: "ws://localhost:4000" },
+ ],
+ },
+ issue: {
+ pillar: PILLARS.analytics,
+ icon: CircleDot,
+ command: "gh issue create --repo scibly-dev/scibly",
+ lines: [
+ { tone: "log", text: "Creating issue in scibly-dev/scibly" },
+ { tone: "log", text: "? Title SCORM export for course packages" },
+ { tone: "ok", text: "? What's next? Submit" },
+ ],
+ },
+} satisfies Record
;
+
+export function transcriptOf(step: SelfHostStep): string {
+ return step.lines
+ .map((line) => [line.text, line.value].filter(Boolean).join(" "))
+ .join(" · ");
+}
+
+export const SELF_HOSTING_GUIDE_HREF =
+ routes.external.github.file("docs/docker.md");
diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source.tsx b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source.tsx
new file mode 100644
index 0000000..b0a417a
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/open-source.tsx
@@ -0,0 +1,95 @@
+import { routes } from "@scibly/routes";
+import {
+ actionClass,
+ primaryActionClass,
+ secondaryActionClass,
+} from "@scibly/ui/design-language";
+import { cn } from "@scibly/ui/utils";
+import { ArrowUpRight, Github, Server } from "lucide-react";
+
+import {
+ MarketingSection,
+ MarketingSectionHeader,
+} from "@/app/[lang]/components/marketing-section-content";
+import {
+ lipShadow,
+ PILLARS,
+ tint,
+} from "@/app/[lang]/components/marketing-tokens";
+
+import { type OpenSourceDictionary } from "./i18n/open-source.types";
+import { OpenSourceAtmosphere } from "./open-source-atmosphere";
+import { MONO_FONT, SELF_HOSTING_GUIDE_HREF } from "./open-source-facts";
+import { SelfHostTerminal } from "./self-host-terminal";
+
+export function OpenSourceSection({ t }: { t: OpenSourceDictionary }) {
+ return (
+ }
+ >
+
+ {t.eyebrow}
+ {t.licenseLabel}
+
+ }
+ title={t.title}
+ description={t.subtitle}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+function LicensePill({ children }: { children: React.ReactNode }) {
+ const tone = PILLARS.learner;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/app/[lang]/components/home-page-section/open-source/self-host-terminal.tsx b/apps/web/src/app/[lang]/components/home-page-section/open-source/self-host-terminal.tsx
new file mode 100644
index 0000000..24303b3
--- /dev/null
+++ b/apps/web/src/app/[lang]/components/home-page-section/open-source/self-host-terminal.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { cn } from "@scibly/ui/utils";
+import { GitBranch } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+
+import {
+ chosenKey,
+ IDLE_KEY,
+ keyStyle,
+} from "@/app/[lang]/components/marketing-tokens";
+import {
+ useInViewOnce,
+ usePrefersReducedMotion,
+} from "@/components/in-view-reveal";
+
+import { type OpenSourceDictionary } from "./i18n/open-source.types";
+import {
+ MONO_FONT,
+ SELF_HOST_STEPS,
+ type TerminalLine,
+ toSelfHostStepId,
+ transcriptOf,
+} from "./open-source-facts";
+
+const PROMPT_GREEN = "#a3e163";
+const CHECK_GREEN = "#58cc02";
+const URL_BLUE = "#94c4ff";
+
+const TYPE_MS_PER_CHAR = 24;
+const TYPE_START_MS = 160;
+const LINE_STAGGER_MS = 150;
+const STEP_HOLD_MS = 1900;
+
+export function SelfHostTerminal({ t }: { t: OpenSourceDictionary }) {
+ const { ref, inView } = useInViewOnce(0.3);
+ const prefersReducedMotion = usePrefersReducedMotion();
+
+ const steps = useMemo(
+ () =>
+ t.steps.flatMap((step) => {
+ const kind = toSelfHostStepId(step.kind);
+ return kind ? [{ ...step, ...SELF_HOST_STEPS[kind], kind }] : [];
+ }),
+ [t.steps],
+ );
+
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [isAutoplaying, setIsAutoplaying] = useState(true);
+
+ const active = steps[activeIndex] ?? steps[0];
+ const lastIndex = steps.length - 1;
+
+ const typeMs = active ? active.command.length * TYPE_MS_PER_CHAR : 0;
+ const outroMs = active
+ ? TYPE_START_MS + typeMs + (active.lines.length + 1) * LINE_STAGGER_MS
+ : 0;
+
+ useEffect(() => {
+ if (!inView || !isAutoplaying || prefersReducedMotion) return;
+ if (activeIndex >= lastIndex) return;
+
+ const timer = window.setTimeout(
+ () => setActiveIndex((index) => index + 1),
+ outroMs + STEP_HOLD_MS,
+ );
+ return () => window.clearTimeout(timer);
+ }, [
+ inView,
+ isAutoplaying,
+ prefersReducedMotion,
+ activeIndex,
+ lastIndex,
+ outroMs,
+ ]);
+
+ if (!active) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ scibly — bash
+
+
+
+
+ main
+
+
+
+ {/* Keyed on the step so switching remounts the lines and replays their CSS timeline. */}
+
+ {/* `div`, not `p`: the app's unlayered `:where(p, li, dd) { text-wrap: pretty }` outranks `whitespace-nowrap`. */}
+
+ ${" "}
+
+ {active.command}
+
+
+
+ {active.lines.map((line, index) => (
+
+ ))}
+
+
+ # {active.note}
+
+
+
+
+ {steps.map((step) => (
+ -
+ {step.label}:
{step.command} — {transcriptOf(step)} —{" "}
+ {step.note}
+
+ ))}
+
+
+
+
+ {steps.map((step, index) => {
+ const isActive = index === activeIndex;
+ const Icon = step.icon;
+
+ return (
+ -
+
+
+ );
+ })}
+
+
+ );
+}
+
+function OutputLine({
+ line,
+ delayMs,
+}: {
+ line: TerminalLine;
+ delayMs: number;
+}) {
+ return (
+
+ {line.tone === "ok" ? (
+
+ ✔
+
+ ) : (
+ ·
+ )}
+
+ {line.tone === "port" ? (
+ <>
+
+ {line.text}
+
+ {line.value}
+ >
+ ) : (
+ {line.text}
+ )}
+
+ );
+}
+
+function Dot({ color }: { color: string }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/[lang]/page.tsx b/apps/web/src/app/[lang]/page.tsx
index feb1632..769fc80 100644
--- a/apps/web/src/app/[lang]/page.tsx
+++ b/apps/web/src/app/[lang]/page.tsx
@@ -36,6 +36,11 @@ const ComplianceSection = dynamic(() =>
(m) => m.ComplianceSection,
),
);
+const OpenSourceSection = dynamic(() =>
+ import("./components/home-page-section/open-source/open-source").then(
+ (m) => m.OpenSourceSection,
+ ),
+);
const CtaSection = dynamic(() =>
import("./components/home-page-section/cta/cta").then((m) => m.CtaSection),
);
@@ -88,6 +93,7 @@ export default async function Home(props: {
+
diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts
index e2a786a..642bd79 100644
--- a/apps/web/src/i18n/types.ts
+++ b/apps/web/src/i18n/types.ts
@@ -6,6 +6,7 @@ import { type FaqDictionary } from "@/app/[lang]/components/home-page-section/fa
import { type FeatureShowcaseDictionary } from "@/app/[lang]/components/home-page-section/feature-showcase/i18n/feature-showcase.types";
import { type FounderDictionary } from "@/app/[lang]/components/home-page-section/founder/i18n/founder.types";
import { type HeroDictionary } from "@/app/[lang]/components/home-page-section/hero/i18n/hero.types";
+import { type OpenSourceDictionary } from "@/app/[lang]/components/home-page-section/open-source/i18n/open-source.types";
import { type QualityProofDictionary } from "@/app/[lang]/components/home-page-section/quality-proof/i18n/quality-proof.types";
import { type AppRoutesPage } from "@/app/[lang]/i18n/app-routes.types";
import { type BlogPage } from "@/app/[lang]/i18n/blog.types";
@@ -27,6 +28,7 @@ export type Dictionary = {
compliance: ComplianceDictionary;
demoTour: DemoTourDictionary;
founder: FounderDictionary;
+ openSource: OpenSourceDictionary;
};
};
diff --git a/apps/web/src/styles/marketing-motion.css b/apps/web/src/styles/marketing-motion.css
index be44339..fff4c00 100644
--- a/apps/web/src/styles/marketing-motion.css
+++ b/apps/web/src/styles/marketing-motion.css
@@ -370,3 +370,71 @@
animation: none;
}
}
+
+@keyframes sc-type {
+ from {
+ width: 0;
+ }
+ to {
+ width: var(--sc-type-width);
+ }
+}
+
+@keyframes sc-caret {
+ 0%,
+ 100% {
+ border-right-color: rgba(163, 225, 99, 0.95);
+ }
+ 50% {
+ border-right-color: transparent;
+ }
+}
+
+[data-term-command] {
+ /* Content-box keeps `width: Nch` exactly N characters wide; under border-box the caret's 2px eats the last glyph. */
+ box-sizing: content-box;
+ display: inline-block;
+ width: var(--sc-type-width);
+ overflow: hidden;
+ vertical-align: bottom;
+ white-space: nowrap;
+ border-right: 2px solid rgba(163, 225, 99, 0.95);
+}
+
+@media (scripting: enabled) {
+ .sc-term:not(.sc-term-ready) [data-term-command] {
+ width: 0;
+ }
+
+ .sc-term:not(.sc-term-ready) [data-term-anim] {
+ opacity: 0;
+ }
+}
+
+.sc-term-ready [data-term-command] {
+ animation-name: sc-type, sc-caret;
+ animation-duration: var(--sc-type-duration), 1.1s;
+ animation-timing-function: steps(var(--sc-type-steps), end), step-end;
+ animation-delay: 160ms, 160ms;
+ animation-fill-mode: both, none;
+ animation-iteration-count: 1, infinite;
+}
+
+.sc-term-ready [data-term-anim] {
+ animation: sc-rise 260ms cubic-bezier(0.22, 1, 0.36, 1) var(--sc-line-delay)
+ both;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sc-term [data-term-command] {
+ animation: none !important;
+ width: var(--sc-type-width) !important;
+ border-right-color: transparent;
+ }
+
+ .sc-term [data-term-anim] {
+ animation: none !important;
+ opacity: 1 !important;
+ transform: none !important;
+ }
+}
diff --git a/packages/routes/src/index.ts b/packages/routes/src/index.ts
index af9f85a..7dbe897 100644
--- a/packages/routes/src/index.ts
+++ b/packages/routes/src/index.ts
@@ -28,6 +28,8 @@ const toHomeUrl = (path: string) =>
const toAppUrl = (path: string) =>
env.NEXT_PUBLIC_APP_URL.concat(path.startsWith("/") ? path : `/${path}`);
+const GITHUB_REPO_URL = "https://github.com/scibly-dev/scibly" as const;
+
const BASE_AUTH_PATH = "/auth" as const;
const BASE_PROFILE_PATH = "/profile" as const;
const BASE_API_PATH = "/api" as const;
@@ -206,6 +208,12 @@ export const routes = {
external: {
docs: env.NEXT_PUBLIC_DOCS_URL,
+
+ github: {
+ repo: GITHUB_REPO_URL,
+ issues: `${GITHUB_REPO_URL}/issues` as const,
+ file: (path: string) => `${GITHUB_REPO_URL}/blob/main/${path}` as const,
+ },
},
pythonBackend: {