feat: style
@@ -3,6 +3,7 @@ import AppShell from "./components/shared/AppShell";
|
|||||||
import ComposeView from "./routes/ComposeView";
|
import ComposeView from "./routes/ComposeView";
|
||||||
import BrowseView from "./routes/BrowseView";
|
import BrowseView from "./routes/BrowseView";
|
||||||
import EditorView from "./routes/EditorView";
|
import EditorView from "./routes/EditorView";
|
||||||
|
import SettingsView from "./routes/SettingsView";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -10,6 +11,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ComposeView />} />
|
<Route path="/" element={<ComposeView />} />
|
||||||
<Route path="/browse" element={<BrowseView />} />
|
<Route path="/browse" element={<BrowseView />} />
|
||||||
|
<Route path="/settings" element={<SettingsView />} />
|
||||||
<Route path="/editor/new" element={<EditorView />} />
|
<Route path="/editor/new" element={<EditorView />} />
|
||||||
<Route path="/editor/:name" element={<EditorView />} />
|
<Route path="/editor/:name" element={<EditorView />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 552 KiB |
@@ -4,6 +4,7 @@ import pointerSvg from "@/assets/pointer.min.svg";
|
|||||||
/**
|
/**
|
||||||
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation.
|
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation.
|
||||||
* Positions itself right next to the left border of the editor container.
|
* Positions itself right next to the left border of the editor container.
|
||||||
|
* Uses a ResizeObserver to keep horizontal position synced on window resize.
|
||||||
*/
|
*/
|
||||||
export default function EditorPointer() {
|
export default function EditorPointer() {
|
||||||
const [y, setY] = useState<number | null>(null);
|
const [y, setY] = useState<number | null>(null);
|
||||||
@@ -12,19 +13,58 @@ export default function EditorPointer() {
|
|||||||
const currentRef = useRef(0);
|
const currentRef = useRef(0);
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const activeRef = useRef(false);
|
const activeRef = useRef(false);
|
||||||
|
const [clickKey, setClickKey] = useState(0);
|
||||||
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const cmRef = useRef<Element | null>(null);
|
||||||
|
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const eyeTargetRef = useRef({ x: 0, y: 0 });
|
||||||
|
const eyeCurrentRef = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ease = 0.09;
|
const ease = 0.09;
|
||||||
|
const eyeEase = 0.06;
|
||||||
|
|
||||||
|
const updateLeft = () => {
|
||||||
|
if (cmRef.current) {
|
||||||
|
setEditorLeft(cmRef.current.getBoundingClientRect().left);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
const pointerX = (cmRef.current?.getBoundingClientRect().left ?? 0) - 100;
|
||||||
|
const pointerY = currentRef.current;
|
||||||
|
const dx = e.clientX - pointerX;
|
||||||
|
const dy = e.clientY - pointerY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
const maxShift = 1.5;
|
||||||
|
eyeTargetRef.current = {
|
||||||
|
x: (dx / dist) * maxShift,
|
||||||
|
y: (dy / dist) * maxShift,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
|
||||||
|
const onEditorClick = () => {
|
||||||
|
clearTimeout(clickTimerRef.current);
|
||||||
|
setClickKey((k) => k + 1);
|
||||||
|
clickTimerRef.current = setTimeout(() => setClickKey(0), 200);
|
||||||
|
};
|
||||||
|
|
||||||
const onCursorMove = (e: Event) => {
|
const onCursorMove = (e: Event) => {
|
||||||
const { top } = (e as CustomEvent).detail;
|
const { top } = (e as CustomEvent).detail;
|
||||||
targetRef.current = top;
|
targetRef.current = top;
|
||||||
|
|
||||||
// Find editor container left edge once (or when it changes)
|
// Find editor container and attach click listener lazily
|
||||||
const cm = document.querySelector(".cm-editor");
|
if (!cmRef.current) {
|
||||||
if (cm) {
|
const cm = document.querySelector(".cm-editor");
|
||||||
setEditorLeft(cm.getBoundingClientRect().left);
|
if (cm) {
|
||||||
|
cmRef.current = cm;
|
||||||
|
cm.addEventListener("mousedown", onEditorClick);
|
||||||
|
ro.observe(cm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
updateLeft();
|
||||||
|
|
||||||
if (!activeRef.current) {
|
if (!activeRef.current) {
|
||||||
currentRef.current = top;
|
currentRef.current = top;
|
||||||
@@ -43,15 +83,34 @@ export default function EditorPointer() {
|
|||||||
}
|
}
|
||||||
setY(currentRef.current);
|
setY(currentRef.current);
|
||||||
}
|
}
|
||||||
|
// Lerp eyes toward target
|
||||||
|
const ec = eyeCurrentRef.current;
|
||||||
|
const et = eyeTargetRef.current;
|
||||||
|
ec.x += (et.x - ec.x) * eyeEase;
|
||||||
|
ec.y += (et.y - ec.y) * eyeEase;
|
||||||
|
setEyeOffset({ x: ec.x, y: ec.y });
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("cm-cursor-move", onCursorMove);
|
window.addEventListener("cm-cursor-move", onCursorMove);
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
// Keep left position updated on resize
|
||||||
|
const ro = new ResizeObserver(() => updateLeft());
|
||||||
|
const existingCm = document.querySelector(".cm-editor");
|
||||||
|
if (existingCm) {
|
||||||
|
cmRef.current = existingCm;
|
||||||
|
ro.observe(existingCm);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", updateLeft);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("cm-cursor-move", onCursorMove);
|
window.removeEventListener("cm-cursor-move", onCursorMove);
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("resize", updateLeft);
|
||||||
|
cmRef.current?.removeEventListener("mousedown", onEditorClick);
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -59,7 +118,8 @@ export default function EditorPointer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="editor-pointer"
|
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
||||||
|
key={clickKey}
|
||||||
style={{ top: y, left: editorLeft }}
|
style={{ top: y, left: editorLeft }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -69,6 +129,18 @@ export default function EditorPointer() {
|
|||||||
maskImage: `url(${pointerSvg})`,
|
maskImage: `url(${pointerSvg})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
||||||
|
<div
|
||||||
|
className="editor-pointer-iris"
|
||||||
|
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
||||||
|
<div
|
||||||
|
className="editor-pointer-iris"
|
||||||
|
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import frameTerraSvg from "@/assets/frame.themed.svg";
|
import frameSvg from "@/assets/frame.themed.svg";
|
||||||
import frameAzureSvg from "@/assets/frame.azure.svg";
|
|
||||||
import NavMenu from "./NavMenu";
|
import NavMenu from "./NavMenu";
|
||||||
|
|
||||||
const TITLE = `
|
const TITLE = `
|
||||||
@@ -99,8 +98,6 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem("micronomicon-theme", theme);
|
localStorage.setItem("micronomicon-theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const frameSvg = theme === "azure" ? frameAzureSvg : frameTerraSvg;
|
|
||||||
|
|
||||||
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -109,13 +106,21 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<main className="flex-1 overflow-auto" data-scroll-root>
|
<main className="flex-1 overflow-auto" data-scroll-root>
|
||||||
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full">
|
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full">
|
||||||
{/* Frame SVG — background */}
|
{/* Frame SVG — background */}
|
||||||
<img
|
<div
|
||||||
key={theme}
|
|
||||||
src={frameSvg}
|
|
||||||
alt=""
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
|
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
|
||||||
style={{ zIndex: 0, objectFit: "fill" }}
|
style={{
|
||||||
|
zIndex: 0,
|
||||||
|
height: "1315px",
|
||||||
|
background: "var(--primary)",
|
||||||
|
WebkitMaskImage: `url(${frameSvg})`,
|
||||||
|
maskImage: `url(${frameSvg})`,
|
||||||
|
WebkitMaskSize: "cover",
|
||||||
|
maskSize: "cover",
|
||||||
|
WebkitMaskRepeat: "no-repeat",
|
||||||
|
maskRepeat: "no-repeat",
|
||||||
|
objectFit: "fill"
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top hole — ASCII title */}
|
{/* Top hole — ASCII title */}
|
||||||
@@ -147,7 +152,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
className="absolute z-20"
|
className="absolute z-20"
|
||||||
style={{ top: 200, left: -210 }}
|
style={{ top: 200, left: -210 }}
|
||||||
>
|
>
|
||||||
<NavMenu />
|
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -155,14 +160,6 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</main>
|
</main>
|
||||||
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
||||||
<span>Becoming with hubris · MMXXVI</span>
|
<span>Becoming with hubris · MMXXVI</span>
|
||||||
<span className="text-muted-foreground/50">│</span>
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="hover:text-foreground transition-colors cursor-pointer"
|
|
||||||
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
|
|
||||||
>
|
|
||||||
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import menuSvg from "@/assets/menu.min.svg";
|
|||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ label: "Browse", path: "/browse" },
|
{ label: "Browse", path: "/browse" },
|
||||||
{ label: "Compose", path: "/" },
|
{ label: "Compose", path: "/" },
|
||||||
|
{ label: "Settings", path: "/settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +48,12 @@ function useSmoothScroll(scrollSelector: string, ease = 0.08) {
|
|||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NavMenu() {
|
interface NavMenuProps {
|
||||||
|
theme: "terra" | "azure";
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavMenu({ theme, onToggleTheme }: NavMenuProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
|
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
|
||||||
@@ -82,6 +88,15 @@ export default function NavMenu() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Theme toggle — bottom container */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
className="nav-menu-theme-toggle"
|
||||||
|
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
|
||||||
|
>
|
||||||
|
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,8 +351,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding-top: 168px;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +381,27 @@
|
|||||||
background: var(--primary) / 0.08;
|
background: var(--primary) / 0.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-menu-theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 97px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-theme-toggle:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Editor Pointer ── */
|
/* ── Editor Pointer ── */
|
||||||
|
|
||||||
.editor-pointer {
|
.editor-pointer {
|
||||||
@@ -391,6 +413,24 @@
|
|||||||
margin-top: -20px;
|
margin-top: -20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-pointer-click {
|
||||||
|
animation: pointer-click 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pointer-click {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateX(-96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.editor-pointer-img {
|
.editor-pointer-img {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 130px;
|
height: 130px;
|
||||||
@@ -403,4 +443,26 @@
|
|||||||
-webkit-mask-position: center;
|
-webkit-mask-position: center;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-pointer-eye {
|
||||||
|
position: absolute;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: black;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pointer-iris {
|
||||||
|
position: absolute;
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1.5px;
|
||||||
|
margin-left: -1.5px;
|
||||||
|
transition: transform 0.15s ease-out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
7
frontend/src/routes/SettingsView.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function SettingsView() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
Settings
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||