Feat: Add custom landing page with theme switcher and auto-versioned SPM badge

- Next.js static site in docs/ with SN Pro font and WarText headline
- 4 phosphor themes (Green, Amber, White, Red) with localStorage persistence
- Animated cloud background tinted to active theme
- SF Symbols icons via sf-symbols-lib/hierarchical
- PackageBadge with copy-to-clipboard, version from CI tag
- CI workflow builds landing page + DocC, deploys to GitHub Pages
- Version auto-resolved from git tags on deploy
This commit is contained in:
phranck
2026-01-31 16:33:32 +01:00
parent 18a9449f45
commit ba92c10863
23 changed files with 7798 additions and 16 deletions
+36 -14
View File
@@ -3,6 +3,7 @@ name: CI
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
workflow_dispatch:
@@ -61,15 +62,46 @@ jobs:
DISABLE_SWIFTLINT: "1"
run: swift test
# ── DocC Documentation (macOS only, main branch only) ──────────
# ── Landing Page + DocC (macOS only, main/tag only) ────────────
build-docs:
name: Build DocC
name: Build Landing Page & DocC
needs: build
runs-on: macos-15
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version
id: version
run: |
# Use tag name if triggered by a tag push, otherwise latest tag
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.1.0")
fi
# Strip leading 'v' if present (v1.2.3 -> 1.2.3)
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved version: $VERSION"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: docs/package-lock.json
- name: Build landing page
working-directory: docs
env:
TUIKIT_VERSION: ${{ steps.version.outputs.version }}
run: |
npm ci
npm run build
- name: Build DocC documentation
env:
@@ -81,19 +113,9 @@ jobs:
--output-path docs-output \
--transform-for-static-hosting
- name: Add root redirect to documentation
- name: Assemble site
run: |
cat > docs-output/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0; url=/documentation/tuikit" />
</head>
<body>
<a href="/documentation/tuikit">Redirecting to TUIkit Documentation...</a>
</body>
</html>
HTMLEOF
cp -r docs/out/* docs-output/
- name: Add CNAME for custom domain
run: echo "tuikit.layered.work" > docs-output/CNAME
+3 -2
View File
@@ -10,9 +10,10 @@ DerivedData/
# DocC generated output
/docs-output/
# VitePress and dependencies
# Next.js landing page
docs/node_modules/
docs/.vitepress/dist/
docs/.next/
docs/out/
# Claude Code
.claude/
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+77
View File
@@ -0,0 +1,77 @@
"use client";
/**
* Animated cloudy background tinted to the active theme color.
*
* Uses CSS custom properties (--accent-glow, --accent-glow-secondary,
* --accent-glow-tertiary) so the clouds automatically recolor on theme switch.
* Each blob has its own animation timing, position, and intensity.
*/
export default function CloudBackground() {
return (
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
{/* Large cloud — top left */}
<div
className="absolute -left-32 -top-32 h-[800px] w-[800px] rounded-full blur-[150px] transition-[background] duration-700"
style={{
background:
"radial-gradient(circle, rgba(var(--accent-glow),0.50) 0%, rgba(var(--accent-glow-secondary),0.20) 40%, transparent 70%)",
animation: "cloud-drift 90s ease-in-out infinite",
}}
/>
{/* Medium cloud — top right */}
<div
className="absolute -right-20 top-1/4 h-[700px] w-[700px] rounded-full blur-[130px] transition-[background] duration-700"
style={{
background:
"radial-gradient(circle, rgba(var(--accent-glow-secondary),0.40) 0%, rgba(var(--accent-glow-tertiary),0.15) 40%, transparent 70%)",
animation: "cloud-drift-reverse 110s ease-in-out infinite",
}}
/>
{/* Accent cloud — center */}
<div
className="absolute left-1/3 top-1/2 h-[600px] w-[600px] rounded-full blur-[120px] transition-[background] duration-700"
style={{
background:
"radial-gradient(circle, rgba(var(--accent-glow),0.35) 0%, rgba(var(--accent-glow-secondary),0.12) 40%, transparent 70%)",
animation: "cloud-drift-slow 120s ease-in-out infinite",
}}
/>
{/* Bottom-left glow */}
<div
className="absolute -bottom-40 -left-20 h-[700px] w-[700px] rounded-full blur-[140px] transition-[background] duration-700"
style={{
background:
"radial-gradient(circle, rgba(var(--accent-glow-tertiary),0.38) 0%, rgba(var(--accent-glow-secondary),0.14) 40%, transparent 70%)",
animation: "cloud-drift-reverse 100s ease-in-out infinite",
animationDelay: "-30s",
}}
/>
{/* Wide upper cloud for depth */}
<div
className="absolute left-1/2 -top-20 h-[500px] w-[900px] -translate-x-1/2 rounded-full blur-[150px] transition-[background] duration-700"
style={{
background:
"radial-gradient(ellipse, rgba(var(--accent-glow),0.30) 0%, transparent 60%)",
animation: "cloud-drift-slow 130s ease-in-out infinite",
animationDelay: "-50s",
}}
/>
{/* Extra cloud — bottom right */}
<div
className="absolute -bottom-20 -right-32 h-[600px] w-[600px] rounded-full blur-[130px] transition-[background] duration-700"
style={{
background:
"radial-gradient(circle, rgba(var(--accent-glow-secondary),0.32) 0%, rgba(var(--accent-glow),0.10) 40%, transparent 70%)",
animation: "cloud-drift 105s ease-in-out infinite",
animationDelay: "-40s",
}}
/>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
/** A Swift code block with copy-to-clipboard and minimal syntax highlighting. */
export default function CodePreview() {
const [copied, setCopied] = useState(false);
const code = `import TUIkit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
VStack {
Text("Hello, TUIkit!")
.bold()
.foregroundColor(.cyan)
Button("Press me") {
// handle action
}
}
}
}
}`;
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="group relative w-full overflow-hidden rounded-xl border border-border bg-[#0c0c0e]">
{/* Header bar */}
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<div className="h-3 w-3 rounded-full bg-[#febc2e]" />
<div className="h-3 w-3 rounded-full bg-[#28c840]" />
</div>
<span className="ml-3 text-xs text-muted">MyApp.swift</span>
</div>
<button
onClick={handleCopy}
className="rounded-md px-2.5 py-1 text-xs text-muted transition-colors hover:bg-white/5 hover:text-foreground"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
{/* Code content */}
<pre className="overflow-x-auto p-5 text-base leading-relaxed">
<code>
<Highlight code={code} />
</code>
</pre>
</div>
);
}
/** Minimal Swift syntax highlighter — no external dependency. */
function Highlight({ code }: { code: string }) {
const keywords =
/\b(struct|var|some|func|import|let|return|if|else|for|in|while|switch|case|default|class|protocol|enum|init|self|true|false|nil|private|public|internal)\b/g;
const decorators = /(@\w+)/g;
const types = /\b(App|Scene|WindowGroup|VStack|HStack|Text|Button|View|String|Int|Bool|Never)\b/g;
const strings = /("(?:[^"\\]|\\.)*")/g;
const comments = /(\/\/.*$)/gm;
const modifiers = /(\.\w+\()/g;
const lines = code.split("\n");
return (
<>
{lines.map((line, lineIndex) => (
<span key={lineIndex}>
{tokenizeLine(line, {
keywords,
decorators,
types,
strings,
comments,
modifiers,
})}
{lineIndex < lines.length - 1 && "\n"}
</span>
))}
</>
);
}
interface Patterns {
keywords: RegExp;
decorators: RegExp;
types: RegExp;
strings: RegExp;
comments: RegExp;
modifiers: RegExp;
}
function tokenizeLine(line: string, patterns: Patterns) {
// Comments take precedence
const commentMatch = line.match(/^(.*?)(\/\/.*)$/);
if (commentMatch) {
const [, before, comment] = commentMatch;
return (
<>
{tokenizeSegment(before, patterns)}
<span className="text-[#6a737d]">{comment}</span>
</>
);
}
return tokenizeSegment(line, patterns);
}
function tokenizeSegment(segment: string, patterns: Patterns) {
// Build a combined regex for all token types
const combined =
/(@\w+)|("(?:[^"\\]|\\.)*")|(\.\w+)\(|\b(struct|var|some|func|import|let|return|if|else|for|in|while|switch|case|default|class|protocol|enum|init|self|true|false|nil|private|public|internal)\b|\b(App|Scene|WindowGroup|VStack|HStack|Text|Button|View|String|Int|Bool|Never)\b/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
// Reset pattern state
combined.lastIndex = 0;
while ((match = combined.exec(segment)) !== null) {
// Add text before match
if (match.index > lastIndex) {
parts.push(segment.slice(lastIndex, match.index));
}
if (match[1]) {
// Decorator (@main, @State)
parts.push(
<span key={match.index} className="text-[#d19a66]">
{match[1]}
</span>
);
} else if (match[2]) {
// String literal
parts.push(
<span key={match.index} className="text-[#98c379]">
{match[2]}
</span>
);
} else if (match[3]) {
// Modifier (.bold, .foregroundColor) — add dot+name, then the ( back
parts.push(
<span key={match.index} className="text-[#61afef]">
{match[3]}
</span>
);
parts.push("(");
} else if (match[4]) {
// Keyword
parts.push(
<span key={match.index} className="text-[#c678dd]">
{match[4]}
</span>
);
} else if (match[5]) {
// Type
parts.push(
<span key={match.index} className="text-[#e5c07b]">
{match[5]}
</span>
);
}
lastIndex = combined.lastIndex;
}
// Remaining text
if (lastIndex < segment.length) {
parts.push(segment.slice(lastIndex));
}
return <>{parts}</>;
}
+26
View File
@@ -0,0 +1,26 @@
import { ReactNode } from "react";
interface FeatureCardProps {
icon: ReactNode;
title: string;
description: string;
}
/** A feature highlight card with icon, title, and description. */
export default function FeatureCard({
icon,
title,
description,
}: FeatureCardProps) {
return (
<div className="group rounded-xl border border-border bg-card p-6 transition-all duration-300 hover:border-accent/30 hover:bg-card-hover">
<div className="mb-3 flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent transition-colors group-hover:bg-accent/15">
{icon}
</div>
<h3 className="text-2xl font-semibold text-foreground">{title}</h3>
</div>
<p className="text-xl leading-relaxed text-muted">{description}</p>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import {
SFSymbol,
SFAppleTerminalFill,
SFPaintbrushFill,
SFKeyboardFill,
SFSquareStack3dUpFill,
SFBoltFill,
SFDocumentFill,
SFEyeFill,
SFArrowLeftArrowRight,
SFSwift,
SFCheckmarkCircleFill,
} from "sf-symbols-lib/hierarchical";
/** Maps of icon names to SF Symbol constants for type-safe usage. */
const icons = {
terminal: SFAppleTerminalFill,
paintbrush: SFPaintbrushFill,
keyboard: SFKeyboardFill,
stack: SFSquareStack3dUpFill,
bolt: SFBoltFill,
document: SFDocumentFill,
eye: SFEyeFill,
arrows: SFArrowLeftArrowRight,
swift: SFSwift,
checkmark: SFCheckmarkCircleFill,
} as const;
export type IconName = keyof typeof icons;
interface IconProps {
name: IconName;
size?: number;
className?: string;
}
/** Wrapper around SFSymbol that works in both server and client contexts. */
export default function Icon({ name, size = 20, className }: IconProps) {
return <SFSymbol name={icons[name]} size={size} className={className} />;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import Icon from "./Icon";
const VERSION = process.env.TUIKIT_VERSION ?? "0.1.0";
const PACKAGE_LINE = `.package(url: "https://github.com/phranck/TUIkit.git", from: "${VERSION}")`;
/** SPM package dependency badge with copy-to-clipboard. */
export default function PackageBadge() {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(PACKAGE_LINE);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="inline-flex items-center gap-3 rounded-full border border-border bg-card/50 px-6 py-3 text-muted backdrop-blur-sm">
<Icon name="swift" size={20} className="text-accent" />
<code className="font-mono text-xl">
{PACKAGE_LINE}
</code>
<button
onClick={handleCopy}
aria-label="Copy to clipboard"
className="ml-1 rounded-md p-1.5 text-muted transition-colors hover:bg-white/10 hover:text-foreground"
>
{copied ? (
<Icon name="checkmark" size={18} className="text-accent" />
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
/** Available phosphor themes matching TUIkit's built-in palettes. */
export const themes = ["green", "amber", "white", "red"] as const;
export type Theme = (typeof themes)[number];
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "amber",
setTheme: () => {},
});
const STORAGE_KEY = "tuikit-theme";
/** Provides theme state and applies it to the document root via data-theme attribute. */
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>("amber");
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored && themes.includes(stored)) {
setThemeState(stored);
document.documentElement.setAttribute("data-theme", stored);
} else {
document.documentElement.setAttribute("data-theme", "amber");
}
}, []);
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem(STORAGE_KEY, next);
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
/** Hook to access the current theme and setter. */
export function useTheme() {
return useContext(ThemeContext);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useTheme, themes, type Theme } from "./ThemeProvider";
/** Color dot for each theme used in the switcher. */
const themeColors: Record<Theme, string> = {
green: "#33ff33",
amber: "#ffbf00",
white: "#e4e4e7",
red: "#ff3333",
};
/** Theme labels for accessibility. */
const themeLabels: Record<Theme, string> = {
green: "Green",
amber: "Amber",
white: "White",
red: "Red",
};
/** Compact theme switcher with colored dots and active indicator. */
export default function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div className="flex items-center gap-1.5">
{themes.map((themeOption) => (
<button
key={themeOption}
onClick={() => setTheme(themeOption)}
aria-label={`Switch to ${themeLabels[themeOption]} theme`}
className="group relative flex h-7 w-7 items-center justify-center rounded-full transition-transform hover:scale-110"
>
<span
className="block h-3 w-3 rounded-full transition-all"
style={{
backgroundColor: themeColors[themeOption],
boxShadow:
theme === themeOption
? `0 0 6px ${themeColors[themeOption]}, 0 0 14px ${themeColors[themeOption]}60`
: "none",
opacity: theme === themeOption ? 1 : 0.4,
}}
/>
</button>
))}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+203
View File
@@ -0,0 +1,203 @@
@import url("https://fonts.googleapis.com/css2?family=SN+Pro:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@font-face {
font-family: "WarText";
src: url("/fonts/WarText.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Theme: Amber (default) */
:root,
[data-theme="amber"] {
--background: #0a0a0a;
--foreground: #e8dcc2;
--accent: #fbbf24;
--accent-secondary: #f59e0b;
--accent-glow: 251, 191, 36;
--accent-glow-secondary: 245, 158, 11;
--accent-glow-tertiary: 217, 119, 6;
--headline-color: #ffbf00;
--headline-glow: 255, 191, 0;
--muted: #ad9a6e;
--border: #27272a;
--card: #111113;
--card-hover: #18181b;
}
/* Theme: Green */
[data-theme="green"] {
--foreground: #c2e8d4;
--accent: #6ee7b7;
--accent-secondary: #34d399;
--accent-glow: 110, 231, 183;
--accent-glow-secondary: 52, 211, 153;
--accent-glow-tertiary: 16, 185, 129;
--headline-color: #33ff33;
--headline-glow: 51, 255, 51;
--muted: #7aad94;
}
/* Theme: White */
[data-theme="white"] {
--foreground: #d8d8da;
--accent: #e4e4e7;
--accent-secondary: #a1a1aa;
--accent-glow: 228, 228, 231;
--accent-glow-secondary: 161, 161, 170;
--accent-glow-tertiary: 113, 113, 122;
--headline-color: #f0f0f0;
--headline-glow: 240, 240, 240;
--muted: #8a8a90;
}
/* Theme: Red */
[data-theme="red"] {
--foreground: #e8c8c8;
--accent: #f87171;
--accent-secondary: #ef4444;
--accent-glow: 248, 113, 113;
--accent-glow-secondary: 239, 68, 68;
--accent-glow-tertiary: 220, 38, 38;
--headline-color: #ff3333;
--headline-glow: 255, 51, 51;
--muted: #ad7a7a;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-accent-secondary: var(--accent-secondary);
--color-muted: var(--muted);
--color-border: var(--border);
--color-card: var(--card);
--color-card-hover: var(--card-hover);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: "SN Pro", system-ui, sans-serif;
font-size: 1.375rem;
line-height: 1.75;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
/* Code block styling */
code {
font-family: var(--font-geist-mono), ui-monospace, monospace;
}
/* Cloud animation keyframes — slow, massive wandering paths */
@keyframes cloud-drift {
0% {
transform: translate(0, 0) scale(1);
opacity: 0.5;
}
10% {
transform: translate(900px, -500px) scale(1.15);
opacity: 0.6;
}
22% {
transform: translate(-600px, 800px) scale(0.85);
opacity: 0.4;
}
38% {
transform: translate(1100px, 300px) scale(1.1);
opacity: 0.55;
}
52% {
transform: translate(-900px, -700px) scale(0.9);
opacity: 0.45;
}
68% {
transform: translate(500px, -1000px) scale(1.08);
opacity: 0.5;
}
82% {
transform: translate(-400px, 600px) scale(0.92);
opacity: 0.48;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 0.5;
}
}
@keyframes cloud-drift-reverse {
0% {
transform: translate(0, 0) scale(1);
opacity: 0.4;
}
12% {
transform: translate(-1000px, 600px) scale(1.12);
opacity: 0.55;
}
25% {
transform: translate(700px, -900px) scale(0.88);
opacity: 0.35;
}
40% {
transform: translate(-500px, -800px) scale(1.08);
opacity: 0.5;
}
55% {
transform: translate(1100px, 400px) scale(0.92);
opacity: 0.42;
}
70% {
transform: translate(-800px, 700px) scale(1.06);
opacity: 0.48;
}
85% {
transform: translate(400px, -500px) scale(0.95);
opacity: 0.44;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 0.4;
}
}
@keyframes cloud-drift-slow {
0% {
transform: translate(0, 0) scale(1);
opacity: 0.35;
}
14% {
transform: translate(800px, 900px) scale(1.1);
opacity: 0.5;
}
28% {
transform: translate(-1000px, -400px) scale(0.88);
opacity: 0.3;
}
42% {
transform: translate(600px, -1100px) scale(1.06);
opacity: 0.45;
}
57% {
transform: translate(-700px, 500px) scale(0.92);
opacity: 0.38;
}
72% {
transform: translate(1000px, -300px) scale(1.04);
opacity: 0.42;
}
86% {
transform: translate(-500px, 800px) scale(0.96);
opacity: 0.4;
}
100% {
transform: translate(0, 0) scale(1);
opacity: 0.35;
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { Geist_Mono } from "next/font/google";
import { ThemeProvider } from "./components/ThemeProvider";
import "./globals.css";
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TUIkit — Terminal UI Framework for Swift",
description:
"A declarative, SwiftUI-like framework for building Terminal User Interfaces in Swift. No ncurses, no C dependencies — pure Swift.",
openGraph: {
title: "TUIkit — Terminal UI Framework for Swift",
description:
"Build terminal apps with SwiftUI-like syntax. Pure Swift, no ncurses.",
url: "https://tuikit.layered.work",
siteName: "TUIkit",
type: "website",
},
icons: {
icon: "/tuikit-logo.png",
apple: "/tuikit-logo.png",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body
className={`${geistMono.variable} antialiased`}
>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
+288
View File
@@ -0,0 +1,288 @@
import Image from "next/image";
import CloudBackground from "./components/CloudBackground";
import CodePreview from "./components/CodePreview";
import FeatureCard from "./components/FeatureCard";
import Icon from "./components/Icon";
import PackageBadge from "./components/PackageBadge";
import ThemeSwitcher from "./components/ThemeSwitcher";
export default function Home() {
return (
<div className="relative min-h-screen">
<CloudBackground />
{/* Navigation */}
<nav className="fixed top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Image
src="/tuikit-logo.png"
alt="TUIkit Logo"
width={32}
height={32}
className="rounded-lg"
/>
<span className="text-2xl font-semibold text-foreground">
TUIkit
</span>
</div>
<div className="flex items-center gap-6">
<a
href="/documentation/tuikit"
className="text-lg text-muted transition-colors hover:text-foreground"
>
Documentation
</a>
<a
href="https://github.com/phranck/TUIkit"
target="_blank"
rel="noopener noreferrer"
className="text-lg text-muted transition-colors hover:text-foreground"
>
GitHub
</a>
<div className="ml-2 border-l border-border pl-4">
<ThemeSwitcher />
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="relative mx-auto flex max-w-6xl flex-col items-center px-6 pt-40 pb-24 text-center">
<Image
src="/tuikit-logo.png"
alt="TUIkit Logo"
width={200}
height={200}
className="mb-10 rounded-3xl"
priority
/>
<h1
className="mb-6 max-w-4xl text-5xl leading-tight tracking-wide transition-all duration-500 md:text-7xl"
style={{
fontFamily: "WarText, monospace",
color: "var(--headline-color)",
textShadow:
"0 0 7px rgba(var(--headline-glow),0.6), 0 0 20px rgba(var(--headline-glow),0.35), 0 0 42px rgba(var(--headline-glow),0.15)",
}}
>
&gt; Terminal UIs, the Swift way_
</h1>
<p className="mb-10 max-w-2xl text-2xl leading-relaxed text-muted">
A declarative, SwiftUI-like framework for building Terminal User
Interfaces. No ncurses, no C dependencies pure Swift on macOS and
Linux.
</p>
<div className="flex flex-col gap-4 sm:flex-row">
<a
href="/documentation/tuikit"
className="inline-flex items-center justify-center gap-2 rounded-full bg-accent px-7 py-2.5 text-xl font-semibold text-background transition-all hover:bg-accent-secondary hover:shadow-lg hover:shadow-accent/20"
>
<Icon name="document" size={22} />
Read the Docs
</a>
<a
href="https://github.com/phranck/TUIkit"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-full border border-border px-7 py-2.5 text-xl font-semibold text-foreground transition-all hover:border-accent/40 hover:bg-white/5"
>
View on GitHub
</a>
</div>
{/* Swift Package badge */}
<p className="mt-20 mb-4 max-w-2xl text-xl leading-relaxed text-muted">
Getting started is simple. Add TUIkit as a dependency to your
Swift package no extra configuration, no system libraries to install:
</p>
<PackageBadge />
</section>
{/* Code Preview Section */}
<section className="mx-auto max-w-3xl px-6 pb-28">
<CodePreview />
</section>
{/* Features Grid */}
<section className="mx-auto max-w-6xl px-6 pb-28">
<div className="mb-12 text-center">
<h2 className="mb-4 text-4xl font-bold text-foreground">
Everything you need
</h2>
<p className="mx-auto max-w-2xl text-2xl text-muted">
Built from the ground up for the terminal, with APIs you already
know from SwiftUI.
</p>
</div>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon={<Icon name="terminal" size={28} />}
title="Declarative Syntax"
description="Build UIs with VStack, HStack, Text, Button, and more — the same patterns you know from SwiftUI."
/>
<FeatureCard
icon={<Icon name="paintbrush" size={28} />}
title="Theming System"
description="Multiple built-in phosphor themes with full RGB color support. Cycle at runtime or create custom palettes."
/>
<FeatureCard
icon={<Icon name="keyboard" size={28} />}
title="Keyboard-Driven"
description="Focus management, key event handlers, customizable status bar with shortcut display."
/>
<FeatureCard
icon={<Icon name="stack" size={28} />}
title="Rich Components"
description="Panel, Card, Dialog, Alert, Menu, Button, ForEach — container and interactive views out of the box."
/>
<FeatureCard
icon={<Icon name="bolt" size={28} />}
title="Zero Dependencies"
description="Pure Swift. No ncurses, no C libraries. Just add the Swift package and go."
/>
<FeatureCard
icon={<Icon name="arrows" size={28} />}
title="Cross-Platform"
description="Runs on macOS and Linux. Same code, same API, same results."
/>
</div>
</section>
{/* Architecture highlights */}
<section className="mx-auto max-w-6xl px-6 pb-28">
<div className="rounded-2xl border border-border bg-card/50 p-8 backdrop-blur-sm md:p-12">
<h2 className="mb-8 text-center text-4xl font-bold text-foreground">
Built right
</h2>
<div className="grid gap-8 md:grid-cols-2">
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Icon name="swift" size={24} />
</div>
<div>
<h3 className="mb-1 text-xl font-semibold text-foreground">
Swift 6.0 + Strict Concurrency
</h3>
<p className="text-xl leading-relaxed text-muted">
Full Sendable compliance. No data races, no unsafe globals.
Modern Swift from top to bottom.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Icon name="eye" size={24} />
</div>
<div>
<h3 className="mb-1 text-xl font-semibold text-foreground">
5 Border Appearances
</h3>
<p className="text-xl leading-relaxed text-muted">
Line, rounded, double-line, heavy, and block style. Cycle with
a single keystroke.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Icon name="checkmark" size={24} />
</div>
<div>
<h3 className="mb-1 text-xl font-semibold text-foreground">
498 Tests
</h3>
<p className="text-xl leading-relaxed text-muted">
Comprehensive test suite covering views, modifiers, rendering,
state management, and more.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
<Icon name="document" size={24} />
</div>
<div>
<h3 className="mb-1 text-xl font-semibold text-foreground">
13 DocC Articles
</h3>
<p className="text-xl leading-relaxed text-muted">
Architecture guides, API references, theming, focus system,
keyboard shortcuts, and palette documentation.
</p>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="mx-auto max-w-6xl px-6 pb-20">
<div className="text-center">
<h2 className="mb-4 text-4xl font-bold text-foreground">
Ready to build?
</h2>
<p className="mb-8 text-2xl text-muted">
Get started with TUIkit in minutes. Add the package, write your
first view, run it.
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="/documentation/tuikit/gettingstarted"
className="inline-flex items-center justify-center gap-2 rounded-full bg-accent px-7 py-2.5 text-xl font-semibold text-background transition-all hover:bg-accent-secondary hover:shadow-lg hover:shadow-accent/20"
>
Getting Started Guide
</a>
<a
href="/documentation/tuikit"
className="inline-flex items-center justify-center gap-2 rounded-full border border-border px-7 py-2.5 text-xl font-semibold text-foreground transition-all hover:border-accent/40 hover:bg-white/5"
>
Browse Documentation
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border bg-card/30 backdrop-blur-sm">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 py-8 sm:flex-row">
<span className="text-base text-muted">
Made with in Bregenz at Lake Constance
</span>
<div className="flex items-center gap-6 text-base text-muted">
<a
href="/documentation/tuikit"
className="transition-colors hover:text-foreground"
>
Docs
</a>
<a
href="https://github.com/phranck/TUIkit"
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-foreground"
>
GitHub
</a>
<a
href="https://creativecommons.org/licenses/by-nc-sa/4.0/"
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-foreground"
>
CC BY-NC-SA 4.0
</a>
</div>
</div>
</footer>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+13
View File
@@ -0,0 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
images: {
unoptimized: true,
},
env: {
TUIKIT_VERSION: process.env.TUIKIT_VERSION ?? "0.1.0",
},
};
export default nextConfig;
+6563
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"sf-symbols-lib": "^1.1.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 908 KiB

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}