feat(web): MS15 Phase 1 — Design System & App Shell (#451)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #451.
This commit is contained in:
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal file
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export interface MosaicLogoProps {
|
||||
/** Width and height in pixels (default: 36) */
|
||||
size?: number;
|
||||
/** Whether to animate rotation (default: false) */
|
||||
spinning?: boolean;
|
||||
/** Seconds for one full rotation (default: 20) */
|
||||
spinDuration?: number;
|
||||
/** Additional CSS classes for the root element */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MosaicLogo renders the 5-element Mosaic logo icon:
|
||||
* - 4 corner squares (blue, purple, teal, amber)
|
||||
* - 1 center circle (pink)
|
||||
*
|
||||
* Colors use CSS custom properties so they respond to theme changes.
|
||||
* When `spinning` is true the logo rotates continuously, making it
|
||||
* suitable for use as a loading indicator.
|
||||
*/
|
||||
export function MosaicLogo({
|
||||
size = 36,
|
||||
spinning = false,
|
||||
spinDuration = 20,
|
||||
className = "",
|
||||
}: MosaicLogoProps): React.JSX.Element {
|
||||
// Scale factor relative to the 36px reference design
|
||||
const scale = size / 36;
|
||||
|
||||
// Derived dimensions
|
||||
const squareSize = Math.round(14 * scale);
|
||||
const circleSize = Math.round(11 * scale);
|
||||
const borderRadius = Math.round(3 * scale);
|
||||
|
||||
const animationValue = spinning
|
||||
? `mosaicLogoSpin ${String(spinDuration)}s linear infinite`
|
||||
: undefined;
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
animation: animationValue,
|
||||
transformOrigin: "center",
|
||||
};
|
||||
|
||||
const baseSquareStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
width: squareSize,
|
||||
height: squareSize,
|
||||
borderRadius,
|
||||
};
|
||||
|
||||
const circleStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: circleSize,
|
||||
height: circleSize,
|
||||
borderRadius: "50%",
|
||||
background: "var(--ms-pink-500)",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{spinning && (
|
||||
<style>{`
|
||||
@keyframes mosaicLogoSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<div style={containerStyle} className={className} role="img" aria-label="Mosaic logo">
|
||||
{/* Top-left: blue */}
|
||||
<div style={{ ...baseSquareStyle, top: 0, left: 0, background: "var(--ms-blue-500)" }} />
|
||||
{/* Top-right: purple */}
|
||||
<div style={{ ...baseSquareStyle, top: 0, right: 0, background: "var(--ms-purple-500)" }} />
|
||||
{/* Bottom-right: teal */}
|
||||
<div
|
||||
style={{ ...baseSquareStyle, bottom: 0, right: 0, background: "var(--ms-teal-500)" }}
|
||||
/>
|
||||
{/* Bottom-left: amber */}
|
||||
<div
|
||||
style={{ ...baseSquareStyle, bottom: 0, left: 0, background: "var(--ms-amber-500)" }}
|
||||
/>
|
||||
{/* Center: pink circle */}
|
||||
<div style={circleStyle} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MosaicLogo;
|
||||
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal file
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { MosaicLogo } from "./MosaicLogo";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export interface MosaicSpinnerProps {
|
||||
/** Width and height of the logo in pixels (default: 36) */
|
||||
size?: number;
|
||||
/** Seconds for one full rotation (default: 20) */
|
||||
spinDuration?: number;
|
||||
/** Optional text label displayed below the spinner */
|
||||
label?: string;
|
||||
/**
|
||||
* When true, wraps the spinner in a full-page centered overlay.
|
||||
* When false (default), renders inline.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
/** Additional CSS classes for the wrapper element */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MosaicSpinner wraps MosaicLogo with spinning enabled.
|
||||
* It can be used as a full-page loading overlay or as an inline indicator.
|
||||
*/
|
||||
export function MosaicSpinner({
|
||||
size = 36,
|
||||
spinDuration = 20,
|
||||
label,
|
||||
fullPage = false,
|
||||
className = "",
|
||||
}: MosaicSpinnerProps): ReactElement {
|
||||
const inner = (
|
||||
<div className={`flex flex-col items-center gap-3 ${className}`}>
|
||||
<MosaicLogo size={size} spinning spinDuration={spinDuration} />
|
||||
{label !== undefined && label !== "" && (
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullPage) {
|
||||
return <div className="flex min-h-screen items-center justify-center">{inner}</div>;
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
|
||||
export default MosaicSpinner;
|
||||
Reference in New Issue
Block a user